auto-sync: 2026-05-17 13:20:44
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* EdictBoard v2.0 — 任务看板
|
||||
* 从 store 读取真实 V2Task 数据,无 mock
|
||||
* EdictBoard v2.0 — 任务看板(真实 API 对接版)
|
||||
* 数据来源:store.v2tasks(通过 loadV2Tasks 从后端 API 加载)
|
||||
* 保留 mock fallback(无项目/无数据时展示示例)
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useStore, type V2Task } from '../store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
|
||||
// ── 状态管线 ──
|
||||
// ── 状态管线(5 步线性 + blocked/failed 旁路)──
|
||||
const PIPELINE_STEPS = [
|
||||
{ key: 'pending', label: '待认领', icon: '📋' as string },
|
||||
{ key: 'claimed', label: '已认领', icon: '👤' as string },
|
||||
{ key: 'working', label: '执行中', icon: '⚔️' as string },
|
||||
{ key: 'review', label: '审查中', icon: '🔍' as string },
|
||||
{ key: 'done', label: '已完成', icon: '✅' as string },
|
||||
];
|
||||
{ key: 'pending', label: '待认领', icon: '📋' },
|
||||
{ key: 'claimed', label: '已认领', icon: '👤' },
|
||||
{ key: 'working', label: '执行中', icon: '⚔️' },
|
||||
{ key: 'review', label: '审查中', icon: '🔍' },
|
||||
{ key: 'done', label: '已完成', icon: '✅' },
|
||||
] as const;
|
||||
|
||||
const PIPELINE_ORDER: Record<string, number> = {
|
||||
pending: 0, claimed: 1, working: 2, review: 3, done: 4,
|
||||
@@ -43,23 +44,45 @@ const RISK_META: Record<string, { color: string; label: string }> = {
|
||||
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: '🔍' },
|
||||
APPROVE: { color: '#2ecc8a', label: '已通过', icon: '✅' },
|
||||
REJECT: { 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': '📊',
|
||||
};
|
||||
|
||||
function getPriorityLabel(p: number) {
|
||||
return PRIORITY_META[p] || { color: '#6b7280', label: `P${p}` };
|
||||
}
|
||||
// V2Task type (import from store)
|
||||
type V2Task = {
|
||||
id: string; title: string; description: string | null;
|
||||
status: string; assignee: string | null; assigned_by: string | null;
|
||||
depends_on: string | null; parent_task: string | null;
|
||||
priority: number; task_type: string | null;
|
||||
created_at: string; updated_at: string;
|
||||
claimed_at: string | null; completed_at: string | null; started_at: string | null;
|
||||
deadline: string | null; retry_count: number; max_retries: number;
|
||||
risk_level: string | null; escalated: number;
|
||||
comments_count?: number; outputs_count?: number;
|
||||
review_status?: string | null; latest_event_detail?: string | null;
|
||||
};
|
||||
|
||||
// ── 工具函数 ──
|
||||
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)}天前`;
|
||||
try {
|
||||
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||
const diff = Date.now() - d.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)}天前`;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function formatDeadline(iso: string): string {
|
||||
@@ -71,6 +94,15 @@ function formatDeadline(iso: string): string {
|
||||
return `${Math.floor(hrs / 24)}天后`;
|
||||
}
|
||||
|
||||
function getPriorityLabel(p: number): { color: string; label: string } {
|
||||
return PRIORITY_META[p] || { color: '#6b7280', label: `P${p}` };
|
||||
}
|
||||
|
||||
function parseDependsOn(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try { return JSON.parse(raw); } catch { return []; }
|
||||
}
|
||||
|
||||
// ── 状态管线组件 ──
|
||||
function StatusPipeline({ status }: { status: string }) {
|
||||
const isFailed = status === 'failed';
|
||||
@@ -81,27 +113,27 @@ function StatusPipeline({ status }: { status: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
||||
{PIPELINE_STEPS.map((stage, i) => {
|
||||
const isDone = !isFailed && !isBlocked && !isCancelled && i < curIdx;
|
||||
const isActive = (curIdx === i) || (isFailed && i === 2) || (isBlocked && i === 2);
|
||||
const isDone = !isFailed && !isBlocked && !isCancelled && curIdx >= 0 && i < curIdx;
|
||||
const isActive = curIdx >= 0 && i === curIdx;
|
||||
const isFailedNode = isFailed && i === 2;
|
||||
const isBlockedNode = isBlocked && i === 2;
|
||||
|
||||
let bg = 'transparent', borderColor = 'transparent', textColor = '#6b7280', icon = stage.icon;
|
||||
let bg = 'transparent', border = 'transparent', textColor = '#6b7280', icon = stage.icon;
|
||||
if (isDone) { bg = '#0a2018'; textColor = '#2ecc8a'; icon = '✓'; }
|
||||
if (isActive && !isFailedNode && !isBlockedNode) { bg = '#0a1530'; borderColor = '#6a9eff'; textColor = '#6a9eff'; }
|
||||
if (isFailedNode) { bg = '#200a10'; borderColor = '#ff5270'; textColor = '#ff5270'; icon = '✗'; }
|
||||
if (isBlockedNode) { bg = '#1a1508'; borderColor = '#f59e0b'; textColor = '#f59e0b'; icon = '🚧'; }
|
||||
else if (isFailedNode) { bg = '#200a10'; border = '#ff5270'; textColor = '#ff5270'; icon = '✗'; }
|
||||
else if (isBlockedNode) { bg = '#1a1508'; border = '#f59e0b'; textColor = '#f59e0b'; icon = '🚧'; }
|
||||
else if (isActive) { bg = '#0a1530'; border = '#6a9eff'; textColor = '#6a9eff'; }
|
||||
|
||||
return (
|
||||
<span key={stage.key} style={{ display: 'contents' }}>
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
|
||||
padding: '4px 6px', borderRadius: 6, minWidth: 44,
|
||||
background: bg, border: `1px solid ${borderColor}`,
|
||||
opacity: isDone || isActive ? 1 : isCancelled ? 0.25 : 0.35,
|
||||
background: bg, border: `1px solid ${border}`,
|
||||
opacity: isDone || isActive || isFailedNode || isBlockedNode ? 1 : isCancelled ? 0.25 : 0.35,
|
||||
}}>
|
||||
<span style={{ fontSize: 13 }}>{icon}</span>
|
||||
<span style={{ fontSize: 8, whiteSpace: 'nowrap', color: textColor, fontWeight: isActive ? 700 : 400 }}>
|
||||
<span style={{ fontSize: 8, whiteSpace: 'nowrap', color: textColor, fontWeight: isActive || isFailedNode || isBlockedNode ? 700 : 400 }}>
|
||||
{isFailedNode ? '失败' : isBlockedNode ? '阻塞' : stage.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -120,7 +152,9 @@ function TaskCard({ task, onOpen }: { task: V2Task; onOpen: () => void }) {
|
||||
const sm = STATUS_META[task.status] || STATUS_META.pending;
|
||||
const pm = getPriorityLabel(task.priority);
|
||||
const rm = RISK_META[task.risk_level || 'standard'] || RISK_META.standard;
|
||||
const rvm = REVIEW_META[task.review_status || 'none'] || REVIEW_META.none;
|
||||
const agentEmoji = task.assignee ? (AGENT_EMOJI[task.assignee] || '🤖') : '';
|
||||
const deps = parseDependsOn(task.depends_on);
|
||||
|
||||
return (
|
||||
<div onClick={onOpen} style={{
|
||||
@@ -137,15 +171,16 @@ function TaskCard({ task, onOpen }: { task: V2Task; onOpen: () => void }) {
|
||||
<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>}
|
||||
<span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 4, border: `1px solid ${pm.color}44`, color: pm.color, background: '#141824' }}>{pm.label}优先级</span>
|
||||
<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> : null}
|
||||
{task.depends_on && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 4, border: '1px solid #f59e0b44', color: '#f59e0b', background: '#1a1508' }}>🔗 依赖</span>}
|
||||
{deps.length > 0 && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 4, border: '1px solid #f59e0b44', color: '#f59e0b', background: '#1a1508' }}>🔗 依赖{deps.length}</span>}
|
||||
</div>
|
||||
|
||||
<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) > 0 && <span>📦 {task.outputs_count}产出</span>}
|
||||
{(task.comments_count || 0) > 0 && <span>💬 {task.comments_count}评论</span>}
|
||||
{rvm.icon && <span style={{ color: rvm.color }}>{rvm.icon}</span>}
|
||||
</div>
|
||||
|
||||
{task.latest_event_detail && (
|
||||
@@ -160,7 +195,11 @@ function TaskCard({ task, onOpen }: { task: V2Task; onOpen: () => void }) {
|
||||
{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>}
|
||||
{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>
|
||||
@@ -168,19 +207,54 @@ function TaskCard({ task, onOpen }: { task: V2Task; onOpen: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 空状态(无项目/无数据)──
|
||||
function EmptyState({ hasProject }: { hasProject: boolean }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 60, color: 'var(--muted)' }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>{hasProject ? '📭' : '📂'}</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
|
||||
{hasProject ? '暂无任务' : '请先创建项目'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
{hasProject
|
||||
? '点击右上角「新建军令」创建第一个任务'
|
||||
: '在「系统设置」中创建项目,或通过 API 创建'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 加载骨架 ──
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 12 }}>
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, opacity: 0.5 }}>
|
||||
<div style={{ height: 20, background: 'var(--panel2)', borderRadius: 4, marginBottom: 12, width: '60%' }} />
|
||||
<div style={{ height: 14, background: 'var(--panel2)', borderRadius: 4, marginBottom: 8, width: '80%' }} />
|
||||
<div style={{ height: 14, background: 'var(--panel2)', borderRadius: 4, width: '40%' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 主组件 ──
|
||||
export default function EdictBoard() {
|
||||
const v2tasks = useStore(s => s.v2tasks);
|
||||
const loading = useStore(s => s.v2tasksLoading);
|
||||
const setModalTaskId = useStore(s => s.setModalTaskId);
|
||||
const loadV2Tasks = useStore(s => s.loadV2Tasks);
|
||||
const v2tasks = useStore(s => s.v2tasks);
|
||||
const v2tasksLoading = useStore(s => s.v2tasksLoading);
|
||||
const selectedProjectId = useStore(s => s.selectedProjectId);
|
||||
|
||||
const loadV2Tasks = useStore(s => s.loadV2Tasks);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => { loadV2Tasks(); }, [selectedProjectId]);
|
||||
// 初始化加载 + 轮询
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) loadV2Tasks();
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const tasks = v2tasks as V2Task[];
|
||||
|
||||
const filters = [
|
||||
{ key: 'all', label: '全部', icon: '📋' },
|
||||
@@ -193,39 +267,47 @@ export default function EdictBoard() {
|
||||
{ key: 'blocked', label: '阻塞', icon: '🚧' },
|
||||
];
|
||||
|
||||
let tasks = v2tasks;
|
||||
if (statusFilter !== 'all') tasks = tasks.filter(t => t.status === statusFilter);
|
||||
let filtered = tasks;
|
||||
if (statusFilter !== 'all') filtered = filtered.filter(t => t.status === statusFilter);
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
tasks = tasks.filter(t => (t.title || '').toLowerCase().includes(q) || (t.id || '').toLowerCase().includes(q));
|
||||
filtered = filtered.filter(t => (t.title || '').toLowerCase().includes(q) || (t.id || '').toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
const order: Record<string, number> = { working: 0, review: 1, claimed: 2, blocked: 3, pending: 4, failed: 5, done: 6, cancelled: 7 };
|
||||
tasks = [...tasks].sort((a, b) => (order[a.status] ?? 8) - (order[b.status] ?? 8));
|
||||
filtered.sort((a, b) => (order[a.status] ?? 8) - (order[b.status] ?? 8));
|
||||
|
||||
const counts: Record<string, number> = { all: v2tasks.length };
|
||||
v2tasks.forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1; });
|
||||
const counts: Record<string, number> = { all: tasks.length };
|
||||
tasks.forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1; });
|
||||
|
||||
const activeCount = v2tasks.filter(t => ['working', 'claimed', 'review'].includes(t.status)).length;
|
||||
const doneCount = v2tasks.filter(t => t.status === 'done').length;
|
||||
const failedCount = v2tasks.filter(t => ['failed', 'blocked'].includes(t.status)).length;
|
||||
const reviewCount = v2tasks.filter(t => t.status === 'review').length;
|
||||
const activeCount = tasks.filter(t => ['working', 'claimed', 'review'].includes(t.status)).length;
|
||||
const doneCount = tasks.filter(t => t.status === 'done').length;
|
||||
const failedCount = tasks.filter(t => ['failed', 'blocked'].includes(t.status)).length;
|
||||
const reviewCount = tasks.filter(t => t.status === 'review').length;
|
||||
|
||||
if (!selectedProjectId) return <EmptyState hasProject={false} />;
|
||||
if (v2tasksLoading && tasks.length === 0) return <LoadingSkeleton />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 顶部统计 */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 14 }}>
|
||||
{[
|
||||
{ label: '活跃任务', val: activeCount, color: '#6a9eff' },
|
||||
{ label: '已完成', val: doneCount, color: '#2ecc8a' },
|
||||
{ label: '失败/阻塞', val: failedCount, color: '#ff5270' },
|
||||
{ label: '审查中', val: reviewCount, color: '#818cf8' },
|
||||
].map(s => (
|
||||
<div key={s.label} 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 }}>{s.label}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: s.color }}>{s.val}</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: '#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: '#818cf8' }}>{reviewCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选 + 搜索 */}
|
||||
@@ -247,17 +329,23 @@ export default function EdictBoard() {
|
||||
))}
|
||||
<div style={{ marginLeft: 'auto', position: 'relative' }}>
|
||||
<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' }} />
|
||||
style={{ padding: '4px 10px 4px 28px', borderRadius: 6, fontSize: 12, 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' }}>🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 卡片网格 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 12, marginTop: 12 }}>
|
||||
{loading && tasks.length === 0 && <div style={{ gridColumn: '1/-1', textAlign: 'center', padding: 40, color: 'var(--muted)' }}>加载中...</div>}
|
||||
{!loading && tasks.length === 0 && <div style={{ gridColumn: '1/-1', textAlign: 'center', padding: 40, color: 'var(--muted)' }}>暂无任务</div>}
|
||||
{tasks.map(t => <TaskCard key={t.id} task={t} onOpen={() => setModalTaskId(t.id)} />)}
|
||||
{filtered.map(t => (
|
||||
<TaskCard key={t.id} task={t} onOpen={() => setModalTaskId(t.id)} />
|
||||
))}
|
||||
{filtered.length === 0 && tasks.length > 0 && (
|
||||
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: 40, color: 'var(--muted)' }}>当前筛选无匹配任务</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 && <EmptyState hasProject={true} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user