|
@@ -0,0 +1,330 @@
|
|
|
|
|
+import {
|
|
|
|
|
+ QUICK_ENTRY_SLOT_COUNT,
|
|
|
|
|
+ mapSidebarIconToQuickClass,
|
|
|
|
|
+ pickPreferredQuickSlotIds,
|
|
|
|
|
+ type QuickEntryMenuOption,
|
|
|
|
|
+} from './quickEntry';
|
|
|
|
|
+import * as workbenchApi from './userWorkbenchShortcutApi';
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 写入后端时使用的字段名(若与你们序列化器不一致,只改此一处)
|
|
|
|
|
+ */
|
|
|
|
|
+export const WORKBENCH_SHORTCUT_WRITE = {
|
|
|
|
|
+ path: 'web_path',
|
|
|
|
|
+ sort: 'sort',
|
|
|
|
|
+ frequent: 'is_frequent',
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 与 `index.vue` 中「标记为常用 / 橙色高亮」单选一致:仅当前选中的槽为 true。
|
|
|
|
|
+ * 若你们后端用 `is_active` 表示「该条已启用、五条均为 true」,勿把该字段与单选混用,见文档说明。
|
|
|
|
|
+ */
|
|
|
|
|
+ active: 'is_active',
|
|
|
|
|
+ /** 展示名,与后端模型 `name` 对齐;若字段名不同只改此处 */
|
|
|
|
|
+ name: 'name',
|
|
|
|
|
+ /** 跳转地址(与路由 path 一致即可;别名字段只改此处) */
|
|
|
|
|
+ link: 'link',
|
|
|
|
|
+ /** 图标,与 `QuickEntryMenuOption.iconClass` 一致(侧栏 `meta.icon` 映射的 FontAwesome 类名) */
|
|
|
|
|
+ icon: 'icon',
|
|
|
|
|
+} as const;
|
|
|
|
|
+
|
|
|
|
|
+type RawRow = Record<string, unknown> & { id?: string | number };
|
|
|
|
|
+
|
|
|
|
|
+function readString(obj: unknown, keys: readonly string[]): string | null {
|
|
|
|
|
+ if (obj === null || typeof obj !== 'object') return null;
|
|
|
|
|
+ const o = obj as Record<string, unknown>;
|
|
|
|
|
+ for (const k of keys) {
|
|
|
|
|
+ const v = o[k];
|
|
|
|
|
+ if (typeof v === 'string' && v.trim()) return v.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ const menu = o.menu;
|
|
|
|
|
+ if (menu && typeof menu === 'object') {
|
|
|
|
|
+ for (const k of keys) {
|
|
|
|
|
+ const v = (menu as Record<string, unknown>)[k];
|
|
|
|
|
+ if (typeof v === 'string' && v.trim()) return v.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function maybeStripHttpPath(s: string): string {
|
|
|
|
|
+ if (!/^https?:\/\//i.test(s)) return s;
|
|
|
|
|
+ try {
|
|
|
|
|
+ return new URL(s).pathname || s;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return s;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 与侧栏/选项 id 一致的路由段;`link` / `url` 可作为列表接口中的别名字段;绝对 URL 会取 pathname
|
|
|
|
|
+ */
|
|
|
|
|
+export function getShortcutPathFromRow(row: unknown): string | null {
|
|
|
|
|
+ if (!row || typeof row !== 'object') return null;
|
|
|
|
|
+ const raw = readString(row, ['web_path', 'path', 'menu_path', 'webPath', 'link', 'url']);
|
|
|
|
|
+ if (!raw) return null;
|
|
|
|
|
+ return maybeStripHttpPath(raw.trim()) || null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 列表里若带 `name`/`title` 等,供以后与侧栏标题对账用 */
|
|
|
|
|
+export function getShortcutNameFromRow(row: unknown): string | null {
|
|
|
|
|
+ if (!row || typeof row !== 'object') return null;
|
|
|
|
|
+ return readString(row, ['name', 'title', 'menu_name', 'menuName', 'label']);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 当前页 `QuickEntryMenuOption` 上已带 i18n 后的标题,作为请求体 `name`;无匹配时退回 path,避免空串
|
|
|
|
|
+ */
|
|
|
|
|
+export function resolveShortcutDisplayName(
|
|
|
|
|
+ menuPath: string,
|
|
|
|
|
+ options: QuickEntryMenuOption[]
|
|
|
|
|
+): string {
|
|
|
|
|
+ const o = options.find((x) => x.id === menuPath);
|
|
|
|
|
+ if (o?.title?.trim()) {
|
|
|
|
|
+ return o.title.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ return menuPath;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 后端必填的 `link`:与 `QuickEntryMenuOption.path` 一致(侧栏即路由;外链菜单若 `path` 为完整 URL 则原样上送)
|
|
|
|
|
+ */
|
|
|
|
|
+export function resolveShortcutLink(
|
|
|
|
|
+ menuPath: string,
|
|
|
|
|
+ options: QuickEntryMenuOption[]
|
|
|
|
|
+): string {
|
|
|
|
|
+ const o = options.find((x) => x.id === menuPath);
|
|
|
|
|
+ const raw = (o?.path ?? o?.id ?? menuPath).trim();
|
|
|
|
|
+ if (!raw) return menuPath;
|
|
|
|
|
+ if (/^https?:\/\//i.test(raw)) return raw;
|
|
|
|
|
+ return raw.startsWith('/') ? raw : `/${raw.replace(/^\//, '')}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 与侧栏/首页卡片同源的图标 class(如 `fa fa-…`),来自 `iconClass`;无则与 `mapSidebarIconToQuickClass` 默认一致
|
|
|
|
|
+ */
|
|
|
|
|
+export function resolveShortcutIcon(
|
|
|
|
|
+ menuPath: string,
|
|
|
|
|
+ options: QuickEntryMenuOption[]
|
|
|
|
|
+): string {
|
|
|
|
|
+ const o = options.find((x) => x.id === menuPath);
|
|
|
|
|
+ if (o?.iconClass?.trim()) {
|
|
|
|
|
+ return o.iconClass.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ return mapSidebarIconToQuickClass(undefined);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 读列表时的图标别名字段 */
|
|
|
|
|
+export function getShortcutIconFromRow(row: unknown): string | null {
|
|
|
|
|
+ if (!row || typeof row !== 'object') return null;
|
|
|
|
|
+ return readString(row, ['icon', 'icon_class', 'iconClass', 'icon_name', 'iconName']);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 读列表时若单独提供 link 与 path 分开展示,可与 `getShortcutPathFromRow` 二选一,字段别名:link / href */
|
|
|
|
|
+export function getShortcutLinkFromRow(row: unknown): string | null {
|
|
|
|
|
+ if (!row || typeof row !== 'object') return null;
|
|
|
|
|
+ const raw = readString(row, ['link', 'url', 'href', 'web_path', 'path']);
|
|
|
|
|
+ if (!raw) return null;
|
|
|
|
|
+ return maybeStripHttpPath(raw.trim()) || null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 是否为「常用 / 高亮」槽:与 `quickEditDraft.highlightIndex` 对应;读列表时兼容 `is_active` 表示同一语义
|
|
|
|
|
+ */
|
|
|
|
|
+function getFrequentFromRow(row: unknown): boolean {
|
|
|
|
|
+ if (!row || typeof row !== 'object') return false;
|
|
|
|
|
+ const o = row as Record<string, unknown>;
|
|
|
|
|
+ /* 若 is_active 在贵方模型中表示「全量启用」且恒为 true,请从本列表中移除 is_active,仅用 is_frequent */
|
|
|
|
|
+ for (const k of [
|
|
|
|
|
+ 'is_frequent',
|
|
|
|
|
+ 'is_favorite',
|
|
|
|
|
+ 'is_common',
|
|
|
|
|
+ 'frequent',
|
|
|
|
|
+ 'is_hot',
|
|
|
|
|
+ 'is_active',
|
|
|
|
|
+ ] as const) {
|
|
|
|
|
+ if (o[k] === true) return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getSortFromRow(row: unknown): number | null {
|
|
|
|
|
+ if (!row || typeof row !== 'object') return null;
|
|
|
|
|
+ const v = (row as RawRow).sort;
|
|
|
|
|
+ if (typeof v !== 'number' || Number.isNaN(v)) return null;
|
|
|
|
|
+ if (v >= 0 && v < QUICK_ENTRY_SLOT_COUNT) return v;
|
|
|
|
|
+ if (v >= 1 && v <= QUICK_ENTRY_SLOT_COUNT) return v - 1;
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function compareRows(a: unknown, b: unknown): number {
|
|
|
|
|
+ const sa = getSortFromRow(a);
|
|
|
|
|
+ const sb = getSortFromRow(b);
|
|
|
|
|
+ if (sa !== null && sb !== null && sa !== sb) return sa - sb;
|
|
|
|
|
+ const idA = (a as RawRow)?.id;
|
|
|
|
|
+ const idB = (b as RawRow)?.id;
|
|
|
|
|
+ const nA = idA == null ? 0 : Number(idA);
|
|
|
|
|
+ const nB = idB == null ? 0 : Number(idB);
|
|
|
|
|
+ if (!Number.isNaN(nA) && !Number.isNaN(nB) && nA !== nB) return nA - nB;
|
|
|
|
|
+ return 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 把槽位未填的路径补满(与首页本地填充思路一致,避免空槽)
|
|
|
|
|
+ */
|
|
|
|
|
+function ensureFiveMenuIds(
|
|
|
|
|
+ partial: (string | null)[],
|
|
|
|
|
+ idSet: Set<string>,
|
|
|
|
|
+ opts: QuickEntryMenuOption[]
|
|
|
|
|
+): string[] {
|
|
|
|
|
+ const out = partial.map((p) => p || '');
|
|
|
|
|
+ const used = new Set(out.filter(Boolean));
|
|
|
|
|
+ for (let s = 0; s < QUICK_ENTRY_SLOT_COUNT; s++) {
|
|
|
|
|
+ if (out[s]) continue;
|
|
|
|
|
+ const fill = opts.find((o) => !used.has(o.id) && idSet.has(o.id));
|
|
|
|
|
+ if (fill) {
|
|
|
|
|
+ out[s] = fill.id;
|
|
|
|
|
+ used.add(fill.id);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const id of pickPreferredQuickSlotIds(opts)) {
|
|
|
|
|
+ if (used.has(id) || !idSet.has(id)) continue;
|
|
|
|
|
+ out[s] = id;
|
|
|
|
|
+ used.add(id);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (let s = 0; s < QUICK_ENTRY_SLOT_COUNT; s++) {
|
|
|
|
|
+ if (out[s]) continue;
|
|
|
|
|
+ for (const o of opts) {
|
|
|
|
|
+ if (used.has(o.id)) continue;
|
|
|
|
|
+ out[s] = o.id;
|
|
|
|
|
+ used.add(o.id);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type Cell = { id: string | number; path: string; raw: unknown };
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 将 GET 列表(已按 sort、id 升序为最佳)解析为 5 槽 + 高亮 + 已存在记录的 id
|
|
|
|
|
+ */
|
|
|
|
|
+export function mapWorkbenchShortcutListToState(
|
|
|
|
|
+ rows: unknown[],
|
|
|
|
|
+ opts: QuickEntryMenuOption[]
|
|
|
|
|
+): { menuIds: string[]; highlightIndex: number; recordBySlot: Map<number, { id: string | number }> } | null {
|
|
|
|
|
+ if (!Array.isArray(rows) || !rows.length) return null;
|
|
|
|
|
+ const idSet = new Set(opts.map((o) => o.id));
|
|
|
|
|
+ const validRows = rows
|
|
|
|
|
+ .map((r) => {
|
|
|
|
|
+ if (!r || typeof r !== 'object') return null;
|
|
|
|
|
+ const path = getShortcutPathFromRow(r);
|
|
|
|
|
+ if (!path || !idSet.has(path)) return null;
|
|
|
|
|
+ const id = (r as RawRow).id;
|
|
|
|
|
+ if (id == null) return null;
|
|
|
|
|
+ return { id, path, raw: r } as Cell;
|
|
|
|
|
+ })
|
|
|
|
|
+ .filter((x): x is Cell => x !== null);
|
|
|
|
|
+ if (!validRows.length) return null;
|
|
|
|
|
+
|
|
|
|
|
+ const rawSorted = [...validRows].sort((a, b) => compareRows(a.raw, b.raw));
|
|
|
|
|
+ const bySlot: (Cell | null)[] = new Array(QUICK_ENTRY_SLOT_COUNT).fill(null);
|
|
|
|
|
+ const free: Cell[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ for (const cell of rawSorted) {
|
|
|
|
|
+ const si = getSortFromRow(cell.raw);
|
|
|
|
|
+ if (si !== null && si >= 0 && si < QUICK_ENTRY_SLOT_COUNT && bySlot[si] === null) {
|
|
|
|
|
+ bySlot[si] = cell;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ free.push(cell);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const cell of free) {
|
|
|
|
|
+ const i = bySlot.findIndex((x) => x === null);
|
|
|
|
|
+ if (i < 0) break;
|
|
|
|
|
+ bySlot[i] = cell;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const pathPerSlot: (string | null)[] = new Array(QUICK_ENTRY_SLOT_COUNT).fill(null);
|
|
|
|
|
+ const recordBySlot = new Map<number, { id: string | number }>();
|
|
|
|
|
+ let highlightIndex = 0;
|
|
|
|
|
+ let hasFrequent = false;
|
|
|
|
|
+ for (let s = 0; s < QUICK_ENTRY_SLOT_COUNT; s++) {
|
|
|
|
|
+ const c = bySlot[s];
|
|
|
|
|
+ if (c) {
|
|
|
|
|
+ pathPerSlot[s] = c.path;
|
|
|
|
|
+ recordBySlot.set(s, { id: c.id });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (let s = 0; s < QUICK_ENTRY_SLOT_COUNT; s++) {
|
|
|
|
|
+ const c = bySlot[s];
|
|
|
|
|
+ if (c && getFrequentFromRow(c.raw) && !hasFrequent) {
|
|
|
|
|
+ highlightIndex = s;
|
|
|
|
|
+ hasFrequent = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const menuIds = ensureFiveMenuIds(pathPerSlot, idSet, opts);
|
|
|
|
|
+ if (menuIds.length !== QUICK_ENTRY_SLOT_COUNT || menuIds.some((m) => !m)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return { menuIds, highlightIndex, recordBySlot };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildWritePayload(
|
|
|
|
|
+ slotIndex: number,
|
|
|
|
|
+ menuPath: string,
|
|
|
|
|
+ /** 与 `quickEditDraft.highlightIndex === slotIndex` 一致,即单选「标记为常用」 */
|
|
|
|
|
+ isPreferredSlot: boolean,
|
|
|
|
|
+ displayName: string,
|
|
|
|
|
+ linkValue: string,
|
|
|
|
|
+ iconValue: string
|
|
|
|
|
+): Record<string, unknown> {
|
|
|
|
|
+ return {
|
|
|
|
|
+ [WORKBENCH_SHORTCUT_WRITE.sort]: slotIndex,
|
|
|
|
|
+ [WORKBENCH_SHORTCUT_WRITE.path]: menuPath,
|
|
|
|
|
+ [WORKBENCH_SHORTCUT_WRITE.frequent]: isPreferredSlot,
|
|
|
|
|
+ [WORKBENCH_SHORTCUT_WRITE.active]: isPreferredSlot,
|
|
|
|
|
+ [WORKBENCH_SHORTCUT_WRITE.name]: displayName,
|
|
|
|
|
+ [WORKBENCH_SHORTCUT_WRITE.link]: linkValue,
|
|
|
|
|
+ [WORKBENCH_SHORTCUT_WRITE.icon]: iconValue,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function syncQuickSlotsToServer(
|
|
|
|
|
+ slots: string[],
|
|
|
|
|
+ highlightIndex: number,
|
|
|
|
|
+ recordBySlot: Map<number, { id: string | number }>,
|
|
|
|
|
+ options: QuickEntryMenuOption[]
|
|
|
|
|
+): Promise<void> {
|
|
|
|
|
+ const tasks: Promise<unknown>[] = [];
|
|
|
|
|
+ for (let s = 0; s < QUICK_ENTRY_SLOT_COUNT; s++) {
|
|
|
|
|
+ const path = slots[s];
|
|
|
|
|
+ if (!path) continue;
|
|
|
|
|
+ const frequent = s === highlightIndex;
|
|
|
|
|
+ const rec = recordBySlot.get(s);
|
|
|
|
|
+ const name = resolveShortcutDisplayName(path, options);
|
|
|
|
|
+ const link = resolveShortcutLink(path, options);
|
|
|
|
|
+ const icon = resolveShortcutIcon(path, options);
|
|
|
|
|
+ const payload = buildWritePayload(s, path, frequent, name, link, icon);
|
|
|
|
|
+ if (rec) {
|
|
|
|
|
+ tasks.push(workbenchApi.patchUserWorkbenchShortcut(rec.id, payload));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ tasks.push(workbenchApi.createUserWorkbenchShortcut(payload));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ await Promise.all(tasks);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 与 map 规则一致,从当前列表重算每槽的 id,保存后调用以同步 PATCH 目标 */
|
|
|
|
|
+export function buildRecordBySlotFromList(
|
|
|
|
|
+ list: unknown[],
|
|
|
|
|
+ opts: QuickEntryMenuOption[]
|
|
|
|
|
+): Map<number, { id: string | number }> {
|
|
|
|
|
+ const m = new Map<number, { id: string | number }>();
|
|
|
|
|
+ const st = mapWorkbenchShortcutListToState(list, opts);
|
|
|
|
|
+ if (st) {
|
|
|
|
|
+ return st.recordBySlot;
|
|
|
|
|
+ }
|
|
|
|
|
+ return m;
|
|
|
|
|
+}
|