发布 v1.10 #5
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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__';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -865,7 +865,7 @@ async function shareWorkflow(row: any) {
|
||||
}
|
||||
const shareUrl = buildAbsoluteAppRouteUrl(
|
||||
router.resolve({
|
||||
name: 'WorkflowDesign',
|
||||
name: 'WorkflowShare',
|
||||
query: {
|
||||
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 {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user