From 74f801a0810ac4ac90a52087c720a48b044bd93a Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 3 Nov 2026 22:40:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(gxmu):=20=E6=A8=A1=E5=9D=97=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E6=94=B9=E7=94=B1=E6=A8=A1=E5=9D=97=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E9=A9=B1=E5=8A=A8=EF=BC=8C=E5=AE=A1=E6=89=B9=E9=93=BE?= =?UTF-8?q?=E6=AF=8F=E7=BA=A7=E6=94=AF=E6=8C=81=E5=AE=A1=E6=89=B9=E4=BA=BA?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增模块注册表 api 与进程内缓存(首次使用拉一次、并发共用同一个请求), 启用清单驱动「可新建审批链」的模块下拉与审核列表模块筛选 - reviewFlowConfigEdit 不再维护硬编码模块清单;停用模块的历史配置按 「标题(已停用)」回显,避免下拉空白 - module → detailKey 的映射收敛到 detailKeyOf 一处,moduleDetailConfig 未命中注册表时回退 module 本身(停用模块的详情仍可打开) - 审核流每级新增「审批人规则」输入(USER: / COLLEGE_ROLE:, 分号拼装多段,留空沿用「指定人」),随该级提交 reviewerRule 并回显 - 新增 scripts/selfcheck-module-registry.mjs:前端无单测基建时验证下拉过滤、 detailKey 回退与停用模块回显 --- scripts/selfcheck-module-registry.mjs | 108 ++++++++++++++++++ src/api/gxmu/moduleRegistry/index.ts | 17 +++ src/api/gxmu/moduleRegistry/model/index.ts | 21 ++++ src/api/gxmu/reviewFlow/model/index.ts | 3 + src/api/gxmu/reviewFlowConfig/model/index.ts | 2 + src/utils/module-registry-core.ts | 85 ++++++++++++++ src/utils/module-registry.ts | 65 +++++++++++ .../reviewFlow/components/reviewFlowEdit.vue | 34 +++++- .../components/reviewFlowConfigEdit.vue | 46 ++++++-- .../components/moduleDetailConfig.ts | 14 ++- .../gxmu/reviewList/components/search.vue | 30 +++-- 11 files changed, 396 insertions(+), 29 deletions(-) create mode 100644 scripts/selfcheck-module-registry.mjs create mode 100644 src/api/gxmu/moduleRegistry/index.ts create mode 100644 src/api/gxmu/moduleRegistry/model/index.ts create mode 100644 src/utils/module-registry-core.ts create mode 100644 src/utils/module-registry.ts diff --git a/scripts/selfcheck-module-registry.mjs b/scripts/selfcheck-module-registry.mjs new file mode 100644 index 0000000..7712d2d --- /dev/null +++ b/scripts/selfcheck-module-registry.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * 模块注册表前端逻辑的最小自检(仓库无单测基建,用 Node + typescript.transpileModule 跑纯函数)。 + * + * 覆盖 ticket #65 的可见行为: + * 1. 「可新建审批链」下拉只含 enabled=1 的模块,按 sortNumber 升序,标题取注册表 title; + * 2. module → detailKey 的映射收在 resolveDetailKey 一处,注册表里没有该模块时回退 module 本身, + * 保证停用模块的历史审批链仍能回显标题、仍能打开详情; + * 3. 编辑历史配置时把停用的模块补进下拉(带「已停用」标记),不重复添加。 + * + * 运行:node scripts/selfcheck-module-registry.mjs + */ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const loadTs = (relative) => { + const file = resolve(root, relative); + const source = readFileSync(file, 'utf8'); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2019 + }, + fileName: file + }); + const mod = { exports: {} }; + new Function('exports', 'require', 'module', outputText)( + mod.exports, + require, + mod + ); + return mod.exports; +}; + +let failed = 0; +const assert = (name, condition, detail) => { + if (condition) { + console.log(' ok ' + name); + return; + } + failed += 1; + console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')); +}; + +const core = loadTs('src/utils/module-registry-core.ts'); + +// 与后端种子数据一致:10 个模块里只有 4 个 enabled=1 +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_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 } +]; + +console.log('新建审批链的模块下拉(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', + JSON.stringify(options) +); +assert('label 取注册表标题', options[0].label === '十佳青年岗位能手'); +assert('停用模块不出现', !options.some((o) => o.value === 'gxmu_sjtbzbsj')); + +console.log('审核列表的模块筛选项(同理,按注册表顺序)'); +assert( + '与新建下拉同源', + core.toModuleOptions(REGISTRY).length === options.length +); + +console.log('module → detailKey 映射(收在一处)'); +assert('走注册表 detailKey', core.resolveDetailKey(REGISTRY, 'gxmu_sjqn') === 'gxmu_sjqn'); +assert( + '注册表缺该模块时回退 module(停用模块的详情仍可解析)', + core.resolveDetailKey(REGISTRY, 'legacy_module') === 'legacy_module' +); +assert('空模块返回空', core.resolveDetailKey(REGISTRY, undefined) === undefined); + +console.log('标题解析(历史配置回显)'); +assert('注册表命中', core.resolveModuleTitle(REGISTRY, 'gxmu_qmgc_form') === '青马工程'); +assert('注册表未命中回退 module 本身', core.resolveModuleTitle(REGISTRY, 'legacy_module') === 'legacy_module'); + +console.log('编辑历史配置:把停用模块补进下拉'); +const merged = core.ensureModuleOption(options, 'gxmu_sjtbzbsj', '十佳团支部书记'); +assert('补进来的项标记已停用', merged[0]?.label === '十佳团支部书记(已停用)', JSON.stringify(merged[0])); +assert('补进来的项在最前面且可回显', merged[0]?.value === 'gxmu_sjtbzbsj'); +assert('原有选项保留', merged.length === options.length + 1); +const duplicated = core.ensureModuleOption(options, 'gxmu_sjqn'); +assert('已在选项里则不重复添加', duplicated.length === options.length); +assert( + '无 moduleName 时用 module 当标题', + core.ensureModuleOption(options, 'legacy_module')[0]?.label === 'legacy_module(已停用)' +); + +if (failed) { + console.error('\n自检失败:' + failed + ' 项'); + process.exit(1); +} +console.log('\n自检通过:' + 5 + ' 组行为'); diff --git a/src/api/gxmu/moduleRegistry/index.ts b/src/api/gxmu/moduleRegistry/index.ts new file mode 100644 index 0000000..0dee1ab --- /dev/null +++ b/src/api/gxmu/moduleRegistry/index.ts @@ -0,0 +1,17 @@ +import request from '@/utils/request'; +import type { ApiResult } from '@/api'; +import type { ModuleRegistry } from './model'; +import { MODULES_API_URL } from '@/config/setting'; + +/** + * 启用模块清单(注册表唯一来源,前端不再维护模块清单) + */ +export async function listModuleRegistry() { + const res = await request.get>( + MODULES_API_URL + '/gxmu/module-registry/list' + ); + if (res.data.code === 0) { + return res.data.data || []; + } + return Promise.reject(new Error(res.data.message)); +} diff --git a/src/api/gxmu/moduleRegistry/model/index.ts b/src/api/gxmu/moduleRegistry/model/index.ts new file mode 100644 index 0000000..7935595 --- /dev/null +++ b/src/api/gxmu/moduleRegistry/model/index.ts @@ -0,0 +1,21 @@ +/** + * 模块注册表:业务模块元数据的唯一来源,见 docs/adr/0006-approval-chain-convergence.md。 + */ +export interface ModuleRegistry { + // 主键 + id?: number; + // 模块标识,同时是业务表名 + module?: string; + // 业务表名,必须与 module 一致 + tableName?: string; + // 模块标题 + title?: string; + // 详情页配置键(moduleDetailConfig.ts) + detailKey?: string; + // 导出模板标识,空表示走默认导出 + exportTemplate?: string; + // 是否启用:0否 1是 + enabled?: number; + // 排序号,小的在前 + sortNumber?: number; +} diff --git a/src/api/gxmu/reviewFlow/model/index.ts b/src/api/gxmu/reviewFlow/model/index.ts index 16c3944..dc0e526 100644 --- a/src/api/gxmu/reviewFlow/model/index.ts +++ b/src/api/gxmu/reviewFlow/model/index.ts @@ -17,6 +17,9 @@ export interface ReviewFlow { // 租户id tenantId?: number; reviewUserId?: number; + // 审批人规则,';' 分隔可拼装多段:USER: / COLLEGE_ROLE:, + // 为空回退 reviewUserId(见 docs/adr/0006-approval-chain-convergence.md) + reviewerRule?: string; // 创建时间 createTime?: string; // 修改时间 diff --git a/src/api/gxmu/reviewFlowConfig/model/index.ts b/src/api/gxmu/reviewFlowConfig/model/index.ts index 600cf14..946a0ba 100644 --- a/src/api/gxmu/reviewFlowConfig/model/index.ts +++ b/src/api/gxmu/reviewFlowConfig/model/index.ts @@ -16,6 +16,8 @@ export interface ReviewFlowConfig { flowId?: number; // 模块 module?: string; + // 模块名称(后端填充) + moduleName?: string; // 是否删除, 0否, 1是 deleted?: number; // 租户id diff --git a/src/utils/module-registry-core.ts b/src/utils/module-registry-core.ts new file mode 100644 index 0000000..5cd8659 --- /dev/null +++ b/src/utils/module-registry-core.ts @@ -0,0 +1,85 @@ +/** + * 模块注册表的纯逻辑:选项构造、module → detailKey 映射、标题回退。 + * + * 这里刻意不依赖 request / Vue,便于 scripts/selfcheck-module-registry.mjs + * 在无单测基建的前端仓库里直接验证行为。副作用(拉取与缓存)在 + * src/utils/module-registry.ts。 + */ + +/** 注册表条目(与后端 ModuleRegistry 字段一致) */ +export interface RegistryModule { + module?: string; + title?: string; + detailKey?: string; + enabled?: number; + sortNumber?: number; +} + +/** 下拉选项 */ +export interface ModuleOption { + label: string; + value: string; +} + +/** + * 构造模块下拉选项:只含启用模块,按 sortNumber 升序,标题取注册表 title。 + * + * 停用模块(enabled=0)不出现在「可新建审批链」的下拉里,历史数据仍可查, + * 见 docs/adr/0006-approval-chain-convergence.md。 + */ +export const toModuleOptions = (list?: RegistryModule[]): ModuleOption[] => { + return (list || []) + .filter((item) => !!item.module && item.enabled === 1) + .slice() + .sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0)) + .map((item) => ({ + label: item.title || item.module || '', + value: item.module || '' + })); +}; + +/** + * module → 详情页配置键(moduleDetailConfig.ts 的键)的唯一映射。 + * + * 注册表里没有该模块时回退 module 本身:停用模块的历史审批链仍能打开详情, + * 而注册表的 detail_key 与 module 不一致时以注册表为准。 + */ +export const resolveDetailKey = ( + list: RegistryModule[] | undefined, + module?: string +): string | undefined => { + if (!module) { + return undefined; + } + const item = (list || []).find((entry) => entry.module === module); + return item?.detailKey || module; +}; + +/** 模块标题,注册表未命中时回退 module 本身,避免回显成空白 */ +export const resolveModuleTitle = ( + list: RegistryModule[] | undefined, + module?: string +): string | undefined => { + if (!module) { + return undefined; + } + const item = (list || []).find((entry) => entry.module === module); + return item?.title || module; +}; + +/** + * 编辑存量配置时把当前值补进下拉。 + * + * 停用模块不在启用清单里,但历史审批链仍要回显标题而不是空白/裸 module 值, + * 因此按「标题(已停用)」补一项;已经在清单里则原样返回。 + */ +export const ensureModuleOption = ( + options: ModuleOption[], + module?: string, + moduleName?: string +): ModuleOption[] => { + if (!module || options.some((item) => item.value === module)) { + return options; + } + return [{ label: `${moduleName || module}(已停用)`, value: module }, ...options]; +}; diff --git a/src/utils/module-registry.ts b/src/utils/module-registry.ts new file mode 100644 index 0000000..327569f --- /dev/null +++ b/src/utils/module-registry.ts @@ -0,0 +1,65 @@ +import { listModuleRegistry } from '@/api/gxmu/moduleRegistry'; +import type { ModuleRegistry } from '@/api/gxmu/moduleRegistry/model'; +import { + ensureModuleOption, + resolveDetailKey, + resolveModuleTitle, + toModuleOptions +} from './module-registry-core'; +import type { ModuleOption } from './module-registry-core'; + +/** + * 模块注册表缓存:首次使用时拉一次,进程内缓存;并发调用共用同一个请求。 + */ +let cache: ModuleRegistry[] = []; +let pending: Promise | null = null; + +/** 当前缓存(未加载时为空数组) */ +export const getModuleRegistryCache = (): ModuleRegistry[] => cache; + +/** 清空缓存(注册表变更后手动失效) */ +export const invalidateModuleRegistry = (): void => { + cache = []; + pending = null; +}; + +/** + * 加载模块注册表。默认命中缓存直接返回;force 为 true 时重新拉取。 + */ +export const loadModuleRegistry = async ( + force = false +): Promise => { + if (!force && cache.length) { + return cache; + } + if (!pending) { + pending = listModuleRegistry() + .then((list) => { + cache = list || []; + return cache; + }) + .finally(() => { + pending = null; + }); + } + return pending; +}; + +/** 「可新建审批链」的模块下拉:只含启用模块 */ +export const enabledModuleOptions = (): ModuleOption[] => + toModuleOptions(cache); + +/** module → 详情页配置键,映射收在这一处 */ +export const detailKeyOf = (module?: string): string | undefined => + resolveDetailKey(cache, module); + +/** 模块标题,注册表未命中时回退 module 本身 */ +export const moduleTitleOf = (module?: string): string | undefined => + resolveModuleTitle(cache, module); + +/** 编辑存量配置时把停用模块补进下拉,保证标题回显 */ +export const withLegacyModuleOption = ( + options: ModuleOption[], + module?: string, + moduleName?: string +): ModuleOption[] => ensureModuleOption(options, module, moduleName); diff --git a/src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue b/src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue index d698fa4..ad0b525 100644 --- a/src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue +++ b/src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue @@ -63,6 +63,12 @@ @focus="loadReviewUsers(item.organizationId)" @update:value="updateReviewUser(index, $event)" /> + / COLLEGE_ROLE: + reviewerRule?: string; } interface ReviewFlowForm extends Omit { @@ -148,6 +156,9 @@ const reviewUserOptionsMap = ref>({}); const reviewUserLoadingMap = ref>({}); const reviewUserNameMap = ref>({}); + // 审批人规则语法见 docs/adr/0006-approval-chain-convergence.md + const reviewerRulePlaceholder = + '审批人规则:USER:12 或 COLLEGE_ROLE:COLLEGE_LEAGUE_SECRETARY,可用 ; 拼装多段,留空沿用「指定人」'; // 用户信息 const form = reactive({ @@ -190,9 +201,13 @@ }, { validator: async () => { - const invalid = form.flowItems.some((item) => !item.reviewUserId); + const invalid = form.flowItems.some( + (item) => !item.reviewUserId && !item.reviewerRule?.trim() + ); if (invalid) { - return Promise.reject(new Error('请为每一级选择审核人')); + return Promise.reject( + new Error('请为每一级选择审核人或填写审批人规则') + ); } return Promise.resolve(); }, @@ -357,6 +372,12 @@ syncOrganizationIds(); }; + const updateReviewerRule = (index: number, reviewerRule?: string) => { + const items = [...form.flowItems]; + items[index] = { ...items[index], reviewerRule }; + form.flowItems = items; + }; + const getSortedFlows = () => { if (props.data?.flows?.length) { return [...props.data.flows].sort( @@ -399,6 +420,7 @@ title: form.title, organizationId: item.organizationId, reviewUserId: item.reviewUserId, + reviewerRule: item.reviewerRule?.trim() || undefined, level: index + 1 }; delete (formData as ReviewFlow & { organizationIds?: number[] }) @@ -452,7 +474,8 @@ .filter((item) => typeof item.organizationId === 'number') .map((item) => ({ organizationId: item.organizationId, - reviewUserId: item.reviewUserId + reviewUserId: item.reviewUserId, + reviewerRule: item.reviewerRule })); syncOrganizationIds(); await preloadReviewUsers(); @@ -503,7 +526,10 @@ } .flow-step-reviewer { - width: 220px; + display: flex; + flex-direction: column; + gap: 6px; + width: 320px; flex-shrink: 0; } diff --git a/src/views/gxmu/reviewFlowConfig/components/reviewFlowConfigEdit.vue b/src/views/gxmu/reviewFlowConfig/components/reviewFlowConfigEdit.vue index 3ce1b62..d6431ea 100644 --- a/src/views/gxmu/reviewFlowConfig/components/reviewFlowConfigEdit.vue +++ b/src/views/gxmu/reviewFlowConfig/components/reviewFlowConfigEdit.vue @@ -55,6 +55,7 @@ allow-clear show-search :options="moduleOptions" + :loading="moduleLoading" :filter-option="filterSelectOption" placeholder="请选择模块" v-model:value="form.module" @@ -81,6 +82,12 @@ import type { ReviewFlow } from '@/api/gxmu/reviewFlow/model'; import { listOrganizations } from '@/api/system/organization'; import type { Organization } from '@/api/system/organization/model'; + import { + enabledModuleOptions, + loadModuleRegistry, + withLegacyModuleOption + } from '@/utils/module-registry'; + import type { ModuleOption } from '@/utils/module-registry-core'; // 是否是修改 const isUpdate = ref(false); @@ -111,18 +118,9 @@ const reviewFlowOptions = ref<{ label: string; value: number }[]>([]); const organizationLoading = ref(false); const organizationTreeData = ref([]); - const moduleOptions = [ - { label: '稿件', value: 'cms_manuscript' }, - { label: '十佳青年岗位能手', value: 'gxmu_sjqn' }, - { label: '十佳团支部书记', value: 'gxmu_sjtbzbsj' }, - { label: '五四红旗团委', value: 'gxmu_wshqtw' }, - { label: '五四红旗团支部', value: 'gxmu_wshqtzb' }, - { label: '优秀共青团干部', value: 'gxmu_yxgqtdgb' }, - { label: '优秀共青团员', value: 'gxmu_yxgqty' }, - { label: '挑战杯', value: 'gxmu_tzbcy_form' }, - { label: '青马工程', value: 'gxmu_qmgc_form' }, - { label: '未来学术之星', value: 'gxmu_wxxzx_project' } - ]; + // 模块下拉来自模块注册表(只含启用模块),不再维护前端硬编码清单 + const moduleLoading = ref(false); + const moduleOptions = ref([]); // 用户信息 const form = reactive({ @@ -204,6 +202,28 @@ } }; + /** + * 加载模块下拉。 + * + * 注册表接口只返回启用模块,编辑停用模块的历史审批链时把当前值补成 + * 「标题(已停用)」,保证回显不空白。 + */ + const loadModuleOptions = async (module?: string, moduleName?: string) => { + moduleLoading.value = true; + try { + await loadModuleRegistry(); + moduleOptions.value = withLegacyModuleOption( + enabledModuleOptions(), + module, + moduleName + ); + } catch (e: any) { + message.error(e.message || '模块清单加载失败'); + } finally { + moduleLoading.value = false; + } + }; + const loadOrganizations = async () => { organizationLoading.value = true; try { @@ -273,9 +293,11 @@ } if (props.data) { assignObject(form, props.data); + await loadModuleOptions(props.data.module, props.data.moduleName); isUpdate.value = true; } else { resetFields(); + await loadModuleOptions(); isUpdate.value = false; } } else { diff --git a/src/views/gxmu/reviewList/components/moduleDetailConfig.ts b/src/views/gxmu/reviewList/components/moduleDetailConfig.ts index 44b66da..20de575 100644 --- a/src/views/gxmu/reviewList/components/moduleDetailConfig.ts +++ b/src/views/gxmu/reviewList/components/moduleDetailConfig.ts @@ -5,6 +5,8 @@ * 中展示的附件字段,未列出的字段会兜底追加到「其他信息」分组,避免数据丢失。 */ +import { detailKeyOf } from '@/utils/module-registry'; + export interface ModuleSection { // 分组标题 title: string; @@ -896,9 +898,19 @@ export const TABLE_FIELD_LABELS: Record = { year: '年份' }; +/** + * 取模块的详情页配置。 + * + * module → detailKey 的映射只走 detailKeyOf 一处(注册表 detail_key); + * 注册表未命中时回退 module 本身,停用模块的历史记录仍能打开详情。 + */ export const getModuleDetailConfig = (module?: string) => { if (!module) { return undefined; } - return MODULE_DETAIL_CONFIGS[module]; + const detailKey = detailKeyOf(module); + return ( + (detailKey && MODULE_DETAIL_CONFIGS[detailKey]) || + MODULE_DETAIL_CONFIGS[module] + ); }; diff --git a/src/views/gxmu/reviewList/components/search.vue b/src/views/gxmu/reviewList/components/search.vue index 06e50fa..01dbf49 100644 --- a/src/views/gxmu/reviewList/components/search.vue +++ b/src/views/gxmu/reviewList/components/search.vue @@ -20,8 +20,14 @@