ebe33392ca
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.
165 lines
11 KiB
TypeScript
165 lines
11 KiB
TypeScript
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> },
|
||
]} />
|
||
</>;
|
||
}
|