auto-sync: 2026-05-17 12:12:27

This commit is contained in:
cfdaily
2026-05-17 12:12:27 +08:00
parent 9f6c6bc01b
commit d0419634ad
+311 -453
View File
@@ -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_statusedict 用 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>