From d6203cc7eab5fe6ea5ee3c12713be3b61867721e Mon Sep 17 00:00:00 2001 From: SkyLi Date: Fri, 25 Sep 2026 11:11:31 +0800 Subject: [PATCH] feat: add teacher-facing frontend --- frontend/src/App.tsx | 135 +++++++++++++++++++++++++++++++++++++++++ frontend/src/api.ts | 11 ++++ frontend/src/main.tsx | 5 ++ frontend/src/style.css | 20 ++++++ 4 files changed, 171 insertions(+) create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/style.css diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1d12061 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT/PDF' }; +const nav = [ + { key: 'dashboard', icon: , label: '仪表盘' }, + { key: 'courses', icon: , label: '课程与规则' }, + { key: 'knowledge', icon: , label: '知识库资料' }, + { key: 'import', icon: , label: '目录扫描与导入' }, + { key: 'projects', icon: , label: '学生项目' }, + { key: 'batches', icon: , label: '评分批次' }, + { key: 'scores', icon: , label: '评分与复核' }, + { key: 'exports', icon: , label: '导出任务' }, + { key: 'settings', icon: , 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 setText(event.target.value)} onBlur={() => { try { onChange(JSON.parse(text)); } catch { message.error('JSON格式错误,请修正后保存'); } }} />; +} + +function Dashboard() { + const [data, setData] = useState({}); + useEffect(() => { load('/dashboard', setData); }, []); + return <>工作概览先确认课程版本与规则,再扫描挂载目录,创建评分批次并逐项复核。{[['课程', data.courses], ['学生项目', data.projects], ['评分批次', data.batches], ['待处理评分项', data.needs_attention]].map(([label, count]) => )}; +} + +function Courses() { + const [courses, setCourses] = useState([]), [courseId, setCourseId] = useState(), [versions, setVersions] = useState([]), [versionId, setVersionId] = useState(), [draft, setDraft] = useState(), [rubrics, setRubrics] = useState([]), [rubricId, setRubricId] = useState(), [rubric, setRubric] = useState(); + 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: , 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: c.name)} value={courseId} onChange={id => { setCourseId(id); setVersionId(undefined); }} />{courseId && } + {courseId && updateDraft('title', event.target.value)} />{['objectives', 'indicators', 'directions', 'prompts', 'templates'].map(key => updateDraft(key, value)} />)}} + {versionId && {rubric && !rubric.published && <>}}> updateRubricCriterion(index, 'name', event.target.value)} /> updateRubricCriterion(index, 'max_score', value)} /> updateRubricCriterion(index, 'description', event.target.value)} />{['required_evidence', 'linked_objectives', 'linked_outcomes', 'score_levels'].map(key => updateRubricCriterion(index, key, value)} />)} }))} />}} + ; +} + +function Knowledge() { + const [courses, setCourses] = useState([]), [versions, setVersions] = useState([]), [courseId, setCourseId] = useState(), [versionId, setVersionId] = useState(), [docs, setDocs] = useState([]); + 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 <>公共课程知识库 `v${v.version} ${v.title}`)} onChange={setVersionId} />; +} + +function Import() { + const [path, setPath] = useState('.'), [dirs, setDirs] = useState([]), [scanData, setScanData] = useState(), [courses, setCourses] = useState([]), [versions, setVersions] = useState([]), [courseId, setCourseId] = useState(), [versionId, setVersionId] = useState(); + 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 <>挂载目录扫描与导入确认浏览 /data/inbox 下的目录,选择包含学生子文件夹的大目录。原始文件不会经过浏览器上传。{path}
{dirs.map(dir => )}
+ {scanData && <> v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} />
editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'student_no', e.target.value)} /> }, { title: '姓名', render: (_, p, index) => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'name', e.target.value)} /> }, { title: '选题', render: (_, p, index) => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'topic', e.target.value)} /> }, { title: '选题方向', render: (_, p) => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'direction', e.target.value)} /> }, { title: '待处理', render: (_, p) => {p.status !== 'recognized' && 目录需确认}{p.missing.map((c: string) => 缺{categories[c]})}{p.warnings?.map((w: string) => {w})} }]} + expandable={{ expandedRowRender: (p: any) => { const pi = scanData.projects.findIndex((item: any) => item.relative_dir === p.relative_dir); return size="small" rowKey="relative_path" pagination={false} dataSource={p.files} columns={[{ title: '文件', dataIndex: 'relative_path' }, { title: '类型', render: (_, f, fi) => editFile(pi, fi, 'status', value)} /> }, { title: '扫描结果', render: (_, f) => {f.status} }, { title: '异常', dataIndex: 'error' }]} />; } }} />} + ; +} + +function Projects({ openScores }: { openScores: (id: number) => void }) { + const [projects, setProjects] = useState([]), [selected, setSelected] = useState(); + useEffect(() => { load('/projects', setProjects); }, []); + return <>学生项目
p.missing.length ? p.missing.map((c: string) => {categories[c]}) : 齐全 }, { title: '解析异常', render: (_, p) => p.parse_errors.length ? : '—' }, { title: '操作', render: (_, p) => }]} /> setSelected(undefined)} title={selected?.topic} width={700}>{selected && <> rowKey="id" size="small" dataSource={selected.files} columns={[{ title: '材料', render: (_, f) => categories[f.category] }, { title: '文件', dataIndex: 'relative_path' }, { title: '解析', render: (_, f) => {f.parse_status} }, { title: '错误', dataIndex: 'parse_error' }]} />抽取事实与一致性
{JSON.stringify(selected.facts, null, 2)}
}
; +} + +function Batches({ openBatch }: { openBatch: (id: number) => void }) { + const [batches, setBatches] = useState([]), [courses, setCourses] = useState([]), [versions, setVersions] = useState([]), [rubrics, setRubrics] = useState([]), [courseId, setCourseId] = useState(), [versionId, setVersionId] = useState(), [rubricId, setRubricId] = useState(); + 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 <>评分批次与进度 v.published), v => `v${v.version} ${v.title}`)} onChange={setVersionId} />
`${b.course_version_id} / ${b.rubric_version_id}` }, { title: '状态', render: (_, b) => {b.status} }, { title: '进度', render: (_, b) => }, { title: '异常', dataIndex: 'error' }, { title: '操作', render: (_, b) => }]} />; +} + +function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) { + const [batches, setBatches] = useState([]), [projects, setProjects] = useState([]), [batch, setBatch] = useState(batchId), [project, setProject] = useState(projectId), [result, setResult] = useState(), [review, setReview] = useState(), [revisions, setRevisions] = useState([]); + 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 <>逐项评分与教师复核 `${p.student_no} ${p.name} · ${p.topic}`)} onChange={setProject} />{result && <>{Object.entries(result.totals).map(([key, value]) => {key}: {String(value ?? '待处理')})}{result.facts?.consistency_issues?.length > 0 && issue.description).join(';')} />}
row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <>{categories[r.criterion.category] || r.criterion.category}{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) => {r.status} }, { title: '操作', render: (_, r) => }]} />} + 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.evidence?.map((ev: any, index: number) => {ev.document} · 第 {ev.page} 页/张{ev.quote}{ev.image_url && 查看页面图像})}
{review.score_id && }{revisions.length > 0 &&
{JSON.stringify(revisions, null, 2)}
}}
+ ; +} + +function Exports() { + const [tasks, setTasks] = useState([]), [batches, setBatches] = useState([]), [projects, setProjects] = useState([]), [batchId, setBatchId] = useState(), [projectId, setProjectId] = useState(); + 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 <>成绩与反馈导出 `${p.student_no} ${p.name}`)} onChange={setProjectId} />
task.status === 'completed' ? 下载文件 : '—' }]} />; +} + +function Settings() { + const [config, setConfig] = useState(), [form] = Form.useForm(); + useEffect(() => { api('/settings/model').then(value => { setConfig(value); form.setFieldsValue(value); }).catch(fail); }, []); + return <>DeepSeek与本地嵌入模型
{ try { setConfig(await put('/settings/model', values)); form.setFieldValue('api_key', ''); message.success('模型设置已保存'); } catch (e) { fail(e); } }}>
; +} + +export default function App() { + const [page, setPage] = useState('dashboard'), [batchId, setBatchId] = useState(), [projectId, setProjectId] = useState(); + const openScores = (id: number) => { setProjectId(id); setPage('scores'); }; + const openBatch = (id: number) => { setBatchId(id); setPage('scores'); }; + const pages: Record = { dashboard: , courses: , knowledge: , import: , projects: , batches: , scores: , exports: , settings: }; + return
智
WiseGrade教师工作台
setPage(key)} />
智评自动化专业综合实践
单机教师端
{pages[page]}
; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..719b38e --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,11 @@ +export async function api(path: string, method = 'GET', body?: unknown): Promise { + 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); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..461bc36 --- /dev/null +++ b/frontend/src/main.tsx @@ -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(); diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..3ee7901 --- /dev/null +++ b/frontend/src/style.css @@ -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; } }