Files
L2keka/web/src/components/ProjectView.tsx
T
hangshuo652 6a2419d4a4 L2考核落地:人才测评整合为L2考核track,新增查重初筛与e2e验证
- 新增 L2考核 track(7维标准/100分):选题难度赋分cap、功能完整性地板线、合格判定
- 人才测评整合进 L2考核:移除 L2/L3 两级认定与 question_id 机制
- 新增 l2-topics 选题元数据服务与 config/l2-topics.json(11命题题+自选题)
- 新增查重初筛 plagiarism-detect(MD5精确比对+归一化相似度+提交行为,仅告警)
- 修复 L2 维度 DIM_FILE_FILTERS 缺失导致 AI 协作记录证据漏喂
- e2e:修复 Windows spawn、DB 隔离,L2 用例 27 项全绿
2026-08-23 20:58:56 +08:00

1293 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../services/api';
async function downloadPdf(url: string, options?: { silent?: boolean }) {
try {
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) {
let msg = '下载失败';
try { msg = (await res.json()).error || msg; } catch { /* 非 JSON 响应 */ }
alert(msg);
return false;
}
const blob = await res.blob();
// 优先 filename*RFC 5987);其次 filename(剥离引号);最后兜底
const disposition = res.headers.get('Content-Disposition') || '';
let filename = '';
const star = disposition.match(/filename\*=(?:UTF-8'')?([^;\s]+)/i);
if (star) {
try { filename = decodeURIComponent(star[1]); } catch { filename = star[1]; }
} else {
const plain = disposition.match(/filename="?([^";\s]+)"?/i);
filename = plain ? plain[1] : '';
}
if (!filename) filename = url.includes('summary') ? '汇总报告.pdf' : url.includes('deliverables') ? '成果物清单.csv' : '报告.pdf';
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
if (!options?.silent) alert(`已开始下载:${filename}(保存到浏览器下载目录)`);
return true;
} catch (e: any) {
alert('下载失败: ' + (e?.message || e));
return false;
}
}
type Tab = 'standards' | 'entries' | 'deliverables' | 'summary';
export default function ProjectView() {
const { id } = useParams<{ id: string }>();
const [project, setProject] = useState<any>(null);
const [projectError, setProjectError] = useState('');
const [tab, setTab] = useState<Tab>('entries');
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState('');
useEffect(() => {
if (id) {
setProjectError('');
let cancelled = false;
api.getProject(id).then(p => { if (!cancelled) setProject(p); }).catch((err: any) => {
if (!cancelled) {
setProjectError(err.message || '项目不存在');
setProject(null);
}
});
return () => { cancelled = true; };
}
}, [id]);
if (projectError) return <div className="project-view"><div className="empty">{projectError}</div></div>;
if (!project) return <div className="loading">加载中...</div>;
return (
<div className="project-view">
<div className="project-header">
<div className="flex justify-between items-center">
<div className="flex items-center gap-10">
{editingName ? (
<input value={newName} onChange={e => setNewName(e.target.value)} autoFocus
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', border: '2px solid var(--primary)', borderRadius: 8, outline: 'none', width: 400 }}
onKeyDown={async e => {
if (e.key === 'Enter' && newName.trim()) {
try {
await api.request('PUT', `/projects/${id}`, { name: newName.trim() });
setProject({ ...project, name: newName.trim() });
setEditingName(false);
} catch (err: any) { alert(err.message || '重命名失败'); }
}
if (e.key === 'Escape') { setNewName(project.name); setEditingName(false); }
}}
onBlur={async () => {
if (newName.trim() && newName.trim() !== project.name) {
try {
await api.request('PUT', `/projects/${id}`, { name: newName.trim() });
setProject({ ...project, name: newName.trim() });
} catch (err: any) { alert(err.message || '重命名失败'); }
}
setEditingName(false);
}}
/>
) : (
<h2 style={{ cursor: 'pointer' }} onClick={() => { setNewName(project.name); setEditingName(true); }} title="点击重命名">{project.name} </h2>
)}
{project.track && <span className="badge" style={{ background: '#6366f1', marginLeft: 8, fontSize: 12 }}>{project.track}</span>}
</div>
<button className="btn-danger-outline" onClick={async () => { if (confirm(`删除项目"${project.name}"?所有关联数据将丢失`)) { try { await api.request('DELETE', `/projects/${id}?force=true`); window.location.href = '/'; } catch (err: any) { alert(err.message); } } }}>删除项目</button>
</div>
<div className="project-meta">
<span>总计 {project.total}</span>
<span> {project.reviewed || 0}</span>
<span> {project.active || 0}</span>
<span> {project.failed || 0}</span>
</div>
</div>
<div className="tabs">
<button className={`tab ${tab === 'standards' ? 'active' : ''}`} onClick={() => setTab('standards')}>标准</button>
<button className={`tab ${tab === 'entries' ? 'active' : ''}`} onClick={() => setTab('entries')}>条目</button>
<button className={`tab ${tab === 'deliverables' ? 'active' : ''}`} onClick={() => setTab('deliverables')}>成果物</button>
<button className={`tab ${tab === 'summary' ? 'active' : ''}`} onClick={() => setTab('summary')}>汇总</button>
</div>
<div className="tab-content">
{tab === 'standards' && <StandardsManager projectId={id!} />}
{tab === 'entries' && <EntryManager projectId={id!} track={project.track} />}
{tab === 'deliverables' && <DeliverablesView projectId={id!} />}
{tab === 'summary' && <SummaryView projectId={id!} />}
</div>
</div>
);
}
function StandardsManager({ projectId }: { projectId: string }) {
const [standards, setStandards] = useState<any[]>([]);
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState('');
const [content, setContent] = useState('');
const [catTag, setCatTag] = useState('');
const [maxScore, setMaxScore] = useState('150');
const load = () => api.listStandards(projectId).then(setStandards).catch(() => {});
useEffect(() => { load(); }, [projectId]);
const create = async () => {
if (!name.trim() || !content.trim()) return;
try {
await api.createStandard(projectId, { name: name.trim(), content, category_tag: catTag, max_score: parseInt(maxScore) || 150 });
setName(''); setContent(''); setCatTag(''); setMaxScore('150'); setShowForm(false); await load();
} catch (err: any) { alert(err.message || '保存失败,请检查服务端'); }
};
const remove = async (sid: string) => {
if (!confirm('确定删除?')) return;
try { await api.deleteStandard(projectId, sid); await load(); } catch (err: any) { alert(err.message || '删除失败'); }
};
return (
<div>
<div className="section-header"><h3>评审标准 ({standards.length})</h3><button onClick={() => setShowForm(true)}>+ 上传标准</button></div>
{showForm && (
<div className="standard-form">
<input value={name} onChange={e => setName(e.target.value)} placeholder="标准名称" />
<input value={catTag} onChange={e => setCatTag(e.target.value)} placeholder="分类标签(留空为默认标准)" />
<input value={maxScore} onChange={e => setMaxScore(e.target.value)} placeholder="总分上限" type="number" min={1} style={{ width: 120, padding: '10px 14px', border: '2px solid var(--border)', borderRadius: 8, fontSize: 14, outline: 'none' }} />
<textarea value={content} onChange={e => setContent(e.target.value)} placeholder={'## 维度名(分值)\n评审要点\n可选:文件关键词: data,report,benchmark(限定该维度只看这些文件,留空则按系统规则)'} rows={8} />
<div className="form-actions">
<button onClick={create}>保存</button>
<button onClick={() => setShowForm(false)}>取消</button>
</div>
</div>
)}
<div className="standard-list">
{standards.length === 0 && <div className="empty">暂无评审标准</div>}
{standards.map(s => (
<div key={s.id} className="standard-card" style={{ flexDirection: 'column', alignItems: 'stretch' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div>
<strong style={{ fontSize: 15 }}>{s.name}</strong>
{s.category_tag && <span className="tag">{s.category_tag}</span>}
<span className="tag" style={{ background: '#e0e7ff', color: '#4338ca' }}>上限{s.max_score || 150}</span>
</div>
<button className="btn-danger" onClick={() => remove(s.id)}>删除</button>
</div>
<details style={{ fontSize: 13 }}>
<summary style={{ cursor: 'pointer', color: 'var(--primary)', fontWeight: 500, marginBottom: 4 }}>
查看维度详情({s.dimensions?.length || 0}个维度)
</summary>
{s.dimensions?.map((d: any, i: number) => (
<div key={i} style={{ padding: '10px 12px', margin: '6px 0', background: 'var(--bg-subtle)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{d.name}{d.maxScore}分)</div>
{d.group && d.group !== 'common' && <span className="tag" style={{ background: '#f3e8ff', color: '#7c3aed', marginBottom: 4, display: 'inline-block' }}>{d.group}</span>}
<div style={{ whiteSpace: 'pre-wrap', color: 'var(--text-secondary)', lineHeight: 1.6, fontSize: 12 }}>{d.content || '无详细说明'}</div>
</div>
))}
</details>
</div>
))}
</div>
</div>
);
}
function EntryManager({ projectId, track }: { projectId: string; track?: string }) {
const [entries, setEntries] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [status, setStatus] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [detail, setDetail] = useState<any>(null);
const [showImport, setShowImport] = useState(false);
const [csvText, setCsvText] = useState('');
const [importResult, setImportResult] = useState<any>(null);
const [editEntry, setEditEntry] = useState<any>(null);
const [showAdd, setShowAdd] = useState(false);
const [addForm, setAddForm] = useState({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', selected_topic: 'self', reg_no: '', reg_features: '' });
const [l2Topics, setL2Topics] = useState<any[]>([]);
const [offset, setOffset] = useState(0);
const PAGE_LIMIT = 50;
// L2考核:加载选题元数据(命题01~11),供条目表单选题下拉
useEffect(() => {
if (track === 'L2考核') {
api.request('GET', '/projects/l2-topics').then((d: any) => setL2Topics(d?.topics || [])).catch(() => {});
}
}, [track]);
const latestSearchRef = useRef('');
const doSearch = (term: string, pageOffset = offset) => {
latestSearchRef.current = term;
const params: any = { limit: PAGE_LIMIT, offset: pageOffset };
if (status) params.status = status;
if (term) params.search = term;
api.listEntries(projectId, params).then(r => {
if (latestSearchRef.current === term) {
setEntries(r.items);
setTotal(r.total);
}
}).catch(() => {});
};
const load = () => doSearch(search, offset);
const goPage = (newOffset: number) => {
setOffset(newOffset);
setTimeout(() => doSearch(search, newOffset), 0);
};
useEffect(() => { setOffset(0); doSearch(search, 0); }, [projectId, status, search]);
const toggleSelect = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
setSelected(next);
};
const doAction = async (action: string, entryId: string) => {
try {
if (action === 'start') await api.request('POST', `/projects/${projectId}/entries/${entryId}/start`);
else if (action === 'cancel') await api.request('POST', `/projects/${projectId}/entries/${entryId}/cancel`);
else if (action === 'retry') await api.request('POST', `/projects/${projectId}/entries/${entryId}/retry`);
await load();
} catch (err: any) { alert(err.message || '操作失败'); }
};
// §2.2 人工构建确认:a_done 提供「确认构建完成 / 构建失败」两按钮,结果传给 /verify
const doVerify = async (entryId: string, buildStatus: 'done' | 'failed') => {
try {
await api.request('POST', `/projects/${projectId}/entries/${entryId}/verify`, { build_status: buildStatus });
await load();
} catch (err: any) { alert(err.message || '启动系统验证失败'); }
};
const doDelete = async (entryId: string, title: string) => {
if (!confirm(`删除条目"${title}"`)) return;
try { await api.request('DELETE', `/projects/${projectId}/entries/${entryId}`); await load(); } catch (err: any) { alert(err.message || '删除失败'); }
};
const doEditSave = async () => {
if (!editEntry) return;
try {
const payload: any = {
title: editEntry.title,
repo_url: editEntry.repo_url,
participant: editEntry.participant,
branch: editEntry.branch,
sub_type: editEntry.sub_type,
service_url: editEntry.service_url,
build_status: editEntry.build_status,
};
if (track === 'L2考核') {
payload.selected_topic = editEntry.selected_topic || 'self';
if (payload.selected_topic === 'self') {
payload.self_registration = { no: editEntry.reg_no, features: editEntry.reg_features };
}
}
await api.request('PUT', `/projects/${projectId}/entries/${editEntry.id}`, payload);
setEditEntry(null);
await load();
} catch (err: any) { alert(err.message || '保存失败'); }
};
const openEdit = (e: any) => {
let reg = { no: '', features: '' };
try { const p = JSON.parse(e.self_registration || '{}'); reg = { no: p.no || '', features: Array.isArray(p.features) ? p.features.join('\n') : (p.features || '') }; } catch { }
setEditEntry({
id: e.id, title: e.title, repo_url: e.repo_url, participant: e.participant || '',
sub_type: e.sub_type || '', branch: e.branch || '',
service_url: e.service_url || '', build_status: e.build_status || '',
selected_topic: e.selected_topic || 'self', reg_no: reg.no, reg_features: reg.features,
});
};
const doAdd = async () => {
if (!addForm.title.trim() || !addForm.repo_url.trim()) return;
const payload: any = { ...addForm };
delete payload.reg_no;
delete payload.reg_features;
if (track === 'L2考核') {
payload.selected_topic = addForm.selected_topic || 'self';
if (payload.selected_topic === 'self') {
payload.self_registration = { no: addForm.reg_no, features: addForm.reg_features };
}
}
try {
await api.request('POST', `/projects/${projectId}/entries`, payload);
setAddForm({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', selected_topic: 'self', reg_no: '', reg_features: '' });
setShowAdd(false);
await load();
} catch (err: any) { alert(err.message || '添加失败'); }
};
const batchStart = async () => {
const ids = Array.from(selected);
if (ids.length === 0) return;
try { await api.request('POST', `/projects/${projectId}/entries/batch-start`, { entryIds: ids }); await load(); } catch (err: any) { alert(err.message || '启动失败'); }
};
const doImport = async () => {
const lines = csvText.trim().split('\n');
if (lines.length < 2) return;
const headers = lines[0].split(',').map(h => h.trim());
const items = lines.slice(1).map(line => {
const vals = line.split(',').map(v => v.trim());
const item: any = {};
headers.forEach((h, i) => { if (vals[i]) item[h] = vals[i]; });
return item;
});
try {
const r = await api.batchImport(projectId, items);
setImportResult(r);
setCsvText('');
if (r.imported > 0) await load();
} catch (err: any) { alert(err.message || '导入失败'); }
};
const downloadTemplate = () => {
const header = ['title', 'repo_url', 'participant', 'branch', 'service_url', 'base_branch'];
if (track === '赛道一') header.push('sub_type');
if (track === 'L2考核') header.push('selected_topic');
const example = header.map(h =>
h === 'repo_url' ? 'https://github.com/user/repo' :
h === 'selected_topic' ? '01' :
h === 'sub_type' ? '新規' : ''
).join(',');
const blob = new Blob(['\uFEFF' + header.join(',') + '\n' + example], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'entry-import-template.csv';
a.click();
URL.revokeObjectURL(a.href);
};
const openDetail = async (eid: string) => {
try { const d = await api.getEntry(projectId, eid); setDetail(d); } catch (err: any) { alert(err.message || '加载详情失败'); }
};
const statusBadge = (s: string) => {
const map: Record<string, string> = {
pending: 'badge-gray', queued: 'badge-amber', cloning: 'badge-blue', analyzing: 'badge-blue',
a_done: 'badge-purple', verifying: 'badge-blue',
review_done: 'badge-green', admin_reviewed: 'badge-green',
clone_fail: 'badge-red', analysis_fail: 'badge-red', failed: 'badge-red', cancelled: 'badge-gray',
};
const label: Record<string, string> = {
pending: '待评审', queued: '排队中', cloning: '克隆中', analyzing: '分析中',
a_done: 'A阶段完成', verifying: '系统验证中',
review_done: '已完成', admin_reviewed: '已修正',
clone_fail: '克隆失败', analysis_fail: '分析失败', failed: '失败', cancelled: '已取消',
};
const cls = map[s] || 'badge-gray';
return <span className={`badge ${cls}`}>{label[s] || s}</span>;
};
const scoreColor = (_s: string, score: number | null | undefined) => {
if (score === null || score === undefined) return 'score-empty';
if (score >= 60) return 'score-ok';
if (score >= 40) return 'score-warn';
return 'score-low';
};
return (
<div>
<div className="section-header">
<h3>评审条目 ({total})</h3>
<div style={{ display: 'flex', gap: 8 }}>
<select value={status} onChange={e => { setStatus(e.target.value); setOffset(0); }} className="filter-select">
<option value="">全部</option>
<option value="pending">待评审</option>
<option value="queued">排队中</option>
<option value="cloning">克隆中</option>
<option value="analyzing">分析中</option>
<option value="a_done">A阶段完成</option>
<option value="verifying">系统验证中</option>
<option value="review_done">已完成</option>
<option value="admin_reviewed">已修正</option>
<option value="clone_fail">克隆失败</option>
<option value="analysis_fail">分析失败</option>
<option value="failed">失败</option>
</select>
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索标题..." className="search-input" onKeyDown={e => e.key === 'Enter' && doSearch(e.currentTarget.value)} />
<button onClick={() => setShowAdd(true)} className="btn-primary">+ 添加条目</button>
<button onClick={batchStart} disabled={selected.size === 0} className="btn-primary">启动选中 ({selected.size})</button>
<button onClick={() => setShowImport(!showImport)} className="btn-secondary">批量导入</button>
</div>
</div>
{showAdd && (
<div className="import-panel" style={{ animation: 'slideDown 0.2s ease-out' }}>
<div className="flex-col gap-10">
<input value={addForm.title} onChange={e => setAddForm({ ...addForm, title: e.target.value })} placeholder="条目标题 *" className="input-field" autoFocus />
<input value={addForm.repo_url} onChange={e => setAddForm({ ...addForm, repo_url: e.target.value })} placeholder="Git仓库URL * (https://github.com/...)" className="input-field" />
<input value={addForm.branch} onChange={e => setAddForm({ ...addForm, branch: e.target.value })} placeholder="分支 (可选,默认 main)" className="input-field" />
<div className="flex gap-8">
<input value={addForm.participant} onChange={e => setAddForm({ ...addForm, participant: e.target.value })} placeholder="参赛者" className="input-field flex-1" />
{track ? (
<span className="badge badge-blue" style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, whiteSpace: 'nowrap' }}>{track}</span>
) : (
<span style={{ padding: '10px 14px', borderRadius: 8, fontSize: 13, background: '#f3f4f6', color: '#9ca3af', whiteSpace: 'nowrap' }}>未设置赛道</span>
)}
</div>
{track === '赛道一' && (
<select value={addForm.sub_type} onChange={e => setAddForm({ ...addForm, sub_type: e.target.value })} className="select-field">
<option value="">选择子类型</option>
<option value="新規">新規开发</option>
<option value="修正">修正/升级</option>
</select>
)}
{track === 'L2考核' && (
<>
<select value={addForm.selected_topic} onChange={e => setAddForm({ ...addForm, selected_topic: e.target.value })} className="select-field">
<option value="self">自选题(需事前登记)</option>
{l2Topics.map(t => (
<option key={t.id} value={t.id}>命题 {t.id}{t.title}{'★'.repeat(t.difficulty)} 满分{t.maxScoreCap}</option>
))}
</select>
{addForm.selected_topic === 'self' && (
<>
<input value={addForm.reg_no} onChange={e => setAddForm({ ...addForm, reg_no: e.target.value })} placeholder="登记编号(组委会登记确认后获得)" className="input-field" />
<textarea value={addForm.reg_features} onChange={e => setAddForm({ ...addForm, reg_features: e.target.value })} placeholder="登记功能清单(每行一条;低于该范围核心功能的缩减按未实现计)" rows={3} className="input-field" />
</>
)}
</>
)}
<div className="form-actions">
<button onClick={doAdd} disabled={!addForm.title.trim() || !addForm.repo_url.trim()}>添加</button>
<button onClick={() => setShowAdd(false)}>取消</button>
</div>
</div>
</div>
)}
{showImport && (
<div className="import-panel">
<textarea value={csvText} onChange={e => setCsvText(e.target.value)} placeholder={'首行为列头(UTF-8 编码,推荐带 BOM):\ntitle,repo_url,participant,branch,service_url\n张三月结,https://gitea/zhang-01,张三,main,'} rows={5} />
<div className="form-actions">
<button onClick={doImport}>导入</button>
<button onClick={downloadTemplate} className="btn-secondary">下载模板</button>
<button onClick={() => setShowImport(false)}>取消</button>
</div>
{importResult && (
<div className="import-result">
成功 {importResult.imported} {importResult.errors?.length > 0 ? `,失败 ${importResult.errors.length} 条` : ''}
{importResult.errors?.slice(0, 5).map((e: any, i: number) => <div key={i} className="error-row">{e.row}行:{e.reason}</div>)}
</div>
)}
</div>
)}
<table className="entry-table">
<thead>
<tr>
<th><input type="checkbox" onChange={e => { if (e.target.checked) setSelected(new Set(entries.map(x => x.id))); else setSelected(new Set()); }} checked={selected.size === entries.length && entries.length > 0} /></th>
<th>标题</th>
<th>参赛者</th>
<th>赛道</th>
<th>状态</th>
<th>分数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && <tr><td colSpan={7} className="empty-row">暂无条目</td></tr>}
{entries.map(e => (
<tr key={e.id} className={e.id === detail?.id ? 'active-row' : ''}>
<td><input type="checkbox" checked={selected.has(e.id)} onChange={() => toggleSelect(e.id)} /></td>
<td className="title-cell" onClick={() => openDetail(e.id)}>{e.title}</td>
<td>{e.participant}</td>
<td>
<span className="badge badge-blue">{e.category_tag}</span>
{e.sub_type && <span className="badge badge-purple ml-8">{e.sub_type}</span>}
{e.selected_topic && <span className="badge badge-gray ml-8">{e.selected_topic === 'self' ? '自选' : `命题 ${e.selected_topic}`}</span>}
{e.final_level && (
<span className={`badge ml-8 ${e.final_level === 'L3' ? 'badge-purple' : (e.final_level === 'L2' || e.final_level === '合格') ? 'badge-green' : 'badge-red'}`}>
{e.final_level}
</span>
)}
</td>
<td>{statusBadge(e.status)}</td>
<td className={`score-cell ${scoreColor(e.status, e.final_score ?? e.raw_score ?? (e.status === 'a_done' ? e.score_a : null))}`}>
{e.status === 'a_done'
? (e.score_a != null ? `A:${e.score_a}` : 'A:-')
: (e.aggregate_count > 0
? `${e.aggregate_score}${e.is_formal ? `(聚合${e.aggregate_count}次)` : `(初评${e.aggregate_count}次)`}`
: (e.final_score ?? e.raw_score ?? '-'))}
</td>
<td className="action-cell">
{e.status === 'pending' && <button className="btn-action" onClick={() => doAction('start', e.id)}>启动</button>}
{e.status === 'pending' && <button className="btn-action" onClick={() => openEdit(e)}>编辑</button>}
{e.status === 'pending' && <button className="btn-action" style={{ borderColor: '#ef4444', color: '#ef4444' }} onClick={() => doDelete(e.id, e.title)}>删除</button>}
{['queued', 'cloning', 'analyzing'].includes(e.status) && <button className="btn-action warn" onClick={() => doAction('cancel', e.id)}>取消</button>}
{e.status === 'a_done' && <button className="btn-action" onClick={() => openEdit(e)}>编辑</button>}
{e.status === 'a_done' && <button className="btn-action" style={{ borderColor: '#10b981', color: '#10b981' }} onClick={() => doVerify(e.id, 'done')}>构建完成</button>}
{e.status === 'a_done' && <button className="btn-action" style={{ borderColor: '#ef4444', color: '#ef4444' }} onClick={() => doVerify(e.id, 'failed')}>构建失败</button>}
{e.status === 'a_done' && <button className="btn-action" onClick={() => openDetail(e.id)}>详情</button>}
{e.status === 'verifying' && <button className="btn-action warn" onClick={() => doAction('cancel', e.id)}>取消</button>}
{['clone_fail', 'analysis_fail', 'failed'].includes(e.status) && <button className="btn-action" onClick={() => doAction('retry', e.id)}>重试</button>}
{['review_done', 'admin_reviewed'].includes(e.status) && <button className="btn-action" onClick={() => openDetail(e.id)}>详情</button>}
{['review_done', 'admin_reviewed'].includes(e.status) && <button className="btn-action" onClick={() => doAction('start', e.id)}>重新评审</button>}
</td>
</tr>
))}
</tbody>
</table>
{total > PAGE_LIMIT && (
<div className="pagination" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8, marginTop: 16 }}>
<button disabled={offset === 0} onClick={() => goPage(offset - PAGE_LIMIT)} className="btn-action" style={{ padding: '6px 14px' }}> 上一页</button>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{Math.floor(offset / PAGE_LIMIT) + 1} / {Math.ceil(total / PAGE_LIMIT)} 页(共 {total} 条)
</span>
<button disabled={offset + PAGE_LIMIT >= total} onClick={() => goPage(offset + PAGE_LIMIT)} className="btn-action" style={{ padding: '6px 14px' }}>下一页 </button>
</div>
)}
{detail && <DetailPanel entry={detail} projectId={projectId} onClose={() => setDetail(null)} onSave={() => { load(); openDetail(detail.id); }} />}
{editEntry && (
<div className="detail-overlay" onClick={() => setEditEntry(null)}>
<div className="detail-panel" onClick={e => e.stopPropagation()} style={{ width: 480 }}>
<div className="detail-header">
<h3>编辑条目</h3>
<button className="btn-close" onClick={() => setEditEntry(null)}>×</button>
</div>
<div className="flex-col gap-12">
<input value={editEntry.title} onChange={e => setEditEntry({ ...editEntry, title: e.target.value })} placeholder="标题" className="input-field" />
<input value={editEntry.repo_url} onChange={e => setEditEntry({ ...editEntry, repo_url: e.target.value })} placeholder="仓库URL" className="input-field" />
<input value={editEntry.branch} onChange={e => setEditEntry({ ...editEntry, branch: e.target.value })} placeholder="分支" className="input-field" />
<input value={editEntry.service_url || ''} onChange={e => setEditEntry({ ...editEntry, service_url: e.target.value })} placeholder="服务地址(B阶段验证用,如 http://8.8.8.8:8080/app" className="input-field" />
<select value={editEntry.build_status || ''} onChange={e => setEditEntry({ ...editEntry, build_status: e.target.value })} className="select-field">
<option value="">构建确认:未设置(系统自动构建)</option>
<option value="done">构建确认:成功(跳过自动构建)</option>
<option value="failed">构建确认:失败(跳过自动构建,注入失败证据)</option>
</select>
<div className="flex gap-8">
<input value={editEntry.participant} onChange={e => setEditEntry({ ...editEntry, participant: e.target.value })} placeholder="参赛者" className="input-field flex-1" />
{track && <span className="badge badge-blue" style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, whiteSpace: 'nowrap' }}>{track}</span>}
</div>
{track === '赛道一' && (
<select value={editEntry.sub_type || ''} onChange={e => setEditEntry({ ...editEntry, sub_type: e.target.value })} className="select-field">
<option value="">选择子类型</option>
<option value="新規">新規开发</option>
<option value="修正">修正/升级</option>
</select>
)}
{track === 'L2考核' && (
<>
<select value={editEntry.selected_topic || 'self'} onChange={e => setEditEntry({ ...editEntry, selected_topic: e.target.value })} className="select-field">
<option value="self">自选题(需事前登记)</option>
{l2Topics.map(t => (
<option key={t.id} value={t.id}>命题 {t.id}{t.title}{'★'.repeat(t.difficulty)} 满分{t.maxScoreCap}</option>
))}
</select>
{editEntry.selected_topic === 'self' && (
<>
<input value={editEntry.reg_no || ''} onChange={e => setEditEntry({ ...editEntry, reg_no: e.target.value })} placeholder="登记编号" className="input-field" />
<textarea value={editEntry.reg_features || ''} onChange={e => setEditEntry({ ...editEntry, reg_features: e.target.value })} placeholder="登记功能清单(每行一条)" rows={3} className="input-field" />
</>
)}
</>
)}
<div className="form-actions">
<button onClick={doEditSave}>保存</button>
<button onClick={() => setEditEntry(null)}>取消</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
function DetailPanel({ entry, projectId, onClose, onSave }: { entry: any; projectId: string; onClose: () => void; onSave: () => void }) {
const [dims, setDims] = useState<any[]>([]);
const [dimsAgg, setDimsAgg] = useState<any>(null);
const [overview, setOverview] = useState('');
const [overall, setOverall] = useState<any>(null);
const [saving, setSaving] = useState(false);
const [editingComment, setEditingComment] = useState<number | null>(null);
const [deliverables, setDeliverables] = useState<any[]>(() => {
try { return JSON.parse(entry.deliverables || '[]'); } catch { return []; }
});
useEffect(() => {
if (entry.ai_report) {
try {
const r = JSON.parse(entry.ai_report);
setDims(r.dimensions || []);
setOverview(r.overview || '');
setOverall(r.overall || null);
} catch { setDims(entry.dimensions || []); }
} else {
setDims(entry.dimensions || []);
}
setDimsAgg((entry as any).dimsAgg || null);
try { const d = JSON.parse(entry.deliverables || '[]'); if (d.length > 0) setDeliverables(d); } catch {}
}, [entry]);
const totalScore = dims.reduce((s: number, d: any) => s + (Number(d.score) || 0), 0);
const maxTotal = dims.reduce((s: number, d: any) => s + (d.maxScore || 0), 0);
const pct = maxTotal > 0 ? Math.round((totalScore / maxTotal) * 100) : 0;
// Old ai_report dimensions lack `group` → derive from standard_snapshot dims by name
const stdGroupMap: Record<string, string> = {};
for (const d of (entry.dimensions || [])) stdGroupMap[d.name] = d.group || 'common';
const dimGroup = (d: any) => d.group || stdGroupMap[d.name] || 'common';
const l2Dims = dims.filter((d: any) => dimGroup(d) === 'common');
const l3Dims = dims.filter((d: any) => dimGroup(d) !== 'common');
const l2Score = l2Dims.reduce((s, d) => s + (Number(d.score) || 0), 0);
const l2Max = l2Dims.reduce((s, d) => s + (d.maxScore || 0), 0);
const l3Score = l3Dims.reduce((s, d) => s + (Number(d.score) || 0), 0);
const l3Max = l3Dims.reduce((s, d) => s + (d.maxScore || 0), 0);
// 维度级聚合历史(2026-08-19):dimsAgg.perRun 含各次维度分
const dimHistory = (name: string): string => {
if (!dimsAgg?.perRun?.length) return '';
const scores = dimsAgg.perRun
.map((r: any) => r.dims?.find((d: any) => d.name === name)?.score)
.filter((s: any) => s !== undefined && s !== null);
return scores.length > 1 ? `(历次 ${scores.join(' / ')}` : '';
};
const finalLevel = entry.final_level || '';
const updateDim = (i: number, field: string, value: any) => {
const next = [...dims];
(next[i] as any)[field] = value;
setDims(next);
};
const saveDeliverables = async () => {
try {
await api.request('PUT', `/projects/${projectId}/entries/${entry.id}/deliverables`, { deliverables });
} catch {}
};
const toggleDeliverable = (idx: number) => {
const next = [...deliverables];
next[idx] = { ...next[idx], submitted: !next[idx].submitted };
setDeliverables(next);
saveDeliverables();
};
const saveReport = async () => {
setSaving(true);
try {
await api.request('PUT', `/projects/${projectId}/entries/${entry.id}/report`, { dimensions: dims });
onSave();
} catch (err: any) { alert(err.message); }
setSaving(false);
};
return (
<div className="detail-overlay" onClick={onClose}>
<div className="detail-panel" onClick={e => e.stopPropagation()}>
<div className="detail-header">
<h3>{entry.title}</h3>
<button className="btn-close" onClick={onClose}></button>
</div>
<div className="detail-meta">
<div>仓库:<code>{entry.repo_url}</code></div>
<div>参赛者:{entry.participant || '-'} | 及格线:{entry.pass_line || '-'}{entry.selected_topic ? ` | 选题:${entry.selected_topic === 'self' ? '自选题' : `命题 ${entry.selected_topic}`}` : ''}</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
{finalLevel && (
<span className={`badge ${finalLevel === 'L3' ? 'badge-purple' : finalLevel === 'L2' ? 'badge-green' : 'badge-red'}`}>
{finalLevel === 'L3' ? '🏆 L3合格' : finalLevel === 'L2' ? '✅ L2合格' : '❌ 不合格'}
</span>
)}
<span style={{ fontSize: 15, fontWeight: 600, color: finalLevel === 'L3' ? '#8b5cf6' : pct >= (entry.pass_line || 60) ? '#10b981' : '#ef4444' }}>
{l3Max > 0 ? `L2: ${l2Score}/${l2Max} | L3: ${l3Score}/${l3Max} | 总分: ${totalScore}/${maxTotal}${pct}%` : `得分:${totalScore}/${maxTotal}${pct}%`}
</span>
{entry.late_days > 0 && <span style={{ color: '#f59e0b' }}>迟交 {entry.late_days} </span>}
</div>
{entry.score_a != null && (entry.score_b != null || entry.status === 'a_done' || entry.status === 'verifying') && (
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
A阶段 {entry.score_a}
{entry.score_b != null && ` · B阶段 ${entry.score_b} 分`}
{entry.stage_b_status && entry.stage_b_status !== 'pending' && entry.stage_b_status !== 'done' && (
<span style={{ color: entry.stage_b_status === 'failed' ? '#ef4444' : '#f59e0b', marginLeft: 8 }}>
· B阶段状态{entry.stage_b_status === 'failed' ? '验证失败' : entry.stage_b_status === 'skipped' ? '已跳过' : entry.stage_b_status}
</span>
)}
</div>
)}
{entry.attempt > 1 && <div className="retake-badge"> {entry.attempt} 次提交</div>}
</div>
{entry.plagiarism_json && (() => {
let p: any = null;
try { p = JSON.parse(entry.plagiarism_json); } catch { }
if (!p || !p.flags || p.flags.length === 0) return null;
return (
<div className="overview-section" style={{ paddingLeft: 12, borderLeft: '3px solid #f59e0b', marginBottom: 12 }}>
<h4 style={{ marginBottom: 8, color: '#b45309' }}>查重初筛告警(需人工复核,不构成违规认定)</h4>
{p.flags.map((f: any, i: number) => (
<div key={i} style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 6, marginLeft: 12 }}>
<span className={`badge ${f.severity === 'high' ? 'badge-red' : 'badge-amber'} mr-8`}>{f.kind === 'identicalFile' ? '相同文件' : f.kind === 'highSimilarity' ? '高相似源码' : '提交行为'}</span>
{f.detail}
</div>
))}
</div>
);
})()}
{(overview || overall) && (
<div className="overview-section" style={{ paddingLeft: 12 }}>
<h4 style={{ marginBottom: 8 }}>整体评价</h4>
{overview && (
<div style={{ marginBottom: 8 }}>
<div style={{ color: 'var(--primary, #6aa1f7)', fontWeight: 600, marginBottom: 4 }}>项目总览</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginLeft: 12, marginBottom: 4, lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>{overview}</div>
</div>
)}
{overall && Array.isArray(overall.highlights) && overall.highlights.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ color: 'var(--success, #2e7d32)', fontWeight: 600, marginBottom: 4 }}>核心亮点点评</div>
{overall.highlights.map((h: any, i: number) => (
<div key={i} style={{ fontSize: 13, color: 'var(--text-secondary)', marginLeft: 12, marginBottom: 4 }}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{h.point}</span>
{h.review ? <span style={{ opacity: 0.85 }}> —— {h.review}</span> : null}
</div>
))}
</div>
)}
{overall && Array.isArray(overall.weaknesses) && overall.weaknesses.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ color: 'var(--danger, #c62828)', fontWeight: 600, marginBottom: 4 }}>主要不足点评</div>
{overall.weaknesses.map((w: any, i: number) => (
<div key={i} style={{ fontSize: 13, color: 'var(--text-secondary)', marginLeft: 12, marginBottom: 4 }}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{w.point}</span>
{w.review ? <span style={{ opacity: 0.85 }}> —— {w.review}</span> : null}
</div>
))}
</div>
)}
{overall && overall.verdict && <p style={{ lineHeight: 1.7, color: 'var(--text-secondary)', fontSize: 14, whiteSpace: 'pre-wrap' }}>{overall.verdict}</p>}
</div>
)}
{entry.progress_log && (() => {
try {
const logs = JSON.parse(entry.progress_log);
if (!Array.isArray(logs) || logs.length === 0) return null;
return (
<details className="history-section" style={{ marginBottom: 16 }}>
<summary>评审进度({logs.length} 步)</summary>
{logs.map((l: any, i: number) => (
<div key={i} className="history-item">
<div style={{ fontSize: 12, color: '#666' }}>
{l.time ? new Date(l.time).toLocaleString() : ''}{l.status ? ` · ${l.status}` : ''}
</div>
{l.msg && <div style={{ fontSize: 12 }}>{l.msg}</div>}
</div>
))}
</details>
);
} catch { return null; }
})()}
{/* deliverables checklist */}
<div style={{ marginBottom: 16, padding: 16, background: 'var(--bg-subtle)', borderRadius: 'var(--radius)', border: '1px solid var(--border)' }}>
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>成果物确认</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(deliverables.length > 0 ? deliverables : DEFAULT_DELIVERABLES).map((d: any, i: number) => (
<label key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input type="checkbox" checked={d.submitted} onChange={() => toggleDeliverable(i)} />
<span style={{ textDecoration: d.submitted ? 'line-through' : 'none', color: d.submitted ? 'var(--success)' : 'var(--text)' }}>
{d.name}
</span>
{d.required && <span style={{ color: 'var(--danger)', fontSize: 11 }}>(必须)</span>}
{!d.required && <span style={{ color: 'var(--muted)', fontSize: 11 }}>(可选)</span>}
</label>
))}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
已提交 {deliverables.filter((d: any) => d.submitted).length}/{deliverables.length || DEFAULT_DELIVERABLES.length}
{deliverables.filter((d: any) => d.required && !d.submitted).length > 0 && (
<span style={{ color: 'var(--danger)', marginLeft: 8 }}>
缺少 {deliverables.filter((d: any) => d.required && !d.submitted).length} 项必须成果物
</span>
)}
</div>
</div>
{dims.length > 0 && <RadarChart dims={dims} size={360} />}
{l2Dims.length > 0 && (
<div style={{ marginBottom: 16 }}>
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: 'var(--text)' }}>L2共通评分{l2Score}/{l2Max}</h4>
<div style={{ overflowX: 'auto' }}>
<table className="detail-dims">
<thead><tr><th>评审项</th><th style={{ textAlign: 'center' }}>得分</th><th>评语</th><th>建议</th></tr></thead>
<tbody>
{l2Dims.map((d, i) => {
const idx = dims.indexOf(d);
return (
<tr key={i}>
<td>
<div className="dim-name">{d.name}</div>
{dimHistory(d.name) && <div style={{ fontSize: 11, color: 'var(--text-secondary)', opacity: 0.75 }}>{dimHistory(d.name)}</div>}
<DimBar score={Number(d.score) || 0} max={d.maxScore} />
</td>
<td style={{ textAlign: 'center', verticalAlign: 'middle' }}>
<input type="number" value={Number(d.score) || 0} min={0} max={d.maxScore} onChange={e => updateDim(idx, 'score', e.target.value === '' ? 0 : Number(e.target.value))} className="score-input" />
<span className="score-max">满分 {d.maxScore}</span>
</td>
<td>
{editingComment === idx ? (
<textarea value={d.comment || ''} onChange={e => updateDim(idx, 'comment', e.target.value)} onBlur={() => setEditingComment(null)} autoFocus rows={3} className="comment-input" placeholder="评语" />
) : (
<span className={`dim-comment-btn ${d.comment ? '' : 'is-empty'}`} onClick={() => setEditingComment(idx)} title="点击编辑评语">{d.comment || '点击填写评语'}</span>
)}
{d.verifiability?.note && <div style={{ fontSize: 11, color: 'var(--text-secondary)', opacity: 0.8, marginTop: 4 }}>{d.verifiability.note}</div>}
</td>
<td><div className="suggestion-cell">{d.suggestion || '-'}</div></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{l3Dims.length > 0 && (
<div style={{ marginBottom: 16 }}>
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: '#8b5cf6' }}>L3追加评分{l3Score}/{l3Max}</h4>
<div style={{ overflowX: 'auto' }}>
<table className="detail-dims">
<thead><tr><th>评审项</th><th style={{ textAlign: 'center' }}>得分</th><th>评语</th><th>建议</th></tr></thead>
<tbody>
{l3Dims.map((d, i) => {
const idx = dims.indexOf(d);
return (
<tr key={i}>
<td>
<div className="dim-name" style={{ color: '#8b5cf6' }}>{d.name}</div>
{dimHistory(d.name) && <div style={{ fontSize: 11, color: 'var(--text-secondary)', opacity: 0.75 }}>{dimHistory(d.name)}</div>}
<DimBar score={Number(d.score) || 0} max={d.maxScore} />
</td>
<td style={{ textAlign: 'center', verticalAlign: 'middle' }}>
<input type="number" value={Number(d.score) || 0} min={0} max={d.maxScore} onChange={e => updateDim(idx, 'score', e.target.value === '' ? 0 : Number(e.target.value))} className="score-input" />
<span className="score-max">满分 {d.maxScore}</span>
</td>
<td>
{editingComment === idx ? (
<textarea value={d.comment || ''} onChange={e => updateDim(idx, 'comment', e.target.value)} onBlur={() => setEditingComment(null)} autoFocus rows={3} className="comment-input" placeholder="评语" />
) : (
<span className={`dim-comment-btn ${d.comment ? '' : 'is-empty'}`} onClick={() => setEditingComment(idx)} title="点击编辑评语">{d.comment || '点击填写评语'}</span>
)}
{d.verifiability?.note && <div style={{ fontSize: 11, color: 'var(--text-secondary)', opacity: 0.8, marginTop: 4 }}>{d.verifiability.note}</div>}
</td>
<td><div className="suggestion-cell">{d.suggestion || '-'}</div></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{entry.snapshots?.length > 0 && (
<details className="history-section">
<summary>评审快照({entry.snapshots.length} 次)</summary>
{entry.snapshots.map((s: any) => (
<div key={s.id} className="history-item">
{s.attempt} · {new Date(s.created_at).toLocaleString()}
</div>
))}
</details>
)}
{entry.revisions?.length > 0 && (
<details className="history-section">
<summary>修正历史({entry.revisions.length} 次)</summary>
{entry.revisions.map((r: any) => {
let oldScores = '', newScores = '';
try { oldScores = JSON.parse(r.comments || '[]').map((x: any) => `${x.name}:${x.score}`).join('; '); } catch {}
try { newScores = JSON.parse(r.scores || '[]').map((x: any) => `${x.name}:${x.score}`).join('; '); } catch {}
return (
<div key={r.id} className="history-item">
<div style={{ fontSize: 12, color: '#666' }}>{new Date(r.created_at).toLocaleString()}</div>
{oldScores && <div style={{ fontSize: 12 }}>修正前:{oldScores}</div>}
{newScores && <div style={{ fontSize: 12 }}>修正后:{newScores}</div>}
</div>
);
})}
</details>
)}
<div className="detail-actions">
<button onClick={() => downloadPdf(`/api/projects/${projectId}/entries/${entry.id}/report/export`).catch(() => {})} className="btn-secondary" title="下载PDF报告">下载报告</button>
<button onClick={saveReport} disabled={saving} className="btn-primary">{saving ? '保存中...' : '保存修正'}</button>
</div>
</div>
</div>
);
}
function DimBar({ score, max }: { score: number; max: number }) {
const pct = max > 0 ? Math.max(0, Math.min(100, (Number(score) / max) * 100)) : 0;
const color = pct >= 80 ? '#10b981' : pct >= 60 ? '#6366f1' : pct >= 40 ? '#f59e0b' : '#ef4444';
return (
<div className="dim-bar-row">
<div className="dim-bar">
<div className="dim-bar-fill" style={{ width: `${pct}%`, background: color }} />
</div>
<span className="dim-pct">{Math.round(pct)}%</span>
</div>
);
}
function RadarChart({ dims, size = 260 }: { dims: any[]; size?: number }) {
const n = dims.length;
if (n === 0) return null;
const cx = size / 2, cy = size / 2, r = size * 0.38;
const labelR = r + size * 0.07;
const fontSize = size <= 280 ? 10 : 11;
const angle = (i: number) => (2 * Math.PI * i) / n - Math.PI / 2;
const pt = (i: number, radius: number) => {
const a = angle(i);
return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) };
};
const scorePts = dims.map((d, i) => {
const pct = Math.max(0, Math.min(1, d.maxScore > 0 ? Number(d.score) / d.maxScore : 0));
const p = pt(i, r * pct);
return `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`;
}).join(' ') + ' Z';
return (
<div style={{ textAlign: 'center', margin: '16px 0' }}>
<h4 style={{ marginBottom: 8, color: '#666', fontSize: 13 }}>维度评分</h4>
<svg width={size + 20} height={size + 20} viewBox={`0 0 ${size} ${size}`} style={{ maxWidth: '100%' }}>
<g>
{[25, 50, 75, 100].map(pct => {
const rr = r * pct / 100;
const pts = Array.from({ length: n }, (_, i) => pt(i, rr)).map(p => `${p.x},${p.y}`).join(' ');
return <g key={pct}>
<polygon points={pts} fill="none" stroke="#e5e7eb" strokeWidth="1" strokeDasharray="3,3" />
<text x={cx} y={cy - rr} fontSize="9" fill="#999" textAnchor="middle" dominantBaseline="middle">{pct}%</text>
</g>;
})}
{Array.from({ length: n }, (_, i) => {
const p = pt(i, r);
return <line key={`ax-${i}`} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#e5e7eb" strokeWidth="1" />;
})}
{dims.map((d, i) => {
const pct = Math.max(0, Math.min(1, d.maxScore > 0 ? Number(d.score) / d.maxScore : 0));
const p = pt(i, r * pct);
return <circle key={`dot-${i}`} cx={p.x} cy={p.y} r="3" fill="#4f46e5" />;
})}
{dims.map((d, i) => {
const p = pt(i, labelR);
const anchor = p.x > cx + 5 ? 'start' : p.x < cx - 5 ? 'end' : 'middle';
return <text key={`lb-${i}`} x={p.x} y={p.y} fontSize={fontSize} fill="#333" textAnchor={anchor} dominantBaseline="middle">{d.name}</text>;
})}
</g>
<path d={scorePts} fill="rgba(79,70,229,0.15)" stroke="#4f46e5" strokeWidth="2" />
</svg>
</div>
);
}
function BarChart({ entries, title }: { entries: any[]; title?: string }) {
if (entries.length === 0) return null;
const barH = 28, gap = 8, labelW = 160, chartW = 400;
const h = entries.length * (barH + gap) + 30;
const maxScore = Math.max(...entries.map(e => e.score || 0));
const bars = entries.map((e, i) => {
const y = 30 + i * (barH + gap);
const w = maxScore > 0 ? ((e.score || 0) / maxScore) * chartW : 0;
const color = e.passed ? '#10b981' : '#ef4444';
return (
<g key={e.id}>
<text x={labelW - 6} y={y + barH / 2} fontSize="11" fill="#333" textAnchor="end" dominantBaseline="middle">{e.title}</text>
<rect x={labelW} y={y} width={Math.max(w, 2)} height={barH} rx={4} fill={color} opacity={0.85} />
<text x={labelW + w + 4} y={y + barH / 2} fontSize="11" fill={color} dominantBaseline="middle">{e.score}</text>
</g>
);
});
return (
<div style={{ textAlign: 'center', margin: '20px 0', overflowX: 'auto' }}>
{title && <h4 style={{ marginBottom: 8, color: '#666', fontSize: 13 }}>{title}</h4>}
<svg width={Math.min(labelW + chartW + 60, 800)} height={h} viewBox={`0 0 ${Math.min(labelW + chartW + 60, 800)} ${h}`} style={{ maxWidth: '100%' }}>
<text x={labelW} y="16" fontSize="12" fill="#999">分数分布</text>
{bars}
</svg>
</div>
);
}
const DEFAULT_DELIVERABLES = [
{ name: '源代码', required: true, submitted: false },
{ name: 'README', required: true, submitted: false },
{ name: '设计文档', required: true, submitted: false },
{ name: '测试用例与测试结果', required: true, submitted: false },
{ name: 'AGENTS.md', required: true, submitted: false },
{ name: '样本数据', required: true, submitted: false },
{ name: '演示录屏', required: false, submitted: false },
];
function DeliverablesView({ projectId }: { projectId: string }) {
const [entries, setEntries] = useState<any[]>([]);
const [deliverableMap, setDeliverableMap] = useState<Record<string, any[]>>({});
const [loading, setLoading] = useState(false);
const loadAll = async () => {
setLoading(true);
try {
const r = await api.request<{ items: any[] }>('GET', `/projects/${projectId}/entries?limit=250`);
const items = r.items || [];
setEntries(items);
const map: Record<string, any[]> = {};
for (const e of items) {
let d: any[] = [];
try { d = JSON.parse(e.deliverables || '[]'); } catch {}
if (d.length === 0) {
d = DEFAULT_DELIVERABLES.map(x => ({ ...x, submitted: false }));
}
map[e.id] = d;
}
setDeliverableMap(map);
} catch {}
setLoading(false);
};
useEffect(() => { loadAll(); }, [projectId]);
// Initialize all entries with default deliverables
const initAll = async () => {
try {
const r = await fetch(`/api/projects/${projectId}/entries/deliverables/init`, {
method: 'PUT',
credentials: 'include'
});
const result = await r.json();
console.log('Initialized:', result.initialized);
} catch {}
await loadAll();
};
// Toggle a single deliverable
const toggle = async (entryId: string, idx: number) => {
const d = [...(deliverableMap[entryId] || [])];
d[idx] = { ...d[idx], submitted: !d[idx].submitted };
setDeliverableMap({ ...deliverableMap, [entryId]: d });
try {
await fetch(`/api/projects/${projectId}/entries/${entryId}/deliverables`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ deliverables: d })
});
} catch {}
};
// Calculate summary
const colNames = DEFAULT_DELIVERABLES.map(d => d.name);
const summary = DEFAULT_DELIVERABLES.map(d => {
let submitted = 0, total = 0;
for (const eid of Object.keys(deliverableMap)) {
const items = deliverableMap[eid] || [];
const found = items.find((x: any) => x.name === d.name);
if (found) { total++; if (found.submitted) submitted++; }
}
return { ...d, submitted, total };
});
const totalRequired = summary.filter(s => s.required).reduce((s, x) => s + x.total, 0);
const totalSubmitted = summary.filter(s => s.required).reduce((s, x) => s + x.submitted, 0);
const rate = totalRequired > 0 ? Math.round((totalSubmitted / totalRequired) * 100) : 0;
if (loading && entries.length === 0) return <div className="loading">加载中...</div>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div>
<h3 style={{ margin: 0 }}>成果物确认</h3>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4 }}>
{entries.length}个条目 · 提交率 {rate}%{totalSubmitted}/{totalRequired}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={initAll} className="btn-secondary">初始化一覧</button>
<button onClick={() => downloadPdf(`/api/projects/${projectId}/entries/deliverables/export`).catch(() => {})} className="btn-secondary">下载CSV</button>
</div>
</div>
{/* Summary cards */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
{summary.map((s: any) => (
<div key={s.name} style={{ padding: '8px 14px', background: 'var(--card)', borderRadius: 'var(--radius)', border: '1px solid var(--border)', fontSize: 13 }}>
<div style={{ fontWeight: 600, fontSize: 12 }}>{s.name}</div>
<div style={{ color: s.submitted === s.total ? 'var(--success)' : 'var(--danger)', fontSize: 15, fontWeight: 700 }}>
{s.submitted}/{s.total}
</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{s.required ? '必须' : '可选'}</div>
</div>
))}
</div>
{/* Checklist table */}
<div className="deliverables-scroll" style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: 'calc(100vh - 430px)' }}>
<table className="entry-table" style={{ minWidth: 1000 }}>
<thead>
<tr>
<th style={{ position: 'sticky', left: 0, top: 0, background: 'var(--bg-subtle)', zIndex: 2, width: 130 }}>参赛者</th>
<th style={{ position: 'sticky', left: 130, top: 0, background: 'var(--bg-subtle)', zIndex: 2, width: 200 }}>标题</th>
{colNames.map((name, ci) => (
<th key={ci} style={{ position: 'sticky', top: 0, fontSize: 11, textAlign: 'center', minWidth: 80 }}>
{name.replace('与测试结果', '')}
<div style={{ fontSize: 10, color: 'var(--muted)', fontWeight: 400 }}>
{summary[ci]?.submitted}/{summary[ci]?.total}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{entries.map((e) => {
const d = deliverableMap[e.id] || [];
const hasDetected = d.some((x: any) => x.submitted);
return (
<tr key={e.id} style={{ opacity: d.length === 0 ? 0.5 : 1 }}>
<td style={{ position: 'sticky', left: 0, background: 'var(--card)', zIndex: 1, fontWeight: 500, width: 130 }}>
{e.participant || e.title.substring(0, 8)}
</td>
<td style={{ position: 'sticky', left: 130, background: 'var(--card)', zIndex: 1, fontSize: 12, width: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{e.title}
{hasDetected && (
<span style={{ marginLeft: 6, fontSize: 10, color: 'var(--success)', border: '1px solid var(--success)', borderRadius: 4, padding: '0 4px', flexShrink: 0 }}>
已检测
</span>
)}
</td>
{colNames.map((name, ci) => {
const item = d.find((x: any) => x.name === name);
const checked = item?.submitted || false;
const isReq = DEFAULT_DELIVERABLES[ci]?.required;
return (
<td key={ci} style={{ textAlign: 'center' }}>
<input
type="checkbox"
checked={checked}
onChange={() => {
const idx = d.findIndex((x: any) => x.name === name);
if (idx >= 0) toggle(e.id, idx);
}}
style={{ cursor: 'pointer', width: 16, height: 16, accentColor: isReq ? 'var(--success)' : undefined }}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{entries.length === 0 && (
<div className="empty" style={{ padding: '60px 40px' }}>
<div style={{ fontSize: 32, marginBottom: 16 }}>📦</div>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 8 }}>暂无条目</div>
<div style={{ fontSize: 14, color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto', lineHeight: 1.7 }}>
请先在「条目」标签页中添加参赛条目。<br />
添加后返回此页,点击「初始化一覧」按钮为所有条目设置成果物清单,然后逐一确认提交状态。
</div>
</div>
)}
{entries.length > 0 && totalRequired === 0 && (
<div className="empty" style={{ padding: '40px 40px', marginTop: 16 }}>
<div style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 12 }}>
条目已存在,但尚未初始化成果物清单。
</div>
<button onClick={initAll} className="btn-primary" style={{ padding: '8px 24px' }}>初始化一覧</button>
</div>
)}
</div>
);
}
function SummaryView({ projectId }: { projectId: string }) {
const [summary, setSummary] = useState<any>(null);
useEffect(() => { api.request('GET', `/projects/${projectId}/summary`).then(setSummary).catch(() => {}); }, [projectId]);
// L2考核 按 final_level(合格/不合格)展示认定结果,其余赛道按是否达线展示
const isL2Cat = (c: string) => c === 'L2考核';
if (!summary) return <div className="loading">加载中...</div>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>汇总排名</h3>
<button onClick={() => downloadPdf(`/api/projects/${projectId}/summary/export`).catch(() => {})} className="btn-secondary">下载汇总PDF</button>
</div>
{/* Category groups */}
{summary.categories?.map((cat: any) => {
const showLevel = isL2Cat(cat.category);
return (
<div key={cat.category} className="summary-section">
<h4>{cat.category} · {cat.entries.length}个条目</h4>
<table className="entry-table">
<thead><tr><th>排名</th><th>标题</th><th>参赛者</th><th>得分</th><th>及格线</th><th>{showLevel ? '认定' : '结果'}</th></tr></thead>
<tbody>
{cat.entries.map((e: any) => (
<tr key={e.id}>
<td className="rank">#{e.rank}</td>
<td>{e.title}</td>
<td>{e.participant}</td>
<td style={{ fontWeight: 600 }}>{e.score}{e.aggregate_count > 0 ? (e.is_formal ? `(聚合${e.aggregate_count}次)` : `(初评${e.aggregate_count}次)`) : ''}</td>
<td>{e.pass_line}</td>
<td>
{showLevel && e.final_level ? (
<span className={`badge ${e.final_level === '合格' || e.final_level === 'L2' ? 'badge-green' : e.final_level === 'L3' ? 'badge-purple' : 'badge-red'}`}>
{e.final_level}
</span>
) : (
e.passed ? <span className="pass"> 通过</span> : <span className="fail"> 未达线</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{cat.entries.length > 1 && <BarChart entries={cat.entries} />}
</div>
);
})}
{/* Participant summary */}
{summary.participants?.length > 0 && (
<>
<h4 style={{ marginTop: 24 }}>参赛者合格判定</h4>
<table className="entry-table">
<thead><tr><th>参赛者</th><th>题目</th><th>得分</th><th>及格线</th><th>总评</th></tr></thead>
<tbody>
{summary.participants.map((p: any) => (
<tr key={p.participant}>
<td><strong>{p.participant}</strong></td>
<td>{p.entries.map((e: any) => e.title).join(', ')}</td>
<td>{p.entries.map((e: any) => e.score).join(' / ')}</td>
<td>{p.entries.map((e: any) => e.pass_line).join(' / ')}</td>
<td>{p.passed ? <span className="pass"> 通过</span> : <span className="fail"> 未通过</span>}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
);
}