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,455 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { IconifyIcon } from '@easyflow/icons';
import { ArrowLeft } from '@element-plus/icons-vue';
import {
ElButton,
ElDescriptions,
ElDescriptionsItem,
ElEmpty,
ElMessage,
ElMessageBox,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import { router } from '#/router';
import BotApprovalSnapshotPreview from '#/views/system/approval/components/BotApprovalSnapshotPreview.vue';
import KnowledgeApprovalSnapshotPreview from '#/views/system/approval/components/KnowledgeApprovalSnapshotPreview.vue';
import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/WorkflowApprovalSnapshotPreview.vue';
const route = useRoute();
const loading = ref(false);
const detail = ref<any>(null);
const resourceLabelMap: Record<string, string> = {
BOT: $t('approval.resource.bot'),
KNOWLEDGE: $t('approval.resource.knowledge'),
WORKFLOW: $t('approval.resource.workflow'),
};
const actionLabelMap: Record<string, string> = {
DELETE: $t('approval.action.delete'),
PUBLISH: $t('approval.action.publish'),
};
const canOperate = computed(() => {
return (
!!detail.value?.canApprove ||
!!detail.value?.canReject ||
!!detail.value?.canRevoke
);
});
const workflowSnapshot = computed(() => {
if (detail.value?.resourceType !== 'WORKFLOW') {
return null;
}
const snapshot = detail.value?.snapshotJson?.resourceSnapshot;
return {
title: snapshot?.title || '',
description: snapshot?.description || '',
content: snapshot?.content || '',
};
});
const knowledgeSnapshot = computed(() => {
if (detail.value?.resourceType !== 'KNOWLEDGE') {
return null;
}
return detail.value?.snapshotJson?.resourceSnapshot || null;
});
const botSnapshot = computed(() => {
if (detail.value?.resourceType !== 'BOT') {
return null;
}
return detail.value?.snapshotJson?.resourceSnapshot || null;
});
onMounted(() => {
void loadDetail();
});
async function loadDetail() {
loading.value = true;
try {
const res = await api.get('/api/v1/approvalInstance/detail', {
params: {
id: route.params.id,
},
});
if (res.errorCode !== 0) {
return;
}
detail.value = res.data;
} finally {
loading.value = false;
}
}
async function submitApprovalAction(action: 'approve' | 'reject' | 'revoke') {
const titleMap = {
approve: $t('approval.action.approve'),
reject: $t('approval.action.reject'),
revoke: $t('approval.action.revoke'),
};
const { value } = await ElMessageBox.prompt(
$t('approval.placeholder.actionComment'),
titleMap[action],
{
inputValue: '',
inputType: 'textarea',
},
);
const res = await api.post(`/api/v1/approvalInstance/${action}`, {
comment: value || '',
instanceId: detail.value?.id,
});
if (res.errorCode !== 0) {
return;
}
ElMessage.success($t('approval.message.actionSuccess'));
await loadDetail();
}
function getStatusType(status: string) {
const map: Record<string, any> = {
APPROVED: 'success',
PENDING: 'warning',
PROCESSING: 'primary',
REJECTED: 'danger',
REVOKED: 'info',
};
return map[status] || 'info';
}
function getStatusLabel(status: string) {
return $t(`approval.status.${String(status || '').toLowerCase()}`);
}
function formatPayload(payload: Record<string, any>) {
return JSON.stringify(payload || {}, null, 2);
}
function formatAccountDisplay(name?: string, id?: null | number | string) {
if (name && id) {
return `${name}${id}`;
}
if (name) {
return name;
}
return id || '-';
}
function formatOperatorId(id?: null | number | string) {
return id || '-';
}
function formatOperatorName(name?: null | string) {
return name || '-';
}
function formatAssigneeDisplay(row: Record<string, any>) {
if (!row?.assigneeType || !row?.assigneeTargetName) {
return '-';
}
if (row.assigneeType === 'ROLE') {
return `${$t('approval.assignee.role')}${row.assigneeTargetName}`;
}
return `${$t('approval.assignee.user')}${row.assigneeTargetName}`;
}
function getEventTypeLabel(eventType?: string) {
const eventTypeMap: Record<string, string> = {
APPROVED: $t('approval.event.approved'),
REJECTED: $t('approval.event.rejected'),
REVOKED: $t('approval.event.revoked'),
STEP_CREATED: $t('approval.event.stepCreated'),
SUBMITTED: $t('approval.event.submitted'),
};
return eventTypeMap[String(eventType || '')] || eventType || '-';
}
function formatEventInfo(row: Record<string, any>) {
const payload = row?.payloadJson || {};
const eventType = String(row?.eventType || '');
switch (eventType) {
case 'APPROVED': {
return payload.stepNo
? $t('approval.message.eventApprovedStep', { value: payload.stepNo })
: $t('approval.message.eventApproved');
}
case 'REJECTED': {
return payload.stepNo
? $t('approval.message.eventRejectedStep', { value: payload.stepNo })
: $t('approval.message.eventRejected');
}
case 'REVOKED': {
return payload.stepNo
? $t('approval.message.eventRevokedStep', { value: payload.stepNo })
: $t('approval.message.eventRevoked');
}
case 'STEP_CREATED': {
return payload.stepName || '-';
}
case 'SUBMITTED': {
return payload.summary || detail.value?.summary || '-';
}
default: {
return payload.stepName || payload.summary || row?.eventType || '-';
}
}
}
</script>
<template>
<div class="flex h-full flex-col gap-4 p-6" v-loading="loading">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<ElButton :icon="ArrowLeft" @click="router.back()">
{{ $t('button.back') }}
</ElButton>
<div class="flex items-center gap-2 text-lg font-semibold">
<IconifyIcon icon="svg:approval" class="size-5" />
<span>{{ $t('approval.title') }}</span>
</div>
</div>
<div v-if="canOperate" class="flex items-center gap-3">
<ElButton
v-if="detail?.canApprove"
type="success"
@click="submitApprovalAction('approve')"
>
{{ $t('approval.action.approve') }}
</ElButton>
<ElButton
v-if="detail?.canReject"
type="danger"
@click="submitApprovalAction('reject')"
>
{{ $t('approval.action.reject') }}
</ElButton>
<ElButton
v-if="detail?.canRevoke"
@click="submitApprovalAction('revoke')"
>
{{ $t('approval.action.revoke') }}
</ElButton>
</div>
</div>
<template v-if="detail">
<section class="approval-detail__panel">
<div class="approval-detail__panel-header">
<h3>{{ $t('approval.section.basic') }}</h3>
<ElTag :type="getStatusType(detail.status)">
{{ getStatusLabel(detail.status) }}
</ElTag>
</div>
<ElDescriptions :column="2" border>
<ElDescriptionsItem :label="$t('approval.fields.summary')">
{{ detail.summary || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.currentStep')">
{{ detail.currentStepNo || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.resourceType')">
{{
resourceLabelMap[detail.resourceType] ||
detail.resourceType ||
'-'
}}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.actionType')">
{{ actionLabelMap[detail.actionType] || detail.actionType || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.taskId')">
{{ detail.id || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.applicant')">
{{ formatAccountDisplay(detail.applicantName, detail.applicantId) }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.submittedAt')">
{{ detail.submittedAt || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.finishedAt')">
{{ detail.finishedAt || '-' }}
</ElDescriptionsItem>
</ElDescriptions>
</section>
<section class="approval-detail__panel">
<div class="approval-detail__panel-header">
<h3>{{ $t('approval.section.tasks') }}</h3>
</div>
<ElTable :data="detail.tasks || []" show-overflow-tooltip>
<ElTableColumn
prop="stepNo"
:label="$t('approval.fields.stepNoLabel')"
width="100"
/>
<ElTableColumn
prop="stepName"
:label="$t('approval.fields.stepName')"
min-width="180"
/>
<ElTableColumn :label="$t('approval.fields.status')" width="120">
<template #default="{ row }">
<ElTag :type="getStatusType(row.status)">
{{ getStatusLabel(row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('approval.fields.assigneeTarget')"
width="220"
>
<template #default="{ row }">
{{ formatAssigneeDisplay(row) }}
</template>
</ElTableColumn>
<ElTableColumn :label="$t('approval.fields.actedBy')" width="180">
<template #default="{ row }">
{{ formatAccountDisplay(row.actedByName, row.actedBy) }}
</template>
</ElTableColumn>
<ElTableColumn
prop="actedAt"
:label="$t('approval.fields.actedAt')"
width="180"
/>
<ElTableColumn
prop="comment"
:label="$t('approval.fields.comment')"
min-width="200"
/>
</ElTable>
</section>
<section class="approval-detail__panel">
<div class="approval-detail__panel-header">
<h3>{{ $t('approval.section.logs') }}</h3>
</div>
<ElTable :data="detail.logs || []" show-overflow-tooltip>
<ElTableColumn :label="$t('approval.fields.eventType')" width="160">
<template #default="{ row }">
{{ getEventTypeLabel(row.eventType) }}
</template>
</ElTableColumn>
<ElTableColumn :label="$t('approval.fields.operatorId')" width="140">
<template #default="{ row }">
{{ formatOperatorId(row.operatorId) }}
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('approval.fields.operatorName')"
width="180"
>
<template #default="{ row }">
{{ formatOperatorName(row.operatorName) }}
</template>
</ElTableColumn>
<ElTableColumn
prop="created"
:label="$t('approval.fields.createdAt')"
width="180"
/>
<ElTableColumn
:label="$t('approval.fields.eventInfo')"
min-width="280"
>
<template #default="{ row }">
<div class="approval-detail__event-info">
{{ formatEventInfo(row) }}
</div>
</template>
</ElTableColumn>
</ElTable>
</section>
<section class="approval-detail__panel">
<div class="approval-detail__panel-header">
<h3>{{ $t('approval.section.snapshot') }}</h3>
</div>
<WorkflowApprovalSnapshotPreview
v-if="workflowSnapshot"
:title="workflowSnapshot.title"
:description="workflowSnapshot.description"
:content="workflowSnapshot.content"
/>
<KnowledgeApprovalSnapshotPreview
v-else-if="knowledgeSnapshot"
:snapshot="knowledgeSnapshot"
/>
<BotApprovalSnapshotPreview
v-else-if="botSnapshot"
:snapshot="botSnapshot"
/>
<pre v-else class="approval-detail__snapshot">{{
formatPayload(detail.snapshotJson)
}}</pre>
</section>
</template>
<ElEmpty v-else />
</div>
</template>
<style scoped>
.approval-detail__panel {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
background: linear-gradient(
180deg,
hsl(var(--glass-border) / 72%) 0%,
hsl(var(--surface-panel) / 95%) 100%
);
border: 1px solid hsl(var(--divider-faint) / 50%);
border-radius: 24px;
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 72%),
0 18px 36px -32px hsl(var(--primary) / 20%);
}
.approval-detail__panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.approval-detail__panel-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.approval-detail__snapshot,
.approval-detail__payload {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
padding: 14px 16px;
background: hsl(var(--surface-contrast-soft) / 0.9);
border: 1px solid hsl(var(--divider-faint) / 0.54);
border-radius: 16px;
font-size: 12px;
line-height: 1.6;
}
.approval-detail__event-info {
line-height: 1.6;
color: hsl(var(--foreground));
}
</style>