浏览代码

修改内容

qaz 2 天之前
父节点
当前提交
a1eaa02371

+ 2 - 2
.env.development

@@ -5,8 +5,8 @@ ENV = 'development'
 #线下:http://192.168.100.187:8083
 # 本地环境接口地址 121.36.251.245
 
-#VITE_API_URL = 'http://192.168.100.179:8086'
-VITE_API_URL = 'http://1.94.168.85:8086'
+VITE_API_URL = 'http://192.168.100.179:8086'
+#VITE_API_URL = 'http://1.94.168.85:8086'
 # VITE_API_URL = 'https://backend.qicai321.com'
 VITE_API_WX_URL='https://api.weixin.qq.com/'
 VITE_API_URL_MINLONG='http://117.185.80.170:7861'

+ 19 - 0
docs/screenconsole/用户工作台快捷入口.md

@@ -0,0 +1,19 @@
+# 用户工作台快捷入口(user_workbench_shortcut)
+
+与控制台首页 `screenconsole` 中「快捷入口」区域对接的后端为:
+
+- 基路径:`/api/system/user_workbench_shortcut/`
+- **列表(当前用户、按 `sort`、`id` 升序)**:`GET` 无路径参数
+- **新增**:`POST`(与当前用户、租户自动绑定,不可代他人建)
+- **单条/更新/删除**:`GET|PUT|PATCH|DELETE` `/api/system/user_workbench_shortcut/{id}/`
+
+## 前端约定
+
+1. 列表响应体可能被包在 `data` 中,为 **数组** 或 **分页** `{ results: [] }`;解析见 `userWorkbenchShortcutApi.ts` 中 `unwrapShortcutListData`。
+2. 每条记录需能解析出**与侧栏一致的路由 `path`(`web_path` / `link` / `url` 等别名字段由适配层读取,绝对 URL 会取 pathname 再与菜单 id 匹配)**、**槽位 `sort`(0~4 或 1~5)**、**是否「常用」/ 橙色高亮**(与 `index.vue` 里「标记为常用」单选、即 `highlightIndex` 一致:写入时 `is_frequent` 与 `is_active` 在**同一条**上为 `true`,其余槽为 `false`;**读列表**时在 `getFrequentFromRow` 中把 `is_frequent`、`is_active` 等一并当作「是否常用」判断;若你方将 `is_active` 仅用于**启用/未删除**且五条**均为** `true`,则不要用该字段表示常用,从 `getFrequentFromRow` 的键列表中去掉 `is_active`,仅在写入时删除 `is_active` 或恒传 `true`,按你方实际含义改 `quickEntryServerAdapter.ts`),以及**展示用名称**、**`link`**、**`icon`**(见 `resolveShortcutDisplayName` / `resolveShortcutLink` / `resolveShortcutIcon`)。
+3. 具体字段名以你们序列化器为准;若与约定不一致,只改 `quickEntryServerAdapter.ts` 中的 `WORKBENCH_SHORTCUT_WRITE` 与 `getShortcutPathFromRow` / `getShortcutNameFromRow` / `getShortcutIconFromRow` 的读取逻辑,勿在多个页面里硬编码。
+4. 保存时:有 `id` 的槽位用 `PATCH` 同一路径,无 `id` 的用 `POST`;不主动批量删除,除非后续产品要求「以服务端为唯一全量集」并单独实现同步删除策略。
+
+## 降级
+
+- `GET` 无数据、解析失败或请求异常时,仍使用原逻辑:按本地缓存 `screenconsole_quick_entry_v2` 与侧栏可访问菜单的默认/填充规则生成五项。

+ 33 - 11
src/views/system/borrow/component/CollectEquipment/index.vue

@@ -1320,14 +1320,21 @@ const autoSubmitPendingCodes = async () => {
           const avail = getDeviceAvailableQuantityForBorrow(device);
           if (avail <= 0) {
             const label = [device.name, device.code].filter(Boolean).join(' ') || trimmedCode;
-            ElMessage.warning(`设备 ${label}(${trimmedCode})库存不足`);
+            ElMessage.warning(`设备 ${label}(${trimmedCode})库存不足!`);
             continue;
           }
           
           // 检查设备是否已存在于列表中
           const existingDevice = deviceList.value.find(d => d.device_no === device.id);
           if (existingDevice) {
-            // 如果已存在,增加借用数量
+            const cap = Number(existingDevice.available_quantity) || 0;
+            if (existingDevice.borrow_count >= cap) {
+              const label = [existingDevice.device_name, existingDevice.device_code].filter(Boolean).join(' ').trim() || trimmedCode;
+              ElMessage.warning(
+                `设备 ${label}库存不足!`
+              );
+              continue;
+            }
             existingDevice.borrow_count++;
             console.log(`设备 ${trimmedCode} 已存在,借用数量+1`);
           } else {
@@ -2039,7 +2046,12 @@ const handleScanSearch = async (event?: Event) => {
               const device = res.data[0];
               const avail = getDeviceAvailableQuantityForBorrow(device);
               if (avail <= 0) {
-                return { success: false, code, message: `设备 ${code} 可借用数量为0,无法加入领取列表` };
+                const label = [device.name, device.code].filter(Boolean).join(' ').trim() || String(code);
+                return {
+                  success: false,
+                  code,
+                  message: `设备 ${label}库存不足!`,
+                };
               }
               
               // 收集设备数据,统一处理添加逻辑
@@ -2085,14 +2097,20 @@ const handleScanSearch = async (event?: Event) => {
       
       // 统一处理设备添加,避免并发竞态条件
       deviceMap.forEach((deviceData, deviceId) => {
-        // 再次检查是否已存在(防止在并发过程中被其他请求添加)
         const existingDevice = deviceList.value.find(d => d.device_no === deviceId);
         if (existingDevice) {
-          // 如果已存在,增加借用数量
+          const cap = Number(existingDevice.available_quantity) || 0;
+          if (existingDevice.borrow_count >= cap) {
+            failCount++;
+            const label = [existingDevice.device_name, existingDevice.device_code].filter(Boolean).join(' ').trim() || String(deviceId);
+            errorMessages.push(
+              `设备 ${label} 库存不足!`
+            );
+            return;
+          }
           existingDevice.borrow_count++;
           successCount++;
         } else {
-          // 如果不存在,添加新设备
           deviceList.value.push(deviceData);
           successCount++;
         }
@@ -2103,13 +2121,17 @@ const handleScanSearch = async (event?: Event) => {
         if (successCount > 0 && failCount === 0) {
           ElMessage.success(`成功添加 ${successCount} 个设备`);
         } else if (successCount > 0 && failCount > 0) {
-          ElMessage.warning(`成功添加 ${successCount} 个设备,失败 ${failCount} 个`);
-          // 只显示前3个错误信息,避免消息过多
-          if (errorMessages.length > 0) {
-            console.warn('批量处理错误详情:', errorMessages.slice(0, 3));
+          const detail = errorMessages.length
+            ? `:${errorMessages.slice(0, 3).join(';')}${errorMessages.length > 3 ? '…' : ''}`
+            : '';
+          ElMessage.warning(`成功添加 ${successCount} 个,失败 ${failCount} 个${detail}`);
+          if (errorMessages.length > 3) {
+            console.warn('批量处理错误详情:', errorMessages);
           }
         } else if (failCount > 0) {
-          ElMessage.error(`添加失败,共 ${failCount} 个设备编号`);
+          ElMessage.warning(
+            errorMessages[0] ? `${errorMessages[0]}${failCount > 1 ? ` 等共 ${failCount} 条` : ''}` : `添加失败,共 ${failCount} 个设备`
+          );
         }
       } else if (successCount > 0) {
         ElMessage.success('设备已添加到列表');

+ 107 - 14
src/views/system/screenconsole/index.vue

@@ -74,7 +74,14 @@
 			</div>
 			<template #footer>
 				<el-button @click="quickEditVisible = false">取消</el-button>
-				<el-button type="primary" class="screenconsole-quick-save-btn" @click="saveQuickEdit">保存修改</el-button>
+				<el-button
+					type="primary"
+					class="screenconsole-quick-save-btn"
+					:loading="quickSaving"
+					@click="saveQuickEdit"
+				>
+					保存修改
+				</el-button>
 			</template>
 		</el-dialog>
 
@@ -124,6 +131,12 @@ import {
 	resolvePathForQuickOption,
 	type QuickEntryMenuOption,
 } from './quickEntry';
+import * as workbenchApi from './userWorkbenchShortcutApi';
+import {
+	mapWorkbenchShortcutListToState,
+	syncQuickSlotsToServer,
+	buildRecordBySlotFromList,
+} from './quickEntryServerAdapter';
 import { Session } from '/@/utils/storage';
 
 const router = useRouter();
@@ -142,6 +155,10 @@ function getQuickOptionById(id: string): QuickEntryMenuOption | undefined {
 
 const quickSlotMenuIds = ref<string[]>([]);
 const quickHighlightIndex = ref(0);
+/** 与 `/api/system/user_workbench_shortcut/` 中每条 id 的对应,用于 PATCH */
+const quickServerRecordBySlot = ref(new Map<number, { id: string | number }>());
+const quickEntryHydrated = ref(false);
+const quickSaving = ref(false);
 
 function applyQuickSlotsFromRoutes() {
 	const opts = quickMenuOptions.value;
@@ -192,16 +209,61 @@ function applyQuickSlotsFromRoutes() {
 	}
 }
 
-watch(quickMenuOptions, applyQuickSlotsFromRoutes, { immediate: true });
+/**
+ * 优先拉取用户工作台快捷入口;无数据或失败时走本地/默认填充(与原先一致)
+ */
+async function hydrateQuickEntry() {
+	const opts = quickMenuOptions.value;
+	if (!opts.length) return;
+	if (quickEntryHydrated.value) {
+		applyQuickSlotsFromRoutes();
+		return;
+	}
+	try {
+		const res = await workbenchApi.listUserWorkbenchShortcuts();
+		if (res.code === 2000) {
+			const list = workbenchApi.unwrapShortcutListData(res.data);
+			const mapped = mapWorkbenchShortcutListToState(list, opts);
+			if (mapped) {
+				quickSlotMenuIds.value = mapped.menuIds;
+				quickHighlightIndex.value = mapped.highlightIndex;
+				quickServerRecordBySlot.value = mapped.recordBySlot;
+				quickEntryHydrated.value = true;
+				return;
+			}
+		}
+	} catch (e) {
+		console.error('load user_workbench_shortcut', e);
+	}
+	applyQuickSlotsFromRoutes();
+	quickEntryHydrated.value = true;
+}
+
+watch(quickMenuOptions, () => {
+	void hydrateQuickEntry();
+}, { immediate: true });
+
+/** 与「常用」单选对应的目标菜单 path(id),用 id 判断高亮,避免无权限项被滤掉后下标错位 */
+const quickFrequentMenuId = computed(() => {
+	const ids = quickSlotMenuIds.value;
+	const h = quickHighlightIndex.value;
+	if (h < 0 || h >= ids.length) return null;
+	return ids[h] ?? null;
+});
 
+/**
+ * 仅展示当前 `routesList` 过滤后仍有权访问的项(`quickMenuOptions` 中存在的 path);
+ * 无权限/菜单已下线的项在列表中不渲染。
+ */
 const quickDisplayItems = computed(() => {
+	const frequentId = quickFrequentMenuId.value;
 	return quickSlotMenuIds.value
-		.map((menuId, index) => {
+		.map((menuId) => {
 			const opt = getQuickOptionById(menuId);
 			if (!opt) return null;
 			return {
 				...opt,
-				highlight: index === quickHighlightIndex.value,
+				highlight: frequentId !== null && opt.id === frequentId,
 			};
 		})
 		.filter((x): x is QuickEntryMenuOption & { highlight: boolean } => x !== null);
@@ -219,20 +281,51 @@ function isQuickEntryOptionTakenByOtherSlot(optionId: string, slotIndex: number)
 }
 
 function openQuickEdit() {
-	quickEditDraft.slots = [...quickSlotMenuIds.value];
+	const pad = quickSlotMenuIds.value.slice(0, QUICK_ENTRY_SLOT_COUNT);
+	const used = new Set(pad);
+	while (pad.length < QUICK_ENTRY_SLOT_COUNT) {
+		const o = quickMenuOptions.value.find((x) => !used.has(x.id));
+		if (!o) break;
+		pad.push(o.id);
+		used.add(o.id);
+	}
+	quickEditDraft.slots = pad;
 	quickEditDraft.highlightIndex = quickHighlightIndex.value;
 	quickEditVisible.value = true;
 }
 
-function saveQuickEdit() {
-	quickSlotMenuIds.value = [...quickEditDraft.slots];
-	quickHighlightIndex.value = quickEditDraft.highlightIndex;
-	saveQuickEntryPrefs({
-		menuIds: [...quickSlotMenuIds.value],
-		highlightIndex: quickHighlightIndex.value,
-	});
-	quickEditVisible.value = false;
-	ElMessage.success('快捷入口已更新');
+async function saveQuickEdit() {
+	quickSaving.value = true;
+	try {
+		quickSlotMenuIds.value = [...quickEditDraft.slots];
+		quickHighlightIndex.value = quickEditDraft.highlightIndex;
+		const opts = quickMenuOptions.value;
+		await syncQuickSlotsToServer(
+			quickSlotMenuIds.value,
+			quickHighlightIndex.value,
+			new Map(quickServerRecordBySlot.value),
+			opts
+		);
+		const res = await workbenchApi.listUserWorkbenchShortcuts();
+		if (res.code === 2000) {
+			const list = workbenchApi.unwrapShortcutListData(res.data);
+			const nextMap = buildRecordBySlotFromList(list, opts);
+			if (nextMap.size) {
+				quickServerRecordBySlot.value = nextMap;
+			}
+		}
+		saveQuickEntryPrefs({
+			menuIds: [...quickSlotMenuIds.value],
+			highlightIndex: quickHighlightIndex.value,
+		});
+		quickEditVisible.value = false;
+		ElMessage.success('快捷入口已更新');
+	} catch (e) {
+		console.error(e);
+		ElMessage.error('保存失败,请稍后再试');
+	} finally {
+		quickSaving.value = false;
+	}
 }
 
 function onQuickCardClick(item: QuickEntryMenuOption & { highlight?: boolean }) {

+ 330 - 0
src/views/system/screenconsole/quickEntryServerAdapter.ts

@@ -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;
+}

+ 53 - 0
src/views/system/screenconsole/userWorkbenchShortcutApi.ts

@@ -0,0 +1,53 @@
+import { request } from '/@/utils/service';
+
+/** 与 OpenAPI/后端约定一致,勿在页面内散落路径 */
+export const USER_WORKBENCH_SHORTCUT_PREFIX = '/api/system/user_workbench_shortcut/';
+
+export function listUserWorkbenchShortcuts() {
+	return request({
+		url: USER_WORKBENCH_SHORTCUT_PREFIX,
+		method: 'get',
+	});
+}
+
+export function createUserWorkbenchShortcut(data: Record<string, unknown>) {
+	return request({
+		url: USER_WORKBENCH_SHORTCUT_PREFIX,
+		method: 'post',
+		data,
+	});
+}
+
+export function patchUserWorkbenchShortcut(id: string | number, data: Record<string, unknown>) {
+	return request({
+		url: `${USER_WORKBENCH_SHORTCUT_PREFIX}${id}/`,
+		method: 'patch',
+		data,
+	});
+}
+
+export function putUserWorkbenchShortcut(id: string | number, data: Record<string, unknown>) {
+	return request({
+		url: `${USER_WORKBENCH_SHORTCUT_PREFIX}${id}/`,
+		method: 'put',
+		data,
+	});
+}
+
+export function deleteUserWorkbenchShortcut(id: string | number) {
+	return request({
+		url: `${USER_WORKBENCH_SHORTCUT_PREFIX}${id}/`,
+		method: 'delete',
+	});
+}
+
+/** 拉列表:可能是数组或分页 { results: [] } */
+export function unwrapShortcutListData(data: unknown): unknown[] {
+	if (data == null) return [];
+	if (Array.isArray(data)) return data;
+	if (typeof data === 'object' && data !== null && 'results' in data) {
+		const r = (data as { results?: unknown }).results;
+		if (Array.isArray(r)) return r;
+	}
+	return [];
+}