评审真实性增强:前端硬规则+人工启动方案+文档-实现三级校验
- 前端存在性硬规则: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:
@@ -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 后立即 alert(alert 阻塞主线程,且过早 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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user