Files
WiseGrade/frontend/src/Batches.tsx
T
aerospaceboy ebe33392ca fix: improve import, evidence indexing and material-scoped grading
Preserve document revisions and evidence, harden indexing and export behavior, add explicit material selection and direct-folder import, validate model JSON schemas with safe error messages, and retain student selection while loading scoring data.
2026-10-14 13:52:56 +08:00

165 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Form, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
import { api, post } from './api';
import { projectLabel, projectsInBatch } from './batchProjects';
const labels: Record<string, string> = {
created: '待启动', queued: '排队中', running: '运行中', completed: '已完成',
review_required: '已完成,有待复核项', partial: '部分完成', failed: '失败',
};
const fail = (error: unknown) => message.error(error instanceof Error ? error.message : String(error));
const options = (rows: any[], label: (row: any) => string) => rows.map(row => ({ value: row.id, label: label(row) }));
const materialTypes: Record<string, string> = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT' };
export function Batches({ openBatch }: { openBatch: (id: number, projectId?: number) => void }) {
const [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]);
const [courses, setCourses] = useState<any[]>([]), [versions, setVersions] = useState<any[]>([]), [rubrics, setRubrics] = useState<any[]>([]);
const [courseId, setCourseId] = useState<number>(), [versionId, setVersionId] = useState<number>(), [rubricId, setRubricId] = useState<number>();
const [projectIds, setProjectIds] = useState<number[]>([]), [creating, setCreating] = useState(false);
const refresh = () => Promise.all([api('/scoring-batches'), api('/projects')])
.then(([batchRows, projectRows]) => { setBatches(batchRows); setProjects(projectRows); }).catch(fail);
useEffect(() => {
refresh(); api('/courses').then(setCourses).catch(fail);
const timer = window.setInterval(refresh, 5000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const version = Number(params.get('course_version_id'));
const ids = (params.get('project_ids') || '').split(',').map(Number).filter(id => Number.isInteger(id) && id > 0);
if (!Number.isInteger(version) || version <= 0) return;
let cancelled = false;
api(`/course-versions/${version}`).then(item => {
if (!cancelled && item.published) {
setCourseId(item.course_id); setVersionId(item.id); setProjectIds([...new Set(ids)]);
}
}).catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!courseId) return;
let cancelled = false;
api(`/courses/${courseId}/versions`).then(rows => { if (!cancelled) setVersions(rows); })
.catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; };
}, [courseId]);
useEffect(() => {
if (!versionId) return;
let cancelled = false;
api(`/course-versions/${versionId}/rubrics`).then(rows => { if (!cancelled) setRubrics(rows); })
.catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; };
}, [versionId]);
const available = projects.filter(project => project.course_version_id === versionId);
const selected = projectIds.filter(id => available.some(project => project.id === id));
const selectedFiles = available.filter(project => selected.includes(project.id)).flatMap(project =>
(project.materials || []).map((file: any) => ({ ...file, project_id: project.id, student: `${project.name}(${project.student_no})` })));
const materialLabel = (project: any) => `${projectLabel(project)}${project.materials?.length ? ` · ${project.materials.length === 1 ? project.materials[0].file_name : `${project.materials.length}份资料`}` : ''}`;
const chooseCourse = (id: number) => {
setCourseId(id); setVersionId(undefined); setRubricId(undefined);
setVersions([]); setRubrics([]); setProjectIds([]);
};
const chooseVersion = (id: number) => {
setVersionId(id); setRubricId(undefined); setRubrics([]); setProjectIds([]);
};
const create = async () => {
setCreating(true);
try {
const batch = await post('/scoring-batches', {
course_version_id: versionId, rubric_version_id: rubricId, project_ids: selected,
});
await refresh();
message.success(`已创建批次 #${batch.id},包含 ${selected.length} 个项目`);
} catch (error) { fail(error); }
finally { setCreating(false); }
};
return <>
<Typography.Title level={2}>评分批次与进度</Typography.Title>
<Card title="创建独立批次">
<Alert type="info" showIcon message="先选评分规则,再选择要评分的资料。资料按学生分组,选中后可核对具体文件。" />
<Form layout="vertical" className="top-gap">
<div className="batch-create-grid">
<Form.Item label="课程" htmlFor="batch-course">
<Select id="batch-course" style={{ width: '100%' }} placeholder="选择课程" value={courseId}
options={options(courses, course => course.name)} onChange={chooseCourse} />
</Form.Item>
<Form.Item label="课程版本" htmlFor="batch-version">
<Select id="batch-version" style={{ width: '100%' }} placeholder="选择已发布课程版本" value={versionId}
disabled={!courseId} options={options(versions.filter(version => version.published),
version => `课程 v${version.version} · ${version.title}`)} onChange={chooseVersion} />
</Form.Item>
<Form.Item label="评分规则" htmlFor="batch-rubric">
<Select id="batch-rubric" style={{ width: '100%' }} placeholder="选择已发布评分规则" value={rubricId}
disabled={!versionId} options={options(rubrics.filter(rubric => rubric.published),
rubric => `规则 v${rubric.version} · ${rubric.criteria.length}项`)} onChange={setRubricId} />
</Form.Item>
<Form.Item label="选择资料" htmlFor="batch-projects">
<Select id="batch-projects" mode="multiple" style={{ width: '100%' }} maxTagCount="responsive"
popupMatchSelectWidth={600} placeholder="选择已导入的资料" disabled={!versionId}
value={projectIds} options={options(available, materialLabel)} onChange={setProjectIds} optionFilterProp="label" />
</Form.Item>
<Form.Item label=" " colon={false}>
<Button type="primary" loading={creating} disabled={!versionId || !rubrics.some(rubric => rubric.id === rubricId && rubric.published) || !selected.length}
onClick={create}>创建批次</Button>
</Form.Item>
</div>
<Space wrap>
<Button disabled={!available.length} onClick={() => setProjectIds(available.map(project => project.id))}>全选本课程资料</Button>
<Button disabled={!projectIds.length} onClick={() => setProjectIds([])}>清空选择</Button>
<Typography.Text>已选择 {selected.length} 个项目</Typography.Text>
</Space>
</Form>
{selected.length > 0 && <div className="top-gap">
<Typography.Text strong>已选资料文件({selectedFiles.length}份)</Typography.Text>
<Table size="small" rowKey={file => `${file.project_id}-${file.id}`} className="top-gap" dataSource={selectedFiles}
pagination={selectedFiles.length > 5 ? { pageSize: 5 } : false} columns={[
{ title: '资料所属学生', dataIndex: 'student', width: 250 },
{ title: '文件名', dataIndex: 'file_name' },
{ title: '材料类型', render: (_, file) => materialTypes[file.category] || file.category, width: 130 },
{ title: '解析状态', render: (_, file) => <Tag color={file.parse_status === 'ready' ? 'green' : file.parse_status === 'failed' ? 'red' : 'blue'}>{file.parse_status === 'ready' ? '已解析' : file.parse_status === 'failed' ? '解析失败' : '处理中'}</Tag>, width: 110 },
]} />
</div>}
<Typography.Paragraph type="secondary" className="top-gap" style={{ marginBottom: 0 }}>
每个项目包含该学生已确认导入的材料。批次创建后固定项目清单;后续新导入的学生不会自动加入旧批次。
</Typography.Paragraph>
</Card>
<Table className="top-gap" rowKey="id" scroll={{ x: 1250 }} dataSource={batches}
rowClassName={(batch: any) => Number(new URLSearchParams(window.location.search).get('batch_id')) === batch.id ? 'linked-row' : ''}
columns={[
{ title: '批次', width: 100, render: (_, batch) => `#${batch.id}` },
{ title: '评分项目(谁的资料)', width: 310, render: (_, batch) => {
const members = projectsInBatch(batch, projects);
const ids: number[] = batch.project_ids ?? members.map(project => project.id);
return <Space direction="vertical" size={2}>
<Typography.Text>{ids.length} 个项目</Typography.Text>
{ids.map(id => {
const project = members.find(member => member.id === id);
return project ? <Button type="link" size="small" style={{ padding: 0 }} key={id}
title={project.topic} onClick={() => openBatch(batch.id, id)}>{project.name}({project.student_no})</Button>
: <Typography.Text type="secondary" key={id}>项目 #{id}(已不存在)</Typography.Text>;
})}
{batch.legacy_membership && <Tag>历史动态项目范围</Tag>}
</Space>;
}},
{ title: '课程 / 评分规则', width: 220, render: (_, batch) => <Space direction="vertical" size={2}>
<Typography.Text>{batch.course_title || `课程版本 ID ${batch.course_version_id}`}</Typography.Text>
<Typography.Text type="secondary">课程 {batch.course_version != null ? `v${batch.course_version}` : `ID ${batch.course_version_id}`} · 规则 {batch.rubric_version != null ? `v${batch.rubric_version}` : `ID ${batch.rubric_version_id}`}{batch.criterion_count != null && `(${batch.criterion_count}项)`}</Typography.Text>
</Space> },
{ title: '状态', width: 140, render: (_, batch) => <Space wrap>
<Tag color={batch.status === 'completed' ? 'green' : batch.status === 'review_required' ? 'gold' : ['partial', 'failed'].includes(batch.status) ? 'orange' : 'blue'}>{labels[batch.status] || batch.status}</Tag>
{batch.needs_review > 0 && <Tag color="gold">待复核 {batch.needs_review}</Tag>}
</Space> },
{ title: '进度', width: 160, render: (_, batch) => <>
<Typography.Text>{batch.progress_done} / {batch.progress_total} 项</Typography.Text>
<Progress percent={batch.progress_total ? Math.round(batch.progress_done / batch.progress_total * 100) : 0} size="small" />
</> },
{ title: '异常', dataIndex: 'error', width: 150 },
{ title: '操作', width: 180, render: (_, batch) => <Space wrap>
<Button disabled={!['created', 'failed', 'partial', 'review_required'].includes(batch.status)}
onClick={async () => { try { await post(`/scoring-batches/${batch.id}/start`); refresh(); } catch (error) { fail(error); } }}>启动/重试</Button>
<Button type="link" onClick={() => openBatch(batch.id)}>查看评分</Button>
</Space> },
]} />
</>;
}