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.
This commit is contained in:
+74
-25
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Alert, Badge, Button, Card, Col, Collapse, Descriptions, Divider, Drawer, Form, Input, InputNumber, Layout, Menu, Modal, Progress, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd';
|
||||
import { Alert, Badge, Button, Card, Col, Collapse, Descriptions, Divider, Drawer, Form, Input, InputNumber, Layout, Menu, Modal, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd';
|
||||
import { DashboardOutlined, BookOutlined, FileSearchOutlined, FolderOpenOutlined, TeamOutlined, ThunderboltOutlined, AuditOutlined, ExportOutlined, SettingOutlined, MessageOutlined } from '@ant-design/icons';
|
||||
import { api, post, put } from './api';
|
||||
import Chat from './Chat';
|
||||
import { Batches } from './Batches';
|
||||
import { projectLabel, projectsInBatch } from './batchProjects';
|
||||
const Chat = lazy(() => import('./Chat'));
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
@@ -14,7 +16,7 @@ const nav = [
|
||||
{ key: 'courses', icon: <BookOutlined />, label: '课程与规则' },
|
||||
{ key: 'knowledge', icon: <FileSearchOutlined />, label: '知识库资料' },
|
||||
{ key: 'import', icon: <FolderOpenOutlined />, label: '目录扫描与导入' },
|
||||
{ key: 'projects', icon: <TeamOutlined />, label: '学生项目' },
|
||||
{ key: 'projects', icon: <TeamOutlined />, label: '学生资料' },
|
||||
{ key: 'batches', icon: <ThunderboltOutlined />, label: '评分批次' },
|
||||
{ key: 'scores', icon: <AuditOutlined />, label: '评分与复核' },
|
||||
{ key: 'exports', icon: <ExportOutlined />, label: '导出任务' },
|
||||
@@ -91,19 +93,40 @@ function Knowledge() {
|
||||
useEffect(() => { const id = Number(new URLSearchParams(window.location.search).get('course_version_id')); if (id) api(`/course-versions/${id}`).then(v => { setCourseId(v.course_id); setVersionId(id); }).catch(fail); }, []);
|
||||
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
|
||||
useEffect(() => { if (versionId) load(`/knowledge?course_version_id=${versionId}`, setDocs); }, [versionId]);
|
||||
return <><Title level={2}>公共课程知识库</Title><Space><Select placeholder="课程" style={{ width: 260 }} options={options(courses, c => c.name)} onChange={setCourseId} /><Select placeholder="课程版本" style={{ width: 220 }} options={options(versions, v => `v${v.version} ${v.title}`)} onChange={setVersionId} /><Button type="primary" disabled={!versionId} onClick={async () => { try { const result = await post(`/knowledge/ingest-course/${versionId}`); message.info(`入库 ${result.completed.length} 份;失败 ${Object.keys(result.errors).length} 份`); load(`/knowledge?course_version_id=${versionId}`, setDocs); } catch (e) { fail(e); } }}>入库课程资料</Button></Space><Table className="top-gap" rowKey="id" dataSource={docs} columns={[{ title: '资料', dataIndex: 'title' }, { title: '类型', dataIndex: 'type' }, { title: '块数', dataIndex: 'chunks' }, { title: '状态', dataIndex: 'status' }]} /></>;
|
||||
return <><Title level={2}>公共课程知识库</Title><Space><Select placeholder="课程" style={{ width: 260 }} options={options(courses, c => c.name)} onChange={setCourseId} /><Select placeholder="课程版本" style={{ width: 220 }} options={options(versions, v => `v${v.version} ${v.title}`)} onChange={setVersionId} /><Button type="primary" disabled={!versionId} onClick={async () => { try { const result = await post(`/knowledge/ingest-course/${versionId}`); message.info(`入库 ${result.completed.length} 份;失败 ${Object.keys(result.errors).length} 份`); load(`/knowledge?course_version_id=${versionId}`, setDocs); } catch (e) { fail(e); } }}>入库课程资料</Button></Space><Table className="top-gap" rowKey="id" dataSource={docs} columns={[{ title: '资料', dataIndex: 'title' }, { title: '类型', dataIndex: 'type' }, { title: '块数', dataIndex: 'chunks' }, { title: '状态', render: (_, doc) => <Tag color={doc.requires_reindex ? 'gold' : doc.status === 'ready' ? 'green' : 'default'}>{doc.requires_reindex ? '需重建索引' : ({ ready: '可检索', superseded: '历史版本', indexing: '索引处理中' }[doc.status as string] || doc.status)}</Tag> }]} /></>;
|
||||
}
|
||||
|
||||
function Import() {
|
||||
function Import({ openImported, scoreImported }: { openImported: (ids: number[]) => void; scoreImported: (versionId: number, ids: number[]) => void }) {
|
||||
const [path, setPath] = useState('.'), [dirs, setDirs] = useState<any[]>([]), [scanData, setScanData] = useState<any>(), [courses, setCourses] = useState<any[]>([]), [versions, setVersions] = useState<any[]>([]), [courseId, setCourseId] = useState<number>(), [versionId, setVersionId] = useState<number>();
|
||||
const [imported, setImported] = useState<{ courseVersionId: number; projectIds: number[] }>();
|
||||
const [importing, setImporting] = useState(false);
|
||||
useEffect(() => { load('/courses', setCourses); }, []);
|
||||
useEffect(() => { load(`/storage/directories?relative=${encodeURIComponent(path)}`, setDirs); }, [path]);
|
||||
useEffect(() => { setScanData(undefined); setImported(undefined); load(`/storage/directories?relative=${encodeURIComponent(path)}`, setDirs); }, [path]);
|
||||
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
|
||||
const editProject = (index: number, key: string, value: any) => { const next = structuredClone(scanData); next.projects[index][key] = value; setScanData(next); };
|
||||
const editFile = (projectIndex: number, fileIndex: number, key: string, value: any) => { const next = structuredClone(scanData); next.projects[projectIndex].files[fileIndex][key] = value; setScanData(next); };
|
||||
return <><Title level={2}>挂载目录扫描与导入确认</Title><Paragraph>浏览 <code>/data/inbox</code> 下的目录,选择包含学生子文件夹的大目录。原始文件不会经过浏览器上传。</Paragraph><Space><Button disabled={path === '.'} onClick={() => setPath(path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')}>上一级</Button><Text code>{path}</Text><Button type="primary" onClick={async () => { try { setScanData(await post('/import/scan', { relative_dir: path })); } catch (e) { fail(e); } }}>扫描此目录</Button></Space><div className="folder-list">{dirs.map(dir => <Button key={dir.relative_path} onClick={() => setPath(dir.relative_path)}>📁 {dir.name}</Button>)}</div>
|
||||
{scanData && <><Divider /><Space><Select style={{ width: 260 }} placeholder="课程" options={options(courses, c => c.name)} onChange={setCourseId} /><Select style={{ width: 270 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} /><Button type="primary" disabled={!versionId} onClick={async () => { try { const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no, name: p.name, topic: p.topic, direction: p.direction || '', files: p.files.map((f: any) => ({ relative_path: f.relative_path, category: f.category, status: f.status === 'ignored' ? 'ignored' : 'recognized', sha256: f.sha256 })) })); const result = await post('/import/confirm', { course_version_id: versionId, projects }); message.success(`已导入 ${result.project_ids.length} 个项目`); } catch (e) { fail(e); } }}>确认导入</Button></Space><Table className="top-gap" rowKey="relative_dir" dataSource={scanData.projects} pagination={{ pageSize: 10 }} columns={[{ title: '学号', render: (_, p, index) => <Input value={p.student_no} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'student_no', e.target.value)} /> }, { title: '姓名', render: (_, p, index) => <Input value={p.name} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'name', e.target.value)} /> }, { title: '选题', render: (_, p, index) => <Input value={p.topic} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'topic', e.target.value)} /> }, { title: '选题方向', render: (_, p) => <Input value={p.direction || ''} placeholder="可修正" onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'direction', e.target.value)} /> }, { title: '待处理', render: (_, p) => <Space wrap>{p.status !== 'recognized' && <Tag color="orange">目录需确认</Tag>}{p.missing.map((c: string) => <Tag color="red" key={c}>缺{categories[c]}</Tag>)}{p.warnings?.map((w: string) => <Tag color="orange" key={w}>{w}</Tag>)}</Space> }]}
|
||||
expandable={{ expandedRowRender: (p: any) => { const pi = scanData.projects.findIndex((item: any) => item.relative_dir === p.relative_dir); return <Table<any> size="small" rowKey="relative_path" pagination={false} dataSource={p.files} columns={[{ title: '文件', dataIndex: 'relative_path' }, { title: '类型', render: (_, f, fi) => <Select style={{ width: 170 }} value={f.category} options={[{ value: 'unknown', label: '未识别' }, ...Object.entries(categories).map(([value, label]) => ({ value, label }))]} onChange={value => editFile(pi, fi, 'category', value)} /> }, { title: '处理', render: (_, f, fi) => <Select style={{ width: 120 }} value={f.status === 'ignored' ? 'ignored' : 'recognized'} options={[{ value: 'recognized', label: '导入' }, { value: 'ignored', label: '忽略' }]} onChange={value => editFile(pi, fi, 'status', value)} /> }, { title: '扫描结果', render: (_, f) => <Tag color={f.status === 'recognized' ? 'green' : 'orange'}>{f.status}</Tag> }, { title: '异常', dataIndex: 'error' }]} />; } }} /></>}
|
||||
const confirm = async () => {
|
||||
if (!versionId || !scanData?.projects?.length) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no,
|
||||
name: p.name, topic: p.topic, direction: p.direction || '',
|
||||
files: p.files.map((f: any) => ({ relative_path: f.relative_path, category: f.category,
|
||||
status: f.status === 'ignored' ? 'ignored' : 'recognized', sha256: f.sha256 })) }));
|
||||
const result = await post('/import/confirm', { course_version_id: versionId, projects });
|
||||
setImported({ courseVersionId: versionId, projectIds: result.project_ids });
|
||||
message.success(`已导入 ${result.project_ids.length} 个项目`);
|
||||
} catch (error) { fail(error); }
|
||||
finally { setImporting(false); }
|
||||
};
|
||||
return <><Title level={2}>挂载目录扫描与导入确认</Title><Paragraph>浏览 <code>/data/inbox</code> 下的目录,可选择包含学生子文件夹的总目录,也可直接进入放有 PDF、DOCX、PPTX 的资料目录扫描。封面信息会预填,请核对学号、姓名和选题后确认导入。</Paragraph><Space><Button disabled={path === '.'} onClick={() => setPath(path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')}>上一级</Button><Text code>{path}</Text><Button type="primary" onClick={async () => { try { setImported(undefined); setScanData(await post('/import/scan', { relative_dir: path })); } catch (e) { fail(e); } }}>扫描此目录</Button></Space><div className="folder-list">{dirs.map(dir => <Button key={dir.relative_path} onClick={() => setPath(dir.relative_path)}>📁 {dir.name}</Button>)}</div>
|
||||
{imported && imported.projectIds.length > 0 && <Alert className="top-gap" type="success" showIcon
|
||||
message={`已导入 ${imported.projectIds.length} 个学生资料包`}
|
||||
description="点击查看已导入资料核对文件与解析状态,或去评分选择这次导入的资料。"
|
||||
action={<Space wrap><Button onClick={() => openImported(imported.projectIds)}>查看已导入资料</Button>
|
||||
<Button type="primary" onClick={() => scoreImported(imported.courseVersionId, imported.projectIds)}>去评分</Button></Space>} />}
|
||||
{scanData && <><Divider />{!scanData.projects.length && <Alert className="top-gap" type="warning" showIcon message="未找到可导入资料" description="当前目录及其下一级学生目录中没有 PDF、DOCX 或 PPTX;请进入放有这些文件的目录再扫描。" />}<Space><Select aria-label="导入课程" style={{ width: 260 }} placeholder="课程" options={options(courses, c => c.name)} value={courseId} onChange={id => { setCourseId(id); setVersionId(undefined); setVersions([]); setImported(undefined); }} /><Select aria-label="导入课程版本" style={{ width: 270 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} /><Button type="primary" loading={importing} disabled={!versionId || importing || !scanData.projects.length || scanData.projects.some((p: any) => !/^\d{5,20}$/.test(p.student_no) || !p.name?.trim() || !p.topic?.trim() || !p.files.some((f: any) => f.status !== 'ignored'))} onClick={confirm}>确认导入</Button></Space><Table className="top-gap" rowKey="relative_dir" dataSource={scanData.projects} pagination={{ pageSize: 10 }} columns={[{ title: '学号', render: (_, p, index) => <Input value={p.student_no} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'student_no', e.target.value)} /> }, { title: '姓名', render: (_, p, index) => <Input value={p.name} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'name', e.target.value)} /> }, { title: '选题', render: (_, p, index) => <Input value={p.topic} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'topic', e.target.value)} /> }, { title: '选题方向', render: (_, p) => <Input value={p.direction || ''} placeholder="可修正" onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'direction', e.target.value)} /> }, { title: '待处理', render: (_, p) => <Space wrap>{p.status !== 'recognized' && <Tag color="orange">身份信息需确认</Tag>}{p.missing.map((c: string) => <Tag color="red" key={c}>缺{categories[c]}</Tag>)}{p.warnings?.map((w: string) => <Tag color="orange" key={w}>{w}</Tag>)}</Space> }]}
|
||||
expandable={{ defaultExpandAllRows: true, expandedRowRender: (p: any) => { const pi = scanData.projects.findIndex((item: any) => item.relative_dir === p.relative_dir); return <Table<any> size="small" rowKey="relative_path" pagination={false} dataSource={p.files} columns={[{ title: '文件', dataIndex: 'relative_path' }, { title: '类型', render: (_, f, fi) => <Select style={{ width: 170 }} value={f.category} options={[{ value: 'unknown', label: '未识别' }, ...Object.entries(categories).map(([value, label]) => ({ value, label }))]} onChange={value => editFile(pi, fi, 'category', value)} /> }, { title: '处理', render: (_, f, fi) => <Select style={{ width: 120 }} value={f.status === 'ignored' ? 'ignored' : 'recognized'} options={[{ value: 'recognized', label: '导入' }, { value: 'ignored', label: '忽略' }]} onChange={value => editFile(pi, fi, 'status', value)} /> }, { title: '扫描结果', render: (_, f) => <Tag color={f.status === 'recognized' ? 'green' : 'orange'}>{f.status}</Tag> }, { title: '异常', dataIndex: 'error' }]} />; } }} /></>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -112,25 +135,46 @@ function Projects({ openScores }: { openScores: (id: number) => void }) {
|
||||
useEffect(() => { load('/projects', setProjects); }, []);
|
||||
useEffect(() => { const id = Number(new URLSearchParams(window.location.search).get('project_id')); if (id) load(`/projects/${id}`, setSelected); }, []);
|
||||
useEffect(() => { if (selected?.id) load(`/index-tasks?project_id=${selected.id}`, setIndexTasks); }, [selected?.id]);
|
||||
return <><Title level={2}>学生项目</Title><Table rowKey="id" dataSource={projects} columns={[{ title: '学号', dataIndex: 'student_no' }, { title: '姓名', dataIndex: 'name' }, { title: '选题', dataIndex: 'topic' }, { title: '缺失材料', render: (_, p) => p.missing.length ? p.missing.map((c: string) => <Tag color="red" key={c}>{categories[c]}</Tag>) : <Tag color="green">齐全</Tag> }, { title: '解析异常', render: (_, p) => p.parse_errors.length ? <Badge status="error" text={p.parse_errors.length} /> : '—' }, { title: '操作', render: (_, p) => <Space><Button onClick={() => load(`/projects/${p.id}`, setSelected)}>材料详情</Button><Button type="link" onClick={() => openScores(p.id)}>查看评分</Button></Space> }]} /><Drawer open={!!selected} onClose={() => setSelected(undefined)} title={selected?.topic} width={700}>{selected && <><Descriptions column={1} items={[{ key: 'no', label: '学号', children: selected.student_no }, { key: 'name', label: '姓名', children: selected.name }, { key: 'direction', label: '选题方向', children: selected.direction || '未指定' }]} /><Table<any> rowKey="id" size="small" dataSource={selected.files} columns={[{ title: '材料', render: (_, f) => categories[f.category] }, { title: '文件', dataIndex: 'relative_path' }, { title: '解析', render: (_, f) => <Tag color={f.parse_status === 'ready' ? 'green' : 'orange'}>{f.parse_status}</Tag> }, { title: '错误', dataIndex: 'parse_error' }]} /><Title level={5}>解析与索引任务</Title><Table<any> rowKey="id" size="small" pagination={false} dataSource={indexTasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '材料 ID', dataIndex: 'submission_file_id' }, { title: '状态', render: (_, task) => <Tag color={task.status === 'completed' ? 'green' : task.status === 'failed' ? 'red' : 'orange'}>{task.status}</Tag> }, { title: '错误', dataIndex: 'error' }, { title: '操作', render: (_, task) => task.status === 'failed' && <Button size="small" onClick={async () => { try { await post(`/index-tasks/${task.id}/retry`); load(`/index-tasks?project_id=${selected.id}`, setIndexTasks); } catch (e) { fail(e); } }}>重试</Button> }]} /><Title level={5}>抽取事实与一致性</Title><pre className="json-view">{JSON.stringify(selected.facts, null, 2)}</pre></>}</Drawer></>;
|
||||
}
|
||||
|
||||
function Batches({ openBatch }: { openBatch: (id: number) => void }) {
|
||||
const [batches, setBatches] = useState<any[]>([]), [courses, setCourses] = useState<any[]>([]), [versions, setVersions] = useState<any[]>([]), [rubrics, setRubrics] = useState<any[]>([]), [courseId, setCourseId] = useState<number>(), [versionId, setVersionId] = useState<number>(), [rubricId, setRubricId] = useState<number>();
|
||||
const refresh = () => load('/scoring-batches', setBatches);
|
||||
useEffect(() => { refresh(); load('/courses', setCourses); const id = window.setInterval(refresh, 5000); return () => clearInterval(id); }, []);
|
||||
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
|
||||
useEffect(() => { if (versionId) load(`/course-versions/${versionId}/rubrics`, setRubrics); }, [versionId]);
|
||||
return <><Title level={2}>评分批次与进度</Title><Card title="创建独立批次"><Space wrap><Select style={{ width: 250 }} placeholder="课程" options={options(courses, c => c.name)} onChange={setCourseId} /><Select style={{ width: 250 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} onChange={setVersionId} /><Select style={{ width: 230 }} placeholder="已发布规则版本" options={options(rubrics.filter(r => r.published), r => `v${r.version} · ${r.criteria.length}项`)} onChange={setRubricId} /><Button type="primary" disabled={!versionId || !rubricId} onClick={async () => { try { await post('/scoring-batches', { course_version_id: versionId, rubric_version_id: rubricId }); refresh(); message.success('批次已创建'); } catch (e) { fail(e); } }}>创建批次</Button></Space></Card><Table className="top-gap" rowKey="id" rowClassName={(b: any) => Number(new URLSearchParams(window.location.search).get("batch_id")) === b.id ? "linked-row" : ""} dataSource={batches} columns={[{ title: '批次', dataIndex: 'id' }, { title: '课程/规则版本', render: (_, b) => `${b.course_version_id} / ${b.rubric_version_id}` }, { title: '状态', render: (_, b) => <Space wrap><Tag color={b.status === 'completed' ? 'green' : b.status === 'review_required' ? 'gold' : b.status === 'partial' || b.status === 'failed' ? 'orange' : 'blue'}>{batchStatusLabel(b.status)}</Tag>{b.needs_review > 0 && <Tag color="gold">待复核 {b.needs_review}</Tag>}</Space> }, { title: '进度', render: (_, b) => <Progress percent={b.progress_total ? Math.round(b.progress_done / b.progress_total * 100) : 0} size="small" /> }, { title: '异常', dataIndex: 'error' }, { title: '操作', render: (_, b) => <Space><Button disabled={!['created', 'failed', 'partial', 'review_required'].includes(b.status)} onClick={async () => { try { await post(`/scoring-batches/${b.id}/start`); refresh(); } catch (e) { fail(e); } }}>启动/重试</Button><Button type="link" onClick={() => openBatch(b.id)}>查看评分</Button></Space> }]} /></>;
|
||||
return <><Title level={2}>已导入学生资料</Title><Table rowKey="id" dataSource={projects} columns={[{ title: '学号', dataIndex: 'student_no' }, { title: '姓名', dataIndex: 'name' }, { title: '选题', dataIndex: 'topic' }, { title: '缺失材料', render: (_, p) => p.missing.length ? p.missing.map((c: string) => <Tag color="red" key={c}>{categories[c]}</Tag>) : <Tag color="green">齐全</Tag> }, { title: '解析异常', render: (_, p) => p.parse_errors.length ? <Badge status="error" text={p.parse_errors.length} /> : '—' }, { title: '操作', render: (_, p) => <Space><Button onClick={() => load(`/projects/${p.id}`, setSelected)}>材料详情</Button><Button type="link" onClick={() => openScores(p.id)}>查看评分</Button></Space> }]} /><Drawer open={!!selected} onClose={() => setSelected(undefined)} title={selected?.topic} width={700}>{selected && <><Descriptions column={1} items={[{ key: 'no', label: '学号', children: selected.student_no }, { key: 'name', label: '姓名', children: selected.name }, { key: 'direction', label: '选题方向', children: selected.direction || '未指定' }]} /><Table<any> rowKey="id" size="small" dataSource={selected.files} columns={[{ title: '材料', render: (_, f) => categories[f.category] }, { title: '文件', dataIndex: 'relative_path' }, { title: '解析', render: (_, f) => <Tag color={f.parse_status === 'ready' ? 'green' : 'orange'}>{f.parse_status}</Tag> }, { title: '错误', dataIndex: 'parse_error' }, { title: '解析提醒', render: (_, f) => <Space wrap>{f.warnings?.map((warning: any, index: number) => <Tag color="gold" key={index}>第{warning.page}页:{warning.message}</Tag>)}</Space> }]} /><Title level={5}>解析与索引任务</Title><Table<any> rowKey="id" size="small" pagination={false} dataSource={indexTasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '材料 ID', dataIndex: 'submission_file_id' }, { title: '状态', render: (_, task) => <Tag color={task.status === 'completed' ? 'green' : task.status === 'failed' ? 'red' : 'orange'}>{task.status}</Tag> }, { title: '错误', dataIndex: 'error' }, { title: '操作', render: (_, task) => task.status === 'failed' && <Button size="small" onClick={async () => { try { await post(`/index-tasks/${task.id}/retry`); load(`/index-tasks?project_id=${selected.id}`, setIndexTasks); } catch (e) { fail(e); } }}>重试</Button> }]} /><Title level={5}>抽取事实与一致性</Title><pre className="json-view">{JSON.stringify(selected.facts, null, 2)}</pre></>}</Drawer></>;
|
||||
}
|
||||
|
||||
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 [form] = Form.useForm();
|
||||
useEffect(() => { load('/scoring-batches', setBatches); load('/projects', setProjects); }, []);
|
||||
useEffect(() => { if (batch && project) load(`/projects/${project}/scores?batch_id=${batch}`, setResult); }, [batch, project]);
|
||||
const [sourcesReady, setSourcesReady] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([api('/scoring-batches'), api('/projects')]).then(([batchRows, projectRows]) => {
|
||||
if (cancelled) return;
|
||||
setBatches(batchRows); setProjects(projectRows); setSourcesReady(true);
|
||||
}).catch(error => { if (!cancelled) fail(error); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
const selectedBatch = batches.find(item => item.id === batch);
|
||||
const scopedProjects = projectsInBatch(selectedBatch, projects);
|
||||
useEffect(() => {
|
||||
setResult(undefined); setReview(undefined); setRevisions([]);
|
||||
if (!sourcesReady || !selectedBatch) return;
|
||||
if (!scopedProjects.some(item => item.id === project)) {
|
||||
setProject(scopedProjects.length === 1 ? scopedProjects[0].id : undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api(`/projects/${project}/scores?batch_id=${batch}`).then(value => { if (!cancelled) setResult(value); })
|
||||
.catch(error => { if (!cancelled) fail(error); });
|
||||
return () => { cancelled = true; };
|
||||
}, [batch, project, batches, projects, sourcesReady]);
|
||||
const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult);
|
||||
return <><Title level={2}>逐项评分与教师复核</Title><Space wrap><Select style={{ width: 180 }} placeholder="评分批次" value={batch} options={options(batches, b => `批次 #${b.id} · ${b.status}`)} onChange={setBatch} /><Select style={{ width: 330 }} placeholder="学生项目" value={project} options={options(projects, p => `${p.student_no} ${p.name} · ${p.topic}`)} onChange={setProject} /></Space>{result && <><Card className="top-gap" title={`${result.student_no} ${result.name} · ${result.topic}`}><Space wrap>{Object.entries(result.totals).map(([key, value]) => <Tag key={key} color={value === null ? 'orange' : 'blue'}>{key}: {String(value ?? '待处理')}</Tag>)}</Space>{result.facts?.consistency_issues?.length > 0 && <Alert className="top-gap" type="warning" message="跨文档一致性提醒" description={result.facts.consistency_issues.map((issue: any) => issue.description).join(';')} />}</Card><Table className="top-gap" rowKey={(row: any) => row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <><Tag>{categories[r.criterion.category] || r.criterion.category}</Tag>{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) => <Tag color={statusColor(r.status)}>{statusLabel(r.status)}</Tag> }, { title: '操作', render: (_, r) => <Button onClick={() => { setReview(r); form.setFieldsValue(reviewFormValues(r)); }}>查看证据/复核</Button> }]} /></>}
|
||||
return <><Title level={2}>逐项评分与教师复核</Title>
|
||||
<Space wrap>
|
||||
<Select aria-label="评分批次" style={{ width: 250 }} placeholder="评分批次" value={batch}
|
||||
options={options(batches, b => `批次 #${b.id} · ${batchStatusLabel(b.status)}`)} onChange={setBatch} />
|
||||
<Select aria-label="学生项目" style={{ width: 460 }} placeholder="选择该批次中的学生项目" value={project}
|
||||
disabled={!sourcesReady || !selectedBatch} options={options(scopedProjects, projectLabel)} onChange={setProject} />
|
||||
</Space>
|
||||
{selectedBatch && <Alert className="top-gap" type="info" showIcon
|
||||
message={`批次 #${batch} 包含 ${scopedProjects.length} 个项目。下方分数仅对应当前选中的学生项目。`} />}
|
||||
{result && <>{result.rows.some((r: any) => r.status === 'failed') && <Alert className="top-gap" type="error" showIcon message={`当前项目有 ${result.rows.filter((r: any) => r.status === 'failed').length} 项评分失败`} description="具体原因见下表。网络恢复后,可在评分批次页点击启动/重试;已经成功的评分和教师复核会保留。" />}<Card className="top-gap" title={`${result.student_no} ${result.name} · ${result.topic}`}><Space wrap>{Object.entries(result.totals).map(([key, value]) => <Tag key={key} color={value === null ? 'orange' : 'blue'}>{key}: {String(value ?? '待处理')}</Tag>)}</Space>{result.facts?.consistency_issues?.length > 0 && <Alert className="top-gap" type="warning" message="跨文档一致性提醒" description={result.facts.consistency_issues.map((issue: any) => issue.description).join(';')} />}</Card><Table className="top-gap" rowKey={(row: any) => row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <><Tag>{categories[r.criterion.category] || r.criterion.category}</Tag>{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) => <Space direction="vertical"><Tag color={statusColor(r.status)}>{statusLabel(r.status)}</Tag>{r.error && <Text type="secondary" style={{ maxWidth: 300 }}>{r.error}</Text>}</Space> }, { title: '操作', render: (_, r) => <Button onClick={() => { setReview(r); form.setFieldsValue(reviewFormValues(r)); }}>查看证据/复核</Button> }]} /></>}
|
||||
<Modal open={!!review} title={review?.criterion?.name} width={850} onCancel={() => 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' && <Alert type="warning" showIcon className="top-gap" message="本项缺少正文证据,需要教师复核" description={`未找到正文证据的目标:${(review.evidence_gaps || []).map((gap: any) => gap.target_label).join('、') || review.missing_evidence?.join('、') || '见缺失证据'}。系统未给出模型分,也没有按低分处理;请核对材料后直接填写教师评分。`} />}<Descriptions column={1} items={[{ key: 'model', label: '模型分数/评语', children: `${review.model_score ?? '未给出'} · ${review.model_comment || ''}` }, { key: 'confidence', label: '模型置信度', children: review.confidence ?? '—' }, { key: 'missing', label: '缺失证据', children: review.missing_evidence?.join(';') || '无' }, { key: 'gaps', label: '缺正文证据的目标', children: (review.evidence_gaps || []).map((gap: any) => `${gap.target_label}(检索范围:${(gap.searched_page_types || []).map(pageTypeLabel).join('、') || '正文'})`).join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} /><Divider />{review.evidence?.map((ev: any, index: number) => <Card size="small" key={index} className="evidence"><Space wrap><Text strong>{ev.document} · {evidencePlace(ev)}</Text><Tag color={ev.page_type === 'body' || ev.page_type === 'appendix' ? 'green' : 'orange'}>{pageTypeLabel(ev.page_type)}</Tag>{ev.historical && <Tag>历史评分引用</Tag>}{ev.target_label && <Tag>{ev.target_label}</Tag>}</Space><Paragraph>{ev.quote}</Paragraph><Space wrap>{ev.url && <a href={ev.url} target="_blank" rel="noreferrer">查看页面文字</a>}{ev.image_url && <a href={ev.image_url} target="_blank" rel="noreferrer">{evidencePreviewLabel(ev)}(核对引文)</a>}</Space></Card>)}<Divider /><Form form={form} layout="vertical"><Form.Item label="教师评分" name="score" rules={[{ required: true, message: '请填写教师评分' }]}><InputNumber min={0} max={review.criterion.max_score} placeholder="请填写" /></Form.Item><Form.Item label="教师评语" name="comment" rules={[{ required: true }]}><Input.TextArea rows={3} /></Form.Item><Form.Item label="修改说明" name="reason"><Input /></Form.Item></Form>{review.score_id && <Button onClick={async () => { try { setRevisions(await api(`/criterion-scores/${review.score_id}/revisions`)); } catch (e) { fail(e); } }}>查看修改记录</Button>}{revisions.length > 0 && <pre className="json-view">{JSON.stringify(revisions, null, 2)}</pre>}</>}</Modal>
|
||||
</>;
|
||||
}
|
||||
@@ -164,7 +208,12 @@ export default function App() {
|
||||
};
|
||||
useEffect(() => { const onPop = () => { const query = new URLSearchParams(window.location.search); setPage(query.get('page') || 'chat'); setBatchId(Number(query.get('batch_id')) || undefined); setProjectId(Number(query.get('project_id')) || undefined); }; window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, []);
|
||||
const openScores = (id: number) => navigate('scores', batchId, id);
|
||||
const openBatch = (id: number) => navigate('scores', id, projectId);
|
||||
const pages: Record<string, ReactNode> = { chat: <Chat />, dashboard: <Dashboard />, courses: <Courses />, knowledge: <Knowledge />, import: <Import />, projects: <Projects openScores={openScores} />, batches: <Batches openBatch={openBatch} />, scores: <Scores key={`${batchId}-${projectId}`} batchId={batchId} projectId={projectId} />, exports: <Exports />, settings: <Settings /> };
|
||||
const openBatch = (id: number, targetProject?: number) => navigate('scores', id, targetProject);
|
||||
const scoreImported = (versionId: number, ids: number[]) => {
|
||||
navigate('batches');
|
||||
const query = new URLSearchParams({ page: 'batches', course_version_id: String(versionId), project_ids: ids.join(',') });
|
||||
window.history.replaceState({}, '', `/?${query.toString()}`);
|
||||
};
|
||||
const pages: Record<string, ReactNode> = { chat: <Suspense fallback={<div>正在加载会话…</div>}><Chat /></Suspense>, dashboard: <Dashboard />, courses: <Courses />, knowledge: <Knowledge />, import: <Import openImported={ids => navigate('projects', undefined, ids.length === 1 ? ids[0] : undefined)} scoreImported={scoreImported} />, projects: <Projects openScores={openScores} />, batches: <Batches key={`${params.get("course_version_id")}-${params.get("project_ids")}`} openBatch={openBatch} />, scores: <Scores key={`${batchId}-${projectId}`} batchId={batchId} projectId={projectId} />, exports: <Exports />, settings: <Settings /> };
|
||||
return <Layout className="shell"><Sider breakpoint="lg" collapsedWidth="0" width={230} theme="dark"><div className="brand"><span className="brand-icon">智</span><div><strong>WiseGrade</strong><small>教师工作台</small></div></div><Menu theme="dark" mode="inline" selectedKeys={[page]} items={nav} onClick={({ key }) => navigate(key)} /></Sider><Layout><Header className="header"><div><strong>智评</strong><span className="header-sub">自动化专业综合实践</span></div><Tag color="blue">单机教师端</Tag></Header><Content className="content"><div className="content-inner">{pages[page] || pages.chat}</div></Content></Layout></Layout>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user