776 lines
27 KiB
TypeScript
776 lines
27 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
ApartmentOutlined,
|
||
ClockCircleOutlined,
|
||
PlayCircleOutlined,
|
||
PlusOutlined,
|
||
ThunderboltOutlined
|
||
} from '@ant-design/icons';
|
||
import {
|
||
Button,
|
||
Card,
|
||
Drawer,
|
||
Empty,
|
||
Form,
|
||
Input,
|
||
Modal,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
Typography,
|
||
App as AntApp,
|
||
Popconfirm,
|
||
Tabs
|
||
} from 'antd';
|
||
import {
|
||
Workflow,
|
||
WorkflowAPI,
|
||
WorkflowGraph,
|
||
WorkflowNode,
|
||
WorkflowNodeType,
|
||
WorkflowRun,
|
||
WorkflowRunDetail,
|
||
streamWorkflowRun
|
||
} from '../api';
|
||
|
||
const { Text, Paragraph } = Typography;
|
||
|
||
const NODE_TYPE_LABEL: Record<WorkflowNodeType, string> = {
|
||
agent: '🤖 Agent',
|
||
skill: '🛠️ Skill',
|
||
http: '🌐 HTTP',
|
||
transform: '🔧 Transform',
|
||
branch: '🔀 Branch'
|
||
};
|
||
|
||
const STATUS_COLOR: Record<string, string> = {
|
||
running: 'blue',
|
||
success: 'green',
|
||
failed: 'red',
|
||
aborted: 'orange',
|
||
skipped: 'default'
|
||
};
|
||
|
||
const SAMPLE_GRAPH: WorkflowGraph = {
|
||
entry: 'fetch',
|
||
variables: { topic: 'Go 1.23 新特性' },
|
||
nodes: [
|
||
{
|
||
id: 'fetch',
|
||
type: 'agent',
|
||
name: '搜集资料',
|
||
config: {
|
||
agentId: '',
|
||
prompt: '请围绕主题"{{vars.topic}}"列出 5 条最近的关键信息,要点形式。'
|
||
},
|
||
next: 'summarize'
|
||
},
|
||
{
|
||
id: 'summarize',
|
||
type: 'agent',
|
||
name: '总结成稿',
|
||
config: {
|
||
agentId: '',
|
||
prompt: '基于以下要点写一段 200 字日报:\n{{steps.fetch.output.text}}'
|
||
},
|
||
next: ''
|
||
}
|
||
]
|
||
};
|
||
|
||
export default function WorkflowsPage() {
|
||
const { message, modal } = AntApp.useApp();
|
||
const [list, setList] = useState<Workflow[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [editorOpen, setEditorOpen] = useState(false);
|
||
const [editing, setEditing] = useState<Workflow | null>(null);
|
||
const [runsOpen, setRunsOpen] = useState(false);
|
||
const [runsFor, setRunsFor] = useState<Workflow | null>(null);
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await WorkflowAPI.list();
|
||
setList(data);
|
||
} catch (e: any) {
|
||
message.error('加载失败:' + (e?.message ?? e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
|
||
const onCreate = () => {
|
||
setEditing({
|
||
id: '',
|
||
name: '新工作流',
|
||
description: '',
|
||
graph: SAMPLE_GRAPH,
|
||
scheduleCron: '',
|
||
scheduleEnabled: false,
|
||
enabled: true,
|
||
lastRunAt: 0,
|
||
runCount: 0,
|
||
createdAt: 0,
|
||
updatedAt: 0
|
||
});
|
||
setEditorOpen(true);
|
||
};
|
||
|
||
const onEdit = (w: Workflow) => {
|
||
setEditing(w);
|
||
setEditorOpen(true);
|
||
};
|
||
|
||
const onDelete = async (w: Workflow) => {
|
||
await WorkflowAPI.remove(w.id);
|
||
message.success('已删除');
|
||
load();
|
||
};
|
||
|
||
return (
|
||
<div className="page-container" style={{ maxWidth: 1400 }}>
|
||
<div
|
||
style={{
|
||
borderRadius: 24,
|
||
padding: '30px 30px 26px',
|
||
background:
|
||
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
|
||
border: '1px solid rgba(8, 145, 178, 0.12)',
|
||
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
|
||
marginBottom: 24
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
gap: 20,
|
||
flexWrap: 'wrap',
|
||
marginBottom: 20
|
||
}}
|
||
>
|
||
<div style={{ maxWidth: 680 }}>
|
||
<div
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: '6px 12px',
|
||
borderRadius: 999,
|
||
background: 'rgba(255,255,255,0.78)',
|
||
border: '1px solid rgba(8, 145, 178, 0.10)',
|
||
color: 'var(--color-text-secondary)',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
marginBottom: 16
|
||
}}
|
||
>
|
||
<ApartmentOutlined style={{ color: 'var(--color-brand)' }} />
|
||
自动化编排中心
|
||
</div>
|
||
<h1 className="page-title" style={{ marginBottom: 10 }}>工作流编排</h1>
|
||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
||
让多个 Agent、技能、HTTP 请求与数据转换连成一条可运行的自动化链路。这里更像一个流程画廊,而不是传统表格后台。
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="primary"
|
||
size="large"
|
||
icon={<PlusOutlined />}
|
||
onClick={onCreate}
|
||
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
|
||
>
|
||
新建工作流
|
||
</Button>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
|
||
{[
|
||
{ label: '工作流数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
|
||
{ label: '启用中', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
||
{ label: '定时运行', value: list.filter((item) => item.scheduleEnabled && item.scheduleCron).length, tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
|
||
].map((item) => (
|
||
<div
|
||
key={item.label}
|
||
style={{
|
||
borderRadius: 18,
|
||
padding: '16px 18px',
|
||
background: 'rgba(255,255,255,0.72)',
|
||
border: '1px solid rgba(255,255,255,0.7)'
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
|
||
<span
|
||
style={{
|
||
borderRadius: 999,
|
||
padding: '4px 8px',
|
||
background: item.tone,
|
||
color: item.color,
|
||
fontSize: 12,
|
||
fontWeight: 600
|
||
}}
|
||
>
|
||
实时状态
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? null : list.length === 0 ? (
|
||
<div
|
||
style={{
|
||
borderRadius: 22,
|
||
background: 'var(--color-surface)',
|
||
border: '1px solid var(--color-border)',
|
||
padding: '54px 24px'
|
||
}}
|
||
>
|
||
<Empty description="还没有工作流,点击上方开始搭建第一条自动化流程" />
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}>
|
||
{list.map((r) => (
|
||
<div
|
||
key={r.id}
|
||
style={{
|
||
borderRadius: 20,
|
||
border: '1px solid var(--color-border)',
|
||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
||
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
|
||
padding: 20,
|
||
minHeight: 308,
|
||
display: 'flex',
|
||
flexDirection: 'column'
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6 }}>{r.name}</div>
|
||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
|
||
{r.description || '还没有补充描述,可以说明这个流程负责什么自动化任务。'}
|
||
</div>
|
||
</div>
|
||
<Tag
|
||
bordered={false}
|
||
style={{
|
||
margin: 0,
|
||
borderRadius: 999,
|
||
background: r.enabled ? 'var(--color-success-soft)' : 'var(--color-surface-2)',
|
||
color: r.enabled ? 'var(--color-success)' : 'var(--color-text-secondary)',
|
||
height: 28,
|
||
lineHeight: '28px'
|
||
}}
|
||
>
|
||
{r.enabled ? '已启用' : '已停用'}
|
||
</Tag>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
|
||
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>节点数</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{r.graph?.nodes?.length ?? 0}</div>
|
||
</div>
|
||
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>运行次数</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{r.runCount}</div>
|
||
</div>
|
||
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(249, 115, 22, 0.06)', border: '1px solid rgba(249, 115, 22, 0.10)' }}>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>触发方式</div>
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>
|
||
{r.scheduleEnabled && r.scheduleCron ? '定时' : '手动'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
borderRadius: 16,
|
||
padding: '16px 16px 14px',
|
||
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
|
||
border: '1px solid rgba(148, 163, 184, 0.14)',
|
||
marginBottom: 16
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
|
||
<ClockCircleOutlined />
|
||
调度与运行
|
||
</div>
|
||
{r.scheduleEnabled && r.scheduleCron ? (
|
||
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999 }}>
|
||
cron: {r.scheduleCron}
|
||
</Tag>
|
||
) : (
|
||
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
|
||
手动触发
|
||
</Tag>
|
||
)}
|
||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 10 }}>
|
||
上次运行 {r.lastRunAt ? new Date(r.lastRunAt).toLocaleString() : '尚未运行'}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
|
||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onEdit(r)} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
|
||
编辑流程
|
||
</Button>
|
||
<Button icon={<PlayCircleOutlined />} onClick={() => { setRunsFor(r); setRunsOpen(true); }} style={{ borderRadius: 12, height: 40 }}>
|
||
运行 / 历史
|
||
</Button>
|
||
<Popconfirm title="删除?" onConfirm={() => onDelete(r)}>
|
||
<Button danger style={{ borderRadius: 12, height: 40 }}>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{editing && (
|
||
<WorkflowEditor
|
||
open={editorOpen}
|
||
workflow={editing}
|
||
onClose={() => setEditorOpen(false)}
|
||
onSaved={() => {
|
||
setEditorOpen(false);
|
||
load();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{runsFor && (
|
||
<RunsDrawer
|
||
open={runsOpen}
|
||
workflow={runsFor}
|
||
onClose={() => setRunsOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ================== 编辑器 ==================
|
||
|
||
function WorkflowEditor({
|
||
open, workflow, onClose, onSaved
|
||
}: {
|
||
open: boolean;
|
||
workflow: Workflow;
|
||
onClose: () => void;
|
||
onSaved: () => void;
|
||
}) {
|
||
const { message } = AntApp.useApp();
|
||
const [form] = Form.useForm();
|
||
const [graphText, setGraphText] = useState(JSON.stringify(workflow.graph, null, 2));
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
form.setFieldsValue({
|
||
name: workflow.name,
|
||
description: workflow.description,
|
||
scheduleCron: workflow.scheduleCron,
|
||
scheduleEnabled: workflow.scheduleEnabled,
|
||
enabled: workflow.enabled
|
||
});
|
||
setGraphText(JSON.stringify(workflow.graph, null, 2));
|
||
}, [workflow]);
|
||
|
||
const onSave = async () => {
|
||
const values = await form.validateFields();
|
||
let graph: WorkflowGraph;
|
||
try {
|
||
graph = JSON.parse(graphText);
|
||
} catch (e: any) {
|
||
message.error('Graph JSON 解析失败:' + e.message);
|
||
return;
|
||
}
|
||
if (!graph.entry || !Array.isArray(graph.nodes)) {
|
||
message.error('Graph 必须包含 entry + nodes 数组');
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
const payload = { ...values, graph };
|
||
if (workflow.id) {
|
||
await WorkflowAPI.update(workflow.id, payload);
|
||
} else {
|
||
await WorkflowAPI.create(payload);
|
||
}
|
||
message.success('已保存');
|
||
onSaved();
|
||
} catch (e: any) {
|
||
message.error('保存失败:' + (e?.message ?? e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Drawer
|
||
title={workflow.id ? `编辑「${workflow.name}」` : '新建工作流'}
|
||
width={840}
|
||
open={open}
|
||
onClose={onClose}
|
||
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
|
||
extra={
|
||
<Button type="primary" loading={saving} onClick={onSave} style={{ borderRadius: 10 }}>
|
||
保存
|
||
</Button>
|
||
}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item label="名称" name="name" rules={[{ required: true, message: '必填' }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item label="描述" name="description">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
<Space size="large" style={{ marginBottom: 12 }}>
|
||
<Form.Item name="enabled" valuePropName="checked" noStyle>
|
||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||
</Form.Item>
|
||
<Form.Item name="scheduleEnabled" valuePropName="checked" noStyle>
|
||
<Switch checkedChildren="定时开" unCheckedChildren="定时关" />
|
||
</Form.Item>
|
||
</Space>
|
||
<Form.Item
|
||
label={
|
||
<Space>
|
||
<span>Cron 表达式</span>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>5 段:分 时 日 月 周,例如 "*/30 * * * *"</Text>
|
||
</Space>
|
||
}
|
||
name="scheduleCron"
|
||
>
|
||
<Input placeholder="例如 0 8 * * 1-5(工作日早 8 点)" />
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<NodeQuickAdd
|
||
onAdd={(node) => {
|
||
try {
|
||
const g: WorkflowGraph = JSON.parse(graphText);
|
||
g.nodes = [...(g.nodes || []), node];
|
||
if (!g.entry) g.entry = node.id;
|
||
setGraphText(JSON.stringify(g, null, 2));
|
||
} catch {
|
||
message.error('Graph JSON 不合法,无法追加');
|
||
}
|
||
}}
|
||
/>
|
||
|
||
<Card
|
||
size="small"
|
||
title={<Text strong>Graph JSON(节点列表)</Text>}
|
||
style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}
|
||
extra={
|
||
<Tooltip title="还原为示例">
|
||
<Button size="small" onClick={() => setGraphText(JSON.stringify(SAMPLE_GRAPH, null, 2))} style={{ borderRadius: 8 }}>
|
||
示例
|
||
</Button>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
<Input.TextArea
|
||
rows={20}
|
||
value={graphText}
|
||
onChange={(e) => setGraphText(e.target.value)}
|
||
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
||
/>
|
||
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
|
||
支持模板 <code>{'{{input.x}} {{vars.x}} {{steps.<nodeId>.output...}}'}</code>。<br />
|
||
节点类型:agent / skill / http / transform / branch;branch 节点用 <code>condition</code>(返回 bool 的 JS 片段),引擎按 next/elseNext 走。
|
||
</Paragraph>
|
||
</Card>
|
||
</Drawer>
|
||
);
|
||
}
|
||
|
||
function NodeQuickAdd({ onAdd }: { onAdd: (n: WorkflowNode) => void }) {
|
||
const [type, setType] = useState<WorkflowNodeType>('agent');
|
||
const [id, setId] = useState('');
|
||
return (
|
||
<Card size="small" title="快速追加节点" style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}>
|
||
<Space wrap>
|
||
<Select
|
||
value={type}
|
||
style={{ width: 140 }}
|
||
onChange={(v) => setType(v as WorkflowNodeType)}
|
||
options={Object.entries(NODE_TYPE_LABEL).map(([k, label]) => ({
|
||
value: k, label
|
||
}))}
|
||
/>
|
||
<Input
|
||
placeholder="节点 id(如 step1)"
|
||
value={id}
|
||
onChange={(e) => setId(e.target.value)}
|
||
style={{ width: 200 }}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
disabled={!id.trim()}
|
||
style={{ borderRadius: 10 }}
|
||
onClick={() => {
|
||
const base: WorkflowNode = {
|
||
id: id.trim(),
|
||
type,
|
||
name: NODE_TYPE_LABEL[type],
|
||
config: defaultConfig(type),
|
||
next: ''
|
||
};
|
||
if (type === 'branch') base.elseNext = '';
|
||
onAdd(base);
|
||
setId('');
|
||
}}
|
||
>
|
||
追加
|
||
</Button>
|
||
</Space>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function defaultConfig(type: WorkflowNodeType): Record<string, any> {
|
||
switch (type) {
|
||
case 'agent':
|
||
return { agentId: '', prompt: '请帮我处理:{{input.text}}' };
|
||
case 'skill':
|
||
return { skillId: '', args: {} };
|
||
case 'http':
|
||
return { method: 'GET', url: 'https://example.com/api', headers: {}, body: null };
|
||
case 'transform':
|
||
return { code: 'return { value: input.text };' };
|
||
case 'branch':
|
||
return { condition: 'steps.prev?.output?.value === true' };
|
||
}
|
||
}
|
||
|
||
// ================== 运行抽屉 ==================
|
||
|
||
function RunsDrawer({
|
||
open, workflow, onClose
|
||
}: {
|
||
open: boolean;
|
||
workflow: Workflow;
|
||
onClose: () => void;
|
||
}) {
|
||
const { message } = AntApp.useApp();
|
||
const [tab, setTab] = useState('run');
|
||
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
||
const [detail, setDetail] = useState<WorkflowRunDetail | null>(null);
|
||
const [streaming, setStreaming] = useState(false);
|
||
const [steps, setSteps] = useState<any[]>([]);
|
||
const [finalRun, setFinalRun] = useState<any>(null);
|
||
const [inputJson, setInputJson] = useState('{}');
|
||
|
||
const loadRuns = async () => {
|
||
try {
|
||
const data = await WorkflowAPI.listRuns(workflow.id, 30);
|
||
setRuns(data);
|
||
} catch (e: any) {
|
||
message.error('加载历史失败:' + (e?.message ?? e));
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (open) loadRuns();
|
||
}, [open]);
|
||
|
||
const onRunStream = () => {
|
||
let input: any = undefined;
|
||
if (inputJson.trim()) {
|
||
try {
|
||
input = JSON.parse(inputJson);
|
||
} catch (e: any) {
|
||
message.error('input JSON 不合法');
|
||
return;
|
||
}
|
||
}
|
||
setSteps([]);
|
||
setFinalRun(null);
|
||
setStreaming(true);
|
||
streamWorkflowRun(workflow.id, input, {
|
||
onStepStart: (d) => {
|
||
setSteps((s) => [...s, { ...d, status: 'running' }]);
|
||
},
|
||
onStepFinish: (d) => {
|
||
setSteps((s) => {
|
||
const idx = [...s].reverse().findIndex((x) => x.nodeId === d.nodeId && x.status === 'running');
|
||
if (idx === -1) return [...s, d];
|
||
const realIdx = s.length - 1 - idx;
|
||
const cp = [...s];
|
||
cp[realIdx] = d;
|
||
return cp;
|
||
});
|
||
},
|
||
onRunFinish: (d) => {
|
||
setStreaming(false);
|
||
setFinalRun(d);
|
||
loadRuns();
|
||
},
|
||
onError: (msg) => {
|
||
setStreaming(false);
|
||
message.error(msg);
|
||
}
|
||
});
|
||
};
|
||
|
||
const showDetail = async (runId: string) => {
|
||
try {
|
||
const d = await WorkflowAPI.getRun(runId);
|
||
setDetail(d);
|
||
} catch (e: any) {
|
||
message.error('加载详情失败:' + (e?.message ?? e));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Drawer
|
||
title={`运行:${workflow.name}`}
|
||
width={760}
|
||
open={open}
|
||
onClose={() => { setDetail(null); onClose(); }}
|
||
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
|
||
>
|
||
<Tabs
|
||
activeKey={tab}
|
||
onChange={setTab}
|
||
items={[
|
||
{
|
||
key: 'run',
|
||
label: '触发',
|
||
children: (
|
||
<div>
|
||
<Form layout="vertical">
|
||
<Form.Item label="Input(JSON,会作为 input.* 注入)">
|
||
<Input.TextArea
|
||
rows={4}
|
||
value={inputJson}
|
||
onChange={(e) => setInputJson(e.target.value)}
|
||
placeholder='{"text":"hello"}'
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
<Space style={{ marginBottom: 12 }}>
|
||
<Button type="primary" loading={streaming} onClick={onRunStream} style={{ borderRadius: 10 }}>
|
||
▶ 流式执行
|
||
</Button>
|
||
<Button
|
||
style={{ borderRadius: 10 }}
|
||
onClick={async () => {
|
||
try {
|
||
const r = await WorkflowAPI.run(workflow.id, JSON.parse(inputJson || '{}'));
|
||
message.success(`已触发 run=${r.runId}`);
|
||
loadRuns();
|
||
} catch (e: any) {
|
||
message.error('触发失败:' + (e?.message ?? e));
|
||
}
|
||
}}
|
||
>
|
||
同步执行(不看进度)
|
||
</Button>
|
||
</Space>
|
||
|
||
{steps.length > 0 && (
|
||
<Card size="small" title="实时进度" style={{ marginBottom: 12, borderRadius: 16 }}>
|
||
{steps.map((s, i) => (
|
||
<div key={i} style={{ marginBottom: 8 }}>
|
||
<Space>
|
||
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
||
<Text strong>{s.nodeId}</Text>
|
||
<Text type="secondary">{s.nodeType}</Text>
|
||
{s.durationMs != null && <Text type="secondary">{s.durationMs}ms</Text>}
|
||
</Space>
|
||
{s.error && <Paragraph type="danger" style={{ marginBottom: 0 }}>{s.error}</Paragraph>}
|
||
{s.output != null && (
|
||
<pre style={{ background: '#f5f5f5', padding: 6, borderRadius: 4, fontSize: 12, marginTop: 4, maxHeight: 160, overflow: 'auto' }}>
|
||
{JSON.stringify(s.output, null, 2)}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
))}
|
||
</Card>
|
||
)}
|
||
|
||
{finalRun && (
|
||
<Card size="small" title="最终结果" style={{ borderRadius: 16 }}>
|
||
<pre style={{ fontSize: 12, margin: 0 }}>
|
||
{JSON.stringify(finalRun, null, 2)}
|
||
</pre>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)
|
||
},
|
||
{
|
||
key: 'history',
|
||
label: `历史 (${runs.length})`,
|
||
children: (
|
||
<div>
|
||
<Button size="small" onClick={loadRuns} style={{ marginBottom: 8, borderRadius: 8 }}>刷新</Button>
|
||
<Table<WorkflowRun>
|
||
rowKey="id"
|
||
size="small"
|
||
pagination={false}
|
||
dataSource={runs}
|
||
columns={[
|
||
{ title: '状态', dataIndex: 'status', render: (s) => <Tag color={STATUS_COLOR[s] || 'default'}>{s}</Tag>, width: 100 },
|
||
{ title: '触发', dataIndex: 'trigger', width: 80 },
|
||
{ title: '开始', dataIndex: 'startedAt', render: (v) => new Date(v).toLocaleString(), width: 180 },
|
||
{ title: '耗时', dataIndex: 'durationMs', render: (v) => `${v} ms`, width: 100 },
|
||
{ title: '', render: (_, r) => <Button size="small" onClick={() => showDetail(r.id)}>详情</Button> }
|
||
]}
|
||
/>
|
||
{detail && (
|
||
<Modal
|
||
title={`Run ${detail.id}`}
|
||
open
|
||
onCancel={() => setDetail(null)}
|
||
onOk={() => setDetail(null)}
|
||
width={680}
|
||
>
|
||
<p><b>状态:</b><Tag color={STATUS_COLOR[detail.status] || 'default'}>{detail.status}</Tag>{detail.error && <Text type="danger" style={{ marginLeft: 8 }}>{detail.error}</Text>}</p>
|
||
<p><b>耗时:</b>{detail.durationMs} ms</p>
|
||
<p><b>Input:</b></p>
|
||
<pre style={{ background: '#f5f5f5', padding: 8, fontSize: 12 }}>{JSON.stringify(detail.input, null, 2)}</pre>
|
||
<p><b>Steps:</b></p>
|
||
{detail.steps.map((s) => (
|
||
<Card size="small" key={s.id} style={{ marginBottom: 8, borderRadius: 14 }}>
|
||
<Space>
|
||
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
||
<Text strong>{s.nodeId}</Text>
|
||
<Text type="secondary">{s.nodeType}</Text>
|
||
<Text type="secondary">{s.durationMs} ms</Text>
|
||
</Space>
|
||
{s.error && <p style={{ color: 'red', marginTop: 4 }}>{s.error}</p>}
|
||
{s.output != null && (
|
||
<pre style={{ fontSize: 12, marginTop: 4, maxHeight: 200, overflow: 'auto' }}>
|
||
{JSON.stringify(s.output, null, 2)}
|
||
</pre>
|
||
)}
|
||
</Card>
|
||
))}
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
</Drawer>
|
||
);
|
||
}
|