feat: 收敛AI资源发布审批生命周期

- 统一工作流、知识库、聊天助手的发布、重新发布、下线与删除链路

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

- 调整审批管理权限模型为单入口页面加内部按钮权限
This commit is contained in:
2026-04-09 17:13:54 +08:00
parent 81125ce55c
commit 4e565aef99
68 changed files with 3859 additions and 817 deletions

View File

@@ -61,6 +61,14 @@ export const submitBotPublishApproval = (id: string) => {
);
};
/** 提交 Bot 下线审批 */
export const submitBotOfflineApproval = (id: string) => {
return api.post<RequestResult<number | string>>(
'/api/v1/bot/submitOfflineApproval',
{ id },
);
};
/** 提交 Bot 删除审批 */
export const submitBotDeleteApproval = (id: string) => {
return api.post<RequestResult<number | string>>(

View File

@@ -28,6 +28,7 @@ export interface ActionButton {
permission?: string;
placement?: ActionPlacement;
tone?: ActionTone;
visible?: ((row: any) => boolean) | boolean;
onClick: (row: any) => void;
}
@@ -77,6 +78,13 @@ function hasPermission(permission?: string) {
return !permission || hasAccessByCodes([permission]);
}
function isActionVisible(action: ActionButton, row: any) {
if (typeof action.visible === 'function') {
return action.visible(row);
}
return action.visible !== false;
}
const resolvedPrimaryAction = computed(() => {
if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) {
return undefined;
@@ -109,24 +117,6 @@ const resolvedActions = computed<ResolvedActionButton[]>(() => {
}));
});
const inlineActions = computed(() => {
return resolvedActions.value.filter(
(action) => action.placement === 'inline',
);
});
const menuActions = computed(() => {
return resolvedActions.value.filter((action) => action.placement === 'menu');
});
const showFooter = computed(() => {
return Boolean(
resolvedPrimaryAction.value ||
inlineActions.value.length > 0 ||
menuActions.value.length > 0,
);
});
function handlePrimaryAction(item: any) {
resolvedPrimaryAction.value?.onClick(item);
}
@@ -139,6 +129,24 @@ function handleActionClick(event: Event, action: ActionButton, item: any) {
function resolveActionText(action: ActionButton, item: any) {
return typeof action.text === 'function' ? action.text(item) : action.text;
}
function resolveInlineActions(item: any) {
return resolvedActions.value.filter(
(action) => action.placement === 'inline' && isActionVisible(action, item),
);
}
function resolveMenuActions(item: any) {
return resolvedActions.value.filter(
(action) => action.placement === 'menu' && isActionVisible(action, item),
);
}
function hasVisibleActions(item: any) {
return (
resolveInlineActions(item).length > 0 || resolveMenuActions(item).length > 0
);
}
</script>
<template>
@@ -198,7 +206,7 @@ function resolveActionText(action: ActionButton, item: any) {
</div>
</div>
<template v-if="showFooter" #footer>
<template v-if="resolvedPrimaryAction || hasVisibleActions(item)" #footer>
<div class="card-footer">
<div v-if="resolvedPrimaryAction" class="card-primary-hint">
<div class="primary-label">
@@ -214,12 +222,12 @@ function resolveActionText(action: ActionButton, item: any) {
</div>
<div
v-if="inlineActions.length > 0 || menuActions.length > 0"
v-if="hasVisibleActions(item)"
class="card-actions"
@click.stop
>
<ElButton
v-for="(action, actionIndex) in inlineActions"
v-for="(action, actionIndex) in resolveInlineActions(item)"
:key="`${item.id ?? index}-inline-${actionIndex}`"
:icon="typeof action.icon === 'string' ? undefined : action.icon"
size="small"
@@ -235,7 +243,7 @@ function resolveActionText(action: ActionButton, item: any) {
</ElButton>
<ElDropdown
v-if="menuActions.length > 0"
v-if="resolveMenuActions(item).length > 0"
trigger="click"
placement="bottom-end"
>
@@ -248,7 +256,7 @@ function resolveActionText(action: ActionButton, item: any) {
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="(action, actionIndex) in menuActions"
v-for="(action, actionIndex) in resolveMenuActions(item)"
:key="`${item.id ?? index}-menu-${actionIndex}`"
:class="{
'card-menu-item--danger': action.tone === 'danger',

View File

@@ -65,10 +65,16 @@
"publishStatusDraft": "Draft",
"publishStatusPublishPending": "Publish Pending",
"publishStatusPublished": "Published",
"publishStatusOfflinePending": "Offline Pending",
"publishStatusOffline": "Offline",
"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.",
"submitPublishApprovalConfirm": "Publish the current workflow now?",
"submitRepublishApprovalConfirm": "Republish the current workflow now?",
"submitOfflineApprovalConfirm": "Take the current workflow offline?",
"submitDeleteApprovalConfirm": "Delete the current workflow?",
"offlineImpactBoundBotsIntro": "This workflow is currently bound to the following bots:",
"offlineImpactBoundBotsFooter": "After the workflow goes offline, the system will automatically remove it from these bots.",
"publishPendingHint": "There is already an approval in progress for this workflow.",
"deletePendingHint": "There is already an approval in progress for this workflow.",
"check": "Check",

View File

@@ -13,6 +13,7 @@
},
"action": {
"publish": "Publish",
"offline": "Offline",
"delete": "Delete",
"addFlow": "New Flow",
"editFlow": "Edit Flow",
@@ -79,7 +80,7 @@
"actedAt": "Acted At",
"comment": "Comment",
"eventType": "Event Type",
"operatorId": "Operator ID",
"operatorId": "Operator Account",
"operatorName": "Operator Name",
"createdAt": "Created At",
"eventInfo": "Event Info",

View File

@@ -18,10 +18,16 @@
"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.",
"publishStatusOfflinePending": "Offline Pending",
"publishStatusOfflinePendingDesc": "The current release stays available until approval completes, but it is hidden from new binding candidates.",
"publishStatusOffline": "Offline",
"publishStatusOfflineDesc": "The current release is offline. External chat, Public API, and new bindings are unavailable.",
"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.",
"submitPublishApprovalConfirm": "Publish the current assistant now?",
"submitRepublishApprovalConfirm": "Republish the current assistant now?",
"submitOfflineApprovalConfirm": "Take the current assistant offline?",
"submitDeleteApprovalConfirm": "Delete the current assistant?",
"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.",

View File

@@ -41,7 +41,9 @@
"markAsResolved": "MarkAsResolved",
"optimizing": "Optimizing",
"regenerate": "Regenerate",
"publish": "Publish",
"republish": "Republish",
"offline": "Offline",
"hide": "Hide",
"more": "Mode",
"submitDeleteApproval": "Submit Delete Approval",

View File

@@ -38,10 +38,18 @@
"publishStatusDraft": "Draft",
"publishStatusPublishPending": "Publish Pending",
"publishStatusPublished": "Published",
"publishStatusOfflinePending": "Offline Pending",
"publishStatusOffline": "Offline",
"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.",
"submitPublishApprovalConfirm": "Publish the current knowledge base now?",
"submitRepublishApprovalConfirm": "Republish the current knowledge base now?",
"submitOfflineApprovalConfirm": "Take the current knowledge base offline?",
"submitDeleteApprovalConfirm": "Delete the current knowledge base?",
"offlineImpactBoundBotsIntro": "This knowledge base is currently bound to the following bots:",
"offlineImpactBoundBotsFooter": "After the knowledge base goes offline, the system will automatically remove it from these bots.",
"offlineImpactWorkflowBlockedIntro": "This knowledge base is still used by the following workflows:",
"offlineImpactWorkflowBlockedFooter": "Please update those workflow nodes before taking the knowledge base offline.",
"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",

View File

@@ -65,10 +65,16 @@
"publishStatusDraft": "草稿",
"publishStatusPublishPending": "发布审批中",
"publishStatusPublished": "已发布",
"publishStatusOfflinePending": "下线审批中",
"publishStatusOffline": "已下线",
"publishStatusDeletePending": "删除审批中",
"publishStatusLabel": "发布状态",
"submitPublishApprovalConfirm": "提交后会进入发布审批,审批通过后新版本才会正式对外可用。",
"submitDeleteApprovalConfirm": "提交后会进入删除审批,审批通过后将执行真实删除。",
"submitPublishApprovalConfirm": "确认发布当前工作流吗?",
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?",
"submitDeleteApprovalConfirm": "确认删除当前工作流吗?",
"offlineImpactBoundBotsIntro": "当前工作流被以下聊天助手绑定:",
"offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该工作流。",
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"check": "检查",

View File

@@ -13,6 +13,7 @@
},
"action": {
"publish": "发布",
"offline": "下线",
"delete": "删除",
"addFlow": "新建流程",
"editFlow": "编辑流程",
@@ -79,7 +80,7 @@
"actedAt": "处理时间",
"comment": "处理意见",
"eventType": "事件类型",
"operatorId": "操作人ID",
"operatorId": "操作人账号",
"operatorName": "操作人名称",
"createdAt": "创建时间",
"eventInfo": "事件信息",

View File

@@ -18,10 +18,16 @@
"publishStatusPublishPendingDesc": "审批通过后,聊天助手会切换为新的正式版本。",
"publishStatusPublished": "已发布",
"publishStatusPublishedDesc": "当前正式版本已可对外使用,编辑中的草稿不会立即影响线上。",
"publishStatusOfflinePending": "下线审批中",
"publishStatusOfflinePendingDesc": "审批完成前当前正式版本仍可访问,但不会继续作为新的绑定候选。",
"publishStatusOffline": "已下线",
"publishStatusOfflineDesc": "当前正式版本已下线外链聊天、Public API 和新的资源绑定都不可用。",
"publishStatusDeletePending": "删除审批中",
"publishStatusDeletePendingDesc": "当前正式版本仍可访问,但不会继续作为新的绑定候选。",
"submitPublishApprovalConfirm": "提交后会进入发布审批,审批通过后聊天助手才会正式对外可用。",
"submitDeleteApprovalConfirm": "提交后会进入删除审批,审批通过后将执行真实删除。",
"submitPublishApprovalConfirm": "确认发布当前聊天助手吗?",
"submitRepublishApprovalConfirm": "确认重新发布当前聊天助手吗?",
"submitOfflineApprovalConfirm": "确认下线当前聊天助手吗?",
"submitDeleteApprovalConfirm": "确认删除当前聊天助手吗?",
"publishPendingHint": "当前聊天助手已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前聊天助手已有进行中的审批,请等待处理完成。",
"publishRequiredHint": "当前还没有正式发布版本,请先提交发布审批。",

View File

@@ -41,7 +41,9 @@
"markAsResolved": "标记已处理",
"optimizing": "正在优化中...",
"regenerate": "重新生成",
"publish": "发布",
"republish": "重新发布",
"offline": "下线",
"hide": "隐藏",
"more": "更多",
"submitDeleteApproval": "提交删除审批",

View File

@@ -38,10 +38,18 @@
"publishStatusDraft": "草稿",
"publishStatusPublishPending": "发布审批中",
"publishStatusPublished": "已发布",
"publishStatusOfflinePending": "下线审批中",
"publishStatusOffline": "已下线",
"publishStatusDeletePending": "删除审批中",
"publishStatusLabel": "发布状态",
"submitPublishApprovalConfirm": "提交后会进入发布审批,审批通过后该知识库才可作为正式版本被聊天助手引用。",
"submitDeleteApprovalConfirm": "提交后会进入删除审批,审批通过后将执行真实删除。",
"submitPublishApprovalConfirm": "确认发布当前知识库吗?",
"submitRepublishApprovalConfirm": "确认重新发布当前知识库吗?",
"submitOfflineApprovalConfirm": "确认下线当前知识库吗?",
"submitDeleteApprovalConfirm": "确认删除当前知识库吗?",
"offlineImpactBoundBotsIntro": "当前知识库被以下聊天助手绑定:",
"offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该知识库。",
"offlineImpactWorkflowBlockedIntro": "当前知识库仍被以下工作流使用:",
"offlineImpactWorkflowBlockedFooter": "请先在工作流中调整相关知识库节点后再下线。",
"publishPendingHint": "当前知识库已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前知识库已有进行中的审批,请等待处理完成。",
"createdModifyTime": "创建/更新时间",

View File

@@ -243,6 +243,10 @@ function setupAccessGuard(router: Router) {
return true;
}
// 页面菜单与按钮权限码是两套数据源。每次重新构建动态菜单时,
// 同步刷新一次 accessCodes避免后端权限模型调整后页面仍持有旧按钮权限。
await authStore.fetchAccessCodes();
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userRoles = userInfo.roles ?? [];

View File

@@ -3,6 +3,74 @@ import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
name: 'ApprovalFlowPage',
path: '/sys/approval/flow',
redirect: {
path: '/sys/approval',
query: {
tab: 'flow',
},
},
meta: {
title: $t('approval.tab.flow'),
hideInMenu: true,
hideInBreadcrumb: true,
hideInTab: true,
activePath: '/sys/approval',
},
},
{
name: 'ApprovalPendingPage',
path: '/sys/approval/pending',
redirect: {
path: '/sys/approval',
query: {
tab: 'pending',
},
},
meta: {
title: $t('approval.tab.pending'),
hideInMenu: true,
hideInBreadcrumb: true,
hideInTab: true,
activePath: '/sys/approval',
},
},
{
name: 'ApprovalProcessedPage',
path: '/sys/approval/processed',
redirect: {
path: '/sys/approval',
query: {
tab: 'processed',
},
},
meta: {
title: $t('approval.tab.processed'),
hideInMenu: true,
hideInBreadcrumb: true,
hideInTab: true,
activePath: '/sys/approval',
},
},
{
name: 'ApprovalInitiatedPage',
path: '/sys/approval/initiated',
redirect: {
path: '/sys/approval',
query: {
tab: 'initiated',
},
},
meta: {
title: $t('approval.tab.initiated'),
hideInMenu: true,
hideInBreadcrumb: true,
hideInTab: true,
activePath: '/sys/approval',
},
},
{
name: 'ApprovalDetail',
path: '/sys/approval/detail/:id',

View File

@@ -154,6 +154,12 @@ export const useAuthStore = defineStore('auth', () => {
return userInfo;
}
async function fetchAccessCodes() {
const accessCodes = await getAccessCodesApi();
accessStore.setAccessCodes(accessCodes);
return accessCodes;
}
function $reset() {
loginLoading.value = false;
}
@@ -162,6 +168,7 @@ export const useAuthStore = defineStore('auth', () => {
$reset,
authDevLogin,
authLogin,
fetchAccessCodes,
fetchUserInfo,
loginLoading,
logout,

View File

@@ -26,7 +26,11 @@ import {
} from 'element-plus';
import { tryit } from 'radash';
import { submitBotDeleteApproval, submitBotPublishApproval } from '#/api';
import {
submitBotDeleteApproval,
submitBotOfflineApproval,
submitBotPublishApproval,
} from '#/api';
import { api } from '#/api/request';
import defaultAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
@@ -34,9 +38,12 @@ import CardList from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {
canAiResourceDelete,
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
isAiResourcePublished,
normalizeAiPublishStatus,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
import { useDictStore } from '#/store';
@@ -105,35 +112,57 @@ const actions: ActionButton[] = [
{
icon: Promotion,
text: (row: BotInfo) =>
isAiResourcePublished(row.publishStatus)
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
? $t('button.republish')
: $t('button.submitPublishApproval'),
: $t('button.publish'),
permission: '/api/v1/bot/save',
placement: 'inline',
visible: (row: BotInfo) =>
canAiResourcePublish(row.displayPublishStatus, row.publishStatus) ||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus),
onClick(row: BotInfo) {
handleSubmitPublishApproval(row);
handlePublishAction(row);
},
},
{
icon: Promotion,
text: $t('button.offline'),
permission: '/api/v1/bot/save',
placement: 'menu',
visible: (row: BotInfo) => canAiResourceOffline(row.displayPublishStatus, row.publishStatus),
onClick(row: BotInfo) {
handleOfflineAction(row);
},
},
{
icon: Delete,
text: $t('button.submitDeleteApproval'),
text: $t('button.delete'),
tone: 'danger',
permission: '/api/v1/bot/remove',
placement: 'menu',
visible: (row: BotInfo) => canAiResourceDelete(row.displayPublishStatus, row.publishStatus),
onClick(row: BotInfo) {
handleSubmitDeleteApproval(row);
},
},
];
const handleSubmitPublishApproval = async (bot: BotInfo) => {
if (isAiResourceApprovalPending(bot.publishStatus)) {
function isRepublishAction(bot: BotInfo) {
return canAiResourceRepublish(bot.displayPublishStatus, bot.publishStatus);
}
const handlePublishAction = async (bot: BotInfo) => {
if (
isAiResourceApprovalPending(bot.displayPublishStatus, bot.publishStatus)
) {
ElMessage.warning($t('bot.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitPublishApprovalConfirm'),
isRepublishAction(bot)
? $t('bot.submitRepublishApprovalConfirm')
: $t('bot.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -150,8 +179,36 @@ const handleSubmitPublishApproval = async (bot: BotInfo) => {
pageDataRef.value?.reload?.();
}
};
const handleOfflineAction = async (bot: BotInfo) => {
if (
isAiResourceApprovalPending(bot.displayPublishStatus, bot.publishStatus)
) {
ElMessage.warning($t('bot.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitOfflineApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch {
return;
}
const res = await submitBotOfflineApproval(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)) {
if (
isAiResourceApprovalPending(bot.displayPublishStatus, bot.publishStatus)
) {
ElMessage.warning($t('bot.deletePendingHint'));
return;
}
@@ -174,8 +231,11 @@ const handleSubmitDeleteApproval = async (bot: BotInfo) => {
pageDataRef.value?.reload?.();
}
};
function resolvePublishStatusMeta(status?: string) {
switch (normalizeAiPublishStatus(status)) {
function resolvePublishStatusMetaByInstance(
displayPublishStatus?: string,
publishStatus?: string,
) {
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
case 'PUBLISHED':
return {
label: $t('bot.publishStatusPublished'),
@@ -186,6 +246,16 @@ function resolvePublishStatusMeta(status?: string) {
label: $t('bot.publishStatusPublishPending'),
type: 'warning' as const,
};
case 'OFFLINE_PENDING':
return {
label: $t('bot.publishStatusOfflinePending'),
type: 'warning' as const,
};
case 'OFFLINE':
return {
label: $t('bot.publishStatusOffline'),
type: 'info' as const,
};
case 'DELETE_PENDING':
return {
label: $t('bot.publishStatusDeletePending'),
@@ -378,9 +448,9 @@ const getSideList = async () => {
size="small"
effect="plain"
round
:type="resolvePublishStatusMeta(item.publishStatus).type"
:type="resolvePublishStatusMetaByInstance(item.displayPublishStatus, item.publishStatus).type"
>
{{ resolvePublishStatusMeta(item.publishStatus).label }}
{{ resolvePublishStatusMetaByInstance(item.displayPublishStatus, item.publishStatus).label }}
</ElTag>
</template>
</CardList>

View File

@@ -42,6 +42,7 @@ import { tryit } from 'radash';
import {
getPerQuestions,
submitBotDeleteApproval,
submitBotOfflineApproval,
submitBotPublishApproval,
updateBotApi,
updateBotOptions,
@@ -56,10 +57,13 @@ import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDa
import DictSelect from '#/components/dict/DictSelect.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import {
canAiResourceDelete,
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
isAiResourceExternallyVisible,
isAiResourcePublished,
normalizeAiPublishStatus,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
interface SelectedMcpTool {
@@ -163,18 +167,42 @@ const publicChatUrl = computed(() => {
const publicChatEmbedUrl = computed(() => {
return buildPublicChatUrl(true);
});
const botDisplayPublishStatus = computed(() =>
resolveAiResourceDisplayStatus(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
),
);
const botApprovalPending = computed(() =>
isAiResourceApprovalPending(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
),
);
const publishStatusMeta = computed<{
description: string;
label: string;
type: 'danger' | 'info' | 'success' | 'warning';
}>(() => {
switch (normalizeAiPublishStatus(botInfo.value?.publishStatus)) {
switch (botDisplayPublishStatus.value) {
case 'PUBLISHED':
return {
label: $t('bot.publishStatusPublished'),
type: 'success',
description: $t('bot.publishStatusPublishedDesc'),
};
case 'OFFLINE_PENDING':
return {
label: $t('bot.publishStatusOfflinePending'),
type: 'warning',
description: $t('bot.publishStatusOfflinePendingDesc'),
};
case 'OFFLINE':
return {
label: $t('bot.publishStatusOffline'),
type: 'info',
description: $t('bot.publishStatusOfflineDesc'),
};
case 'PUBLISH_PENDING':
return {
label: $t('bot.publishStatusPublishPending'),
@@ -199,9 +227,44 @@ const canUsePublicAccess = computed(() =>
isAiResourceExternallyVisible(botInfo.value?.publishStatus),
);
const publishPrimaryActionLabel = computed(() =>
isAiResourcePublished(botInfo.value?.publishStatus)
canAiResourceRepublish(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
)
? $t('button.republish')
: $t('button.submitPublishApproval'),
: $t('button.publish'),
);
const canShowPublishPrimaryAction = computed(() =>
canAiResourcePublish(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
) ||
canAiResourceRepublish(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
),
);
const secondaryActionLabel = computed(() => {
if (
canAiResourceOffline(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
)
) {
return $t('button.offline');
}
if (
canAiResourceDelete(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
)
) {
return $t('button.delete');
}
return '';
});
const canShowSecondaryAction = computed(() =>
Boolean(secondaryActionLabel.value),
);
const iframeCode = computed(() => {
if (!publicChatEmbedUrl.value) {
@@ -820,17 +883,24 @@ const handleDeletePresetQuestion = (item: any) => {
const handlePublishWx = () => {
publishWxRef.value.openDialog(botId.value, botInfo.value?.options || {});
};
const handleSubmitPublishApproval = async () => {
const handleLifecycleAction = async () => {
if (!botInfo.value) {
return;
}
if (isAiResourceApprovalPending(botInfo.value.publishStatus)) {
if (
botApprovalPending.value
) {
ElMessage.warning($t('bot.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitPublishApprovalConfirm'),
canAiResourceRepublish(
botInfo.value.displayPublishStatus,
botInfo.value.publishStatus,
)
? $t('bot.submitRepublishApprovalConfirm')
: $t('bot.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -849,17 +919,32 @@ const handleSubmitPublishApproval = async () => {
ElMessage.error(res.message || $t('message.saveFailMessage'));
}
};
const handleSubmitDeleteApproval = async () => {
const handleSecondaryAction = async () => {
if (!botInfo.value) {
return;
}
if (isAiResourceApprovalPending(botInfo.value.publishStatus)) {
ElMessage.warning($t('bot.deletePendingHint'));
if (
botApprovalPending.value
) {
ElMessage.warning($t('bot.publishPendingHint'));
return;
}
const canOffline = canAiResourceOffline(
botInfo.value.displayPublishStatus,
botInfo.value.publishStatus,
);
const canDelete = canAiResourceDelete(
botInfo.value.displayPublishStatus,
botInfo.value.publishStatus,
);
if (!canOffline && !canDelete) {
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitDeleteApprovalConfirm'),
canOffline
? $t('bot.submitOfflineApprovalConfirm')
: $t('bot.submitDeleteApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -870,7 +955,9 @@ const handleSubmitDeleteApproval = async () => {
} catch {
return;
}
const res = await submitBotDeleteApproval(String(botInfo.value.id));
const res = canOffline
? await submitBotOfflineApproval(String(botInfo.value.id))
: await submitBotDeleteApproval(String(botInfo.value.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
getBotDetail();
@@ -1485,19 +1572,21 @@ const handleBasicInfoChange = async (
</div>
<div class="publish-summary-actions">
<ElButton
v-if="canShowPublishPrimaryAction"
type="primary"
:disabled="!hasSavePermission"
@click="handleSubmitPublishApproval"
@click="handleLifecycleAction"
>
{{ publishPrimaryActionLabel }}
</ElButton>
<ElButton
v-if="canShowSecondaryAction"
plain
type="danger"
:type="secondaryActionLabel === $t('button.delete') ? 'danger' : 'default'"
:disabled="!hasSavePermission"
@click="handleSubmitDeleteApproval"
@click="handleSecondaryAction"
>
{{ $t('button.submitDeleteApproval') }}
{{ secondaryActionLabel }}
</ElButton>
</div>
</div>

View File

@@ -46,9 +46,16 @@ import PageSide from '#/components/page/PageSide.vue';
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import {
buildOfflineImpactMessage,
type OfflineImpactCheck,
} from '#/views/ai/shared/offline-impact';
import {
canAiResourceDelete,
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
isAiResourcePublished,
normalizeAiPublishStatus,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
const router = useRouter();
@@ -186,24 +193,41 @@ const actions: ActionButton[] = [
{
icon: Promotion,
text: (row) =>
isAiResourcePublished(row.publishStatus)
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
? $t('button.republish')
: $t('button.submitPublishApproval'),
: $t('button.publish'),
permission: '/api/v1/documentCollection/save',
placement: 'inline',
visible: (row) =>
canAiResourcePublish(row.displayPublishStatus, row.publishStatus) ||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus),
onClick(row) {
if (!ensureManageKnowledgeItem(row)) {
return;
}
submitPublishApproval(row);
submitPublishAction(row);
},
},
{
text: $t('button.submitDeleteApproval'),
icon: Promotion,
text: $t('button.offline'),
permission: '/api/v1/documentCollection/save',
placement: 'menu',
visible: (row) => canAiResourceOffline(row.displayPublishStatus, row.publishStatus),
onClick(row) {
if (!ensureManageKnowledgeItem(row)) {
return;
}
submitOfflineAction(row);
},
},
{
text: $t('button.delete'),
icon: Delete,
tone: 'danger',
permission: '/api/v1/documentCollection/remove',
placement: 'menu',
visible: (row) => canAiResourceDelete(row.displayPublishStatus, row.publishStatus),
onClick(row) {
if (!ensureManageKnowledgeItem(row)) {
return;
@@ -216,14 +240,22 @@ const actions: ActionButton[] = [
onMounted(() => {
getCategoryList();
});
const submitPublishApproval = async (item: any) => {
if (isAiResourceApprovalPending(item.publishStatus)) {
function isRepublishAction(item: any) {
return canAiResourceRepublish(item.displayPublishStatus, item.publishStatus);
}
const submitPublishAction = async (item: any) => {
if (
isAiResourceApprovalPending(item.displayPublishStatus, item.publishStatus)
) {
ElMessage.warning($t('documentCollection.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('documentCollection.submitPublishApprovalConfirm'),
isRepublishAction(item)
? $t('documentCollection.submitRepublishApprovalConfirm')
: $t('documentCollection.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -245,8 +277,71 @@ const submitPublishApproval = async (item: any) => {
reloadKnowledgeList();
}
};
const submitOfflineAction = async (item: any) => {
if (
isAiResourceApprovalPending(item.displayPublishStatus, item.publishStatus)
) {
ElMessage.warning($t('documentCollection.publishPendingHint'));
return;
}
const impactRes = await api.get<{
data: OfflineImpactCheck;
errorCode: number;
}>(
'/api/v1/documentCollection/offlineImpactCheck',
{
params: { id: item.id },
},
);
if (impactRes.errorCode !== 0) {
return;
}
if (impactRes.data?.hasWorkflowUsages) {
await ElMessageBox.alert(
buildOfflineImpactMessage(
$t('documentCollection.offlineImpactWorkflowBlockedIntro'),
impactRes.data.workflowUsages,
$t('documentCollection.offlineImpactWorkflowBlockedFooter'),
),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
type: 'warning',
},
);
return;
}
try {
await ElMessageBox.confirm(
impactRes.data?.hasBotBindings
? buildOfflineImpactMessage(
$t('documentCollection.offlineImpactBoundBotsIntro'),
impactRes.data.botBindings,
$t('documentCollection.offlineImpactBoundBotsFooter'),
)
: $t('documentCollection.submitOfflineApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch {
return;
}
const res = await api.post('/api/v1/documentCollection/submitOfflineApproval', {
id: item.id,
});
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
reloadKnowledgeList();
}
};
const submitDeleteApproval = async (item: any) => {
if (isAiResourceApprovalPending(item.publishStatus)) {
if (
isAiResourceApprovalPending(item.displayPublishStatus, item.publishStatus)
) {
ElMessage.warning($t('documentCollection.deletePendingHint'));
return;
}
@@ -274,14 +369,29 @@ const submitDeleteApproval = async (item: any) => {
reloadKnowledgeList();
}
};
function resolvePublishStatusMeta(status?: string) {
switch (normalizeAiPublishStatus(status)) {
function resolvePublishStatusMeta(
displayPublishStatus?: string,
publishStatus?: string,
) {
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
case 'DELETE_PENDING': {
return {
label: $t('documentCollection.publishStatusDeletePending'),
tone: 'danger',
};
}
case 'OFFLINE_PENDING': {
return {
label: $t('documentCollection.publishStatusOfflinePending'),
tone: 'pending',
};
}
case 'OFFLINE': {
return {
label: $t('documentCollection.publishStatusOffline'),
tone: 'draft',
};
}
case 'PUBLISH_PENDING': {
return {
label: $t('documentCollection.publishStatusPublishPending'),
@@ -553,11 +663,11 @@ function changeCategory(category: any) {
<template #publish>
<div
class="knowledge-publish-chip"
:class="`knowledge-publish-chip--${resolvePublishStatusMeta(item.publishStatus).tone}`"
:class="`knowledge-publish-chip--${resolvePublishStatusMeta(item.displayPublishStatus, item.publishStatus).tone}`"
>
<span class="knowledge-publish-chip__dot"></span>
<span>{{
resolvePublishStatusMeta(item.publishStatus).label
resolvePublishStatusMeta(item.displayPublishStatus, item.publishStatus).label
}}</span>
</div>
</template>

View File

@@ -1,22 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { EasyFlowPanelModal } from '@easyflow/common-ui';
import { ElImage } from 'element-plus';
import { ElButton, ElEmpty, ElImage, ElScrollbar } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
defineExpose({
openDialog,
});
const dialogVisible = ref(false);
const data = ref<any>();
function openDialog(row: any) {
const docPreviewLoading = ref(false);
const docPreviewContent = ref('');
const docPreviewTruncated = ref(false);
const docPreviewError = ref('');
let previewRequestId = 0;
const isDocument = computed(() => data.value?.resourceType === 3);
const fileName = computed(() => {
const resourceName = data.value?.resourceName || '';
const suffix = data.value?.suffix || '';
return suffix ? `${resourceName}.${suffix}` : resourceName;
});
const previewWidth = computed(() => (isDocument.value ? 'xl' : 'md'));
async function openDialog(row: any) {
data.value = row;
dialogVisible.value = true;
resetDocumentPreview();
if (row?.resourceType === 3) {
await loadDocumentPreview(row);
}
}
function closeDialog() {
dialogVisible.value = false;
}
function resetDocumentPreview() {
docPreviewLoading.value = false;
docPreviewContent.value = '';
docPreviewTruncated.value = false;
docPreviewError.value = '';
}
async function loadDocumentPreview(row: any) {
if (!row?.id) {
docPreviewError.value = '当前素材缺少预览标识,请下载后查看';
return;
}
const currentRequestId = ++previewRequestId;
docPreviewLoading.value = true;
try {
const res = await api.get('/api/v1/resource/previewContent', {
params: { id: row.id },
});
if (currentRequestId !== previewRequestId) {
return;
}
docPreviewContent.value = res.data?.content || '';
docPreviewTruncated.value = !!res.data?.truncated;
if (!docPreviewContent.value) {
docPreviewError.value = '暂未提取到可预览内容,请下载后查看';
}
} catch {
if (currentRequestId !== previewRequestId) {
return;
}
docPreviewError.value = '文档预览加载失败,请下载后查看';
} finally {
if (currentRequestId === previewRequestId) {
docPreviewLoading.value = false;
}
}
}
function openSourceFile() {
if (data.value?.resourceUrl) {
window.open(data.value.resourceUrl, '_blank');
}
}
</script>
<template>
@@ -24,25 +89,119 @@ function closeDialog() {
v-model:open="dialogVisible"
:title="$t('message.preview')"
:before-close="closeDialog"
width="md"
:width="previewWidth"
:show-footer="false"
>
<div class="flex justify-center">
<div class="resource-preview flex justify-center">
<ElImage
v-if="data.resourceType === 0"
style="width: 200px"
:preview-src-list="[data.resourceUrl]"
:src="data.resourceUrl"
/>
<video v-if="data.resourceType === 1" controls width="640" height="360">
<video
v-else-if="data.resourceType === 1"
controls
width="640"
height="360"
>
<source :src="data.resourceUrl" type="video/mp4" />
{{ $t('message.notVideo') }}
</video>
<audio v-if="data.resourceType === 2" controls :src="data.resourceUrl">
<audio
v-else-if="data.resourceType === 2"
controls
class="mt-8 w-full max-w-[640px]"
:src="data.resourceUrl"
>
{{ $t('message.notAudio') }}
</audio>
<div
v-else-if="isDocument"
v-loading="docPreviewLoading"
:element-loading-text="$t('message.loading')"
class="resource-preview__document bg-background border-border w-full rounded-xl border"
>
<div
class="resource-preview__toolbar border-border flex items-center justify-between gap-3 border-b px-5 py-4"
>
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ fileName }}</div>
<div
v-if="docPreviewTruncated"
class="text-muted-foreground mt-1 text-xs"
>
内容较长当前仅展示前 20000 个字符
</div>
</div>
<ElButton link type="primary" @click="openSourceFile">
{{ $t('button.download') }}
</ElButton>
</div>
<div class="resource-preview__body">
<ElEmpty
v-if="docPreviewError"
:description="docPreviewError"
class="resource-preview__empty"
>
<ElButton link type="primary" @click="openSourceFile">
{{ $t('button.download') }}
</ElButton>
</ElEmpty>
<ElScrollbar
v-else
class="resource-preview__scrollbar"
wrap-class="resource-preview__scrollbar-wrap"
>
<pre class="resource-preview__content">{{
docPreviewContent
}}</pre>
</ElScrollbar>
</div>
</div>
</div>
</EasyFlowPanelModal>
</template>
<style scoped></style>
<style scoped>
.resource-preview {
min-height: 220px;
}
.resource-preview__document {
min-height: 540px;
overflow: hidden;
}
.resource-preview__toolbar {
min-height: 72px;
}
.resource-preview__body {
height: 468px;
}
.resource-preview__scrollbar {
height: 100%;
}
:deep(.resource-preview__scrollbar-wrap) {
padding: 20px;
}
.resource-preview__content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family:
'SFMono-Regular', 'JetBrains Mono', 'Fira Code', Consolas, 'Liberation Mono',
monospace;
font-size: 13px;
line-height: 1.75;
color: hsl(var(--foreground));
}
.resource-preview__empty {
height: 100%;
}
</style>

View File

@@ -0,0 +1,38 @@
import { h } from 'vue';
export interface OfflineImpactBinding {
id?: number | string;
title?: string;
}
export interface OfflineImpactCheck {
canProceed: boolean;
hasBotBindings: boolean;
hasWorkflowUsages: boolean;
botBindings: OfflineImpactBinding[];
workflowUsages: OfflineImpactBinding[];
message?: string;
}
function resolveTitle(item: OfflineImpactBinding) {
return item.title || String(item.id || '');
}
export function joinOfflineImpactTitles(items: OfflineImpactBinding[] = []) {
return items.map(resolveTitle).filter(Boolean).join('、');
}
export function buildOfflineImpactMessage(
intro: string,
items: OfflineImpactBinding[] = [],
footer?: string,
) {
return h('div', [
h('p', intro),
h(
'ul',
items.map((item) => h('li', { key: String(item.id || item.title || '') }, resolveTitle(item))),
),
footer ? h('p', footer) : null,
]);
}

View File

@@ -1,6 +1,8 @@
export type AiPublishStatus =
| 'DELETE_PENDING'
| 'DRAFT'
| 'OFFLINE'
| 'OFFLINE_PENDING'
| 'PUBLISHED'
| 'PUBLISH_PENDING';
@@ -13,6 +15,8 @@ export function normalizeAiPublishStatus(
switch (value) {
case 'PUBLISHED':
case 'PUBLISH_PENDING':
case 'OFFLINE':
case 'OFFLINE_PENDING':
case 'DELETE_PENDING':
return value;
default:
@@ -20,6 +24,17 @@ export function normalizeAiPublishStatus(
}
}
/**
* 解析用于页面展示的发布状态。
* 已发布资源若存在当前审批实例,则视为“发布审批中”,用于统一状态文案与动作禁用。
*/
export function resolveAiResourceDisplayStatus(
displayValue?: null | string,
_fallbackValue?: null | string,
): AiPublishStatus {
return normalizeAiPublishStatus(displayValue);
}
/**
* 当前资源是否已有正式线上版本。
*/
@@ -27,12 +42,23 @@ export function isAiResourcePublished(value?: null | string) {
return normalizeAiPublishStatus(value) === 'PUBLISHED';
}
/**
* 当前资源是否处于已下线状态。
*/
export function isAiResourceOffline(value?: null | string) {
return normalizeAiPublishStatus(value) === 'OFFLINE';
}
/**
* 当前资源是否允许对外可见。
*/
export function isAiResourceExternallyVisible(value?: null | string) {
const normalized = normalizeAiPublishStatus(value);
return normalized === 'PUBLISHED' || normalized === 'DELETE_PENDING';
return (
normalized === 'PUBLISHED' ||
normalized === 'DELETE_PENDING' ||
normalized === 'OFFLINE_PENDING'
);
}
/**
@@ -45,7 +71,68 @@ export function isAiResourceSelectableForBot(value?: null | string) {
/**
* 当前资源是否处于审批处理中。
*/
export function isAiResourceApprovalPending(value?: null | string) {
const normalized = normalizeAiPublishStatus(value);
return normalized === 'PUBLISH_PENDING' || normalized === 'DELETE_PENDING';
export function isAiResourceApprovalPending(
displayValue?: null | string,
_fallbackValue?: null | string,
) {
const normalized = resolveAiResourceDisplayStatus(displayValue);
return (
normalized === 'PUBLISH_PENDING' ||
normalized === 'OFFLINE_PENDING' ||
normalized === 'DELETE_PENDING'
);
}
/**
* 当前资源是否允许发起发布。
*/
export function canAiResourcePublish(
displayValue?: null | string,
_fallbackValue?: null | string,
) {
if (isAiResourceApprovalPending(displayValue)) {
return false;
}
const normalized = resolveAiResourceDisplayStatus(displayValue);
return normalized === 'DRAFT' || normalized === 'OFFLINE';
}
/**
* 当前资源是否允许重新发布。
*/
export function canAiResourceRepublish(
displayValue?: null | string,
_fallbackValue?: null | string,
) {
if (isAiResourceApprovalPending(displayValue)) {
return false;
}
return resolveAiResourceDisplayStatus(displayValue) === 'PUBLISHED';
}
/**
* 当前资源是否允许发起下线。
*/
export function canAiResourceOffline(
displayValue?: null | string,
_fallbackValue?: null | string,
) {
if (isAiResourceApprovalPending(displayValue)) {
return false;
}
return resolveAiResourceDisplayStatus(displayValue) === 'PUBLISHED';
}
/**
* 当前资源是否允许发起删除。
*/
export function canAiResourceDelete(
displayValue?: null | string,
_fallbackValue?: null | string,
) {
if (isAiResourceApprovalPending(displayValue)) {
return false;
}
const normalized = resolveAiResourceDisplayStatus(displayValue);
return normalized === 'DRAFT' || normalized === 'OFFLINE';
}

View File

@@ -26,8 +26,9 @@ import { $t } from '#/locales';
import { router } from '#/router';
import { getIconByValue } from '#/views/ai/model/modelUtils/defaultIcon';
import {
canAiResourceRepublish,
isAiResourceApprovalPending,
normalizeAiPublishStatus,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
@@ -251,18 +252,26 @@ const updatePluginNode = ref<any>(null);
const pageLoading = ref(false);
const chainInfo = ref<any>(null);
const publishActionText = computed(() => {
switch (normalizeAiPublishStatus(workflowInfo.value?.publishStatus)) {
switch (
resolveAiResourceDisplayStatus(
workflowInfo.value?.displayPublishStatus,
workflowInfo.value?.publishStatus,
)
) {
case 'DELETE_PENDING': {
return $t('aiWorkflow.publishStatusDeletePending');
}
case 'OFFLINE_PENDING': {
return $t('aiWorkflow.publishStatusOfflinePending');
}
case 'PUBLISH_PENDING': {
return $t('aiWorkflow.publishStatusPublishPending');
}
case 'PUBLISHED': {
return `${$t('aiWorkflow.publishStatusPublished')} · ${$t('button.republish')}`;
return $t('button.republish');
}
default: {
return `${$t('aiWorkflow.publishStatusDraft')} · ${$t('button.submitPublishApproval')}`;
return $t('button.publish');
}
}
});
@@ -272,7 +281,10 @@ const publishActionDisabled = computed(
saveLoading.value ||
checkLoading.value ||
publishLoading.value ||
isAiResourceApprovalPending(workflowInfo.value?.publishStatus),
isAiResourceApprovalPending(
workflowInfo.value?.displayPublishStatus,
workflowInfo.value?.publishStatus,
),
);
function syncNavTitle(title: string) {
@@ -498,17 +510,27 @@ function closeCheckIssues() {
async function handleCheck() {
await runCheck('PRE_EXECUTE');
}
async function handlePublish() {
async function handlePublishAction() {
if (publishLoading.value) {
return;
}
if (isAiResourceApprovalPending(workflowInfo.value?.publishStatus)) {
if (
isAiResourceApprovalPending(
workflowInfo.value?.displayPublishStatus,
workflowInfo.value?.publishStatus,
)
) {
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('aiWorkflow.submitPublishApprovalConfirm'),
canAiResourceRepublish(
workflowInfo.value?.displayPublishStatus,
workflowInfo.value?.publishStatus,
)
? $t('aiWorkflow.submitRepublishApprovalConfirm')
: $t('aiWorkflow.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -680,8 +702,8 @@ function onAsyncExecute(info: any) {
:loading="publishLoading"
:disabled="publishActionDisabled"
class="workflow-publish-button"
:class="`workflow-publish-button--${normalizeAiPublishStatus(workflowInfo?.publishStatus)}`"
@click="handlePublish"
:class="`workflow-publish-button--${resolveAiResourceDisplayStatus(workflowInfo?.displayPublishStatus, workflowInfo?.publishStatus)}`"
@click="handlePublishAction"
>
{{ publishActionText }}
</ElButton>
@@ -809,12 +831,24 @@ function onAsyncExecute(info: any) {
border-color: hsl(var(--warning) / 24%);
}
:deep(.workflow-publish-button--OFFLINE_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--OFFLINE.el-button) {
color: hsl(var(--foreground) / 78%);
background: hsl(var(--muted) / 62%);
border-color: hsl(var(--foreground) / 14%);
}
:deep(.workflow-publish-button--DELETE_PENDING.el-button) {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 16%);

View File

@@ -50,9 +50,16 @@ import { router } from '#/router';
import { useDictStore } from '#/store';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import {
buildOfflineImpactMessage,
type OfflineImpactCheck,
} from '#/views/ai/shared/offline-impact';
import {
canAiResourceDelete,
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
isAiResourcePublished,
normalizeAiPublishStatus,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
import WorkflowModal from './WorkflowModal.vue';
@@ -175,21 +182,35 @@ const actions: ActionButton[] = [
{
icon: Promotion,
text: (row: any) =>
isAiResourcePublished(row.publishStatus)
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
? $t('button.republish')
: $t('button.submitPublishApproval'),
: $t('button.publish'),
permission: '/api/v1/workflow/save',
placement: 'inline',
visible: (row: any) =>
canAiResourcePublish(row.displayPublishStatus, row.publishStatus) ||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus),
onClick: (row: any) => {
submitPublishApproval(row);
submitPublishAction(row);
},
},
{
icon: Promotion,
text: $t('button.offline'),
permission: '/api/v1/workflow/save',
placement: 'menu',
visible: (row: any) => canAiResourceOffline(row.displayPublishStatus, row.publishStatus),
onClick: (row: any) => {
submitOfflineAction(row);
},
},
{
icon: Delete,
text: $t('button.submitDeleteApproval'),
text: $t('button.delete'),
tone: 'danger',
permission: '/api/v1/workflow/remove',
placement: 'menu',
visible: (row: any) => canAiResourceDelete(row.displayPublishStatus, row.publishStatus),
onClick: (row: any) => {
submitDeleteApproval(row);
},
@@ -282,14 +303,22 @@ function showDialog(row: any, importMode = false) {
function resolveNavTitle(row: any) {
return row?.title || row?.name || '';
}
async function submitPublishApproval(row: any) {
if (isAiResourceApprovalPending(row.publishStatus)) {
function isRepublishAction(row: any) {
return canAiResourceRepublish(row.displayPublishStatus, row.publishStatus);
}
async function submitPublishAction(row: any) {
if (
isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)
) {
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('aiWorkflow.submitPublishApprovalConfirm'),
isRepublishAction(row)
? $t('aiWorkflow.submitRepublishApprovalConfirm')
: $t('aiWorkflow.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -300,7 +329,56 @@ async function submitPublishApproval(row: any) {
} catch {
return;
}
const res = await api.post('/api/v1/workflow/submitPublishApproval', {
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 submitOfflineAction(row: any) {
if (
isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)
) {
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
return;
}
const impactRes = await api.get<{
data: OfflineImpactCheck;
errorCode: number;
}>(
'/api/v1/workflow/offlineImpactCheck',
{
params: { id: row.id },
},
);
if (impactRes.errorCode !== 0) {
return;
}
try {
await ElMessageBox.confirm(
impactRes.data?.hasBotBindings
? buildOfflineImpactMessage(
$t('aiWorkflow.offlineImpactBoundBotsIntro'),
impactRes.data.botBindings,
$t('aiWorkflow.offlineImpactBoundBotsFooter'),
)
: $t('aiWorkflow.submitOfflineApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch {
return;
}
const res = await api.post('/api/v1/workflow/submitOfflineApproval', {
id: row.id,
});
if (res.errorCode === 0) {
@@ -309,7 +387,9 @@ async function submitPublishApproval(row: any) {
}
}
async function submitDeleteApproval(row: any) {
if (isAiResourceApprovalPending(row.publishStatus)) {
if (
isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)
) {
ElMessage.warning($t('aiWorkflow.deletePendingHint'));
return;
}
@@ -334,14 +414,29 @@ async function submitDeleteApproval(row: any) {
pageDataRef.value?.reload?.();
}
}
function resolvePublishStatusMeta(status?: string) {
switch (normalizeAiPublishStatus(status)) {
function resolvePublishStatusMetaByInstance(
displayPublishStatus?: string,
publishStatus?: string,
) {
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
case 'DELETE_PENDING': {
return {
label: $t('aiWorkflow.publishStatusDeletePending'),
tone: 'danger',
};
}
case 'OFFLINE_PENDING': {
return {
label: $t('aiWorkflow.publishStatusOfflinePending'),
tone: 'pending',
};
}
case 'OFFLINE': {
return {
label: $t('aiWorkflow.publishStatusOffline'),
tone: 'draft',
};
}
case 'PUBLISH_PENDING': {
return {
label: $t('aiWorkflow.publishStatusPublishPending'),
@@ -572,11 +667,11 @@ function handleHeaderButtonClick(data: any) {
<template #publish>
<div
class="workflow-publish-chip"
:class="`workflow-publish-chip--${resolvePublishStatusMeta(item.publishStatus).tone}`"
:class="`workflow-publish-chip--${resolvePublishStatusMetaByInstance(item.displayPublishStatus, item.publishStatus).tone}`"
>
<span class="workflow-publish-chip__dot"></span>
<span>{{
resolvePublishStatusMeta(item.publishStatus).label
resolvePublishStatusMetaByInstance(item.displayPublishStatus, item.publishStatus).label
}}</span>
</div>
</template>

View File

@@ -36,6 +36,7 @@ const resourceLabelMap: Record<string, string> = {
const actionLabelMap: Record<string, string> = {
DELETE: $t('approval.action.delete'),
OFFLINE: $t('approval.action.offline'),
PUBLISH: $t('approval.action.publish'),
};
@@ -138,18 +139,28 @@ 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}`;
function formatAccountDisplay(
name?: string,
account?: null | string,
fallbackId?: null | number | string,
) {
if (name && account) {
return `${name}${account}`;
}
if (name) {
return name;
}
return id || '-';
if (account) {
return account;
}
return fallbackId || '-';
}
function formatOperatorId(id?: null | number | string) {
return id || '-';
function formatOperatorId(
account?: null | string,
fallbackId?: null | number | string,
) {
return account || fallbackId || '-';
}
function formatOperatorName(name?: null | string) {
@@ -276,7 +287,13 @@ function formatEventInfo(row: Record<string, any>) {
{{ detail.id || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.applicant')">
{{ formatAccountDisplay(detail.applicantName, detail.applicantId) }}
{{
formatAccountDisplay(
detail.applicantName,
detail.applicantAccount,
detail.applicantId,
)
}}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.submittedAt')">
{{ detail.submittedAt || '-' }}
@@ -347,7 +364,7 @@ function formatEventInfo(row: Record<string, any>) {
</ElTableColumn>
<ElTableColumn :label="$t('approval.fields.operatorId')" width="140">
<template #default="{ row }">
{{ formatOperatorId(row.operatorId) }}
{{ formatOperatorId(row.operatorAccount, row.operatorId) }}
</template>
</ElTableColumn>
<ElTableColumn

View File

@@ -31,7 +31,7 @@ defineExpose({
});
type ResourceType = '' | 'BOT' | 'KNOWLEDGE' | 'WORKFLOW';
type ActionType = '' | 'DELETE' | 'PUBLISH';
type ActionType = '' | 'DELETE' | 'OFFLINE' | 'PUBLISH';
type AssigneeType = 'ROLE' | 'USER';
type ScopeType = 'CATEGORY' | 'DEPT';
type FlowStatus = 'DISABLED' | 'ENABLED';
@@ -77,6 +77,7 @@ const RESOURCE_OPTIONS = [
const ACTION_OPTIONS = [
{ label: $t('approval.action.publish'), value: 'PUBLISH' },
{ label: $t('approval.action.offline'), value: 'OFFLINE' },
{ label: $t('approval.action.delete'), value: 'DELETE' },
];

View File

@@ -23,6 +23,7 @@ import {
} from 'element-plus';
import { api } from '#/api/request';
import { hasPermission } from '#/api/common/hasPermission';
import ListPageShell from '#/components/page/ListPageShell.vue';
import PageData from '#/components/page/PageData.vue';
import { $t } from '#/locales';
@@ -40,6 +41,7 @@ const RESOURCE_OPTIONS = [
const ACTION_OPTIONS = [
{ label: $t('approval.action.publish'), value: 'PUBLISH' },
{ label: $t('approval.action.offline'), value: 'OFFLINE' },
{ label: $t('approval.action.delete'), value: 'DELETE' },
];
@@ -66,21 +68,25 @@ const TAB_CONFIG = [
label: $t('approval.tab.flow'),
name: 'flow',
path: '/sys/approval/flow',
permission: '/page/approval/flow',
},
{
label: $t('approval.tab.pending'),
name: 'pending',
path: '/sys/approval/pending',
permission: '/page/approval/pending',
},
{
label: $t('approval.tab.processed'),
name: 'processed',
path: '/sys/approval/processed',
permission: '/page/approval/processed',
},
{
label: $t('approval.tab.initiated'),
name: 'initiated',
path: '/sys/approval/initiated',
permission: '/page/approval/initiated',
},
] as const;
@@ -115,14 +121,23 @@ const pendingBadgeText = computed(() =>
const hasApprovalRootMenu = computed(
() => !!accessStore.getMenuByPath('/sys/approval'),
);
const hasLegacyTabMenus = computed(() =>
TAB_CONFIG.some((item) => !!accessStore.getMenuByPath(item.path)),
);
const hasButtonTabPermissions = computed(() =>
TAB_CONFIG.some((item) => hasPermission([item.permission])),
);
const visibleTabs = computed(() => {
const matchedTabs = TAB_CONFIG.filter((item) =>
accessStore.getMenuByPath(item.path),
);
if (matchedTabs.length > 0) {
return matchedTabs;
if (!hasApprovalRootMenu.value) {
return [];
}
return hasApprovalRootMenu.value ? [...TAB_CONFIG] : [];
if (hasButtonTabPermissions.value) {
return TAB_CONFIG.filter((item) => hasPermission([item.permission]));
}
if (hasLegacyTabMenus.value) {
return TAB_CONFIG.filter((item) => accessStore.getMenuByPath(item.path));
}
return [];
});
const visibleTabNames = computed(() =>
visibleTabs.value.map((item) => item.name),
@@ -139,7 +154,7 @@ const instanceStatusOptions = computed(() => {
});
onMounted(() => {
void syncRouteTab(route.path);
void syncRouteTab();
});
watch(activeTab, () => {
@@ -153,9 +168,9 @@ watch(activeTab, () => {
}
});
watch(
[() => route.path, visibleTabNames],
async ([path]) => {
await syncRouteTab(path);
[() => route.fullPath, visibleTabNames],
async () => {
await syncRouteTab();
},
{ immediate: true },
);
@@ -177,19 +192,55 @@ function resolveTabNameByPath(path: string): ApprovalTabName {
return 'flow';
}
async function syncRouteTab(path: string) {
function resolveTabNameByRoute() {
const queryTab = String(route.query.tab || '');
if (
queryTab === 'flow' ||
queryTab === 'pending' ||
queryTab === 'processed' ||
queryTab === 'initiated'
) {
return queryTab as ApprovalTabName;
}
return resolveTabNameByPath(route.path);
}
async function syncRouteTab() {
if (visibleTabs.value.length === 0) {
activeTab.value = 'flow';
pendingBadgeCount.value = 0;
return;
}
const expectedTab = resolveTabNameByPath(path);
const fallbackTab = visibleTabs.value[0];
const queryTab = String(route.query.tab || '');
// 进入审批管理根路径时,默认落到当前可见页签中排序第一个的页签。
if (route.path === '/sys/approval' && !queryTab && fallbackTab) {
activeTab.value = fallbackTab.name;
if (fallbackTab.name !== 'flow') {
await router.replace({
path: '/sys/approval',
query: { tab: fallbackTab.name },
});
return;
}
}
const expectedTab = resolveTabNameByRoute();
if (visibleTabNames.value.includes(expectedTab)) {
activeTab.value = expectedTab;
} else {
if (fallbackTab && path !== fallbackTab.path) {
await router.replace(fallbackTab.path);
const fallbackQuery =
fallbackTab?.name === 'flow' ? {} : { tab: fallbackTab?.name };
const currentQueryTab = String(route.query.tab || '');
if (
fallbackTab &&
(route.path !== '/sys/approval' || currentQueryTab !== String(fallbackTab.name))
) {
await router.replace({
path: '/sys/approval',
query: fallbackQuery,
});
return;
}
activeTab.value = fallbackTab?.name || 'flow';
@@ -206,10 +257,16 @@ function handleTabChange(name: number | string) {
return;
}
const target = visibleTabs.value.find((item) => item.name === name);
if (!target || route.path === target.path) {
const nextQuery = name === 'flow' ? {} : { tab: name };
const currentQueryTab = String(route.query.tab || '');
const currentTab = currentQueryTab || 'flow';
if (!target || currentTab === name) {
return;
}
router.push(target.path);
router.replace({
path: '/sys/approval',
query: nextQuery,
});
}
function reloadCurrentTab() {

View File

@@ -611,6 +611,9 @@ function getTabKey(tab: RouteLocationNormalized | RouteRecordNormalized) {
meta: { fullPathKey } = {},
query = {},
} = tab as RouteLocationNormalized;
if (path === '/sys/approval') {
return path;
}
// pageKey可能是数组查询参数重复时可能出现
const pageKey = Array.isArray(query.pageKey)
? query.pageKey[0]

View File

@@ -1,7 +1,10 @@
interface BotInfo {
alias: string;
anonymousEnabled: boolean;
approvalPending?: boolean;
currentApprovalInstanceId?: number | string;
currentApprovalActionType?: string;
displayPublishStatus?: string;
created: string;
createdBy: number;
deptId: number;