feat: 将工作流分享改为独立页面

- 新增无后台布局的工作流分享与失效路由

- 同步分享地址并保留旧链接兼容

- 补充前后端分享链路测试
This commit is contained in:
2026-07-24 18:51:53 +08:00
parent 526e16163b
commit 63eb55e24c
11 changed files with 212 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';
}
/**

View File

@@ -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<any[]>([
]);
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) {
<template>
<div
class="head-div h-full w-full"
:class="{ 'workflow-issue-focus': issueFocusActive }"
:class="{
'head-div--share': props.shareMode,
'workflow-issue-focus': issueFocusActive,
}"
v-loading="pageLoading"
>
<CommonSelectDataModal
@@ -846,9 +873,16 @@ function onAsyncExecute(info: any) {
:polling-data="chainInfo"
/>
</ElDrawer>
<div class="flex items-center justify-between border-b p-2.5">
<div>
<ElButton :icon="ArrowLeft" link @click="router.back()">
<div
class="workflow-designer-header flex items-center justify-between gap-2 border-b p-2.5"
>
<div class="min-w-0">
<ElButton
v-if="!props.shareMode"
:icon="ArrowLeft"
link
@click="router.back()"
>
<span
class="max-w-[500px] overflow-hidden text-ellipsis text-nowrap text-base"
style="font-size: 14px"
@@ -857,6 +891,9 @@ function onAsyncExecute(info: any) {
{{ workflowInfo.title }}
</span>
</ElButton>
<div v-else class="workflow-share-title" :title="workflowInfo.title">
{{ workflowInfo.title }}
</div>
</div>
<div class="workflow-head-actions">
<ElButton
@@ -893,7 +930,7 @@ function onAsyncExecute(info: any) {
class="load-div"
>
<template #extra>
<ElButton @click="backToWorkflowList">
<ElButton v-if="!props.shareMode" @click="backToWorkflowList">
{{ $t('button.back') }}
</ElButton>
<ElButton type="primary" @click="initializeWorkflow">
@@ -1001,6 +1038,40 @@ function onAsyncExecute(info: any) {
background-color: var(--el-bg-color);
}
.head-div--share {
display: flex;
flex-direction: column;
height: 100vh !important;
min-height: 0;
overflow: hidden;
}
.head-div--share :deep(.agentsflow) {
height: 100% !important;
}
.head-div--share .tiny-flow-container {
flex: 1;
min-height: 0;
height: auto;
}
.workflow-designer-header {
flex: 0 0 auto;
}
.workflow-share-title {
max-width: min(500px, 48vw);
padding: 0 8px;
overflow: hidden;
font-size: 14px;
font-weight: 600;
line-height: 32px;
color: var(--el-text-color-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-head-actions {
display: flex;
flex-wrap: wrap;

View File

@@ -865,7 +865,7 @@ async function shareWorkflow(row: any) {
}
const shareUrl = buildAbsoluteAppRouteUrl(
router.resolve({
name: 'WorkflowDesign',
name: 'WorkflowShare',
query: {
shareKey: res.data.shareKey,
},

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import WorkflowDesign from './WorkflowDesign.vue';
</script>
<template>
<WorkflowDesign share-mode />
</template>

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
isWorkflowShareRequest,
readWorkflowShareKey,
resolveWorkflowShareFailureReason,
resolveWorkflowShareWorkflowId,
withWorkflowShareHeader,
WORKFLOW_SHARE_HEADER,
@@ -25,6 +26,14 @@ describe('workflow share context', () => {
).toBe('hash-key');
});
it('reads the share key from the standalone workflow share route', () => {
expect(
readWorkflowShareKey(
'https://example.test/flow/#/share/workflow?shareKey=standalone-key',
),
).toBe('standalone-key');
});
it('adds the workflow share header without dropping existing headers', () => {
expect(
withWorkflowShareHeader(
@@ -163,4 +172,15 @@ describe('workflow share context', () => {
).resolves.toBeNull();
expect(onFailure).toHaveBeenCalledWith(resolveError);
});
it('maps workflow share failures to the expired page reason', () => {
expect(
resolveWorkflowShareFailureReason({
response: { data: { message: '工作流分享链接已过期' } },
}),
).toBe('expired');
expect(
resolveWorkflowShareFailureReason(new Error('工作流分享链接无效')),
).toBe('invalid');
});
});