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:
Generated
+3
-3
@@ -4107,9 +4107,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"overrides": { "source-map-js": "^1.2.2" },
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
|
||||
+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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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> },
|
||||
]} />
|
||||
</>;
|
||||
}
|
||||
@@ -112,7 +112,9 @@ function CitationCards({ items }: { items: any[] }) {
|
||||
const sourceHref = safeUrl(item.url);
|
||||
const scoreHref = safeUrl(item.score_url);
|
||||
const imageHref = safeUrl(item.image_url);
|
||||
return <div className="citation-card" key={`${name}-${page}-${index}`}><div className="citation-heading"><Text strong>{name}</Text>{place && <Tag>{place}</Tag>}</div>
|
||||
return <div className="citation-card" key={`${name}-${page}-${index}`}><div className="citation-heading"><Text strong>{name}</Text>{place && <Tag>{place}</Tag>}
|
||||
{item.page_type_label && <Tag color={['body', 'appendix'].includes(item.page_type) ? 'green' : 'orange'}>{item.page_type_label}</Tag>}
|
||||
{item.source_role === 'structure' && <Tag>结构信息</Tag>}{item.historical && <Tag>历史评分引用</Tag>}</div>
|
||||
{quote && (quote.length > 320 ? <details><summary>{quote.slice(0, 240)}… 展开原文</summary><p>{quote}</p></details> : <p>{quote}</p>)}
|
||||
<Space wrap size="small">{sourceHref && <a href={sourceHref} target="_blank" rel="noopener noreferrer">{sourceLabel(sourceHref)}</a>}
|
||||
{scoreHref && scoreHref !== sourceHref && <a href={scoreHref} target="_blank" rel="noopener noreferrer">查看评分</a>}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const projectLabel = (project: any) =>
|
||||
`${project.name}(${project.student_no}) · ${project.topic}`;
|
||||
|
||||
export function projectsInBatch(batch: any, projects: any[]): any[] {
|
||||
if (!batch) return [];
|
||||
// [] is an empty snapshot. Only null/absent membership is a legacy batch.
|
||||
if (batch.project_ids == null) {
|
||||
return projects.filter(project => project.course_version_id === batch.course_version_id);
|
||||
}
|
||||
const byId = new Map(projects.map(project => [project.id, project]));
|
||||
return batch.project_ids.map((id: number) => byId.get(id)).filter(Boolean);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ body { margin: 0; }
|
||||
.content { padding: 28px; }
|
||||
.content-inner { max-width: 1450px; margin: 0 auto; }
|
||||
.top-gap { margin-top: 20px; }
|
||||
.batch-create-grid { display: grid; grid-template-columns: minmax(165px,.9fr) minmax(200px,1fr) minmax(160px,.8fr) minmax(260px,1.5fr) auto; gap: 10px; align-items: start; }
|
||||
.batch-create-grid .ant-form-item { min-width: 0; }
|
||||
@media (max-width: 1100px) { .batch-create-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width: 700px) { .batch-create-grid { grid-template-columns: minmax(0,1fr); } }
|
||||
.left-gap { margin-left: 12px; }
|
||||
.folder-list { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 18px; }
|
||||
.folder-list button { height: auto; min-height: 40px; text-align: left; }
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const projects = [
|
||||
{ id: 11, course_version_id: 1, student_no: '9000000001', name: '学生甲', topic: '真实报告样例', missing: ['stage1'], parse_errors: [] },
|
||||
{ id: 12, course_version_id: 1, student_no: '9000000002', name: '学生乙', topic: '完整模拟资料', missing: [], parse_errors: [], materials: [
|
||||
{ id: 121, file_name: '最终报告.pdf', category: 'final_report', parse_status: 'ready' },
|
||||
{ id: 122, file_name: '汇报PPT.pdf', category: 'presentation', parse_status: 'ready' },
|
||||
] },
|
||||
{ id: 13, course_version_id: 2, student_no: '9000000003', name: '学生丙', topic: '另一课程项目', missing: [], parse_errors: [] },
|
||||
];
|
||||
|
||||
async function setup(page: Page) {
|
||||
const creates: any[] = [], scoreReads: string[] = [];
|
||||
const batches: any[] = [
|
||||
{ id: 71, course_version_id: 1, rubric_version_id: 1, course_version: 1, rubric_version: 1,
|
||||
course_title: '初始课程版本', criterion_count: 22, project_ids: [11], status: 'created',
|
||||
progress_done: 0, progress_total: 0, error: '' },
|
||||
];
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(/^\/api/, '');
|
||||
let data: any = [];
|
||||
if (path === '/courses') data = [{ id: 1, name: '课程甲' }, { id: 2, name: '课程乙' }];
|
||||
else if (path === '/courses/1/versions') data = [{ id: 1, version: 1, title: '初始课程版本', published: true }];
|
||||
else if (path === '/courses/2/versions') data = [{ id: 2, version: 1, title: '其他课程版本', published: true }];
|
||||
else if (path === '/course-versions/1') data = { id: 1, course_id: 1, version: 1, title: '初始课程版本', published: true };
|
||||
else if (path === '/course-versions/1/rubrics') data = [{ id: 1, version: 1, published: true, criteria: Array(22).fill({}) }];
|
||||
else if (path === '/course-versions/2/rubrics') data = [{ id: 2, version: 1, published: true, criteria: Array(22).fill({}) }];
|
||||
else if (path === '/projects') data = projects;
|
||||
else if (path === '/import/scan') data = { projects: [{ relative_dir: 'test/student', student_no: '9000000002',
|
||||
name: '学生乙', topic: '完整模拟资料', status: 'recognized', missing: [], warnings: [],
|
||||
files: [{ relative_path: 'test/student/最终报告.pdf', category: 'final_report', status: 'recognized', sha256: 'a'.repeat(64) }] }] };
|
||||
else if (path === '/import/confirm') data = { project_ids: [12], index_task_ids: [1] };
|
||||
else if (path === '/scoring-batches') {
|
||||
if (request.method() === 'POST') {
|
||||
const body = request.postDataJSON(); creates.push(body);
|
||||
const batch = { ...batches[0], id: 72, project_ids: body.project_ids };
|
||||
batches.unshift(batch); data = { id: 72, status: 'created' };
|
||||
} else data = batches;
|
||||
} else if (path.startsWith('/projects/') && path.endsWith('/scores')) {
|
||||
scoreReads.push(request.url());
|
||||
data = { project_id: 11, student_no: '9000000001', name: '学生甲', topic: '真实报告样例',
|
||||
totals: { overall: null }, rows: [], facts: {} };
|
||||
}
|
||||
await route.fulfill({ json: data });
|
||||
});
|
||||
return { creates, scoreReads };
|
||||
}
|
||||
|
||||
async function choose(page: Page, label: string, option: string | RegExp) {
|
||||
const selector = page.getByRole('combobox', { name: label, exact: true });
|
||||
await selector.focus();
|
||||
await selector.press('ArrowDown');
|
||||
await page.locator('.ant-select-dropdown:visible').getByText(option, { exact: typeof option === 'string' }).click();
|
||||
}
|
||||
|
||||
test('batch list identifies its students instead of showing only rule v1', async ({ page }) => {
|
||||
await setup(page); await page.goto('/?page=batches');
|
||||
const row = page.getByRole('row').filter({ hasText: '#71' });
|
||||
await expect(row).toContainText('学生甲');
|
||||
await expect(row).toContainText('9000000001');
|
||||
await expect(row).not.toContainText('学生乙');
|
||||
});
|
||||
|
||||
test('choose only one of two same-course projects when creating a batch', async ({ page }) => {
|
||||
const state = await setup(page); await page.goto('/?page=batches');
|
||||
await choose(page, '课程', '课程甲');
|
||||
await choose(page, '课程版本', /初始课程版本/);
|
||||
await choose(page, '评分规则', /22项/);
|
||||
await expect(page.getByRole('button', { name: '创建批次', exact: true })).toBeDisabled();
|
||||
await choose(page, '选择资料', /学生乙/);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: '创建批次', exact: true }).click();
|
||||
await expect.poll(() => state.creates.length).toBe(1);
|
||||
expect(state.creates[0]).toEqual({ course_version_id: 1, rubric_version_id: 1, project_ids: [12] });
|
||||
await expect(page.getByRole('row').filter({ hasText: '#72' })).toContainText('学生乙');
|
||||
});
|
||||
|
||||
test('changing course clears previous rules and selected projects', async ({ page }) => {
|
||||
await setup(page); await page.goto('/?page=batches');
|
||||
await choose(page, '课程', '课程甲');
|
||||
await choose(page, '课程版本', /初始课程版本/);
|
||||
await choose(page, '评分规则', /22项/);
|
||||
await choose(page, '选择资料', /学生乙/);
|
||||
await page.keyboard.press('Escape');
|
||||
await choose(page, '课程', '课程乙');
|
||||
await expect(page.getByRole('button', { name: '创建批次', exact: true })).toBeDisabled();
|
||||
await expect(page.getByText('已选择 0 个项目', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('score selector excludes projects outside the fixed batch membership', async ({ page }) => {
|
||||
const state = await setup(page); await page.goto('/?page=scores&batch_id=71&project_id=12');
|
||||
const selector = page.getByRole('combobox', { name: '学生项目', exact: true });
|
||||
await selector.focus();
|
||||
await selector.press('ArrowDown');
|
||||
await expect(page.locator('.ant-select-dropdown:visible').getByText(/学生甲/)).toBeVisible();
|
||||
await expect(page.locator('.ant-select-dropdown:visible').getByText(/学生乙/)).toHaveCount(0);
|
||||
expect(state.scoreReads.every(url => !url.includes('/projects/12/'))).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
test('material selection follows the rule and displays its PDF filenames', async ({ page }) => {
|
||||
const state = await setup(page);
|
||||
await page.goto('/?page=batches&course_version_id=1&project_ids=12');
|
||||
await expect(page.getByText('已选择 1 个项目', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('最终报告.pdf', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('汇报PPT.pdf', { exact: true })).toBeVisible();
|
||||
await choose(page, '评分规则', /22项/);
|
||||
await page.getByRole('button', { name: '创建批次', exact: true }).click();
|
||||
await expect.poll(() => state.creates.length).toBe(1);
|
||||
expect(state.creates[0].project_ids).toEqual([12]);
|
||||
});
|
||||
|
||||
test('after confirming import the grading link selects only the imported materials', async ({ page }) => {
|
||||
await setup(page); await page.goto('/?page=import');
|
||||
await page.getByRole('button', { name: '扫描此目录', exact: true }).click();
|
||||
await choose(page, '导入课程', '课程甲');
|
||||
await choose(page, '导入课程版本', /初始课程版本/);
|
||||
await page.getByRole('button', { name: '确认导入', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '查看已导入资料', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '去评分', exact: true }).click();
|
||||
await expect(page).toHaveURL(/page=batches.*project_ids=12/);
|
||||
await expect(page.getByText('已选择 1 个项目', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('最终报告.pdf', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('empty scan explains the problem and cannot confirm an empty import', async ({ page }) => {
|
||||
await setup(page);
|
||||
await page.route('**/api/import/scan', route => route.fulfill({ json: { projects: [] } }));
|
||||
await page.goto('/?page=import');
|
||||
await page.getByRole('button', { name: '扫描此目录', exact: true }).click();
|
||||
await expect(page.getByText('未找到可导入资料', { exact: true })).toBeVisible();
|
||||
await choose(page, '导入课程', '课程甲');
|
||||
await choose(page, '导入课程版本', /初始课程版本/);
|
||||
await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('failed scoring shows the network error and retry guidance on the page', async ({ page }) => {
|
||||
await setup(page);
|
||||
await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
|
||||
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {},
|
||||
rows: [{ criterion: { criterion_id: 'stage1.problem', category: 'stage1', name: '需求分析', max_score: 25 },
|
||||
status: 'failed', error: 'ConnectError:无法连接模型端点,请检查 Docker 网络、DNS 或代理',
|
||||
evidence: [], missing_evidence: [], evidence_gaps: [] }],
|
||||
} }));
|
||||
await page.goto('/?page=scores&batch_id=71&project_id=11');
|
||||
await expect(page.getByText('当前项目有 1 项评分失败', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/ConnectError:无法连接模型端点/)).toBeVisible();
|
||||
await expect(page.getByText(/已经成功的评分和教师复核会保留/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => {
|
||||
const state = await setup(page);
|
||||
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
|
||||
id: 71, course_version_id: 1, project_ids: [11, 12], status: 'partial',
|
||||
}] }));
|
||||
await page.route('**/api/projects', async route => {
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
await route.fulfill({ json: projects });
|
||||
});
|
||||
await page.goto('/?page=scores&batch_id=71&project_id=11');
|
||||
await expect.poll(() => state.scoreReads.some(url => url.includes('/projects/11/scores'))).toBe(true);
|
||||
await expect(page.getByText('9000000001 学生甲 · 真实报告样例', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -11,6 +11,7 @@ async function mockApi(page: Page) {
|
||||
{ chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/knowledge/documents/12/pages/48', image_url: '/api/knowledge/documents/12/pages/48/image' },
|
||||
{ title: '已迁移评分证据.pdf', page: 50, quote: '评分证据', url: '/api/knowledge/documents/12/pages/50', image_url: '/api/knowledge/documents/12/pages/50/image' },
|
||||
{ title: '评分报告.pdf', page: 49, quote: '评分原文片段', url: '/api/knowledge/documents/12/pages/49',
|
||||
page_type: 'body', page_type_label: '正文', historical: true,
|
||||
image_url: '/api/knowledge/documents/12/pages/49/image', score_url: '/?page=scores&batch_id=3&project_id=5' },
|
||||
{ title: '课程资料.pdf', page: 5, quote: '课程说明', url: '/api/knowledge/documents/11/pages/5' }],
|
||||
tool_calls: [{ id: 'call-one', name: 'search_project_material', status: 'completed', arguments: { project_id: 1, omitted_text_fields: 1 },
|
||||
@@ -134,6 +135,8 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa
|
||||
await expect(scoreEvidence.getByRole('link', { name: '查看页面文字' })).toBeVisible();
|
||||
await expect(scoreEvidence.getByRole('link', { name: '查看图像' })).toBeVisible();
|
||||
const scoreCitation = page.locator('.citation-card').filter({ hasText: '评分报告.pdf' });
|
||||
await expect(scoreCitation.getByText('正文', { exact: true })).toBeVisible();
|
||||
await expect(scoreCitation.getByText('历史评分引用', { exact: true })).toBeVisible();
|
||||
await expect(scoreCitation.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/49$/);
|
||||
await expect(scoreCitation.getByRole('link', { name: '查看评分' })).toHaveAttribute('href', /page=scores&batch_id=3&project_id=5$/);
|
||||
await expect(scoreCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/49\/image$/);
|
||||
@@ -149,7 +152,7 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.locator('.ant-layout-sider')).toHaveClass(/ant-layout-sider-zero-width/);
|
||||
await expect(page.locator('.chat-scope .ant-select').first()).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/mobile-chat.png', fullPage: true });
|
||||
await page.screenshot({ path: 'test-results/mobile-chat.png', fullPage: true, animations: 'disabled' });
|
||||
const width = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth);
|
||||
expect(width).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user