feat: 将工作流分享改为独立页面
- 新增无后台布局的工作流分享与失效路由 - 同步分享地址并保留旧链接兼容 - 补充前后端分享链路测试
This commit is contained in:
@@ -109,24 +109,24 @@ public class WorkflowShareController {
|
|||||||
* 根据管理端来源构建工作流分享基础 URL。
|
* 根据管理端来源构建工作流分享基础 URL。
|
||||||
*
|
*
|
||||||
* @param request HTTP 请求
|
* @param request HTTP 请求
|
||||||
* @return 工作流设计页 URL
|
* @return 工作流独立分享页 URL
|
||||||
*/
|
*/
|
||||||
private String buildShareBaseUrl(HttpServletRequest request) {
|
private String buildShareBaseUrl(HttpServletRequest request) {
|
||||||
String refererBaseUrl = extractFrontendBaseUrl(RequestUtil.getReferer(request));
|
String refererBaseUrl = extractFrontendBaseUrl(RequestUtil.getReferer(request));
|
||||||
if (refererBaseUrl != null) {
|
if (refererBaseUrl != null) {
|
||||||
return refererBaseUrl + "/ai/workflow/design";
|
return refererBaseUrl + "/share/workflow";
|
||||||
}
|
}
|
||||||
|
|
||||||
String forwardedOrigin = buildForwardedOrigin(request);
|
String forwardedOrigin = buildForwardedOrigin(request);
|
||||||
if (forwardedOrigin != null) {
|
if (forwardedOrigin != null) {
|
||||||
return forwardedOrigin
|
return forwardedOrigin
|
||||||
+ normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix")))
|
+ normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix")))
|
||||||
+ "/ai/workflow/design";
|
+ "/share/workflow";
|
||||||
}
|
}
|
||||||
|
|
||||||
String origin = normalizeOrigin(request.getHeader("Origin"));
|
String origin = normalizeOrigin(request.getHeader("Origin"));
|
||||||
if (origin != null) {
|
if (origin != null) {
|
||||||
return origin + normalizeBasePath(request.getContextPath()) + "/ai/workflow/design";
|
return origin + normalizeBasePath(request.getContextPath()) + "/share/workflow";
|
||||||
}
|
}
|
||||||
StringBuilder builder = new StringBuilder();
|
StringBuilder builder = new StringBuilder();
|
||||||
builder.append(request.getScheme()).append("://").append(request.getServerName());
|
builder.append(request.getScheme()).append("://").append(request.getServerName());
|
||||||
@@ -134,7 +134,7 @@ public class WorkflowShareController {
|
|||||||
builder.append(':').append(request.getServerPort());
|
builder.append(':').append(request.getServerPort());
|
||||||
}
|
}
|
||||||
return builder.append(normalizeBasePath(request.getContextPath()))
|
return builder.append(normalizeBasePath(request.getContextPath()))
|
||||||
.append("/ai/workflow/design")
|
.append("/share/workflow")
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class WorkflowShareControllerTest {
|
|||||||
|
|
||||||
Assert.assertEquals(
|
Assert.assertEquals(
|
||||||
buildShareBaseUrl(request),
|
buildShareBaseUrl(request),
|
||||||
"https://example.test/easyflow/ai/workflow/design"
|
"https://example.test/easyflow/share/workflow"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ public class WorkflowShareControllerTest {
|
|||||||
|
|
||||||
Assert.assertEquals(
|
Assert.assertEquals(
|
||||||
buildShareBaseUrl(request),
|
buildShareBaseUrl(request),
|
||||||
"https://example.test/easyflow/ai/workflow/design"
|
"https://example.test/easyflow/share/workflow"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,8 +28,13 @@ interface NetworkConnectionLike {
|
|||||||
|
|
||||||
const CHUNK_ERROR_RELOAD_KEY = '__easyflow_chunk_error_reload_path__';
|
const CHUNK_ERROR_RELOAD_KEY = '__easyflow_chunk_error_reload_path__';
|
||||||
|
|
||||||
function isKnowledgeShareRoute(path: string) {
|
function isExternalShareRoute(path: string) {
|
||||||
return path === '/share/knowledge' || path === '/share/knowledge/expired';
|
return (
|
||||||
|
path === '/share/knowledge' ||
|
||||||
|
path === '/share/knowledge/expired' ||
|
||||||
|
path === '/share/workflow' ||
|
||||||
|
path === '/share/workflow/expired'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSlowNetworkConnection() {
|
function isSlowNetworkConnection() {
|
||||||
@@ -242,7 +247,7 @@ function setupAccessGuard(router: Router) {
|
|||||||
return buildForcePasswordRoute();
|
return buildForcePasswordRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isKnowledgeShareRoute(to.path)) {
|
if (isExternalShareRoute(to.path)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,31 @@ const routes: RouteRecordRaw[] = [
|
|||||||
hideInTab: true,
|
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;
|
export default routes;
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ describe('login redirect', () => {
|
|||||||
|
|
||||||
it('accepts an unencoded workflow share route', () => {
|
it('accepts an unencoded workflow share route', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveLoginRedirectPath('/ai/workflow/design?shareKey=workflow-key'),
|
resolveLoginRedirectPath('/share/workflow?shareKey=workflow-key'),
|
||||||
).toBe('/ai/workflow/design?shareKey=workflow-key');
|
).toBe('/share/workflow?shareKey=workflow-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the first valid query value', () => {
|
it('uses the first valid query value', () => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface WorkflowShareHeaderOptions {
|
|||||||
requestUrl?: string;
|
requestUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORKFLOW_DESIGN_ROUTE = '/ai/workflow/design';
|
const WORKFLOW_SHARE_ROUTES = ['/share/workflow', '/ai/workflow/design'];
|
||||||
const WORKFLOW_SHARE_REQUESTS = [
|
const WORKFLOW_SHARE_REQUESTS = [
|
||||||
['GET', '/api/v1/workflow/detail'],
|
['GET', '/api/v1/workflow/detail'],
|
||||||
['GET', '/api/v1/workflow/getRunningParameters'],
|
['GET', '/api/v1/workflow/getRunningParameters'],
|
||||||
@@ -37,7 +37,42 @@ const WORKFLOW_SHARE_REQUESTS = [
|
|||||||
* 从页面地址读取工作流分享密钥,兼容 history 与 hash 路由。
|
* 从页面地址读取工作流分享密钥,兼容 history 与 hash 路由。
|
||||||
*/
|
*/
|
||||||
export function readWorkflowShareKey(url?: string): null | string {
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import {api} from '#/api/request';
|
|||||||
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
|
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
|
||||||
import {$t} from '#/locales';
|
import {$t} from '#/locales';
|
||||||
import {router} from '#/router';
|
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 {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon';
|
||||||
import {
|
import {
|
||||||
canAiResourceRepublish,
|
canAiResourceRepublish,
|
||||||
@@ -49,6 +52,14 @@ import {
|
|||||||
|
|
||||||
import '@tinyflow-ai/vue/dist/index.css';
|
import '@tinyflow-ai/vue/dist/index.css';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
shareMode?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
shareMode: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { isDark } = usePreferences();
|
const { isDark } = usePreferences();
|
||||||
// vue
|
// vue
|
||||||
@@ -86,16 +97,29 @@ const codeEngineList = ref<any[]>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
async function resolveSharedWorkflowId() {
|
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({
|
workflowId.value = await resolveWorkflowShareWorkflowId({
|
||||||
currentWorkflowId: workflowId.value,
|
currentWorkflowId: workflowId.value,
|
||||||
shareKey: route.query.shareKey,
|
shareKey,
|
||||||
resolve: async () => {
|
resolve: async () => {
|
||||||
const res = await api.get('/api/v1/workflowShare/resolve');
|
const res = await api.get('/api/v1/workflowShare/resolve');
|
||||||
return res.data?.workflowId;
|
return res.data?.workflowId;
|
||||||
},
|
},
|
||||||
onFailure: async () => {
|
onFailure: async (error) => {
|
||||||
ElMessage.error($t('aiWorkflow.shareExpired'));
|
await router.replace({
|
||||||
await router.replace({ path: '/ai/workflow' });
|
name: 'WorkflowShareExpired',
|
||||||
|
query: { reason: resolveWorkflowShareFailureReason(error) },
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -787,7 +811,10 @@ function onAsyncExecute(info: any) {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="head-div h-full w-full"
|
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"
|
v-loading="pageLoading"
|
||||||
>
|
>
|
||||||
<CommonSelectDataModal
|
<CommonSelectDataModal
|
||||||
@@ -846,9 +873,16 @@ function onAsyncExecute(info: any) {
|
|||||||
:polling-data="chainInfo"
|
:polling-data="chainInfo"
|
||||||
/>
|
/>
|
||||||
</ElDrawer>
|
</ElDrawer>
|
||||||
<div class="flex items-center justify-between border-b p-2.5">
|
<div
|
||||||
<div>
|
class="workflow-designer-header flex items-center justify-between gap-2 border-b p-2.5"
|
||||||
<ElButton :icon="ArrowLeft" link @click="router.back()">
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<ElButton
|
||||||
|
v-if="!props.shareMode"
|
||||||
|
:icon="ArrowLeft"
|
||||||
|
link
|
||||||
|
@click="router.back()"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="max-w-[500px] overflow-hidden text-ellipsis text-nowrap text-base"
|
class="max-w-[500px] overflow-hidden text-ellipsis text-nowrap text-base"
|
||||||
style="font-size: 14px"
|
style="font-size: 14px"
|
||||||
@@ -857,6 +891,9 @@ function onAsyncExecute(info: any) {
|
|||||||
{{ workflowInfo.title }}
|
{{ workflowInfo.title }}
|
||||||
</span>
|
</span>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
|
<div v-else class="workflow-share-title" :title="workflowInfo.title">
|
||||||
|
{{ workflowInfo.title }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="workflow-head-actions">
|
<div class="workflow-head-actions">
|
||||||
<ElButton
|
<ElButton
|
||||||
@@ -893,7 +930,7 @@ function onAsyncExecute(info: any) {
|
|||||||
class="load-div"
|
class="load-div"
|
||||||
>
|
>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<ElButton @click="backToWorkflowList">
|
<ElButton v-if="!props.shareMode" @click="backToWorkflowList">
|
||||||
{{ $t('button.back') }}
|
{{ $t('button.back') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton type="primary" @click="initializeWorkflow">
|
<ElButton type="primary" @click="initializeWorkflow">
|
||||||
@@ -1001,6 +1038,40 @@ function onAsyncExecute(info: any) {
|
|||||||
background-color: var(--el-bg-color);
|
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 {
|
.workflow-head-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -865,7 +865,7 @@ async function shareWorkflow(row: any) {
|
|||||||
}
|
}
|
||||||
const shareUrl = buildAbsoluteAppRouteUrl(
|
const shareUrl = buildAbsoluteAppRouteUrl(
|
||||||
router.resolve({
|
router.resolve({
|
||||||
name: 'WorkflowDesign',
|
name: 'WorkflowShare',
|
||||||
query: {
|
query: {
|
||||||
shareKey: res.data.shareKey,
|
shareKey: res.data.shareKey,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import WorkflowDesign from './WorkflowDesign.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<WorkflowDesign share-mode />
|
||||||
|
</template>
|
||||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
isWorkflowShareRequest,
|
isWorkflowShareRequest,
|
||||||
readWorkflowShareKey,
|
readWorkflowShareKey,
|
||||||
|
resolveWorkflowShareFailureReason,
|
||||||
resolveWorkflowShareWorkflowId,
|
resolveWorkflowShareWorkflowId,
|
||||||
withWorkflowShareHeader,
|
withWorkflowShareHeader,
|
||||||
WORKFLOW_SHARE_HEADER,
|
WORKFLOW_SHARE_HEADER,
|
||||||
@@ -25,6 +26,14 @@ describe('workflow share context', () => {
|
|||||||
).toBe('hash-key');
|
).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', () => {
|
it('adds the workflow share header without dropping existing headers', () => {
|
||||||
expect(
|
expect(
|
||||||
withWorkflowShareHeader(
|
withWorkflowShareHeader(
|
||||||
@@ -163,4 +172,15 @@ describe('workflow share context', () => {
|
|||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
expect(onFailure).toHaveBeenCalledWith(resolveError);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user