[moz] feat(frontend): 新增工具链 Tab — 列表+详情+搜索栏
This commit is contained in:
@@ -17,6 +17,7 @@ import CourtCeremony from './components/CourtCeremony';
|
|||||||
import CourtDiscussion from './components/CourtDiscussion';
|
import CourtDiscussion from './components/CourtDiscussion';
|
||||||
import UsagePanel from './components/UsagePanel';
|
import UsagePanel from './components/UsagePanel';
|
||||||
import SettingsPanel from './components/SettingsPanel';
|
import SettingsPanel from './components/SettingsPanel';
|
||||||
|
import ToolchainPanel from './components/ToolchainPanel';
|
||||||
import GlobalSearch from './components/GlobalSearch';
|
import GlobalSearch from './components/GlobalSearch';
|
||||||
import NotificationCenter from './components/NotificationCenter';
|
import NotificationCenter from './components/NotificationCenter';
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ export default function App() {
|
|||||||
usage: <UsagePanel />,
|
usage: <UsagePanel />,
|
||||||
morning: <MorningPanel />,
|
morning: <MorningPanel />,
|
||||||
settings: <SettingsPanel />,
|
settings: <SettingsPanel />,
|
||||||
|
toolchain: <ToolchainPanel />,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ export function isArchived(t: Task): boolean {
|
|||||||
export type TabKey =
|
export type TabKey =
|
||||||
| 'tasks' | 'court' | 'monitor' | 'agents'
|
| 'tasks' | 'court' | 'monitor' | 'agents'
|
||||||
| 'models' | 'skills' | 'sessions' | 'archives' | 'templates'
|
| 'models' | 'skills' | 'sessions' | 'archives' | 'templates'
|
||||||
| 'usage' | 'settings' | 'officials' | 'morning' | 'mail';
|
| 'usage' | 'settings' | 'officials' | 'morning' | 'mail' | 'toolchain';
|
||||||
|
|
||||||
export const TAB_DEFS: { key: TabKey; label: string; icon: string }[] = [
|
export const TAB_DEFS: { key: TabKey; label: string; icon: string }[] = [
|
||||||
{ key: 'tasks', label: '任务看板', icon: '📜' },
|
{ key: 'tasks', label: '任务看板', icon: '📜' },
|
||||||
@@ -135,6 +135,7 @@ export const TAB_DEFS: { key: TabKey; label: string; icon: string }[] = [
|
|||||||
{ key: 'archives', label: '奏折阁', icon: '📜' },
|
{ key: 'archives', label: '奏折阁', icon: '📜' },
|
||||||
{ key: 'morning', label: '早朝简报', icon: '🌅' },
|
{ key: 'morning', label: '早朝简报', icon: '🌅' },
|
||||||
{ key: 'templates', label: '任务模板', icon: '📋' },
|
{ key: 'templates', label: '任务模板', icon: '📋' },
|
||||||
|
{ key: 'toolchain', label: '工具链', icon: '⛓️' },
|
||||||
{ key: 'settings', label: '系统设置', icon: '⚙️' },
|
{ key: 'settings', label: '系统设置', icon: '⚙️' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user