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

@@ -53,6 +53,22 @@ export const removeBotFromId = (id: string) => {
return api.post<RequestResult>('/api/v1/bot/remove', { id });
};
/** 提交 Bot 发布审批 */
export const submitBotPublishApproval = (id: string) => {
return api.post<RequestResult<number | string>>(
'/api/v1/bot/submitPublishApproval',
{ id },
);
};
/** 提交 Bot 删除审批 */
export const submitBotDeleteApproval = (id: string) => {
return api.post<RequestResult<number | string>>(
'/api/v1/bot/submitDeleteApproval',
{ id },
);
};
export interface GetMessageListParams {
conversationId: string;
botId: string;

View File

@@ -23,7 +23,7 @@ export type ActionTone = 'danger' | 'default';
export interface ActionButton {
icon?: any;
text: string;
text: ((row: any) => string) | string;
className?: string;
permission?: string;
placement?: ActionPlacement;
@@ -135,6 +135,10 @@ function handleActionClick(event: Event, action: ActionButton, item: any) {
event.stopPropagation();
action.onClick(item);
}
function resolveActionText(action: ActionButton, item: any) {
return typeof action.text === 'function' ? action.text(item) : action.text;
}
</script>
<template>
@@ -215,8 +219,8 @@ function handleActionClick(event: Event, action: ActionButton, item: any) {
@click.stop
>
<ElButton
v-for="action in inlineActions"
:key="action.text"
v-for="(action, actionIndex) in inlineActions"
:key="`${item.id ?? index}-inline-${actionIndex}`"
:icon="typeof action.icon === 'string' ? undefined : action.icon"
size="small"
class="card-action-btn"
@@ -227,7 +231,7 @@ function handleActionClick(event: Event, action: ActionButton, item: any) {
<template v-if="typeof action.icon === 'string'" #icon>
<IconifyIcon :icon="action.icon" />
</template>
{{ action.text }}
{{ resolveActionText(action, item) }}
</ElButton>
<ElDropdown
@@ -244,8 +248,8 @@ function handleActionClick(event: Event, action: ActionButton, item: any) {
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="action in menuActions"
:key="action.text"
v-for="(action, actionIndex) in menuActions"
:key="`${item.id ?? index}-menu-${actionIndex}`"
:class="{
'card-menu-item--danger': action.tone === 'danger',
}"
@@ -259,7 +263,7 @@ function handleActionClick(event: Event, action: ActionButton, item: any) {
/>
<component v-else :is="action.icon" />
</ElIcon>
<span>{{ action.text }}</span>
<span>{{ resolveActionText(action, item) }}</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>

View File

@@ -62,6 +62,15 @@
"subProcess": "SubProcess",
"workflowSelect": "WorkflowSelect",
"bochaSearch": "BochaSearch",
"publishStatusDraft": "Draft",
"publishStatusPublishPending": "Publish Pending",
"publishStatusPublished": "Published",
"publishStatusDeletePending": "Delete Pending",
"publishStatusLabel": "Release",
"submitPublishApprovalConfirm": "The current draft will enter the publish approval flow. It becomes externally available only after approval.",
"submitDeleteApprovalConfirm": "The workflow will enter the delete approval flow. It will be physically deleted only after approval.",
"publishPendingHint": "There is already an approval in progress for this workflow.",
"deletePendingHint": "There is already an approval in progress for this workflow.",
"check": "Check",
"checkPassed": "Workflow check passed",
"checkFailed": "Workflow check failed. Please fix the issues first",

View File

@@ -0,0 +1,161 @@
{
"title": "Approval Detail",
"tab": {
"flow": "Flow Config",
"pending": "Pending",
"processed": "Processed",
"initiated": "Initiated"
},
"resource": {
"bot": "Chat Assistant",
"workflow": "Workflow",
"knowledge": "Knowledge Base"
},
"action": {
"publish": "Publish",
"delete": "Delete",
"addFlow": "New Flow",
"editFlow": "Edit Flow",
"enableFlow": "Enable Flow",
"disableFlow": "Disable Flow",
"approve": "Approve",
"reject": "Reject",
"revoke": "Revoke",
"addScope": "Add Scope",
"addStep": "Add Step"
},
"scope": {
"category": "Category",
"dept": "Department"
},
"assignee": {
"role": "Role",
"user": "User"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled",
"pending": "Pending",
"processing": "Processing",
"approved": "Approved",
"rejected": "Rejected",
"revoked": "Revoked"
},
"section": {
"basic": "Basic Info",
"scope": "Scope Config",
"steps": "Approval Steps",
"tasks": "Approval Tasks",
"logs": "Approval Logs",
"snapshot": "Approval Snapshot"
},
"helper": {
"scope": "Limit the flow by resource category or applicant department. Leave empty to apply globally.",
"scopeEmpty": "No scope configured. This flow applies globally."
},
"fields": {
"flowName": "Flow Name",
"resourceType": "Resource Type",
"actionType": "Action Type",
"priority": "Priority",
"version": "Version",
"status": "Status",
"remark": "Remark",
"scopeSummary": "Scope Summary",
"stepCount": "Step Count",
"includeChildren": "Include Children",
"stepName": "Step Name",
"stepNoLabel": "Step No.",
"currentStep": "Current Step",
"summary": "Summary",
"resourceId": "Resource ID",
"taskId": "Approval Task ID",
"applicant": "Applicant",
"applicantId": "Applicant ID",
"submittedAt": "Submitted At",
"finishedAt": "Finished At",
"assigneeTarget": "Assignee",
"actedBy": "Acted By",
"actedAt": "Acted At",
"comment": "Comment",
"eventType": "Event Type",
"operatorId": "Operator ID",
"operatorName": "Operator Name",
"createdAt": "Created At",
"eventInfo": "Event Info",
"stepNo": "Step {value}"
},
"event": {
"approved": "Approved",
"rejected": "Rejected",
"revoked": "Revoked",
"stepCreated": "Step Created",
"submitted": "Submitted"
},
"placeholder": {
"flowName": "Search flow name",
"keyword": "Search summary",
"resourceType": "Filter resource type",
"actionType": "Filter action type",
"flowStatus": "Filter flow status",
"instanceStatus": "Status",
"scopeValue": "Select scope value",
"assigneeType": "Select assignee type",
"assigneeTarget": "Select assignee",
"stepName": "Enter step name",
"actionComment": "Enter a comment"
},
"message": {
"needStep": "At least one step is required",
"needStepAssignee": "Each approval step requires an assignee",
"saveSuccess": "Flow saved",
"statusUpdated": "Flow status updated",
"deleteSuccess": "Flow deleted",
"actionSuccess": "Approval action completed",
"confirmDeleteFlow": "This action cannot be undone. Continue?",
"confirmFlowStatus": "Confirm to {title}?",
"eventApproved": "Approval completed",
"eventApprovedStep": "Step {value} approved",
"eventRejected": "Approval rejected",
"eventRejectedStep": "Step {value} rejected",
"eventRevoked": "Approval revoked",
"eventRevokedStep": "Step {value} revoked",
"workflowSnapshotUntitled": "Untitled workflow snapshot",
"workflowSnapshotMissing": "Workflow snapshot not found",
"workflowSnapshotParseFailed": "Failed to parse workflow snapshot"
},
"snapshot": {
"knowledgeBasic": "Basic Info",
"knowledgeConfig": "Retrieval Config",
"botOverview": "Assistant Overview",
"botModelConfig": "Model Config",
"botBindings": "Capability Bindings",
"systemPrompt": "System Prompt",
"department": "Department",
"category": "Category",
"modelName": "Model Name",
"vectorStoreType": "Vector Store Type",
"vectorEmbedModel": "Embedding Model",
"rerankModel": "Rerank Model",
"maxMessageCount": "Max Context Messages",
"canUpdateEmbeddingModel": "Allow Embedding Update",
"anonymousEnabled": "Anonymous Access",
"anonymousDisabled": "Login Only",
"knowledgeBindings": "Knowledge Bases",
"workflowBindings": "Workflows",
"pluginBindings": "Plugins",
"mcpBindings": "MCP",
"expandPrompt": "Expand",
"collapsePrompt": "Collapse",
"enabled": "Enabled",
"disabled": "Disabled",
"notConfigured": "Not configured",
"noBindings": "No bindings",
"untitledKnowledge": "Untitled knowledge base",
"untitledBot": "Untitled assistant",
"unnamedKnowledge": "Unnamed knowledge base",
"unnamedWorkflow": "Unnamed workflow",
"unnamedPlugin": "Unnamed plugin",
"unnamedMcp": "Unnamed MCP"
}
}

View File

@@ -11,6 +11,20 @@
"deepThinking": "DeepThinking",
"enableDeepThinking": "EnableDeepThinking",
"publish": "Publish",
"publishStatusLabel": "Current Release",
"publishStatusDraft": "Draft",
"publishStatusDraftDesc": "Only the draft is saved. External chat and Public API are still unavailable.",
"publishStatusPublishPending": "Publish Pending",
"publishStatusPublishPendingDesc": "The assistant will switch to the new release after approval.",
"publishStatusPublished": "Published",
"publishStatusPublishedDesc": "The current release is externally available. Ongoing edits still stay in draft.",
"publishStatusDeletePending": "Delete Pending",
"publishStatusDeletePendingDesc": "The current release remains available, but it is no longer offered as a new binding candidate.",
"submitPublishApprovalConfirm": "Submit the current draft to publish approval. It becomes externally available only after approval.",
"submitDeleteApprovalConfirm": "Submit the bot to delete approval. It will be physically deleted only after approval.",
"publishPendingHint": "There is already an approval in progress for this bot.",
"deletePendingHint": "There is already an approval in progress for this bot.",
"publishRequiredHint": "There is no released version yet. Submit publish approval first.",
"postToWeChatOfficialAccount": "PostToWeChatOfficialAccount",
"publishExternalLink": "Publish External Chat Link",
"configured": "Configured",

View File

@@ -41,7 +41,10 @@
"markAsResolved": "MarkAsResolved",
"optimizing": "Optimizing",
"regenerate": "Regenerate",
"republish": "Republish",
"hide": "Hide",
"more": "Mode",
"submitDeleteApproval": "Submit Delete Approval",
"submitPublishApproval": "Submit Publish Approval",
"viewSegmentation": "ViewSegmentation"
}

View File

@@ -35,6 +35,15 @@
"documentType": "DocumentType",
"fileName": "fileName",
"knowledgeCount": "Number of knowledge items",
"publishStatusDraft": "Draft",
"publishStatusPublishPending": "Publish Pending",
"publishStatusPublished": "Published",
"publishStatusDeletePending": "Delete Pending",
"publishStatusLabel": "Release",
"submitPublishApprovalConfirm": "The knowledge base will enter the publish approval flow. It can be referenced by bots only after approval.",
"submitDeleteApprovalConfirm": "The knowledge base will enter the delete approval flow. It will be physically deleted only after approval.",
"publishPendingHint": "There is already an approval in progress for this knowledge base.",
"deletePendingHint": "There is already an approval in progress for this knowledge base.",
"createdModifyTime": "Creation/update time",
"documentList": "documentList",
"knowledgeRetrieval": "knowledgeRetrieval",

View File

@@ -13,6 +13,11 @@
"sysJob": "Job",
"sysLog": "Log",
"sysFeedback": "UserFeedback",
"approval": "Approval",
"approvalFlow": "Flow Setup",
"approvalPending": "Pending",
"approvalProcessed": "Processed",
"approvalInitiated": "Initiated",
"sysAppearance": "Appearance",
"oauth": "OAuth"
},

View File

@@ -62,6 +62,15 @@
"subProcess": "子流程",
"workflowSelect": "工作流选择",
"bochaSearch": "博查搜索",
"publishStatusDraft": "草稿",
"publishStatusPublishPending": "发布审批中",
"publishStatusPublished": "已发布",
"publishStatusDeletePending": "删除审批中",
"publishStatusLabel": "发布状态",
"submitPublishApprovalConfirm": "提交后会进入发布审批,审批通过后新版本才会正式对外可用。",
"submitDeleteApprovalConfirm": "提交后会进入删除审批,审批通过后将执行真实删除。",
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"check": "检查",
"checkPassed": "工作流检查通过",
"checkFailed": "工作流检查未通过,请先修复问题",

View File

@@ -0,0 +1,161 @@
{
"title": "审批详情",
"tab": {
"flow": "流程配置",
"pending": "待审批",
"processed": "已审批",
"initiated": "我发起"
},
"resource": {
"bot": "聊天助手",
"workflow": "工作流",
"knowledge": "知识库"
},
"action": {
"publish": "发布",
"delete": "删除",
"addFlow": "新建流程",
"editFlow": "编辑流程",
"enableFlow": "启用流程",
"disableFlow": "停用流程",
"approve": "通过",
"reject": "驳回",
"revoke": "撤回",
"addScope": "新增范围",
"addStep": "新增步骤"
},
"scope": {
"category": "分类",
"dept": "部门"
},
"assignee": {
"role": "角色",
"user": "用户"
},
"status": {
"enabled": "启用",
"disabled": "停用",
"pending": "待审批",
"processing": "审批中",
"approved": "已通过",
"rejected": "已驳回",
"revoked": "已撤回"
},
"section": {
"basic": "基础信息",
"scope": "范围配置",
"steps": "审批步骤",
"tasks": "审批任务",
"logs": "审批日志",
"snapshot": "审批快照"
},
"helper": {
"scope": "按资源分类或申请人部门限定流程命中范围;留空时默认对全部资源生效。",
"scopeEmpty": "未配置范围,当前流程默认全局生效。"
},
"fields": {
"flowName": "流程名称",
"resourceType": "资源类型",
"actionType": "动作类型",
"priority": "优先级",
"version": "版本",
"status": "状态",
"remark": "备注",
"scopeSummary": "命中范围",
"stepCount": "步骤数",
"includeChildren": "包含子级",
"stepName": "步骤名称",
"stepNoLabel": "步骤序号",
"currentStep": "当前步骤",
"summary": "审批摘要",
"resourceId": "资源ID",
"taskId": "审批任务ID",
"applicant": "申请人",
"applicantId": "申请人ID",
"submittedAt": "提交时间",
"finishedAt": "完成时间",
"assigneeTarget": "审批对象",
"actedBy": "处理人",
"actedAt": "处理时间",
"comment": "处理意见",
"eventType": "事件类型",
"operatorId": "操作人ID",
"operatorName": "操作人名称",
"createdAt": "创建时间",
"eventInfo": "事件信息",
"stepNo": "第 {value} 步"
},
"event": {
"approved": "审批通过",
"rejected": "审批驳回",
"revoked": "审批撤回",
"stepCreated": "步骤创建",
"submitted": "提交审批"
},
"placeholder": {
"flowName": "搜索流程名称",
"keyword": "搜索审批摘要",
"resourceType": "筛选资源类型",
"actionType": "筛选动作类型",
"flowStatus": "筛选流程状态",
"instanceStatus": "审批状态",
"scopeValue": "请选择范围值",
"assigneeType": "请选择审批方式",
"assigneeTarget": "请选择审批对象",
"stepName": "请输入步骤名称",
"actionComment": "请输入处理说明"
},
"message": {
"needStep": "至少需要一个审批步骤",
"needStepAssignee": "每个审批步骤都需要配置审批对象",
"saveSuccess": "审批流程已保存",
"statusUpdated": "流程状态已更新",
"deleteSuccess": "流程已删除",
"actionSuccess": "审批操作已完成",
"confirmDeleteFlow": "删除后将无法恢复,确认继续吗?",
"confirmFlowStatus": "确认执行{title}吗?",
"eventApproved": "审批已通过",
"eventApprovedStep": "第 {value} 步已通过",
"eventRejected": "审批已驳回",
"eventRejectedStep": "第 {value} 步已驳回",
"eventRevoked": "审批已撤回",
"eventRevokedStep": "第 {value} 步已撤回",
"workflowSnapshotUntitled": "未命名工作流快照",
"workflowSnapshotMissing": "未找到工作流快照",
"workflowSnapshotParseFailed": "工作流快照解析失败"
},
"snapshot": {
"knowledgeBasic": "基础信息",
"knowledgeConfig": "检索配置",
"botOverview": "助手概览",
"botModelConfig": "模型配置",
"botBindings": "能力绑定",
"systemPrompt": "系统提示词",
"department": "所属部门",
"category": "所属分类",
"modelName": "模型名称",
"vectorStoreType": "向量数据库类型",
"vectorEmbedModel": "向量模型",
"rerankModel": "重排模型",
"maxMessageCount": "最大上下文消息数",
"canUpdateEmbeddingModel": "允许更新向量模型",
"anonymousEnabled": "允许匿名访问",
"anonymousDisabled": "仅登录访问",
"knowledgeBindings": "知识库",
"workflowBindings": "工作流",
"pluginBindings": "插件",
"mcpBindings": "MCP",
"expandPrompt": "展开全文",
"collapsePrompt": "收起全文",
"enabled": "已开启",
"disabled": "已关闭",
"notConfigured": "未配置",
"noBindings": "未绑定任何能力",
"untitledKnowledge": "未命名知识库",
"untitledBot": "未命名聊天助手",
"unnamedKnowledge": "未命名知识库",
"unnamedWorkflow": "未命名工作流",
"unnamedPlugin": "未命名插件",
"unnamedMcp": "未命名MCP"
}
}

View File

@@ -11,6 +11,20 @@
"deepThinking": "深度思考",
"enableDeepThinking": "是否启用深度思考",
"publish": "发布",
"publishStatusLabel": "当前正式版本",
"publishStatusDraft": "草稿",
"publishStatusDraftDesc": "当前仅保存草稿,外链聊天和 Public API 仍不可用。",
"publishStatusPublishPending": "发布审批中",
"publishStatusPublishPendingDesc": "审批通过后,聊天助手会切换为新的正式版本。",
"publishStatusPublished": "已发布",
"publishStatusPublishedDesc": "当前正式版本已可对外使用,编辑中的草稿不会立即影响线上。",
"publishStatusDeletePending": "删除审批中",
"publishStatusDeletePendingDesc": "当前正式版本仍可访问,但不会继续作为新的绑定候选。",
"submitPublishApprovalConfirm": "提交后会进入发布审批,审批通过后聊天助手才会正式对外可用。",
"submitDeleteApprovalConfirm": "提交后会进入删除审批,审批通过后将执行真实删除。",
"publishPendingHint": "当前聊天助手已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前聊天助手已有进行中的审批,请等待处理完成。",
"publishRequiredHint": "当前还没有正式发布版本,请先提交发布审批。",
"postToWeChatOfficialAccount": "发布到微信公众号",
"publishExternalLink": "发布外链聊天页",
"configured": "已配置",

View File

@@ -41,7 +41,10 @@
"markAsResolved": "标记已处理",
"optimizing": "正在优化中...",
"regenerate": "重新生成",
"republish": "重新发布",
"hide": "隐藏",
"more": "更多",
"submitDeleteApproval": "提交删除审批",
"submitPublishApproval": "提交发布审批",
"viewSegmentation": "查看分段"
}

View File

@@ -35,6 +35,15 @@
"documentType": "文件类型",
"fileName": "文件名",
"knowledgeCount": "知识条数",
"publishStatusDraft": "草稿",
"publishStatusPublishPending": "发布审批中",
"publishStatusPublished": "已发布",
"publishStatusDeletePending": "删除审批中",
"publishStatusLabel": "发布状态",
"submitPublishApprovalConfirm": "提交后会进入发布审批,审批通过后该知识库才可作为正式版本被聊天助手引用。",
"submitDeleteApprovalConfirm": "提交后会进入删除审批,审批通过后将执行真实删除。",
"publishPendingHint": "当前知识库已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前知识库已有进行中的审批,请等待处理完成。",
"createdModifyTime": "创建/更新时间",
"documentList": "文档列表",
"knowledgeRetrieval": "知识检索",

View File

@@ -13,6 +13,11 @@
"sysJob": "定时任务",
"sysLog": "日志管理",
"sysFeedback": "用户反馈",
"approval": "审批管理",
"approvalFlow": "流程配置",
"approvalPending": "待审批",
"approvalProcessed": "已审批",
"approvalInitiated": "我发起",
"sysAppearance": "外观设置",
"oauth": "认证设置"
},

View File

@@ -0,0 +1,20 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
name: 'ApprovalDetail',
path: '/sys/approval/detail/:id',
component: () => import('#/views/system/approval/ApprovalDetail.vue'),
meta: {
title: $t('menus.system.approval'),
hideInMenu: true,
hideInBreadcrumb: true,
hideInTab: true,
activePath: '/sys/approval',
},
},
];
export default routes;

View File

@@ -1,9 +1,14 @@
declare module '@tinyflow-ai/vue' {
import type { DefineComponent } from 'vue';
import type { TinyflowData } from '@tinyflow-ai/ui';
export const Tinyflow: DefineComponent<
Record<string, any>,
Record<string, any>,
any
>;
export function sanitizeTinyflowDataForReadonlyPreview(
input: TinyflowData | string,
): TinyflowData | null | undefined;
}

View File

@@ -14,7 +14,7 @@ import { useRouter } from 'vue-router';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { $t } from '@easyflow/locales';
import { Delete, Edit, Plus, Setting } from '@element-plus/icons-vue';
import { Delete, Edit, Plus, Promotion, Setting } from '@element-plus/icons-vue';
import {
ElForm,
ElFormItem,
@@ -22,16 +22,22 @@ import {
ElInputNumber,
ElMessage,
ElMessageBox,
ElTag,
} from 'element-plus';
import { tryit } from 'radash';
import { removeBotFromId } from '#/api';
import { submitBotDeleteApproval, submitBotPublishApproval } from '#/api';
import { api } from '#/api/request';
import defaultAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import CardList from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {
isAiResourceApprovalPending,
isAiResourcePublished,
normalizeAiPublishStatus,
} from '#/views/ai/shared/publish-status';
import { useDictStore } from '#/store';
import Modal from './modal.vue';
@@ -97,37 +103,101 @@ const actions: ActionButton[] = [
},
},
{
icon: Delete,
text: $t('button.delete'),
tone: 'danger',
permission: '/api/v1/bot/remove',
icon: Promotion,
text: (row: BotInfo) =>
isAiResourcePublished(row.publishStatus)
? $t('button.republish')
: $t('button.submitPublishApproval'),
permission: '/api/v1/bot/save',
placement: 'inline',
onClick(row: BotInfo) {
removeBot(row);
handleSubmitPublishApproval(row);
},
},
{
icon: Delete,
text: $t('button.submitDeleteApproval'),
tone: 'danger',
permission: '/api/v1/bot/remove',
placement: 'menu',
onClick(row: BotInfo) {
handleSubmitDeleteApproval(row);
},
},
];
const removeBot = async (bot: BotInfo) => {
const [action] = await tryit(ElMessageBox.confirm)(
$t('message.deleteAlert'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
},
);
if (!action) {
const [err, res] = await tryit(removeBotFromId)(bot.id);
if (!err && res.errorCode === 0) {
ElMessage.success($t('message.deleteOkMessage'));
pageDataRef.value.setQuery({});
}
const handleSubmitPublishApproval = async (bot: BotInfo) => {
if (isAiResourceApprovalPending(bot.publishStatus)) {
ElMessage.warning($t('bot.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'info',
},
);
} catch {
return;
}
const res = await submitBotPublishApproval(String(bot.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
pageDataRef.value?.reload?.();
}
};
const handleSubmitDeleteApproval = async (bot: BotInfo) => {
if (isAiResourceApprovalPending(bot.publishStatus)) {
ElMessage.warning($t('bot.deletePendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitDeleteApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch {
return;
}
const res = await submitBotDeleteApproval(String(bot.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
pageDataRef.value?.reload?.();
}
};
function resolvePublishStatusMeta(status?: string) {
switch (normalizeAiPublishStatus(status)) {
case 'PUBLISHED':
return {
label: $t('bot.publishStatusPublished'),
type: 'success' as const,
};
case 'PUBLISH_PENDING':
return {
label: $t('bot.publishStatusPublishPending'),
type: 'warning' as const,
};
case 'DELETE_PENDING':
return {
label: $t('bot.publishStatusDeletePending'),
type: 'danger' as const,
};
default:
return {
label: $t('bot.publishStatusDraft'),
type: 'info' as const,
};
}
}
const handleSearch = (params: string) => {
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
@@ -302,7 +372,18 @@ const getSideList = async () => {
:data="pageList"
:primary-action="primaryAction"
:actions="actions"
/>
>
<template #corner="{ item }">
<ElTag
size="small"
effect="plain"
round
:type="resolvePublishStatusMeta(item.publishStatus).type"
>
{{ resolvePublishStatusMeta(item.publishStatus).label }}
</ElTag>
</template>
</CardList>
</template>
</PageData>
</div>

View File

@@ -34,12 +34,15 @@ import {
ElSkeleton,
ElSlider,
ElSwitch,
ElTag,
ElTooltip,
} from 'element-plus';
import { tryit } from 'radash';
import {
getPerQuestions,
submitBotDeleteApproval,
submitBotPublishApproval,
updateBotApi,
updateBotOptions,
updateLlmId,
@@ -52,6 +55,12 @@ import CollapseViewItem from '#/components/collapseViewItem/CollapseViewItem.vue
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
import DictSelect from '#/components/dict/DictSelect.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import {
isAiResourceApprovalPending,
isAiResourceExternallyVisible,
isAiResourcePublished,
normalizeAiPublishStatus,
} from '#/views/ai/shared/publish-status';
interface SelectedMcpTool {
name: string;
@@ -154,6 +163,46 @@ const publicChatUrl = computed(() => {
const publicChatEmbedUrl = computed(() => {
return buildPublicChatUrl(true);
});
const publishStatusMeta = computed<{
description: string;
label: string;
type: 'danger' | 'info' | 'success' | 'warning';
}>(() => {
switch (normalizeAiPublishStatus(botInfo.value?.publishStatus)) {
case 'PUBLISHED':
return {
label: $t('bot.publishStatusPublished'),
type: 'success',
description: $t('bot.publishStatusPublishedDesc'),
};
case 'PUBLISH_PENDING':
return {
label: $t('bot.publishStatusPublishPending'),
type: 'warning',
description: $t('bot.publishStatusPublishPendingDesc'),
};
case 'DELETE_PENDING':
return {
label: $t('bot.publishStatusDeletePending'),
type: 'danger',
description: $t('bot.publishStatusDeletePendingDesc'),
};
default:
return {
label: $t('bot.publishStatusDraft'),
type: 'info',
description: $t('bot.publishStatusDraftDesc'),
};
}
});
const canUsePublicAccess = computed(() =>
isAiResourceExternallyVisible(botInfo.value?.publishStatus),
);
const publishPrimaryActionLabel = computed(() =>
isAiResourcePublished(botInfo.value?.publishStatus)
? $t('button.republish')
: $t('button.submitPublishApproval'),
);
const iframeCode = computed(() => {
if (!publicChatEmbedUrl.value) {
return '';
@@ -494,6 +543,10 @@ const handleCopyValue = async (value: string, successMessage?: string) => {
};
const openPublicPage = () => {
if (!canUsePublicAccess.value) {
ElMessage.warning($t('bot.publishRequiredHint'));
return;
}
if (!publicChatUrl.value) {
ElMessage.warning($t('bot.chatPublishBaseUrlMissing'));
return;
@@ -767,6 +820,64 @@ const handleDeletePresetQuestion = (item: any) => {
const handlePublishWx = () => {
publishWxRef.value.openDialog(botId.value, botInfo.value?.options || {});
};
const handleSubmitPublishApproval = async () => {
if (!botInfo.value) {
return;
}
if (isAiResourceApprovalPending(botInfo.value.publishStatus)) {
ElMessage.warning($t('bot.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'info',
},
);
} catch {
return;
}
const res = await submitBotPublishApproval(String(botInfo.value.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
getBotDetail();
} else {
ElMessage.error(res.message || $t('message.saveFailMessage'));
}
};
const handleSubmitDeleteApproval = async () => {
if (!botInfo.value) {
return;
}
if (isAiResourceApprovalPending(botInfo.value.publishStatus)) {
ElMessage.warning($t('bot.deletePendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitDeleteApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch {
return;
}
const res = await submitBotDeleteApproval(String(botInfo.value.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
getBotDetail();
} else {
ElMessage.error(res.message || $t('message.saveFailMessage'));
}
};
const handleUpdatePublishWx = () => {
api
.post('/api/v1/bot/updateOptions', {
@@ -1352,6 +1463,44 @@ const handleBasicInfoChange = async (
<h1 class="text-base font-medium">
{{ $t('bot.publish') }}
</h1>
<div class="publish-summary-card">
<div class="publish-summary-main">
<div class="publish-summary-label">
{{ $t('bot.publishStatusLabel') }}
</div>
<div class="publish-summary-row">
<ElTag :type="publishStatusMeta.type" effect="plain" round>
{{ publishStatusMeta.label }}
</ElTag>
<span
v-if="botInfo?.currentApprovalInstanceId"
class="publish-summary-instance"
>
#{{ botInfo.currentApprovalInstanceId }}
</span>
</div>
<p class="publish-summary-desc">
{{ publishStatusMeta.description }}
</p>
</div>
<div class="publish-summary-actions">
<ElButton
type="primary"
:disabled="!hasSavePermission"
@click="handleSubmitPublishApproval"
>
{{ publishPrimaryActionLabel }}
</ElButton>
<ElButton
plain
type="danger"
:disabled="!hasSavePermission"
@click="handleSubmitDeleteApproval"
>
{{ $t('button.submitDeleteApproval') }}
</ElButton>
</div>
</div>
<div class="flex w-full flex-col justify-between rounded-lg">
<ElCollapse expand-icon-position="left">
<ElCollapseItem :title="$t('bot.postToWeChatOfficialAccount')">
@@ -1428,12 +1577,23 @@ const handleBasicInfoChange = async (
<label class="publish-external-label">
{{ $t('bot.chatExternalLink') }}
</label>
<div
v-if="!canUsePublicAccess"
class="publish-external-alert"
>
<ElAlert
:title="$t('bot.publishRequiredHint')"
type="info"
:closable="false"
/>
</div>
<ElInput :model-value="publicChatUrl" readonly />
<div class="publish-external-actions">
<ElButton
size="small"
type="primary"
plain
:disabled="!canUsePublicAccess"
@click="handleCopyValue(publicChatUrl)"
>
<ElIcon class="mr-1">
@@ -1441,7 +1601,12 @@ const handleBasicInfoChange = async (
</ElIcon>
{{ $t('bot.copyLink') }}
</ElButton>
<ElButton size="small" type="primary" @click="openPublicPage">
<ElButton
size="small"
type="primary"
:disabled="!canUsePublicAccess"
@click="openPublicPage"
>
<ElIcon class="mr-1">
<Link />
</ElIcon>
@@ -1477,6 +1642,7 @@ const handleBasicInfoChange = async (
<ElButton
size="small"
plain
:disabled="!canUsePublicAccess"
@click="handleCopyValue(iframeCode)"
>
<ElIcon class="mr-1">
@@ -1511,6 +1677,7 @@ const handleBasicInfoChange = async (
width="730"
ref="knowledgeDataRef"
page-url="/api/v1/documentCollection/page"
:extra-query-params="{ publishedOnly: true }"
@get-data="confirmUpdateAiBotKnowledge"
/>
@@ -1520,6 +1687,7 @@ const handleBasicInfoChange = async (
width="730"
ref="workflowDataRef"
page-url="/api/v1/workflow/page"
:extra-query-params="{ publishedOnly: true }"
@get-data="confirmUpdateAiBotWorkflow"
/>
@@ -1643,6 +1811,58 @@ const handleBasicInfoChange = async (
background-color: var(--bot-collapse-itme-back);
}
.publish-summary-card {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 16px;
background: hsl(var(--surface-subtle) / 78%);
border: 1px solid hsl(var(--line-subtle));
border-radius: 16px;
}
.publish-summary-main {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
min-width: 220px;
}
.publish-summary-label {
font-size: 13px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.publish-summary-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.publish-summary-instance {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.publish-summary-desc {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--text-secondary));
}
.publish-summary-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.publish-wx {
display: flex;
align-items: center;

View File

@@ -44,6 +44,12 @@ import CardPage from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import {
isAiResourceApprovalPending,
isAiResourcePublished,
normalizeAiPublishStatus,
} from '#/views/ai/shared/publish-status';
const router = useRouter();
const userStore = useUserStore();
@@ -178,16 +184,31 @@ const actions: ActionButton[] = [
},
},
{
text: $t('button.delete'),
icon: Delete,
tone: 'danger',
permission: '/api/v1/documentCollection/remove',
icon: Promotion,
text: (row) =>
isAiResourcePublished(row.publishStatus)
? $t('button.republish')
: $t('button.submitPublishApproval'),
permission: '/api/v1/documentCollection/save',
placement: 'inline',
onClick(row) {
if (!ensureManageKnowledgeItem(row)) {
return;
}
handleDelete(row);
submitPublishApproval(row);
},
},
{
text: $t('button.submitDeleteApproval'),
icon: Delete,
tone: 'danger',
permission: '/api/v1/documentCollection/remove',
placement: 'menu',
onClick(row) {
if (!ensureManageKnowledgeItem(row)) {
return;
}
submitDeleteApproval(row);
},
},
];
@@ -195,24 +216,92 @@ const actions: ActionButton[] = [
onMounted(() => {
getCategoryList();
});
const handleDelete = (item: any) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
})
.then(() => {
api
.post('/api/v1/documentCollection/remove', { id: item.id })
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.deleteOkMessage'));
pageDataRef.value.setQuery({});
}
});
})
.catch(() => {});
const submitPublishApproval = async (item: any) => {
if (isAiResourceApprovalPending(item.publishStatus)) {
ElMessage.warning($t('documentCollection.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('documentCollection.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'info',
},
);
} catch {
return;
}
const res = await api.post(
'/api/v1/documentCollection/submitPublishApproval',
{
id: item.id,
},
);
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
reloadKnowledgeList();
}
};
const submitDeleteApproval = async (item: any) => {
if (isAiResourceApprovalPending(item.publishStatus)) {
ElMessage.warning($t('documentCollection.deletePendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('documentCollection.submitDeleteApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch {
return;
}
const res = await api.post(
'/api/v1/documentCollection/submitDeleteApproval',
{
id: item.id,
},
);
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
reloadKnowledgeList();
}
};
function resolvePublishStatusMeta(status?: string) {
switch (normalizeAiPublishStatus(status)) {
case 'DELETE_PENDING': {
return {
label: $t('documentCollection.publishStatusDeletePending'),
tone: 'danger',
};
}
case 'PUBLISH_PENDING': {
return {
label: $t('documentCollection.publishStatusPublishPending'),
tone: 'pending',
};
}
case 'PUBLISHED': {
return {
label: $t('documentCollection.publishStatusPublished'),
tone: 'published',
};
}
default: {
return {
label: $t('documentCollection.publishStatusDraft'),
tone: 'draft',
};
}
}
}
const pageDataRef = ref();
const aiKnowledgeModalRef = ref();
@@ -460,21 +549,97 @@ function changeCategory(category: any) {
:tag-map="collectionTypeLabelMap"
>
<template #corner="{ item }">
<ElPopover
v-if="canManageKnowledgeItem(item)"
:ref="(el) => setVisibilityScopePopoverRef(item.id, el)"
trigger="click"
placement="bottom-end"
:width="208"
popper-class="knowledge-visibility-popover"
>
<template #reference>
<button
type="button"
class="knowledge-scope-chip"
<AiResourceCornerMeta>
<template #publish>
<div
class="knowledge-publish-chip"
:class="`knowledge-publish-chip--${resolvePublishStatusMeta(item.publishStatus).tone}`"
>
<span class="knowledge-publish-chip__dot"></span>
<span>{{
resolvePublishStatusMeta(item.publishStatus).label
}}</span>
</div>
</template>
<template #scope>
<ElPopover
v-if="canManageKnowledgeItem(item)"
:ref="(el) => setVisibilityScopePopoverRef(item.id, el)"
trigger="click"
placement="bottom-end"
:width="208"
popper-class="knowledge-visibility-popover"
>
<template #reference>
<button
type="button"
class="knowledge-scope-chip"
:class="`knowledge-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
:disabled="updatingScopeId === item.id"
@click.stop
>
<ElIcon class="knowledge-scope-chip__icon">
<component
:is="
resolveVisibilityScopeMeta(item.visibilityScope)
.icon
"
/>
</ElIcon>
<span class="knowledge-scope-chip__label">
{{
resolveVisibilityScopeMeta(item.visibilityScope)
.label
}}
</span>
</button>
</template>
<div class="knowledge-scope-panel" @click.stop>
<button
v-for="option in visibilityScopeOptions"
:key="option.value"
type="button"
class="knowledge-scope-option"
:class="[
`knowledge-scope-option--${option.tone}`,
{
'knowledge-scope-option--active':
item.visibilityScope === option.value,
},
]"
:disabled="updatingScopeId === item.id"
@click.stop="
updateVisibilityScope(item, option.value)
"
>
<span class="knowledge-scope-option__leading">
<span class="knowledge-scope-option__icon-wrap">
<ElIcon class="knowledge-scope-option__icon">
<component :is="option.icon" />
</ElIcon>
</span>
<span class="knowledge-scope-option__text">
<span class="knowledge-scope-option__label">
{{ option.label }}
</span>
<span class="knowledge-scope-option__desc">
{{ option.description }}
</span>
</span>
</span>
<ElIcon
v-if="item.visibilityScope === option.value"
class="knowledge-scope-option__check"
>
<Check />
</ElIcon>
</button>
</div>
</ElPopover>
<div
v-else
class="knowledge-scope-chip knowledge-scope-chip--readonly"
:class="`knowledge-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
:disabled="updatingScopeId === item.id"
@click.stop
>
<ElIcon class="knowledge-scope-chip__icon">
<component
@@ -489,64 +654,9 @@ function changeCategory(category: any) {
resolveVisibilityScopeMeta(item.visibilityScope).label
}}
</span>
</button>
</div>
</template>
<div class="knowledge-scope-panel" @click.stop>
<button
v-for="option in visibilityScopeOptions"
:key="option.value"
type="button"
class="knowledge-scope-option"
:class="[
`knowledge-scope-option--${option.tone}`,
{
'knowledge-scope-option--active':
item.visibilityScope === option.value,
},
]"
:disabled="updatingScopeId === item.id"
@click.stop="updateVisibilityScope(item, option.value)"
>
<span class="knowledge-scope-option__leading">
<span class="knowledge-scope-option__icon-wrap">
<ElIcon class="knowledge-scope-option__icon">
<component :is="option.icon" />
</ElIcon>
</span>
<span class="knowledge-scope-option__text">
<span class="knowledge-scope-option__label">
{{ option.label }}
</span>
<span class="knowledge-scope-option__desc">
{{ option.description }}
</span>
</span>
</span>
<ElIcon
v-if="item.visibilityScope === option.value"
class="knowledge-scope-option__check"
>
<Check />
</ElIcon>
</button>
</div>
</ElPopover>
<div
v-else
class="knowledge-scope-chip knowledge-scope-chip--readonly"
:class="`knowledge-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
>
<ElIcon class="knowledge-scope-chip__icon">
<component
:is="
resolveVisibilityScopeMeta(item.visibilityScope).icon
"
/>
</ElIcon>
<span class="knowledge-scope-chip__label">
{{ resolveVisibilityScopeMeta(item.visibilityScope).label }}
</span>
</div>
</AiResourceCornerMeta>
</template>
</CardPage>
</template>
@@ -600,6 +710,54 @@ function changeCategory(category: any) {
</template>
<style scoped>
.knowledge-publish-chip {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
min-height: 22px;
min-width: 70px;
padding: 0 9px;
font-size: 11px;
font-weight: 600;
line-height: 1;
border: 1px solid transparent;
border-radius: 999px;
box-shadow: inset 0 1px 0 hsl(var(--card) / 46%);
}
.knowledge-publish-chip__dot {
width: 6px;
height: 6px;
background: currentColor;
border-radius: 999px;
opacity: 0.88;
}
.knowledge-publish-chip--draft {
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 42%);
border-color: hsl(var(--line-subtle));
}
.knowledge-publish-chip--pending {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
border-color: hsl(var(--warning) / 14%);
}
.knowledge-publish-chip--published {
color: hsl(var(--success));
background: hsl(var(--success) / 12%);
border-color: hsl(var(--success) / 14%);
}
.knowledge-publish-chip--danger {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 10%);
border-color: hsl(var(--destructive) / 14%);
}
h1 {
margin-bottom: 30px;
color: #303133;
@@ -608,16 +766,18 @@ h1 {
.knowledge-scope-chip {
display: inline-flex;
gap: 8px;
gap: 6px;
align-items: center;
min-height: 30px;
padding: 0 12px;
font-size: 12px;
justify-content: center;
min-height: 22px;
min-width: 70px;
padding: 0 9px;
font-size: 11px;
font-weight: 600;
line-height: 1;
color: hsl(var(--text-strong));
background: hsl(var(--surface-subtle) / 92%);
border: 1px solid hsl(var(--line-subtle));
background: transparent;
border: 1px solid transparent;
border-radius: 999px;
transition:
border-color 0.18s ease,
@@ -632,8 +792,8 @@ button.knowledge-scope-chip {
}
button.knowledge-scope-chip:hover {
box-shadow: 0 10px 22px -18px hsl(var(--foreground) / 32%);
transform: translateY(-1px);
background: hsl(var(--card) / 76%);
box-shadow: 0 8px 16px -16px hsl(var(--foreground) / 28%);
}
button.knowledge-scope-chip:focus-visible {
@@ -646,7 +806,6 @@ button.knowledge-scope-chip:focus-visible {
button.knowledge-scope-chip:disabled {
cursor: not-allowed;
opacity: 0.72;
transform: none;
}
.knowledge-scope-chip--readonly {
@@ -654,7 +813,7 @@ button.knowledge-scope-chip:disabled {
}
.knowledge-scope-chip__icon {
font-size: 14px;
font-size: 12px;
}
.knowledge-scope-chip__label {
@@ -663,20 +822,20 @@ button.knowledge-scope-chip:disabled {
.knowledge-scope-chip--private {
color: hsl(var(--primary));
background: hsl(var(--primary) / 9%);
border-color: hsl(var(--primary) / 20%);
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 14%);
}
.knowledge-scope-chip--dept {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
border-color: hsl(var(--warning) / 20%);
border-color: hsl(var(--warning) / 14%);
}
.knowledge-scope-chip--public {
color: hsl(var(--success));
background: hsl(var(--success) / 12%);
border-color: hsl(var(--success) / 20%);
border-color: hsl(var(--success) / 14%);
}
.knowledge-scope-panel {

View File

@@ -0,0 +1,27 @@
<template>
<div class="ai-resource-corner-meta">
<div class="ai-resource-corner-meta__item">
<slot name="publish"></slot>
</div>
<div class="ai-resource-corner-meta__item">
<slot name="scope"></slot>
</div>
</div>
</template>
<style scoped>
.ai-resource-corner-meta {
display: flex;
gap: 6px;
align-items: center;
justify-content: flex-end;
}
.ai-resource-corner-meta__item {
display: flex;
align-items: center;
display: flex;
justify-content: flex-end;
min-width: 0;
}
</style>

View File

@@ -0,0 +1,51 @@
export type AiPublishStatus =
| 'DELETE_PENDING'
| 'DRAFT'
| 'PUBLISHED'
| 'PUBLISH_PENDING';
/**
* 规范化发布状态,避免页面散落默认值判断。
*/
export function normalizeAiPublishStatus(
value?: null | string,
): AiPublishStatus {
switch (value) {
case 'PUBLISHED':
case 'PUBLISH_PENDING':
case 'DELETE_PENDING':
return value;
default:
return 'DRAFT';
}
}
/**
* 当前资源是否已有正式线上版本。
*/
export function isAiResourcePublished(value?: null | string) {
return normalizeAiPublishStatus(value) === 'PUBLISHED';
}
/**
* 当前资源是否允许对外可见。
*/
export function isAiResourceExternallyVisible(value?: null | string) {
const normalized = normalizeAiPublishStatus(value);
return normalized === 'PUBLISHED' || normalized === 'DELETE_PENDING';
}
/**
* 当前资源是否允许作为新的 Bot 引用候选。
*/
export function isAiResourceSelectableForBot(value?: null | string) {
return normalizeAiPublishStatus(value) === 'PUBLISHED';
}
/**
* 当前资源是否处于审批处理中。
*/
export function isAiResourceApprovalPending(value?: null | string) {
const normalized = normalizeAiPublishStatus(value);
return normalized === 'PUBLISH_PENDING' || normalized === 'DELETE_PENDING';
}

View File

@@ -5,15 +5,30 @@ import { useRoute } from 'vue-router';
import { usePreferences } from '@easyflow/preferences';
import { getOptions, sortNodes } from '@easyflow/utils';
import { ArrowLeft, CircleCheck, Close } from '@element-plus/icons-vue';
import {
ArrowLeft,
CircleCheck,
Close,
Promotion,
} from '@element-plus/icons-vue';
import { Tinyflow } from '@tinyflow-ai/vue';
import { ElButton, ElDrawer, ElMessage, ElSkeleton } from 'element-plus';
import {
ElButton,
ElDrawer,
ElMessage,
ElMessageBox,
ElSkeleton,
} from 'element-plus';
import { api } from '#/api/request';
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
import { $t } from '#/locales';
import { router } from '#/router';
import { getIconByValue } from '#/views/ai/model/modelUtils/defaultIcon';
import {
isAiResourceApprovalPending,
normalizeAiPublishStatus,
} from '#/views/ai/shared/publish-status';
import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
@@ -135,6 +150,7 @@ const customNode = ref();
const showTinyFlow = ref(false);
const saveLoading = ref(false);
const checkLoading = ref(false);
const publishLoading = ref(false);
const checkIssuesVisible = ref(false);
const checkResult = ref<any>(null);
const checkContentSnapshot = ref<any>(null);
@@ -234,6 +250,30 @@ const pluginSelectRef = ref();
const updatePluginNode = ref<any>(null);
const pageLoading = ref(false);
const chainInfo = ref<any>(null);
const publishActionText = computed(() => {
switch (normalizeAiPublishStatus(workflowInfo.value?.publishStatus)) {
case 'DELETE_PENDING': {
return $t('aiWorkflow.publishStatusDeletePending');
}
case 'PUBLISH_PENDING': {
return $t('aiWorkflow.publishStatusPublishPending');
}
case 'PUBLISHED': {
return `${$t('aiWorkflow.publishStatusPublished')} · ${$t('button.republish')}`;
}
default: {
return `${$t('aiWorkflow.publishStatusDraft')} · ${$t('button.submitPublishApproval')}`;
}
}
});
const publishActionDisabled = computed(
() =>
!workflowId.value ||
saveLoading.value ||
checkLoading.value ||
publishLoading.value ||
isAiResourceApprovalPending(workflowInfo.value?.publishStatus),
);
function syncNavTitle(title: string) {
if (!title) {
@@ -458,6 +498,44 @@ function closeCheckIssues() {
async function handleCheck() {
await runCheck('PRE_EXECUTE');
}
async function handlePublish() {
if (publishLoading.value) {
return;
}
if (isAiResourceApprovalPending(workflowInfo.value?.publishStatus)) {
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('aiWorkflow.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'info',
},
);
} catch {
return;
}
const saved = await handleSave();
if (!saved) {
return;
}
publishLoading.value = true;
try {
const res = await api.post('/api/v1/workflow/submitPublishApproval', {
id: workflowId.value,
});
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
await getWorkflowInfo(workflowId.value);
}
} finally {
publishLoading.value = false;
}
}
function onSubmit() {
initState.value = !initState.value;
}
@@ -584,7 +662,7 @@ function onAsyncExecute(info: any) {
<div class="workflow-head-actions">
<ElButton
:loading="checkLoading"
:disabled="saveLoading"
:disabled="saveLoading || publishLoading"
:icon="CircleCheck"
@click="handleCheck"
>
@@ -592,11 +670,21 @@ function onAsyncExecute(info: any) {
</ElButton>
<ElButton
type="primary"
:disabled="saveLoading || checkLoading"
:disabled="saveLoading || checkLoading || publishLoading"
@click="handleSave(true)"
>
{{ $t('button.save') }}(ctrl+s)
</ElButton>
<ElButton
:icon="Promotion"
:loading="publishLoading"
:disabled="publishActionDisabled"
class="workflow-publish-button"
:class="`workflow-publish-button--${normalizeAiPublishStatus(workflowInfo?.publishStatus)}`"
@click="handlePublish"
>
{{ publishActionText }}
</ElButton>
</div>
</div>
<Tinyflow
@@ -695,8 +783,42 @@ function onAsyncExecute(info: any) {
.workflow-head-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
:deep(.workflow-publish-button.el-button) {
box-shadow: none;
}
:deep(.workflow-publish-button.el-button:not(.is-disabled)) {
background: transparent;
}
:deep(.workflow-publish-button--DRAFT.el-button) {
color: hsl(var(--foreground) / 72%);
background: hsl(var(--muted) / 68%);
border-color: hsl(var(--foreground) / 14%);
}
:deep(.workflow-publish-button--PUBLISH_PENDING.el-button) {
color: hsl(var(--warning));
background: hsl(var(--warning) / 18%);
border-color: hsl(var(--warning) / 24%);
}
:deep(.workflow-publish-button--PUBLISHED.el-button) {
color: hsl(var(--success));
background: hsl(var(--success) / 18%);
border-color: hsl(var(--success) / 24%);
}
:deep(.workflow-publish-button--DELETE_PENDING.el-button) {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 16%);
border-color: hsl(var(--destructive) / 24%);
}
.tiny-flow-container {

View File

@@ -48,6 +48,12 @@ import PageSide from '#/components/page/PageSide.vue';
import { $t } from '#/locales';
import { router } from '#/router';
import { useDictStore } from '#/store';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import {
isAiResourceApprovalPending,
isAiResourcePublished,
normalizeAiPublishStatus,
} from '#/views/ai/shared/publish-status';
import WorkflowModal from './WorkflowModal.vue';
@@ -167,12 +173,25 @@ const actions: ActionButton[] = [
},
},
{
icon: Delete,
text: $t('button.delete'),
tone: 'danger',
icon: Promotion,
text: (row: any) =>
isAiResourcePublished(row.publishStatus)
? $t('button.republish')
: $t('button.submitPublishApproval'),
permission: '/api/v1/workflow/save',
placement: 'inline',
onClick: (row: any) => {
remove(row);
submitPublishApproval(row);
},
},
{
icon: Delete,
text: $t('button.submitDeleteApproval'),
tone: 'danger',
permission: '/api/v1/workflow/remove',
placement: 'menu',
onClick: (row: any) => {
submitDeleteApproval(row);
},
},
];
@@ -263,32 +282,85 @@ function showDialog(row: any, importMode = false) {
function resolveNavTitle(row: any) {
return row?.title || row?.name || '';
}
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/workflow/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset();
done();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
async function submitPublishApproval(row: any) {
if (isAiResourceApprovalPending(row.publishStatus)) {
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('aiWorkflow.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'info',
},
);
} catch {
return;
}
const res = await api.post('/api/v1/workflow/submitPublishApproval', {
id: row.id,
});
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
pageDataRef.value?.reload?.();
}
}
async function submitDeleteApproval(row: any) {
if (isAiResourceApprovalPending(row.publishStatus)) {
ElMessage.warning($t('aiWorkflow.deletePendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('aiWorkflow.submitDeleteApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch {
return;
}
const res = await api.post('/api/v1/workflow/submitDeleteApproval', {
id: row.id,
});
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
pageDataRef.value?.reload?.();
}
}
function resolvePublishStatusMeta(status?: string) {
switch (normalizeAiPublishStatus(status)) {
case 'DELETE_PENDING': {
return {
label: $t('aiWorkflow.publishStatusDeletePending'),
tone: 'danger',
};
}
case 'PUBLISH_PENDING': {
return {
label: $t('aiWorkflow.publishStatusPublishPending'),
tone: 'pending',
};
}
case 'PUBLISHED': {
return {
label: $t('aiWorkflow.publishStatusPublished'),
tone: 'published',
};
}
default: {
return {
label: $t('aiWorkflow.publishStatusDraft'),
tone: 'draft',
};
}
}
}
function toDesignPage(row: any) {
router.push({
@@ -496,21 +568,97 @@ function handleHeaderButtonClick(data: any) {
:actions="actions"
>
<template #corner="{ item }">
<ElPopover
v-if="canManageWorkflow"
:ref="(el) => setVisibilityScopePopoverRef(item.id, el)"
trigger="click"
placement="bottom-end"
:width="208"
popper-class="workflow-visibility-popover"
>
<template #reference>
<button
type="button"
class="workflow-scope-chip"
<AiResourceCornerMeta>
<template #publish>
<div
class="workflow-publish-chip"
:class="`workflow-publish-chip--${resolvePublishStatusMeta(item.publishStatus).tone}`"
>
<span class="workflow-publish-chip__dot"></span>
<span>{{
resolvePublishStatusMeta(item.publishStatus).label
}}</span>
</div>
</template>
<template #scope>
<ElPopover
v-if="canManageWorkflow"
:ref="(el) => setVisibilityScopePopoverRef(item.id, el)"
trigger="click"
placement="bottom-end"
:width="208"
popper-class="workflow-visibility-popover"
>
<template #reference>
<button
type="button"
class="workflow-scope-chip"
:class="`workflow-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
:disabled="updatingScopeId === item.id"
@click.stop
>
<ElIcon class="workflow-scope-chip__icon">
<component
:is="
resolveVisibilityScopeMeta(item.visibilityScope)
.icon
"
/>
</ElIcon>
<span class="workflow-scope-chip__label">
{{
resolveVisibilityScopeMeta(item.visibilityScope)
.label
}}
</span>
</button>
</template>
<div class="workflow-scope-panel" @click.stop>
<button
v-for="option in visibilityScopeOptions"
:key="option.value"
type="button"
class="workflow-scope-option"
:class="[
`workflow-scope-option--${option.tone}`,
{
'workflow-scope-option--active':
item.visibilityScope === option.value,
},
]"
:disabled="updatingScopeId === item.id"
@click.stop="
updateVisibilityScope(item, option.value)
"
>
<span class="workflow-scope-option__leading">
<span class="workflow-scope-option__icon-wrap">
<ElIcon class="workflow-scope-option__icon">
<component :is="option.icon" />
</ElIcon>
</span>
<span class="workflow-scope-option__text">
<span class="workflow-scope-option__label">
{{ option.label }}
</span>
<span class="workflow-scope-option__desc">
{{ option.description }}
</span>
</span>
</span>
<ElIcon
v-if="item.visibilityScope === option.value"
class="workflow-scope-option__check"
>
<Check />
</ElIcon>
</button>
</div>
</ElPopover>
<div
v-else
class="workflow-scope-chip workflow-scope-chip--readonly"
:class="`workflow-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
:disabled="updatingScopeId === item.id"
@click.stop
>
<ElIcon class="workflow-scope-chip__icon">
<component
@@ -525,64 +673,9 @@ function handleHeaderButtonClick(data: any) {
resolveVisibilityScopeMeta(item.visibilityScope).label
}}
</span>
</button>
</div>
</template>
<div class="workflow-scope-panel" @click.stop>
<button
v-for="option in visibilityScopeOptions"
:key="option.value"
type="button"
class="workflow-scope-option"
:class="[
`workflow-scope-option--${option.tone}`,
{
'workflow-scope-option--active':
item.visibilityScope === option.value,
},
]"
:disabled="updatingScopeId === item.id"
@click.stop="updateVisibilityScope(item, option.value)"
>
<span class="workflow-scope-option__leading">
<span class="workflow-scope-option__icon-wrap">
<ElIcon class="workflow-scope-option__icon">
<component :is="option.icon" />
</ElIcon>
</span>
<span class="workflow-scope-option__text">
<span class="workflow-scope-option__label">
{{ option.label }}
</span>
<span class="workflow-scope-option__desc">
{{ option.description }}
</span>
</span>
</span>
<ElIcon
v-if="item.visibilityScope === option.value"
class="workflow-scope-option__check"
>
<Check />
</ElIcon>
</button>
</div>
</ElPopover>
<div
v-else
class="workflow-scope-chip workflow-scope-chip--readonly"
:class="`workflow-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
>
<ElIcon class="workflow-scope-chip__icon">
<component
:is="
resolveVisibilityScopeMeta(item.visibilityScope).icon
"
/>
</ElIcon>
<span class="workflow-scope-chip__label">
{{ resolveVisibilityScopeMeta(item.visibilityScope).label }}
</span>
</div>
</AiResourceCornerMeta>
</template>
</CardList>
</template>
@@ -631,18 +724,68 @@ function handleHeaderButtonClick(data: any) {
</template>
<style scoped>
.workflow-publish-chip {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
min-height: 22px;
min-width: 70px;
padding: 0 9px;
font-size: 11px;
font-weight: 600;
line-height: 1;
border: 1px solid transparent;
border-radius: 999px;
box-shadow: inset 0 1px 0 hsl(var(--card) / 46%);
}
.workflow-publish-chip__dot {
width: 6px;
height: 6px;
background: currentColor;
border-radius: 999px;
opacity: 0.88;
}
.workflow-publish-chip--draft {
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 42%);
border-color: hsl(var(--line-subtle));
}
.workflow-publish-chip--pending {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
border-color: hsl(var(--warning) / 14%);
}
.workflow-publish-chip--published {
color: hsl(var(--success));
background: hsl(var(--success) / 12%);
border-color: hsl(var(--success) / 14%);
}
.workflow-publish-chip--danger {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 10%);
border-color: hsl(var(--destructive) / 14%);
}
.workflow-scope-chip {
display: inline-flex;
gap: 8px;
gap: 6px;
align-items: center;
min-height: 30px;
padding: 0 12px;
font-size: 12px;
justify-content: center;
min-height: 22px;
min-width: 70px;
padding: 0 9px;
font-size: 11px;
font-weight: 600;
line-height: 1;
color: hsl(var(--text-strong));
background: hsl(var(--surface-subtle) / 92%);
border: 1px solid hsl(var(--line-subtle));
background: transparent;
border: 1px solid transparent;
border-radius: 999px;
transition:
border-color 0.18s ease,
@@ -657,8 +800,8 @@ button.workflow-scope-chip {
}
button.workflow-scope-chip:hover {
box-shadow: 0 10px 22px -18px hsl(var(--foreground) / 32%);
transform: translateY(-1px);
background: hsl(var(--card) / 76%);
box-shadow: 0 8px 16px -16px hsl(var(--foreground) / 28%);
}
button.workflow-scope-chip:focus-visible {
@@ -671,7 +814,6 @@ button.workflow-scope-chip:focus-visible {
button.workflow-scope-chip:disabled {
cursor: not-allowed;
opacity: 0.72;
transform: none;
}
.workflow-scope-chip--readonly {
@@ -679,7 +821,7 @@ button.workflow-scope-chip:disabled {
}
.workflow-scope-chip__icon {
font-size: 14px;
font-size: 12px;
}
.workflow-scope-chip__label {
@@ -688,20 +830,20 @@ button.workflow-scope-chip:disabled {
.workflow-scope-chip--private {
color: hsl(var(--primary));
background: hsl(var(--primary) / 9%);
border-color: hsl(var(--primary) / 20%);
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 14%);
}
.workflow-scope-chip--dept {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
border-color: hsl(var(--warning) / 20%);
border-color: hsl(var(--warning) / 14%);
}
.workflow-scope-chip--public {
color: hsl(var(--success));
background: hsl(var(--success) / 12%);
border-color: hsl(var(--success) / 20%);
border-color: hsl(var(--success) / 14%);
}
.workflow-scope-panel {

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>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -90,6 +90,7 @@ watchDebounced(
'svg:talk',
'svg:plugin',
'svg:workflow',
'svg:approval',
'svg:knowledge',
'svg:resource',
'svg:data-center',

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<g
fill="none"
stroke="currentColor"
stroke-width="1.15"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3.2" y="3.3" width="8.2" height="9.2" rx="1.6" />
<path d="M5.4 3.3c.1-.9.8-1.5 1.7-1.5h.4c.9 0 1.6.6 1.7 1.5" />
<path d="m5.2 8 1.4 1.4 2.6-2.8" />
<path d="M5.3 11.2h3.8" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 419 B

View File

@@ -21,6 +21,7 @@ const SvgTDesignIcon = createIconifyIcon('svg:tdesign-logo');
const SvgTalkIcon = createIconifyIcon('svg:talk');
const SvgPluginIcon = createIconifyIcon('svg:plugin');
const SvgWorkflowIcon = createIconifyIcon('svg:workflow');
const SvgApprovalIcon = createIconifyIcon('svg:approval');
const SvgKnowledgeIcon = createIconifyIcon('svg:knowledge');
const SvgResourceIcon = createIconifyIcon('svg:resource');
const SvgDataCenterIcon = createIconifyIcon('svg:data-center');
@@ -36,6 +37,7 @@ const SvgApiIcon = createIconifyIcon('svg:api');
export {
SvgAccountIcon,
SvgApprovalIcon,
SvgAntdvLogoIcon,
SvgApiIcon,
SvgAvatar1Icon,

View File

@@ -139,6 +139,18 @@ export class Tinyflow {
return true;
}
async fitView(options?: { duration?: number; padding?: number }) {
const flow = this._getFlowInstance();
if (!flow) {
return false;
}
await flow.fitView({
duration: options?.duration ?? 220,
padding: options?.padding ?? 0.2,
});
return true;
}
setTheme(theme: TinyflowTheme) {
this.options.theme = theme;
if (this.tinyflowEl) {

View File

@@ -20,6 +20,7 @@
} = $props();
let { data } = options;
let initialViewport = null;
if (typeof data === 'string') {
try {
@@ -28,7 +29,12 @@
console.error('Invalid JSON data:', data);
}
}
store.init((data as TinyflowData)?.nodes || [], (data as TinyflowData)?.edges || []);
initialViewport = (data as TinyflowData)?.viewport || null;
store.init(
(data as TinyflowData)?.nodes || [],
(data as TinyflowData)?.edges || [],
initialViewport,
);
setContext('tinyflow_options', options);
</script>

View File

@@ -57,9 +57,11 @@
let flowRootEl = $state<HTMLDivElement | null>(null);
let inlineNodePickerEl = $state<HTMLDivElement | null>(null);
let connectStartPoint = $state<{ x: number; y: number } | null>(null);
let canvasLocked = $state(false);
const asString = (value: unknown) => (value == null ? '' : String(value));
const options = getOptions();
const readonly = options.readonly === true;
let canvasLocked = $state(readonly);
const hideBottomDock = options.hideBottomDock === true;
const availableNodes = getAvailableNodes(options);
const onRunTest = options.onRunTest;
@@ -567,6 +569,9 @@
};
function handleGlobalPointerDown(event: PointerEvent) {
if (readonly) {
return;
}
if (!nodePickerVisible || !inlineNodePickerEl) {
return;
}
@@ -579,15 +584,19 @@
onMount(() => {
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('paste', handleGlobalPaste);
window.addEventListener('pointerdown', handleGlobalPointerDown);
if (!readonly) {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('paste', handleGlobalPaste);
window.addEventListener('pointerdown', handleGlobalPointerDown);
}
});
onDestroy(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('paste', handleGlobalPaste);
window.removeEventListener('pointerdown', handleGlobalPointerDown);
if (!readonly) {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('paste', handleGlobalPaste);
window.removeEventListener('pointerdown', handleGlobalPointerDown);
}
});
const customNodeTypes = {
@@ -630,24 +639,30 @@
nodesDraggable={!canvasLocked}
nodesConnectable={!canvasLocked}
elementsSelectable={!canvasLocked}
panOnDrag={!canvasLocked}
zoomOnScroll={!canvasLocked}
zoomOnDoubleClick={!canvasLocked}
ondrop={onDrop}
ondragover={onDragOver}
panOnDrag={readonly ? true : !canvasLocked}
zoomOnScroll={readonly ? true : !canvasLocked}
zoomOnDoubleClick={readonly ? true : !canvasLocked}
ondrop={readonly ? undefined : onDrop}
ondragover={readonly ? undefined : onDragOver}
isValidConnection={isValidConnection}
onconnectend={onconnectend}
onconnectstart={onconnectstart}
onconnect={onconnect}
onconnectend={readonly ? undefined : onconnectend}
onconnectstart={readonly ? undefined : onconnectstart}
onconnect={readonly ? undefined : onconnect}
connectionRadius={50}
connectionLineComponent={FlowConnectionLine}
onedgeclick={(e) => {
if (readonly) {
return;
}
showEdgePanel = true;
currentEdge = e.edge;
}}
onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)}
ondelete={onDelete}
ondelete={readonly ? undefined : onDelete}
onclick={(e) => {
if (readonly) {
return;
}
const el = e.target as HTMLElement;
if (el.classList.contains("svelte-flow__edge-interaction")
|| el.classList.contains('panel-content')
@@ -750,6 +765,7 @@
<NodePicker nodes={availableNodes} onSelect={handlePickerSelectNode} />
</div>
{/if}
{#if !hideBottomDock}
<div class="tf-bottom-dock">
<div class="tf-unified-bar">
<!-- 缩放百分比选择器 -->
@@ -818,6 +834,7 @@
</button>
{/if}
</div>
{/if}
</div>
<style>

View File

@@ -2,27 +2,14 @@ import { store } from '#store/stores.svelte';
import { genShortId } from '#components/utils/IdGen';
import { type Edge, type Node, useSvelteFlow } from '@xyflow/svelte';
import { sanitizeEdge, sanitizeNode } from '#utils/sanitize';
interface ClipboardData {
tinyflowNodes: Node[];
tinyflowEdges?: Edge[];
version: string;
}
/**
* 清理节点中不可序列化的字段,确保可安全 JSON.stringify
*/
function sanitizeNode(node: Node): Node {
const { id, type, position, data, parentId } = node;
return {
id,
type,
position: { x: position.x, y: position.y },
parentId: parentId ? parentId : undefined,
data: data ? JSON.parse(JSON.stringify(data)) : {},
...(parentId !== undefined && { parentId }),
};
}
/**
* 对 nodes 数组排序,确保每个父节点出现在其所有子节点之前。
* 使用 Kahn 算法(拓扑排序)处理任意嵌套层级。
@@ -83,22 +70,6 @@ export function sortNodesForSvelteFlow(nodes: Node[]): Node[] {
return result;
}
/**
* 清理边中不可序列化的字段
*/
function sanitizeEdge(edge: Edge): Edge {
const { id, source, target, sourceHandle, targetHandle, type, data } = edge;
return {
id,
source,
target,
...(sourceHandle !== undefined && { sourceHandle }),
...(targetHandle !== undefined && { targetHandle }),
...(type !== undefined && { type }),
data: data ? JSON.parse(JSON.stringify(data)) : {},
};
}
/**
* 递归遍历对象,仅当遇到 { refType: 'ref', ref: string } 时重写 ref 的 id
*/

View File

@@ -1,3 +1,4 @@
export * from './types';
export * from './Tinyflow';
export * from './components/TinyflowComponent.svelte';
export * from './utils/sanitize';

View File

@@ -1,17 +1,20 @@
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
const DEFAULT_VIEWPORT: Viewport = { x: 250, y: 100, zoom: 1 };
const createStore = () => {
let nodesInternal = $state.raw([] as Node[]);
let edgesInternal = $state.raw([] as Edge[]);
let viewport = $state.raw({ x: 250, y: 100, zoom: 1 } as Viewport);
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
return {
// nodes: nodesInternal,
// edges: edgesInternal,
// viewport,
init: (nodes: Node[], edges: Edge[]) => {
init: (nodes: Node[], edges: Edge[], nextViewport?: Viewport | null) => {
nodesInternal = nodes;
edgesInternal = edges;
viewport = nextViewport ? { ...nextViewport } : { ...DEFAULT_VIEWPORT };
},
getNodes: () => nodesInternal,

View File

@@ -87,6 +87,8 @@ export type TinyflowOptions = {
element: string | Element;
theme?: TinyflowTheme;
data?: TinyflowData | string;
readonly?: boolean;
hideBottomDock?: boolean;
provider?: {
llm?: () => SelectItem[] | Promise<SelectItem[]>;
knowledge?: () => SelectItem[] | Promise<SelectItem[]>;

View File

@@ -0,0 +1,89 @@
import { type Edge, type Node } from '@xyflow/svelte';
import type { TinyflowData } from '#types';
/**
* 清理节点中不适合序列化或预览的运行时字段。
*/
export function sanitizeNode(node: Node): Node {
const { id, type, position, data, parentId } = node;
return {
id,
type,
position: {
x: position?.x ?? 0,
y: position?.y ?? 0,
},
data: data ? JSON.parse(JSON.stringify(data)) : {},
...(parentId !== undefined && { parentId }),
};
}
/**
* 清理边中不适合序列化或预览的运行时字段。
*/
export function sanitizeEdge(edge: Edge): Edge {
const { id, source, target, sourceHandle, targetHandle, type, data } = edge;
return {
id,
source,
target,
...(sourceHandle !== undefined && { sourceHandle }),
...(targetHandle !== undefined && { targetHandle }),
...(type !== undefined && { type }),
data: data ? JSON.parse(JSON.stringify(data)) : {},
};
}
function sanitizeReadonlyNode(node: Node): Node {
const sanitized = sanitizeNode(node);
const nextData =
sanitized.data && typeof sanitized.data === 'object'
? {
...sanitized.data,
expand: false,
}
: {};
return {
...sanitized,
data: nextData,
};
}
/**
* 为只读预览重建 tinyflow 数据,移除 viewport 和节点运行时几何状态。
*/
export function sanitizeTinyflowDataForReadonlyPreview(
input: TinyflowData | string,
): null | TinyflowData | undefined {
let parsed: TinyflowData | undefined;
if (typeof input === 'string') {
const raw = input.trim();
if (!raw) {
return null;
}
try {
parsed = JSON.parse(raw);
} catch {
return undefined;
}
} else {
parsed = input;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return undefined;
}
const nodes = Array.isArray(parsed.nodes)
? parsed.nodes.map((node) => sanitizeReadonlyNode(node as Node))
: [];
const edges = Array.isArray(parsed.edges)
? parsed.edges.map((edge) => sanitizeEdge(edge as Edge))
: [];
return {
nodes,
edges,
};
}

View File

@@ -5,7 +5,7 @@
<script setup lang="ts">
import { Tinyflow as TinyflowNative, TinyflowOptions } from '@tinyflow-ai/ui';
import '@tinyflow-ai/ui/dist/index.css';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps<
{
@@ -94,9 +94,19 @@ const focusNode = async (
return false;
};
const fitView = async (options?: { duration?: number; padding?: number }) => {
if (tinyflow) {
await nextTick();
return tinyflow.fitView(options);
}
console.warn('Tinyflow instance is not initialized');
return false;
};
defineExpose({
getData,
getInstance,
focusNode,
fitView,
});
</script>

View File

@@ -1,3 +1,4 @@
import Tinyflow from './Tinyflow.vue';
export { sanitizeTinyflowDataForReadonlyPreview } from '@tinyflow-ai/ui';
export { Tinyflow };

View File

@@ -1,6 +1,7 @@
interface BotInfo {
alias: string;
anonymousEnabled: boolean;
currentApprovalInstanceId?: number | string;
created: string;
createdBy: number;
deptId: number;
@@ -33,6 +34,10 @@ interface BotInfo {
weChatMpToken: string;
welcomeMessage?: string;
};
publishStatus?: string;
publishedAt?: string;
publishedBy?: number;
publishedSnapshotJson?: Record<string, any>;
tenantId: number;
title: string;
categoryId: any;