206 lines
8.3 KiB
TypeScript
206 lines
8.3 KiB
TypeScript
/**
|
||
* ToolchainPanel — 工具链事件(系统级)
|
||
* 展示 _toolchain 项目的 tasks:CI/PR/部署/Review 通知
|
||
*/
|
||
import { useEffect, useState } from 'react';
|
||
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
pending: '#f59e0b22', claimed: '#6a9eff22', working: '#6a9eff22',
|
||
review: '#818cf822', done: '#2ecc8a22', failed: '#ef444422',
|
||
cancelled: '#6b728022', blocked: '#ef444422',
|
||
};
|
||
|
||
const STATUS_LABELS: Record<string, string> = {
|
||
pending: '待处理', claimed: '已认领', working: '处理中',
|
||
review: '审查中', done: '已完成', failed: '失败',
|
||
cancelled: '已取消', blocked: '已拦截',
|
||
};
|
||
|
||
function fmtTime(iso: string): string {
|
||
try {
|
||
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
||
const now = Date.now();
|
||
const diff = 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 `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||
} catch { return iso; }
|
||
}
|
||
|
||
export default function ToolchainPanel() {
|
||
const [tasks, setTasks] = useState<any[]>([]);
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
const [detail, setDetail] = useState<any>(null);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const loadTasks = async (q?: string) => {
|
||
setLoading(true);
|
||
try {
|
||
const url = q
|
||
? `/api/projects/_toolchain/tasks?q=${encodeURIComponent(q)}`
|
||
: `/api/projects/_toolchain/tasks`;
|
||
const res = await fetch(url);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setTasks(data.tasks || []);
|
||
}
|
||
} catch { /* */ }
|
||
setLoading(false);
|
||
};
|
||
|
||
useEffect(() => { loadTasks(); }, []);
|
||
|
||
// 搜索防抖 300ms
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
if (searchQuery !== undefined) loadTasks(searchQuery || undefined);
|
||
}, 300);
|
||
return () => clearTimeout(timer);
|
||
}, [searchQuery]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedId) { setDetail(null); return; }
|
||
(async () => {
|
||
try {
|
||
const res = await fetch(
|
||
`/api/projects/_toolchain/tasks/${selectedId}?expand=comments`
|
||
);
|
||
if (res.ok) setDetail(await res.json());
|
||
} catch { /* */ }
|
||
})();
|
||
}, [selectedId]);
|
||
|
||
// 渲染评论列表(兼容 expand 和裸 list 格式)
|
||
const renderComments = (comments: any[]) => {
|
||
if (!comments || comments.length === 0) return null;
|
||
return (
|
||
<div style={{ marginTop: 16 }}>
|
||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 8, fontWeight: 600 }}>
|
||
📋 处理记录 ({comments.length})
|
||
</div>
|
||
{comments.map((c: any, i: number) => (
|
||
<div key={c.id || i} style={{
|
||
padding: '8px 12px', background: 'var(--panel2)', borderRadius: 6, marginBottom: 6,
|
||
}}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 600 }}>
|
||
{c.author || 'system'}
|
||
</span>
|
||
<span style={{ fontSize: 9, color: 'var(--muted)' }}>{fmtTime(c.created_at)}</span>
|
||
</div>
|
||
<div style={{ fontSize: 12, color: '#a0aec0', lineHeight: 1.5 }}>{c.body}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div style={{ display: 'flex', gap: 0, height: '100%', minHeight: 500 }}>
|
||
{/* 左侧列表 */}
|
||
<div style={{ width: 380, borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
|
||
{/* 搜索栏 + 刷新 */}
|
||
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--line)', display: 'flex', gap: 6, alignItems: 'center' }}>
|
||
<input
|
||
type="text"
|
||
placeholder="搜索工具链事件..."
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
style={{
|
||
flex: 1, padding: '4px 8px', borderRadius: 4, fontSize: 11,
|
||
border: '1px solid #2a3550', background: '#161b2e', color: '#dde4f8',
|
||
outline: 'none',
|
||
}}
|
||
/>
|
||
<button onClick={() => loadTasks(searchQuery || undefined)} style={{
|
||
padding: '3px 8px', borderRadius: 4, fontSize: 10,
|
||
border: '1px solid #2a3550', background: '#161b2e', color: '#8899aa', cursor: 'pointer',
|
||
}}>🔄</button>
|
||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{tasks.length} 条</span>
|
||
</div>
|
||
|
||
{/* 事件列表 */}
|
||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||
{tasks.length === 0 && (
|
||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--muted)', fontSize: 12 }}>
|
||
{loading ? '加载中...' : '暂无工具链事件'}
|
||
</div>
|
||
)}
|
||
{tasks.map((t: any) => (
|
||
<div key={t.id} onClick={() => setSelectedId(t.id)} style={{
|
||
padding: '10px 14px', borderBottom: '1px solid var(--line)',
|
||
cursor: 'pointer', transition: 'background .15s',
|
||
background: selectedId === t.id ? 'var(--panel2)' : 'transparent',
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.background = 'var(--panel2)'}
|
||
onMouseLeave={e => e.currentTarget.style.background = selectedId === t.id ? 'var(--panel2)' : 'transparent'}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||
<span style={{
|
||
fontSize: 9, padding: '1px 5px', borderRadius: 3,
|
||
background: STATUS_COLORS[t.status] || '#2a3550',
|
||
color: '#dde4f8',
|
||
}}>{STATUS_LABELS[t.status] || t.status}</span>
|
||
<span style={{ fontSize: 9, color: 'var(--muted)' }}>{fmtTime(t.created_at)}</span>
|
||
</div>
|
||
<div style={{
|
||
fontSize: 12, fontWeight: 500, color: '#dde4f8',
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}}>{t.title}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧详情 */}
|
||
<div style={{ flex: 1, padding: '16px 20px', overflowY: 'auto' }}>
|
||
{!detail ? (
|
||
<div style={{ textAlign: 'center', padding: 60, color: 'var(--muted)' }}>
|
||
<div style={{ fontSize: 36, marginBottom: 12 }}>⛓️</div>
|
||
<div style={{ fontSize: 13 }}>选择一条事件查看详情</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* 头部 */}
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||
<span style={{ fontSize: 10, padding: '2px 6px', borderRadius: 4, background: STATUS_COLORS[detail.status] || '#2a3550', color: '#dde4f8' }}>
|
||
{STATUS_LABELS[detail.status] || detail.status}
|
||
</span>
|
||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{detail.id}</span>
|
||
</div>
|
||
<div style={{ fontSize: 18, fontWeight: 700, lineHeight: 1.3 }}>{detail.title}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}>
|
||
{fmtTime(detail.created_at)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 正文 */}
|
||
{detail.description && (
|
||
<div style={{
|
||
padding: '14px 16px', background: 'var(--panel2)', borderRadius: 10,
|
||
fontSize: 13, color: '#a0aec0', lineHeight: 1.7, whiteSpace: 'pre-wrap',
|
||
}}>
|
||
{detail.description}
|
||
</div>
|
||
)}
|
||
|
||
{/* action_report 评论 — expand 格式 {items, total_count} */}
|
||
{detail.comments && detail.comments.items && detail.comments.items.length > 0 &&
|
||
renderComments(detail.comments.items)
|
||
}
|
||
{/* 兼容裸 list 格式 */}
|
||
{detail.comments && Array.isArray(detail.comments) && detail.comments.length > 0 &&
|
||
renderComments(detail.comments)
|
||
}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|