Files
tuanwei-admin/src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue
T
weicw 74f801a081 feat(gxmu): 模块下拉改由模块注册表驱动,审批链每级支持审批人规则
- 新增模块注册表 api 与进程内缓存(首次使用拉一次、并发共用同一个请求),
  启用清单驱动「可新建审批链」的模块下拉与审核列表模块筛选
- reviewFlowConfigEdit 不再维护硬编码模块清单;停用模块的历史配置按
  「标题(已停用)」回显,避免下拉空白
- module → detailKey 的映射收敛到 detailKeyOf 一处,moduleDetailConfig
  未命中注册表时回退 module 本身(停用模块的详情仍可打开)
- 审核流每级新增「审批人规则」输入(USER:<userId> / COLLEGE_ROLE:<roleCode>,
  分号拼装多段,留空沿用「指定人」),随该级提交 reviewerRule 并回显
- 新增 scripts/selfcheck-module-registry.mjs:前端无单测基建时验证下拉过滤、
  detailKey 回退与停用模块回显
2026-11-03 22:40:43 +08:00

536 lines
16 KiB
Vue

<!-- 编辑弹窗 -->
<template>
<ele-modal
:width="800"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:confirm-loading="loading"
:title="isUpdate ? '编辑审核流' : '添加审核流'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 4, sm: 5, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="名称" name="title">
<a-input
allow-clear
placeholder="请输入名称"
v-model:value="form.title"
/>
</a-form-item>
<a-form-item label="机构" name="organizationIds">
<a-tree-select
multiple
allow-clear
tree-default-expand-all
:tree-data="organizationTreeData"
:value="form.organizationIds"
:loading="organizationLoading"
:dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
placeholder="请选择审核机构"
@update:value="updateOrganizationIds"
/>
</a-form-item>
<a-form-item label="审核顺序">
<div class="flow-step-list">
<div
v-for="(item, index) in form.flowItems"
:key="item.organizationId"
class="flow-step-item"
>
<div class="flow-step-info">
<a-tag color="blue">第{{ index + 1 }}级</a-tag>
<span>{{ getOrganizationName(item.organizationId) }}</span>
</div>
<div class="flow-step-reviewer">
<a-select
allow-clear
show-search
:value="item.reviewUserId"
:loading="reviewUserLoadingMap[item.organizationId || 0]"
:options="getReviewUserOptions(item.organizationId)"
:filter-option="filterReviewUserOption"
placeholder="请选择审核人"
@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
size="small"
:disabled="index === 0"
@click="moveOrganization(index, 'up')"
>
上移
</a-button>
<a-button
size="small"
:disabled="index === form.flowItems.length - 1"
@click="moveOrganization(index, 'down')"
>
下移
</a-button>
<a-button size="small" danger @click="removeOrganization(index)">
移除
</a-button>
</a-space>
</div>
<a-empty v-if="!form.flowItems.length" description="请选择审核机构" />
</div>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject } from 'ele-admin-pro';
import { toTreeData } from 'ele-admin-pro/es';
import { addReviewFlow, removeReviewFlow } from '@/api/gxmu/reviewFlow';
import { ReviewFlow } from '@/api/gxmu/reviewFlow/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
import { listOrganizations } from '@/api/system/organization';
import type { Organization } from '@/api/system/organization/model';
import { listUsers } from '@/api/system/user';
import type { User } from '@/api/system/user/model';
interface ReviewFlowItem {
organizationId?: number;
reviewUserId?: number;
// 审批人规则:';' 分隔可拼装多段,USER:<userId> / COLLEGE_ROLE:<roleCode>
reviewerRule?: string;
}
interface ReviewFlowForm extends Omit<ReviewFlow, 'reviewUserId'> {
organizationIds: number[];
flowItems: ReviewFlowItem[];
}
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: ReviewFlow | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 表格选中数据
const formRef = ref<FormInstance | null>(null);
// 机构树数据
const organizationTreeData = ref<Organization[]>([]);
const organizationLoading = ref(false);
const organizationNameMap = ref<Record<number, string>>({});
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>({
id: undefined,
title: undefined,
organizationId: undefined,
organizationIds: [],
flowItems: [],
level: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
userId: undefined
});
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
title: [
{
required: true,
type: 'string',
whitespace: true,
message: '请输入名称',
trigger: 'blur'
}
],
organizationIds: [
{
required: true,
type: 'array',
min: 1,
message: '请选择至少一个机构',
trigger: 'change'
},
{
validator: async () => {
const invalid = form.flowItems.some(
(item) => !item.reviewUserId && !item.reviewerRule?.trim()
);
if (invalid) {
return Promise.reject(
new Error('请为每一级选择审核人或填写审批人规则')
);
}
return Promise.resolve();
},
trigger: 'change'
}
]
});
const { resetFields } = useForm(form, rules);
const syncOrganizationIds = () => {
form.organizationIds = form.flowItems
.map((item) => item.organizationId)
.filter((item): item is number => typeof item === 'number');
};
const loadOrganizations = async () => {
organizationLoading.value = true;
try {
const result = await listOrganizations();
organizationNameMap.value = result.reduce<Record<number, string>>(
(map, item) => {
if (item.organizationId) {
map[item.organizationId] =
item.organizationName || `机构${item.organizationId}`;
}
return map;
},
{}
);
organizationTreeData.value = toTreeData({
data: result.map((item) => ({
...item,
key: item.organizationId,
value: item.organizationId,
title: item.organizationName
})),
idField: 'organizationId',
parentIdField: 'parentId'
}) as Organization[];
} catch (e: any) {
message.error(e.message || '机构列表加载失败');
} finally {
organizationLoading.value = false;
}
};
const updateOrganizationIds = (values?: number[]) => {
const selectedIds = values || [];
const selectedSet = new Set(selectedIds);
const flowItemMap = new Map(
form.flowItems.map((item) => [item.organizationId, item] as const)
);
const keptItems = form.flowItems.filter((item) =>
selectedSet.has(item.organizationId || 0)
);
const appendItems = selectedIds
.filter((id) => !flowItemMap.has(id))
.map((organizationId) => ({
organizationId,
reviewUserId: undefined
}));
form.flowItems = [...keptItems, ...appendItems];
syncOrganizationIds();
};
const moveOrganization = (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= form.flowItems.length) {
return;
}
const items = [...form.flowItems];
[items[index], items[targetIndex]] = [items[targetIndex], items[index]];
form.flowItems = items;
syncOrganizationIds();
};
const removeOrganization = (index: number) => {
form.flowItems = form.flowItems.filter((_, i) => i !== index);
syncOrganizationIds();
};
const getOrganizationName = (organizationId?: number) => {
if (!organizationId) {
return '-';
}
return organizationNameMap.value[organizationId] || `机构${organizationId}`;
};
const loadReviewUsers = async (organizationId?: number) => {
if (!organizationId || reviewUserOptionsMap.value[organizationId]) {
return;
}
reviewUserLoadingMap.value = {
...reviewUserLoadingMap.value,
[organizationId]: true
};
try {
const result = await listUsers({ organizationId });
reviewUserOptionsMap.value = {
...reviewUserOptionsMap.value,
[organizationId]: result
};
reviewUserNameMap.value = result.reduce<Record<number, string>>(
(map, item) => {
if (item.userId) {
map[item.userId] =
item.realName || item.nickname || `用户${item.userId}`;
}
return map;
},
reviewUserNameMap.value
);
} catch (e: any) {
message.error(e.message || '审核人列表加载失败');
} finally {
reviewUserLoadingMap.value = {
...reviewUserLoadingMap.value,
[organizationId]: false
};
}
};
const getReviewUserOptions = (organizationId?: number) => {
if (!organizationId) {
return [];
}
return (reviewUserOptionsMap.value[organizationId] || []).map((item) => ({
value: item.userId,
label: item.realName || item.nickname || `用户${item.userId}`
}));
};
const filterReviewUserOption = (input: string, option: any) => {
return String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase());
};
const updateReviewUser = (index: number, reviewUserId?: number) => {
const items = [...form.flowItems];
const organizationId = items[index]?.organizationId;
const currentUser = organizationId
? reviewUserOptionsMap.value[organizationId]?.find(
(item) => item.userId === reviewUserId
)
: undefined;
if (reviewUserId && currentUser?.userId) {
reviewUserNameMap.value = {
...reviewUserNameMap.value,
[reviewUserId]:
currentUser.realName ||
currentUser.nickname ||
`用户${currentUser.userId}`
};
}
items[index] = {
...items[index],
reviewUserId
};
form.flowItems = items;
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(
(a, b) => (a.level || 0) - (b.level || 0)
);
}
if (props.data?.id) {
return [props.data];
}
return [];
};
const preloadReviewUsers = async () => {
for (const item of form.flowItems) {
await loadReviewUsers(item.organizationId);
}
};
/* 保存编辑 */
const save = async () => {
if (!formRef.value) {
return;
}
try {
await formRef.value.validate();
loading.value = true;
let successMessage = '';
if (isUpdate.value) {
const currentFlows = getSortedFlows();
for (const currentFlow of currentFlows) {
if (currentFlow.id) {
await removeReviewFlow(currentFlow.id);
}
}
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;
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;
successMessage = await addReviewFlow(formData);
}
}
message.success(successMessage || '保存成功');
updateVisible(false);
emit('done');
} catch (e: any) {
if (e?.errorFields) {
return;
}
message.error(e.message || '保存失败');
} finally {
loading.value = false;
}
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
if (!organizationTreeData.value.length) {
await loadOrganizations();
}
if (props.data) {
assignObject(form, props.data);
const currentFlows = getSortedFlows();
form.flowItems = currentFlows
.filter((item) => typeof item.organizationId === 'number')
.map((item) => ({
organizationId: item.organizationId,
reviewUserId: item.reviewUserId,
reviewerRule: item.reviewerRule
}));
syncOrganizationIds();
await preloadReviewUsers();
isUpdate.value = true;
} else {
resetFields();
form.organizationIds = [];
form.flowItems = [];
isUpdate.value = false;
}
} else {
resetFields();
form.organizationIds = [];
form.flowItems = [];
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.flow-step-list {
width: 100%;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 8px 12px;
background: #fafafa;
}
.flow-step-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.flow-step-item + .flow-step-item {
border-top: 1px solid #f0f0f0;
}
.flow-step-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
word-break: break-all;
}
.flow-step-reviewer {
display: flex;
flex-direction: column;
gap: 6px;
width: 320px;
flex-shrink: 0;
}
</style>