发布 v1.10 #5
@@ -1,4 +1,5 @@
|
|||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
|
import { readScopedRouteQueryParam } from '#/utils/share-route-context';
|
||||||
|
|
||||||
const EXPIRED_ERROR_CODES = new Set([4601, 4602]);
|
const EXPIRED_ERROR_CODES = new Set([4601, 4602]);
|
||||||
const SHARE_ERROR_REASON: Record<number, string> = {
|
const SHARE_ERROR_REASON: Record<number, string> = {
|
||||||
@@ -8,8 +9,8 @@ const SHARE_ERROR_REASON: Record<number, string> = {
|
|||||||
const APP_BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
const APP_BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||||
|
|
||||||
function getShareParams() {
|
function getShareParams() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const shareKey =
|
||||||
const shareKey = params.get('shareKey') || '';
|
readScopedRouteQueryParam('/share/knowledge', 'shareKey') || '';
|
||||||
return { shareKey };
|
return { shareKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +37,12 @@ function redirectIfExpired(response: any) {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
const reason = SHARE_ERROR_REASON[errorCode] || 'expired';
|
const reason = SHARE_ERROR_REASON[errorCode] || 'expired';
|
||||||
window.location.assign(
|
const route = `/share/knowledge/expired?reason=${encodeURIComponent(reason)}`;
|
||||||
`${APP_BASE_PATH}/share/knowledge/expired?reason=${reason}`,
|
const target =
|
||||||
);
|
import.meta.env.VITE_ROUTER_HISTORY === 'hash'
|
||||||
|
? `${APP_BASE_PATH}/#${route}`
|
||||||
|
: `${APP_BASE_PATH}${route}`;
|
||||||
|
window.location.assign(target);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { events } from 'fetch-event-stream';
|
|||||||
|
|
||||||
import { useAuthStore } from '#/store';
|
import { useAuthStore } from '#/store';
|
||||||
import {
|
import {
|
||||||
|
isWorkflowShareRequest,
|
||||||
readWorkflowShareKey,
|
readWorkflowShareKey,
|
||||||
withWorkflowShareHeader,
|
withWorkflowShareHeader,
|
||||||
WORKFLOW_SHARE_HEADER,
|
WORKFLOW_SHARE_HEADER,
|
||||||
@@ -101,7 +102,10 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
|
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
|
||||||
config.headers['Accept-Language'] = preferences.app.locale;
|
config.headers['Accept-Language'] = preferences.app.locale;
|
||||||
const workflowShareKey = readWorkflowShareKey();
|
const workflowShareKey = readWorkflowShareKey();
|
||||||
if (workflowShareKey) {
|
if (
|
||||||
|
workflowShareKey &&
|
||||||
|
isWorkflowShareRequest(config.url, config.method)
|
||||||
|
) {
|
||||||
config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey;
|
config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
@@ -196,7 +200,7 @@ export class SseClient {
|
|||||||
const res = await fetch(apiURL + url, {
|
const res = await fetch(apiURL + url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
signal, // 使用局部变量 signal
|
signal, // 使用局部变量 signal
|
||||||
headers: this.getHeaders(options?.headers),
|
headers: this.getHeaders(url, options?.headers),
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,7 +269,7 @@ export class SseClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHeaders(extraHeaders?: HeadersInit) {
|
private getHeaders(requestUrl: string, extraHeaders?: HeadersInit) {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: 'text/event-stream',
|
Accept: 'text/event-stream',
|
||||||
@@ -277,7 +281,10 @@ export class SseClient {
|
|||||||
headers[key] = value;
|
headers[key] = value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return withWorkflowShareHeader(headers);
|
return withWorkflowShareHeader(headers, {
|
||||||
|
requestMethod: 'POST',
|
||||||
|
requestUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
"apiStatusExample": "Status Query Example",
|
"apiStatusExample": "Status Query Example",
|
||||||
"apiResumeExample": "Resume Example",
|
"apiResumeExample": "Resume Example",
|
||||||
"shareExpired": "This workflow share link has expired. Request a new link",
|
"shareExpired": "This workflow share link has expired. Request a new link",
|
||||||
|
"loadFailed": "Unable to load the workflow. Please try again",
|
||||||
|
"reload": "Reload",
|
||||||
"submitPublishApprovalConfirm": "Publish the current workflow now?",
|
"submitPublishApprovalConfirm": "Publish the current workflow now?",
|
||||||
"submitRepublishApprovalConfirm": "Republish the current workflow now?",
|
"submitRepublishApprovalConfirm": "Republish the current workflow now?",
|
||||||
"submitOfflineApprovalConfirm": "Take the current workflow offline?",
|
"submitOfflineApprovalConfirm": "Take the current workflow offline?",
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
"apiStatusExample": "状态查询示例",
|
"apiStatusExample": "状态查询示例",
|
||||||
"apiResumeExample": "恢复执行示例",
|
"apiResumeExample": "恢复执行示例",
|
||||||
"shareExpired": "工作流分享链接已失效,请重新获取",
|
"shareExpired": "工作流分享链接已失效,请重新获取",
|
||||||
|
"loadFailed": "工作流加载失败,请重试",
|
||||||
|
"reload": "重新加载",
|
||||||
"submitPublishApprovalConfirm": "确认发布当前工作流吗?",
|
"submitPublishApprovalConfirm": "确认发布当前工作流吗?",
|
||||||
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
|
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
|
||||||
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?",
|
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?",
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ import {
|
|||||||
logoutApi,
|
logoutApi,
|
||||||
} from '#/api';
|
} from '#/api';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
|
||||||
|
import { resolveLoginRedirectPath } from '#/utils/login-redirect';
|
||||||
import {
|
import {
|
||||||
buildForcePasswordRoute,
|
buildForcePasswordRoute,
|
||||||
shouldForcePasswordChange,
|
shouldForcePasswordChange,
|
||||||
} from '#/utils/password-reset';
|
} from '#/utils/password-reset';
|
||||||
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
@@ -67,9 +68,16 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
} else if (!options.skipRedirect) {
|
} else if (!options.skipRedirect) {
|
||||||
const homePath =
|
const homePath =
|
||||||
userInfo.homePath || preferences.app.defaultHomePath || '/';
|
userInfo.homePath || preferences.app.defaultHomePath || '/';
|
||||||
options.onSuccess
|
const redirectPath = resolveLoginRedirectPath(
|
||||||
? await options.onSuccess()
|
router.currentRoute.value.query.redirect,
|
||||||
: await router.push(homePath);
|
);
|
||||||
|
if (options.onSuccess) {
|
||||||
|
await options.onSuccess();
|
||||||
|
} else if (redirectPath) {
|
||||||
|
await router.replace(redirectPath);
|
||||||
|
} else {
|
||||||
|
await router.push(homePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.notify !== false && userInfo?.nickname) {
|
if (options.notify !== false && userInfo?.nickname) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveLoginRedirectPath } from '#/utils/login-redirect';
|
||||||
|
|
||||||
|
describe('login redirect', () => {
|
||||||
|
it('restores an encoded knowledge share route', () => {
|
||||||
|
expect(
|
||||||
|
resolveLoginRedirectPath(
|
||||||
|
encodeURIComponent('/share/knowledge?shareKey=knowledge-key'),
|
||||||
|
),
|
||||||
|
).toBe('/share/knowledge?shareKey=knowledge-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an unencoded workflow share route', () => {
|
||||||
|
expect(
|
||||||
|
resolveLoginRedirectPath('/ai/workflow/design?shareKey=workflow-key'),
|
||||||
|
).toBe('/ai/workflow/design?shareKey=workflow-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the first valid query value', () => {
|
||||||
|
expect(
|
||||||
|
resolveLoginRedirectPath([
|
||||||
|
undefined,
|
||||||
|
encodeURIComponent('/share/knowledge?shareKey=knowledge-key'),
|
||||||
|
]),
|
||||||
|
).toBe('/share/knowledge?shareKey=knowledge-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects external and malformed redirects', () => {
|
||||||
|
expect(resolveLoginRedirectPath('https://example.test')).toBeNull();
|
||||||
|
expect(resolveLoginRedirectPath('//example.test/path')).toBeNull();
|
||||||
|
expect(resolveLoginRedirectPath('%E0%A4%A')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
easyflow-ui-admin/app/src/utils/login-redirect.ts
Normal file
25
easyflow-ui-admin/app/src/utils/login-redirect.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
function firstQueryValue(value: unknown) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.find((item) => typeof item === 'string' && item.trim());
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析登录成功后的站内回跳地址,并拒绝外部或无效跳转。
|
||||||
|
*/
|
||||||
|
export function resolveLoginRedirectPath(value: unknown): null | string {
|
||||||
|
const rawValue = firstQueryValue(value);
|
||||||
|
if (typeof rawValue !== 'string' || !rawValue.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const path = decodeURIComponent(rawValue.trim());
|
||||||
|
if (!path.startsWith('/') || path.startsWith('//')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
easyflow-ui-admin/app/src/utils/share-route-context.test.ts
Normal file
66
easyflow-ui-admin/app/src/utils/share-route-context.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildAbsoluteAppRouteUrl,
|
||||||
|
readScopedRouteQueryParam,
|
||||||
|
} from './share-route-context';
|
||||||
|
|
||||||
|
describe('share route context', () => {
|
||||||
|
it('reads a knowledge share key from the active hash route', () => {
|
||||||
|
expect(
|
||||||
|
readScopedRouteQueryParam(
|
||||||
|
'/share/knowledge',
|
||||||
|
'shareKey',
|
||||||
|
'https://example.test/flow/#/share/knowledge?shareKey=knowledge-key',
|
||||||
|
),
|
||||||
|
).toBe('knowledge-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a knowledge share key from a history route', () => {
|
||||||
|
expect(
|
||||||
|
readScopedRouteQueryParam(
|
||||||
|
'/share/knowledge',
|
||||||
|
'shareKey',
|
||||||
|
'https://example.test/flow/share/knowledge?shareKey=knowledge-key',
|
||||||
|
),
|
||||||
|
).toBe('knowledge-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the active hash route over an outer query', () => {
|
||||||
|
expect(
|
||||||
|
readScopedRouteQueryParam(
|
||||||
|
'/share/knowledge',
|
||||||
|
'shareKey',
|
||||||
|
'https://example.test/flow/share/knowledge?shareKey=stale-key#/ai/workflow',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the hash route when building an absolute share URL', () => {
|
||||||
|
expect(
|
||||||
|
buildAbsoluteAppRouteUrl(
|
||||||
|
'/flow/#/share/knowledge?shareKey=knowledge-key',
|
||||||
|
'https://example.test',
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
'https://example.test/flow/#/share/knowledge?shareKey=knowledge-key',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the runtime base path for a hash-only router href', () => {
|
||||||
|
const previousPath = `${window.location.pathname}${window.location.search}`;
|
||||||
|
window.history.replaceState({}, '', '/flow/');
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
buildAbsoluteAppRouteUrl(
|
||||||
|
'#/share/knowledge?shareKey=knowledge-key',
|
||||||
|
'https://example.test',
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
'https://example.test/flow/#/share/knowledge?shareKey=knowledge-key',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
window.history.replaceState({}, '', previousPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
87
easyflow-ui-admin/app/src/utils/share-route-context.ts
Normal file
87
easyflow-ui-admin/app/src/utils/share-route-context.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 判断当前路径是否对应指定的应用路由。
|
||||||
|
*/
|
||||||
|
function matchesRoutePath(pathname: string, routePath: string) {
|
||||||
|
const normalizedPathname = pathname.replace(/\/+$/, '');
|
||||||
|
const normalizedRoutePath = routePath.replace(/\/+$/, '');
|
||||||
|
return (
|
||||||
|
normalizedPathname === normalizedRoutePath ||
|
||||||
|
normalizedPathname.endsWith(normalizedRoutePath)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 Hash 模式下当前激活的应用路由。
|
||||||
|
*/
|
||||||
|
function parseHashRoute(hash: string) {
|
||||||
|
if (!hash.startsWith('#/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new URL(hash.slice(1), 'http://easyflow.local');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从指定应用路由读取查询参数。
|
||||||
|
*
|
||||||
|
* Hash 路由存在时仅信任 Hash 内的激活路由,避免 pathname 上遗留的分享参数
|
||||||
|
* 污染后续页面。
|
||||||
|
*/
|
||||||
|
export function readScopedRouteQueryParam(
|
||||||
|
routePath: string,
|
||||||
|
queryName: string,
|
||||||
|
url?: string,
|
||||||
|
): null | string {
|
||||||
|
const currentUrl =
|
||||||
|
url || (typeof window === 'undefined' ? '' : window.location.href);
|
||||||
|
if (!currentUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(
|
||||||
|
currentUrl,
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? 'http://localhost'
|
||||||
|
: window.location.origin,
|
||||||
|
);
|
||||||
|
const hashRoute = parseHashRoute(parsed.hash);
|
||||||
|
if (hashRoute) {
|
||||||
|
if (!matchesRoutePath(hashRoute.pathname, routePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hashRoute.searchParams.get(queryName)?.trim() || null;
|
||||||
|
}
|
||||||
|
if (!matchesRoutePath(parsed.pathname, routePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.searchParams.get(queryName)?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Router 解析出的应用地址转换为可复制的绝对地址。
|
||||||
|
*
|
||||||
|
* Hash 路由下 Router 可能只返回 `#/path`,需显式补齐部署基路径。
|
||||||
|
*/
|
||||||
|
export function buildAbsoluteAppRouteUrl(href: string, origin?: string) {
|
||||||
|
const currentOrigin =
|
||||||
|
origin ||
|
||||||
|
(typeof window === 'undefined'
|
||||||
|
? 'http://localhost'
|
||||||
|
: window.location.origin);
|
||||||
|
if (!href.startsWith('#/') && !href.startsWith('/#/')) {
|
||||||
|
return new URL(href, currentOrigin).toString();
|
||||||
|
}
|
||||||
|
const configuredBase = import.meta.env.BASE_URL || '/';
|
||||||
|
const runtimeBase =
|
||||||
|
configuredBase === '/' &&
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.location.pathname.endsWith('/')
|
||||||
|
? window.location.pathname
|
||||||
|
: configuredBase;
|
||||||
|
return new URL(
|
||||||
|
`${runtimeBase.replace(/\/?$/, '/')}${href.slice(href.indexOf('#'))}`,
|
||||||
|
currentOrigin,
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readScopedRouteQueryParam } from './share-route-context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作流协作分享请求头。
|
* 工作流协作分享请求头。
|
||||||
*/
|
*/
|
||||||
@@ -10,37 +12,52 @@ interface WorkflowShareResolutionOptions<T> {
|
|||||||
shareKey?: unknown;
|
shareKey?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkflowShareHeaderOptions {
|
||||||
|
pageUrl?: string;
|
||||||
|
requestMethod?: string;
|
||||||
|
requestUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKFLOW_DESIGN_ROUTE = '/ai/workflow/design';
|
||||||
|
const WORKFLOW_SHARE_REQUESTS = [
|
||||||
|
['GET', '/api/v1/workflow/detail'],
|
||||||
|
['GET', '/api/v1/workflow/getRunningParameters'],
|
||||||
|
['GET', '/api/v1/workflow/publishApprovalRequirement'],
|
||||||
|
['GET', '/api/v1/workflowShare/resolve'],
|
||||||
|
['POST', '/api/v1/workflow/check'],
|
||||||
|
['POST', '/api/v1/workflow/getChainStatus'],
|
||||||
|
['POST', '/api/v1/workflow/resume'],
|
||||||
|
['POST', '/api/v1/workflow/runAsync'],
|
||||||
|
['POST', '/api/v1/workflow/singleRun'],
|
||||||
|
['POST', '/api/v1/workflow/submitPublishApproval'],
|
||||||
|
['POST', '/api/v1/workflow/update'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从页面地址读取工作流分享密钥,兼容 history 与 hash 路由。
|
* 从页面地址读取工作流分享密钥,兼容 history 与 hash 路由。
|
||||||
*/
|
*/
|
||||||
export function readWorkflowShareKey(url?: string): null | string {
|
export function readWorkflowShareKey(url?: string): null | string {
|
||||||
const currentUrl =
|
return readScopedRouteQueryParam(WORKFLOW_DESIGN_ROUTE, 'shareKey', url);
|
||||||
url || (typeof window === 'undefined' ? '' : window.location.href);
|
}
|
||||||
if (!currentUrl) {
|
|
||||||
return null;
|
/**
|
||||||
|
* 判断当前请求是否属于工作流分享授权白名单。
|
||||||
|
*/
|
||||||
|
export function isWorkflowShareRequest(
|
||||||
|
requestUrl?: string,
|
||||||
|
requestMethod?: string,
|
||||||
|
) {
|
||||||
|
if (!requestUrl || !requestMethod) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(
|
const parsed = new URL(requestUrl, 'http://easyflow.local');
|
||||||
currentUrl,
|
const method = requestMethod.trim().toUpperCase();
|
||||||
typeof window === 'undefined'
|
return WORKFLOW_SHARE_REQUESTS.some(([allowedMethod, allowedPath]) => {
|
||||||
? 'http://localhost'
|
return method === allowedMethod && parsed.pathname.endsWith(allowedPath);
|
||||||
: window.location.origin,
|
});
|
||||||
);
|
|
||||||
const historyKey = parsed.searchParams.get('shareKey')?.trim();
|
|
||||||
if (historyKey) {
|
|
||||||
return historyKey;
|
|
||||||
}
|
|
||||||
const queryIndex = parsed.hash.indexOf('?');
|
|
||||||
if (queryIndex === -1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
new URLSearchParams(parsed.hash.slice(queryIndex + 1))
|
|
||||||
.get('shareKey')
|
|
||||||
?.trim() || null
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +66,12 @@ export function readWorkflowShareKey(url?: string): null | string {
|
|||||||
*/
|
*/
|
||||||
export function withWorkflowShareHeader(
|
export function withWorkflowShareHeader(
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
url?: string,
|
options: WorkflowShareHeaderOptions = {},
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const shareKey = readWorkflowShareKey(url);
|
if (!isWorkflowShareRequest(options.requestUrl, options.requestMethod)) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
const shareKey = readWorkflowShareKey(options.pageUrl);
|
||||||
if (!shareKey) {
|
if (!shareKey) {
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import CardPage from '#/components/page/CardList.vue';
|
|||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
import PageSide from '#/components/page/PageSide.vue';
|
import PageSide from '#/components/page/PageSide.vue';
|
||||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||||
|
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||||
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
|
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
|
||||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||||
@@ -154,10 +155,16 @@ async function shareKnowledge(row: Record<string, any>) {
|
|||||||
const res = await api.post('/api/v1/knowledgeShare/url/create', {
|
const res = await api.post('/api/v1/knowledgeShare/url/create', {
|
||||||
knowledgeId: row.id,
|
knowledgeId: row.id,
|
||||||
});
|
});
|
||||||
const shareUrl = String(res.data?.shareUrl || '').trim();
|
const shareKey = String(res.data?.shareKey || '').trim();
|
||||||
if (res.errorCode !== 0 || !shareUrl) {
|
if (res.errorCode !== 0 || !shareKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const shareUrl = buildAbsoluteAppRouteUrl(
|
||||||
|
router.resolve({
|
||||||
|
name: 'KnowledgeShare',
|
||||||
|
query: { shareKey },
|
||||||
|
}).href,
|
||||||
|
);
|
||||||
await copyTextWithFeedback(
|
await copyTextWithFeedback(
|
||||||
shareUrl,
|
shareUrl,
|
||||||
$t('message.copySuccess'),
|
$t('message.copySuccess'),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ import { ElButton, ElCard, ElIcon, ElInput, ElMessage } from 'element-plus';
|
|||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||||
|
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||||
|
|
||||||
type EndpointParam = {
|
type EndpointParam = {
|
||||||
location: 'body' | 'query';
|
location: 'body' | 'query';
|
||||||
@@ -38,6 +40,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const createLoading = ref(false);
|
const createLoading = ref(false);
|
||||||
const generatedUrl = ref('');
|
const generatedUrl = ref('');
|
||||||
const generatedExpireAt = ref('');
|
const generatedExpireAt = ref('');
|
||||||
@@ -189,7 +192,15 @@ const createShare = async () => {
|
|||||||
knowledgeId: props.knowledgeId,
|
knowledgeId: props.knowledgeId,
|
||||||
});
|
});
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
generatedUrl.value = res.data?.shareUrl || '';
|
const shareKey = String(res.data?.shareKey || '').trim();
|
||||||
|
generatedUrl.value = shareKey
|
||||||
|
? buildAbsoluteAppRouteUrl(
|
||||||
|
router.resolve({
|
||||||
|
name: 'KnowledgeShare',
|
||||||
|
query: { shareKey },
|
||||||
|
}).href,
|
||||||
|
)
|
||||||
|
: '';
|
||||||
generatedExpireAt.value = res.data?.expiresAt || '';
|
generatedExpireAt.value = res.data?.expiresAt || '';
|
||||||
ElMessage.success('已创建分享链接');
|
ElMessage.success('已创建分享链接');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {getOptions, sortNodes} from '@easyflow/utils';
|
|||||||
import {Tinyflow} from '@tinyflow-ai/vue';
|
import {Tinyflow} from '@tinyflow-ai/vue';
|
||||||
|
|
||||||
import {ArrowLeft, CircleCheck, Close, Promotion,} from '@element-plus/icons-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 {api} from '#/api/request';
|
||||||
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
|
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
|
||||||
@@ -54,18 +54,7 @@ const { isDark } = usePreferences();
|
|||||||
// vue
|
// vue
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('keydown', handleKeydown);
|
document.addEventListener('keydown', handleKeydown);
|
||||||
await resolveSharedWorkflowId();
|
await initializeWorkflow();
|
||||||
if (!workflowId.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await Promise.all([
|
|
||||||
loadCustomNode(),
|
|
||||||
getLlmList(),
|
|
||||||
getKnowledgeList(),
|
|
||||||
getCodeEngineList(),
|
|
||||||
getWorkflowInfo(workflowId.value),
|
|
||||||
]);
|
|
||||||
showTinyFlow.value = true;
|
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
captureCurrentWorkflowDraft();
|
captureCurrentWorkflowDraft();
|
||||||
@@ -83,6 +72,7 @@ onDeactivated(() => {
|
|||||||
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
|
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
|
||||||
const workflowId = ref(route.query.id);
|
const workflowId = ref(route.query.id);
|
||||||
const workflowInfo = ref<any>({});
|
const workflowInfo = ref<any>({});
|
||||||
|
const initializationError = ref(false);
|
||||||
const runParams = ref<any>(null);
|
const runParams = ref<any>(null);
|
||||||
const tinyFlowData = ref<any>(null);
|
const tinyFlowData = ref<any>(null);
|
||||||
const llmList = ref<any>([]);
|
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;
|
const WORKFLOW_DRAFT_WRITE_DELAY = 320;
|
||||||
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
|
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let pendingDraftContent: any = null;
|
let pendingDraftContent: any = null;
|
||||||
@@ -870,9 +886,24 @@ function onAsyncExecute(info: any) {
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Tinyflow
|
||||||
ref="tinyflowRef"
|
ref="tinyflowRef"
|
||||||
v-if="showTinyFlow"
|
v-else-if="showTinyFlow"
|
||||||
class="tiny-flow-container"
|
class="tiny-flow-container"
|
||||||
:data="JSON.parse(JSON.stringify(tinyFlowData))"
|
:data="JSON.parse(JSON.stringify(tinyFlowData))"
|
||||||
:theme="isDark ? 'dark' : 'light'"
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
@@ -941,7 +972,12 @@ function onAsyncExecute(info: any) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
<ElSkeleton v-if="!showTinyFlow" class="load-div" :rows="5" animated />
|
<ElSkeleton
|
||||||
|
v-if="!showTinyFlow && !initializationError"
|
||||||
|
class="load-div"
|
||||||
|
:rows="5"
|
||||||
|
animated
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { $t } from '#/locales';
|
|||||||
import { router } from '#/router';
|
import { router } from '#/router';
|
||||||
import { useDictStore } from '#/store';
|
import { useDictStore } from '#/store';
|
||||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||||
|
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||||
import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact';
|
import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact';
|
||||||
@@ -862,15 +863,14 @@ async function shareWorkflow(row: any) {
|
|||||||
if (res.errorCode !== 0 || !res.data?.shareKey) {
|
if (res.errorCode !== 0 || !res.data?.shareKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const routeLocation = router.resolve({
|
const shareUrl = buildAbsoluteAppRouteUrl(
|
||||||
name: 'WorkflowDesign',
|
router.resolve({
|
||||||
query: {
|
name: 'WorkflowDesign',
|
||||||
shareKey: res.data.shareKey,
|
query: {
|
||||||
},
|
shareKey: res.data.shareKey,
|
||||||
});
|
},
|
||||||
const shareUrl =
|
}).href,
|
||||||
res.data.shareUrl ||
|
);
|
||||||
new URL(routeLocation.href, window.location.origin).toString();
|
|
||||||
await copyTextWithFeedback(
|
await copyTextWithFeedback(
|
||||||
shareUrl,
|
shareUrl,
|
||||||
$t('message.copySuccess'),
|
$t('message.copySuccess'),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
isWorkflowShareRequest,
|
||||||
readWorkflowShareKey,
|
readWorkflowShareKey,
|
||||||
resolveWorkflowShareWorkflowId,
|
resolveWorkflowShareWorkflowId,
|
||||||
withWorkflowShareHeader,
|
withWorkflowShareHeader,
|
||||||
@@ -28,7 +29,12 @@ describe('workflow share context', () => {
|
|||||||
expect(
|
expect(
|
||||||
withWorkflowShareHeader(
|
withWorkflowShareHeader(
|
||||||
{ 'Accept-Language': 'zh-CN' },
|
{ '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({
|
).toEqual({
|
||||||
'Accept-Language': 'zh-CN',
|
'Accept-Language': 'zh-CN',
|
||||||
@@ -40,13 +46,92 @@ describe('workflow share context', () => {
|
|||||||
const headers = { 'Accept-Language': 'zh-CN' };
|
const headers = { 'Accept-Language': 'zh-CN' };
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
withWorkflowShareHeader(
|
withWorkflowShareHeader(headers, {
|
||||||
headers,
|
pageUrl: 'https://example.test/ai/workflow/design?id=1',
|
||||||
'https://example.test/ai/workflow/design?id=1',
|
requestMethod: 'GET',
|
||||||
),
|
requestUrl: '/api/v1/workflow/detail?id=1',
|
||||||
|
}),
|
||||||
).toEqual(headers);
|
).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 () => {
|
it('resolves the workflow id for a shared URL', async () => {
|
||||||
const resolve = vi.fn().mockResolvedValue('workflow-1');
|
const resolve = vi.fn().mockResolvedValue('workflow-1');
|
||||||
const onFailure = vi.fn();
|
const onFailure = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user