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 = { 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 = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT' }; export function Batches({ openBatch }: { openBatch: (id: number, projectId?: number) => void }) { const [batches, setBatches] = useState([]), [projects, setProjects] = useState([]); const [courses, setCourses] = useState([]), [versions, setVersions] = useState([]), [rubrics, setRubrics] = useState([]); const [courseId, setCourseId] = useState(), [versionId, setVersionId] = useState(), [rubricId, setRubricId] = useState(); const [projectIds, setProjectIds] = useState([]), [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 <> 评分批次与进度
version.published), version => `课程 v${version.version} · ${version.title}`)} onChange={chooseVersion} />
已选择 {selected.length} 个项目
{selected.length > 0 &&
已选资料文件({selectedFiles.length}份) `${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) => {file.parse_status === 'ready' ? '已解析' : file.parse_status === 'failed' ? '解析失败' : '处理中'}, width: 110 }, ]} /> } 每个项目包含该学生已确认导入的材料。批次创建后固定项目清单;后续新导入的学生不会自动加入旧批次。
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 {ids.length} 个项目 {ids.map(id => { const project = members.find(member => member.id === id); return project ? : 项目 #{id}(已不存在); })} {batch.legacy_membership && 历史动态项目范围} ; }}, { title: '课程 / 评分规则', width: 220, render: (_, batch) => {batch.course_title || `课程版本 ID ${batch.course_version_id}`} 课程 {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}项)`} }, { title: '状态', width: 140, render: (_, batch) => {labels[batch.status] || batch.status} {batch.needs_review > 0 && 待复核 {batch.needs_review}} }, { title: '进度', width: 160, render: (_, batch) => <> {batch.progress_done} / {batch.progress_total} 项 }, { title: '异常', dataIndex: 'error', width: 150 }, { title: '操作', width: 180, render: (_, batch) => }, ]} /> ; }