feat: 归档L03与L09审批发布能力
- 新增统一审批中心与审批管理页面,支持流程配置、审批详情与角色/用户审批对象 - 接入聊天助手、知识库、工作流的发布与删除审批,并补齐发布态校验与快照展示
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
1134
easyflow-ui-admin/app/src/views/system/approval/ApprovalManage.vue
Normal file
1134
easyflow-ui-admin/app/src/views/system/approval/ApprovalManage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const props = defineProps<{
|
||||
snapshot?: null | Record<string, any>;
|
||||
}>();
|
||||
|
||||
const bot = computed(() => props.snapshot || {});
|
||||
const expandedPrompt = ref(false);
|
||||
|
||||
const modelItems = computed(() => [
|
||||
{
|
||||
label: $t('approval.snapshot.modelName'),
|
||||
value: bot.value.modelName,
|
||||
},
|
||||
{
|
||||
label: $t('bot.temperature'),
|
||||
value: bot.value.temperature,
|
||||
},
|
||||
{
|
||||
label: 'Top P',
|
||||
value: bot.value.topP,
|
||||
},
|
||||
{
|
||||
label: 'Top K',
|
||||
value: bot.value.topK,
|
||||
},
|
||||
{
|
||||
label: $t('bot.maxReplyLength'),
|
||||
value: bot.value.maxReplyLength,
|
||||
},
|
||||
{
|
||||
label: $t('approval.snapshot.maxMessageCount'),
|
||||
value: bot.value.maxMessageCount,
|
||||
},
|
||||
]);
|
||||
|
||||
const bindingGroups = computed(() => [
|
||||
{
|
||||
items: Array.isArray(bot.value.knowledgeBindings)
|
||||
? bot.value.knowledgeBindings
|
||||
: [],
|
||||
key: 'knowledge',
|
||||
label: $t('approval.snapshot.knowledgeBindings'),
|
||||
},
|
||||
{
|
||||
items: Array.isArray(bot.value.workflowBindings)
|
||||
? bot.value.workflowBindings
|
||||
: [],
|
||||
key: 'workflow',
|
||||
label: $t('approval.snapshot.workflowBindings'),
|
||||
},
|
||||
{
|
||||
items: Array.isArray(bot.value.pluginBindings)
|
||||
? bot.value.pluginBindings
|
||||
: [],
|
||||
key: 'plugin',
|
||||
label: $t('approval.snapshot.pluginBindings'),
|
||||
},
|
||||
{
|
||||
items: Array.isArray(bot.value.mcpBindings) ? bot.value.mcpBindings : [],
|
||||
key: 'mcp',
|
||||
label: $t('approval.snapshot.mcpBindings'),
|
||||
},
|
||||
]);
|
||||
|
||||
const promptPreview = computed(() => bot.value.systemPrompt || '');
|
||||
|
||||
function formatValue(value?: null | number | string) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return $t('approval.snapshot.notConfigured');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function getBindingName(groupKey: string, item: Record<string, any>) {
|
||||
switch (groupKey) {
|
||||
case 'knowledge': {
|
||||
return item.knowledgeName || $t('approval.snapshot.unnamedKnowledge');
|
||||
}
|
||||
case 'mcp': {
|
||||
return (
|
||||
item.mcpName || item.mcpToolName || $t('approval.snapshot.unnamedMcp')
|
||||
);
|
||||
}
|
||||
case 'plugin': {
|
||||
return item.pluginItemName || $t('approval.snapshot.unnamedPlugin');
|
||||
}
|
||||
case 'workflow': {
|
||||
return item.workflowName || $t('approval.snapshot.unnamedWorkflow');
|
||||
}
|
||||
default: {
|
||||
return $t('approval.snapshot.notConfigured');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRetrievalModeLabel(mode?: string) {
|
||||
const normalized = String(mode || '').toLowerCase();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
const label = $t(`bot.retrievalModes.${normalized}`);
|
||||
return label === `bot.retrievalModes.${normalized}` ? String(mode) : label;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="snapshot-card">
|
||||
<div class="snapshot-card__hero">
|
||||
<div v-if="bot.icon" class="snapshot-card__avatar">
|
||||
<img :src="bot.icon" :alt="bot.title || $t('approval.resource.bot')" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="snapshot-card__avatar snapshot-card__avatar--placeholder"
|
||||
>
|
||||
{{ $t('approval.resource.bot').slice(0, 1) }}
|
||||
</div>
|
||||
<div class="snapshot-card__hero-copy">
|
||||
<div class="snapshot-card__title-row">
|
||||
<div class="snapshot-card__title">
|
||||
{{ bot.title || $t('approval.snapshot.untitledBot') }}
|
||||
</div>
|
||||
<div class="snapshot-card__meta-tags">
|
||||
<ElTag round effect="plain" type="primary">
|
||||
{{ bot.alias || $t('approval.snapshot.notConfigured') }}
|
||||
</ElTag>
|
||||
<ElTag
|
||||
round
|
||||
effect="plain"
|
||||
:type="bot.anonymousEnabled ? 'success' : 'info'"
|
||||
>
|
||||
{{
|
||||
bot.anonymousEnabled
|
||||
? $t('approval.snapshot.anonymousEnabled')
|
||||
: $t('approval.snapshot.anonymousDisabled')
|
||||
}}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bot.description" class="snapshot-card__description">
|
||||
{{ bot.description }}
|
||||
</div>
|
||||
<div class="snapshot-card__submeta">
|
||||
<span>
|
||||
{{ $t('approval.snapshot.department') }}:{{
|
||||
formatValue(bot.deptName)
|
||||
}}
|
||||
</span>
|
||||
<span>
|
||||
{{ $t('approval.snapshot.category') }}:{{
|
||||
formatValue(bot.categoryName)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="snapshot-card__section">
|
||||
<div class="snapshot-card__section-title">
|
||||
{{ $t('approval.snapshot.botModelConfig') }}
|
||||
</div>
|
||||
<div class="snapshot-card__info-grid">
|
||||
<div
|
||||
v-for="item in modelItems"
|
||||
:key="item.label"
|
||||
class="snapshot-card__info-item"
|
||||
>
|
||||
<div class="snapshot-card__label">{{ item.label }}</div>
|
||||
<div class="snapshot-card__value">{{ formatValue(item.value) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="snapshot-card__section">
|
||||
<div class="snapshot-card__section-head">
|
||||
<div class="snapshot-card__section-title">
|
||||
{{ $t('approval.snapshot.systemPrompt') }}
|
||||
</div>
|
||||
<ElButton
|
||||
v-if="promptPreview"
|
||||
link
|
||||
type="primary"
|
||||
@click="expandedPrompt = !expandedPrompt"
|
||||
>
|
||||
{{
|
||||
expandedPrompt
|
||||
? $t('approval.snapshot.collapsePrompt')
|
||||
: $t('approval.snapshot.expandPrompt')
|
||||
}}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="promptPreview"
|
||||
class="snapshot-card__prompt"
|
||||
:class="{ 'snapshot-card__prompt--expanded': expandedPrompt }"
|
||||
>
|
||||
{{ promptPreview }}
|
||||
</div>
|
||||
<div v-else class="snapshot-card__empty-text">
|
||||
{{ $t('approval.snapshot.notConfigured') }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="snapshot-card__section">
|
||||
<div class="snapshot-card__section-title">
|
||||
{{ $t('approval.snapshot.botBindings') }}
|
||||
</div>
|
||||
<div class="snapshot-card__bindings-grid">
|
||||
<div
|
||||
v-for="group in bindingGroups"
|
||||
:key="group.key"
|
||||
class="snapshot-card__binding-group"
|
||||
>
|
||||
<div class="snapshot-card__binding-head">
|
||||
<span>{{ group.label }}</span>
|
||||
<ElTag round effect="plain" type="info">
|
||||
{{ group.items.length }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div
|
||||
v-if="group.items.length > 0"
|
||||
class="snapshot-card__binding-list"
|
||||
>
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="`${group.key}-${JSON.stringify(item)}`"
|
||||
class="snapshot-card__binding-item"
|
||||
>
|
||||
<span>{{ getBindingName(group.key, item) }}</span>
|
||||
<ElTag
|
||||
v-if="group.key === 'knowledge' && item.retrievalMode"
|
||||
round
|
||||
effect="plain"
|
||||
type="primary"
|
||||
>
|
||||
{{ getRetrievalModeLabel(item.retrievalMode) }}
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-else-if="group.key === 'mcp' && item.mcpToolName"
|
||||
round
|
||||
effect="plain"
|
||||
type="success"
|
||||
>
|
||||
{{ item.mcpToolName }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="snapshot-card__empty-text">
|
||||
{{ $t('approval.snapshot.noBindings') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.snapshot-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.snapshot-card__hero,
|
||||
.snapshot-card__section {
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.54);
|
||||
border-radius: 20px;
|
||||
background: hsl(var(--surface-contrast-soft) / 0.9);
|
||||
}
|
||||
|
||||
.snapshot-card__hero {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.snapshot-card__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.5);
|
||||
background: hsl(var(--surface) / 0.96);
|
||||
}
|
||||
|
||||
.snapshot-card__avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.snapshot-card__avatar--placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.snapshot-card__hero-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.snapshot-card__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.snapshot-card__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__description {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__submeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__meta-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.snapshot-card__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.snapshot-card__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.snapshot-card__section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__info-grid,
|
||||
.snapshot-card__bindings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.snapshot-card__info-item,
|
||||
.snapshot-card__binding-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: hsl(var(--surface-panel) / 0.88);
|
||||
}
|
||||
|
||||
.snapshot-card__label {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.snapshot-card__prompt {
|
||||
max-height: 132px;
|
||||
overflow: hidden;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: hsl(var(--surface-panel) / 0.88);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__prompt--expanded {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.snapshot-card__binding-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__binding-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.snapshot-card__binding-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__empty-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.snapshot-card__hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.snapshot-card__title-row,
|
||||
.snapshot-card__section-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.snapshot-card__meta-tags {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.snapshot-card__info-grid,
|
||||
.snapshot-card__bindings-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const props = defineProps<{
|
||||
snapshot?: null | Record<string, any>;
|
||||
}>();
|
||||
|
||||
const knowledge = computed(() => props.snapshot || {});
|
||||
|
||||
const visibilityLabelMap: Record<string, string> = {
|
||||
DEPT: $t('documentCollection.visibilityScopeDept'),
|
||||
PRIVATE: $t('documentCollection.visibilityScopePrivate'),
|
||||
PUBLIC: $t('documentCollection.visibilityScopePublic'),
|
||||
};
|
||||
|
||||
const collectionTypeLabelMap: Record<string, string> = {
|
||||
DOCUMENT: $t('documentCollection.collectionTypeDocument'),
|
||||
FAQ: $t('documentCollection.collectionTypeFaq'),
|
||||
};
|
||||
|
||||
const visibilityLabel = computed(() => {
|
||||
const code = String(knowledge.value.visibilityScope || '').toUpperCase();
|
||||
return (
|
||||
knowledge.value.visibilityScopeLabel ||
|
||||
visibilityLabelMap[code] ||
|
||||
$t('approval.snapshot.notConfigured')
|
||||
);
|
||||
});
|
||||
|
||||
const collectionTypeLabel = computed(() => {
|
||||
const code = String(knowledge.value.collectionType || '').toUpperCase();
|
||||
return (
|
||||
knowledge.value.collectionTypeLabel ||
|
||||
collectionTypeLabelMap[code] ||
|
||||
$t('approval.snapshot.notConfigured')
|
||||
);
|
||||
});
|
||||
|
||||
const basicItems = computed(() => [
|
||||
{
|
||||
label: $t('documentCollection.alias'),
|
||||
value: knowledge.value.alias,
|
||||
},
|
||||
{
|
||||
label: $t('approval.snapshot.department'),
|
||||
value: knowledge.value.deptName,
|
||||
},
|
||||
{
|
||||
label: $t('approval.snapshot.category'),
|
||||
value: knowledge.value.categoryName,
|
||||
},
|
||||
{
|
||||
label: $t('documentCollection.englishName'),
|
||||
value: knowledge.value.englishName,
|
||||
},
|
||||
]);
|
||||
|
||||
const configItems = computed(() => [
|
||||
{
|
||||
label: $t('approval.snapshot.vectorStoreType'),
|
||||
value: knowledge.value.vectorStoreType,
|
||||
},
|
||||
{
|
||||
label: $t('approval.snapshot.vectorEmbedModel'),
|
||||
value: knowledge.value.vectorEmbedModelName,
|
||||
},
|
||||
{
|
||||
label: $t('documentCollection.dimensionOfVectorModel'),
|
||||
value: knowledge.value.dimensionOfVectorModel,
|
||||
},
|
||||
{
|
||||
label: $t('approval.snapshot.rerankModel'),
|
||||
value: knowledge.value.rerankModelName,
|
||||
},
|
||||
]);
|
||||
|
||||
function formatValue(value?: null | number | string) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return $t('approval.snapshot.notConfigured');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="snapshot-card">
|
||||
<div class="snapshot-card__hero">
|
||||
<div v-if="knowledge.icon" class="snapshot-card__avatar">
|
||||
<img
|
||||
:src="knowledge.icon"
|
||||
:alt="knowledge.title || $t('approval.resource.knowledge')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="snapshot-card__avatar snapshot-card__avatar--placeholder"
|
||||
>
|
||||
{{ $t('approval.resource.knowledge').slice(0, 1) }}
|
||||
</div>
|
||||
<div class="snapshot-card__hero-copy">
|
||||
<div class="snapshot-card__title-row">
|
||||
<div class="snapshot-card__title">
|
||||
{{ knowledge.title || $t('approval.snapshot.untitledKnowledge') }}
|
||||
</div>
|
||||
<div class="snapshot-card__meta-tags">
|
||||
<ElTag round effect="plain" type="info">
|
||||
{{ visibilityLabel }}
|
||||
</ElTag>
|
||||
<ElTag round effect="plain" type="primary">
|
||||
{{ collectionTypeLabel }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="knowledge.description" class="snapshot-card__description">
|
||||
{{ knowledge.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="snapshot-card__section">
|
||||
<div class="snapshot-card__section-title">
|
||||
{{ $t('approval.snapshot.knowledgeBasic') }}
|
||||
</div>
|
||||
<div class="snapshot-card__info-grid">
|
||||
<div
|
||||
v-for="item in basicItems"
|
||||
:key="item.label"
|
||||
class="snapshot-card__info-item"
|
||||
>
|
||||
<div class="snapshot-card__label">{{ item.label }}</div>
|
||||
<div class="snapshot-card__value">{{ formatValue(item.value) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="snapshot-card__section">
|
||||
<div class="snapshot-card__section-title">
|
||||
{{ $t('approval.snapshot.knowledgeConfig') }}
|
||||
</div>
|
||||
<div class="snapshot-card__toggles">
|
||||
<div class="snapshot-card__toggle">
|
||||
<span>{{ $t('documentCollection.vectorStoreEnable') }}</span>
|
||||
<ElTag
|
||||
round
|
||||
:type="knowledge.vectorStoreEnable ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
>
|
||||
{{
|
||||
knowledge.vectorStoreEnable
|
||||
? $t('approval.snapshot.enabled')
|
||||
: $t('approval.snapshot.disabled')
|
||||
}}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="snapshot-card__toggle">
|
||||
<span>{{ $t('documentCollection.searchEngineEnable') }}</span>
|
||||
<ElTag
|
||||
round
|
||||
:type="knowledge.searchEngineEnable ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
>
|
||||
{{
|
||||
knowledge.searchEngineEnable
|
||||
? $t('approval.snapshot.enabled')
|
||||
: $t('approval.snapshot.disabled')
|
||||
}}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="snapshot-card__toggle">
|
||||
<span>{{ $t('documentCollection.rerankEnable') }}</span>
|
||||
<ElTag
|
||||
round
|
||||
:type="knowledge.rerankEnable ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
>
|
||||
{{
|
||||
knowledge.rerankEnable
|
||||
? $t('approval.snapshot.enabled')
|
||||
: $t('approval.snapshot.disabled')
|
||||
}}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="snapshot-card__toggle">
|
||||
<span>{{ $t('approval.snapshot.canUpdateEmbeddingModel') }}</span>
|
||||
<ElTag
|
||||
round
|
||||
:type="knowledge.canUpdateEmbeddingModel ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
>
|
||||
{{
|
||||
knowledge.canUpdateEmbeddingModel
|
||||
? $t('approval.snapshot.enabled')
|
||||
: $t('approval.snapshot.disabled')
|
||||
}}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="snapshot-card__info-grid">
|
||||
<div
|
||||
v-for="item in configItems"
|
||||
:key="item.label"
|
||||
class="snapshot-card__info-item"
|
||||
>
|
||||
<div class="snapshot-card__label">{{ item.label }}</div>
|
||||
<div class="snapshot-card__value">{{ formatValue(item.value) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.snapshot-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.snapshot-card__hero,
|
||||
.snapshot-card__section {
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.54);
|
||||
border-radius: 20px;
|
||||
background: hsl(var(--surface-contrast-soft) / 0.9);
|
||||
}
|
||||
|
||||
.snapshot-card__hero {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.snapshot-card__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.5);
|
||||
background: hsl(var(--surface) / 0.96);
|
||||
}
|
||||
|
||||
.snapshot-card__avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.snapshot-card__avatar--placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.snapshot-card__hero-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.snapshot-card__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.snapshot-card__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__description {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__meta-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.snapshot-card__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.snapshot-card__section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__info-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.snapshot-card__info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: hsl(var(--surface-panel) / 0.88);
|
||||
}
|
||||
|
||||
.snapshot-card__label {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.snapshot-card__value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.snapshot-card__toggles {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.snapshot-card__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: hsl(var(--surface-panel) / 0.88);
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.snapshot-card__hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.snapshot-card__title-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.snapshot-card__meta-tags {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.snapshot-card__info-grid,
|
||||
.snapshot-card__toggles {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { usePreferences } from '@easyflow/preferences';
|
||||
|
||||
import {
|
||||
sanitizeTinyflowDataForReadonlyPreview,
|
||||
Tinyflow,
|
||||
} from '@tinyflow-ai/vue';
|
||||
import { ElEmpty, ElSkeleton } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
import { getCustomNode } from '#/views/ai/workflow/customNode';
|
||||
|
||||
import '@tinyflow-ai/vue/dist/index.css';
|
||||
|
||||
const props = defineProps<{
|
||||
content?: null | string;
|
||||
description?: null | string;
|
||||
title?: null | string;
|
||||
}>();
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const customNodes = ref<null | Record<string, any>>(null);
|
||||
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
|
||||
|
||||
const parsedData = computed(() => {
|
||||
return sanitizeTinyflowDataForReadonlyPreview(props.content || '');
|
||||
});
|
||||
|
||||
const hasSnapshot = computed(() => !!String(props.content || '').trim());
|
||||
const parseFailed = computed(() => parsedData.value === undefined);
|
||||
const canRender = computed(
|
||||
() => !!customNodes.value && parsedData.value && !parseFailed.value,
|
||||
);
|
||||
const tinyflowKey = computed(
|
||||
() => `${isDark.value ? 'dark' : 'light'}:${String(props.content || '')}`,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
customNodes.value = await getCustomNode({});
|
||||
} catch {
|
||||
customNodes.value = {};
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [customNodes.value, parsedData.value, isDark.value],
|
||||
async ([nodes, data]) => {
|
||||
if (!nodes || !data || data === undefined) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
requestAnimationFrame(() => {
|
||||
void tinyflowRef.value?.fitView({
|
||||
duration: 0,
|
||||
padding: 0.18,
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workflow-snapshot-preview">
|
||||
<div class="workflow-snapshot-preview__meta">
|
||||
<div class="workflow-snapshot-preview__title">
|
||||
{{ title || $t('approval.message.workflowSnapshotUntitled') }}
|
||||
</div>
|
||||
<div v-if="description" class="workflow-snapshot-preview__description">
|
||||
{{ description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!customNodes" class="workflow-snapshot-preview__loading">
|
||||
<ElSkeleton animated :rows="6" />
|
||||
</div>
|
||||
|
||||
<ElEmpty
|
||||
v-else-if="!hasSnapshot"
|
||||
:description="$t('approval.message.workflowSnapshotMissing')"
|
||||
class="workflow-snapshot-preview__empty"
|
||||
/>
|
||||
|
||||
<ElEmpty
|
||||
v-else-if="parseFailed"
|
||||
:description="$t('approval.message.workflowSnapshotParseFailed')"
|
||||
class="workflow-snapshot-preview__empty"
|
||||
/>
|
||||
|
||||
<div v-else-if="canRender" class="workflow-snapshot-preview__canvas">
|
||||
<Tinyflow
|
||||
ref="tinyflowRef"
|
||||
:key="tinyflowKey"
|
||||
class-name="workflow-snapshot-preview__tinyflow"
|
||||
:data="parsedData"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
:custom-nodes="customNodes || undefined"
|
||||
:readonly="true"
|
||||
:hide-bottom-dock="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-snapshot-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.workflow-snapshot-preview__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workflow-snapshot-preview__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.workflow-snapshot-preview__description {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.workflow-snapshot-preview__loading,
|
||||
.workflow-snapshot-preview__empty,
|
||||
.workflow-snapshot-preview__canvas {
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.54);
|
||||
border-radius: 20px;
|
||||
background: hsl(var(--surface-contrast-soft) / 0.9);
|
||||
}
|
||||
|
||||
.workflow-snapshot-preview__loading,
|
||||
.workflow-snapshot-preview__empty {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.workflow-snapshot-preview__canvas {
|
||||
overflow: hidden;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
:deep(.workflow-snapshot-preview__tinyflow) {
|
||||
height: 520px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user