Files
EasyFlow/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue
陈子默 0dc5c3ca55 feat: 重构 Skill 管理与编辑工作台
- 统一列表、分类、新建与详情页的产品交互

- 提供 Markdown 实时编辑、源码与脚本资源工作台

- 实现能力自动保存、导入导出及完整状态反馈
2026-07-27 18:54:57 +08:00

520 lines
15 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 { hasPermission } from '#/api/common/hasPermission';
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';
import { formatApprovalAccount } from './approval-format';
const route = useRoute();
const loading = ref(false);
const detail = ref<any>(null);
const approvalActionLoading = ref<'approve' | 'reject' | 'revoke' | null>(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 canApproveAction = computed(
() =>
!!detail.value?.canApprove &&
hasPermission(['/api/v1/approvalInstance/approve']),
);
const canRejectAction = computed(
() =>
!!detail.value?.canReject &&
hasPermission(['/api/v1/approvalInstance/reject']),
);
const canRevokeAction = computed(
() =>
!!detail.value?.canRevoke &&
hasPermission(['/api/v1/approvalInstance/revoke']),
);
const canOperate = computed(() => {
return (
canApproveAction.value || canRejectAction.value || canRevokeAction.value
);
});
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') {
if (approvalActionLoading.value) {
return;
}
approvalActionLoading.value = action;
const titleMap = {
approve: $t('approval.action.approve'),
reject: $t('approval.action.reject'),
revoke: $t('approval.action.revoke'),
};
try {
let value = '';
try {
const promptResult = await ElMessageBox.prompt(
$t('approval.placeholder.actionComment'),
titleMap[action],
{
inputValue: '',
inputType: 'textarea',
},
);
value = promptResult.value || '';
} catch (error) {
if (error === 'cancel' || error === 'close') {
return;
}
throw error;
}
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();
} finally {
approvalActionLoading.value = null;
}
}
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 formatOperatorId(
account?: null | string,
fallbackId?: null | number | string,
) {
return account || fallbackId || '-';
}
function formatOperatorName(name?: null | string) {
return name || '-';
}
function formatApplicationReason(value?: null | string) {
return String(value || '').trim() || '-';
}
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="canApproveAction"
type="success"
:disabled="Boolean(approvalActionLoading)"
:loading="approvalActionLoading === 'approve'"
@click="submitApprovalAction('approve')"
>
{{ $t('approval.action.approve') }}
</ElButton>
<ElButton
v-if="canRejectAction"
type="danger"
:disabled="Boolean(approvalActionLoading)"
:loading="approvalActionLoading === 'reject'"
@click="submitApprovalAction('reject')"
>
{{ $t('approval.action.reject') }}
</ElButton>
<ElButton
v-if="canRevokeAction"
type="warning"
:disabled="Boolean(approvalActionLoading)"
:loading="approvalActionLoading === 'revoke'"
@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.applicationReason')">
{{ detail.applicationReason || '-' }}
</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')">
{{
formatApprovalAccount(
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.applicationReason')"
min-width="220"
>
<template #default="{ row }">
{{ formatApplicationReason(row.applicationReason) }}
</template>
</ElTableColumn>
<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 }">
{{ formatApprovalAccount(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>
<ElTableColumn
:label="$t('approval.fields.applicationReason')"
min-width="220"
>
<template #default="{ row }">
{{ formatApplicationReason(row.applicationReason) }}
</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>