Files
udmin/frontend/src/flows/components/testrun/testrun-panel/index.tsx
ayou 296f0ae9f6 feat(backend): 新增 QuickJS 运行时支持 JavaScript 执行器
refactor(backend): 重构 script_js 执行器实现 JavaScript 文件/内联脚本执行
feat(backend): 变量节点支持表达式/引用快捷语法输入
docs: 添加变量节点使用文档说明快捷语法功能
style(frontend): 调整测试面板样式和布局
fix(frontend): 修复测试面板打开时自动关闭节点编辑侧栏
build(backend): 添加 rquickjs 依赖用于 JavaScript 执行
2025-09-20 17:35:36 +08:00

214 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
};