初始提交:ai-review 项目当前版本(含赛道一/二提交规范修订与时间节点文档)

This commit is contained in:
hangshuo652
2026-08-23 11:52:45 +08:00
commit 4da7044c4b
152 changed files with 33490 additions and 0 deletions
+1270
View File
@@ -0,0 +1,1270 @@
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: '', question_id: '' });
const [offset, setOffset] = useState(0);
const [questionFilter, setQuestionFilter] = useState('');
const PAGE_LIMIT = 50;
const latestSearchRef = useRef('');
const doSearch = (term: string, pageOffset = offset, qFilter = questionFilter) => {
latestSearchRef.current = term;
const params: any = { limit: PAGE_LIMIT, offset: pageOffset };
if (status) params.status = status;
if (term) params.search = term;
if (qFilter) params.question_id = qFilter;
api.listEntries(projectId, params).then(r => {
if (latestSearchRef.current === term) {
setEntries(r.items);
setTotal(r.total);
}
}).catch(() => {});
};
const load = () => doSearch(search, offset, questionFilter);
const goPage = (newOffset: number) => {
setOffset(newOffset);
setTimeout(() => doSearch(search, newOffset, questionFilter), 0);
};
useEffect(() => { setOffset(0); doSearch(search, 0, questionFilter); }, [projectId, status, search, questionFilter]);
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 {
await api.request('PUT', `/projects/${projectId}/entries/${editEntry.id}`, {
title: editEntry.title,
repo_url: editEntry.repo_url,
participant: editEntry.participant,
branch: editEntry.branch,
sub_type: editEntry.sub_type,
question_id: editEntry.question_id,
service_url: editEntry.service_url,
build_status: editEntry.build_status,
});
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 || '', question_id: e.question_id || '', branch: e.branch || '',
service_url: e.service_url || '', build_status: e.build_status || '',
});
const doAdd = async () => {
if (!addForm.title.trim() || !addForm.repo_url.trim()) return;
try {
await api.request('POST', `/projects/${projectId}/entries`, addForm);
setAddForm({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', question_id: '' });
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 === '人才测评') header.push('question_id');
const example = header.map(h =>
h === 'repo_url' ? 'https://github.com/user/repo' :
h === 'question_id' ? 'Q1' :
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>
{track === '人才测评' && (
<select value={questionFilter} onChange={e => { setQuestionFilter(e.target.value); setOffset(0); }} className="filter-select">
<option value=""></option>
<option value="Q1">Q1 </option>
<option value="Q2">Q2 </option>
<option value="Q3">Q3 RAG检索</option>
<option value="Q4">Q4 </option>
<option value="Q5">Q5 </option>
<option value="Q6">Q6 </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 === '人才测评' && (
<>
<select value={addForm.question_id} onChange={e => setAddForm({ ...addForm, question_id: e.target.value })} className="select-field">
<option value=""> *</option>
<option value="Q1">Q1 L2のみ</option>
<option value="Q2">Q2 L3可</option>
<option value="Q3">Q3 RAG检索 L3可</option>
<option value="Q4">Q4 L3可</option>
<option value="Q5">Q5 L3可</option>
<option value="Q6">Q6 L3可</option>
</select>
{addForm.question_id && (
<div style={{ fontSize: 12, color: 'var(--text-secondary)', padding: '4px 2px' }}>
{addForm.question_id === 'Q1' ? '仅L2评审(共通100分)' : 'L2共通100分 + L3追加评审'}
</div>
)}
</>
)}
<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.question_id && <span className="badge badge-gray ml-8">{e.question_id}</span>}
{e.final_level && (
<span className={`badge ml-8 ${e.final_level === 'L3' ? 'badge-purple' : e.final_level === 'L2' ? '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 === '人才测评' && (
<>
<select value={editEntry.question_id || ''} onChange={e => setEditEntry({ ...editEntry, question_id: e.target.value })} className="select-field">
<option value=""> *</option>
<option value="Q1">Q1 L2のみ</option>
<option value="Q2">Q2 L3可</option>
<option value="Q3">Q3 RAG检索 L3可</option>
<option value="Q4">Q4 L3可</option>
<option value="Q5">Q5 L3可</option>
<option value="Q6">Q6 L3可</option>
</select>
{editEntry.question_id && (
<div style={{ fontSize: 12, color: 'var(--text-secondary)', padding: '4px 2px' }}>
{editEntry.question_id === 'Q1' ? '仅L2评审(共通100分)' : 'L2共通100分 + L3追加评审'}
</div>
)}
</>
)}
<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.question_id ? ` | 选题:${entry.question_id}` : ''}</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>
{(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]);
const isTalent = summary?.categories?.some((c: any) => /^Q\d$/.test(c.category));
if (!summary) return <div className="loading">...</div>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>{isTalent ? '(人才测评)' : ''}</h3>
<button onClick={() => downloadPdf(`/api/projects/${projectId}/summary/export`).catch(() => {})} className="btn-secondary">PDF</button>
</div>
{/* Category groups */}
{summary.categories?.map((cat: any) => {
const isQ = /^Q\d$/.test(cat.category);
const showLevel = isQ || cat.category === '人才测评';
return (
<div key={cat.category} className="summary-section">
<h4>
{isQ ? (
<><span className="badge badge-blue" style={{ marginRight: 6 }}>{cat.category}</span> {cat.entries.length}</>
) : (
cat.category
)}
</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 === 'L3' ? 'badge-purple' : e.final_level === 'L2' ? 'badge-green' : '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>
);
}