feat: add teacher-facing frontend

This commit is contained in:
2026-09-25 11:11:31 +08:00
parent eece55d08d
commit d6203cc7ea
4 changed files with 171 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
import { 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 { DashboardOutlined, BookOutlined, FileSearchOutlined, FolderOpenOutlined, TeamOutlined, ThunderboltOutlined, AuditOutlined, ExportOutlined, SettingOutlined } from '@ant-design/icons';
import { api, post, put } from './api';
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: '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) }));
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]].map(([label, count]) => <Col xs={24} sm={12} lg={6} 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(() => { 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(() => { 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' }]} /></>;
}
function Import() {
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>();
useEffect(() => { load('/courses', setCourses); }, []);
useEffect(() => { 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' }]} />; } }} /></>}
</>;
}
function Projects({ openScores }: { openScores: (id: number) => void }) {
const [projects, setProjects] = useState<any[]>([]), [selected, setSelected] = useState<any>();
useEffect(() => { load('/projects', setProjects); }, []);
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><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" dataSource={batches} columns={[{ title: '批次', dataIndex: 'id' }, { title: '课程/规则版本', render: (_, b) => `${b.course_version_id} / ${b.rubric_version_id}` }, { title: '状态', render: (_, b) => <Tag color={b.status === 'completed' ? 'green' : b.status === 'partial' || b.status === 'failed' ? 'orange' : 'blue'}>{b.status}</Tag> }, { 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 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> }]} /></>;
}
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 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={r.status === 'scored' || r.status === 'reviewed' ? 'green' : 'orange'}>{r.status}</Tag> }, { title: '操作', render: (_, r) => <Button onClick={() => { setReview(r); form.setFieldsValue({ score: r.final_score ?? 0, comment: r.teacher_comment || r.model_comment || '', reason: '' }); }}>查看证据/复核</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 && <><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: '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"><Text strong>{ev.document} · 第 {ev.page} 页/张</Text><Paragraph>{ev.quote}</Paragraph>{ev.image_url && <a href={ev.image_url} target="_blank" rel="noreferrer">查看页面图像</a>}</Card>)}<Divider /><Form form={form} layout="vertical"><Form.Item label="教师评分" name="score" rules={[{ required: true }]}><InputNumber min={0} max={review.criterion.max_score} /></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); }, []);
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="选择批次" 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 [page, setPage] = useState('dashboard'), [batchId, setBatchId] = useState<number>(), [projectId, setProjectId] = useState<number>();
const openScores = (id: number) => { setProjectId(id); setPage('scores'); };
const openBatch = (id: number) => { setBatchId(id); setPage('scores'); };
const pages: Record<string, ReactNode> = { 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 /> };
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 }) => setPage(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]}</div></Content></Layout></Layout>;
}
+11
View File
@@ -0,0 +1,11 @@
export async function api(path: string, method = 'GET', body?: unknown): Promise<any> {
const response = await fetch('/api' + path, { method, headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
if (!response.ok) {
let detail = response.statusText;
try { const value = await response.json(); detail = typeof value.detail === 'string' ? value.detail : JSON.stringify(value.detail); } catch { /* server returned no JSON */ }
throw new Error(detail);
}
return response.json();
}
export const post = (path: string, body?: unknown) => api(path, 'POST', body);
export const put = (path: string, body: unknown) => api(path, 'PUT', body);
+5
View File
@@ -0,0 +1,5 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './style.css';
createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);
+20
View File
@@ -0,0 +1,20 @@
:root { font-family: Inter, "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; color: #172b4d; background: #f4f7fb; }
body { margin: 0; }
.shell { min-height: 100vh; }
.brand { display: flex; align-items: center; gap: 11px; height: 75px; padding: 0 22px; color: white; letter-spacing: .2px; }
.brand strong, .brand small { display: block; }
.brand strong { font-size: 19px; }
.brand small { color: #9fb7d1; font-size: 11px; margin-top: 2px; }
.brand-icon { background: #3689ff; border-radius: 9px; width: 34px; height: 34px; display: grid; place-items: center; font-weight: bold; }
.header { background: white; padding: 0 28px; border-bottom: 1px solid #e8edf4; display: flex; justify-content: space-between; align-items: center; }
.header strong { font-size: 19px; color: #183c64; }
.header-sub { margin-left: 15px; color: #7890a8; }
.content { padding: 28px; }
.content-inner { max-width: 1450px; margin: 0 auto; }
.top-gap { margin-top: 20px; }
.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; }
.evidence { margin: 9px 0; border-left: 3px solid #4e91ed; }
.json-view { white-space: pre-wrap; overflow: auto; background: #f5f7fa; padding: 12px; border-radius: 6px; }
@media (max-width: 700px) { .content { padding: 14px; } .header { padding: 0 15px; } .header-sub { display: none; } }