feat: 完成分享、单会话与发布审批改造

- 增加工作流协作分享与知识库卡片分享入口,统一低版本浏览器复制反馈

- Web 新登录替换旧会话,并保持 API Key 会话隔离

- 发布审批增加必填说明并在审批详情展示

- 账号重置与导入改用可配置默认强密码
This commit is contained in:
2026-07-23 16:09:31 +08:00
parent caa1f07b66
commit 5a42826d44
71 changed files with 3191 additions and 132 deletions

View File

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

View File

@@ -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', {

View File

@@ -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);
});
});