feat: 归档L03与L09审批发布能力

- 新增统一审批中心与审批管理页面,支持流程配置、审批详情与角色/用户审批对象

- 接入聊天助手、知识库、工作流的发布与删除审批,并补齐发布态校验与快照展示
This commit is contained in:
2026-04-07 14:41:52 +08:00
parent 7e7c236c2a
commit 3f128e977a
138 changed files with 13035 additions and 346 deletions

View File

@@ -0,0 +1,792 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { nextTick, ref, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import {
ElButton,
ElDivider,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElOption,
ElSelect,
ElSwitch,
ElTreeSelect,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
const emit = defineEmits<{
reload: [];
}>();
defineExpose({
openDialog,
});
type ResourceType = '' | 'BOT' | 'KNOWLEDGE' | 'WORKFLOW';
type ActionType = '' | 'DELETE' | 'PUBLISH';
type AssigneeType = 'ROLE' | 'USER';
type ScopeType = 'CATEGORY' | 'DEPT';
type FlowStatus = 'DISABLED' | 'ENABLED';
interface SelectOption {
id: number | string;
label: string;
}
interface ScopeItem {
id?: number | string;
includeChildren: boolean;
scopeType: ScopeType;
scopeValue?: number | string;
}
interface StepItem {
assigneeTargetCode?: string;
assigneeTargetId?: number | string;
assigneeTargetName?: string;
assigneeType: AssigneeType;
id?: number | string;
stepName: string;
}
interface FlowFormModel {
actionType: ActionType;
id?: number | string;
name: string;
priority: number;
remark: string;
resourceType: ResourceType;
scopes: ScopeItem[];
status: FlowStatus;
steps: StepItem[];
}
const RESOURCE_OPTIONS = [
{ label: $t('approval.resource.bot'), value: 'BOT' },
{ label: $t('approval.resource.workflow'), value: 'WORKFLOW' },
{ label: $t('approval.resource.knowledge'), value: 'KNOWLEDGE' },
];
const ACTION_OPTIONS = [
{ label: $t('approval.action.publish'), value: 'PUBLISH' },
{ label: $t('approval.action.delete'), value: 'DELETE' },
];
const STATUS_OPTIONS = [
{ label: $t('approval.status.enabled'), value: 'ENABLED' },
{ label: $t('approval.status.disabled'), value: 'DISABLED' },
];
const SCOPE_TYPE_OPTIONS = [
{ label: $t('approval.scope.category'), value: 'CATEGORY' },
{ label: $t('approval.scope.dept'), value: 'DEPT' },
];
const ASSIGNEE_TYPE_OPTIONS: Array<{ label: string; value: AssigneeType }> = [
{ label: $t('approval.assignee.role'), value: 'ROLE' },
{ label: $t('approval.assignee.user'), value: 'USER' },
];
const saveForm = ref<FormInstance>();
const dialogVisible = ref(false);
const isAdd = ref(true);
const btnLoading = ref(false);
const categoryLoaded = ref(false);
const deptLoaded = ref(false);
const roleLoaded = ref(false);
const accountLoading = ref(false);
const deptTreeOptions = ref<any[]>([]);
const roleOptions = ref<SelectOption[]>([]);
const accountOptions = ref<SelectOption[]>([]);
const categoryOptions = ref<Record<Exclude<ResourceType, ''>, SelectOption[]>>({
BOT: [],
KNOWLEDGE: [],
WORKFLOW: [],
});
const formModel = ref<FlowFormModel>(buildDefaultForm());
const rules = {
actionType: [
{ required: true, message: $t('message.required'), trigger: 'change' },
],
name: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
priority: [
{ required: true, message: $t('message.required'), trigger: 'change' },
],
resourceType: [
{ required: true, message: $t('message.required'), trigger: 'change' },
],
status: [
{ required: true, message: $t('message.required'), trigger: 'change' },
],
};
watch(
() => formModel.value.resourceType,
(resourceType) => {
formModel.value.scopes = formModel.value.scopes.map((scope) => {
if (scope.scopeType === 'CATEGORY') {
return {
...scope,
scopeValue: undefined,
};
}
return scope;
});
if (resourceType) {
void ensureCategoryOptions();
}
},
);
function buildDefaultForm(): FlowFormModel {
return {
actionType: 'PUBLISH',
name: '',
priority: 100,
remark: '',
resourceType: 'BOT',
scopes: [],
status: 'ENABLED',
steps: [{ assigneeType: 'ROLE', stepName: '' }],
};
}
async function openDialog(row: any = {}) {
isAdd.value = !row?.id;
formModel.value = buildDefaultForm();
dialogVisible.value = true;
await Promise.all([
ensureCategoryOptions(),
ensureDeptOptions(),
ensureRoleOptions(),
]);
if (!row?.id) {
await nextTick();
saveForm.value?.clearValidate();
return;
}
btnLoading.value = true;
try {
const res = await api.get('/api/v1/approvalFlow/detail', {
params: {
id: row.id,
},
});
if (res.errorCode !== 0) {
return;
}
formModel.value = {
actionType: res.data?.actionType || 'PUBLISH',
id: res.data?.id,
name: res.data?.name || '',
priority: Number(res.data?.priority || 100),
remark: res.data?.remark || '',
resourceType: res.data?.resourceType || 'BOT',
scopes: (res.data?.scopes || []).map((item: any) => ({
id: item.id,
includeChildren: item.includeChildren === 1,
scopeType: item.scopeType,
scopeValue: item.scopeValue,
})),
status: res.data?.status || 'ENABLED',
steps: (res.data?.steps || []).map((item: any) => ({
assigneeTargetCode: item.assigneeTargetCode,
assigneeTargetId: item.assigneeTargetId,
assigneeTargetName: item.assigneeTargetName,
assigneeType: item.assigneeType || 'ROLE',
id: item.id,
stepName: item.stepName,
})),
};
if (formModel.value.steps.length === 0) {
formModel.value.steps = [{ assigneeType: 'ROLE', stepName: '' }];
}
for (const step of formModel.value.steps) {
registerAccountOption(step);
}
if (formModel.value.steps.some((item) => item.assigneeType === 'USER')) {
await searchAccountOptions('');
}
} finally {
btnLoading.value = false;
await nextTick();
saveForm.value?.clearValidate();
}
}
async function ensureCategoryOptions() {
if (categoryLoaded.value) {
return;
}
const [botRes, workflowRes, knowledgeRes] = await Promise.all([
api.get('/api/v1/botCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
api.get('/api/v1/workflowCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
api.get('/api/v1/documentCollectionCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
]);
categoryOptions.value = {
BOT: normalizeCategoryOptions(botRes.data, 'categoryName'),
KNOWLEDGE: normalizeCategoryOptions(knowledgeRes.data, 'categoryName'),
WORKFLOW: normalizeCategoryOptions(workflowRes.data, 'categoryName'),
};
categoryLoaded.value = true;
}
async function ensureDeptOptions() {
if (deptLoaded.value) {
return;
}
const res = await api.get('/api/v1/sysDept/list', {
params: {
asTree: true,
sortKey: 'sortNo',
sortType: 'asc',
},
});
deptTreeOptions.value = Array.isArray(res.data) ? res.data : [];
deptLoaded.value = true;
}
async function ensureRoleOptions() {
if (roleLoaded.value) {
return;
}
const res = await api.get('/api/v1/approvalFlow/assigneeRoleOptions');
roleOptions.value = normalizeSelectOptions(res.data);
roleLoaded.value = true;
}
async function searchAccountOptions(keyword = '') {
accountLoading.value = true;
try {
const res = await api.get('/api/v1/approvalFlow/assigneeAccountPage', {
params: {
keyword,
pageNumber: 1,
pageSize: 20,
},
});
for (const item of normalizeSelectOptions(res?.data?.records)) {
if (
!accountOptions.value.some(
(option) => String(option.id) === String(item.id),
)
) {
accountOptions.value.push(item);
}
}
} finally {
accountLoading.value = false;
}
}
function normalizeCategoryOptions(data: any[] = [], labelKey: string) {
return (Array.isArray(data) ? data : []).map((item) => ({
id: item.id,
label: item[labelKey],
}));
}
function normalizeSelectOptions(data: any[] = []) {
return (Array.isArray(data) ? data : []).map((item) => ({
id: item.id,
label: item.name || item.label || item.code || String(item.id),
}));
}
function addScope() {
formModel.value.scopes.push({
includeChildren: false,
scopeType: 'CATEGORY',
scopeValue: undefined,
});
}
function removeScope(index: number) {
formModel.value.scopes.splice(index, 1);
}
function addStep() {
formModel.value.steps.push({ assigneeType: 'ROLE', stepName: '' });
}
function removeStep(index: number) {
if (formModel.value.steps.length <= 1) {
return;
}
formModel.value.steps.splice(index, 1);
}
function getCategoryScopeOptions() {
const resourceType = formModel.value.resourceType;
if (!resourceType) {
return [];
}
return categoryOptions.value[resourceType];
}
function handleAssigneeTypeChange(step: StepItem) {
step.assigneeTargetId = undefined;
step.assigneeTargetCode = '';
step.assigneeTargetName = '';
if (step.assigneeType === 'USER') {
void searchAccountOptions('');
}
}
function handleAssigneeTargetChange(step: StepItem) {
const options =
step.assigneeType === 'ROLE' ? roleOptions.value : accountOptions.value;
const selected = options.find(
(item) => String(item.id) === String(step.assigneeTargetId),
);
step.assigneeTargetName = selected?.label || '';
}
function registerAccountOption(step?: StepItem) {
if (!step?.assigneeTargetId || !step.assigneeTargetName) {
return;
}
if (
accountOptions.value.some(
(item) => String(item.id) === String(step.assigneeTargetId),
)
) {
return;
}
accountOptions.value.push({
id: step.assigneeTargetId,
label: step.assigneeTargetName,
});
}
async function save() {
saveForm.value?.validate(async (valid) => {
if (!valid) {
return;
}
btnLoading.value = true;
try {
const steps = formModel.value.steps
.map((step, index) => ({
assigneeTargetId: step.assigneeTargetId,
assigneeType: step.assigneeType,
id: step.id,
stepName: step.stepName?.trim(),
stepNo: index + 1,
}))
.filter((step) => step.stepName);
if (steps.length === 0) {
ElMessage.warning($t('approval.message.needStep'));
btnLoading.value = false;
return;
}
if (steps.some((step) => !step.assigneeType || !step.assigneeTargetId)) {
ElMessage.warning($t('approval.message.needStepAssignee'));
btnLoading.value = false;
return;
}
const scopes = formModel.value.scopes
.filter((scope) => scope.scopeValue && scope.scopeType)
.map((scope) => ({
id: scope.id,
includeChildren: scope.includeChildren ? 1 : 0,
scopeType: scope.scopeType,
scopeValue: scope.scopeValue,
}));
const payload = {
actionType: formModel.value.actionType,
id: formModel.value.id,
name: formModel.value.name.trim(),
priority: formModel.value.priority,
remark: formModel.value.remark.trim(),
resourceType: formModel.value.resourceType,
scopes,
status: formModel.value.status,
steps,
};
const url = isAdd.value
? '/api/v1/approvalFlow/save'
: '/api/v1/approvalFlow/update';
const res = await api.post(url, payload);
if (res.errorCode !== 0) {
btnLoading.value = false;
return;
}
ElMessage.success($t('approval.message.saveSuccess'));
emit('reload');
closeDialog();
} finally {
btnLoading.value = false;
}
});
}
function closeDialog() {
formModel.value = buildDefaultForm();
dialogVisible.value = false;
saveForm.value?.resetFields();
}
</script>
<template>
<EasyFlowFormModal
v-model:open="dialogVisible"
:title="
isAdd ? $t('approval.action.addFlow') : $t('approval.action.editFlow')
"
:before-close="closeDialog"
:confirm-loading="btnLoading"
:submitting="btnLoading"
:confirm-text="$t('button.save')"
width="xl"
@confirm="save"
>
<ElForm
ref="saveForm"
:model="formModel"
:rules="rules"
label-position="top"
class="easyflow-modal-form"
>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<ElFormItem prop="name" :label="$t('approval.fields.flowName')">
<ElInput v-model.trim="formModel.name" maxlength="128" />
</ElFormItem>
<ElFormItem prop="priority" :label="$t('approval.fields.priority')">
<ElInputNumber
v-model="formModel.priority"
:min="0"
:step="10"
class="!w-full"
/>
</ElFormItem>
<ElFormItem
prop="resourceType"
:label="$t('approval.fields.resourceType')"
>
<ElSelect v-model="formModel.resourceType">
<ElOption
v-for="item in RESOURCE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem prop="actionType" :label="$t('approval.fields.actionType')">
<ElSelect v-model="formModel.actionType">
<ElOption
v-for="item in ACTION_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem prop="status" :label="$t('approval.fields.status')">
<ElSelect v-model="formModel.status">
<ElOption
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem prop="remark" :label="$t('approval.fields.remark')">
<ElInput v-model.trim="formModel.remark" maxlength="500" />
</ElFormItem>
</div>
<ElDivider>{{ $t('approval.section.scope') }}</ElDivider>
<div class="approval-flow-modal__section">
<div class="approval-flow-modal__section-meta">
<p>{{ $t('approval.helper.scope') }}</p>
<ElButton text type="primary" class="w-fit" @click="addScope">
{{ $t('approval.action.addScope') }}
</ElButton>
</div>
<div class="approval-flow-modal__scope-list">
<div
v-if="formModel.scopes.length === 0"
class="approval-flow-modal__scope-empty"
>
{{ $t('approval.helper.scopeEmpty') }}
</div>
<div
v-for="(scope, index) in formModel.scopes"
:key="`scope-${index}`"
class="approval-flow-modal__scope-row"
>
<ElSelect
v-model="scope.scopeType"
class="approval-flow-modal__scope-control approval-flow-modal__scope-type"
>
<ElOption
v-for="item in SCOPE_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
<ElSelect
v-if="scope.scopeType === 'CATEGORY'"
v-model="scope.scopeValue"
class="approval-flow-modal__scope-control"
filterable
clearable
:placeholder="$t('approval.placeholder.scopeValue')"
>
<ElOption
v-for="item in getCategoryScopeOptions()"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</ElSelect>
<ElTreeSelect
v-else
v-model="scope.scopeValue"
class="approval-flow-modal__scope-control"
clearable
check-strictly
:data="deptTreeOptions"
:props="{
label: 'deptName',
children: 'children',
value: 'id',
}"
:placeholder="$t('approval.placeholder.scopeValue')"
/>
<div class="approval-flow-modal__scope-switch">
<span>{{ $t('approval.fields.includeChildren') }}</span>
<ElSwitch v-model="scope.includeChildren" />
</div>
<ElButton text type="danger" @click="removeScope(index)">
{{ $t('button.delete') }}
</ElButton>
</div>
</div>
</div>
<ElDivider>{{ $t('approval.section.steps') }}</ElDivider>
<div class="flex flex-col gap-3">
<div
v-for="(step, index) in formModel.steps"
:key="`step-${index}`"
class="grid grid-cols-1 gap-3 rounded-2xl border border-[hsl(var(--divider-faint)/0.66)] bg-[hsl(var(--surface-panel)/0.72)] p-4 md:grid-cols-[72px,minmax(0,1.1fr),148px,minmax(0,1fr),88px]"
>
<div
class="flex items-center text-sm font-medium text-[hsl(var(--text-muted))]"
>
{{ $t('approval.fields.stepNo', { value: index + 1 }) }}
</div>
<ElInput
v-model.trim="step.stepName"
:placeholder="$t('approval.placeholder.stepName')"
/>
<div class="approval-flow-modal__assignee-type">
<button
v-for="item in ASSIGNEE_TYPE_OPTIONS"
:key="item.value"
type="button"
class="approval-flow-modal__assignee-type-item"
:class="{
'is-active': step.assigneeType === item.value,
}"
:aria-pressed="step.assigneeType === item.value"
@click="
step.assigneeType !== item.value &&
((step.assigneeType = item.value),
handleAssigneeTypeChange(step))
"
>
{{ item.label }}
</button>
</div>
<ElSelect
v-if="step.assigneeType === 'ROLE'"
v-model="step.assigneeTargetId"
filterable
clearable
:placeholder="$t('approval.placeholder.assigneeTarget')"
@change="handleAssigneeTargetChange(step)"
>
<ElOption
v-for="item in roleOptions"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</ElSelect>
<ElSelect
v-else
v-model="step.assigneeTargetId"
filterable
clearable
remote
reserve-keyword
:loading="accountLoading"
:placeholder="$t('approval.placeholder.assigneeTarget')"
:remote-method="searchAccountOptions"
@change="handleAssigneeTargetChange(step)"
@visible-change="(visible) => visible && searchAccountOptions('')"
>
<ElOption
v-for="item in accountOptions"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</ElSelect>
<ElButton
text
type="danger"
:disabled="formModel.steps.length <= 1"
@click="removeStep(index)"
>
{{ $t('button.delete') }}
</ElButton>
</div>
<ElButton text type="primary" class="w-fit" @click="addStep">
{{ $t('approval.action.addStep') }}
</ElButton>
</div>
</ElForm>
</EasyFlowFormModal>
</template>
<style scoped>
.approval-flow-modal__section {
display: flex;
flex-direction: column;
gap: 12px;
}
.approval-flow-modal__section-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.approval-flow-modal__section-meta p {
margin: 0;
color: hsl(var(--text-muted));
font-size: 13px;
line-height: 1.5;
}
.approval-flow-modal__scope-list {
display: flex;
flex-direction: column;
gap: 0;
}
.approval-flow-modal__scope-empty {
padding: 4px 0 2px;
color: hsl(var(--text-muted));
font-size: 13px;
}
.approval-flow-modal__scope-row {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding: 10px 0;
}
.approval-flow-modal__scope-row + .approval-flow-modal__scope-row {
border-top: 1px solid hsl(var(--divider-faint) / 0.72);
}
.approval-flow-modal__scope-control {
width: 100%;
}
.approval-flow-modal__scope-switch {
display: flex;
align-items: center;
gap: 10px;
color: hsl(var(--text-muted));
font-size: 13px;
}
.approval-flow-modal__assignee-type {
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
min-height: 40px;
padding: 4px;
border: 1px solid hsl(var(--divider-faint) / 0.72);
border-radius: 14px;
background: hsl(var(--surface-contrast-soft) / 0.82);
}
.approval-flow-modal__assignee-type-item {
min-width: 64px;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 10px;
background: transparent;
color: hsl(var(--text-muted));
font-size: 13px;
font-weight: 600;
line-height: 32px;
transition:
background-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
cursor: pointer;
}
.approval-flow-modal__assignee-type-item:hover {
color: hsl(var(--foreground));
}
.approval-flow-modal__assignee-type-item:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.28);
outline-offset: 1px;
}
.approval-flow-modal__assignee-type-item.is-active {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.14);
}
@media (min-width: 768px) {
.approval-flow-modal__scope-row {
grid-template-columns: 148px minmax(0, 1fr) 128px 72px;
align-items: center;
}
}
</style>