审核管理:三个子菜单收敛为「待办审核 + 审批规则」(变体 A 落地)

按任务切菜单,不再按表切(见 docs/adr/0007):
- 待办审核(reviewList):左侧模块导航带待办数,右侧该模块申报清单,
  行内「通过 / 驳回」,顶部常驻该模块审批链(第几级、每级谁审),
  详情抽屉里看审批进度,再进「查看申报内容」
- 审批规则(reviewFlow):一个模块一行,右边是生效审批链的逐级 chip,
  可换链、可编辑链;同一模块多条绑定显式告警(后端 limit 1 只认一条)
- 删除「审核流分配管理」页与其编辑弹窗、审核列表的裸字段编辑弹窗
- 新增 utils/reviewChains.ts:把 模块 → 链 → 层级 的 join 收成一处,
  两个页面共用,避免各写一套
- 模块清单继续以注册表为唯一来源
This commit is contained in:
agent
2026-11-04 00:53:02 +08:00
parent 60ff9e2c10
commit f5dc459e64
11 changed files with 1239 additions and 1265 deletions
+18 -1
View File
@@ -1,6 +1,10 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ReviewList, ReviewListParam } from './model';
import type {
ReviewList,
ReviewListParam,
ReviewPendingSummary
} from './model';
import { MODULES_API_URL } from '@/config/setting';
/**
@@ -35,6 +39,19 @@ export async function pageGroupReviewList(params: ReviewListParam) {
return Promise.reject(new Error(res.data.message));
}
/**
* 当前用户的待办统计(按模块)
*/
export async function getPendingSummary() {
const res = await request.get<ApiResult<ReviewPendingSummary>>(
MODULES_API_URL + '/gxmu/review-list/pending-summary'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 查询审核列表列表
*/
+19 -1
View File
@@ -4,7 +4,7 @@ import type { PageParam } from '@/api';
* 审核列表
*/
export interface ReviewList {
//
//
id?: number;
// 模块
module?: string;
@@ -39,6 +39,20 @@ export interface ReviewList {
reviewFlowList?: ReviewList[];
}
/**
* 当前用户待办统计(按模块)
*/
export interface ReviewPendingSummary {
// 待办总数
total: number;
// 按模块分组的待办数
modules: {
module: string;
moduleName: string;
count: number;
}[];
}
/**
* 审核列表搜索条件
*/
@@ -48,4 +62,8 @@ export interface ReviewListParam extends PageParam {
keywords?: string;
module?: string;
pk?: number;
// 状态(0待审核 1通过 2驳回)
status?: number;
// 审批人
userId?: number;
}
@@ -1,42 +0,0 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon>
<PlusOutlined />
</template>
<span>添加</span>
</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue';
import type { GradeParam } from '@/api/user/grade/model';
import { watch } from 'vue';
const props = withDefaults(
defineProps<{
// 选中的角色
selection?: [];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: GradeParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
}>();
// 新增
const add = () => {
emit('add');
};
watch(
() => props.selection,
() => {}
);
</script>
+298 -203
View File
@@ -1,222 +1,317 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<a-image :src="record.image" :width="50" />
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<div class="rr">
<header class="rr-head">
<div>
<h2 class="rr-head__title">审批规则</h2>
<p class="rr-head__sub">
一个模块对应一条审批链。链里每一级写「谁审」——按学院角色解析时,一条链覆盖全部学院。
</p>
</div>
<a-button type="primary" @click="openEditor(undefined)"
>新建审批链</a-button
>
</header>
<!-- 编辑弹窗 -->
<ReviewFlowEdit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</div>
<a-alert
v-if="conflicts.length"
type="warning"
show-icon
class="rr-alert"
:message="
conflicts.length +
' 个模块绑定了多条审批链,只有 id 最小的一条生效,请清理'
"
/>
<a-card :bordered="false" :body-style="{ padding: '8px 16px 16px' }">
<a-spin :spinning="loading">
<a-table
:columns="columns"
:data-source="rows"
:pagination="false"
row-key="module"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'module'">
<div class="rr-module">
<b>{{ record.label }}</b>
<span>{{ record.module }}</span>
</div>
</template>
<template v-else-if="column.key === 'chain'">
<div v-if="record.chain" class="rr-chain">
<div class="rr-chain__head">
<span class="rr-chain__name">{{ record.chain.title }}</span>
<a-tag v-if="record.chain.conflict" color="orange">
{{ record.configCount }} 条绑定
</a-tag>
</div>
<div class="rr-chain__levels">
<span
v-for="(lv, i) in record.chain.levels"
:key="lv.id || i"
class="rr-lv"
>
<em>{{ i + 1 }}</em>
<b>{{ levelName(lv) }}</b>
<u v-if="ruleText(lv.reviewerRule)">{{
ruleText(lv.reviewerRule)
}}</u>
</span>
</div>
</div>
<div v-else class="rr-empty">
未绑定审批链,沿用旧版固定审批人(该模块的申报仍会出现在待办里)
</div>
</template>
<template v-else-if="column.key === 'action'">
<a-space :size="12">
<a v-if="record.chain" @click="openEditor(record.chain.flowId)">
编辑链
</a>
<a-dropdown v-if="record.configId">
<a @click.prevent>换链</a>
<template #overlay>
<a-menu @click="(e: any) => changeChain(record, e.key)">
<a-menu-item
v-for="f in flowOptions"
:key="String(f.value)"
:disabled="f.value === record.chain?.flowId"
>
{{ f.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-tooltip
v-else
title="该模块还没有绑定记录,暂不支持在这里新建绑定"
>
<span class="rr-dim">换链</span>
</a-tooltip>
</a-space>
</template>
</template>
</a-table>
</a-spin>
</a-card>
<review-flow-edit
v-model:visible="editorOpen"
:data="editing"
@done="onSaved"
/>
</div>
</template>
<script lang="ts" setup>
import { createVNode, ref } from "vue";
import { message, Modal } from "ant-design-vue";
import { ExclamationCircleOutlined } from "@ant-design/icons-vue";
import type { EleProTable } from "ele-admin-pro";
import { toDateString } from "ele-admin-pro";
import type {
DatasourceFunction,
ColumnItem
} from "ele-admin-pro/es/ele-pro-table/types";
import Search from "./components/search.vue";
import ReviewFlowEdit from "./components/reviewFlowEdit.vue";
import { pageReviewFlow, removeReviewFlow, removeBatchReviewFlow } from "@/api/gxmu/reviewFlow";
import type { ReviewFlow, ReviewFlowParam } from "@/api/gxmu/reviewFlow/model";
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import ReviewFlowEdit from '../reviewFlow/components/reviewFlowEdit.vue';
import type { ReviewFlow } from '@/api/gxmu/reviewFlow/model';
import { updateReviewFlowConfig } from '@/api/gxmu/reviewFlowConfig';
import type { ReviewFlowConfig } from '@/api/gxmu/reviewFlowConfig/model';
import { loadModuleRegistry } from '@/utils/module-registry';
import { toAllModuleOptions } from '@/utils/module-registry-core';
import type { ModuleOption } from '@/utils/module-registry-core';
import {
chainOf,
configsOf,
levelName,
loadReviewChains,
ruleText,
useReviewChains
} from '@/views/gxmu/utils/reviewChains';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
interface RuleRow {
module: string;
label: string;
disabled?: boolean;
configId?: number;
configCount: number;
chain?: ReturnType<typeof chainOf>;
}
// 表格选中数据
const selection = ref<ReviewFlow[]>([]);
// 当前编辑数据
const current = ref<ReviewFlow | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 加载状态
const loading = ref(true);
const { flowGroups, loading } = useReviewChains();
const modules = ref<ModuleOption[]>([]);
const editorOpen = ref(false);
const editing = ref<ReviewFlow | null>(null);
const rows = ref<RuleRow[]>([]);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
}
where.withGroup = true;
return pageReviewFlow({
...where,
...orders,
page,
limit
const columns: ColumnItem[] = [
{ title: '模块', key: 'module', width: 260 },
{ title: '生效审批链(按顺序)', key: 'chain' },
{ title: '操作', key: 'action', width: 190 }
];
const flowOptions = computed(() =>
flowGroups.value
.filter((f) => !!f.title)
.map((f) => ({ label: f.title as string, value: f.id as number }))
);
const conflicts = computed(() => rows.value.filter((r) => r.configCount > 1));
const buildRows = () => {
rows.value = modules.value.map((m) => {
const list = configsOf(m.value);
return {
module: m.value,
label: m.label,
configId: list.length ? list[0].id : undefined,
configCount: list.length,
chain: chainOf(m.value)
};
});
};
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: "ID",
dataIndex: "id",
key: "id",
align: "center",
width: 90
},
{
title: "名称",
dataIndex: "title",
key: "title",
align: "center"
},
{
title: "创建时间",
dataIndex: "createTime",
key: "createTime",
align: "center",
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, "yyyy-MM-dd HH:mm:ss")
},
{
title: "操作",
key: "action",
width: 180,
fixed: "right",
align: "center",
hideInSetting: true
const load = async (force = false) => {
try {
await loadReviewChains(force);
// 含停用模块:停用模块的存量审批链仍要能看到、能改
modules.value = toAllModuleOptions(await loadModuleRegistry());
buildRows();
} catch (e: any) {
message.error(e.message || '审批规则加载失败');
}
]);
};
/* 搜索 */
const reload = (where?: ReviewFlowParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
onMounted(() => load());
/* 打开编辑弹窗 */
const openEdit = (row?: ReviewFlow) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/* 删除单个 */
const remove = (row: ReviewFlow) => {
const hide = message.loading("请求中..", 0);
removeReviewFlow(row.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error("请至少选择一条数据");
return;
const openEditor = (flowId?: number) => {
if (flowId == null) {
editing.value = null;
editorOpen.value = true;
return;
}
Modal.confirm({
title: "提示",
content: "确定要删除选中的记录吗?",
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading("请求中..", 0);
removeBatchReviewFlow(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
const group = flowGroups.value.find((g) => g.id === flowId);
if (!group) {
message.warning('这条审批链已不存在,请刷新后重试');
return;
}
editing.value = group;
editorOpen.value = true;
};
/* 查询 */
const query = () => {
loading.value = true;
};
const changeChain = async (row: RuleRow, flowId: string | number) => {
const target = configsOf(row.module)[0];
if (!target) {
return;
}
try {
const payload: ReviewFlowConfig = { ...target, flowId: Number(flowId) };
await updateReviewFlowConfig(payload);
message.success('已切换审批链,此后新提交的申报按新链走');
await load(true);
} catch (e: any) {
message.error(e.message);
}
};
/* 自定义行属性 */
const customRow = (record: ReviewFlow) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
query();
const onSaved = async () => {
// 链改完,分组的 title / 层级都变了,必须强制重取
await load(true);
};
</script>
<script lang="ts">
export default {
name: "ReviewFlow"
};
</script>
<style lang="less" scoped>
.rr-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
<style lang="less" scoped></style>
.rr-head__title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.rr-head__sub {
margin: 6px 0 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
max-width: 720px;
line-height: 1.7;
}
.rr-alert {
margin-bottom: 12px;
}
.rr-module {
display: flex;
flex-direction: column;
gap: 2px;
}
.rr-module span {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.rr-tag {
align-self: flex-start;
margin-top: 2px;
}
.rr-chain__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.rr-chain__name {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.rr-chain__levels {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rr-lv {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: 6px;
background: #f5f7fa;
font-size: 12px;
}
.rr-lv em {
font-style: normal;
width: 16px;
height: 16px;
border-radius: 50%;
background: #1590ff;
color: #fff;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.rr-lv u {
text-decoration: none;
color: rgba(0, 0, 0, 0.45);
}
.rr-empty {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.rr-dim {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
</style>
@@ -1,334 +0,0 @@
<!-- 编辑弹窗 -->
<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"
>
<module-registry-notice
:error="moduleLoadError"
@retry="loadModuleOptions(form.module, form.moduleName, true)"
/>
<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="flowId">
<a-select
allow-clear
show-search
:options="reviewFlowOptions"
:loading="reviewFlowLoading"
:filter-option="filterSelectOption"
placeholder="请选择审核流"
v-model:value="form.flowId"
/>
</a-form-item>
<a-form-item label="所属部门" name="organizationId">
<a-tree-select
allow-clear
show-search
tree-default-expand-all
:tree-data="organizationTreeData"
:loading="organizationLoading"
:dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
placeholder="请选择所属部门"
v-model:value="form.organizationId"
/>
</a-form-item>
<a-form-item label="模块" name="module">
<a-select
allow-clear
show-search
:options="moduleOptions"
:loading="moduleLoading"
:filter-option="filterSelectOption"
placeholder="请选择模块"
v-model:value="form.module"
/>
</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 {
addReviewFlowConfig,
updateReviewFlowConfig
} from '@/api/gxmu/reviewFlowConfig';
import { ReviewFlowConfig } from '@/api/gxmu/reviewFlowConfig/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
import { listReviewFlow } from '@/api/gxmu/reviewFlow';
import type { ReviewFlow } from '@/api/gxmu/reviewFlow/model';
import { listOrganizations } from '@/api/system/organization';
import type { Organization } from '@/api/system/organization/model';
import {
enabledModuleOptions,
isModuleRegistryStale,
loadModuleRegistry,
withLegacyModuleOption
} from '@/utils/module-registry';
import type { ModuleOption } from '@/utils/module-registry-core';
import ModuleRegistryNotice from '@/views/gxmu/components/moduleRegistryNotice.vue';
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: ReviewFlowConfig | 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 reviewFlowLoading = ref(false);
const reviewFlowOptions = ref<{ label: string; value: number }[]>([]);
const organizationLoading = ref(false);
const organizationTreeData = ref<Organization[]>([]);
// 模块下拉来自模块注册表(只含启用模块),不再维护前端硬编码清单
const moduleLoading = ref(false);
const moduleOptions = ref<ModuleOption[]>([]);
const moduleLoadError = ref<string>();
// 用户信息
const form = reactive<ReviewFlowConfig>({
id: undefined,
title: undefined,
organizationId: undefined,
flowId: undefined,
module: 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'
}
],
flowId: [
{
required: true,
type: 'number',
message: '请选择审核流',
trigger: 'change'
}
],
organizationId: [
{
required: true,
type: 'number',
message: '请选择所属部门',
trigger: 'change'
}
],
module: [
{
required: true,
message: '请选择模块',
trigger: 'change'
}
]
});
const { resetFields } = useForm(form, rules);
const loadReviewFlowOptions = async () => {
reviewFlowLoading.value = true;
try {
const result = await listReviewFlow({ withGroup: true });
const optionMap = new Map<number, string>();
result.forEach((item: ReviewFlow) => {
if (item.id && item.title && !optionMap.has(item.id)) {
optionMap.set(item.id, item.title);
}
});
reviewFlowOptions.value = Array.from(optionMap.entries()).map(
([value, label]) => ({
label,
value
})
);
} catch (e: any) {
message.error(e.message || '审核流列表加载失败');
} finally {
reviewFlowLoading.value = false;
}
};
/**
* 加载模块下拉(审批链配置的下拉只含启用模块)。
*
* 注册表快照显式传入,不再读模块级可变缓存;编辑停用模块的历史审批链时把
* 当前值补成「标题(已停用)」,保证回显不空白。拉取失败时保留上次成功的
* 清单并在顶部提示 + 重试,不静默留空下拉。
*/
const loadModuleOptions = async (
module?: string,
moduleName?: string,
force = false
) => {
moduleLoading.value = true;
moduleLoadError.value = undefined;
try {
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
);
moduleLoadError.value = moduleOptions.value.length
? '模块清单加载失败,已保留上次成功加载的清单'
: '模块清单加载失败,请重试';
message.warning(moduleLoadError.value);
} finally {
moduleLoading.value = false;
}
};
const loadOrganizations = async () => {
organizationLoading.value = true;
try {
const result = await listOrganizations();
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 filterSelectOption = (input: string, option: any) => {
return String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase());
};
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const formData = {
...form
};
const saveOrUpdate = isUpdate.value
? updateReviewFlowConfig
: addReviewFlowConfig;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
if (!organizationTreeData.value.length) {
await loadOrganizations();
}
if (!reviewFlowOptions.value.length) {
await loadReviewFlowOptions();
}
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 {
resetFields();
}
},
{ immediate: true }
);
</script>
@@ -1,42 +0,0 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon>
<PlusOutlined />
</template>
<span>添加</span>
</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue';
import type { GradeParam } from '@/api/user/grade/model';
import { watch } from 'vue';
const props = withDefaults(
defineProps<{
// 选中的角色
selection?: [];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: GradeParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
}>();
// 新增
const add = () => {
emit('add');
};
watch(
() => props.selection,
() => {}
);
</script>
-233
View File
@@ -1,233 +0,0 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<a-image :src="record.image" :width="50" />
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<ReviewFlowConfigEdit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</div>
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import ReviewFlowConfigEdit from './components/reviewFlowConfigEdit.vue';
import { pageReviewFlowConfig, removeReviewFlowConfig, removeBatchReviewFlowConfig } from '@/api/gxmu/reviewFlowConfig';
import type { ReviewFlowConfig, ReviewFlowConfigParam } from '@/api/gxmu/reviewFlowConfig/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<ReviewFlowConfig[]>([]);
// 当前编辑数据
const current = ref<ReviewFlowConfig | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 加载状态
const loading = ref(true);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
}
return pageReviewFlowConfig({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90,
},
{
title: '名称',
dataIndex: 'title',
key: 'title',
align: 'center',
},
{
title: '审核流',
dataIndex: 'flowName',
key: 'flowId',
align: 'center',
},
{
title: '模块',
dataIndex: 'moduleName',
key: 'module',
align: 'center',
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: ReviewFlowConfigParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: ReviewFlowConfig) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/* 删除单个 */
const remove = (row: ReviewFlowConfig) => {
const hide = message.loading('请求中..', 0);
removeReviewFlowConfig(row.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchReviewFlowConfig(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 查询 */
const query = () => {
loading.value = true;
};
/* 自定义行属性 */
const customRow = (record: ReviewFlowConfig) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
query();
</script>
<script lang="ts">
export default {
name: 'ReviewFlowConfig'
};
</script>
<style lang="less" scoped></style>
@@ -1,191 +0,0 @@
<!-- 编辑弹窗 -->
<template>
<ele-modal
:width="800"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
: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="module">
<a-input
allow-clear
placeholder="请输入模块"
v-model:value="form.module"
/>
</a-form-item>
<a-form-item label="主键" name="pk">
<a-input
allow-clear
placeholder="请输入主键"
v-model:value="form.pk"
/>
</a-form-item>
<a-form-item label="状态(0驳回 1通过)" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">显示</a-radio>
<a-radio :value="0">隐藏</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="审核意见" name="content">
<a-input
allow-clear
placeholder="请输入审核意见"
v-model:value="form.content"
/>
</a-form-item>
<a-form-item label="是否删除, 0否, 1是" name="deleted">
<a-input
allow-clear
placeholder="请输入是否删除, 0否, 1是"
v-model:value="form.deleted"
/>
</a-form-item>
<a-form-item label="修改时间" name="updateTime">
<a-input
allow-clear
placeholder="请输入修改时间"
v-model:value="form.updateTime"
/>
</a-form-item>
<a-form-item label="" name="userId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.userId"
/>
</a-form-item>
<a-form-item label="" name="sortNumber">
<a-input-number
:min="0"
:max="9999"
class="ele-fluid"
placeholder="请输入排序号"
v-model:value="form.sortNumber"
/>
</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, uuid } from 'ele-admin-pro';
import { addReviewList, updateReviewList } from '@/api/gxmu/reviewList';
import { ReviewList } from '@/api/gxmu/reviewList/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
import { FileRecord } from '@/api/system/file/model';
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: ReviewList | 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 form = reactive<ReviewList>({
id: undefined,
module: undefined,
pk: undefined,
status: undefined,
content: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
userId: undefined,
sortNumber: undefined,
});
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
});
const { resetFields } = useForm(form, rules);
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const formData = {
...form
};
const saveOrUpdate = isUpdate.value ? updateReviewList : addReviewList;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
assignObject(form, props.data);
isUpdate.value = true;
} else {
isUpdate.value = false;
}
} else {
resetFields();
}
},
{ immediate: true }
);
</script>
+24 -57
View File
@@ -1,81 +1,47 @@
<template>
<div>
<div class="review-search">
<module-registry-notice
:error="moduleLoadError"
@retry="loadModuleOptions(true)"
/>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="where.module"
allow-clear
placeholder="请选择模块"
style="width: 220px"
:options="moduleOptions"
@change="search"
/>
<a-input-search
v-model:value="where.keywords"
allow-clear
placeholder="请输入名称/审核意见"
style="width: 260px"
@search="search"
@pressEnter="search"
/>
</a-space>
<a-input-search
v-model:value="keywords"
allow-clear
placeholder="搜索申报名称 / 审核意见"
style="width: 300px"
@search="search"
@pressEnter="search"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, watch } from 'vue';
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { ReviewListParam } from '@/api/gxmu/reviewList/model';
import {
allModuleOptions,
isModuleRegistryStale,
loadModuleRegistry
} from '@/utils/module-registry';
import type { ModuleOption } from '@/utils/module-registry-core';
import ModuleRegistryNotice from '@/views/gxmu/components/moduleRegistryNotice.vue';
const props = withDefaults(
defineProps<{
selection?: [];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: ReviewListParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
}>();
const where = reactive<ReviewListParam>({
module: undefined,
keywords: undefined
});
// 模块筛选项来自模块注册表的全量清单(含停用模块,标题带「(已停用)」):
// 审核列表是「查存量」的入口,停用模块的历史记录仍要能按模块筛出来
const moduleOptions = ref<ModuleOption[]>([]);
const keywords = ref<string>();
const moduleLoadError = ref<string>();
// 模块标题(列表里的模块名、左侧导航都来自注册表);拉不到时保留上次成功的快照并提示重试
const loadModuleOptions = async (force = false) => {
moduleLoadError.value = undefined;
try {
const list = await loadModuleRegistry(force);
moduleOptions.value = allModuleOptions(list);
// 命中「上次成功」的兜底快照:只给顶部提示 + 重试,不重复弹 message
await loadModuleRegistry(force);
if (isModuleRegistryStale()) {
moduleLoadError.value = '模块清单加载失败,已保留上次成功加载的清单';
}
} catch (e: any) {
// 拉取失败:保留上次成功的清单(内存 / localStorage),顶部提示 + 重试
moduleOptions.value = allModuleOptions();
moduleLoadError.value = moduleOptions.value.length
? '模块清单加载失败,已保留上次成功加载的清单'
: '模块清单加载失败,请重试';
moduleLoadError.value = '模块清单加载失败,请重试';
message.warning(moduleLoadError.value);
}
};
@@ -83,14 +49,15 @@
onMounted(() => loadModuleOptions());
const search = () => {
emit('search', {
...where,
keywords: where.keywords?.trim() || undefined
});
emit('search', { keywords: keywords.value?.trim() || undefined });
};
watch(
() => props.selection,
() => {}
);
</script>
<style lang="less" scoped>
.review-search {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
</style>
+698 -161
View File
@@ -1,6 +1,80 @@
<template>
<div class="page">
<div class="ele-body">
<div class="rw">
<!-- 左:模块导航。审核人每天先看「哪个模块有活」 -->
<aside class="rw-side">
<div class="rw-side__title">审核管理</div>
<button
class="rw-mod"
:class="{ 'is-active': activeModule === 'all' }"
@click="selectModule('all')"
>
<span class="rw-mod__name">全部申报</span>
<a-badge
v-if="summary.total"
:count="summary.total"
:overflow-count="99"
/>
</button>
<button
v-for="m in moduleList"
:key="m.value"
class="rw-mod"
:class="{ 'is-active': activeModule === m.value }"
@click="selectModule(m.value)"
>
<span class="rw-mod__name">{{ m.label }}</span>
<a-badge
v-if="pendingCount(m.value)"
:count="pendingCount(m.value)"
:overflow-count="99"
/>
</button>
<div class="rw-side__foot">
<a-button v-if="canSeeRules" block @click="goRules">审批规则</a-button>
<div class="rw-side__tip">
规则决定「谁在第几级审」。改这里比逐条催单管用。
</div>
</div>
</aside>
<!-- 右:该模块的申报清单,就地审批 -->
<section class="rw-main">
<header class="rw-head">
<div>
<h2 class="rw-head__title">{{ title }}</h2>
<p class="rw-head__sub">{{ subtitle }}</p>
</div>
<a-radio-group
v-model:value="scope"
size="small"
button-style="solid"
@change="reload()"
>
<a-radio-button value="mine">待我审</a-radio-button>
<a-radio-button value="all">全部</a-radio-button>
</a-radio-group>
</header>
<div v-if="activeChain" class="rw-chain">
<span class="rw-chain__label">审批链</span>
<span class="rw-chain__name">{{ activeChain.title }}</span>
<span class="rw-chain__dot">·</span>
<template v-for="(lv, i) in activeChain.levels" :key="lv.id || i">
<span class="rw-chain__node">
{{ i + 1 }} {{ levelName(lv) }}
<em v-if="ruleText(lv.reviewerRule)">{{
ruleText(lv.reviewerRule)
}}</em>
</span>
<span v-if="i < activeChain.levels.length - 1" class="rw-chain__arrow"
>→</span
>
</template>
<a v-if="activeChain.conflict" class="rw-chain__warn" @click="goRules">
该模块有多条绑定,仅一条生效
</a>
</div>
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
@@ -8,61 +82,138 @@
:columns="columns"
:datasource="datasource"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
<search @search="onSearch" />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0">待审核</a-tag>
<a-tag v-else-if="record.status === 1" color="green">通过</a-tag>
<a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
<template v-if="column.key === 'businessName'">
<div class="rw-name">
<b>{{ record.businessName || '未命名申报 #' + record.pk }}</b>
<span>{{ record.moduleName || record.module }}</span>
</div>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDataModal(record)">查看数据</a>
<a-divider type="vertical" />
<a @click="openFlowModal(record)">查看审核流</a>
<template v-else-if="column.key === 'level'">
<span :class="['rw-level', levelClass(record)]">
{{ levelLabel(record) }}
</span>
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="statusBadge(record.status)"
:text="statusText(record.status)"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-space :size="12">
<template v-if="canHandle(record)">
<a @click="approve(record)">通过</a>
<a class="rw-danger" @click="openReject(record)">驳回</a>
</template>
<a @click="openDetail(record)">详情</a>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</section>
<ReviewListEdit
v-model:visible="showEdit"
:data="current"
@done="reload"
<!-- 详情:先看审批走到哪,再看申报内容 -->
<a-drawer
:visible="detailOpen"
:width="560"
title="申报详情"
@close="detailOpen = false"
>
<template v-if="detail">
<div class="rw-detail__head">
<h3>{{ detail.businessName || '未命名申报 #' + detail.pk }}</h3>
<a-badge
:status="statusBadge(detail.status)"
:text="statusText(detail.status)"
/>
</div>
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="模块">
{{ detail.moduleName || detail.module }}
</a-descriptions-item>
<a-descriptions-item label="提交时间">
{{ fmt(detail.createTime) }}
</a-descriptions-item>
<a-descriptions-item label="当前审批人">
{{ detail.user?.realName || '—' }}
</a-descriptions-item>
<a-descriptions-item label="当前环节">
{{ levelLabel(detail) }}
</a-descriptions-item>
<a-descriptions-item v-if="detail.content" label="最新意见" :span="2">
{{ detail.content }}
</a-descriptions-item>
</a-descriptions>
<h4 class="rw-detail__h4">审批进度</h4>
<div
v-if="detailChain && detailChain.levels.length"
class="rw-timeline"
>
<div
v-for="(lv, i) in detailChain.levels"
:key="lv.id || i"
class="rw-tl"
:class="tlClass(detail, i)"
>
<span class="rw-tl__no">{{ i + 1 }}</span>
<div class="rw-tl__body">
<b>{{ levelName(lv) }}</b>
<span v-if="ruleText(lv.reviewerRule)">
{{ ruleText(lv.reviewerRule) }}
</span>
</div>
<span class="rw-tl__state">{{ tlState(detail, i) }}</span>
</div>
</div>
<a-empty v-else description="该模块未绑定审批链,沿用旧版固定审批人" />
<div class="rw-detail__foot">
<a-button @click="openModuleData">查看申报内容</a-button>
<a-space v-if="canHandle(detail)">
<a-button @click="openReject(detail)">驳回</a-button>
<a-button type="primary" @click="approve(detail)">通过</a-button>
</a-space>
</div>
</template>
</a-drawer>
<module-data-modal
v-model:visible="dataVisible"
:module="detail?.module"
:module-name="detail?.moduleName"
:pk="detail?.pk"
@done="reload()"
/>
<a-modal
v-model:visible="rejectOpen"
title="驳回申报"
ok-text="确认驳回"
cancel-text="取消"
:confirm-loading="rejecting"
@ok="doReject"
>
<p class="rw-reject__tip"> 驳回原因会直接发给申报人,写清楚要改什么。 </p>
<a-textarea
v-model:value="rejectReason"
:rows="4"
placeholder="例如:缺少学院推荐意见,请补充后重新提交"
/>
<ModuleDataModal
v-model:visible="showDataVisible"
:module="dataCurrent?.module"
:module-name="dataCurrent?.moduleName"
:pk="dataCurrent?.pk"
@done="reload"
/>
<ReviewFlowModal
v-model:visible="showFlowVisible"
:module="flowCurrent?.module"
:module-name="flowCurrent?.moduleName"
:pk="flowCurrent?.pk"
@done="reload"
/>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
@@ -70,167 +221,553 @@
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import ReviewListEdit from './components/reviewListEdit.vue';
import ModuleDataModal from './components/moduleDataModal.vue';
import ReviewFlowModal from './components/reviewFlowModal.vue';
import {
getPendingSummary,
pageGroupReviewList,
removeBatchReviewList
pageReviewList,
updateReviewList
} from '@/api/gxmu/reviewList';
import type {
ReviewList,
ReviewListParam
ReviewListParam,
ReviewPendingSummary
} from '@/api/gxmu/reviewList/model';
import {
enabledModuleOptions,
loadModuleRegistry
} from '@/utils/module-registry';
import type { ModuleOption } from '@/utils/module-registry-core';
import {
chainOf,
levelName,
levelOf,
ruleText,
useReviewChains
} from '@/views/gxmu/utils/reviewChains';
import { useUserStore } from '@/store/modules/user';
const router = useRouter();
const userStore = useUserStore();
const { load: loadChains, loading: chainsLoading } = useReviewChains();
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const selection = ref<ReviewList[]>([]);
const current = ref<ReviewList | null>(null);
const dataCurrent = ref<ReviewList | null>(null);
const flowCurrent = ref<ReviewList | null>(null);
const showEdit = ref(false);
const showDataVisible = ref(false);
const showFlowVisible = ref(false);
const showMove = ref(false);
const loading = ref(true);
const scope = ref<'mine' | 'all'>('mine');
const activeModule = ref<string>('all');
const keywords = ref<string>();
const summary = ref<ReviewPendingSummary>({ total: 0, modules: [] });
const moduleOptions = ref<ModuleOption[]>([]);
const detail = ref<ReviewList | null>(null);
const detailOpen = ref(false);
const dataVisible = ref(false);
const rejectOpen = ref(false);
const rejectTarget = ref<ReviewList | null>(null);
const rejectReason = ref('');
const rejecting = ref(false);
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
const myUserId = computed(() => userStore.info?.userId);
// 只有拿到「审批规则」菜单的人才能看到入口:改链是校团委的事,
// 学院角色能进工作台、但不该进规则页
const canSeeRules = computed(() => {
const walk = (list?: any[]): boolean =>
(list || []).some(
(node) =>
String(node?.path || '').indexOf('reviewFlow') >= 0 ||
walk(node?.children)
);
return walk(userStore.menus || []);
});
const moduleList = computed(() => moduleOptions.value);
const pendingCount = (module: string) => {
const hit = summary.value.modules.find((m) => m.module === module);
return hit ? hit.count : 0;
};
const title = computed(() =>
activeModule.value === 'all' ? '全部申报' : labelOf(activeModule.value)
);
const activeChain = computed(() =>
activeModule.value === 'all' ? undefined : chainOf(activeModule.value)
);
const detailChain = computed(() =>
detail.value ? chainOf(detail.value.module) : undefined
);
const subtitle = computed(() => {
if (chainsLoading.value) {
return '审批链加载中…';
}
return pageGroupReviewList({
...where,
...orders,
page,
limit
});
if (activeModule.value === 'all') {
return '左侧按模块看谁有活;每条申报都显示它当前停在哪一级。';
}
if (!activeChain.value) {
return '该模块未绑定审批链,仍沿用旧版固定审批人。';
}
return (
'共 ' +
activeChain.value.levels.length +
' 级 · 待我审 ' +
pendingCount(activeModule.value) +
' 条'
);
});
const labelOf = (module: string) => {
const hit = moduleOptions.value.find((m) => m.value === module);
return hit ? hit.label : module;
};
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90
},
{
title: '模块',
dataIndex: 'moduleName',
key: 'module',
align: 'center'
},
{
title: '名称',
dataIndex: 'businessName',
key: 'businessName',
align: 'center',
ellipsis: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center'
},
{
title: '最新审核意见',
dataIndex: 'content',
key: 'content',
align: 'center',
ellipsis: true
},
{ title: '申报名称', key: 'businessName', ellipsis: true },
{ title: '当前环节', key: 'level', width: 210 },
{
title: '提交时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 160,
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '当前审核人',
dataIndex: ['user', 'realName'],
key: 'userId',
align: 'center'
customRender: ({ text }) => fmt(text)
},
{ title: '状态', key: 'status', width: 110 },
{
title: '操作',
key: 'action',
width: 220,
width: 160,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
const base: ReviewListParam = {
...where,
...orders,
page,
limit,
keywords: keywords.value,
module: activeModule.value === 'all' ? undefined : activeModule.value
};
if (scope.value === 'mine') {
// 待我审:按当前登录人取待办节点,后端仍会做归属校验
return pageReviewList({ ...base, status: 0, userId: myUserId.value });
}
return pageGroupReviewList(base);
};
const reload = (where?: ReviewListParam) => {
selection.value = [];
tableRef?.value?.reload({ where });
if (where) {
keywords.value = where.keywords;
}
tableRef.value?.reload();
};
const openEdit = (row?: ReviewList) => {
current.value = row ?? null;
showEdit.value = true;
const onSearch = (where?: ReviewListParam) => {
keywords.value = where?.keywords;
tableRef.value?.reload({ where: {} });
};
const openMove = () => {
showMove.value = true;
const selectModule = (module: string) => {
activeModule.value = module;
tableRef.value?.reload({ where: {} });
};
const openDataModal = (row: ReviewList) => {
dataCurrent.value = row;
showDataVisible.value = true;
const goRules = () => {
router.push('/review/gxmu/reviewFlow');
};
const openFlowModal = (row: ReviewList) => {
flowCurrent.value = row;
showFlowVisible.value = true;
const loadSummary = async () => {
try {
summary.value = await getPendingSummary();
} catch (e: any) {
// 统计失败不该挡住列表,导航不显示数字即可
summary.value = { total: 0, modules: [] };
}
};
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
const loadModules = async () => {
try {
const list = await loadModuleRegistry();
moduleOptions.value = enabledModuleOptions(list);
} catch (e: any) {
moduleOptions.value = enabledModuleOptions();
}
};
onMounted(async () => {
await Promise.all([loadModules(), loadSummary(), loadChains()]);
tableRef.value?.reload();
});
const fmt = (text?: string) =>
text ? toDateString(text, 'yyyy-MM-dd HH:mm') : '—';
const statusText = (status?: number) =>
status === 1 ? '已通过' : status === 2 ? '已驳回' : '待审核';
const statusBadge = (status?: number) =>
status === 1 ? 'success' : status === 2 ? 'error' : 'processing';
const canHandle = (row?: ReviewList | null) =>
!!row && row.status === 0 && row.userId === myUserId.value;
const levelLabel = (row: ReviewList) => {
if (row.status === 1) return '已通过';
if (row.status === 2) return '已驳回';
const lv = levelOf(row.module, row.sortNumber);
const index = (row.sortNumber ?? 0) + 1;
return lv ? '第 ' + index + ' 级 · ' + levelName(lv) : '待审核';
};
const levelClass = (row: ReviewList) =>
row.status === 0 ? 'rw-level--pending' : 'rw-level--done';
const tlState = (row: ReviewList, i: number) => {
const current = row.sortNumber ?? 0;
if (row.status === 2) {
if (i < current) return '通过';
return i === current ? '驳回' : '待流转';
}
if (row.status === 1 || i < current) return '通过';
return i === current ? '审核中' : '待流转';
};
const tlClass = (row: ReviewList, i: number) => {
const state = tlState(row, i);
return state === '通过'
? 'is-done'
: state === '审核中'
? 'is-now'
: state === '驳回'
? 'is-reject'
: '';
};
const openDetail = (row: ReviewList) => {
detail.value = row;
detailOpen.value = true;
};
const openModuleData = () => {
dataVisible.value = true;
};
const approve = async (row: ReviewList) => {
try {
await updateReviewList({ id: row.id, status: 1 });
message.success('已通过');
detailOpen.value = false;
tableRef.value?.reload();
await loadSummary();
} catch (e: any) {
message.error(e.message);
}
};
const openReject = (row: ReviewList) => {
rejectTarget.value = row;
rejectReason.value = '';
rejectOpen.value = true;
};
const doReject = async () => {
if (!rejectReason.value.trim()) {
message.warning('驳回必须填写意见');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchReviewList(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
const query = () => {
loading.value = true;
};
query();
</script>
<script lang="ts">
export default {
name: 'ReviewList'
if (!rejectTarget.value?.id) {
return;
}
rejecting.value = true;
try {
await updateReviewList({
id: rejectTarget.value.id,
status: 2,
content: rejectReason.value.trim()
});
message.success('已驳回');
rejectOpen.value = false;
detailOpen.value = false;
tableRef.value?.reload();
await loadSummary();
} catch (e: any) {
message.error(e.message);
} finally {
rejecting.value = false;
}
};
</script>
<style lang="less" scoped></style>
<style lang="less" scoped>
.rw {
display: flex;
gap: 12px;
padding: 12px 16px 24px;
min-height: calc(100vh - 96px);
box-sizing: border-box;
}
.rw-side {
width: 216px;
flex: 0 0 216px;
background: #fff;
border-radius: 8px;
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 2px;
align-self: flex-start;
}
.rw-side__title {
font-size: 15px;
font-weight: 600;
padding: 0 8px 10px;
}
.rw-mod {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
text-align: left;
font-size: 13px;
color: rgba(0, 0, 0, 0.72);
}
.rw-mod:hover {
background: #f5f7fa;
}
.rw-mod.is-active {
background: #e8f4ff;
color: #1590ff;
font-weight: 600;
}
.rw-mod__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rw-side__foot {
margin-top: auto;
padding-top: 14px;
border-top: 1px dashed #eef0f4;
}
.rw-side__tip {
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
color: rgba(0, 0, 0, 0.45);
}
.rw-main {
flex: 1;
min-width: 0;
}
.rw-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.rw-head__title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.rw-head__sub {
margin: 6px 0 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.rw-chain {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 8px;
background: #fff;
font-size: 12px;
}
.rw-chain__label {
color: rgba(0, 0, 0, 0.45);
}
.rw-chain__name {
font-weight: 600;
}
.rw-chain__dot,
.rw-chain__arrow {
color: rgba(0, 0, 0, 0.25);
}
.rw-chain__node {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
background: #f5f7fa;
color: rgba(0, 0, 0, 0.72);
}
.rw-chain__node em {
font-style: normal;
color: rgba(0, 0, 0, 0.45);
}
.rw-chain__warn {
margin-left: auto;
color: #fa8c16;
}
.rw-name {
display: flex;
flex-direction: column;
line-height: 1.5;
}
.rw-name span {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.rw-level {
font-size: 12px;
}
.rw-level--pending {
color: #1590ff;
}
.rw-level--done {
color: rgba(0, 0, 0, 0.45);
}
.rw-danger {
color: #ff4d4f;
}
.rw-detail__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.rw-detail__head h3 {
margin: 0;
font-size: 16px;
}
.rw-detail__h4 {
margin: 22px 0 12px;
font-size: 14px;
}
.rw-timeline {
display: flex;
flex-direction: column;
gap: 6px;
}
.rw-tl {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
background: #fafbfc;
}
.rw-tl__no {
width: 22px;
height: 22px;
flex: 0 0 22px;
border-radius: 50%;
background: #e6e8ec;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.rw-tl__body {
flex: 1;
display: flex;
flex-direction: column;
line-height: 1.5;
}
.rw-tl__body span {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.rw-tl__state {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.rw-tl.is-done .rw-tl__no {
background: #52c41a;
color: #fff;
}
.rw-tl.is-now {
background: #e8f4ff;
}
.rw-tl.is-now .rw-tl__no {
background: #1590ff;
color: #fff;
}
.rw-tl.is-now .rw-tl__state {
color: #1590ff;
font-weight: 600;
}
.rw-tl.is-reject .rw-tl__no {
background: #ff4d4f;
color: #fff;
}
.rw-detail__foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 22px;
}
.rw-reject__tip {
margin: 0 0 10px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+182
View File
@@ -0,0 +1,182 @@
import { ref } from 'vue';
import { pageReviewFlow } from '@/api/gxmu/reviewFlow';
import { listReviewFlowConfig } from '@/api/gxmu/reviewFlowConfig';
import type { ReviewFlow } from '@/api/gxmu/reviewFlow/model';
import type { ReviewFlowConfig } from '@/api/gxmu/reviewFlowConfig/model';
/**
* 审批链读取(模块 -> 链 -> 层级)。
*
* 后端把「链」存成一行一级(gxmu_review_flow.level),用 title 归组;
* 模块到链的绑定在 gxmu_review_flow_config.flow_id。两个页面(待办审核 / 审批规则)
* 都要把这条关系翻成人话,所以集中在这里,避免各页面各写一套 join。
*
* 注意:同一模块若存在多条配置,后端 getByModule 用 limit 1,谁生效取决于数据库返回顺序。
* 这里按 id 升序取第一条,与「最老的那条生效」保持可解释;规则页会把多条配置标成冲突。
*/
const flowGroups = ref<ReviewFlow[]>([]);
const configs = ref<ReviewFlowConfig[]>([]);
const loading = ref(false);
const error = ref<string>();
let loaded = false;
const levelIndex = new Map<number, ReviewFlow>();
const rebuildIndex = () => {
levelIndex.clear();
flowGroups.value.forEach((group) => {
(group.flows || []).forEach((level) => {
if (level.id != null) {
levelIndex.set(level.id, group);
}
});
});
};
const sortLevels = (levels?: ReviewFlow[]) =>
[...(levels || [])].sort((a, b) => (a.level || 0) - (b.level || 0));
export async function loadReviewChains(force = false) {
if (loaded && !force) {
return;
}
loading.value = true;
error.value = undefined;
try {
// 只有分页接口会按 title 归组并回填 organizationName / reviewUser,
// 列表接口是平铺的层级行;这里取一页足够大的分组结果,避免链被拆散
const [page, configList] = await Promise.all([
pageReviewFlow({ withGroup: true, page: 1, limit: 200 }),
listReviewFlowConfig()
]);
flowGroups.value = page?.list || [];
configs.value = configList || [];
rebuildIndex();
loaded = true;
} catch (e: any) {
error.value = e?.message || '审批链加载失败';
throw e;
} finally {
loading.value = false;
}
}
/** 该模块的所有绑定配置(正常 1 条;多于 1 条说明历史数据有冲突) */
export function configsOf(module?: string) {
return configs.value
.filter((c) => c.module === module)
.sort((a, b) => (a.id || 0) - (b.id || 0));
}
/** 该模块当前生效的配置(与后端 limit 1 对齐:取 id 最小的一条) */
export function effectiveConfigOf(module?: string) {
const list = configsOf(module);
return list.length ? list[0] : undefined;
}
export interface ChainInfo {
flowId?: number;
title: string;
levels: ReviewFlow[];
conflict: boolean;
}
/** 该模块生效的审批链;未配置返回 undefined(沿用旧版固定审批人) */
export function chainOf(module?: string): ChainInfo | undefined {
if (!module) {
return undefined;
}
const all = configsOf(module);
const config = all.length ? all[0] : undefined;
if (!config || config.flowId == null) {
return undefined;
}
const group = levelIndex.get(config.flowId);
if (!group) {
return {
flowId: config.flowId,
title: '审批链已失效',
levels: [],
conflict: all.length > 1
};
}
return {
flowId: group.id,
title: group.title || '未命名审批链',
levels: sortLevels(group.flows),
conflict: all.length > 1
};
}
/** 该模块生效链的完整分组(含 flows),用于打开链编辑器 */
export function groupOf(module?: string): ReviewFlow | undefined {
if (!module) {
return undefined;
}
const config = effectiveConfigOf(module);
if (!config || config.flowId == null) {
return undefined;
}
return levelIndex.get(config.flowId);
}
/** 第 sortNumber 级(0 起)的层级定义 */
export function levelOf(module?: string, sortNumber?: number | null) {
const chain = chainOf(module);
if (!chain || !chain.levels.length || sortNumber == null) {
return undefined;
}
return chain.levels[sortNumber];
}
/** 层级的人话名字:机构名优先,其次规则,旧链只写了 reviewUserId 时显示「指定人员」 */
export function levelName(level?: ReviewFlow) {
if (!level) {
return '';
}
return (
level.organizationName ||
ruleText(level.reviewerRule) ||
(level.reviewUserId ? '指定人员' : '未配置审批人')
);
}
const ROLE_LABEL: Record<string, string> = {
YouthLeagueCommitteeOfSecondaryColleges: '各学院团委书记',
PartyCommitteesOfSecondaryColleges: '各学院党委书记',
YouthLeagueBranchesOfSecondaryColleges: '各班团支部书记',
SchoolYouthLeagueCommittee: '校团委'
};
/** reviewer_rule 的人话渲染 */
export function ruleText(rule?: string) {
if (!rule || !rule.trim()) {
return '';
}
return rule
.split(';')
.map((one) => one.trim())
.filter((one) => !!one)
.map((one) => {
if (one.indexOf('USER:') === 0) {
return '指定人员 ' + one.slice(5);
}
if (one.indexOf('COLLEGE_ROLE:') === 0) {
const code = one.slice(13);
return '学院角色 · ' + (ROLE_LABEL[code] || code);
}
return one;
})
.join('、');
}
export function useReviewChains() {
return {
flowGroups,
configs,
loading,
error,
load: loadReviewChains
};
}