307 lines
43 KiB
TypeScript
307 lines
43 KiB
TypeScript
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, 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 { Batches } from './Batches';
|
||
import { projectLabel, projectsInBatch } from './batchProjects';
|
||
import { fillImportIdentity, importErrors, reviewImport } from './importReview';
|
||
const Chat = lazy(() => import('./Chat'));
|
||
|
||
const { Header, Sider, Content } = Layout;
|
||
const { Title, Text, Paragraph } = Typography;
|
||
const categories: Record<string, string> = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT/PDF' };
|
||
const nav = [
|
||
{ key: 'chat', icon: <MessageOutlined />, label: '会话' },
|
||
{ key: 'dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
||
{ key: 'courses', icon: <BookOutlined />, label: '课程与规则' },
|
||
{ key: 'knowledge', icon: <FileSearchOutlined />, label: '知识库资料' },
|
||
{ key: 'import', icon: <FolderOpenOutlined />, label: '目录扫描与导入' },
|
||
{ key: 'projects', icon: <TeamOutlined />, label: '学生资料' },
|
||
{ key: 'batches', icon: <ThunderboltOutlined />, label: '评分批次' },
|
||
{ key: 'scores', icon: <AuditOutlined />, label: '评分与复核' },
|
||
{ key: 'exports', icon: <ExportOutlined />, label: '导出任务' },
|
||
{ key: 'settings', icon: <SettingOutlined />, label: '模型设置' },
|
||
];
|
||
const fail = (error: unknown) => message.error(error instanceof Error ? error.message : String(error));
|
||
const load = (path: string, setter: (data: any) => void) => api(path).then(setter).catch(fail);
|
||
const options = (rows: any[], label: (row: any) => string) => rows.map(row => ({ value: row.id, label: label(row) }));
|
||
const isSlideEvidence = (evidence: any) => /\.pptx$/i.test(evidence.document || '');
|
||
const evidencePlace = (evidence: any) => `第 ${evidence.page} ${isSlideEvidence(evidence) ? '张幻灯片' : '页'}`;
|
||
const evidencePreviewLabel = (evidence: any) => isSlideEvidence(evidence) ? '查看该张幻灯片的完整图像' : '查看该页的完整图像';
|
||
const pageTypeLabels: Record<string, string> = { cover: '封面', abstract: '摘要', toc: '目录', body: '正文', appendix: '附录', references: '参考文献', unknown: '识别不确定', mixed: '目录与正文混合页' };
|
||
const pageTypeLabel = (value: string) => pageTypeLabels[value] || '未分类页面';
|
||
const statusLabels: Record<string, string> = { pending: '待评分', scored: '模型已评分', reviewed: '教师已复核', needs_review: '待教师复核', unscorable: '材料缺失或解析失败', failed: '评分失败' };
|
||
const statusLabel = (value: string) => statusLabels[value] || value;
|
||
const scoreStatusLabel = (row: any) => {
|
||
if (row.status_label) return row.status_label;
|
||
if (row.status !== 'failed') return statusLabel(row.status);
|
||
if (/模型证据|target_id|评分项或满分|JSON校验/.test(row.error || '')) return '模型输出校验失败';
|
||
if (/ConnectError|Timeout|Network|连接|超时|HTTP/i.test(row.error || '')) return '模型连接失败';
|
||
return '评分失败';
|
||
};
|
||
const statusColor = (value: string) => ({ scored: 'green', reviewed: 'green', needs_review: 'gold', unscorable: 'red', failed: 'red' }[value] || 'blue');
|
||
const batchStatusLabels: Record<string, string> = { created: '待启动', queued: '排队中', running: '运行中', completed: '已完成', review_required: '已完成,有待复核项', partial: '部分完成', failed: '失败' };
|
||
const batchStatusLabel = (value: string) => batchStatusLabels[value] || value;
|
||
// A teacher score box is never prefilled with 0: an empty box forces a deliberate
|
||
// entry, while 0 would read as a judgement the teacher did not make.
|
||
const reviewFormValues = (row: any) => ({
|
||
score: row.teacher_score ?? row.final_score ?? null,
|
||
comment: row.teacher_comment || row.model_comment || '',
|
||
reason: '',
|
||
});
|
||
function JsonField({ value, onChange, disabled, rows = 4, resetKey }: { value: any; onChange: (value: any) => void; disabled?: boolean; rows?: number; resetKey: string }) {
|
||
const [text, setText] = useState(JSON.stringify(value, null, 2));
|
||
useEffect(() => { setText(JSON.stringify(value, null, 2)); }, [resetKey]);
|
||
return <Input.TextArea disabled={disabled} rows={rows} value={text} onChange={event => setText(event.target.value)} onBlur={() => { try { onChange(JSON.parse(text)); } catch { message.error('JSON格式错误,请修正后保存'); } }} />;
|
||
}
|
||
|
||
function Dashboard() {
|
||
const [data, setData] = useState<any>({});
|
||
useEffect(() => { load('/dashboard', setData); }, []);
|
||
return <><Title level={2}>工作概览</Title><Paragraph>先确认课程版本与规则,再扫描挂载目录,创建评分批次并逐项复核。</Paragraph><Row gutter={18}>{[['课程', data.courses], ['学生项目', data.projects], ['评分批次', data.batches], ['待处理评分项', data.needs_attention], ['待教师复核', data.needs_review]].map(([label, count]) => <Col xs={24} sm={12} lg={4} key={label}><Card><Statistic title={label} value={count ?? 0} /></Card></Col>)}</Row><Alert className="top-gap" type="info" showIcon message="学生材料仅从只读挂载目录读取;缺失和解析失败的材料会在项目与评分详情中标出。“待教师复核”表示未检索到可用正文,或模型认为当前证据不足以自动评分;请查看具体原因,系统不会自动给低分。" /></>;
|
||
}
|
||
|
||
function Courses() {
|
||
const [courses, setCourses] = useState<any[]>([]), [courseId, setCourseId] = useState<number>(), [versions, setVersions] = useState<any[]>([]), [versionId, setVersionId] = useState<number>(), [draft, setDraft] = useState<any>(), [rubrics, setRubrics] = useState<any[]>([]), [rubricId, setRubricId] = useState<number>(), [rubric, setRubric] = useState<any>();
|
||
const refreshCourses = () => load('/courses', setCourses);
|
||
useEffect(() => { refreshCourses(); }, []);
|
||
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(() => { setDraft(versions.find(v => v.id === versionId)); if (versionId) load(`/course-versions/${versionId}/rubrics`, setRubrics); }, [versions, versionId]);
|
||
useEffect(() => { setRubric(rubrics.find(r => r.id === rubricId)); }, [rubrics, rubricId]);
|
||
const refreshVersions = () => courseId && load(`/courses/${courseId}/versions`, setVersions);
|
||
const refreshRubrics = () => versionId && load(`/course-versions/${versionId}/rubrics`, setRubrics);
|
||
const createCourse = () => Modal.confirm({ title: '新增课程', content: <Input id="new-course" placeholder="课程名称" />, onOk: async () => { const name = (document.getElementById('new-course') as HTMLInputElement).value; await post('/courses', { name, code: `course_${Date.now()}` }); refreshCourses(); } });
|
||
const updateDraft = (key: string, value: any) => setDraft({ ...draft, [key]: value });
|
||
const updateRubricCriterion = (index: number, key: string, value: any) => { const next = structuredClone(rubric); next.criteria[index][key] = value; setRubric(next); };
|
||
const copyRule = async () => {
|
||
try {
|
||
if (!versionId) return;
|
||
if (rubricId) {
|
||
const created = await post(`/rubrics/${rubricId}/copy`);
|
||
await refreshRubrics(); setRubricId(created.id); return;
|
||
}
|
||
const catalog = await api('/rubrics/catalog');
|
||
if (!catalog.length) { message.error('请先创建一套完整的评分规则'); return; }
|
||
let sourceId = catalog[0].id;
|
||
Modal.confirm({ title: '从已有规则创建草稿', content: <Select style={{ width: '100%' }} defaultValue={sourceId} options={catalog.map((r: any) => ({ value: r.id, label: `规则 #${r.id} · ${r.criteria_count}项` }))} onChange={value => { sourceId = value; }} />, onOk: async () => { const created = await post(`/course-versions/${versionId}/rubrics/copy-from/${sourceId}`); await refreshRubrics(); setRubricId(created.id); } });
|
||
} catch (e) { fail(e); }
|
||
};
|
||
return <><Title level={2}>课程、版本与评分规则</Title><Space wrap><Select style={{ width: 260 }} placeholder="选择课程" options={options(courses, c => c.name)} value={courseId} onChange={id => { setCourseId(id); setVersionId(undefined); }} /><Button onClick={createCourse}>新增课程</Button>{courseId && <Button onClick={() => Modal.confirm({ title: '复制课程版本', content: <Input id="version-title" placeholder="新版本标题" />, onOk: async () => { try { const title = (document.getElementById('version-title') as HTMLInputElement).value || '新版本'; const created = await post(`/courses/${courseId}/versions`, { title, source_version_id: versionId }); await refreshVersions(); setVersionId(created.id); message.success('已创建草稿版本'); } catch (e) { fail(e); } } })}>复制课程版本</Button>}</Space>
|
||
{courseId && <Card className="top-gap" title="课程版本"><Select style={{ width: 280 }} placeholder="选择版本" options={options(versions, v => `v${v.version} ${v.title}${v.published ? ' · 已发布' : ' · 草稿'}`)} value={versionId} onChange={setVersionId} />{draft && <Tag color={draft.published ? 'green' : 'orange'} className="left-gap">{draft.published ? '已发布' : '可编辑草稿'}</Tag>}</Card>}
|
||
{draft && <Card className="top-gap" title="课程目标、指标点、选题方向与 Prompt" extra={!draft.published && <Space><Button type="primary" onClick={async () => { try { await put(`/course-versions/${draft.id}`, { title: draft.title, objectives: draft.objectives, indicators: draft.indicators, directions: draft.directions, prompts: draft.prompts, templates: draft.templates }); message.success('课程草稿已保存'); refreshVersions(); } catch (e) { fail(e); } }}>保存草稿</Button><Button onClick={async () => { try { await post(`/course-versions/${draft.id}/publish`); refreshVersions(); message.success('版本已发布'); } catch (e) { fail(e); } }}>发布版本</Button></Space>}><Form layout="vertical"><Form.Item label="版本标题"><Input disabled={draft.published} value={draft.title} onChange={event => updateDraft('title', event.target.value)} /></Form.Item>{['objectives', 'indicators', 'directions', 'prompts', 'templates'].map(key => <Form.Item key={key} label={{ objectives: '课程目标 JSON', indicators: '毕业要求指标点 JSON', directions: '选题方向 JSON', prompts: '事实抽取、评分与一致性 Prompt JSON', templates: '课程资料文件名 JSON' }[key]}><JsonField disabled={draft.published} rows={key === 'prompts' ? 7 : 5} value={draft[key]} resetKey={`${draft.id}:${key}`} onChange={value => updateDraft(key, value)} /></Form.Item>)}</Form></Card>}
|
||
{versionId && <Card className="top-gap" title="评分规则" extra={<Space><Button onClick={copyRule}>复制规则为草稿</Button>{rubric && !rubric.published && <><Button type="primary" onClick={async () => { try { await put(`/rubrics/${rubric.id}`, { criteria: rubric.criteria, weights: rubric.weights }); refreshRubrics(); message.success('规则已保存'); } catch (e) { fail(e); } }}>保存规则</Button><Button onClick={async () => { try { await post(`/rubrics/${rubric.id}/publish`); refreshRubrics(); } catch (e) { fail(e); } }}>发布规则</Button></>}</Space>}><Select style={{ width: 290 }} placeholder="选择规则版本" options={options(rubrics, r => `v${r.version}${r.published ? ' · 已发布' : ' · 草稿'}`)} value={rubricId} onChange={setRubricId} />{rubric && <><Divider /><Alert type="info" message="各阶段指标满分均须合计100分;成绩权重之和须为1。发布后请复制新版本修改。" /><Form layout="vertical" className="top-gap"><Form.Item label="成绩权重"><Space>{Object.entries(rubric.weights).map(([key, value]) => <span key={key}>{key}<InputNumber disabled={rubric.published} min={0} max={1} step={0.05} value={value as number} onChange={next => setRubric({ ...rubric, weights: { ...rubric.weights, [key]: next } })} /></span>)}</Space></Form.Item></Form><Form.Item label="完整规则 JSON(可增删评分项)"><JsonField value={{ criteria: rubric.criteria, weights: rubric.weights }} resetKey={`rubric-json:${rubric.id}`} disabled={rubric.published} rows={8} onChange={value => setRubric({ ...rubric, ...value })} /></Form.Item><Collapse items={rubric.criteria.map((item: any, index: number) => ({ key: item.criterion_id, label: <Space><Tag>{categories[item.category] || item.category}</Tag>{item.name}<Text type="secondary">{item.max_score}分</Text></Space>, children: <Form layout="vertical"><Row gutter={12}><Col span={12}><Form.Item label="名称"><Input disabled={rubric.published} value={item.name} onChange={event => updateRubricCriterion(index, 'name', event.target.value)} /></Form.Item></Col><Col span={12}><Form.Item label="满分"><InputNumber disabled={rubric.published} min={1} value={item.max_score} onChange={value => updateRubricCriterion(index, 'max_score', value)} /></Form.Item></Col></Row><Form.Item label="评价说明"><Input.TextArea disabled={rubric.published} value={item.description} onChange={event => updateRubricCriterion(index, 'description', event.target.value)} /></Form.Item>{['required_evidence', 'linked_objectives', 'linked_outcomes', 'score_levels'].map(key => <Form.Item key={key} label={{ required_evidence: '证据要求 JSON', linked_objectives: '关联课程目标 JSON', linked_outcomes: '关联毕业要求 JSON', score_levels: '评分等级 JSON' }[key]}><JsonField disabled={rubric.published} rows={3} value={item[key]} resetKey={`${rubric.id}:${item.criterion_id}:${key}`} onChange={value => updateRubricCriterion(index, key, value)} /></Form.Item>)}</Form> }))} /></>}</Card>}
|
||
</>;
|
||
}
|
||
|
||
function Knowledge() {
|
||
const [courses, setCourses] = useState<any[]>([]), [versions, setVersions] = useState<any[]>([]), [courseId, setCourseId] = useState<number>(), [versionId, setVersionId] = useState<number>(), [docs, setDocs] = useState<any[]>([]);
|
||
useEffect(() => { load('/courses', setCourses); }, []);
|
||
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: '状态', 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({ 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(() => { setScanData(undefined); setImported(undefined); load(`/storage/directories?relative=${encodeURIComponent(path)}`, setDirs); }, [path]);
|
||
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
|
||
const errors = importErrors(scanData?.projects, versionId);
|
||
const editProject = (index: number, key: string, value: any) => {
|
||
const next = structuredClone(scanData), project = next.projects[index];
|
||
project[key] = value;
|
||
if (['student_no', 'name', 'topic'].includes(key)) project.identity_edits = { ...project.identity_edits, [key]: true };
|
||
setScanData(next);
|
||
};
|
||
const editFile = (projectIndex: number, fileIndex: number, key: string, value: any) => {
|
||
const next = structuredClone(scanData), project = next.projects[projectIndex];
|
||
project.files[fileIndex][key] = value; fillImportIdentity(project); setScanData(next);
|
||
};
|
||
const useCover = (index: number) => {
|
||
const next = structuredClone(scanData); fillImportIdentity(next.projects[index], true); setScanData(next);
|
||
};
|
||
const confirm = async () => {
|
||
if (errors.length || importing) { message.error(errors[0] || '正在导入,请稍候'); return; }
|
||
setImporting(true);
|
||
try {
|
||
const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no.trim(),
|
||
name: p.name.trim(), topic: p.topic.trim(), direction: (p.direction || '').trim(),
|
||
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); }
|
||
};
|
||
const projectIndex = (project: any) => scanData.projects.findIndex((item: any) => item.relative_dir === project.relative_dir);
|
||
const identityInput = (project: any, key: string, label: string) => <Input aria-label={`${label} ${project.relative_dir}`}
|
||
status={key === 'student_no' ? (!/^[0-9]{5,20}$/.test((project[key] || '').trim()) ? 'error' : undefined) : (!project[key]?.trim() ? 'error' : undefined)}
|
||
placeholder={key === 'student_no' ? '5–20位数字' : `请填写${label}`} value={project[key]}
|
||
onChange={event => editProject(projectIndex(project), key, event.target.value)} />;
|
||
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 {
|
||
setImported(undefined); const data = await post('/import/scan', { relative_dir: path });
|
||
data.projects = data.projects.map((project: any) => fillImportIdentity(project)); setScanData(data);
|
||
} catch (error) { fail(error); } }}>扫描此目录</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 />{errors.length > 0 && <Alert className="top-gap" type="warning" showIcon
|
||
message={scanData.projects.length ? '暂不能确认导入' : '未找到可导入资料'}
|
||
description={<ul>{errors.map((error, index) => <li key={index}>{error}</li>)}</ul>} />}
|
||
<Space className="top-gap" wrap>
|
||
<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={errors.length > 0 || importing} onClick={confirm}>确认导入</Button>
|
||
</Space>
|
||
<Table className="top-gap" rowKey="relative_dir" dataSource={scanData.projects} pagination={{ pageSize: 10 }} columns={[
|
||
{ title: '学号', render: (_, project) => identityInput(project, 'student_no', '学号') },
|
||
{ title: '姓名', render: (_, project) => identityInput(project, 'name', '姓名') },
|
||
{ title: '选题', render: (_, project) => identityInput(project, 'topic', '选题') },
|
||
{ title: '选题方向', render: (_, project) => <Input value={project.direction || ''} placeholder="可修正" onChange={event => editProject(projectIndex(project), 'direction', event.target.value)} /> },
|
||
{ title: '待处理', render: (_, project) => { const review = reviewImport(project); return <Space direction="vertical">
|
||
<Space wrap>{review.errors.length > 0 ? <Tag color="red">请修正导入信息</Tag> : <Tag color="green">导入信息已填写</Tag>}
|
||
{review.missing.map(category => <Tag key={category}>缺{categories[category]}</Tag>)}</Space>
|
||
{review.warnings.map(warning => <Text key={warning} type="warning">{warning}</Text>)}
|
||
{review.metadata && Object.values(review.identity).some(Boolean) && <Button size="small" onClick={() => useCover(projectIndex(project))}>使用所选资料的封面信息</Button>}
|
||
</Space>; } },
|
||
]} expandable={{ defaultExpandAllRows: true, expandedRowRender: (project: any) => {
|
||
const pi = projectIndex(project); return <Table<any> size="small" rowKey="relative_path" pagination={false} dataSource={project.files} columns={[
|
||
{ title: '文件', dataIndex: 'relative_path' },
|
||
{ title: '封面身份', render: (_, file) => <Space direction="vertical">
|
||
<Text>{file.detected_identity?.name ? `${file.detected_identity.name}(${file.detected_identity.student_no || '未识别学号'})` : '首页未识别身份'}</Text>
|
||
<Text type="secondary">{file.detected_identity?.topic || ''}</Text></Space> },
|
||
{ title: '类型', render: (_, file, fi) => <Select aria-label={`材料类型 ${file.relative_path}`} style={{ width: 170 }} value={file.category}
|
||
options={[{ value: 'unknown', label: '未识别' }, ...Object.entries(categories).map(([value, label]) => ({ value, label }))]} onChange={value => editFile(pi, fi, 'category', value)} /> },
|
||
{ title: '处理', render: (_, file, fi) => <Select aria-label={`处理 ${file.relative_path}`} style={{ width: 120 }} value={file.status === 'ignored' ? 'ignored' : 'recognized'}
|
||
options={[{ value: 'recognized', label: '导入' }, { value: 'ignored', label: '忽略' }]} onChange={value => editFile(pi, fi, 'status', value)} /> },
|
||
{ title: '扫描结果', render: (_, file) => <Tag color={file.status === 'recognized' ? 'green' : 'orange'}>
|
||
{({ recognized: '已识别', needs_confirmation: '需确认类型', duplicate: '同类多份', unparseable: '无法读取', ignored: '已忽略' } as Record<string, string>)[file.status] || file.status}</Tag> },
|
||
{ title: '异常', dataIndex: 'error' },
|
||
]} />;
|
||
} }} />
|
||
<Paragraph type="secondary">封面冲突请核对上表来源。允许缺少某类材料,相关评分项会标记为待处理。人工填写的信息会保留;点击“使用所选资料的封面信息”可重新采用识别值。</Paragraph>
|
||
</>}
|
||
</>;
|
||
}
|
||
|
||
function Projects({ openScores }: { openScores: (id: number) => void }) {
|
||
const [projects, setProjects] = useState<any[]>([]), [selected, setSelected] = useState<any>(), [indexTasks, setIndexTasks] = useState<any[]>([]);
|
||
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: '解析提醒', 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 [scoreFilter, setScoreFilter] = useState('all');
|
||
const [refreshVersion, setRefreshVersion] = useState(0);
|
||
const [form] = Form.useForm();
|
||
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; };
|
||
}, [refreshVersion]);
|
||
const selectedBatch = batches.find(item => item.id === batch);
|
||
const scopedProjects = projectsInBatch(selectedBatch, projects);
|
||
useEffect(() => { setScoreFilter('all'); }, [batch, project]);
|
||
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 = () => setRefreshVersion(value => value + 1);
|
||
const scoreRows: any[] = result?.rows || [];
|
||
const count = (status: string) => scoreRows.filter(row => row.status === status).length;
|
||
const unfinished = (row: any) => !['scored', 'reviewed'].includes(row.status);
|
||
const attentionCount = scoreRows.filter(unfinished).length;
|
||
const filteredScores = scoreRows.filter(row => scoreFilter === 'all' ||
|
||
(scoreFilter === 'attention' ? unfinished(row) : row.status === scoreFilter));
|
||
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} />
|
||
<Button disabled={!sourcesReady || !selectedBatch || !project} onClick={refresh}>刷新评分</Button>
|
||
</Space>
|
||
{selectedBatch && <Alert className="top-gap" type="info" showIcon
|
||
message={`批次 #${batch} 包含 ${scopedProjects.length} 个项目。下方分数仅对应当前选中的学生项目。`} />}
|
||
{result && <Space wrap className="top-gap">
|
||
<Select aria-label="评分项筛选" value={scoreFilter} onChange={setScoreFilter} style={{ width: 220 }}
|
||
options={[
|
||
{ value: 'all', label: `全部评分项(${scoreRows.length})` },
|
||
{ value: 'attention', label: `仅待处理(${attentionCount})` },
|
||
{ value: 'needs_review', label: `仅待教师复核(${count('needs_review')})` },
|
||
{ value: 'failed', label: `仅评分失败(${count('failed')})` },
|
||
{ value: 'unscorable', label: `仅材料异常(${count('unscorable')})` },
|
||
]} />
|
||
<Text>已评分 {count('scored')} 项 · 教师已复核 {count('reviewed')} 项 · 待处理 {attentionCount} 项</Text>
|
||
</Space>}
|
||
{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={filteredScores} locale={{ emptyText: '当前筛选下没有评分项' }} 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)}>{scoreStatusLabel(r)}</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>
|
||
</>;
|
||
}
|
||
|
||
function Exports() {
|
||
const [tasks, setTasks] = useState<any[]>([]), [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batchId, setBatchId] = useState<number>(), [projectId, setProjectId] = useState<number>();
|
||
const refresh = () => load('/exports', setTasks);
|
||
useEffect(() => { refresh(); load('/scoring-batches', setBatches); load('/projects', setProjects); }, []);
|
||
useEffect(() => { const id = Number(new URLSearchParams(window.location.search).get('batch_id')); if (id) setBatchId(id); }, []);
|
||
const create = async (kind: 'excel' | 'project-pdf') => { try { await post(kind === 'excel' ? '/exports/excel' : '/exports/project-pdf', { batch_id: batchId, project_id: projectId }); refresh(); message.success('导出完成'); } catch (e) { fail(e); } };
|
||
return <><Title level={2}>成绩与反馈导出</Title><Alert type="info" message="未完成或无法评分项目在Excel中显示为空,并标记待处理;每个批次独立导出。" /><Space className="top-gap" wrap><Select style={{ width: 210 }} placeholder="选择批次" value={batchId} options={options(batches, b => `批次 #${b.id} · ${b.status}`)} onChange={setBatchId} /><Button disabled={!batchId} onClick={() => create('excel')}>导出Excel成绩表</Button><Select style={{ width: 300 }} placeholder="选择学生" options={options(projects, p => `${p.student_no} ${p.name}`)} onChange={setProjectId} /><Button disabled={!batchId || !projectId} onClick={() => create('project-pdf')}>导出学生PDF反馈</Button></Space><Table className="top-gap" rowKey="id" dataSource={tasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '批次', dataIndex: 'batch_id' }, { title: '项目', dataIndex: 'project_id' }, { title: '格式', dataIndex: 'kind' }, { title: '状态', dataIndex: 'status' }, { title: '异常', dataIndex: 'error' }, { title: '下载', render: (_, task) => task.status === 'completed' ? <a href={`/api/exports/${task.id}/download`}>下载文件</a> : '—' }]} /></>;
|
||
}
|
||
|
||
function Settings() {
|
||
const [config, setConfig] = useState<any>(), [form] = Form.useForm();
|
||
useEffect(() => { api('/settings/model').then(value => { setConfig(value); form.setFieldsValue(value); }).catch(fail); }, []);
|
||
return <><Title level={2}>DeepSeek与本地嵌入模型</Title><Alert type="info" message="统一使用一个DeepSeek模型处理文本、图像、事实抽取和评分。API Key只发往后端并加密保存。嵌入模型必须预先离线安装。" /><Card className="top-gap" title="模型配置"><Form form={form} layout="vertical" style={{ maxWidth: 650 }} onFinish={async values => { try { setConfig(await put('/settings/model', values)); form.setFieldValue('api_key', ''); message.success('模型设置已保存'); } catch (e) { fail(e); } }}><Form.Item label="Base URL" name="base_url"><Input /></Form.Item><Form.Item label="模型名" name="model"><Input /></Form.Item><Form.Item label={`API Key(${config?.api_key_configured ? '已配置' : '未配置'};留空则不修改)`} name="api_key"><Input.Password autoComplete="new-password" /></Form.Item><Row gutter={12}><Col span={8}><Form.Item label="超时(秒)" name="timeout_seconds"><InputNumber min={10} max={600} /></Form.Item></Col><Col span={8}><Form.Item label="并发数" name="concurrency"><InputNumber min={1} max={16} /></Form.Item></Col><Col span={8}><Form.Item label="重试次数" name="retries"><InputNumber min={0} max={5} /></Form.Item></Col></Row><Button type="primary" htmlType="submit">保存设置</Button></Form><Divider /><Descriptions column={1} items={[{ key: 'name', label: '本地嵌入模型', children: config?.embedding_model_name }, { key: 'path', label: '离线目录', children: config?.embedding_model_path }, { key: 'version', label: '版本', children: config?.embedding_model_version }]} /></Card></>;
|
||
}
|
||
|
||
export default function App() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const [page, setPage] = useState(params.get('page') || 'chat');
|
||
const [batchId, setBatchId] = useState<number | undefined>(Number(params.get('batch_id')) || undefined);
|
||
const [projectId, setProjectId] = useState<number | undefined>(Number(params.get('project_id')) || undefined);
|
||
const navigate = (next: string, batch?: number, project?: number) => {
|
||
setPage(next); setBatchId(batch); setProjectId(project);
|
||
const query = new URLSearchParams({ page: next });
|
||
if (batch) query.set('batch_id', String(batch));
|
||
if (project) query.set('project_id', String(project));
|
||
window.history.pushState({}, '', `/?${query.toString()}`);
|
||
};
|
||
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, 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>;
|
||
}
|