refactor(backend): 重构 script_js 执行器实现 JavaScript 文件/内联脚本执行 feat(backend): 变量节点支持表达式/引用快捷语法输入 docs: 添加变量节点使用文档说明快捷语法功能 style(frontend): 调整测试面板样式和布局 fix(frontend): 修复测试面板打开时自动关闭节点编辑侧栏 build(backend): 添加 rquickjs 依赖用于 JavaScript 执行
214 lines
6.3 KiB
TypeScript
214 lines
6.3 KiB
TypeScript
/**
|
||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||
* SPDX-License-Identifier: MIT
|
||
*/
|
||
|
||
import { FC, useContext, useEffect, 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 { IconClose, IconPlay, IconSpin } from '@douyinfe/semi-icons';
|
||
|
||
import { TestRunJsonInput } from '../testrun-json-input';
|
||
import { TestRunForm } from '../testrun-form';
|
||
import { NodeStatusGroup } from '../node-status-bar/group';
|
||
// 改为使用后端运行服务
|
||
import { CustomService } from '../../../services';
|
||
import { SidebarContext } from '../../../context';
|
||
import { IconCancel } from '../../../assets/icon-cancel';
|
||
|
||
import styles from './index.module.less';
|
||
|
||
interface TestRunSidePanelProps {
|
||
visible: boolean;
|
||
onCancel: () => void;
|
||
}
|
||
|
||
export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel }) => {
|
||
const customService = useService(CustomService);
|
||
const { nodeId: sidebarNodeId, setNodeId } = useContext(SidebarContext);
|
||
|
||
const [isRunning, setRunning] = useState(false);
|
||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||
const [errors, setErrors] = useState<string[]>();
|
||
const [result, setResult] = useState<
|
||
| {
|
||
ok?: boolean;
|
||
ctx: any;
|
||
logs: string[];
|
||
}
|
||
| undefined
|
||
>();
|
||
|
||
// en - Use localStorage to persist the JSON mode state
|
||
const [inputJSONMode, _setInputJSONMode] = useState(() => {
|
||
const savedMode = localStorage.getItem('testrun-input-json-mode');
|
||
return savedMode ? JSON.parse(savedMode) : false;
|
||
});
|
||
|
||
const setInputJSONMode = (checked: boolean) => {
|
||
_setInputJSONMode(checked);
|
||
localStorage.setItem('testrun-input-json-mode', JSON.stringify(checked));
|
||
};
|
||
|
||
const extractErrorMsg = (logs: string[] | undefined): string | undefined => {
|
||
if (!logs || logs.length === 0) return undefined;
|
||
const patterns = [/failed/i, /error/i, /panic/i];
|
||
for (const line of logs) {
|
||
if (patterns.some((p) => p.test(line))) return line;
|
||
}
|
||
return logs[logs.length - 1];
|
||
};
|
||
|
||
const onTestRun = async () => {
|
||
if (isRunning) {
|
||
// 后端运行不可取消,这里直接忽略重复点击
|
||
return;
|
||
}
|
||
setResult(undefined);
|
||
setErrors(undefined);
|
||
setRunning(true);
|
||
try {
|
||
// 运行前保存(静默),确保后端 YAML 与编辑器一致;若保存失败则不继续运行
|
||
const saved = await customService.save({ silent: true });
|
||
if (!saved) {
|
||
setErrors([I18n.t('Save failed, cannot run')]);
|
||
return;
|
||
}
|
||
const runRes = await customService.run(values);
|
||
if (runRes) {
|
||
// 若后端返回 ok=false,则视为失败并展示失败信息与日志
|
||
if ((runRes as any).ok === false) {
|
||
setResult(runRes as any);
|
||
const err = extractErrorMsg((runRes as any).logs) || I18n.t('Run failed');
|
||
setErrors([err]);
|
||
} else {
|
||
setResult(runRes as any);
|
||
}
|
||
} else {
|
||
setErrors([I18n.t('Run failed')]);
|
||
}
|
||
} catch (e: any) {
|
||
setErrors([e?.message || I18n.t('Run failed')]);
|
||
} finally {
|
||
setRunning(false);
|
||
}
|
||
};
|
||
|
||
const onClose = async () => {
|
||
setValues({});
|
||
setRunning(false);
|
||
onCancel();
|
||
};
|
||
|
||
// 当测试运行面板打开时,自动关闭右侧节点编辑侧栏,避免两个 SideSheet 重叠
|
||
useEffect(() => {
|
||
if (visible) {
|
||
setNodeId(undefined);
|
||
}
|
||
}, [visible]);
|
||
|
||
useEffect(() => {
|
||
if (sidebarNodeId) {
|
||
onCancel();
|
||
}
|
||
}, [sidebarNodeId]);
|
||
|
||
const renderRunning = (
|
||
<div className={styles['testrun-panel-running']}>
|
||
<IconSpin spin size="large" />
|
||
<div className={styles.text}>{I18n.t('Running...')}</div>
|
||
</div>
|
||
);
|
||
|
||
const renderStatus = (
|
||
<div style={{ marginBottom: 8 }}>
|
||
{result?.ok === true && <Tag color="green">{I18n.t('Success')}</Tag>}
|
||
{(errors?.length || result?.ok === false) && (
|
||
<Tag color="red">{I18n.t('Failed')}</Tag>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderForm = (
|
||
<div className={styles['testrun-panel-form']}>
|
||
<div className={styles['testrun-panel-input']}>
|
||
<div className={styles.title}>{I18n.t('Input Form')}</div>
|
||
<div>{I18n.t('JSON Mode')}</div>
|
||
<Switch
|
||
checked={inputJSONMode}
|
||
onChange={(checked: boolean) => setInputJSONMode(checked)}
|
||
size="small"
|
||
/>
|
||
</div>
|
||
{renderStatus}
|
||
{errors?.map((e) => (
|
||
<div className={styles.error} key={e}>
|
||
{e}
|
||
</div>
|
||
))}
|
||
{inputJSONMode ? (
|
||
<TestRunJsonInput values={values} setValues={setValues} />
|
||
) : (
|
||
<TestRunForm values={values} setValues={setValues} />
|
||
)}
|
||
{/* 展示后端返回的执行信息 */}
|
||
<NodeStatusGroup title={I18n.t('Context')} data={result?.ctx} optional disableCollapse />
|
||
<NodeStatusGroup title={I18n.t('Logs')} data={result?.logs} optional disableCollapse />
|
||
</div>
|
||
);
|
||
|
||
const renderButton = (
|
||
<Button
|
||
onClick={onTestRun}
|
||
icon={isRunning ? <IconCancel /> : <IconPlay size="small" />}
|
||
className={classnames(styles.button, {
|
||
[styles.running]: isRunning,
|
||
[styles.default]: !isRunning,
|
||
})}
|
||
>
|
||
{isRunning ? I18n.t('Running...') : I18n.t('Test Run')}
|
||
</Button>
|
||
);
|
||
|
||
return (
|
||
<SideSheet
|
||
title={I18n.t('Test Run')}
|
||
visible={visible}
|
||
mask={false}
|
||
motion={false}
|
||
onCancel={onClose}
|
||
width={500}
|
||
headerStyle={{
|
||
display: 'none',
|
||
}}
|
||
bodyStyle={{
|
||
padding: 0,
|
||
}}
|
||
style={{
|
||
background: 'none',
|
||
boxShadow: 'none',
|
||
}}
|
||
>
|
||
<div className={styles['testrun-panel-container']}>
|
||
<div className={styles['testrun-panel-header']}>
|
||
<div className={styles['testrun-panel-title']}>{I18n.t('Test Run')}</div>
|
||
<Button
|
||
className={styles['testrun-panel-title']}
|
||
type="tertiary"
|
||
icon={<IconClose />}
|
||
size="small"
|
||
theme="borderless"
|
||
onClick={onClose}
|
||
/>
|
||
</div>
|
||
<div className={styles['testrun-panel-content']}>
|
||
{isRunning ? renderRunning : renderForm}
|
||
</div>
|
||
<div className={styles['testrun-panel-footer']}>{renderButton}</div>
|
||
</div>
|
||
</SideSheet>
|
||
);
|
||
};
|