diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 347af11..8cbb27a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -205,6 +205,7 @@ function Projects({ openScores }: { openScores: (id: number) => void }) { function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) { const [batches, setBatches] = useState([]), [projects, setProjects] = useState([]), [batch, setBatch] = useState(batchId), [project, setProject] = useState(projectId), [result, setResult] = useState(), [review, setReview] = useState(), [revisions, setRevisions] = useState([]); + const [scoreFilter, setScoreFilter] = useState('all'); const [form] = Form.useForm(); const [sourcesReady, setSourcesReady] = useState(false); useEffect(() => { @@ -219,6 +220,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number } const scopedProjects = projectsInBatch(selectedBatch, projects); useEffect(() => { setResult(undefined); setReview(undefined); setRevisions([]); + setScoreFilter('all'); if (!sourcesReady || !selectedBatch) return; if (!scopedProjects.some(item => item.id === project)) { setProject(scopedProjects.length === 1 ? scopedProjects[0].id : undefined); @@ -230,6 +232,12 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number } return () => { cancelled = true; }; }, [batch, project, batches, projects, sourcesReady]); const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult); + const scoreRows: any[] = result?.rows || []; + const count = (status: string) => scoreRows.filter(row => row.status === status).length; + const unfinished = (row: any) => !['scored', 'reviewed'].includes(row.status); + const attentionCount = scoreRows.filter(unfinished).length; + const filteredScores = scoreRows.filter(row => scoreFilter === 'all' || + (scoreFilter === 'attention' ? unfinished(row) : row.status === scoreFilter)); return <>逐项评分与教师复核 + 已评分 {count('scored')} 项 · 教师已复核 {count('reviewed')} 项 · 待处理 {attentionCount} 项 + } + {result && <>{result.rows.some((r: any) => r.status === 'failed') && r.status === 'failed').length} 项评分失败`} description="具体原因见下表。连接失败请检查模型端点和网络;输出校验失败可重试,若重复发生请核对具体校验原因。可在评分批次页点击启动/重试;已经成功的评分和教师复核会保留。" />}{Object.entries(result.totals).map(([key, value]) => {key}: {String(value ?? '待处理')})}{result.facts?.consistency_issues?.length > 0 && issue.description).join(';')} />} row.criterion.criterion_id} dataSource={filteredScores} locale={{ emptyText: '当前筛选下没有评分项' }} pagination={false} columns={[{ title: '评分项', render: (_, r) => <>{categories[r.criterion.category] || r.criterion.category}{r.criterion.name} }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态 / 失败原因', render: (_, r) => {scoreStatusLabel(r)}{r.error && {r.error}} }, { title: '操作', render: (_, r) => }]} />} setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <>{review.status === 'needs_review' && gap.target_label).join('、') || review.missing_evidence?.join('、') || '见缺失证据'}。系统未给出模型分,也没有按低分处理;请核对下方证据,补充或更正材料后重试,或直接填写教师评分。`} />} `${gap.target_label}(检索范围:${(gap.searched_page_types || []).map(pageTypeLabel).join('、') || '正文'})`).join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} />{review.evidence?.map((ev: any, index: number) => {ev.document} · {evidencePlace(ev)}{pageTypeLabel(ev.page_type)}{ev.historical && 历史评分引用}{ev.target_label && {ev.target_label}}{ev.quote}{ev.url && 查看页面文字}{ev.image_url && {evidencePreviewLabel(ev)}(核对引文)})}
{review.score_id && }{revisions.length > 0 &&
{JSON.stringify(revisions, null, 2)}
}}
; } diff --git a/frontend/tests/batches.spec.ts b/frontend/tests/batches.spec.ts index 08e09fc..18f404a 100644 --- a/frontend/tests/batches.spec.ts +++ b/frontend/tests/batches.spec.ts @@ -183,6 +183,38 @@ test('output validation and insufficient evidence are not presented as network f await expect(page.getByText(/缺少或尚不足以支撑判断的目标:计划/)).toBeVisible(); }); +test('score filters isolate actionable rows without hiding zero grades or changing data', async ({ page }) => { + await setup(page); + const writes: string[] = []; + page.on('request', request => { if (request.method() !== 'GET') writes.push(request.url()); }); + await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: { + student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {}, + rows: ['scored', 'reviewed', 'needs_review', 'failed', 'unscorable'].map((status, i) => ({ + criterion: { criterion_id: `stage1.${i}`, category: 'stage1', name: `测试项目${i}`, max_score: 20 }, + status, model_score: status === 'scored' ? 0 : null, teacher_score: status === 'reviewed' ? 0 : null, + final_score: ['scored', 'reviewed'].includes(status) ? 0 : null, + error: status === 'failed' ? '模型证据未能匹配检索原文' : '', evidence: [], + })), + } })); + await page.goto('/?page=scores&batch_id=71&project_id=11'); + await expect(page.getByText('已评分 1 项 · 教师已复核 1 项 · 待处理 3 项', { exact: true })).toBeVisible(); + const rows = page.getByRole('row').filter({ hasText: '测试项目' }); + await expect(rows).toHaveCount(5); + for (const [option, count, name] of [ + ['仅待处理(3)', 3, '测试项目2'], ['仅待教师复核(1)', 1, '测试项目2'], + ['仅评分失败(1)', 1, '测试项目3'], ['仅材料异常(1)', 1, '测试项目4'], + ['全部评分项(5)', 5, '测试项目0'], + ] as const) { + await page.keyboard.press('Escape'); + await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + await choose(page, '评分项筛选', option); + await expect(rows).toHaveCount(count); + await expect(page.getByRole('row').filter({ hasText: name })).toBeVisible(); + } + await expect(page.getByRole('row').filter({ hasText: '测试项目0' })).toContainText('0 / 20'); + expect(writes).toEqual([]); +}); + test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => { const state = await setup(page); await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{