feat(flow): 新增分组执行与异步模式支持
refactor(executors): 将 Rhai 引擎评估逻辑迁移至 script_rhai 模块 docs: 添加 Flow 架构文档与示例 JSON feat(i18n): 新增前端多语言支持 perf(axios): 优化 token 刷新与 401 处理逻辑 style: 统一代码格式化与简化条件判断
This commit is contained in:
@ -7,7 +7,7 @@ import { FC, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
import { useService, I18n } from '@flowgram.ai/free-layout-editor';
|
||||
import { Button, SideSheet, Switch, Tag } from '@douyinfe/semi-ui';
|
||||
import { Button, SideSheet, Switch, Tag, Select, InputNumber } from '@douyinfe/semi-ui';
|
||||
import { IconClose, IconPlay, IconSpin } from '@douyinfe/semi-icons';
|
||||
|
||||
import { TestRunJsonInput } from '../testrun-json-input';
|
||||
@ -15,6 +15,7 @@ import { TestRunForm } from '../testrun-form';
|
||||
import { NodeStatusGroup } from '../node-status-bar/group';
|
||||
// 改为使用后端运行服务
|
||||
import { CustomService } from '../../../services';
|
||||
import { tr } from '../../../../utils/i18n';
|
||||
import { SidebarContext } from '../../../context';
|
||||
import { IconCancel } from '../../../assets/icon-cancel';
|
||||
|
||||
@ -76,6 +77,17 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
localStorage.setItem('testrun-input-json-mode', JSON.stringify(checked));
|
||||
};
|
||||
|
||||
// 执行模式与并发上限(仅保存到设计 JSON,不直接随运行请求发送)
|
||||
const [executionMode, setExecutionMode] = useState<'sync'|'async'|'queued'|'bounded'>(() => {
|
||||
const saved = localStorage.getItem('testrun-execution-mode');
|
||||
return (saved === 'async' || saved === 'queued' || saved === 'bounded') ? (saved as any) : 'sync';
|
||||
});
|
||||
const [concurrencyLimit, setConcurrencyLimit] = useState<number | undefined>(() => {
|
||||
const saved = localStorage.getItem('testrun-concurrency-limit');
|
||||
const num = saved ? Number(saved) : NaN;
|
||||
return Number.isFinite(num) && num > 0 ? num : undefined;
|
||||
});
|
||||
|
||||
const extractErrorMsg = (logs: string[] | undefined): string | undefined => {
|
||||
if (!logs || logs.length === 0) return undefined;
|
||||
const patterns = [/failed/i, /error/i, /panic/i];
|
||||
@ -97,9 +109,9 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
setRunning(true);
|
||||
try {
|
||||
// 运行前保存(静默),确保后端 YAML 与编辑器一致;若保存失败则不继续运行
|
||||
const saved = await customService.save({ silent: true });
|
||||
const saved = await customService.save({ silent: true, executionMode, concurrencyLimit });
|
||||
if (!saved) {
|
||||
setErrors([I18n.t('Save failed, cannot run')]);
|
||||
setErrors([tr('Save failed, cannot run')]);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -127,7 +139,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
}
|
||||
},
|
||||
onError: (evt) => {
|
||||
const msg = evt.message || I18n.t('Run failed');
|
||||
const msg = evt.message || tr('Run failed');
|
||||
setErrors((prev) => [...(prev || []), msg]);
|
||||
},
|
||||
onDone: (evt) => {
|
||||
@ -144,7 +156,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
if (evt.logs && evt.logs.length) setStreamLogs((prev: string[]) => [...prev, ...evt.logs!]);
|
||||
},
|
||||
onError: (evt) => {
|
||||
const msg = evt.message || I18n.t('Run failed');
|
||||
const msg = evt.message || tr('Run failed');
|
||||
setErrors((prev) => [...(prev || []), msg]);
|
||||
},
|
||||
onDone: (evt) => {
|
||||
@ -165,7 +177,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
setResult(finished as any);
|
||||
} else {
|
||||
// 流结束但未收到 done 事件,给出提示
|
||||
setErrors((prev) => [...(prev || []), I18n.t('Stream terminated without completion')]);
|
||||
setErrors((prev) => [...(prev || []), tr('Stream terminated without completion')]);
|
||||
}
|
||||
} else {
|
||||
// 普通 HTTP 一次性运行
|
||||
@ -174,20 +186,20 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
if (runRes) {
|
||||
if ((runRes as any).ok === false) {
|
||||
setResult(runRes as any);
|
||||
const err = extractErrorMsg((runRes as any).logs) || I18n.t('Run failed');
|
||||
const err = extractErrorMsg((runRes as any).logs) || tr('Run failed');
|
||||
setErrors([err]);
|
||||
} else {
|
||||
setResult(runRes as any);
|
||||
}
|
||||
} else {
|
||||
setErrors([I18n.t('Run failed')]);
|
||||
setErrors([tr('Run failed')]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErrors([e?.message || I18n.t('Run failed')]);
|
||||
setErrors([e?.message || tr('Run failed')]);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErrors([e?.message || I18n.t('Run failed')]);
|
||||
setErrors([e?.message || tr('Run failed')]);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
cancelRef.current = null;
|
||||
@ -226,7 +238,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
const renderRunning = (
|
||||
<div className={styles['testrun-panel-running']}>
|
||||
<IconSpin spin size="large" />
|
||||
<div className={styles.text}>{I18n.t('Running...')}</div>
|
||||
<div className={styles.text}>{tr('Running...')}</div>
|
||||
{/* 实时输出(仅流式模式显示) */}
|
||||
{streamMode && (
|
||||
<>
|
||||
@ -246,9 +258,9 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
|
||||
const renderStatus = (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{result?.ok === true && <Tag color="green">{I18n.t('Success')}</Tag>}
|
||||
{result?.ok === true && <Tag color="green">{tr('Success')}</Tag>}
|
||||
{(errors?.length || result?.ok === false) && (
|
||||
<Tag color="red">{I18n.t('Failed')}</Tag>
|
||||
<Tag color="red">{tr('Failed')}</Tag>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -256,9 +268,9 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
const renderForm = (
|
||||
<div className={styles['testrun-panel-form']}>
|
||||
<div className={styles['testrun-panel-input']}>
|
||||
<div className={styles.title}>{I18n.t('Input Form')}</div>
|
||||
<div className={styles.title}>{tr('Input Form')}</div>
|
||||
<div className={styles.toggle}>
|
||||
<div>{I18n.t('JSON Mode')}</div>
|
||||
<div>{tr('JSON Mode')}</div>
|
||||
<Switch
|
||||
checked={inputJSONMode}
|
||||
onChange={(checked: boolean) => setInputJSONMode(checked)}
|
||||
@ -266,16 +278,44 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.toggle}>
|
||||
<div>{I18n.t('Streaming Mode')}</div>
|
||||
<div>{tr('Streaming Mode')}</div>
|
||||
<Switch
|
||||
checked={streamMode}
|
||||
onChange={(checked: boolean) => setStreamMode(checked)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.toggle}>
|
||||
<div>{tr('Execution Mode')}</div>
|
||||
<Select
|
||||
value={executionMode}
|
||||
onChange={(v) => { const val = String(v) as any; setExecutionMode(val); localStorage.setItem('testrun-execution-mode', val); }}
|
||||
size="small"
|
||||
style={{ width: 160 }}
|
||||
renderSelectedItem={(option: any) => option?.label ?? tr(String(option?.value || ''))}
|
||||
>
|
||||
<Select.Option value="sync">{tr('sync')}</Select.Option>
|
||||
<Select.Option value="async">{tr('async')}</Select.Option>
|
||||
<Select.Option value="queued">{tr('queued')}</Select.Option>
|
||||
<Select.Option value="bounded">{tr('bounded')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
{executionMode === 'bounded' && (
|
||||
<div className={styles.toggle}>
|
||||
<div>{tr('Concurrency Limit')}</div>
|
||||
<InputNumber
|
||||
value={typeof concurrencyLimit === 'number' ? concurrencyLimit : undefined}
|
||||
onChange={(v) => { const num = Number(v); const next = Number.isFinite(num) && num > 0 ? num : undefined; setConcurrencyLimit(next); localStorage.setItem('testrun-concurrency-limit', String(next ?? '')); }}
|
||||
size="small"
|
||||
min={1}
|
||||
max={128}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{streamMode && (
|
||||
<div className={styles.toggle}>
|
||||
<div>WS</div>
|
||||
<div>{tr('WS')}</div>
|
||||
<Switch
|
||||
checked={useWS}
|
||||
onChange={(checked: boolean) => setUseWS(checked)}
|
||||
@ -298,16 +338,16 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
{/* 运行中(流式)时,直接在表单区域下方展示实时输出,而不是覆盖整块内容 */}
|
||||
{streamMode && isRunning && (
|
||||
<>
|
||||
<NodeStatusGroup title={I18n.t('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse />
|
||||
<NodeStatusGroup title={I18n.t('Context') + ' (Live)'} data={streamCtx} optional disableCollapse />
|
||||
<NodeStatusGroup title={tr('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse />
|
||||
<NodeStatusGroup title={tr('Context') + ' (Live)'} data={streamCtx} optional disableCollapse />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 展示后端返回的执行信息:仅在非流式或流式已结束时显示,避免与实时输出重复 */}
|
||||
{(!streamMode || !isRunning) && (
|
||||
<>
|
||||
<NodeStatusGroup title={I18n.t('Logs')} data={result?.logs} optional disableCollapse />
|
||||
<NodeStatusGroup title={I18n.t('Context')} data={result?.ctx} optional disableCollapse />
|
||||
<NodeStatusGroup title={tr('Logs')} data={result?.logs} optional disableCollapse />
|
||||
<NodeStatusGroup title={tr('Context')} data={result?.ctx} optional disableCollapse />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -326,13 +366,13 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
[styles.default]: !isRunning,
|
||||
})}
|
||||
>
|
||||
{isRunning ? I18n.t('Running...') : I18n.t('Test Run')}
|
||||
{isRunning ? tr('Running...') : tr('Test Run')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
title={I18n.t('Test Run')}
|
||||
title={tr('Test Run')}
|
||||
visible={visible}
|
||||
mask={false}
|
||||
motion={false}
|
||||
@ -351,7 +391,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
>
|
||||
<div className={styles['testrun-panel-container']}>
|
||||
<div className={styles['testrun-panel-header']}>
|
||||
<div className={styles['testrun-panel-title']}>{I18n.t('Test Run')}</div>
|
||||
<div className={styles['testrun-panel-title']}>{tr('Test Run')}</div>
|
||||
<Button
|
||||
className={styles['testrun-panel-title']}
|
||||
type="tertiary"
|
||||
|
||||
@ -8,6 +8,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useRefresh } from '@flowgram.ai/free-layout-editor';
|
||||
import { useClientContext } from '@flowgram.ai/free-layout-editor';
|
||||
import { Tooltip, IconButton, Divider } from '@douyinfe/semi-ui';
|
||||
import { tr } from '../../../utils/i18n';
|
||||
import { IconUndo, IconRedo, IconChevronLeft, IconEdit } from '@douyinfe/semi-icons';
|
||||
|
||||
import { TestRunButton } from '../testrun/testrun-button';
|
||||
@ -172,7 +173,7 @@ export const FlowTools = () => {
|
||||
{flowCode ? <span className="code">{flowCode}</span> : null}
|
||||
{flowName ? <span className="name" title={flowName}>{flowName}</span> : null}
|
||||
<span className="actions">
|
||||
<Tooltip content={I18n.t('Edit Base Info')}>
|
||||
<Tooltip content={tr('Edit Base Info')}>
|
||||
<IconButton type="tertiary" theme="borderless" icon={<IconEdit />} onClick={openBaseInfo} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
@ -182,7 +183,7 @@ export const FlowTools = () => {
|
||||
<ToolContainer className="flow-tools">
|
||||
<ToolSection>
|
||||
{/* 返回列表 */}
|
||||
<Tooltip content={I18n.t('Back to List')}>
|
||||
<Tooltip content={tr('Back to List')}>
|
||||
<IconButton
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
@ -200,7 +201,7 @@ export const FlowTools = () => {
|
||||
<Minimap visible={minimapVisible} />
|
||||
<Readonly />
|
||||
<Comment />
|
||||
<Tooltip content={I18n.t('Undo')}>
|
||||
<Tooltip content={tr('Undo')}>
|
||||
<IconButton
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
@ -209,7 +210,7 @@ export const FlowTools = () => {
|
||||
onClick={() => history.undo()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={I18n.t('Redo')}>
|
||||
<Tooltip content={tr('Redo')}>
|
||||
<IconButton
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
@ -230,7 +231,7 @@ export const FlowTools = () => {
|
||||
|
||||
{/* 基础信息弹窗(对齐新建流程表单) */}
|
||||
<AModal
|
||||
title={`${I18n.t('Edit Base Info')}${baseForm.getFieldValue('name') ? ' - ' + baseForm.getFieldValue('name') : ''}`}
|
||||
title={`${tr('Edit Base Info')}${baseForm.getFieldValue('name') ? ' - ' + baseForm.getFieldValue('name') : ''}`}
|
||||
open={baseOpen}
|
||||
onOk={handleBaseOk}
|
||||
confirmLoading={baseLoading}
|
||||
@ -240,14 +241,14 @@ export const FlowTools = () => {
|
||||
destroyOnHidden
|
||||
>
|
||||
<AForm form={baseForm} layout="vertical" preserve={false}>
|
||||
<AForm.Item name="name" label={I18n.t('Flow Name')} rules={[{ required: true, message: I18n.t('Please input flow name') }, { max: 50, message: I18n.t('Max 50 characters') }]}>
|
||||
<AInput placeholder={I18n.t('Please input flow name')} allowClear />
|
||||
<AForm.Item name="name" label={tr('Flow Name')} rules={[{ required: true, message: tr('Please input flow name') }, { max: 50, message: tr('Max 50 characters') }]}>
|
||||
<AInput placeholder={tr('Please input flow name')} allowClear />
|
||||
</AForm.Item>
|
||||
<AForm.Item name="code" label={I18n.t('Flow Code')} rules={[{ required: true, message: I18n.t('Please input flow code') }, { max: 50, message: I18n.t('Max 50 characters') }]}>
|
||||
<AInput placeholder={I18n.t('Required, recommend letters/numbers/-/_')} allowClear />
|
||||
<AForm.Item name="code" label={tr('Flow Code')} rules={[{ required: true, message: tr('Please input flow code') }, { max: 50, message: tr('Max 50 characters') }]}>
|
||||
<AInput placeholder={tr('Required, recommend letters/numbers/-/_')} allowClear />
|
||||
</AForm.Item>
|
||||
<AForm.Item name="remark" label={I18n.t('Remark')} rules={[{ max: 255, message: I18n.t('Max 255 characters') }]}>
|
||||
<AInput.TextArea rows={3} placeholder={I18n.t('Optional, remark info')} allowClear />
|
||||
<AForm.Item name="remark" label={tr('Remark')} rules={[{ max: 255, message: tr('Max 255 characters') }]}>
|
||||
<AInput.TextArea rows={3} placeholder={tr('Optional, remark info')} allowClear />
|
||||
</AForm.Item>
|
||||
</AForm>
|
||||
</AModal>
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
WorkflowDocument,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { Toast } from '@douyinfe/semi-ui';
|
||||
import { tr } from '../../utils/i18n';
|
||||
import { I18n } from '@flowgram.ai/free-layout-editor';
|
||||
import api, { type ApiResp } from '../../utils/axios';
|
||||
import { stringifyFlowDoc } from '../utils/yaml';
|
||||
@ -73,22 +74,29 @@ export class CustomService {
|
||||
@inject(WorkflowDocument) document!: WorkflowDocument;
|
||||
|
||||
// 新增可选参数,用于静默保存时不弹出 Toast,并返回是否保存成功
|
||||
async save(opts?: { silent?: boolean }): Promise<boolean> {
|
||||
async save(opts?: { silent?: boolean; executionMode?: 'sync'|'async'|'queued'|'bounded'; concurrencyLimit?: number }): Promise<boolean> {
|
||||
const silent = !!opts?.silent;
|
||||
try {
|
||||
const id = getFlowIdFromUrl();
|
||||
if (!id) {
|
||||
if (!silent) Toast.error(I18n.t('Flow ID is missing, cannot save'));
|
||||
if (!silent) Toast.error(tr('Flow ID is missing, cannot save'));
|
||||
return false;
|
||||
}
|
||||
const json = this.document.toJSON() as any;
|
||||
// 在根级写入执行配置(与后端契约保持一致:executionMode/concurrencyLimit)
|
||||
if (opts?.executionMode) {
|
||||
try { json.executionMode = opts.executionMode; } catch {}
|
||||
}
|
||||
if (typeof opts?.concurrencyLimit === 'number') {
|
||||
try { json.concurrencyLimit = opts.concurrencyLimit; } catch {}
|
||||
}
|
||||
const yaml = stringifyFlowDoc(json);
|
||||
// 使用转换后的 design_json,以便后端根据语言选择正确的执行器
|
||||
const designForBackend = transformDesignJsonForBackend(json);
|
||||
const design_json = JSON.stringify(designForBackend);
|
||||
const { data } = await api.put<ApiResp<{ saved: boolean }>>(`/flows/${id}`, { yaml, design_json });
|
||||
if (data?.code === 0) {
|
||||
if (!silent) Toast.success(I18n.t('Saved'));
|
||||
if (!silent) Toast.success(tr('Saved'));
|
||||
try {
|
||||
const key = (() => {
|
||||
const hash = window.location.hash || '';
|
||||
@ -101,12 +109,12 @@ export class CustomService {
|
||||
} catch {}
|
||||
return true;
|
||||
} else {
|
||||
const msg = data?.message || I18n.t('Save failed');
|
||||
const msg = data?.message || tr('Save failed');
|
||||
if (!silent) Toast.error(msg);
|
||||
return false;
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || I18n.t('Save failed');
|
||||
const msg = e?.message || tr('Save failed');
|
||||
if (!silent) Toast.error(msg);
|
||||
return false;
|
||||
}
|
||||
@ -116,16 +124,16 @@ export class CustomService {
|
||||
try {
|
||||
const id = getFlowIdFromUrl();
|
||||
if (!id) {
|
||||
Toast.error(I18n.t('Flow ID is missing, cannot run'));
|
||||
Toast.error(tr('Flow ID is missing, cannot run'));
|
||||
return null;
|
||||
}
|
||||
const { data } = await api.post<ApiResp<RunResult>>(`/flows/${id}/run`, { input });
|
||||
if (data?.code === 0) {
|
||||
return data.data;
|
||||
}
|
||||
throw new Error(data?.message || I18n.t('Run failed'));
|
||||
throw new Error(data?.message || tr('Run failed'));
|
||||
} catch (e: any) {
|
||||
Toast.error(e?.message || I18n.t('Run failed'));
|
||||
Toast.error(e?.message || tr('Run failed'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -142,7 +150,7 @@ export class CustomService {
|
||||
) {
|
||||
const id = getFlowIdFromUrl();
|
||||
if (!id) {
|
||||
const err = new Error(I18n.t('Flow ID is missing, cannot run'));
|
||||
const err = new Error(tr('Flow ID is missing, cannot run'));
|
||||
handlers?.onFatal?.(err);
|
||||
return { cancel: () => {}, done: Promise.resolve<RunResult | null>(null) } as const;
|
||||
}
|
||||
|
||||
@ -14,6 +14,58 @@ const api: AxiosInstance = axios.create({ baseURL: configuredBase ? `${configure
|
||||
let isRefreshing = false
|
||||
let pendingQueue: { resolve: () => void; reject: (e: unknown) => void; config: RetryConfig }[] = []
|
||||
|
||||
function redirectToLogin(msg?: string): Promise<never> {
|
||||
clearToken()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(new Error(msg || '未登录或登录已过期'))
|
||||
}
|
||||
|
||||
async function tryRefreshAndRetry(original: RetryConfig, message?: string): Promise<AxiosResponse<any>> {
|
||||
// 已重试过,直接拒绝
|
||||
if (original._retry) {
|
||||
return Promise.reject(new Error(message || '未授权'))
|
||||
}
|
||||
original._retry = true
|
||||
|
||||
const hasToken = !!getToken()
|
||||
if (!hasToken) {
|
||||
return redirectToLogin(message)
|
||||
}
|
||||
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true
|
||||
try {
|
||||
const { data } = await api.get<ApiResp<{ access_token: string }>>('/auth/refresh')
|
||||
if (data?.code === 0) {
|
||||
const access = data.data?.access_token
|
||||
if (access) setToken(access)
|
||||
pendingQueue.forEach(p => p.resolve())
|
||||
pendingQueue = []
|
||||
return api(original)
|
||||
}
|
||||
// 刷新失败,走登录
|
||||
pendingQueue.forEach(p => p.reject(new Error(data?.message || 'refresh failed')))
|
||||
pendingQueue = []
|
||||
return redirectToLogin(data?.message)
|
||||
} catch (e: any) {
|
||||
pendingQueue.forEach(p => p.reject(e))
|
||||
pendingQueue = []
|
||||
clearToken()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(e)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pendingQueue.push({ resolve: () => resolve(), reject: (e: unknown) => reject(e as unknown), config: original })
|
||||
}).then(() => api(original))
|
||||
}
|
||||
|
||||
api.interceptors.request.use((config: RetryConfig) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
@ -30,7 +82,21 @@ api.interceptors.request.use((config: RetryConfig) => {
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r: AxiosResponse) => r,
|
||||
async (r: AxiosResponse<ApiResp<unknown>>) => {
|
||||
// 业务层 401(HTTP 200, body.code=401)统一处理为未授权
|
||||
const body = r?.data as ApiResp<unknown>
|
||||
if (body && typeof body.code === 'number' && body.code === 401) {
|
||||
const original = (r.config || {}) as RetryConfig
|
||||
const reqUrl = (original?.url || '').toString()
|
||||
// 登录接口返回 401:不做 refresh,不跳转
|
||||
if (reqUrl.includes('/auth/login')) {
|
||||
const msg = body?.message || '未登录或登录已过期'
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
return tryRefreshAndRetry(original, body?.message)
|
||||
}
|
||||
return r
|
||||
},
|
||||
async (error: AxiosError<ApiResp<unknown>>) => {
|
||||
const original = (error.config || {}) as RetryConfig
|
||||
const status = error.response?.status
|
||||
@ -52,12 +118,7 @@ api.interceptors.response.use(
|
||||
|
||||
const hasToken = !!getToken()
|
||||
if (!hasToken) {
|
||||
// 没有 token 的 401:如果不在登录页,则跳转到登录页;否则仅抛错以便界面提示
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
const msg = resp?.message || '未登录或登录已过期'
|
||||
return Promise.reject(new Error(msg))
|
||||
return redirectToLogin(resp?.message)
|
||||
}
|
||||
|
||||
// 有 token 的 401:尝试刷新
|
||||
@ -99,4 +160,4 @@ api.interceptors.response.use(
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
export default api
|
||||
|
||||
44
frontend/src/utils/i18n.ts
Normal file
44
frontend/src/utils/i18n.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export function tr(s: string): string {
|
||||
const lang = (localStorage.getItem('lang') === 'en') ? 'en' : 'zh';
|
||||
if (lang === 'en') return s;
|
||||
const map: Record<string, string> = {
|
||||
'Test Run': '测试运行',
|
||||
'Running...': '运行中...',
|
||||
'JSON Mode': 'JSON 模式',
|
||||
'Streaming Mode': '流式模式',
|
||||
'WS': 'WS',
|
||||
'Execution Mode': '执行模式',
|
||||
'Concurrency Limit': '并发上限',
|
||||
'Success': '成功',
|
||||
'Failed': '失败',
|
||||
'Context': '上下文',
|
||||
'Logs': '日志',
|
||||
'Input Form': '输入表单',
|
||||
'Back to List': '返回列表',
|
||||
'Edit Base Info': '编辑基础信息',
|
||||
'Please input flow name': '请输入流程名称',
|
||||
'Flow Name': '流程名称',
|
||||
'Flow Code': '流程编号',
|
||||
'Remark': '备注',
|
||||
'Max 50 characters': '最多50个字符',
|
||||
'Max 255 characters': '最多255个字符',
|
||||
'Required, recommend letters/numbers/-/_': '必填,建议使用字母/数字/-/_',
|
||||
'Optional, remark info': '可选,备注信息',
|
||||
'Save': '保存',
|
||||
'Undo': '撤销',
|
||||
'Redo': '重做',
|
||||
'Saved': '已保存',
|
||||
'Save failed': '保存失败',
|
||||
'Save failed, cannot run': '保存失败,无法运行',
|
||||
'Run failed': '运行失败',
|
||||
'Flow ID is missing, cannot save': '缺少流程 ID,无法保存',
|
||||
'Flow ID is missing, cannot run': '缺少流程 ID,无法运行',
|
||||
'Stream terminated without completion': '流结束但未完成',
|
||||
// Execution modes
|
||||
'sync': '同步',
|
||||
'async': '异步',
|
||||
'queued': '队列',
|
||||
'bounded': '限并发',
|
||||
};
|
||||
return map[s] ?? s;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user