auto-sync: 2026-05-17 12:12:27
This commit is contained in:
@@ -1,522 +1,380 @@
|
||||
/**
|
||||
* EdictBoard v2.0 — 任务看板
|
||||
* 数据模型:v2.6 黑板(扁平任务,无 DAG)
|
||||
* Mock 数据用于 UI 预览
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useStore, isEdict, isArchived, getPipeStatus, stateLabel, deptColor, PIPE, STATE_LABEL } from '../store';
|
||||
import ArtifactList from './ArtifactList';
|
||||
import { useStore } from '../store';
|
||||
|
||||
/** Format ISO timestamp to relative duration string */
|
||||
function formatDuration(iso: string): string {
|
||||
try {
|
||||
const then = new Date(iso).getTime();
|
||||
const now = Date.now();
|
||||
const diffMs = now - then;
|
||||
if (diffMs < 0) return '刚刚';
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return `${mins}分钟`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
if (hrs < 24) return `${hrs}小时${remainMins ? remainMins + '分' : ''}`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}天${hrs % 24 ? (hrs % 24) + '小时' : ''}`;
|
||||
} catch { return ''; }
|
||||
// ── v2.0 Task 类型 ──
|
||||
export interface V2Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'pending' | 'claimed' | 'working' | 'done' | 'failed';
|
||||
assignee: string | null;
|
||||
assigned_by: string | null;
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
task_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
claimed_at: string | null;
|
||||
completed_at: string | null;
|
||||
started_at: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
risk_level: 'low' | 'standard' | 'high' | 'critical';
|
||||
escalated: boolean;
|
||||
deadline: string | null;
|
||||
// 关联数据(mock)
|
||||
comments_count: number;
|
||||
outputs_count: number;
|
||||
review_status: 'none' | 'pending' | 'approved' | 'rejected' | 'rebuttal';
|
||||
latest_event: string | null;
|
||||
project_id: string;
|
||||
}
|
||||
import { api, type Task } from '../api';
|
||||
|
||||
// 排序权重
|
||||
const STATE_ORDER: Record<string, number> = {
|
||||
created: 0, planning: 1, challenging: 2, assigned: 3,
|
||||
executing: 4, paused: 4.5, reviewing: 5,
|
||||
completed: 8, failed: 7, cancelled: 9, escalated: 6,
|
||||
// legacy
|
||||
Doing: 4, Review: 5, Assigned: 3, Menxia: 1, Zhongshu: 2,
|
||||
Taizi: 0, Inbox: 0, Blocked: 6, Next: 0, Done: 8, Cancelled: 9,
|
||||
// ── 状态管线 ──
|
||||
const PIPELINE = [
|
||||
{ key: 'pending', label: '待认领', icon: '📋' },
|
||||
{ key: 'claimed', label: '已认领', icon: '👤' },
|
||||
{ key: 'working', label: '执行中', icon: '⚔️' },
|
||||
{ key: 'done', label: '已完成', icon: '✅' },
|
||||
] as const;
|
||||
|
||||
const STATUS_META: Record<string, { color: string; bg: string; label: string }> = {
|
||||
pending: { color: '#7a9aff', bg: '#0a1028', label: '待认领' },
|
||||
claimed: { color: '#a07aff', bg: '#110a28', label: '已认领' },
|
||||
working: { color: '#6a9eff', bg: '#0a1530', label: '执行中' },
|
||||
done: { color: '#2ecc8a', bg: '#0a2018', label: '已完成' },
|
||||
failed: { color: '#ff5270', bg: '#200a10', label: '失败' },
|
||||
};
|
||||
|
||||
// 状态筛选定义
|
||||
const STATUS_FILTERS = [
|
||||
{ key: 'all', label: '全部', icon: '📋' },
|
||||
{ key: 'planning', label: '待拆解', icon: '🐦' },
|
||||
{ key: 'challenging', label: '审核中', icon: '🦅' },
|
||||
{ key: 'executing', label: '执行中', icon: '⚔️' },
|
||||
{ key: 'completed', label: '已完成', icon: '🏆' },
|
||||
{ key: 'failed', label: '失败', icon: '❌' },
|
||||
{ key: 'paused', label: '暂停', icon: '⏸️' },
|
||||
{ key: 'cancelled', label: '已取消', icon: '🚫' },
|
||||
const PRIORITY_META: Record<string, { color: string; label: string }> = {
|
||||
low: { color: '#6b7280', label: '低' },
|
||||
medium: { color: '#3b82f6', label: '中' },
|
||||
high: { color: '#f59e0b', label: '高' },
|
||||
critical: { color: '#ff5270', label: '紧急' },
|
||||
};
|
||||
|
||||
const RISK_META: Record<string, { color: string; label: string }> = {
|
||||
low: { color: '#2ecc8a', label: '低风险' },
|
||||
standard: { color: '#6b7280', label: '标准' },
|
||||
high: { color: '#f59e0b', label: '高风险' },
|
||||
critical: { color: '#ff5270', label: '极高风险' },
|
||||
};
|
||||
|
||||
const REVIEW_META: Record<string, { color: string; label: string; icon: string }> = {
|
||||
none: { color: '#6b7280', label: '无审查', icon: '' },
|
||||
pending: { color: '#f59e0b', label: '审查中', icon: '🔍' },
|
||||
approved: { color: '#2ecc8a', label: '已通过', icon: '✅' },
|
||||
rejected: { color: '#ff5270', label: '已驳回', icon: '❌' },
|
||||
rebuttal: { color: '#818cf8', label: '反驳中', icon: '⚔️' },
|
||||
};
|
||||
|
||||
const AGENT_EMOJI: Record<string, string> = {
|
||||
'pangtong-fujunshi': '🐦', 'simayi-challenger': '🦅', 'jiangwei-infra': '🔧',
|
||||
'guanyu-dev': '⚔️', 'zhangfei-dev': '💪', 'zhaoyun-data': '📊',
|
||||
};
|
||||
|
||||
// ── Mock 数据 ──
|
||||
const MOCK_TASKS: V2Task[] = [
|
||||
{
|
||||
id: 'task-001', title: '动量因子策略回测', description: '基于成交量的日线动量策略回测,持仓周期3-10天波段交易',
|
||||
status: 'working', assignee: 'zhangfei-dev', assigned_by: 'pangtong-fujunshi',
|
||||
priority: 'high', task_type: 'backtest', created_at: '2026-05-17T08:30:00', updated_at: '2026-05-17T11:20:00',
|
||||
claimed_at: '2026-05-17T08:35:00', completed_at: null, started_at: '2026-05-17T08:36:00',
|
||||
retry_count: 0, max_retries: 3, risk_level: 'standard', escalated: false,
|
||||
deadline: '2026-05-17T18:00:00', comments_count: 3, outputs_count: 1,
|
||||
review_status: 'pending', latest_event: '回测脚本运行中,已完成70%数据回测', project_id: 'demo',
|
||||
},
|
||||
{
|
||||
id: 'task-002', title: '分钟线数据质量检查', description: '检查沪深300成分股1分钟线数据完整性(2024-01至今)',
|
||||
status: 'done', assignee: 'zhaoyun-data', assigned_by: 'pangtong-fujunshi',
|
||||
priority: 'medium', task_type: 'data_verify', created_at: '2026-05-17T07:00:00', updated_at: '2026-05-17T09:15:00',
|
||||
claimed_at: '2026-05-17T07:05:00', completed_at: '2026-05-17T09:15:00', started_at: '2026-05-17T07:06:00',
|
||||
retry_count: 0, max_retries: 2, risk_level: 'low', escalated: false,
|
||||
deadline: null, comments_count: 5, outputs_count: 2,
|
||||
review_status: 'approved', latest_event: '数据质量报告已通过审查', project_id: 'demo',
|
||||
},
|
||||
{
|
||||
id: 'task-003', title: 'vnpy网关接口升级', description: '将CTP网关从6.3.19升级到6.7.0,适配新版API',
|
||||
status: 'claimed', assignee: 'jiangwei-infra', assigned_by: 'pangtong-fujunshi',
|
||||
priority: 'critical', task_type: 'infra', created_at: '2026-05-17T06:00:00', updated_at: '2026-05-17T10:00:00',
|
||||
claimed_at: '2026-05-17T10:00:00', completed_at: null, started_at: null,
|
||||
retry_count: 0, max_retries: 2, risk_level: 'critical', escalated: false,
|
||||
deadline: '2026-05-18T18:00:00', comments_count: 2, outputs_count: 0,
|
||||
review_status: 'none', latest_event: '姜维已认领,准备开始', project_id: 'demo',
|
||||
},
|
||||
{
|
||||
id: 'task-004', title: '风控规则代码审查', description: '审查止损/仓位/滑点检查模块代码质量',
|
||||
status: 'pending', assignee: null, assigned_by: 'pangtong-fujunshi',
|
||||
priority: 'medium', task_type: 'code_review', created_at: '2026-05-17T09:00:00', updated_at: '2026-05-17T09:00:00',
|
||||
claimed_at: null, completed_at: null, started_at: null,
|
||||
retry_count: 0, max_retries: 2, risk_level: 'standard', escalated: false,
|
||||
deadline: null, comments_count: 0, outputs_count: 0,
|
||||
review_status: 'none', latest_event: null, project_id: 'demo',
|
||||
},
|
||||
{
|
||||
id: 'task-005', title: '策略参数优化实验', description: '使用网格搜索优化动量因子参数(lookback=5-30, threshold=0.01-0.05)',
|
||||
status: 'failed', assignee: 'zhangfei-dev', assigned_by: 'pangtong-fujunshi',
|
||||
priority: 'high', task_type: 'research', created_at: '2026-05-16T14:00:00', updated_at: '2026-05-17T03:00:00',
|
||||
claimed_at: '2026-05-16T14:10:00', completed_at: null, started_at: '2026-05-16T14:12:00',
|
||||
retry_count: 2, max_retries: 3, risk_level: 'high', escalated: true,
|
||||
deadline: null, comments_count: 8, outputs_count: 0,
|
||||
review_status: 'rebuttal', latest_event: '网格搜索内存溢出,第2次重试失败', project_id: 'demo',
|
||||
},
|
||||
{
|
||||
id: 'task-006', title: '滑点模型实现', description: '实现基于成交量加权的滑点模型,支持市价单和限价单',
|
||||
status: 'working', assignee: 'guanyu-dev', assigned_by: 'pangtong-fujunshi',
|
||||
priority: 'high', task_type: 'coding', created_at: '2026-05-17T07:30:00', updated_at: '2026-05-17T11:00:00',
|
||||
claimed_at: '2026-05-17T07:45:00', completed_at: null, started_at: '2026-05-17T07:46:00',
|
||||
retry_count: 0, max_retries: 2, risk_level: 'standard', escalated: false,
|
||||
deadline: '2026-05-17T20:00:00', comments_count: 1, outputs_count: 0,
|
||||
review_status: 'none', latest_event: '滑点基类已完成,正在实现成交量加权逻辑', project_id: 'demo',
|
||||
},
|
||||
];
|
||||
|
||||
// 状态到筛选key的映射
|
||||
function stateToFilterKey(state: string, task: Task): string {
|
||||
const meta = task.sourceMeta || {};
|
||||
// moziplus 用 status + plan_status,edict 用 state + sourceMeta
|
||||
const s = task.status || state; // 优先用 moziplus status
|
||||
const planStatus = task.plan_status || (meta.plan_status as string) || '';
|
||||
if (s === 'planning' && planStatus === 'challenging') return 'challenging';
|
||||
if (s === 'planning') return 'planning';
|
||||
if (s === 'executing') return 'executing';
|
||||
if (s === 'reviewing') return 'executing'; // reviewing is a sub-state of executing
|
||||
if (s === 'completed' || s === 'Done') return 'completed';
|
||||
if (s === 'failed') return 'failed';
|
||||
if (s === 'paused') return 'paused';
|
||||
if (s === 'cancelled') return 'cancelled';
|
||||
return s;
|
||||
// ── 工具函数 ──
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return `${mins}分钟前`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}小时前`;
|
||||
return `${Math.floor(hrs / 24)}天前`;
|
||||
}
|
||||
|
||||
function MiniPipe({ task }: { task: Task }) {
|
||||
const stages = getPipeStatus(task);
|
||||
function formatDeadline(iso: string): string {
|
||||
const diff = new Date(iso).getTime() - Date.now();
|
||||
if (diff < 0) return '已逾期';
|
||||
const hrs = Math.floor(diff / 3600000);
|
||||
if (hrs < 1) return `${Math.floor(diff / 60000)}分钟后`;
|
||||
if (hrs < 24) return `${hrs}小时后`;
|
||||
return `${Math.floor(hrs / 24)}天后`;
|
||||
}
|
||||
|
||||
// ── 状态管线组件 ──
|
||||
function StatusPipeline({ status }: { status: string }) {
|
||||
const currentIdx = PIPELINE.findIndex(p => p.key === status);
|
||||
const failed = status === 'failed';
|
||||
|
||||
return (
|
||||
<div className="ec-pipe">
|
||||
{stages.map((s, i) => (
|
||||
<span key={s.key} style={{ display: 'contents' }}>
|
||||
<div className={`ep-node ${s.status}`}>
|
||||
<div className="ep-icon">{s.icon}</div>
|
||||
<div className="ep-name">{s.dept}</div>
|
||||
</div>
|
||||
{i < stages.length - 1 && <div className="ep-arrow">›</div>}
|
||||
</span>
|
||||
))}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
||||
{PIPELINE.map((stage, i) => {
|
||||
const isDone = !failed && i < currentIdx;
|
||||
const isActive = !failed && i === currentIdx;
|
||||
const isFailed = failed && i === currentIdx;
|
||||
return (
|
||||
<span key={stage.key} style={{ display: 'contents' }}>
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
|
||||
padding: '4px 8px', borderRadius: 6, minWidth: 48,
|
||||
background: isDone ? '#0a2018' : isActive ? '#0a1530' : isFailed ? '#200a10' : 'transparent',
|
||||
border: isActive ? '1px solid #6a9eff' : isFailed ? '1px solid #ff5270' : '1px solid transparent',
|
||||
opacity: isDone || isActive || isFailed ? 1 : 0.35,
|
||||
}}>
|
||||
<span style={{ fontSize: 14 }}>{isDone ? '✓' : stage.icon}</span>
|
||||
<span style={{
|
||||
fontSize: 9, whiteSpace: 'nowrap',
|
||||
color: isDone ? '#2ecc8a' : isActive ? '#6a9eff' : isFailed ? '#ff5270' : '#6b7280',
|
||||
fontWeight: isActive || isFailed ? 700 : 400,
|
||||
}}>{stage.label}</span>
|
||||
</div>
|
||||
{i < PIPELINE.length - 1 && (
|
||||
<span style={{ fontSize: 10, color: isDone ? '#2ecc8a44' : '#1c2236', padding: '0 2px' }}>›</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取任务最近事件的描述 */
|
||||
function getLatestEvent(task: Task): string | null {
|
||||
// 优先取 flow_log 最后一条的 remark
|
||||
if (task.flow_log && task.flow_log.length > 0) {
|
||||
const last = task.flow_log[task.flow_log.length - 1];
|
||||
if (last.remark) return last.remark;
|
||||
}
|
||||
// 次选取 activity 最后一条的 text
|
||||
if (task.activity && task.activity.length > 0) {
|
||||
const last = task.activity[task.activity.length - 1];
|
||||
if (last.text) return last.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function EdictCard({ task }: { task: Task }) {
|
||||
const setModalTaskId = useStore((s) => s.setModalTaskId);
|
||||
const toast = useStore((s) => s.toast);
|
||||
const loadAll = useStore((s) => s.loadAll);
|
||||
|
||||
const hb = task.heartbeat || { status: 'unknown', label: '⚪' };
|
||||
const stCls = 'st-' + (task.state || '');
|
||||
const deptCls = 'dt-' + (task.org || '').replace(/\s/g, '');
|
||||
const curStage = PIPE.find((_, i) => getPipeStatus(task)[i].status === 'active');
|
||||
const todos = task.todos || [];
|
||||
const todoDone = todos.filter((x) => x.status === 'completed').length;
|
||||
const todoTotal = todos.length;
|
||||
const taskState = task.status || task.state || '';
|
||||
// 前端本地跟 ACTION_GUARDS 同样的映射表(不调 API,卡片列表性能要求)
|
||||
const ACTION_GUARDS: Record<string, string[]> = {
|
||||
pause: ['planning', 'executing'],
|
||||
resume: ['paused', 'cancelled'],
|
||||
cancel: ['created', 'planning', 'executing', 'paused', 'escalated', 'failed'],
|
||||
retry: ['failed'],
|
||||
escalate: ['executing', 'failed'],
|
||||
rollback: ['escalated'],
|
||||
steer: ['executing'],
|
||||
};
|
||||
const isAllowed = (action: string) => {
|
||||
if (taskState === 'cancelling' || taskState === 'pausing') return false; // BUG-6: 中间态禁止操作
|
||||
const allowed = ACTION_GUARDS[action] || [];
|
||||
return allowed.includes(taskState);
|
||||
};
|
||||
const canStop = isAllowed('pause');
|
||||
const canResume = isAllowed('resume');
|
||||
const canCancel = isAllowed('cancel');
|
||||
const archived = isArchived(task);
|
||||
const isBlocked = task.block && task.block !== '无' && task.block !== '-';
|
||||
const isCompleted = ['completed', 'Done'].includes(taskState);
|
||||
const latestEvent = getLatestEvent(task);
|
||||
|
||||
const handleAction = async (action: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (action === 'stop' || action === 'cancel') {
|
||||
const reason = prompt(action === 'stop' ? '请输入暂停原因:' : '请输入取消原因:');
|
||||
if (reason === null) return;
|
||||
try {
|
||||
const r = await api.taskAction(task.id, action, reason);
|
||||
if (r.ok) { toast(r.message || '操作成功'); loadAll(); }
|
||||
else toast(r.error || '操作失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
} else if (action === 'resume') {
|
||||
try {
|
||||
const r = await api.taskAction(task.id, 'resume', '恢复执行');
|
||||
if (r.ok) { toast(r.message || '已恢复'); loadAll(); }
|
||||
else toast(r.error || '操作失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const r = await api.archiveTask(task.id, !task.archived);
|
||||
if (r.ok) { toast(r.message || '操作成功'); loadAll(); }
|
||||
else toast(r.error || '操作失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
};
|
||||
|
||||
// M3: 检测 waiting_human 节点
|
||||
const waitingNodes = (task.nodes || []).filter((n: any) => n.status === 'waiting_human');
|
||||
const hasWaitingHuman = waitingNodes.length > 0;
|
||||
// ── 任务卡片 ──
|
||||
function TaskCard({ task, onOpen }: { task: V2Task; onOpen: () => void }) {
|
||||
const sm = STATUS_META[task.status];
|
||||
const pm = PRIORITY_META[task.priority];
|
||||
const rm = RISK_META[task.risk_level];
|
||||
const rvm = REVIEW_META[task.review_status];
|
||||
const agentEmoji = task.assignee ? (AGENT_EMOJI[task.assignee] || '🤖') : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`edict-card${archived ? ' archived' : ''}${hasWaitingHuman ? ' checkpoint-card' : ''}`}
|
||||
onClick={() => setModalTaskId(task.id)}
|
||||
style={hasWaitingHuman ? { border: '2px solid #f59e0b', animation: 'pulse-border 2s ease-in-out infinite' } : undefined}
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14,
|
||||
padding: 18, cursor: 'pointer', transition: 'border-color .15s, transform .1s, box-shadow .15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--acc)'; e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 4px 20px rgba(106,158,255,.1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--line)'; e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}
|
||||
>
|
||||
<MiniPipe task={task} />
|
||||
<div className="ec-id">{task.id}</div>
|
||||
<div className="ec-title">{task.title || '(无标题)'}</div>
|
||||
<div className="ec-meta">
|
||||
<span className={`tag ${stCls}`}>{stateLabel(task)}</span>
|
||||
{task.org && <span className={`tag ${deptCls}`}>{task.org}</span>}
|
||||
{task.updated_at && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
|
||||
⏱ {formatDuration(task.updated_at)}
|
||||
{/* 状态管线 */}
|
||||
<StatusPipeline status={task.status} />
|
||||
|
||||
{/* ID + 标题 */}
|
||||
<div style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', margin: '8px 0 4px' }}>{task.id}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, lineHeight: 1.4, marginBottom: 10 }}>{task.title}</div>
|
||||
|
||||
{/* 标签行 */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 4, border: `1px solid ${sm.color}44`, color: sm.color, background: sm.bg }}>{sm.label}</span>
|
||||
{task.assignee && (
|
||||
<span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 4, border: '1px solid #6b728044', color: '#dde4f8', background: '#141824' }}>
|
||||
{agentEmoji} {task.assignee}
|
||||
</span>
|
||||
)}
|
||||
{curStage && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
|
||||
当前: <b style={{ color: deptColor(curStage.dept) }}>{curStage.dept} · {curStage.action}</b>
|
||||
<span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 4, border: `1px solid ${pm.color}44`, color: pm.color, background: '#141824' }}>
|
||||
{pm.label}优先级
|
||||
</span>
|
||||
{task.escalated && (
|
||||
<span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 4, border: '1px solid #ff527044', color: '#ff5270', background: '#200a10' }}>
|
||||
⚠️ 已升级
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{task.now && task.now !== '-' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.5, marginBottom: 6 }}>
|
||||
{task.now.substring(0, 80)}
|
||||
</div>
|
||||
)}
|
||||
{(task.review_round || 0) > 0 && (
|
||||
<div style={{ fontSize: 11, marginBottom: 6 }}>
|
||||
{Array.from({ length: task.review_round || 0 }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block', width: 14, height: 14, borderRadius: '50%',
|
||||
background: i < (task.review_round || 0) - 1 ? '#1a3a6a22' : 'var(--acc)22',
|
||||
border: `1px solid ${i < (task.review_round || 0) - 1 ? '#2a4a8a' : 'var(--acc)'}`,
|
||||
fontSize: 9, textAlign: 'center', lineHeight: '13px', marginRight: 2,
|
||||
color: i < (task.review_round || 0) - 1 ? '#4a6aaa' : 'var(--acc)',
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
))}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>第 {task.review_round} 轮磋商</span>
|
||||
</div>
|
||||
)}
|
||||
{todoTotal > 0 && (
|
||||
<div className="ec-todo-bar">
|
||||
<span>📋 {todoDone}/{todoTotal}</span>
|
||||
<div className="ec-todo-track">
|
||||
<div className="ec-todo-fill" style={{ width: `${Math.round((todoDone / todoTotal) * 100)}%` }} />
|
||||
</div>
|
||||
<span>{todoDone === todoTotal ? '✅ 全部完成' : '🔄 进行中'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ec-footer">
|
||||
<span className={`hb ${hb.status}`}>{hb.label}</span>
|
||||
{isBlocked && (
|
||||
<span className="tag" style={{ borderColor: '#ff527044', color: 'var(--danger)', background: '#200a10' }}>
|
||||
🚫 {task.block}
|
||||
</span>
|
||||
)}
|
||||
{taskState === 'cancelling' && (
|
||||
<div className="tag" style={{ background: '#ff527022', color: '#ff5270', borderColor: '#ff527044' }}>
|
||||
⏳ 正在取消(等待当前节点完成...)
|
||||
</div>
|
||||
)}
|
||||
{taskState === 'pausing' && (
|
||||
<div className="tag" style={{ background: '#ffd70022', color: '#ffd700', borderColor: '#ffd70044' }}>
|
||||
⏳ 正在暂停(等待当前节点完成...)
|
||||
</div>
|
||||
)}
|
||||
{task.eta && task.eta !== '-' && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>📅 {task.eta}</span>
|
||||
)}
|
||||
|
||||
{/* 元信息行 */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, fontSize: 11, color: 'var(--muted)', marginBottom: 8 }}>
|
||||
<span>⏱ {timeAgo(task.updated_at)}</span>
|
||||
{task.outputs_count > 0 && <span>📦 {task.outputs_count}产出</span>}
|
||||
{task.comments_count > 0 && <span>💬 {task.comments_count}评论</span>}
|
||||
{rvm.icon && <span style={{ color: rvm.color }}>{rvm.icon} {rvm.label}</span>}
|
||||
</div>
|
||||
{/* 最近事件 */}
|
||||
{latestEvent && (
|
||||
|
||||
{/* 最新事件 */}
|
||||
{task.latest_event && (
|
||||
<div style={{
|
||||
fontSize: 10, color: '#8899aa', lineHeight: 1.4, marginTop: 4,
|
||||
fontSize: 10, color: '#8899aa', lineHeight: 1.4,
|
||||
padding: '3px 6px', background: '#1a1f2e', borderRadius: 4,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginBottom: 8,
|
||||
}}>
|
||||
💬 {latestEvent.substring(0, 100)}
|
||||
</div>
|
||||
)}
|
||||
{/* M3: waiting_human 通知 */}
|
||||
{hasWaitingHuman && (
|
||||
<div style={{ marginTop: 6, background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)', borderRadius: 8, padding: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span>🛐</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#f59e0b' }}>等待御批({waitingNodes.length})</span>
|
||||
</div>
|
||||
{waitingNodes.slice(0, 2).map((n: any) => (
|
||||
<div key={n.node_id} style={{ fontSize: 11, color: 'var(--fg)' }}>
|
||||
🎯 {n.name || n.node_id}({n.agent_id})
|
||||
</div>
|
||||
))}
|
||||
<button className="btn-small" style={{ marginTop: 4, borderColor: '#f59e0b', color: '#f59e0b' }} onClick={(e) => { e.stopPropagation(); setModalTaskId(task.id); }}>⚡ 立即处理</button>
|
||||
💬 {task.latest_event}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ec-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{canStop && (
|
||||
<button className="mini-act" onClick={(e) => handleAction('stop', e)}>⏸ 暂停</button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button className="mini-act danger" onClick={(e) => handleAction('cancel', e)}>🚫 取消</button>
|
||||
)}
|
||||
{canResume && (
|
||||
<button className="mini-act" onClick={(e) => handleAction('resume', e)}>▶ 恢复</button>
|
||||
)}
|
||||
{isCompleted && !task.archived && (
|
||||
<button className="mini-act" onClick={handleArchive}>📦 归档</button>
|
||||
)}
|
||||
{task.archived && (
|
||||
<button className="mini-act" onClick={handleArchive}>📤 取消归档</button>
|
||||
)}
|
||||
{/* 底部 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid var(--line)', paddingTop: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 10, padding: '1px 5px', borderRadius: 3, background: `${rm.color}22`, color: rm.color }}>{rm.label}</span>
|
||||
{task.retry_count > 0 && <span style={{ fontSize: 10, color: '#f59e0b' }}>🔄 x{task.retry_count}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{task.deadline && (
|
||||
<span style={{ fontSize: 10, color: new Date(task.deadline).getTime() - Date.now() < 3600000 ? '#ff5270' : 'var(--muted)' }}>
|
||||
📅 {formatDeadline(task.deadline)}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: 10, padding: '2px 8px', borderRadius: 4,
|
||||
background: 'var(--acc)11', color: 'var(--acc)', border: '1px solid var(--acc)33',
|
||||
cursor: 'pointer',
|
||||
}}>详情 →</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* M3: 成果物摘要(已完成 + 执行中任务) */}
|
||||
{(isCompleted || taskState === 'executing') && (
|
||||
<ArtifactList taskId={task.id} isCompleted={isCompleted} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 主组件 ──
|
||||
export default function EdictBoard() {
|
||||
const liveStatus = useStore((s) => s.liveStatus);
|
||||
const edictFilter = useStore((s) => s.edictFilter);
|
||||
const setEdictFilter = useStore((s) => s.setEdictFilter);
|
||||
const statusFilter = useStore((s) => s.statusFilter);
|
||||
const setStatusFilter = useStore((s) => s.setStatusFilter);
|
||||
const searchQuery = useStore((s) => s.searchQuery);
|
||||
const setSearchQuery = useStore((s) => s.setSearchQuery);
|
||||
const toast = useStore((s) => s.toast);
|
||||
const loadAll = useStore((s) => s.loadAll);
|
||||
const setModalTaskId = useStore(s => s.setModalTaskId);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const filters = [
|
||||
{ key: 'all', label: '全部', icon: '📋' },
|
||||
{ key: 'pending', label: '待认领', icon: '📋' },
|
||||
{ key: 'claimed', label: '已认领', icon: '👤' },
|
||||
{ key: 'working', label: '执行中', icon: '⚔️' },
|
||||
{ key: 'done', label: '已完成', icon: '✅' },
|
||||
{ key: 'failed', label: '失败', icon: '❌' },
|
||||
];
|
||||
|
||||
const tasks = liveStatus?.tasks || [];
|
||||
const allEdicts = tasks.filter(isEdict);
|
||||
const activeEdicts = allEdicts.filter((t) => !isArchived(t));
|
||||
const archivedEdicts = allEdicts.filter((t) => isArchived(t));
|
||||
|
||||
// Step 1: archive filter
|
||||
let edicts: Task[];
|
||||
if (edictFilter === 'active') edicts = activeEdicts;
|
||||
else if (edictFilter === 'archived') edicts = archivedEdicts;
|
||||
else edicts = allEdicts;
|
||||
|
||||
// Step 2: status filter
|
||||
if (statusFilter !== 'all') {
|
||||
edicts = edicts.filter((t) => stateToFilterKey(t.state, t) === statusFilter);
|
||||
}
|
||||
|
||||
// Step 3: search filter
|
||||
let tasks = MOCK_TASKS;
|
||||
if (statusFilter !== 'all') tasks = tasks.filter(t => t.status === statusFilter);
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
edicts = edicts.filter((t) =>
|
||||
(t.title || '').toLowerCase().includes(q) ||
|
||||
(t.id || '').toLowerCase().includes(q)
|
||||
);
|
||||
tasks = tasks.filter(t => t.title.toLowerCase().includes(q) || t.id.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
// Sort
|
||||
edicts.sort((a, b) => (STATE_ORDER[a.state] ?? 9) - (STATE_ORDER[b.state] ?? 9));
|
||||
// 排序:working > claimed > pending > done > failed
|
||||
const order: Record<string, number> = { working: 0, claimed: 1, pending: 2, failed: 3, done: 4 };
|
||||
tasks.sort((a, b) => (order[a.status] ?? 5) - (order[b.status] ?? 5));
|
||||
|
||||
const unArchivedDone = allEdicts.filter((t) => !t.archived && ['completed', 'Done', 'Cancelled', 'cancelled'].includes(t.status || t.state));
|
||||
const counts: Record<string, number> = { all: MOCK_TASKS.length };
|
||||
MOCK_TASKS.forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1; });
|
||||
|
||||
const handleArchiveAll = async () => {
|
||||
if (!confirm('将所有已完成/已取消的任务移入归档?')) return;
|
||||
try {
|
||||
const r = await api.archiveAllDone();
|
||||
if (r.ok) { toast(`📦 ${r.count || 0} 道任务已归档`); loadAll(); }
|
||||
else toast(r.error || '批量归档失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
try {
|
||||
const r = await api.schedulerScan();
|
||||
if (r.ok) toast(`🧭 引擎巡检完成:${r.count || 0} 个动作`);
|
||||
else toast(r.error || '巡检失败', 'err');
|
||||
loadAll();
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
};
|
||||
|
||||
// Count per status for badges
|
||||
const statusCounts: Record<string, number> = { all: 0 };
|
||||
for (const t of (edictFilter === 'active' ? activeEdicts : edictFilter === 'archived' ? archivedEdicts : allEdicts)) {
|
||||
statusCounts.all++;
|
||||
const key = stateToFilterKey(t.state, t);
|
||||
statusCounts[key] = (statusCounts[key] || 0) + 1;
|
||||
}
|
||||
// 统计
|
||||
const activeCount = MOCK_TASKS.filter(t => ['working', 'claimed'].includes(t.status)).length;
|
||||
const doneCount = MOCK_TASKS.filter(t => t.status === 'done').length;
|
||||
const failedCount = MOCK_TASKS.filter(t => t.status === 'failed').length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Archive Bar */}
|
||||
<div className="archive-bar">
|
||||
<span className="ab-label">筛选:</span>
|
||||
{(['active', 'archived', 'all'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={`ab-btn ${edictFilter === f ? 'active' : ''}`}
|
||||
onClick={() => setEdictFilter(f)}
|
||||
>
|
||||
{f === 'active' ? '活跃' : f === 'archived' ? '归档' : '全部'}
|
||||
</button>
|
||||
))}
|
||||
{unArchivedDone.length > 0 && (
|
||||
<button className="ab-btn" onClick={handleArchiveAll}>📦 一键归档</button>
|
||||
)}
|
||||
<span className="ab-count">
|
||||
活跃 {activeEdicts.length} · 归档 {archivedEdicts.length} · 共 {allEdicts.length}
|
||||
</span>
|
||||
<button className="ab-scan" onClick={handleScan}>🧭 引擎巡检</button>
|
||||
{/* 顶部统计 */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 14 }}>
|
||||
<div style={{ flex: 1, padding: '10px 14px', background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 10 }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 2 }}>活跃任务</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#6a9eff' }}>{activeCount}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '10px 14px', background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 10 }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 2 }}>已完成</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#2ecc8a' }}>{doneCount}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '10px 14px', background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 10 }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 2 }}>失败/升级</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#ff5270' }}>{failedCount}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '10px 14px', background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 10 }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 2 }}>审查中</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#f59e0b' }}>{MOCK_TASKS.filter(t => t.review_status !== 'none').length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter + Search Bar */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 0', flexWrap: 'wrap',
|
||||
}}>
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setStatusFilter(f.key)}
|
||||
style={{
|
||||
padding: '3px 10px', borderRadius: 6, fontSize: 11,
|
||||
border: `1px solid ${statusFilter === f.key ? 'var(--acc)' : '#2a3550'}`,
|
||||
background: statusFilter === f.key ? 'var(--acc)22' : '#161b2e',
|
||||
color: statusFilter === f.key ? 'var(--acc)' : '#8899aa',
|
||||
cursor: 'pointer', transition: 'all 0.15s',
|
||||
display: 'flex', alignItems: 'center', gap: 3,
|
||||
}}
|
||||
>
|
||||
<span>{f.icon}</span>
|
||||
<span>{f.label}</span>
|
||||
{(statusCounts[f.key] || 0) > 0 && (
|
||||
{/* 筛选 + 搜索 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 0', flexWrap: 'wrap' }}>
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => setStatusFilter(f.key)} style={{
|
||||
padding: '3px 10px', borderRadius: 6, fontSize: 11,
|
||||
border: `1px solid ${statusFilter === f.key ? 'var(--acc)' : '#2a3550'}`,
|
||||
background: statusFilter === f.key ? 'var(--acc)22' : '#161b2e',
|
||||
color: statusFilter === f.key ? 'var(--acc)' : '#8899aa',
|
||||
cursor: 'pointer', transition: 'all .15s',
|
||||
display: 'flex', alignItems: 'center', gap: 3,
|
||||
}}>
|
||||
<span>{f.icon}</span><span>{f.label}</span>
|
||||
{(counts[f.key] || 0) > 0 && (
|
||||
<span style={{
|
||||
fontSize: 10, background: statusFilter === f.key ? 'var(--acc)' : '#2a3550',
|
||||
color: statusFilter === f.key ? '#000' : '#8899aa',
|
||||
borderRadius: 8, padding: '0 4px', minWidth: 16, textAlign: 'center',
|
||||
}}>
|
||||
{statusCounts[f.key]}
|
||||
</span>
|
||||
}}>{counts[f.key]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ marginLeft: 'auto', position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索任务标题..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
<input type="text" placeholder="搜索任务..." value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
padding: '4px 10px 4px 28px', borderRadius: 6, fontSize: 12,
|
||||
border: '1px solid #2a3550', background: '#161b2e', color: '#c0ccdd',
|
||||
width: 200, outline: 'none',
|
||||
border: '1px solid #2a3550', background: '#161b2e', color: '#c0ccdd', width: 200, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
position: 'absolute', left: 8, top: '50%', transform: 'translateY(-50%)',
|
||||
fontSize: 12, color: '#556', pointerEvents: 'none',
|
||||
}}>🔍</span>
|
||||
<span style={{ position: 'absolute', left: 8, top: '50%', transform: 'translateY(-50%)', fontSize: 12, color: '#556' }}>🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch Actions Bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
|
||||
background: '#1a2540', border: '1px solid var(--acc)', borderRadius: 8,
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: 'var(--acc)', fontWeight: 600 }}>
|
||||
☑ 已选 {selectedIds.size} 道
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-g"
|
||||
style={{ fontSize: 11, padding: '4px 12px' }}
|
||||
disabled={batchLoading}
|
||||
onClick={async () => {
|
||||
setBatchLoading(true);
|
||||
let count = 0;
|
||||
for (const id of selectedIds) {
|
||||
try { await api.taskAction(id, 'archive', ''); count++; } catch { /* skip */ }
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setBatchLoading(false);
|
||||
toast(`📦 已归档 ${count} 道军令`, 'ok');
|
||||
loadAll();
|
||||
}}
|
||||
>
|
||||
{batchLoading ? '⟳' : '📦 批量归档'}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ fontSize: 11, padding: '4px 12px', background: '#ff527022', color: '#ff5270', border: '1px solid #ff527044' }}
|
||||
disabled={batchLoading}
|
||||
onClick={async () => {
|
||||
setBatchLoading(true);
|
||||
let count = 0;
|
||||
for (const id of selectedIds) {
|
||||
try { await api.taskAction(id, 'cancel', '批量取消'); count++; } catch { /* skip */ }
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setBatchLoading(false);
|
||||
toast(`🚫 已取消 ${count} 道军令`, 'ok');
|
||||
loadAll();
|
||||
}}
|
||||
>
|
||||
{batchLoading ? '⟳' : '🚫 批量取消'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-g"
|
||||
style={{ fontSize: 11, padding: '4px 8px', marginLeft: 'auto' }}
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
取消选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
<div className="edict-grid">
|
||||
{edicts.length === 0 ? (
|
||||
<div className="empty" style={{ gridColumn: '1/-1' }}>
|
||||
暂无任务<br />
|
||||
<small style={{ fontSize: 11, marginTop: 6, display: 'block', color: 'var(--muted)' }}>
|
||||
通过编排台提交任务,庞统分析后拆解为执行计划
|
||||
</small>
|
||||
{/* 卡片网格 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 12, marginTop: 12 }}>
|
||||
{tasks.map(t => (
|
||||
<TaskCard key={t.id} task={t} onOpen={() => setModalTaskId(t.id)} />
|
||||
))}
|
||||
{tasks.length === 0 && (
|
||||
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: 40, color: 'var(--muted)' }}>
|
||||
暂无匹配任务
|
||||
</div>
|
||||
) : (
|
||||
edicts.map((t) => (
|
||||
<div key={t.id} style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(t.id)}
|
||||
onChange={() => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(t.id)) next.delete(t.id); else next.add(t.id);
|
||||
setSelectedIds(next);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute', top: 8, left: 8, zIndex: 2,
|
||||
width: 16, height: 16, cursor: 'pointer',
|
||||
accentColor: 'var(--acc)',
|
||||
}}
|
||||
/>
|
||||
<EdictCard task={t} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user