diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowShareController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowShareController.java index 22ca654e..3c3ebb39 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowShareController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowShareController.java @@ -109,24 +109,24 @@ public class WorkflowShareController { * 根据管理端来源构建工作流分享基础 URL。 * * @param request HTTP 请求 - * @return 工作流设计页 URL + * @return 工作流独立分享页 URL */ private String buildShareBaseUrl(HttpServletRequest request) { String refererBaseUrl = extractFrontendBaseUrl(RequestUtil.getReferer(request)); if (refererBaseUrl != null) { - return refererBaseUrl + "/ai/workflow/design"; + return refererBaseUrl + "/share/workflow"; } String forwardedOrigin = buildForwardedOrigin(request); if (forwardedOrigin != null) { return forwardedOrigin + normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix"))) - + "/ai/workflow/design"; + + "/share/workflow"; } String origin = normalizeOrigin(request.getHeader("Origin")); if (origin != null) { - return origin + normalizeBasePath(request.getContextPath()) + "/ai/workflow/design"; + return origin + normalizeBasePath(request.getContextPath()) + "/share/workflow"; } StringBuilder builder = new StringBuilder(); builder.append(request.getScheme()).append("://").append(request.getServerName()); @@ -134,7 +134,7 @@ public class WorkflowShareController { builder.append(':').append(request.getServerPort()); } return builder.append(normalizeBasePath(request.getContextPath())) - .append("/ai/workflow/design") + .append("/share/workflow") .toString(); } diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/WorkflowShareControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/WorkflowShareControllerTest.java index c3dc5077..edba644a 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/WorkflowShareControllerTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/WorkflowShareControllerTest.java @@ -28,7 +28,7 @@ public class WorkflowShareControllerTest { Assert.assertEquals( buildShareBaseUrl(request), - "https://example.test/easyflow/ai/workflow/design" + "https://example.test/easyflow/share/workflow" ); } @@ -47,7 +47,7 @@ public class WorkflowShareControllerTest { Assert.assertEquals( buildShareBaseUrl(request), - "https://example.test/easyflow/ai/workflow/design" + "https://example.test/easyflow/share/workflow" ); } diff --git a/easyflow-ui-admin/app/src/router/__tests__/share-routes.test.ts b/easyflow-ui-admin/app/src/router/__tests__/share-routes.test.ts new file mode 100644 index 00000000..30fc6d9a --- /dev/null +++ b/easyflow-ui-admin/app/src/router/__tests__/share-routes.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import routes from '../routes/external/share'; + +describe('external share routes', () => { + it('keeps workflow sharing outside the management layout', () => { + const route = routes.find((item) => item.name === 'WorkflowShare'); + + expect(route?.path).toBe('/share/workflow'); + expect(route?.meta).toMatchObject({ + hideInBreadcrumb: true, + hideInMenu: true, + hideInTab: true, + noBasicLayout: true, + }); + }); + + it('provides a standalone workflow share failure page', () => { + const route = routes.find((item) => item.name === 'WorkflowShareExpired'); + + expect(route?.path).toBe('/share/workflow/expired'); + expect(route?.meta?.noBasicLayout).toBe(true); + }); +}); diff --git a/easyflow-ui-admin/app/src/router/guard.ts b/easyflow-ui-admin/app/src/router/guard.ts index 15301ffd..5ba44539 100644 --- a/easyflow-ui-admin/app/src/router/guard.ts +++ b/easyflow-ui-admin/app/src/router/guard.ts @@ -28,8 +28,13 @@ interface NetworkConnectionLike { const CHUNK_ERROR_RELOAD_KEY = '__easyflow_chunk_error_reload_path__'; -function isKnowledgeShareRoute(path: string) { - return path === '/share/knowledge' || path === '/share/knowledge/expired'; +function isExternalShareRoute(path: string) { + return ( + path === '/share/knowledge' || + path === '/share/knowledge/expired' || + path === '/share/workflow' || + path === '/share/workflow/expired' + ); } function isSlowNetworkConnection() { @@ -242,7 +247,7 @@ function setupAccessGuard(router: Router) { return buildForcePasswordRoute(); } - if (isKnowledgeShareRoute(to.path)) { + if (isExternalShareRoute(to.path)) { return true; } diff --git a/easyflow-ui-admin/app/src/router/routes/external/share.ts b/easyflow-ui-admin/app/src/router/routes/external/share.ts index de77ed75..4385e4a6 100644 --- a/easyflow-ui-admin/app/src/router/routes/external/share.ts +++ b/easyflow-ui-admin/app/src/router/routes/external/share.ts @@ -27,6 +27,31 @@ const routes: RouteRecordRaw[] = [ hideInTab: true, }, }, + { + name: 'WorkflowShare', + path: '/share/workflow', + component: () => import('#/views/ai/workflow/WorkflowShareView.vue'), + meta: { + title: 'Workflow Share', + noBasicLayout: true, + hideInMenu: true, + hideInBreadcrumb: true, + hideInTab: true, + }, + }, + { + name: 'WorkflowShareExpired', + path: '/share/workflow/expired', + component: () => + import('#/views/ai/documentCollection/KnowledgeShareExpired.vue'), + meta: { + title: 'Workflow Share Expired', + noBasicLayout: true, + hideInMenu: true, + hideInBreadcrumb: true, + hideInTab: true, + }, + }, ]; export default routes; diff --git a/easyflow-ui-admin/app/src/utils/__tests__/login-redirect.test.ts b/easyflow-ui-admin/app/src/utils/__tests__/login-redirect.test.ts index cc6a37f5..42e70f4b 100644 --- a/easyflow-ui-admin/app/src/utils/__tests__/login-redirect.test.ts +++ b/easyflow-ui-admin/app/src/utils/__tests__/login-redirect.test.ts @@ -13,8 +13,8 @@ describe('login redirect', () => { it('accepts an unencoded workflow share route', () => { expect( - resolveLoginRedirectPath('/ai/workflow/design?shareKey=workflow-key'), - ).toBe('/ai/workflow/design?shareKey=workflow-key'); + resolveLoginRedirectPath('/share/workflow?shareKey=workflow-key'), + ).toBe('/share/workflow?shareKey=workflow-key'); }); it('uses the first valid query value', () => { diff --git a/easyflow-ui-admin/app/src/utils/workflow-share-context.ts b/easyflow-ui-admin/app/src/utils/workflow-share-context.ts index 036f0e96..29e649e3 100644 --- a/easyflow-ui-admin/app/src/utils/workflow-share-context.ts +++ b/easyflow-ui-admin/app/src/utils/workflow-share-context.ts @@ -18,7 +18,7 @@ interface WorkflowShareHeaderOptions { requestUrl?: string; } -const WORKFLOW_DESIGN_ROUTE = '/ai/workflow/design'; +const WORKFLOW_SHARE_ROUTES = ['/share/workflow', '/ai/workflow/design']; const WORKFLOW_SHARE_REQUESTS = [ ['GET', '/api/v1/workflow/detail'], ['GET', '/api/v1/workflow/getRunningParameters'], @@ -37,7 +37,42 @@ const WORKFLOW_SHARE_REQUESTS = [ * 从页面地址读取工作流分享密钥,兼容 history 与 hash 路由。 */ export function readWorkflowShareKey(url?: string): null | string { - return readScopedRouteQueryParam(WORKFLOW_DESIGN_ROUTE, 'shareKey', url); + for (const routePath of WORKFLOW_SHARE_ROUTES) { + const shareKey = readScopedRouteQueryParam(routePath, 'shareKey', url); + if (shareKey) { + return shareKey; + } + } + return null; +} + +/** + * 将分享解析失败映射为失效页展示原因。 + */ +export function resolveWorkflowShareFailureReason( + error: unknown, +): 'expired' | 'invalid' { + const candidate = error as + | undefined + | { + message?: unknown; + response?: { + data?: { + error?: unknown; + message?: unknown; + }; + }; + }; + const message = [ + candidate?.response?.data?.message, + candidate?.response?.data?.error, + candidate?.message, + ] + .map((value) => String(value || '').trim()) + .find(Boolean); + return message && (message.includes('过期') || /expired/i.test(message)) + ? 'expired' + : 'invalid'; } /** diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue index dd8cd8fa..e29f75d4 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue @@ -20,7 +20,10 @@ 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 { + resolveWorkflowShareFailureReason, + resolveWorkflowShareWorkflowId, +} from '#/utils/workflow-share-context'; import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon'; import { canAiResourceRepublish, @@ -49,6 +52,14 @@ import { import '@tinyflow-ai/vue/dist/index.css'; +const props = withDefaults( + defineProps<{ + shareMode?: boolean; + }>(), + { + shareMode: false, + }, +); const route = useRoute(); const { isDark } = usePreferences(); // vue @@ -86,16 +97,29 @@ const codeEngineList = ref([ ]); async function resolveSharedWorkflowId() { + const shareKey = Array.isArray(route.query.shareKey) + ? route.query.shareKey.find((value) => String(value || '').trim()) + : route.query.shareKey; + if (props.shareMode && !String(shareKey || '').trim()) { + await router.replace({ + name: 'WorkflowShareExpired', + query: { reason: 'invalid' }, + }); + workflowId.value = null; + return; + } workflowId.value = await resolveWorkflowShareWorkflowId({ currentWorkflowId: workflowId.value, - shareKey: route.query.shareKey, + 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' }); + onFailure: async (error) => { + await router.replace({ + name: 'WorkflowShareExpired', + query: { reason: resolveWorkflowShareFailureReason(error) }, + }); }, }); } @@ -787,7 +811,10 @@ function onAsyncExecute(info: any) {