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

@@ -54,10 +54,13 @@ export const removeBotFromId = (id: string) => {
};
/** 提交 Bot 发布审批 */
export const submitBotPublishApproval = (id: string) => {
export const submitBotPublishApproval = (
id: string,
applicationReason?: string,
) => {
return api.post<RequestResult<number | string>>(
'/api/v1/bot/submitPublishApproval',
{ id },
{ applicationReason, id },
);
};

View File

@@ -19,6 +19,11 @@ import { ElMessage } from 'element-plus';
import { events } from 'fetch-event-stream';
import { useAuthStore } from '#/store';
import {
readWorkflowShareKey,
withWorkflowShareHeader,
WORKFLOW_SHARE_HEADER,
} from '#/utils/workflow-share-context';
import { refreshTokenApi } from './core';
@@ -95,6 +100,10 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
const workflowShareKey = readWorkflowShareKey();
if (workflowShareKey) {
config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey;
}
return config;
},
});
@@ -263,13 +272,12 @@ export class SseClient {
'Content-Type': 'application/json',
'easyflow-token': accessStore.accessToken || '',
};
if (!extraHeaders) {
return headers;
if (extraHeaders) {
new Headers(extraHeaders).forEach((value, key) => {
headers[key] = value;
});
}
new Headers(extraHeaders).forEach((value, key) => {
headers[key] = value;
});
return headers;
return withWorkflowShareHeader(headers);
}
}

View File

@@ -5,7 +5,7 @@ import { computed } from 'vue';
import { useAccess } from '@easyflow/access';
import { MoreFilled } from '@element-plus/icons-vue';
import { Loading, MoreFilled } from '@element-plus/icons-vue';
import {
ElAvatar,
ElButton,
@@ -27,6 +27,8 @@ export interface ActionButton {
icon?: any;
text: ((row: any) => string) | string;
className?: string;
disabled?: ((row: any) => boolean) | boolean;
loading?: ((row: any) => boolean) | boolean;
permission?: string;
placement?: ActionPlacement;
tone?: ActionTone;
@@ -87,6 +89,13 @@ function isActionVisible(action: ActionButton, row: any) {
return action.visible !== false;
}
function resolveActionState(
state: ((row: any) => boolean) | boolean | undefined,
row: any,
) {
return typeof state === 'function' ? state(row) : state === true;
}
const resolvedPrimaryAction = computed(() => {
if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) {
return undefined;
@@ -125,6 +134,12 @@ function handlePrimaryAction(item: any) {
function handleActionClick(event: Event, action: ActionButton, item: any) {
event.stopPropagation();
if (
resolveActionState(action.disabled, item) ||
resolveActionState(action.loading, item)
) {
return;
}
action.onClick(item);
}
@@ -265,6 +280,8 @@ function resolveMetaItems(item: any) {
size="small"
class="card-action-btn"
:class="{ 'card-action-btn--danger': action.tone === 'danger' }"
:disabled="resolveActionState(action.disabled, item)"
:loading="resolveActionState(action.loading, item)"
link
@click.stop="handleActionClick($event, action, item)"
>
@@ -293,10 +310,20 @@ function resolveMetaItems(item: any) {
:class="{
'card-menu-item--danger': action.tone === 'danger',
}"
@click="action.onClick(item)"
:disabled="
resolveActionState(action.disabled, item) ||
resolveActionState(action.loading, item)
"
@click="handleActionClick($event, action, item)"
>
<div class="menu-action-content">
<ElIcon v-if="action.icon">
<ElIcon
v-if="resolveActionState(action.loading, item)"
class="is-loading"
>
<Loading />
</ElIcon>
<ElIcon v-else-if="action.icon">
<IconifyIcon
v-if="typeof action.icon === 'string'"
:icon="action.icon"

View File

@@ -89,6 +89,28 @@ describe('cardList', () => {
expect(primaryAction).not.toHaveBeenCalled();
});
it('异步次级操作执行中会禁用重复点击并展示加载态', async () => {
const inlineAction = vi.fn();
const wrapper = mountCardList({
actions: [
{
disabled: () => true,
loading: () => true,
text: '分享',
placement: 'inline',
onClick: inlineAction,
},
],
});
const actionButton = wrapper.get('.card-action-btn');
await actionButton.trigger('click');
expect(actionButton.classes()).toContain('is-loading');
expect(actionButton.classes()).toContain('is-disabled');
expect(inlineAction).not.toHaveBeenCalled();
});
it('键盘 Enter 可以触发主动作', async () => {
const primaryAction = vi.fn();
const wrapper = mountCardList({

View File

@@ -89,6 +89,7 @@
"apiVariablesEmpty": "This workflow has no start parameters",
"apiStatusExample": "Status Query Example",
"apiResumeExample": "Resume Example",
"shareExpired": "This workflow share link has expired. Request a new link",
"submitPublishApprovalConfirm": "Publish the current workflow now?",
"submitRepublishApprovalConfirm": "Republish the current workflow now?",
"submitOfflineApprovalConfirm": "Take the current workflow offline?",

View File

@@ -22,6 +22,7 @@
"approve": "Approve",
"reject": "Reject",
"revoke": "Revoke",
"submit": "Submit Approval",
"addScope": "Add Scope",
"addStep": "Add Step"
},
@@ -69,6 +70,7 @@
"stepNoLabel": "Step No.",
"currentStep": "Current Step",
"summary": "Summary",
"applicationReason": "Application Reason",
"resourceId": "Resource ID",
"taskId": "Approval Task ID",
"applicant": "Applicant",
@@ -104,7 +106,8 @@
"assigneeType": "Select assignee type",
"assigneeTarget": "Select assignee",
"stepName": "Enter step name",
"actionComment": "Enter a comment"
"actionComment": "Enter a comment",
"applicationReason": "Describe this publication and its purpose"
},
"message": {
"needStep": "At least one step is required",
@@ -123,7 +126,10 @@
"eventRevokedStep": "Step {value} revoked",
"workflowSnapshotUntitled": "Untitled workflow snapshot",
"workflowSnapshotMissing": "Workflow snapshot not found",
"workflowSnapshotParseFailed": "Failed to parse workflow snapshot"
"workflowSnapshotParseFailed": "Failed to parse workflow snapshot",
"applicationReasonPrompt": "Enter an approval reason (1-500 characters)",
"applicationReasonRequired": "Approval reason is required",
"applicationReasonTooLong": "Approval reason cannot exceed 500 characters"
},
"snapshot": {
"knowledgeBasic": "Basic Info",

View File

@@ -30,6 +30,7 @@
"run": "Run",
"runTest": "RunTest",
"copy": "Copy",
"share": "Share",
"selectAll": "Select All",
"choose": "Select",
"setting": "Setting",

View File

@@ -27,8 +27,8 @@
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages.",
"resetPassword": "Reset Password",
"resetPasswordConfirm": "Reset this account password to 123456? The user will be required to change it on next login.",
"resetPasswordSuccess": "Password has been reset to 123456 and must be changed on next login",
"resetPasswordConfirm": "Reset this account password to the system default strong password? The user will be required to change it on next login.",
"resetPasswordSuccess": "Password has been reset to the system default strong password and must be changed on next login",
"batchSelectedCount": "{count} selected",
"batchToolbarHint": "Batch actions are available for selected accounts",
"batchActionSelectRequired": "Please select at least one account",
@@ -39,7 +39,7 @@
"batchDeletePartialSuccess": "Batch delete completed. {successCount} succeeded and {errorCount} failed.",
"batchDeleteAllFailed": "Batch delete failed",
"batchResetPassword": "Batch Reset Password",
"batchResetPasswordConfirm": "Reset the selected {count} accounts to 123456? Users must change it on next login, and protected administrator accounts will be skipped.",
"batchResetPasswordConfirm": "Reset the selected {count} accounts to the system default strong password? Users must change it on next login, and protected administrator accounts will be skipped.",
"batchResetPasswordSuccess": "{count} account passwords have been reset",
"batchResetPasswordPartialSuccess": "Batch password reset completed. {successCount} succeeded and {errorCount} failed.",
"batchResetPasswordAllFailed": "Batch password reset failed",

View File

@@ -89,6 +89,7 @@
"apiVariablesEmpty": "当前工作流没有开始参数",
"apiStatusExample": "状态查询示例",
"apiResumeExample": "恢复执行示例",
"shareExpired": "工作流分享链接已失效,请重新获取",
"submitPublishApprovalConfirm": "确认发布当前工作流吗?",
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?",

View File

@@ -22,6 +22,7 @@
"approve": "通过",
"reject": "驳回",
"revoke": "撤回",
"submit": "提交审批",
"addScope": "新增范围",
"addStep": "新增步骤"
},
@@ -69,6 +70,7 @@
"stepNoLabel": "步骤序号",
"currentStep": "当前步骤",
"summary": "审批摘要",
"applicationReason": "审批说明",
"resourceId": "资源ID",
"taskId": "审批任务ID",
"applicant": "申请人",
@@ -104,7 +106,8 @@
"assigneeType": "请选择审批方式",
"assigneeTarget": "请选择审批对象",
"stepName": "请输入步骤名称",
"actionComment": "请输入处理说明"
"actionComment": "请输入处理说明",
"applicationReason": "请输入本次发布内容和原因"
},
"message": {
"needStep": "至少需要一个审批步骤",
@@ -123,7 +126,10 @@
"eventRevokedStep": "第 {value} 步已撤回",
"workflowSnapshotUntitled": "未命名工作流快照",
"workflowSnapshotMissing": "未找到工作流快照",
"workflowSnapshotParseFailed": "工作流快照解析失败"
"workflowSnapshotParseFailed": "工作流快照解析失败",
"applicationReasonPrompt": "请填写审批说明1-500字",
"applicationReasonRequired": "请填写审批说明",
"applicationReasonTooLong": "审批说明不能超过500字"
},
"snapshot": {
"knowledgeBasic": "基础信息",

View File

@@ -30,6 +30,7 @@
"run": "运行",
"runTest": "试运行",
"copy": "复制",
"share": "分享",
"selectAll": "全选",
"choose": "选择",
"setting": "设置",

View File

@@ -28,8 +28,8 @@
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面",
"resetPassword": "重置密码",
"resetPasswordConfirm": "确认将该用户密码重置为 123456 吗?重置后用户下次登录必须先修改密码。",
"resetPasswordSuccess": "密码已重置为 123456,用户下次登录需修改密码",
"resetPasswordConfirm": "确认将该用户密码重置为系统默认强密码吗?重置后用户下次登录必须先修改密码。",
"resetPasswordSuccess": "密码已重置为系统默认强密码,用户下次登录需修改密码",
"batchSelectedCount": "已选择 {count} 项",
"batchToolbarHint": "可对选中账号执行批量操作",
"batchActionSelectRequired": "请先选择要操作的账号",
@@ -40,7 +40,7 @@
"batchDeletePartialSuccess": "批量删除完成,成功 {successCount} 个,失败 {errorCount} 个",
"batchDeleteAllFailed": "批量删除失败",
"batchResetPassword": "批量重置密码",
"batchResetPasswordConfirm": "确认将已选中的 {count} 个账号密码重置为 123456 吗?重置后用户下次登录必须先修改密码,管理员账号将跳过并返回失败结果。",
"batchResetPasswordConfirm": "确认将已选中的 {count} 个账号密码重置为系统默认强密码吗?重置后用户下次登录必须先修改密码,管理员账号将跳过并返回失败结果。",
"batchResetPasswordSuccess": "已完成 {count} 个账号密码重置",
"batchResetPasswordPartialSuccess": "批量重置密码完成,成功 {successCount} 个,失败 {errorCount} 个",
"batchResetPasswordAllFailed": "批量重置密码失败",

View File

@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { copyTextWithFeedback } from '../clipboard-feedback';
const { copyTextToClipboard, error, success } = vi.hoisted(() => ({
copyTextToClipboard: vi.fn(),
error: vi.fn(),
success: vi.fn(),
}));
vi.mock('@easyflow/utils', () => ({
copyTextToClipboard,
}));
vi.mock('element-plus', () => ({
ElMessage: {
error,
success,
},
}));
describe('copyTextWithFeedback', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('copies through the shared Chrome 90 compatible helper', async () => {
copyTextToClipboard.mockResolvedValue('exec-command');
await expect(
copyTextWithFeedback('https://example.test/share', '复制成功'),
).resolves.toBe(true);
expect(copyTextToClipboard).toHaveBeenCalledWith(
'https://example.test/share',
);
expect(success).toHaveBeenCalledWith('复制成功');
expect(error).not.toHaveBeenCalled();
});
it('reports a recoverable error when copying fails', async () => {
copyTextToClipboard.mockRejectedValue(new Error('copy denied'));
await expect(
copyTextWithFeedback('content', '复制成功', '复制失败,请手动复制'),
).resolves.toBe(false);
expect(error).toHaveBeenCalledWith('复制失败,请手动复制');
expect(success).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,26 @@
import { copyTextToClipboard } from '@easyflow/utils';
import { ElMessage } from 'element-plus';
/**
* 复制文本并统一展示成功或失败反馈。
*
* @param text 待复制文本
* @param successMessage 成功提示
* @param failureMessage 失败提示
* @returns 是否复制成功
*/
export async function copyTextWithFeedback(
text: string,
successMessage: string,
failureMessage = '复制失败,请手动复制',
): Promise<boolean> {
try {
await copyTextToClipboard(text);
ElMessage.success(successMessage);
return true;
} catch {
ElMessage.error(failureMessage);
return false;
}
}

View File

@@ -0,0 +1,88 @@
/**
* 工作流协作分享请求头。
*/
export const WORKFLOW_SHARE_HEADER = 'X-Workflow-Share-Key';
interface WorkflowShareResolutionOptions<T> {
currentWorkflowId?: null | T;
onFailure: (error: unknown) => Promise<void> | void;
resolve: () => Promise<null | T | undefined>;
shareKey?: unknown;
}
/**
* 从页面地址读取工作流分享密钥,兼容 history 与 hash 路由。
*/
export function readWorkflowShareKey(url?: string): null | string {
const currentUrl =
url || (typeof window === 'undefined' ? '' : window.location.href);
if (!currentUrl) {
return null;
}
try {
const parsed = new URL(
currentUrl,
typeof window === 'undefined'
? 'http://localhost'
: window.location.origin,
);
const historyKey = parsed.searchParams.get('shareKey')?.trim();
if (historyKey) {
return historyKey;
}
const queryIndex = parsed.hash.indexOf('?');
if (queryIndex === -1) {
return null;
}
return (
new URLSearchParams(parsed.hash.slice(queryIndex + 1))
.get('shareKey')
?.trim() || null
);
} catch {
return null;
}
}
/**
* 在保留现有请求头的基础上附加工作流分享密钥。
*/
export function withWorkflowShareHeader(
headers: Record<string, string>,
url?: string,
): Record<string, string> {
const shareKey = readWorkflowShareKey(url);
if (!shareKey) {
return headers;
}
return {
...headers,
[WORKFLOW_SHARE_HEADER]: shareKey,
};
}
/**
* 解析分享地址对应的工作流,并在链接失效时统一收口异常。
*/
export async function resolveWorkflowShareWorkflowId<T>({
currentWorkflowId,
onFailure,
resolve,
shareKey,
}: WorkflowShareResolutionOptions<T>): Promise<null | T> {
if (currentWorkflowId !== null && currentWorkflowId !== undefined) {
return currentWorkflowId;
}
const normalizedShareKey = Array.isArray(shareKey)
? shareKey.find((value) => String(value || '').trim())
: shareKey;
if (!String(normalizedShareKey || '').trim()) {
return null;
}
try {
return (await resolve()) ?? null;
} catch (error) {
await onFailure(error);
return null;
}
}

View File

@@ -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?.();

View File

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

View File

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

View File

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

View File

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

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

View File

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