fix: refresh grading status after review and ignore stale student responses

This commit is contained in:
2026-10-18 12:18:49 +08:00
parent 4fec28ce8d
commit aa825231ba
2 changed files with 71 additions and 3 deletions
+5 -3
View File
@@ -207,6 +207,7 @@ function Projects({ openScores }: { openScores: (id: number) => void }) {
function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) { function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) {
const [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batch, setBatch] = useState<number | undefined>(batchId), [project, setProject] = useState<number | undefined>(projectId), [result, setResult] = useState<any>(), [review, setReview] = useState<any>(), [revisions, setRevisions] = useState<any[]>([]); const [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batch, setBatch] = useState<number | undefined>(batchId), [project, setProject] = useState<number | undefined>(projectId), [result, setResult] = useState<any>(), [review, setReview] = useState<any>(), [revisions, setRevisions] = useState<any[]>([]);
const [scoreFilter, setScoreFilter] = useState('all'); const [scoreFilter, setScoreFilter] = useState('all');
const [refreshVersion, setRefreshVersion] = useState(0);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [sourcesReady, setSourcesReady] = useState(false); const [sourcesReady, setSourcesReady] = useState(false);
useEffect(() => { useEffect(() => {
@@ -216,12 +217,12 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
setBatches(batchRows); setProjects(projectRows); setSourcesReady(true); setBatches(batchRows); setProjects(projectRows); setSourcesReady(true);
}).catch(error => { if (!cancelled) fail(error); }); }).catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [refreshVersion]);
const selectedBatch = batches.find(item => item.id === batch); const selectedBatch = batches.find(item => item.id === batch);
const scopedProjects = projectsInBatch(selectedBatch, projects); const scopedProjects = projectsInBatch(selectedBatch, projects);
useEffect(() => { setScoreFilter('all'); }, [batch, project]);
useEffect(() => { useEffect(() => {
setResult(undefined); setReview(undefined); setRevisions([]); setResult(undefined); setReview(undefined); setRevisions([]);
setScoreFilter('all');
if (!sourcesReady || !selectedBatch) return; if (!sourcesReady || !selectedBatch) return;
if (!scopedProjects.some(item => item.id === project)) { if (!scopedProjects.some(item => item.id === project)) {
setProject(scopedProjects.length === 1 ? scopedProjects[0].id : undefined); setProject(scopedProjects.length === 1 ? scopedProjects[0].id : undefined);
@@ -232,7 +233,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
.catch(error => { if (!cancelled) fail(error); }); .catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [batch, project, batches, projects, sourcesReady]); }, [batch, project, batches, projects, sourcesReady]);
const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult); const refresh = () => setRefreshVersion(value => value + 1);
const scoreRows: any[] = result?.rows || []; const scoreRows: any[] = result?.rows || [];
const count = (status: string) => scoreRows.filter(row => row.status === status).length; const count = (status: string) => scoreRows.filter(row => row.status === status).length;
const unfinished = (row: any) => !['scored', 'reviewed'].includes(row.status); const unfinished = (row: any) => !['scored', 'reviewed'].includes(row.status);
@@ -245,6 +246,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
options={options(batches, b => `批次 #${b.id} · ${batchStatusLabel(b.status)}`)} onChange={setBatch} /> options={options(batches, b => `批次 #${b.id} · ${batchStatusLabel(b.status)}`)} onChange={setBatch} />
<Select aria-label="学生项目" style={{ width: 460 }} placeholder="选择该批次中的学生项目" value={project} <Select aria-label="学生项目" style={{ width: 460 }} placeholder="选择该批次中的学生项目" value={project}
disabled={!sourcesReady || !selectedBatch} options={options(scopedProjects, projectLabel)} onChange={setProject} /> disabled={!sourcesReady || !selectedBatch} options={options(scopedProjects, projectLabel)} onChange={setProject} />
<Button disabled={!sourcesReady || !selectedBatch || !project} onClick={refresh}>刷新评分</Button>
</Space> </Space>
{selectedBatch && <Alert className="top-gap" type="info" showIcon {selectedBatch && <Alert className="top-gap" type="info" showIcon
message={`批次 #${batch} 包含 ${scopedProjects.length} 个项目。下方分数仅对应当前选中的学生项目。`} />} message={`批次 #${batch} 包含 ${scopedProjects.length} 个项目。下方分数仅对应当前选中的学生项目。`} />}
+66
View File
@@ -217,6 +217,72 @@ test('score filters isolate actionable rows without hiding zero grades or changi
expect(writes).toEqual([]); expect(writes).toEqual([]);
}); });
test('saving the last review refreshes batch status and keeps the active filter', async ({ page }) => {
await setup(page);
let saved = false;
const saves: any[] = [];
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
id: 71, course_version_id: 1, project_ids: [11], status: saved ? 'completed' : 'review_required',
}] }));
await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: saved ? 0 : null }, facts: {},
rows: [{ criterion: { criterion_id: 'stage3.plan', category: 'stage3', name: '下一阶段计划', max_score: 10 },
score_id: 501, status: saved ? 'reviewed' : 'needs_review', final_score: saved ? 0 : null,
teacher_score: saved ? 0 : null, teacher_comment: saved ? '已核对' : null,
evidence: [], missing_evidence: ['计划'], evidence_gaps: [], model_comment: '需要教师核对' }],
} }));
await page.route('**/api/criterion-scores/501/review', route => {
saves.push(route.request().postDataJSON()); saved = true;
return route.fulfill({ json: { id: 501, status: 'reviewed', teacher_score: 0, final_score: 0 } });
});
await page.goto('/?page=scores&batch_id=71&project_id=11');
await choose(page, '评分项筛选', '仅待教师复核(1)');
await page.keyboard.press('Escape');
await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0);
await page.getByRole('button', { name: '查看证据/复核' }).click();
const modal = page.getByRole('dialog');
await modal.getByRole('spinbutton', { name: '教师评分' }).fill('0');
await modal.getByRole('textbox', { name: '教师评语' }).fill('已核对');
await modal.getByRole('button', { name: '保存教师复核', exact: true }).click();
await expect.poll(() => saves.length).toBe(1);
expect(saves[0].score).toBe(0);
await expect(page.getByText('批次 #71 · 已完成', { exact: true })).toBeVisible();
await expect(page.getByText('仅待教师复核(0)', { exact: true })).toBeVisible();
await expect(page.getByText('当前筛选下没有评分项', { exact: true })).toBeVisible();
await expect(page.getByText('已评分 0 项 · 教师已复核 1 项 · 待处理 0 项', { exact: true })).toBeVisible();
});
test('manual score refresh ignores late responses for a previously selected student', async ({ page }) => {
await setup(page);
let held = false, release: (() => void) | undefined;
let firstReads = 0;
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
id: 71, course_version_id: 1, project_ids: [11, 12], status: 'completed',
}] }));
await page.route('**/api/projects/*/scores?*', async route => {
const isFirst = route.request().url().includes('/projects/11/');
if (isFirst && ++firstReads > 1) {
held = true;
await new Promise<void>(resolve => { release = resolve; });
}
await route.fulfill({ json: {
student_no: isFirst ? '9000000001' : '9000000002', name: isFirst ? '学生甲' : '学生乙',
topic: isFirst ? '项目甲' : '项目乙', totals: {}, rows: [], facts: {},
} });
});
await page.goto('/?page=scores&batch_id=71&project_id=11');
await expect(page.getByText('9000000001 学生甲 · 项目甲', { exact: true })).toBeVisible();
await page.getByRole('button', { name: '刷新评分', exact: true }).click();
await expect.poll(() => held).toBe(true);
await choose(page, '学生项目', /学生乙/);
await page.keyboard.press('Escape');
await expect(page.getByText('9000000002 学生乙 · 项目乙', { exact: true })).toBeVisible();
const late = page.waitForResponse(response => response.url().includes('/projects/11/scores'));
release!(); await late;
await expect(page.getByText('9000000002 学生乙 · 项目乙', { exact: true })).toBeVisible();
await expect(page.getByText('9000000001 学生甲 · 项目甲', { exact: true })).toHaveCount(0);
});
test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => { test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => {
const state = await setup(page); const state = await setup(page);
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{ await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{