feat: 完成分享、单会话与发布审批改造
- 增加工作流协作分享与知识库卡片分享入口,统一低版本浏览器复制反馈 - Web 新登录替换旧会话,并保持 API Key 会话隔离 - 发布审批增加必填说明并在审批详情展示 - 账号重置与导入改用可配置默认强密码
This commit is contained in:
@@ -37,6 +37,9 @@ 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 {
|
||||
confirmPublishSubmission,
|
||||
} from '#/views/ai/shared/approval-application-reason';
|
||||
import {
|
||||
canAiResourceDelete,
|
||||
canAiResourceOffline,
|
||||
@@ -158,22 +161,22 @@ const handlePublishAction = async (bot: BotInfo) => {
|
||||
ElMessage.warning($t('bot.publishPendingHint'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
isRepublishAction(bot)
|
||||
const confirmation = await confirmPublishSubmission({
|
||||
api,
|
||||
confirmMessage: isRepublishAction(bot)
|
||||
? $t('bot.submitRepublishApprovalConfirm')
|
||||
: $t('bot.submitPublishApprovalConfirm'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'info',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
id: String(bot.id),
|
||||
resourcePath: '/api/v1/bot',
|
||||
title: $t('message.noticeTitle'),
|
||||
});
|
||||
if (!confirmation) {
|
||||
return;
|
||||
}
|
||||
const res = await submitBotPublishApproval(String(bot.id));
|
||||
const res = await submitBotPublishApproval(
|
||||
String(bot.id),
|
||||
confirmation.applicationReason,
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message || $t('message.saveOkMessage'));
|
||||
pageDataRef.value?.reload?.();
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Plus,
|
||||
Promotion,
|
||||
Search,
|
||||
Share,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElForm,
|
||||
@@ -43,8 +44,10 @@ import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import CardPage from '#/components/page/CardList.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
|
||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||
import {
|
||||
buildOfflineImpactMessage,
|
||||
type OfflineImpactCheck,
|
||||
@@ -70,6 +73,7 @@ type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
|
||||
const canManageKnowledgePermission = computed(() =>
|
||||
hasAccessByCodes(['/api/v1/documentCollection/save']),
|
||||
);
|
||||
const sharingKnowledgeId = ref<null | number | string>(null);
|
||||
const updatingScopeId = ref<null | number | string>(null);
|
||||
const visibilityScopePopoverRefs = ref<Record<string, any>>({});
|
||||
const visibilityScopeMeta = computed(() => ({
|
||||
@@ -140,6 +144,30 @@ function openKnowledgeDetail(row: {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function shareKnowledge(row: Record<string, any>) {
|
||||
if (!row?.id || sharingKnowledgeId.value === row.id) {
|
||||
return;
|
||||
}
|
||||
sharingKnowledgeId.value = row.id;
|
||||
try {
|
||||
const res = await api.post('/api/v1/knowledgeShare/url/create', {
|
||||
knowledgeId: row.id,
|
||||
});
|
||||
const shareUrl = String(res.data?.shareUrl || '').trim();
|
||||
if (res.errorCode !== 0 || !shareUrl) {
|
||||
return;
|
||||
}
|
||||
await copyTextWithFeedback(
|
||||
shareUrl,
|
||||
$t('message.copySuccess'),
|
||||
$t('message.copyFail'),
|
||||
);
|
||||
} finally {
|
||||
sharingKnowledgeId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
interface FieldDefinition {
|
||||
// 字段名称
|
||||
prop: string;
|
||||
@@ -208,6 +236,20 @@ const actions: ActionButton[] = [
|
||||
submitPublishAction(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Share,
|
||||
text: $t('button.share'),
|
||||
permission: '/api/v1/documentCollection/save',
|
||||
placement: 'menu',
|
||||
disabled: (row) => sharingKnowledgeId.value === row.id,
|
||||
loading: (row) => sharingKnowledgeId.value === row.id,
|
||||
onClick(row) {
|
||||
if (!ensureManageKnowledgeItem(row)) {
|
||||
return;
|
||||
}
|
||||
shareKnowledge(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Promotion,
|
||||
text: $t('button.offline'),
|
||||
@@ -251,24 +293,22 @@ const submitPublishAction = async (item: any) => {
|
||||
ElMessage.warning($t('documentCollection.publishPendingHint'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
isRepublishAction(item)
|
||||
const confirmation = await confirmPublishSubmission({
|
||||
api,
|
||||
confirmMessage: isRepublishAction(item)
|
||||
? $t('documentCollection.submitRepublishApprovalConfirm')
|
||||
: $t('documentCollection.submitPublishApprovalConfirm'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'info',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
id: item.id,
|
||||
resourcePath: '/api/v1/documentCollection',
|
||||
title: $t('message.noticeTitle'),
|
||||
});
|
||||
if (!confirmation) {
|
||||
return;
|
||||
}
|
||||
const res = await api.post(
|
||||
'/api/v1/documentCollection/submitPublishApproval',
|
||||
{
|
||||
applicationReason: confirmation.applicationReason,
|
||||
id: item.id,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CopyDocument } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElCard, ElIcon, ElInput, ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
|
||||
type EndpointParam = {
|
||||
location: 'body' | 'query';
|
||||
@@ -202,18 +203,15 @@ const copyGeneratedUrl = async () => {
|
||||
ElMessage.warning('请先生成分享链接');
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(generatedUrl.value);
|
||||
ElMessage.success('已复制分享链接');
|
||||
await copyTextWithFeedback(generatedUrl.value, '已复制分享链接');
|
||||
};
|
||||
|
||||
const copyApiExample = async (content: string) => {
|
||||
await navigator.clipboard.writeText(content);
|
||||
ElMessage.success('已复制调用示例');
|
||||
await copyTextWithFeedback(content, '已复制调用示例');
|
||||
};
|
||||
|
||||
const copyEndpointUrl = async (url: string) => {
|
||||
await navigator.clipboard.writeText(url);
|
||||
ElMessage.success('已复制接口地址');
|
||||
await copyTextWithFeedback(url, '已复制接口地址');
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { confirmPublishSubmission } from './approval-application-reason';
|
||||
|
||||
const { confirm, prompt } = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('element-plus', () => ({
|
||||
ElMessageBox: {
|
||||
confirm,
|
||||
prompt,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
describe('confirmPublishSubmission', () => {
|
||||
const api = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses the normal confirmation when no approval flow matches', async () => {
|
||||
api.get.mockResolvedValue({ data: false, errorCode: 0 });
|
||||
confirm.mockResolvedValue('confirm');
|
||||
|
||||
await expect(
|
||||
confirmPublishSubmission({
|
||||
api,
|
||||
confirmMessage: '确认发布?',
|
||||
id: '1',
|
||||
resourcePath: '/api/v1/workflow',
|
||||
title: '提示',
|
||||
}),
|
||||
).resolves.toEqual({});
|
||||
|
||||
expect(confirm).toHaveBeenCalled();
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires and trims a reason when an approval flow matches', async () => {
|
||||
api.get.mockResolvedValue({ data: true, errorCode: 0 });
|
||||
prompt.mockResolvedValue({ value: ' 本次修复复制兼容问题 ' });
|
||||
|
||||
await expect(
|
||||
confirmPublishSubmission({
|
||||
api,
|
||||
confirmMessage: '确认提交发布审批?',
|
||||
id: '1',
|
||||
resourcePath: '/api/v1/workflow',
|
||||
title: '提示',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
applicationReason: '本次修复复制兼容问题',
|
||||
});
|
||||
|
||||
const promptOptions = prompt.mock.calls[0]?.[2];
|
||||
expect(promptOptions.inputValidator(' ')).toBe(
|
||||
'approval.message.applicationReasonRequired',
|
||||
);
|
||||
expect(promptOptions.inputValidator('a'.repeat(501))).toBe(
|
||||
'approval.message.applicationReasonTooLong',
|
||||
);
|
||||
expect(promptOptions.inputValidator('有效说明')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null when the user cancels', async () => {
|
||||
api.get.mockResolvedValue({ data: true, errorCode: 0 });
|
||||
prompt.mockRejectedValue(new Error('cancel'));
|
||||
|
||||
await expect(
|
||||
confirmPublishSubmission({
|
||||
api,
|
||||
confirmMessage: '确认提交发布审批?',
|
||||
id: '1',
|
||||
resourcePath: '/api/v1/workflow',
|
||||
title: '提示',
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
type ApprovalRequirementApi = {
|
||||
get: (
|
||||
url: string,
|
||||
config: { params: { id: number | string } },
|
||||
) => Promise<{ data?: boolean; errorCode?: number }>;
|
||||
};
|
||||
|
||||
type ConfirmPublishOptions = {
|
||||
api: ApprovalRequirementApi;
|
||||
confirmMessage: string;
|
||||
id: number | string;
|
||||
resourcePath: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type PublishConfirmation = {
|
||||
applicationReason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 预检发布审批并完成相应的确认交互。
|
||||
*
|
||||
* @param options 发布确认参数
|
||||
* @returns 用户取消时返回 null,否则返回提交参数
|
||||
*/
|
||||
export async function confirmPublishSubmission(
|
||||
options: ConfirmPublishOptions,
|
||||
): Promise<null | PublishConfirmation> {
|
||||
const response = await options.api.get(
|
||||
`${options.resourcePath}/publishApprovalRequirement`,
|
||||
{
|
||||
params: { id: options.id },
|
||||
},
|
||||
);
|
||||
try {
|
||||
if (!response.data) {
|
||||
await ElMessageBox.confirm(options.confirmMessage, options.title, {
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
type: 'info',
|
||||
});
|
||||
return {};
|
||||
}
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
$t('approval.message.applicationReasonPrompt'),
|
||||
options.title,
|
||||
{
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
confirmButtonText: $t('approval.action.submit'),
|
||||
inputPlaceholder: $t('approval.placeholder.applicationReason'),
|
||||
inputType: 'textarea',
|
||||
inputValidator: (input: string) => {
|
||||
const normalized = String(input || '').trim();
|
||||
if (!normalized) {
|
||||
return $t('approval.message.applicationReasonRequired');
|
||||
}
|
||||
if (normalized.length > 500) {
|
||||
return $t('approval.message.applicationReasonTooLong');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
type: 'info',
|
||||
},
|
||||
);
|
||||
return {
|
||||
applicationReason: String(value || '').trim(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,20 @@ import {getOptions, sortNodes} from '@easyflow/utils';
|
||||
import {Tinyflow} from '@tinyflow-ai/vue';
|
||||
|
||||
import {ArrowLeft, CircleCheck, Close, Promotion,} from '@element-plus/icons-vue';
|
||||
import {ElButton, ElDrawer, ElMessage, ElMessageBox, ElSkeleton,} from 'element-plus';
|
||||
import {ElButton, ElDrawer, ElMessage, 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 { resolveWorkflowShareWorkflowId } from '#/utils/workflow-share-context';
|
||||
import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon';
|
||||
import {
|
||||
canAiResourceRepublish,
|
||||
isAiResourceApprovalPending,
|
||||
resolveAiResourceDisplayStatus,
|
||||
} from '#/views/ai/shared/publish-status';
|
||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||
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';
|
||||
@@ -52,6 +54,10 @@ const { isDark } = usePreferences();
|
||||
// vue
|
||||
onMounted(async () => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
await resolveSharedWorkflowId();
|
||||
if (!workflowId.value) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
loadCustomNode(),
|
||||
getLlmList(),
|
||||
@@ -88,6 +94,21 @@ const codeEngineList = ref<any[]>([
|
||||
available: true,
|
||||
},
|
||||
]);
|
||||
|
||||
async function resolveSharedWorkflowId() {
|
||||
workflowId.value = await resolveWorkflowShareWorkflowId({
|
||||
currentWorkflowId: workflowId.value,
|
||||
shareKey: route.query.shareKey,
|
||||
resolve: async () => {
|
||||
const res = await api.get('/api/v1/workflowShare/resolve');
|
||||
return res.data?.workflowId;
|
||||
},
|
||||
onFailure: async () => {
|
||||
ElMessage.error($t('aiWorkflow.shareExpired'));
|
||||
await router.replace({ path: '/ai/workflow' });
|
||||
},
|
||||
});
|
||||
}
|
||||
const WORKFLOW_DRAFT_WRITE_DELAY = 320;
|
||||
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingDraftContent: any = null;
|
||||
@@ -372,8 +393,11 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
||||
const res = await api.post('/api/v1/workflow/update', {
|
||||
id: workflowId.value,
|
||||
content,
|
||||
revision: workflowInfo.value?.revision ?? 0,
|
||||
});
|
||||
if (res.errorCode === 0) {
|
||||
workflowInfo.value.revision =
|
||||
res.data?.revision ?? (workflowInfo.value?.revision ?? 0) + 1;
|
||||
reconcileWorkflowDraftAfterSave(savedContentSignature);
|
||||
if (showMsg) {
|
||||
ElMessage.success(res.message);
|
||||
@@ -640,22 +664,19 @@ async function handlePublishAction() {
|
||||
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
canAiResourceRepublish(
|
||||
const confirmation = await confirmPublishSubmission({
|
||||
api,
|
||||
confirmMessage: canAiResourceRepublish(
|
||||
workflowInfo.value?.displayPublishStatus,
|
||||
workflowInfo.value?.publishStatus,
|
||||
)
|
||||
? $t('aiWorkflow.submitRepublishApprovalConfirm')
|
||||
: $t('aiWorkflow.submitPublishApprovalConfirm'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'info',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
id: String(workflowId.value),
|
||||
resourcePath: '/api/v1/workflow',
|
||||
title: $t('message.noticeTitle'),
|
||||
});
|
||||
if (!confirmation) {
|
||||
return;
|
||||
}
|
||||
const saved = await handleSave();
|
||||
@@ -665,6 +686,7 @@ async function handlePublishAction() {
|
||||
publishLoading.value = true;
|
||||
try {
|
||||
const res = await api.post('/api/v1/workflow/submitPublishApproval', {
|
||||
applicationReason: confirmation.applicationReason,
|
||||
id: workflowId.value,
|
||||
});
|
||||
if (res.errorCode === 0) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
OfficeBuilding,
|
||||
Plus,
|
||||
Promotion,
|
||||
Share,
|
||||
Tickets,
|
||||
Upload,
|
||||
VideoPlay,
|
||||
@@ -60,7 +61,9 @@ import PageSide from '#/components/page/PageSide.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { useDictStore } from '#/store';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||
import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact';
|
||||
import {
|
||||
canAiResourceDelete,
|
||||
@@ -117,6 +120,7 @@ const canManageWorkflow = computed(() =>
|
||||
hasAccessByCodes(['/api/v1/workflow/save']),
|
||||
);
|
||||
const updatingScopeId = ref<null | number | string>(null);
|
||||
const sharingWorkflowId = ref<null | number | string>(null);
|
||||
const visibilityScopePopoverRefs = ref<Record<string, any>>({});
|
||||
const apiInstructionVisible = ref(false);
|
||||
const apiInstructionRow = ref<any>(null);
|
||||
@@ -192,6 +196,17 @@ const actions: ActionButton[] = [
|
||||
showApiInstruction(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Share,
|
||||
text: $t('button.share'),
|
||||
permission: '/api/v1/workflow/save',
|
||||
placement: 'menu',
|
||||
disabled: (row: any) => sharingWorkflowId.value === row.id,
|
||||
loading: (row: any) => sharingWorkflowId.value === row.id,
|
||||
onClick: (row: any) => {
|
||||
shareWorkflow(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
text: $t('button.export'),
|
||||
@@ -479,12 +494,11 @@ function buildResumeRequestExample() {
|
||||
);
|
||||
}
|
||||
async function copyApiContent(content: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
ElMessage.success($t('message.copySuccess'));
|
||||
} catch {
|
||||
ElMessage.error($t('message.copyFail'));
|
||||
}
|
||||
await copyTextWithFeedback(
|
||||
content,
|
||||
$t('message.copySuccess'),
|
||||
$t('message.copyFail'),
|
||||
);
|
||||
}
|
||||
function handleApiDocClick(e: MouseEvent) {
|
||||
const target = (e.target as HTMLElement).closest('.api-url-copy-btn');
|
||||
@@ -665,22 +679,20 @@ async function submitPublishAction(row: any) {
|
||||
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
isRepublishAction(row)
|
||||
const confirmation = await confirmPublishSubmission({
|
||||
api,
|
||||
confirmMessage: isRepublishAction(row)
|
||||
? $t('aiWorkflow.submitRepublishApprovalConfirm')
|
||||
: $t('aiWorkflow.submitPublishApprovalConfirm'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'info',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
id: row.id,
|
||||
resourcePath: '/api/v1/workflow',
|
||||
title: $t('message.noticeTitle'),
|
||||
});
|
||||
if (!confirmation) {
|
||||
return;
|
||||
}
|
||||
const res = await api.post('/api/v1/workflow/submitPublishApproval', {
|
||||
applicationReason: confirmation.applicationReason,
|
||||
id: row.id,
|
||||
});
|
||||
if (res.errorCode === 0) {
|
||||
@@ -838,6 +850,36 @@ function toDesignPage(row: any) {
|
||||
},
|
||||
});
|
||||
}
|
||||
async function shareWorkflow(row: any) {
|
||||
if (!row?.id || sharingWorkflowId.value === row.id) {
|
||||
return;
|
||||
}
|
||||
sharingWorkflowId.value = row.id;
|
||||
try {
|
||||
const res = await api.post('/api/v1/workflowShare/url/create', {
|
||||
workflowId: row.id,
|
||||
});
|
||||
if (res.errorCode !== 0 || !res.data?.shareKey) {
|
||||
return;
|
||||
}
|
||||
const routeLocation = router.resolve({
|
||||
name: 'WorkflowDesign',
|
||||
query: {
|
||||
shareKey: res.data.shareKey,
|
||||
},
|
||||
});
|
||||
const shareUrl =
|
||||
res.data.shareUrl ||
|
||||
new URL(routeLocation.href, window.location.origin).toString();
|
||||
await copyTextWithFeedback(
|
||||
shareUrl,
|
||||
$t('message.copySuccess'),
|
||||
$t('message.copyFail'),
|
||||
);
|
||||
} finally {
|
||||
sharingWorkflowId.value = null;
|
||||
}
|
||||
}
|
||||
function exportJson(row: any) {
|
||||
api
|
||||
.get('/api/v1/workflow/exportWorkFlow', {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
readWorkflowShareKey,
|
||||
resolveWorkflowShareWorkflowId,
|
||||
withWorkflowShareHeader,
|
||||
WORKFLOW_SHARE_HEADER,
|
||||
} from '#/utils/workflow-share-context';
|
||||
|
||||
describe('workflow share context', () => {
|
||||
it('reads the share key from a history-mode URL', () => {
|
||||
expect(
|
||||
readWorkflowShareKey(
|
||||
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
|
||||
),
|
||||
).toBe('abc123');
|
||||
});
|
||||
|
||||
it('reads the share key from a hash-mode URL', () => {
|
||||
expect(
|
||||
readWorkflowShareKey(
|
||||
'https://example.test/#/ai/workflow/design?id=1&shareKey=hash-key',
|
||||
),
|
||||
).toBe('hash-key');
|
||||
});
|
||||
|
||||
it('adds the workflow share header without dropping existing headers', () => {
|
||||
expect(
|
||||
withWorkflowShareHeader(
|
||||
{ 'Accept-Language': 'zh-CN' },
|
||||
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
|
||||
),
|
||||
).toEqual({
|
||||
'Accept-Language': 'zh-CN',
|
||||
[WORKFLOW_SHARE_HEADER]: 'abc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves headers unchanged outside a shared URL', () => {
|
||||
const headers = { 'Accept-Language': 'zh-CN' };
|
||||
|
||||
expect(
|
||||
withWorkflowShareHeader(
|
||||
headers,
|
||||
'https://example.test/ai/workflow/design?id=1',
|
||||
),
|
||||
).toEqual(headers);
|
||||
});
|
||||
|
||||
it('resolves the workflow id for a shared URL', async () => {
|
||||
const resolve = vi.fn().mockResolvedValue('workflow-1');
|
||||
const onFailure = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveWorkflowShareWorkflowId({
|
||||
currentWorkflowId: undefined,
|
||||
onFailure,
|
||||
resolve,
|
||||
shareKey: 'share-key',
|
||||
}),
|
||||
).resolves.toBe('workflow-1');
|
||||
expect(resolve).toHaveBeenCalledOnce();
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles an expired share without leaking the resolve error', async () => {
|
||||
const resolveError = new Error('expired');
|
||||
const resolve = vi.fn().mockRejectedValue(resolveError);
|
||||
const onFailure = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
resolveWorkflowShareWorkflowId({
|
||||
currentWorkflowId: undefined,
|
||||
onFailure,
|
||||
resolve,
|
||||
shareKey: 'expired-key',
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(onFailure).toHaveBeenCalledWith(resolveError);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
const approvalActionLoading = ref<'approve' | 'reject' | 'revoke' | null>(null);
|
||||
|
||||
const resourceLabelMap: Record<string, string> = {
|
||||
BOT: $t('approval.resource.bot'),
|
||||
@@ -96,28 +97,45 @@ async function loadDetail() {
|
||||
}
|
||||
|
||||
async function submitApprovalAction(action: 'approve' | 'reject' | 'revoke') {
|
||||
if (approvalActionLoading.value) {
|
||||
return;
|
||||
}
|
||||
approvalActionLoading.value = action;
|
||||
const titleMap = {
|
||||
approve: $t('approval.action.approve'),
|
||||
reject: $t('approval.action.reject'),
|
||||
revoke: $t('approval.action.revoke'),
|
||||
};
|
||||
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;
|
||||
try {
|
||||
let value = '';
|
||||
try {
|
||||
const promptResult = await ElMessageBox.prompt(
|
||||
$t('approval.placeholder.actionComment'),
|
||||
titleMap[action],
|
||||
{
|
||||
inputValue: '',
|
||||
inputType: 'textarea',
|
||||
},
|
||||
);
|
||||
value = promptResult.value || '';
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const res = await api.post(`/api/v1/approvalInstance/${action}`, {
|
||||
comment: value,
|
||||
instanceId: detail.value?.id,
|
||||
});
|
||||
if (res.errorCode !== 0) {
|
||||
return;
|
||||
}
|
||||
ElMessage.success($t('approval.message.actionSuccess'));
|
||||
await loadDetail();
|
||||
} finally {
|
||||
approvalActionLoading.value = null;
|
||||
}
|
||||
ElMessage.success($t('approval.message.actionSuccess'));
|
||||
await loadDetail();
|
||||
}
|
||||
|
||||
function getStatusType(status: string) {
|
||||
@@ -167,6 +185,10 @@ function formatOperatorName(name?: null | string) {
|
||||
return name || '-';
|
||||
}
|
||||
|
||||
function formatApplicationReason(value?: null | string) {
|
||||
return String(value || '').trim() || '-';
|
||||
}
|
||||
|
||||
function formatAssigneeDisplay(row: Record<string, any>) {
|
||||
if (!row?.assigneeType || !row?.assigneeTargetName) {
|
||||
return '-';
|
||||
@@ -238,6 +260,8 @@ function formatEventInfo(row: Record<string, any>) {
|
||||
<ElButton
|
||||
v-if="detail?.canApprove"
|
||||
type="success"
|
||||
:disabled="Boolean(approvalActionLoading)"
|
||||
:loading="approvalActionLoading === 'approve'"
|
||||
@click="submitApprovalAction('approve')"
|
||||
>
|
||||
{{ $t('approval.action.approve') }}
|
||||
@@ -245,12 +269,17 @@ function formatEventInfo(row: Record<string, any>) {
|
||||
<ElButton
|
||||
v-if="detail?.canReject"
|
||||
type="danger"
|
||||
:disabled="Boolean(approvalActionLoading)"
|
||||
:loading="approvalActionLoading === 'reject'"
|
||||
@click="submitApprovalAction('reject')"
|
||||
>
|
||||
{{ $t('approval.action.reject') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="detail?.canRevoke"
|
||||
type="warning"
|
||||
:disabled="Boolean(approvalActionLoading)"
|
||||
:loading="approvalActionLoading === 'revoke'"
|
||||
@click="submitApprovalAction('revoke')"
|
||||
>
|
||||
{{ $t('approval.action.revoke') }}
|
||||
@@ -270,6 +299,9 @@ function formatEventInfo(row: Record<string, any>) {
|
||||
<ElDescriptionsItem :label="$t('approval.fields.summary')">
|
||||
{{ detail.summary || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('approval.fields.applicationReason')">
|
||||
{{ detail.applicationReason || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('approval.fields.currentStep')">
|
||||
{{ detail.currentStepNo || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
@@ -319,6 +351,14 @@ function formatEventInfo(row: Record<string, any>) {
|
||||
:label="$t('approval.fields.stepName')"
|
||||
min-width="180"
|
||||
/>
|
||||
<ElTableColumn
|
||||
:label="$t('approval.fields.applicationReason')"
|
||||
min-width="220"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatApplicationReason(row.applicationReason) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('approval.fields.status')" width="120">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="getStatusType(row.status)">
|
||||
@@ -390,6 +430,14 @@ function formatEventInfo(row: Record<string, any>) {
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('approval.fields.applicationReason')"
|
||||
min-width="220"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatApplicationReason(row.applicationReason) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user