L2考核落地:人才测评整合为L2考核track,新增查重初筛与e2e验证
- 新增 L2考核 track(7维标准/100分):选题难度赋分cap、功能完整性地板线、合格判定 - 人才测评整合进 L2考核:移除 L2/L3 两级认定与 question_id 机制 - 新增 l2-topics 选题元数据服务与 config/l2-topics.json(11命题题+自选题) - 新增查重初筛 plagiarism-detect(MD5精确比对+归一化相似度+提交行为,仅告警) - 修复 L2 维度 DIM_FILE_FILTERS 缺失导致 AI 协作记录证据漏喂 - e2e:修复 Windows spawn、DB 隔离,L2 用例 27 项全绿
This commit is contained in:
@@ -205,19 +205,25 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
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 [addForm, setAddForm] = useState({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', selected_topic: 'self', reg_no: '', reg_features: '' });
|
||||
const [l2Topics, setL2Topics] = useState<any[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [questionFilter, setQuestionFilter] = useState('');
|
||||
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, qFilter = questionFilter) => {
|
||||
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;
|
||||
if (qFilter) params.question_id = qFilter;
|
||||
api.listEntries(projectId, params).then(r => {
|
||||
if (latestSearchRef.current === term) {
|
||||
setEntries(r.items);
|
||||
@@ -225,14 +231,14 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
const load = () => doSearch(search, offset, questionFilter);
|
||||
const load = () => doSearch(search, offset);
|
||||
|
||||
const goPage = (newOffset: number) => {
|
||||
setOffset(newOffset);
|
||||
setTimeout(() => doSearch(search, newOffset, questionFilter), 0);
|
||||
setTimeout(() => doSearch(search, newOffset), 0);
|
||||
};
|
||||
|
||||
useEffect(() => { setOffset(0); doSearch(search, 0, questionFilter); }, [projectId, status, search, questionFilter]);
|
||||
useEffect(() => { setOffset(0); doSearch(search, 0); }, [projectId, status, search]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
const next = new Set(selected);
|
||||
@@ -265,32 +271,52 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
const doEditSave = async () => {
|
||||
if (!editEntry) return;
|
||||
try {
|
||||
await api.request('PUT', `/projects/${projectId}/entries/${editEntry.id}`, {
|
||||
const payload: any = {
|
||||
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,
|
||||
});
|
||||
};
|
||||
if (track === 'L2考核') {
|
||||
payload.selected_topic = editEntry.selected_topic || 'self';
|
||||
if (payload.selected_topic === 'self') {
|
||||
payload.self_registration = { no: editEntry.reg_no, features: editEntry.reg_features };
|
||||
}
|
||||
}
|
||||
await api.request('PUT', `/projects/${projectId}/entries/${editEntry.id}`, payload);
|
||||
setEditEntry(null);
|
||||
await load();
|
||||
} catch (err: any) { alert(err.message || '保存失败'); }
|
||||
};
|
||||
|
||||
const openEdit = (e: any) => 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 openEdit = (e: any) => {
|
||||
let reg = { no: '', features: '' };
|
||||
try { const p = JSON.parse(e.self_registration || '{}'); reg = { no: p.no || '', features: Array.isArray(p.features) ? p.features.join('\n') : (p.features || '') }; } catch { }
|
||||
setEditEntry({
|
||||
id: e.id, title: e.title, repo_url: e.repo_url, participant: e.participant || '',
|
||||
sub_type: e.sub_type || '', branch: e.branch || '',
|
||||
service_url: e.service_url || '', build_status: e.build_status || '',
|
||||
selected_topic: e.selected_topic || 'self', reg_no: reg.no, reg_features: reg.features,
|
||||
});
|
||||
};
|
||||
|
||||
const doAdd = async () => {
|
||||
if (!addForm.title.trim() || !addForm.repo_url.trim()) return;
|
||||
const payload: any = { ...addForm };
|
||||
delete payload.reg_no;
|
||||
delete payload.reg_features;
|
||||
if (track === 'L2考核') {
|
||||
payload.selected_topic = addForm.selected_topic || 'self';
|
||||
if (payload.selected_topic === 'self') {
|
||||
payload.self_registration = { no: addForm.reg_no, features: addForm.reg_features };
|
||||
}
|
||||
}
|
||||
try {
|
||||
await api.request('POST', `/projects/${projectId}/entries`, addForm);
|
||||
setAddForm({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', question_id: '' });
|
||||
await api.request('POST', `/projects/${projectId}/entries`, payload);
|
||||
setAddForm({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', selected_topic: 'self', reg_no: '', reg_features: '' });
|
||||
setShowAdd(false);
|
||||
await load();
|
||||
} catch (err: any) { alert(err.message || '添加失败'); }
|
||||
@@ -323,10 +349,10 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
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');
|
||||
if (track === 'L2考核') header.push('selected_topic');
|
||||
const example = header.map(h =>
|
||||
h === 'repo_url' ? 'https://github.com/user/repo' :
|
||||
h === 'question_id' ? 'Q1' :
|
||||
h === 'selected_topic' ? '01' :
|
||||
h === 'sub_type' ? '新規' : ''
|
||||
).join(',');
|
||||
const blob = new Blob(['\uFEFF' + header.join(',') + '\n' + example], { type: 'text/csv;charset=utf-8' });
|
||||
@@ -384,17 +410,6 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
<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>
|
||||
@@ -423,21 +438,19 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
<option value="修正">修正/升级</option>
|
||||
</select>
|
||||
)}
|
||||
{track === '人才测评' && (
|
||||
{track === 'L2考核' && (
|
||||
<>
|
||||
<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 value={addForm.selected_topic} onChange={e => setAddForm({ ...addForm, selected_topic: e.target.value })} className="select-field">
|
||||
<option value="self">自选题(需事前登记)</option>
|
||||
{l2Topics.map(t => (
|
||||
<option key={t.id} value={t.id}>命题 {t.id}|{t.title}({'★'.repeat(t.difficulty)} 满分{t.maxScoreCap})</option>
|
||||
))}
|
||||
</select>
|
||||
{addForm.question_id && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', padding: '4px 2px' }}>
|
||||
{addForm.question_id === 'Q1' ? '仅L2评审(共通100分)' : 'L2共通100分 + L3追加评审'}
|
||||
</div>
|
||||
{addForm.selected_topic === 'self' && (
|
||||
<>
|
||||
<input value={addForm.reg_no} onChange={e => setAddForm({ ...addForm, reg_no: e.target.value })} placeholder="登记编号(组委会登记确认后获得)" className="input-field" />
|
||||
<textarea value={addForm.reg_features} onChange={e => setAddForm({ ...addForm, reg_features: e.target.value })} placeholder="登记功能清单(每行一条;低于该范围核心功能的缩减按未实现计)" rows={3} className="input-field" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -488,9 +501,9 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
<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.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' ? 'badge-green' : 'badge-red'}`}>
|
||||
<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>
|
||||
)}
|
||||
@@ -562,21 +575,19 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
<option value="修正">修正/升级</option>
|
||||
</select>
|
||||
)}
|
||||
{track === '人才测评' && (
|
||||
{track === 'L2考核' && (
|
||||
<>
|
||||
<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 value={editEntry.selected_topic || 'self'} onChange={e => setEditEntry({ ...editEntry, selected_topic: e.target.value })} className="select-field">
|
||||
<option value="self">自选题(需事前登记)</option>
|
||||
{l2Topics.map(t => (
|
||||
<option key={t.id} value={t.id}>命题 {t.id}|{t.title}({'★'.repeat(t.difficulty)} 满分{t.maxScoreCap})</option>
|
||||
))}
|
||||
</select>
|
||||
{editEntry.question_id && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', padding: '4px 2px' }}>
|
||||
{editEntry.question_id === 'Q1' ? '仅L2评审(共通100分)' : 'L2共通100分 + L3追加评审'}
|
||||
</div>
|
||||
{editEntry.selected_topic === 'self' && (
|
||||
<>
|
||||
<input value={editEntry.reg_no || ''} onChange={e => setEditEntry({ ...editEntry, reg_no: e.target.value })} placeholder="登记编号" className="input-field" />
|
||||
<textarea value={editEntry.reg_features || ''} onChange={e => setEditEntry({ ...editEntry, reg_features: e.target.value })} placeholder="登记功能清单(每行一条)" rows={3} className="input-field" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -680,7 +691,7 @@ function DetailPanel({ entry, projectId, onClose, onSave }: { entry: any; projec
|
||||
</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>参赛者:{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'}`}>
|
||||
@@ -706,6 +717,23 @@ function DetailPanel({ entry, projectId, onClose, onSave }: { entry: any; projec
|
||||
{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>
|
||||
@@ -1193,30 +1221,24 @@ 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));
|
||||
// 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 }}>汇总排名{isTalent ? '(人才测评)' : ''}</h3>
|
||||
<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 isQ = /^Q\d$/.test(cat.category);
|
||||
const showLevel = isQ || cat.category === '人才测评';
|
||||
const showLevel = isL2Cat(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>
|
||||
<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>
|
||||
@@ -1229,7 +1251,7 @@ function SummaryView({ projectId }: { projectId: string }) {
|
||||
<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'}`}>
|
||||
<span className={`badge ${e.final_level === '合格' || e.final_level === 'L2' ? 'badge-green' : e.final_level === 'L3' ? 'badge-purple' : 'badge-red'}`}>
|
||||
{e.final_level}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function Sidebar() {
|
||||
<option value="">请选择赛道(必选)</option>
|
||||
<option value="赛道一">赛道一:Agent开发实战</option>
|
||||
<option value="赛道二">赛道二:IDE+开发范式创新</option>
|
||||
<option value="人才测评">人才测评</option>
|
||||
<option value="L2考核">AI人才育成认证——L2</option>
|
||||
</select>
|
||||
{error && <div className="text-sm" style={{ color: 'var(--danger)', marginTop: 4 }}>{error}</div>}
|
||||
<div className="new-project-actions">
|
||||
|
||||
Reference in New Issue
Block a user