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:
hangshuo652
2026-08-23 20:58:56 +08:00
parent 4da7044c4b
commit 6a2419d4a4
26 changed files with 1306 additions and 662 deletions
+5 -2
View File
@@ -29,16 +29,19 @@ export default async function globalSetup() {
PORT: '3002',
AUTH_PASSWORD: 'test123',
ADMIN_TEST_TOKEN: 'true',
// 隔离 e2e 测试库,避免污染真实评审数据
DB_PATH: path.join(SERVER_DIR, 'data', 'e2e-test.db'),
// 测试模式下 writeEnvVar 只更新进程内 env,避免改密用例污染真实 server/.env
NODE_ENV: 'test',
} as Record<string, string>;
const shell = process.platform === 'win32' ? { shell: 'cmd.exe' } : {};
console.log('[setup] Starting backend server...');
const server = spawn('npx.cmd', ['tsx', 'src/index.ts'], {
cwd: SERVER_DIR,
stdio: ['ignore', 'pipe', 'pipe'],
env,
shell: true,
...shell,
});
server.stdout.on('data', (d: Buffer) => process.stdout.write(`[server] ${d}`));
server.stderr.on('data', (d: Buffer) => process.stderr.write(`[server-err] ${d}`));
@@ -49,7 +52,7 @@ export default async function globalSetup() {
cwd: WEB_DIR,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env } as Record<string, string>,
shell: true,
...shell,
});
frontend.stdout.on('data', (d: Buffer) => process.stdout.write(`[web] ${d}`));
frontend.stderr.on('data', (d: Buffer) => process.stderr.write(`[web-err] ${d}`));
+32 -30
View File
@@ -5,9 +5,9 @@ import { test, expect, type Page, type APIRequestContext } from '@playwright/tes
// 覆盖对象:
// 1. 模板维度可声明「文件关键词」→ DIM_FILE_FILTERS 硬编码仅兑底
// 2. 模板 content 成为评审指南第一来源(评审要点完整保留并透传)
// 3. 人才测评 L2「功能完整性」盲区修复isBuildRelatedDim
// 3. L2考核「功能完整性」构建封顶维度识别isBuildRelatedDim
// 4. 阈值收拢命名常量后 pass_line / 及格线行为回归
// 5. question_id 分组过滤(快照只含 common + 所选 Qn
// 5. L2考核 选题(selected_topic)与难度赋分 cap
// ══════════════════════════════════════════════════════════════════
const PASSWORD = 'test123';
@@ -107,20 +107,21 @@ test.describe('项目创建与赛道自动标准', () => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('1.3 人才测评自动生成含「功能完整性」标准', async ({ request }) => {
test('1.3 L2考核自动生成 7 维标准(功能完整性 30', async ({ request }) => {
const token = await apiLogin(request);
const pid = await apiCreateProject(request, token, `人才-${UNIQUE}`, '人才测评');
const pid = await apiCreateProject(request, token, `L2-${UNIQUE}`, 'L2考核');
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
});
const standards = await r.json();
expect(standards).toHaveLength(1);
const dims = standards[0].dimensions;
expect(dims.some((d: any) => d.name === '功能完整性' && d.maxScore === 40)).toBe(true);
expect(dims.some((d: any) => d.name === '功能完整性' && d.maxScore === 30)).toBe(true);
// 共通维度合计 100L2 格线 = 60
const commonTotal = dims.filter((d: any) => (d.group || 'common') === 'common').reduce((s: number, d: any) => s + d.maxScore, 0);
expect(commonTotal).toBe(100);
// 7 维合计 100L2 格线 = 60
const total = dims.reduce((s: number, d: any) => s + d.maxScore, 0);
expect(total).toBe(100);
expect(dims).toHaveLength(7);
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
});
@@ -273,50 +274,51 @@ test.describe('条目快照透传', () => {
});
// ══════════════════════════════════════
// 4. 人才测评:question_id 分组过滤 + 功能完整性
// 4. L2考核:选题(selected_topic+ 难度赋分 cap
// ══════════════════════════════════════
test.describe('人才测评 question_id 分组', () => {
test.describe('L2考核 选题与 cap', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `人才Q-${UNIQUE}`, '人才测评');
pid = await apiCreateProject(request, token, `L2Q-${UNIQUE}`, 'L2考核');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('4.1 建条目未选题被拒绝(400', async ({ request }) => {
test('4.1 无效选题被拒绝(400', async ({ request }) => {
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: '无题', repo_url: `https://noq-${UNIQUE}.git` },
data: { title: '无效选题', repo_url: `https://noid-${UNIQUE}.git`, selected_topic: '99' },
});
expect(r.status()).toBe(400);
});
test('4.2 Q2 条目快照包含 common 与 [Q2] 维度(完整标准,评审时按题目过滤)', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, 'Q2选手', { question_id: 'Q2' });
const dims = JSON.parse(entry.standard_snapshot);
const groups = new Set(dims.map((d: any) => d.group || 'common'));
expect(groups.has('common')).toBe(true);
expect(groups.has('Q2')).toBe(true);
// 快照保留完整标准(含其他题目维度),评审时按 question_id 过滤
expect(groups.has('Q3')).toBe(true);
// 功能完整性在 common 组且是构建封顶维度
const fnDim = dims.find((d: any) => d.name === '功能完整性');
expect(fnDim).toBeDefined();
expect(fnDim.maxScore).toBe(40);
test('4.2 命题 11(★★)→ cap=100;自选题 → cap=100', async ({ request }) => {
const e1 = await apiCreateEntry(request, token, pid, '题11选手', { selected_topic: '11' });
expect(e1.selected_topic).toBe('11');
expect(e1.max_score_cap).toBe(100);
const e2 = await apiCreateEntry(request, token, pid, '自选选手', { selected_topic: 'self' });
expect(e2.max_score_cap).toBe(100);
});
test('4.3 pass_line = 共通维度 60% = 60', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, 'Q1选手', { question_id: 'Q1' });
expect(entry.pass_line).toBe(60);
test('4.3 命题 03(★★★★)→ cap=110(难度赋分封顶)', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, '题03选手', { selected_topic: '03' });
expect(entry.max_score_cap).toBe(110);
});
test('4.4 快照为 7 维标准(全 common,功能完整性 30', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, '快照选手', { selected_topic: '01' });
const dims = JSON.parse(entry.standard_snapshot);
expect(dims).toHaveLength(7);
expect(dims.every((d: any) => (d.group || 'common') === 'common')).toBe(true);
const fnDim = dims.find((d: any) => d.name === '功能完整性');
expect(fnDim.maxScore).toBe(30);
});
});
+29 -22
View File
@@ -175,55 +175,62 @@ test.describe('PDF 下载', () => {
});
// ══════════════════════════════════════
// 4. 人才测评 L2/L3 分表展示
// 4. L2考核:条目列表合格/不合格徽章展示
// ══════════════════════════════════════
test.describe('人才测评 L2/L3 分表', () => {
test.describe('L2考核 合格/不合格徽章', () => {
let pid = ''; let eid = ''; let token = '';
test.beforeAll(async ({ request }) => {
token = await apiToken(request);
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `l2l3-${UNIQUE}`, track: '人才测评' },
headers: { Authorization: `Bearer ${token}` }, data: { name: `l2badge-${UNIQUE}`, track: 'L2考核' },
});
pid = (await proj.json()).id;
// 删除自动标准,替换为自定义 Q2 标准
const list = await request.get(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
});
for (const s of await list.json()) {
await request.delete(`http://localhost:3002/api/projects/${pid}/standards/${s.id}`, { headers: { Authorization: `Bearer ${token}` } });
}
await request.post(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
data: { name: 'l2-std', category_tag: '人才测评', content: '## 功能完整性(40分)\n## 设计文档(10分)\n## [Q2] LLM生成问卷(15分)' },
});
const entry = await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'l2l3-entry', repo_url: `file://C:\\l2-${UNIQUE}`, question_id: 'Q2' },
data: { title: 'l2-entry', repo_url: `file://C:\\l2-${UNIQUE}`, selected_topic: '11' },
});
eid = (await entry.json()).id;
// 播种评审结果:总分 70 ≥60 且 功能完整性 25 ≥15 → 合格
await request.put(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
dimensions: [
{ name: '功能完整性', score: 40, maxScore: 40, group: 'common' },
{ name: '设计文档', score: 10, maxScore: 10, group: 'common' },
{ name: 'LLM生成问卷', score: 15, maxScore: 15, group: 'Q2' },
{ name: '功能完整性', score: 25, maxScore: 30 },
{ name: '设计文档', score: 8, maxScore: 10 },
{ name: '测试用例与测试结果', score: 7, maxScore: 10 },
{ name: 'AI协作过程记录', score: 10, maxScore: 15 },
{ name: '技术选型与范式运用', score: 10, maxScore: 15 },
{ name: '代码质量+README', score: 6, maxScore: 10 },
{ name: '业务场景理解与需求分析', score: 4, maxScore: 10 },
],
},
});
// 触发合格判定(PUT /report 重算 final_level
await request.put(`http://localhost:3002/api/projects/${pid}/entries/${eid}/report`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { dimensions: [
{ name: '功能完整性', score: 25, maxScore: 30 },
{ name: '设计文档', score: 8, maxScore: 10 },
{ name: '测试用例与测试结果', score: 7, maxScore: 10 },
{ name: 'AI协作过程记录', score: 10, maxScore: 15 },
{ name: '技术选型与范式运用', score: 10, maxScore: 15 },
{ name: '代码质量+README', score: 6, maxScore: 10 },
{ name: '业务场景理解与需求分析', score: 4, maxScore: 10 },
] },
});
});
test.afterAll(async ({ request }) => {
await request.delete(`http://localhost:3002/api/projects/${pid}?force=true`, { headers: { Authorization: `Bearer ${token}` } });
});
test('详情面板分别展示 L2共通 与 L3追加 分表', async ({ page }) => {
test('列表展示「合格」徽章(L2考核 判定)', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.title-cell');
await expect(page.getByText('L2共通评分')).toBeVisible();
await expect(page.getByText('L3追加评分')).toBeVisible();
await expect(page.getByText('合格', { exact: true })).toBeVisible();
// 条目还展示选题徽章
await expect(page.getByText('命题 11')).toBeVisible();
});
});
+93 -71
View File
@@ -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>
) : (
+1 -1
View File
@@ -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">