评审真实性增强:前端硬规则+人工启动方案+文档-实现三级校验

- 前端存在性硬规则:detectFrontend 源码级判定,web形态无真实前端/不可访问 → 实现完整度封顶50%(CLI/插件豁免)
- verify 多态:build_status 支持 done/failed/done_no_ui,前端按钮新增「无前端」
- 人工启动方案:评审环境自动启动失败 → awaiting_browse 暂停,评委手动启动后填 service_url 走 /verify-browse 恢复(SSRF 信任评委确认地址)
- Gitea 稳定性:clone 自动重试×3(525抖动)+ 每队独立 token 回退(全局账号失效兜底)
- A 阶段报告备份:feature_inventory.__stageA_report 防 B 阶段维度重复累积/A维度丢失
- tryTest 依赖预装:Python 项目自动 pip install(测试不再因缺依赖误判失败)
- 运行验证压缩静态维度:前端缺失+测试失败 → 静态纸面维度封顶50%(防原型拿高分)
- 文档-实现三级校验:claim-consistency 注入运行时证据(构建/测试/前端),识别「代码有但跑不起来」的虚报(逸飞冲天 61%→25%)
- 赛事文档:中期20%+最终80%(AI=人工总分)、评审表/评审规则/赛事说明/07设计同步
- 清空 review_snapshots 测试污染数据,保留当前正式分(零号158/逸飞冲天126)
- 测试 446 全通过(后端435+前端11)
This commit is contained in:
hangshuo652
2026-09-01 08:44:56 +08:00
parent f8b4e9d44d
commit 1b89743d77
43 changed files with 3362 additions and 290 deletions
+142 -15
View File
@@ -23,14 +23,22 @@ async function downloadPdf(url: string, options?: { silent?: boolean }) {
filename = plain ? plain[1] : '';
}
if (!filename) filename = url.includes('summary') ? '汇总报告.pdf' : url.includes('deliverables') ? '成果物清单.csv' : '报告.pdf';
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
if (!options?.silent) alert(`已开始下载:${filename}(保存到浏览器下载目录)`);
// 2026-08-28 修复:不得在 click 后立即 alertalert 阻塞主线程,且过早 revokeObjectURL
// 会在浏览器真正落盘前销毁 blob,导致"提示成功但下载目录无文件")。延迟 revoke 到足够久,
// alert 改由 setTimeout 延后,不阻断下载事件循环。
const revoke = () => URL.revokeObjectURL(blobUrl);
setTimeout(revoke, 60000);
window.addEventListener('unload', revoke);
if (!options?.silent) {
setTimeout(() => alert(`已开始下载:${filename}(保存到浏览器下载目录)`), 300);
}
return true;
} catch (e: any) {
alert('下载失败: ' + (e?.message || e));
@@ -200,6 +208,7 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [detail, setDetail] = useState<any>(null);
const [serviceInputs, setServiceInputs] = useState<Record<string, string>>({});
const [showImport, setShowImport] = useState(false);
const [csvText, setCsvText] = useState('');
const [importResult, setImportResult] = useState<any>(null);
@@ -255,14 +264,25 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
} catch (err: any) { alert(err.message || '操作失败'); }
};
// §2.2 人工构建确认:a_done 提供「确认构建完成 / 构建失败」按钮,结果传给 /verify
const doVerify = async (entryId: string, buildStatus: 'done' | 'failed') => {
// §2.2 人工构建确认:a_done 提供「确认构建完成 / 构建成功但无前端 / 构建失败」按钮,结果传给 /verify
const doVerify = async (entryId: string, buildStatus: 'done' | 'failed' | 'done_no_ui') => {
try {
await api.request('POST', `/projects/${projectId}/entries/${entryId}/verify`, { build_status: buildStatus });
await load();
} catch (err: any) { alert(err.message || '启动系统验证失败'); }
};
// §2.5.1 浏览器验证恢复(awaiting_browse):评委手动启动服务后填地址,调 /verify-browse 恢复
const doVerifyBrowse = async (entryId: string) => {
const url = (serviceInputs[entryId] || '').trim();
if (!url) { alert('请先填写手动启动后的服务访问地址'); return; }
try {
await api.request('POST', `/projects/${projectId}/entries/${entryId}/verify-browse`, { service_url: url });
setServiceInputs({ ...serviceInputs, [entryId]: '' });
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 || '删除失败'); }
@@ -360,13 +380,13 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
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',
a_done: 'badge-purple', verifying: 'badge-blue', awaiting_browse: 'badge-amber',
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: '系统验证中',
a_done: 'A阶段完成', verifying: '系统验证中', awaiting_browse: '等待浏览器验证',
review_done: '已完成', admin_reviewed: '已修正',
clone_fail: '克隆失败', analysis_fail: '分析失败', failed: '失败', cancelled: '已取消',
};
@@ -505,9 +525,22 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
{['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: '#f59e0b', color: '#f59e0b' }} title="构建成功但确认无前端界面(触发实现完整度封顶)" onClick={() => doVerify(e.id, 'done_no_ui')}></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>}
{e.status === 'awaiting_browse' && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<input
value={serviceInputs[e.id] ?? ''}
onChange={ev => setServiceInputs({ ...serviceInputs, [e.id]: ev.target.value })}
placeholder="服务地址,如 http://127.0.0.1:8000"
className="input-field"
style={{ width: 200, padding: '6px 10px' }}
/>
<button className="btn-action" style={{ borderColor: '#10b981', color: '#10b981' }} onClick={() => doVerifyBrowse(e.id)}></button>
</span>
)}
{['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>}
@@ -544,6 +577,7 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
<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="done_no_ui"></option>
<option value="failed"></option>
</select>
<div className="flex gap-8">
@@ -691,7 +725,7 @@ function DetailPanel({ entry, projectId, onClose, onSave }: { entry: any; projec
{entry.attempt > 1 && <div className="retake-badge"> {entry.attempt} </div>}
</div>
{entry.plagiarism_json && (() => {
{entry.category_tag === 'L2考核' && 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;
@@ -817,6 +851,21 @@ function DetailPanel({ entry, projectId, onClose, onSave }: { entry: any; projec
<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>}
{Array.isArray(d.checks) && d.checks.length > 0 && (
<div style={{ marginTop: 6, fontSize: 11, borderTop: '1px dashed var(--border)', paddingTop: 4 }}>
{d.checks.map((c: any, ci: number) => (
<div key={ci} style={{ display: 'flex', alignItems: 'flex-start', gap: 6, marginTop: 3 }}>
<span className={`badge ${c.depth === 'real' ? 'badge-green' : c.depth === 'partial' ? 'badge-amber' : 'badge-red'}`} style={{ flexShrink: 0 }}>
{c.depth === 'real' ? '真实' : c.depth === 'partial' ? '部分' : '占位'}
</span>
<span style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{c.feature}</span>
{c.entry ? <span style={{ opacity: 0.7 }}> · {c.entry}</span> : null}
</span>
</div>
))}
</div>
)}
</td>
<td><div className="suggestion-cell">{d.suggestion || '-'}</div></td>
</tr>
@@ -855,6 +904,21 @@ function DetailPanel({ entry, projectId, onClose, onSave }: { entry: any; projec
<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>}
{Array.isArray(d.checks) && d.checks.length > 0 && (
<div style={{ marginTop: 6, fontSize: 11, borderTop: '1px dashed var(--border)', paddingTop: 4 }}>
{d.checks.map((c: any, ci: number) => (
<div key={ci} style={{ display: 'flex', alignItems: 'flex-start', gap: 6, marginTop: 3 }}>
<span className={`badge ${c.depth === 'real' ? 'badge-green' : c.depth === 'partial' ? 'badge-amber' : 'badge-red'}`} style={{ flexShrink: 0 }}>
{c.depth === 'real' ? '真实' : c.depth === 'partial' ? '部分' : '占位'}
</span>
<span style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{c.feature}</span>
{c.entry ? <span style={{ opacity: 0.7 }}> · {c.entry}</span> : null}
</span>
</div>
))}
</div>
)}
</td>
<td><div className="suggestion-cell">{d.suggestion || '-'}</div></td>
</tr>
@@ -866,14 +930,77 @@ function DetailPanel({ entry, projectId, onClose, onSave }: { entry: any; projec
</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()}
{entry.feature_inventory && (() => {
let fi: any = null;
try { fi = JSON.parse(entry.feature_inventory); } catch { }
if (!Array.isArray(fi) || fi.length === 0) return null;
const real = fi.filter((f: any) => f.depth === 'real').length;
const partial = fi.filter((f: any) => f.depth === 'partial').length;
const stub = fi.filter((f: any) => f.depth === 'stub').length;
const MAX_SHOW = 100;
return (
<details className="history-section" style={{ marginBottom: 16 }}>
<summary style={{ cursor: 'pointer' }}>
{fi.length} {real} · {partial} · {stub}
</summary>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 8, marginLeft: 4 }}>
<div style={{ opacity: 0.7, marginBottom: 8 }}>Map-Reduce README/线</div>
{fi.slice(0, MAX_SHOW).map((f: any, i: number) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 6, marginBottom: 4 }}>
<span className={`badge ${f.depth === 'real' ? 'badge-green' : f.depth === 'partial' ? 'badge-amber' : 'badge-red'}`} style={{ flexShrink: 0 }}>
{f.depth === 'real' ? '真实' : f.depth === 'partial' ? '部分' : '占位'}
</span>
<span>{f.feature}</span>
{Array.isArray(f.files) && f.files.length > 0 && <span style={{ opacity: 0.7 }}> · {f.files.slice(0, 2).join(', ')}</span>}
</div>
))}
{fi.length > MAX_SHOW && <div style={{ opacity: 0.7, marginTop: 4 }}> {fi.length} {MAX_SHOW} </div>}
</div>
))}
</details>
);
})()}
{entry.snapshots?.length > 0 && (
<details className="history-section" open>
<summary>{entry.snapshots.length} {entry.aggregate_score != null && ` · 最终 ${entry.aggregate_score}${entry.is_formal ? `(聚合 ${entry.aggregate_count} 次中位数)` : `(初评 ${entry.aggregate_count} 次)`}`}</summary>
{entry.snapshots.map((s: any) => {
let aScore: number | null = null, bScore: number | null = null;
try {
// ai_report.dimensions 无 stage 字段;用 standard_snapshot(entry.dimensions) 的 stage 映射维度名
const stageMap: Record<string, string> = {};
for (const sd of (entry.dimensions || [])) stageMap[sd.name] = String(sd.stage || '').toUpperCase();
const rep = JSON.parse(s.ai_report);
if (Array.isArray(rep.dimensions)) {
for (const d of rep.dimensions) {
const st = stageMap[d.name] || String(d.stage || '').toUpperCase();
if (st === 'A') aScore = (aScore ?? 0) + (Number(d.score) || 0);
else if (st === 'B') bScore = (bScore ?? 0) + (Number(d.score) || 0);
}
}
} catch {}
const total = s.score ?? (aScore ?? 0) + (bScore ?? 0);
return (
<div key={s.id} className="history-item" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<div>
<strong> {s.attempt} · {total} </strong>
<span style={{ marginLeft: 8, fontSize: 12, color: '#666' }}>
{aScore != null ? `A:${aScore}` : ''}{aScore != null && bScore != null ? ' + ' : ''}{bScore != null ? `B:${bScore}` : ''}
{aScore == null && bScore == null ? '(无维度明细)' : ''}
</span>
</div>
<span style={{ fontSize: 12, color: '#999' }}>{new Date(s.created_at).toLocaleString()}</span>
</div>
);
})}
{entry.snapshots.length > 0 && entry.aggregate_score != null && (
<div className="history-item" style={{ marginTop: 6, paddingTop: 6, borderTop: '1px dashed #ddd' }}>
<strong>{entry.aggregate_score} </strong>
<span style={{ marginLeft: 8, fontSize: 12, color: '#666' }}>
{entry.snapshots.map((s: any) => s.score ?? (s.attempt ?? '?')).join(' · ')}
{entry.is_formal ? '(正式分)' : '(未达聚合样本数)'}
</span>
</div>
)}
</details>
)}