From 5ec514afe4cb02c96773e75be2fc1cc56b3a04c6 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 3 Nov 2026 23:16:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(gxmu):=20=E4=BF=AE=E5=A4=8D=20spec=20#58=20?= =?UTF-8?q?=E5=8F=8C=E8=BD=B4=20review=20=E7=9A=84=20admin=20=E4=BE=A7?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新建审批链丢审批人规则:新建/编辑共用 toReviewFlowPayload,reviewerRule 不再漏带; 自检新增「审批链保存体」一组断言。 2. 审核列表模块筛选取注册表全量清单(?all=true),停用模块标题带「(已停用)」; 只有审批链配置下拉仍用启用集。 3. 审批人规则 placeholder 换成真实角色码 YouthLeagueCommitteeOfSecondaryColleges。 4. getModuleDetailConfig 改为显式传入注册表快照(纯函数),消除加载前后配置不一致; 快照在 module-registry 里冻结,并落 localStorage 供失败兜底。 5. 自检脚本:fixture 与种子对齐(10 行、5 个启用,含 cms_manuscript), 「5 组行为」改为按实际组数统计;新增 npm run selfcheck:module-registry。 6. 注册表取不到时:保留上次成功清单 + 顶部 a-alert 提示 + 重试按钮(不静默留空下拉)。 门禁:npm run build 通过;npm run selfcheck:module-registry 6 组行为全绿。 --- package.json | 1 + scripts/selfcheck-module-registry.mjs | 112 +++++++++++++++--- src/api/gxmu/moduleRegistry/index.ts | 11 +- src/utils/module-registry-core.ts | 50 ++++++-- src/utils/module-registry.ts | 112 ++++++++++++++---- .../gxmu/components/moduleRegistryNotice.vue | 25 ++++ .../reviewFlow/components/reviewFlowEdit.vue | 38 +++--- .../components/reviewFlowPayload.ts | 32 +++++ .../components/reviewFlowConfigEdit.vue | 39 ++++-- .../reviewList/components/moduleDataModal.vue | 19 ++- .../components/moduleDetailConfig.ts | 19 ++- .../gxmu/reviewList/components/search.vue | 70 +++++++---- 12 files changed, 415 insertions(+), 113 deletions(-) create mode 100644 src/views/gxmu/components/moduleRegistryNotice.vue create mode 100644 src/views/gxmu/reviewFlow/components/reviewFlowPayload.ts diff --git a/package.json b/package.json index b24c81f..417e531 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "vite", "serve": "vite build && vite preview", "build": "vite build", + "selfcheck:module-registry": "node scripts/selfcheck-module-registry.mjs", "lint:eslint": "eslint --cache --max-warnings 0 \"src/**/*.{vue,ts}\" --fix", "clean:cache": "rimraf node_modules/.cache/ && rimraf node_modules/.vite/", "clean:lib": "rimraf node_modules" diff --git a/scripts/selfcheck-module-registry.mjs b/scripts/selfcheck-module-registry.mjs index 7712d2d..52b6526 100644 --- a/scripts/selfcheck-module-registry.mjs +++ b/scripts/selfcheck-module-registry.mjs @@ -2,13 +2,16 @@ /** * 模块注册表前端逻辑的最小自检(仓库无单测基建,用 Node + typescript.transpileModule 跑纯函数)。 * - * 覆盖 ticket #65 的可见行为: + * 覆盖 ticket #65 与 spec #58 修复后的可见行为: * 1. 「可新建审批链」下拉只含 enabled=1 的模块,按 sortNumber 升序,标题取注册表 title; - * 2. module → detailKey 的映射收在 resolveDetailKey 一处,注册表里没有该模块时回退 module 本身, - * 保证停用模块的历史审批链仍能回显标题、仍能打开详情; - * 3. 编辑历史配置时把停用的模块补进下拉(带「已停用」标记),不重复添加。 + * 2. 审核列表的模块筛选用全量清单(含停用模块),停用项标题带「(已停用)」, + * 且只有审批链配置下拉用启用清单; + * 3. module → detailKey 的映射收在 resolveDetailKey 一处,注册表里没有该模块时回退 module 本身; + * 4. 标题解析:注册表命中取 title,未命中回退 module 本身; + * 5. 编辑历史配置时把停用的模块补进下拉(带「已停用」标记),不重复添加; + * 6. 审批链保存体:新建与编辑两条分支都必须带上每级的 reviewerRule(spec #58 A5)。 * - * 运行:node scripts/selfcheck-module-registry.mjs + * 运行:npm run selfcheck:module-registry */ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; @@ -38,7 +41,10 @@ const loadTs = (relative) => { return mod.exports; }; +const readSource = (relative) => readFileSync(resolve(root, relative), 'utf8'); + let failed = 0; +let groupCount = 0; const assert = (name, condition, detail) => { if (condition) { console.log(' ok ' + name); @@ -47,37 +53,71 @@ const assert = (name, condition, detail) => { failed += 1; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')); }; +/** 开始一组行为断言,结尾按实际组数统计,避免硬编码组数 */ +const group = (name) => { + groupCount += 1; + console.log(name); +}; const core = loadTs('src/utils/module-registry-core.ts'); +const payloadBuilder = loadTs( + 'src/views/gxmu/reviewFlow/components/reviewFlowPayload.ts' +); -// 与后端种子数据一致:10 个模块里只有 4 个 enabled=1 +// 与后端种子一致:sql/20261110_spec58_approval_chain.sql 里 10 个模块, +// 其中 5 个 enabled=1(含稿件 cms_manuscript,它有在跑的审核链)。 const REGISTRY = [ { module: 'gxmu_sjqn', detailKey: 'gxmu_sjqn', title: '十佳青年岗位能手', enabled: 1, sortNumber: 1 }, { module: 'gxmu_sjtbzbsj', detailKey: 'gxmu_sjtbzbsj', title: '十佳团支部书记', enabled: 0, sortNumber: 2 }, + { module: 'gxmu_wshqtw', detailKey: 'gxmu_wshqtw', title: '五四红旗团委', enabled: 0, sortNumber: 3 }, + { module: 'gxmu_wshqtzb', detailKey: 'gxmu_wshqtzb', title: '五四红旗团支部', enabled: 0, sortNumber: 4 }, + { module: 'gxmu_yxgqtdgb', detailKey: 'gxmu_yxgqtdgb', title: '优秀共青团干部', enabled: 0, sortNumber: 5 }, + { module: 'gxmu_yxgqty', detailKey: 'gxmu_yxgqty', title: '优秀共青团员', enabled: 0, sortNumber: 6 }, { module: 'gxmu_tzbcy_form', detailKey: 'gxmu_tzbcy_form', title: '挑战杯', enabled: 1, sortNumber: 7 }, { module: 'gxmu_qmgc_form', detailKey: 'gxmu_qmgc_form', title: '青马工程', enabled: 1, sortNumber: 8 }, { module: 'gxmu_wxxzx_project', detailKey: 'gxmu_wxxzx_project', title: '未来学术之星', enabled: 1, sortNumber: 9 }, - { module: 'cms_manuscript', detailKey: 'cms_manuscript', title: '稿件', enabled: 0, sortNumber: 10 } + { module: 'cms_manuscript', detailKey: 'cms_manuscript', title: '稿件', enabled: 1, sortNumber: 10 } ]; -console.log('新建审批链的模块下拉(enabled=1,按 sortNumber)'); +group('新建审批链的模块下拉(enabled=1,按 sortNumber)'); const options = core.toModuleOptions(REGISTRY); assert( '只含已启用模块', options.map((o) => o.value).join(',') === - 'gxmu_sjqn,gxmu_tzbcy_form,gxmu_qmgc_form,gxmu_wxxzx_project', + 'gxmu_sjqn,gxmu_tzbcy_form,gxmu_qmgc_form,gxmu_wxxzx_project,cms_manuscript', JSON.stringify(options) ); assert('label 取注册表标题', options[0].label === '十佳青年岗位能手'); assert('停用模块不出现', !options.some((o) => o.value === 'gxmu_sjtbzbsj')); -console.log('审核列表的模块筛选项(同理,按注册表顺序)'); +group('审核列表的模块筛选(全量清单,停用项带标记)'); +const filterOptions = core.toAllModuleOptions(REGISTRY); +assert('含停用模块', filterOptions.length === REGISTRY.length, String(filterOptions.length)); assert( - '与新建下拉同源', - core.toModuleOptions(REGISTRY).length === options.length + '按 sortNumber 升序', + filterOptions.map((o) => o.value).join(',') === REGISTRY.map((r) => r.module).join(','), + JSON.stringify(filterOptions.map((o) => o.value)) +); +const disabledOption = filterOptions.find((o) => o.value === 'gxmu_sjtbzbsj'); +assert('停用项标题带「(已停用)」', disabledOption?.label === '十佳团支部书记(已停用)', JSON.stringify(disabledOption)); +assert('启用项标题不加标记', filterOptions.find((o) => o.value === 'gxmu_sjqn')?.label === '十佳青年岗位能手'); +assert( + '停用模块仍能被筛出来(ADR 0006 历史数据仍可查)', + ['gxmu_sjtbzbsj', 'gxmu_wshqtw', 'gxmu_wshqtzb', 'gxmu_yxgqtdgb', 'gxmu_yxgqty'].every((m) => + filterOptions.some((o) => o.value === m) + ) +); +const searchSource = readSource('src/views/gxmu/reviewList/components/search.vue'); +assert('审核列表筛选入口用 allModuleOptions', /allModuleOptions\(/.test(searchSource)); +const configEditSource = readSource( + 'src/views/gxmu/reviewFlowConfig/components/reviewFlowConfigEdit.vue' +); +assert( + '审批链配置下拉仍只用启用清单', + /enabledModuleOptions\(/.test(configEditSource) && !/allModuleOptions\(/.test(configEditSource) ); -console.log('module → detailKey 映射(收在一处)'); +group('module → detailKey 映射(收在一处)'); assert('走注册表 detailKey', core.resolveDetailKey(REGISTRY, 'gxmu_sjqn') === 'gxmu_sjqn'); assert( '注册表缺该模块时回退 module(停用模块的详情仍可解析)', @@ -85,11 +125,11 @@ assert( ); assert('空模块返回空', core.resolveDetailKey(REGISTRY, undefined) === undefined); -console.log('标题解析(历史配置回显)'); +group('标题解析(历史配置回显)'); assert('注册表命中', core.resolveModuleTitle(REGISTRY, 'gxmu_qmgc_form') === '青马工程'); assert('注册表未命中回退 module 本身', core.resolveModuleTitle(REGISTRY, 'legacy_module') === 'legacy_module'); -console.log('编辑历史配置:把停用模块补进下拉'); +group('编辑历史配置:把停用模块补进下拉'); const merged = core.ensureModuleOption(options, 'gxmu_sjtbzbsj', '十佳团支部书记'); assert('补进来的项标记已停用', merged[0]?.label === '十佳团支部书记(已停用)', JSON.stringify(merged[0])); assert('补进来的项在最前面且可回显', merged[0]?.value === 'gxmu_sjtbzbsj'); @@ -101,8 +141,48 @@ assert( core.ensureModuleOption(options, 'legacy_module')[0]?.label === 'legacy_module(已停用)' ); +group('审批链保存体:新建与编辑都带 reviewerRule(spec #58 A5)'); +const newChainForm = { + title: '新链', + organizationIds: [1, 2], + flowItems: [{ organizationId: 1 }, { organizationId: 2 }], + id: undefined +}; +const created = payloadBuilder.toReviewFlowPayload( + newChainForm, + { organizationId: 1, reviewerRule: ' COLLEGE_ROLE:YouthLeagueCommitteeOfSecondaryColleges ' }, + 1 +); +assert( + '新建分支带 reviewerRule(已 trim)', + created.reviewerRule === 'COLLEGE_ROLE:YouthLeagueCommitteeOfSecondaryColleges', + JSON.stringify(created.reviewerRule) +); +assert('新建分支带 level', created.level === 1); +assert( + '提交体不带前端专用字段', + !('organizationIds' in created) && !('flowItems' in created) && !('id' in created) +); +assert( + '未填规则时写 undefined 而不是空串', + payloadBuilder.toReviewFlowPayload(newChainForm, { organizationId: 2 }, 2).reviewerRule === undefined +); +const edited = payloadBuilder.toReviewFlowPayload( + newChainForm, + { organizationId: 2, reviewUserId: 7, reviewerRule: 'USER:12' }, + 2 +); +assert('编辑分支同样带 reviewerRule', edited.reviewerRule === 'USER:12'); +assert('编辑分支保留指定人', edited.reviewUserId === 7); +const flowEditSource = readSource('src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue'); +assert( + '新建/编辑两条分支共用同一份构造逻辑(不再逐字段手拼)', + (flowEditSource.match(/toReviewFlowPayload\(/g) || []).length === 2 && + !/const formData: ReviewFlow = \{/.test(flowEditSource) +); + if (failed) { console.error('\n自检失败:' + failed + ' 项'); process.exit(1); } -console.log('\n自检通过:' + 5 + ' 组行为'); +console.log('\n自检通过:' + groupCount + ' 组行为'); diff --git a/src/api/gxmu/moduleRegistry/index.ts b/src/api/gxmu/moduleRegistry/index.ts index 0dee1ab..c913390 100644 --- a/src/api/gxmu/moduleRegistry/index.ts +++ b/src/api/gxmu/moduleRegistry/index.ts @@ -4,11 +4,16 @@ import type { ModuleRegistry } from './model'; import { MODULES_API_URL } from '@/config/setting'; /** - * 启用模块清单(注册表唯一来源,前端不再维护模块清单) + * 模块清单(注册表唯一来源,前端不再维护模块清单)。 + * + * all=true 取全量(含停用):筛选与标题回显要用它,否则停用模块的历史记录 + * 在界面上就筛不出来了;只有「可新建审批链」的下拉需要按 enabled 再过滤。 + * 后端 /list 默认只返回启用模块。 */ -export async function listModuleRegistry() { +export async function listModuleRegistry(all = true) { const res = await request.get>( - MODULES_API_URL + '/gxmu/module-registry/list' + MODULES_API_URL + '/gxmu/module-registry/list', + { params: { all: all ? 'true' : 'false' } } ); if (res.data.code === 0) { return res.data.data || []; diff --git a/src/utils/module-registry-core.ts b/src/utils/module-registry-core.ts index 5cd8659..3bae4f3 100644 --- a/src/utils/module-registry-core.ts +++ b/src/utils/module-registry-core.ts @@ -1,7 +1,7 @@ /** * 模块注册表的纯逻辑:选项构造、module → detailKey 映射、标题回退。 * - * 这里刻意不依赖 request / Vue,便于 scripts/selfcheck-module-registry.mjs + * 这里刻意不依赖 request / Vue / localStorage,便于 scripts/selfcheck-module-registry.mjs * 在无单测基建的前端仓库里直接验证行为。副作用(拉取与缓存)在 * src/utils/module-registry.ts。 */ @@ -21,23 +21,52 @@ export interface ModuleOption { value: string; } +/** 按 sortNumber 升序(缺省排最后,同序号保持原顺序) */ +const bySortNumber = (a: RegistryModule, b: RegistryModule) => + (a.sortNumber ?? Number.MAX_SAFE_INTEGER) - + (b.sortNumber ?? Number.MAX_SAFE_INTEGER); + /** - * 构造模块下拉选项:只含启用模块,按 sortNumber 升序,标题取注册表 title。 + * 构造「可新建审批链」的模块下拉选项:只含启用模块,按 sortNumber 升序, + * 标题取注册表 title。 * - * 停用模块(enabled=0)不出现在「可新建审批链」的下拉里,历史数据仍可查, - * 见 docs/adr/0006-approval-chain-convergence.md。 + * 停用模块(enabled=0)不出现在这里,历史数据仍可查,见 + * docs/adr/0006-approval-chain-convergence.md。审核列表等「查存量」入口请用 + * toAllModuleOptions。 */ -export const toModuleOptions = (list?: RegistryModule[]): ModuleOption[] => { +export const toModuleOptions = ( + list?: readonly RegistryModule[] +): ModuleOption[] => { return (list || []) .filter((item) => !!item.module && item.enabled === 1) .slice() - .sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0)) + .sort(bySortNumber) .map((item) => ({ label: item.title || item.module || '', value: item.module || '' })); }; +/** + * 构造「查存量」入口(审核列表模块筛选等)的模块下拉选项:含停用模块, + * 停用项标题后加「(已停用)」,与 ADR 0006「历史数据仍可查」一致。 + */ +export const toAllModuleOptions = ( + list?: readonly RegistryModule[] +): ModuleOption[] => { + return (list || []) + .filter((item) => !!item.module) + .slice() + .sort(bySortNumber) + .map((item) => { + const title = item.title || item.module || ''; + return { + label: item.enabled === 1 ? title : `${title}(已停用)`, + value: item.module || '' + }; + }); +}; + /** * module → 详情页配置键(moduleDetailConfig.ts 的键)的唯一映射。 * @@ -45,7 +74,7 @@ export const toModuleOptions = (list?: RegistryModule[]): ModuleOption[] => { * 而注册表的 detail_key 与 module 不一致时以注册表为准。 */ export const resolveDetailKey = ( - list: RegistryModule[] | undefined, + list: readonly RegistryModule[] | undefined, module?: string ): string | undefined => { if (!module) { @@ -57,7 +86,7 @@ export const resolveDetailKey = ( /** 模块标题,注册表未命中时回退 module 本身,避免回显成空白 */ export const resolveModuleTitle = ( - list: RegistryModule[] | undefined, + list: readonly RegistryModule[] | undefined, module?: string ): string | undefined => { if (!module) { @@ -81,5 +110,8 @@ export const ensureModuleOption = ( if (!module || options.some((item) => item.value === module)) { return options; } - return [{ label: `${moduleName || module}(已停用)`, value: module }, ...options]; + return [ + { label: `${moduleName || module}(已停用)`, value: module }, + ...options + ]; }; diff --git a/src/utils/module-registry.ts b/src/utils/module-registry.ts index 327569f..a479ed6 100644 --- a/src/utils/module-registry.ts +++ b/src/utils/module-registry.ts @@ -2,42 +2,110 @@ import { listModuleRegistry } from '@/api/gxmu/moduleRegistry'; import type { ModuleRegistry } from '@/api/gxmu/moduleRegistry/model'; import { ensureModuleOption, - resolveDetailKey, - resolveModuleTitle, + toAllModuleOptions, toModuleOptions } from './module-registry-core'; import type { ModuleOption } from './module-registry-core'; /** - * 模块注册表缓存:首次使用时拉一次,进程内缓存;并发调用共用同一个请求。 + * 模块注册表缓存。 + * + * 口径(与后端、移动端统一): + * - 拉取成功后写入内存快照并冻结,同时落 localStorage,供下次拉取失败时兜底; + * - 拉取失败时保留上次成功的清单(内存 → localStorage),并让调用方做 + * 「顶部提示 + 重试」,不允许静默留空下拉。 */ -let cache: ModuleRegistry[] = []; -let pending: Promise | null = null; +const STORAGE_KEY = 'gxmu.module-registry.snapshot.v1'; -/** 当前缓存(未加载时为空数组) */ -export const getModuleRegistryCache = (): ModuleRegistry[] => cache; +/** 只读快照:换新快照时整体替换引用,已拿到的引用不会被原地修改 */ +let cache: ReadonlyArray = Object.freeze([]); +let pending: Promise> | null = null; +/** 当前快照是否来自「上次成功」的兜底(最近一次拉取失败) */ +let stale = false; + +const freezeList = (list?: ModuleRegistry[]): ReadonlyArray => + Object.freeze((list || []).slice()); + +/** 当前快照是否是「上次成功」的兜底数据,供顶部提示判断 */ +export const isModuleRegistryStale = (): boolean => stale; + +const readStoredSnapshot = (): ModuleRegistry[] => { + if (typeof localStorage === 'undefined') { + return []; + } + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed?.list) ? parsed.list : []; + } catch (e) { + return []; + } +}; + +const writeStoredSnapshot = (list: ReadonlyArray) => { + if (typeof localStorage === 'undefined') { + return; + } + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ savedAt: Date.now(), list }) + ); + } catch (e) { + // 隐私模式 / 配额满:兜底缓存失败不影响主流程 + } +}; + +/** 用上次成功的清单填充内存快照(仅在内存为空时),返回是否恢复了数据 */ +const restoreStoredSnapshot = (): boolean => { + if (cache.length) { + return false; + } + const stored = readStoredSnapshot(); + if (!stored.length) { + return false; + } + cache = freezeList(stored); + stale = true; + return true; +}; /** 清空缓存(注册表变更后手动失效) */ export const invalidateModuleRegistry = (): void => { - cache = []; + cache = Object.freeze([]); pending = null; + stale = false; }; /** - * 加载模块注册表。默认命中缓存直接返回;force 为 true 时重新拉取。 + * 加载模块注册表(含停用模块,筛选与标题回显都从这里取)。 + * + * 默认命中快照直接返回;force 为 true 时重新拉取。拉取失败时保留上次成功的 + * 清单并抛出错误,由调用方展示「顶部提示 + 重试」;若本次直接命中兜底快照, + * 则通过 isModuleRegistryStale() 告知调用方。 */ export const loadModuleRegistry = async ( force = false -): Promise => { +): Promise> => { if (!force && cache.length) { return cache; } if (!pending) { pending = listModuleRegistry() .then((list) => { - cache = list || []; + cache = freezeList(list); + stale = false; + writeStoredSnapshot(cache); return cache; }) + .catch((e: any) => { + stale = true; + restoreStoredSnapshot(); + throw e; + }) .finally(() => { pending = null; }); @@ -45,17 +113,19 @@ export const loadModuleRegistry = async ( return pending; }; -/** 「可新建审批链」的模块下拉:只含启用模块 */ -export const enabledModuleOptions = (): ModuleOption[] => - toModuleOptions(cache); +/** 「可新建审批链」的模块下拉:只含启用模块(仅 reviewFlowConfigEdit 使用) */ +export const enabledModuleOptions = ( + list: ReadonlyArray = cache +): ModuleOption[] => toModuleOptions(list); -/** module → 详情页配置键,映射收在这一处 */ -export const detailKeyOf = (module?: string): string | undefined => - resolveDetailKey(cache, module); - -/** 模块标题,注册表未命中时回退 module 本身 */ -export const moduleTitleOf = (module?: string): string | undefined => - resolveModuleTitle(cache, module); +/** + * 「查存量」入口的模块下拉:含停用模块,停用项标题带「(已停用)」。 + * + * 审核列表的模块筛选等入口用它,保证停用模块的历史记录仍能被筛出来。 + */ +export const allModuleOptions = ( + list: ReadonlyArray = cache +): ModuleOption[] => toAllModuleOptions(list); /** 编辑存量配置时把停用模块补进下拉,保证标题回显 */ export const withLegacyModuleOption = ( diff --git a/src/views/gxmu/components/moduleRegistryNotice.vue b/src/views/gxmu/components/moduleRegistryNotice.vue new file mode 100644 index 0000000..09af8d6 --- /dev/null +++ b/src/views/gxmu/components/moduleRegistryNotice.vue @@ -0,0 +1,25 @@ + + + + diff --git a/src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue b/src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue index ad0b525..e5fb116 100644 --- a/src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue +++ b/src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue @@ -111,6 +111,7 @@ import type { Organization } from '@/api/system/organization/model'; import { listUsers } from '@/api/system/user'; import type { User } from '@/api/system/user/model'; + import { toReviewFlowPayload } from './reviewFlowPayload'; interface ReviewFlowItem { organizationId?: number; @@ -158,7 +159,7 @@ const reviewUserNameMap = ref>({}); // 审批人规则语法见 docs/adr/0006-approval-chain-convergence.md const reviewerRulePlaceholder = - '审批人规则:USER:12 或 COLLEGE_ROLE:COLLEGE_LEAGUE_SECRETARY,可用 ; 拼装多段,留空沿用「指定人」'; + '审批人规则:USER:12 或 COLLEGE_ROLE:YouthLeagueCommitteeOfSecondaryColleges,可用 ; 拼装多段,留空沿用「指定人」'; // 用户信息 const form = reactive({ @@ -415,34 +416,23 @@ } for (let index = 0; index < form.flowItems.length; index++) { const item = form.flowItems[index]; - const formData: ReviewFlow = { - ...form, - title: form.title, - organizationId: item.organizationId, - reviewUserId: item.reviewUserId, - reviewerRule: item.reviewerRule?.trim() || undefined, - level: index + 1 - }; - delete (formData as ReviewFlow & { organizationIds?: number[] }) - .organizationIds; - delete (formData as ReviewFlow & { flowItems?: ReviewFlowItem[] }) - .flowItems; - delete formData.id; + // 新建与编辑共用同一份构造逻辑,reviewerRule 不会漏带 + const formData: ReviewFlow = toReviewFlowPayload( + form, + item, + index + 1 + ); successMessage = await addReviewFlow(formData); } } else { for (let index = 0; index < form.flowItems.length; index++) { const item = form.flowItems[index]; - const formData: ReviewFlow = { - ...form, - organizationId: item.organizationId, - reviewUserId: item.reviewUserId, - level: index + 1 - }; - delete (formData as ReviewFlow & { organizationIds?: number[] }) - .organizationIds; - delete (formData as ReviewFlow & { flowItems?: ReviewFlowItem[] }) - .flowItems; + // 新建与编辑共用同一份构造逻辑,reviewerRule 不会漏带 + const formData: ReviewFlow = toReviewFlowPayload( + form, + item, + index + 1 + ); successMessage = await addReviewFlow(formData); } } diff --git a/src/views/gxmu/reviewFlow/components/reviewFlowPayload.ts b/src/views/gxmu/reviewFlow/components/reviewFlowPayload.ts new file mode 100644 index 0000000..af67238 --- /dev/null +++ b/src/views/gxmu/reviewFlow/components/reviewFlowPayload.ts @@ -0,0 +1,32 @@ +/** + * 审批链保存时逐级构造提交体。 + * + * 新建与编辑两条分支必须共用这一份构造逻辑:审批人规则(reviewerRule)是 + * 每一级的字段而不是表单级字段,逐字段手拼时漏带会让后台录入的规则静默丢失 + * (spec #58 A5)。这里顺带剥掉前端专用的 organizationIds / flowItems / id。 + */ +export interface ReviewFlowItemLike { + organizationId?: number; + reviewUserId?: number; + // 审批人规则:';' 分隔可拼装多段,USER: / COLLEGE_ROLE: + reviewerRule?: string; +} + +/** 单级提交体:form 上的公共字段 + 当前级的机构/审批人/规则/层级 */ +export const toReviewFlowPayload = ( + form: Record, + item: ReviewFlowItemLike, + level: number +): Record => { + const payload: Record = { + ...form, + organizationId: item.organizationId, + reviewUserId: item.reviewUserId, + reviewerRule: item.reviewerRule?.trim() || undefined, + level + }; + delete payload.organizationIds; + delete payload.flowItems; + delete payload.id; + return payload; +}; diff --git a/src/views/gxmu/reviewFlowConfig/components/reviewFlowConfigEdit.vue b/src/views/gxmu/reviewFlowConfig/components/reviewFlowConfigEdit.vue index d6431ea..4b750f4 100644 --- a/src/views/gxmu/reviewFlowConfig/components/reviewFlowConfigEdit.vue +++ b/src/views/gxmu/reviewFlowConfig/components/reviewFlowConfigEdit.vue @@ -11,6 +11,10 @@ @update:visible="updateVisible" @ok="save" > + ([]); + const moduleLoadError = ref(); // 用户信息 const form = reactive({ @@ -203,22 +210,40 @@ }; /** - * 加载模块下拉。 + * 加载模块下拉(审批链配置的下拉只含启用模块)。 * - * 注册表接口只返回启用模块,编辑停用模块的历史审批链时把当前值补成 - * 「标题(已停用)」,保证回显不空白。 + * 注册表快照显式传入,不再读模块级可变缓存;编辑停用模块的历史审批链时把 + * 当前值补成「标题(已停用)」,保证回显不空白。拉取失败时保留上次成功的 + * 清单并在顶部提示 + 重试,不静默留空下拉。 */ - const loadModuleOptions = async (module?: string, moduleName?: string) => { + const loadModuleOptions = async ( + module?: string, + moduleName?: string, + force = false + ) => { moduleLoading.value = true; + moduleLoadError.value = undefined; try { - await loadModuleRegistry(); + const list = await loadModuleRegistry(force); + moduleOptions.value = withLegacyModuleOption( + enabledModuleOptions(list), + module, + moduleName + ); + // 命中「上次成功」的兜底快照:只给顶部提示 + 重试,不重复弹 message + if (isModuleRegistryStale()) { + moduleLoadError.value = '模块清单加载失败,已保留上次成功加载的清单'; + } + } catch (e: any) { moduleOptions.value = withLegacyModuleOption( enabledModuleOptions(), module, moduleName ); - } catch (e: any) { - message.error(e.message || '模块清单加载失败'); + moduleLoadError.value = moduleOptions.value.length + ? '模块清单加载失败,已保留上次成功加载的清单' + : '模块清单加载失败,请重试'; + message.warning(moduleLoadError.value); } finally { moduleLoading.value = false; } diff --git a/src/views/gxmu/reviewList/components/moduleDataModal.vue b/src/views/gxmu/reviewList/components/moduleDataModal.vue index f923969..4c7a37d 100644 --- a/src/views/gxmu/reviewList/components/moduleDataModal.vue +++ b/src/views/gxmu/reviewList/components/moduleDataModal.vue @@ -210,7 +210,7 @@