- 文档重构为 考核目的(1.x)/提交要求(2.x,含成果物需求)/考核内容(3.x) 板块编号体系 - 删除公开评分细则(7维表/合格线/难度赋分/功能拆分),保留验收基准与提交要求 - 自选题取消事前登记,改由 AGENTS.md 记录核心内容;前端同步移除登记字段 - 新增 l2-participants 注册表服务与 teams-config L2 URL 自动映射 - 新增 startReviewRounds 一键N轮评审(自动续跑+快照聚合中位数),前端「重新评审×3」 - 新增 check-repos.mjs 提交状态探测(API四态判定:已提交/空仓/未创建/未授权) - 文档措辞与实现对齐(AI辅助评审)、修复引用/残片/格式问题
1267 lines
65 KiB
TypeScript
1267 lines
65 KiB
TypeScript
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' });
|
||
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, body: any = {}) => {
|
||
try {
|
||
if (action === 'start') await api.request('POST', `/projects/${projectId}/entries/${entryId}/start`, { rounds: 3, ...body });
|
||
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';
|
||
}
|
||
await api.request('PUT', `/projects/${projectId}/entries/${editEntry.id}`, payload);
|
||
setEditEntry(null);
|
||
await load();
|
||
} catch (err: any) { alert(err.message || '保存失败'); }
|
||
};
|
||
|
||
const openEdit = (e: any) => {
|
||
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',
|
||
});
|
||
};
|
||
|
||
const doAdd = async () => {
|
||
if (!addForm.title.trim() || !addForm.repo_url.trim()) return;
|
||
const payload: any = { ...addForm };
|
||
if (track === 'L2考核') {
|
||
payload.selected_topic = addForm.selected_topic || 'self';
|
||
}
|
||
try {
|
||
await api.request('POST', `/projects/${projectId}/entries`, payload);
|
||
setAddForm({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', selected_topic: 'self' });
|
||
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>
|
||
)}
|
||
<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)}>重新评审×3</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>
|
||
)}
|
||
<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>
|
||
);
|
||
}
|