merge: #65 admin 模块清单改由注册表驱动并支持审批人规则

This commit is contained in:
2026-11-03 22:41:42 +08:00
11 changed files with 396 additions and 29 deletions
+108
View File
@@ -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 + ' 组行为');
+17
View File
@@ -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<ApiResult<ModuleRegistry[]>>(
MODULES_API_URL + '/gxmu/module-registry/list'
);
if (res.data.code === 0) {
return res.data.data || [];
}
return Promise.reject(new Error(res.data.message));
}
@@ -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;
}
+3
View File
@@ -17,6 +17,9 @@ export interface ReviewFlow {
// 租户id
tenantId?: number;
reviewUserId?: number;
// 审批人规则,';' 分隔可拼装多段:USER:<userId> / COLLEGE_ROLE:<roleCode>,
// 为空回退 reviewUserId(见 docs/adr/0006-approval-chain-convergence.md)
reviewerRule?: string;
// 创建时间
createTime?: string;
// 修改时间
@@ -16,6 +16,8 @@ export interface ReviewFlowConfig {
flowId?: number;
// 模块
module?: string;
// 模块名称(后端填充)
moduleName?: string;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
+85
View File
@@ -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];
};
+65
View File
@@ -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<ModuleRegistry[]> | null = null;
/** 当前缓存(未加载时为空数组) */
export const getModuleRegistryCache = (): ModuleRegistry[] => cache;
/** 清空缓存(注册表变更后手动失效) */
export const invalidateModuleRegistry = (): void => {
cache = [];
pending = null;
};
/**
* 加载模块注册表。默认命中缓存直接返回;force 为 true 时重新拉取。
*/
export const loadModuleRegistry = async (
force = false
): Promise<ModuleRegistry[]> => {
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);
@@ -63,6 +63,12 @@
@focus="loadReviewUsers(item.organizationId)"
@update:value="updateReviewUser(index, $event)"
/>
<a-input
allow-clear
:value="item.reviewerRule"
:placeholder="reviewerRulePlaceholder"
@update:value="updateReviewerRule(index, $event)"
/>
</div>
<a-space :size="4">
<a-button
@@ -109,6 +115,8 @@
interface ReviewFlowItem {
organizationId?: number;
reviewUserId?: number;
// 审批人规则:';' 分隔可拼装多段,USER:<userId> / COLLEGE_ROLE:<roleCode>
reviewerRule?: string;
}
interface ReviewFlowForm extends Omit<ReviewFlow, 'reviewUserId'> {
@@ -148,6 +156,9 @@
const reviewUserOptionsMap = ref<Record<number, User[]>>({});
const reviewUserLoadingMap = ref<Record<number, boolean>>({});
const reviewUserNameMap = ref<Record<number, string>>({});
// 审批人规则语法见 docs/adr/0006-approval-chain-convergence.md
const reviewerRulePlaceholder =
'审批人规则:USER:12 或 COLLEGE_ROLE:COLLEGE_LEAGUE_SECRETARY,可用 ; 拼装多段,留空沿用「指定人」';
// 用户信息
const form = reactive<ReviewFlowForm>({
@@ -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;
}
</style>
@@ -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<Organization[]>([]);
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<ModuleOption[]>([]);
// 用户信息
const form = reactive<ReviewFlowConfig>({
@@ -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 {
@@ -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<string, string> = {
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]
);
};
+18 -12
View File
@@ -20,8 +20,14 @@
</template>
<script lang="ts" setup>
import { reactive, watch } from 'vue';
import { onMounted, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import type { ReviewListParam } from '@/api/gxmu/reviewList/model';
import {
enabledModuleOptions,
loadModuleRegistry
} from '@/utils/module-registry';
import type { ModuleOption } from '@/utils/module-registry-core';
const props = withDefaults(
defineProps<{
@@ -42,17 +48,17 @@
keywords: undefined
});
const moduleOptions = [
{ 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 moduleOptions = ref<ModuleOption[]>([]);
onMounted(async () => {
try {
await loadModuleRegistry();
moduleOptions.value = enabledModuleOptions();
} catch (e: any) {
message.error(e.message || '模块清单加载失败');
}
});
const search = () => {
emit('search', {