- 新增 CLIPluginAdapter 统一接口 (backend/app/core/agent_adapter.py) - 新增 LLM 服务层,支持 Anthropic/OpenAI/DeepSeek/Ollama (backend/app/services/llm_service.py) - 新增 Agent 执行引擎,支持文件锁自动管理 (backend/app/services/agent_executor.py) - 新增 NativeLLMAgent 原生 LLM 适配器 (backend/app/adapters/native_llm_agent.py) - 新增进程管理器 (backend/app/services/process_manager.py) - 新增 Agent 控制 API (backend/app/routers/agents_control.py) - 新增 WebSocket 实时通信 (backend/app/routers/websocket.py) - 更新前端 AgentsPage,支持启动/停止 Agent - 测试通过:Agent 启动、批量操作、栅栏同步 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
735 lines
25 KiB
TypeScript
735 lines
25 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import {
|
||
Workflow,
|
||
CheckCircle,
|
||
FileText,
|
||
Upload,
|
||
ChevronRight,
|
||
RefreshCw,
|
||
Zap,
|
||
Users,
|
||
ArrowRight,
|
||
AlertTriangle,
|
||
} from 'lucide-react';
|
||
import { api } from '../lib/api';
|
||
import type { Workflow as WorkflowType, WorkflowMeeting } from '../types';
|
||
|
||
// 工作流详情面板
|
||
function WorkflowDetailPanel({
|
||
workflow,
|
||
onClose,
|
||
onReload,
|
||
}: {
|
||
workflow: WorkflowType;
|
||
onClose: () => void;
|
||
onReload: () => void;
|
||
}) {
|
||
const [loading, setLoading] = useState<string | null>(null);
|
||
const [executionStatus, setExecutionStatus] = useState<Record<string, any>>({});
|
||
|
||
// 刷新执行节点状态
|
||
const refreshExecutionStatus = async (meetingId: string) => {
|
||
if (workflow.meetings.find(m => m.meeting_id === meetingId)?.node_type === 'execution') {
|
||
try {
|
||
const status = await api.workflow.getExecutionStatus(workflow.workflow_id, meetingId);
|
||
setExecutionStatus(prev => ({ ...prev, [meetingId]: status }));
|
||
} catch (err) {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
// 刷新所有执行节点的状态
|
||
workflow.meetings.forEach(m => {
|
||
if (m.node_type === 'execution') {
|
||
refreshExecutionStatus(m.meeting_id);
|
||
}
|
||
});
|
||
}, [workflow]);
|
||
|
||
const handleCompleteMeeting = async (meetingId: string) => {
|
||
setLoading(meetingId);
|
||
try {
|
||
await api.workflow.complete(workflow.workflow_id, meetingId);
|
||
onReload();
|
||
} catch (err) {
|
||
alert('标记完成失败');
|
||
} finally {
|
||
setLoading(null);
|
||
}
|
||
};
|
||
|
||
const handleFailAndJump = async (meetingId: string) => {
|
||
const meeting = workflow.meetings.find(m => m.meeting_id === meetingId);
|
||
if (!meeting?.on_failure) {
|
||
alert('此节点未配置失败跳转目标');
|
||
return;
|
||
}
|
||
if (!confirm(`确定要跳转到 "${meeting.on_failure}" 吗?`)) {
|
||
return;
|
||
}
|
||
setLoading(`fail-${meetingId}`);
|
||
try {
|
||
const result = await api.workflow.handleFailure(workflow.workflow_id, meetingId);
|
||
if (result.target) {
|
||
onReload();
|
||
} else {
|
||
alert('未配置失败跳转');
|
||
}
|
||
} catch (err) {
|
||
alert('操作失败');
|
||
} finally {
|
||
setLoading(null);
|
||
}
|
||
};
|
||
|
||
const getMeetingStatus = (meeting: WorkflowMeeting) => {
|
||
if (meeting.completed) return 'completed';
|
||
const allPrevCompleted = workflow.meetings
|
||
.slice(0, workflow.meetings.findIndex((m) => m.meeting_id === meeting.meeting_id))
|
||
.every((m) => m.completed);
|
||
return allPrevCompleted ? 'active' : 'pending';
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: 480,
|
||
background: 'rgba(17, 24, 39, 0.98)',
|
||
borderLeft: '1px solid rgba(0, 240, 255, 0.1)',
|
||
padding: 24,
|
||
zIndex: 100,
|
||
overflow: 'auto',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
||
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#fff', margin: 0 }}>工作流详情</h2>
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
color: 'rgba(255, 255, 255, 0.5)',
|
||
fontSize: 24,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Workflow Info */}
|
||
<div
|
||
style={{
|
||
padding: 20,
|
||
background: 'rgba(0, 0, 0, 0.3)',
|
||
borderRadius: 12,
|
||
marginBottom: 20,
|
||
}}
|
||
>
|
||
<h3 style={{ fontSize: 18, fontWeight: 600, color: '#fff', margin: '0 0 8px 0' }}>
|
||
{workflow.name}
|
||
</h3>
|
||
<p style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.4)', margin: 0 }}>
|
||
{workflow.workflow_id}
|
||
</p>
|
||
<p style={{ fontSize: 14, color: 'rgba(255, 255, 255, 0.6)', margin: '12px 0 0 0' }}>
|
||
{workflow.description}
|
||
</p>
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||
<span
|
||
style={{
|
||
padding: '4px 12px',
|
||
borderRadius: 12,
|
||
background:
|
||
workflow.status === 'completed'
|
||
? '#00ff9d20'
|
||
: workflow.status === 'in_progress'
|
||
? '#00f0ff20'
|
||
: '#ff950020',
|
||
color:
|
||
workflow.status === 'completed'
|
||
? '#00ff9d'
|
||
: workflow.status === 'in_progress'
|
||
? '#00f0ff'
|
||
: '#ff9500',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{workflow.status === 'completed' ? '已完成' : workflow.status === 'in_progress' ? '进行中' : '等待中'}
|
||
</span>
|
||
<span
|
||
style={{
|
||
padding: '4px 12px',
|
||
borderRadius: 12,
|
||
background: 'rgba(139, 92, 246, 0.2)',
|
||
color: '#8b5cf6',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
进度: {workflow.progress}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Progress Bar */}
|
||
<div style={{ marginBottom: 20 }}>
|
||
<div
|
||
style={{
|
||
height: 8,
|
||
background: 'rgba(255, 255, 255, 0.1)',
|
||
borderRadius: 4,
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: workflow.progress,
|
||
height: '100%',
|
||
background: 'linear-gradient(90deg, #00f0ff, #8b5cf6)',
|
||
borderRadius: 4,
|
||
transition: 'width 0.5s ease',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Meetings */}
|
||
<div>
|
||
<h4 style={{ fontSize: 14, fontWeight: 600, color: 'rgba(255, 255, 255, 0.7)', margin: '0 0 12px 0' }}>
|
||
流程节点 ({workflow.meetings.length})
|
||
</h4>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{workflow.meetings.map((meeting, index) => {
|
||
const status = getMeetingStatus(meeting);
|
||
const isExecution = meeting.node_type === 'execution';
|
||
const execStatus = executionStatus[meeting.meeting_id];
|
||
|
||
return (
|
||
<div
|
||
key={meeting.meeting_id}
|
||
style={{
|
||
padding: 16,
|
||
background:
|
||
status === 'completed'
|
||
? 'rgba(0, 255, 157, 0.1)'
|
||
: status === 'active'
|
||
? 'rgba(0, 240, 255, 0.1)'
|
||
: 'rgba(0, 0, 0, 0.2)',
|
||
borderRadius: 10,
|
||
border:
|
||
status === 'active'
|
||
? '1px solid rgba(0, 240, 255, 0.3)'
|
||
: '1px solid transparent',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||
{/* 节点图标 */}
|
||
<div
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: '50%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background:
|
||
status === 'completed'
|
||
? '#00ff9d'
|
||
: status === 'active'
|
||
? isExecution ? '#ff9500' : '#00f0ff'
|
||
: 'rgba(255, 255, 255, 0.1)',
|
||
color: status === 'pending' ? 'rgba(255, 255, 255, 0.4)' : '#000',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{status === 'completed' ? <CheckCircle size={14} /> : isExecution ? <Zap size={14} /> : index + 1}
|
||
</div>
|
||
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{/* 标题和类型 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<p style={{ fontSize: 14, fontWeight: 500, color: '#fff', margin: 0 }}>
|
||
{meeting.title}
|
||
</p>
|
||
{/* 节点类型标签 */}
|
||
<span
|
||
style={{
|
||
padding: '2px 8px',
|
||
borderRadius: 4,
|
||
background: isExecution ? '#ff950020' : '#00f0ff20',
|
||
color: isExecution ? '#ff9500' : '#00f0ff',
|
||
fontSize: 10,
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{isExecution ? '执行' : '会议'}
|
||
</span>
|
||
{/* 失败跳转标签 */}
|
||
{meeting.on_failure && (
|
||
<span
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
padding: '2px 8px',
|
||
borderRadius: 4,
|
||
background: '#ff006e20',
|
||
color: '#ff006e',
|
||
fontSize: 10,
|
||
}}
|
||
>
|
||
<AlertTriangle size={10} />
|
||
失败 → {meeting.on_failure}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* ID 和参会者 */}
|
||
<p style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.4)', margin: '4px 0 0 0' }}>
|
||
{meeting.meeting_id}
|
||
</p>
|
||
|
||
{/* 参会者列表 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
|
||
<Users size={12} color="rgba(255, 255, 255, 0.4)" />
|
||
{meeting.attendees.map((attendee, i) => (
|
||
<span
|
||
key={i}
|
||
style={{
|
||
padding: '2px 6px',
|
||
borderRadius: 4,
|
||
background: 'rgba(255, 255, 255, 0.1)',
|
||
color: 'rgba(255, 255, 255, 0.6)',
|
||
fontSize: 10,
|
||
}}
|
||
>
|
||
{attendee}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{/* 执行节点进度 */}
|
||
{isExecution && execStatus && (
|
||
<div style={{ marginTop: 8 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||
<span style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.5)' }}>
|
||
进度: {execStatus.progress}
|
||
</span>
|
||
<span style={{ fontSize: 11, color: execStatus.is_ready ? '#00ff9d' : '#ff9500' }}>
|
||
{execStatus.is_ready ? '就绪' : '进行中'}
|
||
</span>
|
||
</div>
|
||
{execStatus.missing && execStatus.missing.length > 0 && (
|
||
<div style={{ fontSize: 10, color: 'rgba(255, 255, 255, 0.4)' }}>
|
||
等待中: {execStatus.missing.join(', ')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 依赖关系 */}
|
||
{meeting.depends_on && meeting.depends_on.length > 0 && (
|
||
<div style={{ marginTop: 6, fontSize: 10, color: 'rgba(255, 255, 255, 0.3)' }}>
|
||
依赖: {meeting.depends_on.join(', ')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end' }}>
|
||
{status === 'active' && !meeting.completed && (
|
||
<button
|
||
onClick={() => handleCompleteMeeting(meeting.meeting_id)}
|
||
disabled={loading === meeting.meeting_id}
|
||
style={{
|
||
padding: '6px 12px',
|
||
background: '#00ff9d',
|
||
border: 'none',
|
||
borderRadius: 6,
|
||
color: '#000',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
cursor: loading === meeting.meeting_id ? 'not-allowed' : 'pointer',
|
||
opacity: loading === meeting.meeting_id ? 0.6 : 1,
|
||
}}
|
||
>
|
||
{loading === meeting.meeting_id ? '...' : '完成'}
|
||
</button>
|
||
)}
|
||
{meeting.completed && meeting.on_failure && (
|
||
<button
|
||
onClick={() => handleFailAndJump(meeting.meeting_id)}
|
||
disabled={loading === `fail-${meeting.meeting_id}`}
|
||
style={{
|
||
padding: '4px 10px',
|
||
background: '#ff006e20',
|
||
border: '1px solid #ff006e50',
|
||
borderRadius: 6,
|
||
color: '#ff006e',
|
||
fontSize: 11,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
触发失败跳转
|
||
</button>
|
||
)}
|
||
{meeting.completed && (
|
||
<span
|
||
style={{
|
||
padding: '4px 10px',
|
||
background: '#00ff9d20',
|
||
borderRadius: 12,
|
||
color: '#00ff9d',
|
||
fontSize: 11,
|
||
}}
|
||
>
|
||
已完成
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 节点间的连接线 */}
|
||
{index < workflow.meetings.length - 1 && (
|
||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 8 }}>
|
||
<ArrowRight size={16} color="rgba(255, 255, 255, 0.2)" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function WorkflowPage() {
|
||
const [workflows, setWorkflows] = useState<WorkflowType[]>([]);
|
||
const [selectedWorkflow, setSelectedWorkflow] = useState<WorkflowType | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [starting, setStarting] = useState<string | null>(null);
|
||
|
||
const loadWorkflows = async () => {
|
||
try {
|
||
setLoading(true);
|
||
// 加载已加载的工作流列表
|
||
const listRes = await api.workflow.list();
|
||
const loadedWorkflows: WorkflowType[] = [];
|
||
|
||
// 从已加载的工作流中获取详情
|
||
for (const wf of listRes.workflows) {
|
||
try {
|
||
const detail = await api.workflow.get(wf.workflow_id);
|
||
loadedWorkflows.push(detail);
|
||
} catch {
|
||
// 忽略加载失败的
|
||
}
|
||
}
|
||
|
||
setWorkflows(loadedWorkflows);
|
||
setError(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '加载失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const startDefaultWorkflow = async () => {
|
||
setStarting('default-dev-flow');
|
||
try {
|
||
const workflow = await api.workflow.start('default-dev-flow.yaml');
|
||
setWorkflows(prev => [...prev.filter(w => w.workflow_id !== workflow.workflow_id), workflow]);
|
||
setSelectedWorkflow(workflow);
|
||
} catch (err) {
|
||
alert('启动默认工作流失败: ' + (err instanceof Error ? err.message : '未知错误'));
|
||
} finally {
|
||
setStarting(null);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadWorkflows();
|
||
const interval = setInterval(loadWorkflows, 30000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
if (!file.name.endsWith('.yaml') && !file.name.endsWith('.yml')) {
|
||
alert('请上传 YAML 文件');
|
||
return;
|
||
}
|
||
|
||
setUploading(true);
|
||
try {
|
||
await file.text();
|
||
// 这里应该调用后端 API 上传文件
|
||
// 暂时模拟成功
|
||
alert(`文件 ${file.name} 上传成功`);
|
||
loadWorkflows();
|
||
} catch (err) {
|
||
alert('上传失败');
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const getStatusColor = (status: string) => {
|
||
switch (status) {
|
||
case 'completed':
|
||
return '#00ff9d';
|
||
case 'in_progress':
|
||
return '#00f0ff';
|
||
default:
|
||
return '#ff9500';
|
||
}
|
||
};
|
||
|
||
if (loading && workflows.length === 0) {
|
||
return (
|
||
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
|
||
<div style={{ color: '#00f0ff' }}>加载中...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{/* Header */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||
<div>
|
||
<h1 style={{ fontSize: 28, fontWeight: 700, color: '#fff', margin: 0 }}>工作流</h1>
|
||
<p style={{ fontSize: 14, color: 'rgba(255, 255, 255, 0.5)', margin: '8px 0 0 0' }}>
|
||
管理和执行工作流 - 包含会议节点和执行节点
|
||
</p>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 12 }}>
|
||
<button
|
||
onClick={loadWorkflows}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: '10px 16px',
|
||
background: 'rgba(255, 255, 255, 0.05)',
|
||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||
borderRadius: 8,
|
||
color: 'rgba(255, 255, 255, 0.7)',
|
||
fontSize: 14,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
<RefreshCw size={16} />
|
||
刷新
|
||
</button>
|
||
<button
|
||
onClick={startDefaultWorkflow}
|
||
disabled={starting === 'default-dev-flow'}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: '10px 16px',
|
||
background: 'rgba(0, 240, 255, 0.1)',
|
||
border: '1px solid rgba(0, 240, 255, 0.3)',
|
||
borderRadius: 8,
|
||
color: '#00f0ff',
|
||
fontSize: 14,
|
||
cursor: starting === 'default-dev-flow' ? 'not-allowed' : 'pointer',
|
||
opacity: starting === 'default-dev-flow' ? 0.6 : 1,
|
||
}}
|
||
>
|
||
<Zap size={16} />
|
||
{starting === 'default-dev-flow' ? '启动中...' : '启动默认工作流'}
|
||
</button>
|
||
<label
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: '10px 16px',
|
||
background: 'linear-gradient(135deg, #00f0ff 0%, #8b5cf6 100%)',
|
||
border: 'none',
|
||
borderRadius: 8,
|
||
color: '#000',
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
cursor: uploading ? 'not-allowed' : 'pointer',
|
||
opacity: uploading ? 0.6 : 1,
|
||
}}
|
||
>
|
||
<Upload size={18} />
|
||
{uploading ? '上传中...' : '上传工作流'}
|
||
<input type="file" accept=".yaml,.yml" onChange={handleFileUpload} disabled={uploading} style={{ display: 'none' }} />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div
|
||
style={{
|
||
padding: '12px 16px',
|
||
background: '#ff006e20',
|
||
border: '1px solid #ff006e50',
|
||
borderRadius: 8,
|
||
color: '#ff006e',
|
||
marginBottom: 24,
|
||
}}
|
||
>
|
||
连接后端失败: {error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Workflows Grid */}
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||
gap: 16,
|
||
}}
|
||
>
|
||
{workflows.map((workflow) => (
|
||
<div
|
||
key={workflow.workflow_id}
|
||
onClick={() => setSelectedWorkflow(workflow)}
|
||
style={{
|
||
padding: 20,
|
||
background: 'rgba(17, 24, 39, 0.7)',
|
||
borderRadius: 12,
|
||
border: '1px solid rgba(0, 240, 255, 0.1)',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.2s ease',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = 'rgba(0, 240, 255, 0.3)';
|
||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = 'rgba(0, 240, 255, 0.1)';
|
||
e.currentTarget.style.transform = 'translateY(0)';
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
||
<div
|
||
style={{
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 10,
|
||
background: `linear-gradient(135deg, ${getStatusColor(workflow.status)}40 0%, ${getStatusColor(workflow.status)}10 100%)`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<Workflow size={24} color={getStatusColor(workflow.status)} />
|
||
</div>
|
||
<span
|
||
style={{
|
||
padding: '4px 10px',
|
||
borderRadius: 12,
|
||
background: `${getStatusColor(workflow.status)}20`,
|
||
color: getStatusColor(workflow.status),
|
||
fontSize: 11,
|
||
}}
|
||
>
|
||
{workflow.status === 'completed' ? '已完成' : workflow.status === 'in_progress' ? '进行中' : '等待中'}
|
||
</span>
|
||
</div>
|
||
|
||
<h3 style={{ fontSize: 16, fontWeight: 600, color: '#fff', margin: '0 0 4px 0' }}>
|
||
{workflow.name}
|
||
</h3>
|
||
<p style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.4)', margin: '0 0 12px 0' }}>
|
||
{workflow.workflow_id}
|
||
</p>
|
||
|
||
<p
|
||
style={{
|
||
fontSize: 13,
|
||
color: 'rgba(255, 255, 255, 0.6)',
|
||
margin: '0 0 16px 0',
|
||
lineHeight: 1.5,
|
||
display: '-webkit-box',
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: 'vertical',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{workflow.description}
|
||
</p>
|
||
|
||
{/* Progress */}
|
||
<div style={{ marginBottom: 12 }}>
|
||
<div
|
||
style={{
|
||
height: 6,
|
||
background: 'rgba(255, 255, 255, 0.1)',
|
||
borderRadius: 3,
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: workflow.progress,
|
||
height: '100%',
|
||
background: 'linear-gradient(90deg, #00f0ff, #8b5cf6)',
|
||
borderRadius: 3,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<FileText size={14} color="rgba(255, 255, 255, 0.4)" />
|
||
<span style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.5)' }}>
|
||
{workflow.meetings.length} 个节点
|
||
</span>
|
||
{/* 统计节点类型 */}
|
||
<span style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.3)' }}>
|
||
({workflow.meetings.filter(m => m.node_type === 'execution').length} 执行 + {workflow.meetings.filter(m => m.node_type === 'meeting').length} 会议)
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<span style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.5)' }}>查看详情</span>
|
||
<ChevronRight size={14} color="rgba(255, 255, 255, 0.4)" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{workflows.length === 0 && !loading && (
|
||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||
<Workflow size={48} color="rgba(255, 255, 255, 0.2)" style={{ marginBottom: 16 }} />
|
||
<p style={{ color: 'rgba(255, 255, 255, 0.5)', margin: 0 }}>暂无工作流</p>
|
||
<p style={{ color: 'rgba(255, 255, 255, 0.3)', fontSize: 14 }}>上传 YAML 文件创建</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Detail Panel */}
|
||
{selectedWorkflow && (
|
||
<WorkflowDetailPanel
|
||
workflow={selectedWorkflow}
|
||
onClose={() => setSelectedWorkflow(null)}
|
||
onReload={loadWorkflows}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|