fix: 修复分享链接路由与登录回跳

- 统一生成包含部署基路径和 Hash 路由的知识库、工作流分享地址

- 未登录访问分享页时保留目标地址,并在登录成功后自动回跳

- 限定分享密钥作用域并补充失效、加载失败及回归测试
This commit is contained in:
2026-07-24 16:00:57 +08:00
parent 41545fcef0
commit 526e16163b
15 changed files with 465 additions and 71 deletions

View File

@@ -14,7 +14,7 @@ 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, ElSkeleton,} from 'element-plus';
import {ElButton, ElDrawer, ElMessage, ElResult, ElSkeleton,} from 'element-plus';
import {api} from '#/api/request';
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
@@ -54,18 +54,7 @@ const { isDark } = usePreferences();
// vue
onMounted(async () => {
document.addEventListener('keydown', handleKeydown);
await resolveSharedWorkflowId();
if (!workflowId.value) {
return;
}
await Promise.all([
loadCustomNode(),
getLlmList(),
getKnowledgeList(),
getCodeEngineList(),
getWorkflowInfo(workflowId.value),
]);
showTinyFlow.value = true;
await initializeWorkflow();
});
onBeforeUnmount(() => {
captureCurrentWorkflowDraft();
@@ -83,6 +72,7 @@ onDeactivated(() => {
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
const workflowId = ref(route.query.id);
const workflowInfo = ref<any>({});
const initializationError = ref(false);
const runParams = ref<any>(null);
const tinyFlowData = ref<any>(null);
const llmList = ref<any>([]);
@@ -109,6 +99,32 @@ async function resolveSharedWorkflowId() {
},
});
}
async function initializeWorkflow() {
initializationError.value = false;
showTinyFlow.value = false;
try {
await resolveSharedWorkflowId();
if (!workflowId.value) {
return;
}
await Promise.all([
loadCustomNode(),
getLlmList(),
getKnowledgeList(),
getCodeEngineList(),
getWorkflowInfo(workflowId.value),
]);
showTinyFlow.value = true;
} catch (error) {
console.error('Workflow initialization failed:', error);
initializationError.value = true;
}
}
function backToWorkflowList() {
router.replace({ path: '/ai/workflow' });
}
const WORKFLOW_DRAFT_WRITE_DELAY = 320;
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
let pendingDraftContent: any = null;
@@ -870,9 +886,24 @@ function onAsyncExecute(info: any) {
</ElButton>
</div>
</div>
<ElResult
v-if="initializationError"
icon="error"
:title="$t('aiWorkflow.loadFailed')"
class="load-div"
>
<template #extra>
<ElButton @click="backToWorkflowList">
{{ $t('button.back') }}
</ElButton>
<ElButton type="primary" @click="initializeWorkflow">
{{ $t('aiWorkflow.reload') }}
</ElButton>
</template>
</ElResult>
<Tinyflow
ref="tinyflowRef"
v-if="showTinyFlow"
v-else-if="showTinyFlow"
class="tiny-flow-container"
:data="JSON.parse(JSON.stringify(tinyFlowData))"
:theme="isDark ? 'dark' : 'light'"
@@ -941,7 +972,12 @@ function onAsyncExecute(info: any) {
</div>
</div>
</transition>
<ElSkeleton v-if="!showTinyFlow" class="load-div" :rows="5" animated />
<ElSkeleton
v-if="!showTinyFlow && !initializationError"
class="load-div"
:rows="5"
animated
/>
</div>
</template>

View File

@@ -62,6 +62,7 @@ import { $t } from '#/locales';
import { router } from '#/router';
import { useDictStore } from '#/store';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
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';
@@ -862,15 +863,14 @@ async function shareWorkflow(row: any) {
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();
const shareUrl = buildAbsoluteAppRouteUrl(
router.resolve({
name: 'WorkflowDesign',
query: {
shareKey: res.data.shareKey,
},
}).href,
);
await copyTextWithFeedback(
shareUrl,
$t('message.copySuccess'),

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import {
isWorkflowShareRequest,
readWorkflowShareKey,
resolveWorkflowShareWorkflowId,
withWorkflowShareHeader,
@@ -28,7 +29,12 @@ describe('workflow share context', () => {
expect(
withWorkflowShareHeader(
{ 'Accept-Language': 'zh-CN' },
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
{
pageUrl:
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
requestMethod: 'GET',
requestUrl: '/api/v1/workflow/detail?id=1',
},
),
).toEqual({
'Accept-Language': 'zh-CN',
@@ -40,13 +46,92 @@ describe('workflow share context', () => {
const headers = { 'Accept-Language': 'zh-CN' };
expect(
withWorkflowShareHeader(
headers,
'https://example.test/ai/workflow/design?id=1',
),
withWorkflowShareHeader(headers, {
pageUrl: 'https://example.test/ai/workflow/design?id=1',
requestMethod: 'GET',
requestUrl: '/api/v1/workflow/detail?id=1',
}),
).toEqual(headers);
});
it('ignores a knowledge share key left before the active hash route', () => {
const pollutedUrl =
'https://example.test/flow/share/knowledge?shareKey=knowledge-key#/ai/workflow';
expect(readWorkflowShareKey(pollutedUrl)).toBeNull();
expect(
withWorkflowShareHeader(
{ 'Accept-Language': 'zh-CN' },
{
pageUrl: pollutedUrl,
requestMethod: 'POST',
requestUrl: '/api/v1/workflow/submitPublishApproval',
},
),
).toEqual({ 'Accept-Language': 'zh-CN' });
});
it('does not reuse an outer share key after entering workflow design', () => {
expect(
readWorkflowShareKey(
'https://example.test/flow/share/knowledge?shareKey=knowledge-key#/ai/workflow/design?id=1',
),
).toBeNull();
});
it('only attaches the share key to explicitly allowed workflow requests', () => {
const pageUrl =
'https://example.test/flow/#/ai/workflow/design?shareKey=workflow-key';
expect(
withWorkflowShareHeader(
{},
{
pageUrl,
requestMethod: 'GET',
requestUrl: '/api/v1/workflow/page',
},
),
).toEqual({});
expect(
withWorkflowShareHeader(
{},
{
pageUrl,
requestMethod: 'POST',
requestUrl: '/api/v1/bot/chat',
},
),
).toEqual({});
expect(
withWorkflowShareHeader(
{},
{
pageUrl,
requestMethod: 'POST',
requestUrl: '/api/v1/workflow/update',
},
),
).toEqual({
[WORKFLOW_SHARE_HEADER]: 'workflow-key',
});
});
it('matches only the workflow sharing endpoint whitelist', () => {
expect(
isWorkflowShareRequest(
'/flow/api/v1/workflow/submitPublishApproval',
'post',
),
).toBe(true);
expect(isWorkflowShareRequest('/flow/api/v1/workflow/page', 'get')).toBe(
false,
);
expect(isWorkflowShareRequest('/flow/api/v1/model/list', 'get')).toBe(
false,
);
});
it('resolves the workflow id for a shared URL', async () => {
const resolve = vi.fn().mockResolvedValue('workflow-1');
const onFailure = vi.fn();