Files
WiseGrade/frontend/src/App.tsx
T

307 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}