Files
EasyFlow/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue
陈子默 4e565aef99 feat: 收敛AI资源发布审批生命周期
- 统一工作流、知识库、聊天助手的发布、重新发布、下线与删除链路

- 收敛审批编排、生命周期状态机与展示态,补齐审批管理和快照预览

- 调整审批管理权限模型为单入口页面加内部按钮权限
2026-04-09 17:13:54 +08:00

473 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'),
OFFLINE: $t('approval.action.offline'),
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,
account?: null | string,
fallbackId?: null | number | string,
) {
if (name && account) {
return `${name}${account}`;
}
if (name) {
return name;
}
if (account) {
return account;
}
return fallbackId || '-';
}
function formatOperatorId(
account?: null | string,
fallbackId?: null | number | string,
) {
return account || fallbackId || '-';
}
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.applicantAccount,
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.operatorAccount, 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>