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 = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT/PDF' }; const nav = [ { key: 'chat', icon: , label: '会话' }, { 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) })); 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 = { cover: '封面', abstract: '摘要', toc: '目录', body: '正文', appendix: '附录', references: '参考文献', unknown: '识别不确定', mixed: '目录与正文混合页' }; const pageTypeLabel = (value: string) => pageTypeLabels[value] || '未分类页面'; const statusLabels: Record = { 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 = { 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 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], ['待教师复核', data.needs_review]].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(() => { 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: , 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(() => { 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 <>公共课程知识库 `v${v.version} ${v.title}`)} onChange={setVersionId} />{doc.requires_reindex ? '需重建索引' : ({ ready: '可检索', superseded: '历史版本', indexing: '索引处理中' }[doc.status as string] || doc.status)} }]} />; } function Import({ openImported, scoreImported }: { openImported: (ids: number[]) => void; scoreImported: (versionId: number, ids: number[]) => void }) { const [path, setPath] = useState('.'), [dirs, setDirs] = useState([]), [scanData, setScanData] = useState(), [courses, setCourses] = useState([]), [versions, setVersions] = useState([]), [courseId, setCourseId] = useState(), [versionId, setVersionId] = useState(); 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) => editProject(projectIndex(project), key, event.target.value)} />; return <>挂载目录扫描与导入确认 浏览 /data/inbox 下的目录,可选择学生总目录或直接存放资料的目录。请核对封面身份;不属于当前项目的文件可设为“忽略”。 {path}
{dirs.map(dir => )}
{imported && imported.projectIds.length > 0 && } />} {scanData && <>{errors.length > 0 && {errors.map((error, index) =>
  • {error}
  • )}} />} v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} />
    identityInput(project, 'student_no', '学号') }, { title: '姓名', render: (_, project) => identityInput(project, 'name', '姓名') }, { title: '选题', render: (_, project) => identityInput(project, 'topic', '选题') }, { title: '选题方向', render: (_, project) => editProject(projectIndex(project), 'direction', event.target.value)} /> }, { title: '待处理', render: (_, project) => { const review = reviewImport(project); return {review.errors.length > 0 ? 请修正导入信息 : 导入信息已填写} {review.missing.map(category => 缺{categories[category]})} {review.warnings.map(warning => {warning})} {review.metadata && Object.values(review.identity).some(Boolean) && } ; } }, ]} expandable={{ defaultExpandAllRows: true, expandedRowRender: (project: any) => { const pi = projectIndex(project); return size="small" rowKey="relative_path" pagination={false} dataSource={project.files} columns={[ { title: '文件', dataIndex: 'relative_path' }, { title: '封面身份', render: (_, file) => {file.detected_identity?.name ? `${file.detected_identity.name}(${file.detected_identity.student_no || '未识别学号'})` : '首页未识别身份'} {file.detected_identity?.topic || ''} }, { title: '类型', render: (_, file, fi) => editFile(pi, fi, 'status', value)} /> }, { title: '扫描结果', render: (_, file) => {({ recognized: '已识别', needs_confirmation: '需确认类型', duplicate: '同类多份', unparseable: '无法读取', ignored: '已忽略' } as Record)[file.status] || file.status} }, { title: '异常', dataIndex: 'error' }, ]} />; } }} /> 封面冲突请核对上表来源。允许缺少某类材料,相关评分项会标记为待处理。人工填写的信息会保留;点击“使用所选资料的封面信息”可重新采用识别值。 } ; } function Projects({ openScores }: { openScores: (id: number) => void }) { const [projects, setProjects] = useState([]), [selected, setSelected] = useState(), [indexTasks, setIndexTasks] = useState([]); 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 <>已导入学生资料
    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' }, { title: '解析提醒', render: (_, f) => {f.warnings?.map((warning: any, index: number) => 第{warning.page}页:{warning.message})} }]} />解析与索引任务 rowKey="id" size="small" pagination={false} dataSource={indexTasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '材料 ID', dataIndex: 'submission_file_id' }, { title: '状态', render: (_, task) => {task.status} }, { title: '错误', dataIndex: 'error' }, { title: '操作', render: (_, task) => task.status === 'failed' && }]} />抽取事实与一致性
    {JSON.stringify(selected.facts, null, 2)}
    }
    ; } 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 [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 <>逐项评分与教师复核 {selectedBatch && } {result &&
    row.criterion.criterion_id} dataSource={filteredScores} locale={{ emptyText: '当前筛选下没有评分项' }} 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) => {scoreStatusLabel(r)}{r.error && {r.error}} }, { 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.status === 'needs_review' && gap.target_label).join('、') || review.missing_evidence?.join('、') || '见缺失证据'}。系统未给出模型分,也没有按低分处理;请核对下方证据,补充或更正材料后重试,或直接填写教师评分。`} />} `${gap.target_label}(检索范围:${(gap.searched_page_types || []).map(pageTypeLabel).join('、') || '正文'})`).join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} />{review.evidence?.map((ev: any, index: number) => {ev.document} · {evidencePlace(ev)}{pageTypeLabel(ev.page_type)}{ev.historical && 历史评分引用}{ev.target_label && {ev.target_label}}{ev.quote}{ev.url && 查看页面文字}{ev.image_url && {evidencePreviewLabel(ev)}(核对引文)})}
    {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); }, []); 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 <>成绩与反馈导出 `${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 params = new URLSearchParams(window.location.search); const [page, setPage] = useState(params.get('page') || 'chat'); const [batchId, setBatchId] = useState(Number(params.get('batch_id')) || undefined); const [projectId, setProjectId] = useState(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 = { chat: 正在加载会话…}>, dashboard: , courses: , knowledge: , import: navigate('projects', undefined, ids.length === 1 ? ids[0] : undefined)} scoreImported={scoreImported} />, projects: , batches: , scores: , exports: , settings: }; return
    智
    WiseGrade教师工作台
    navigate(key)} />
    智评自动化专业综合实践
    单机教师端
    {pages[page] || pages.chat}
    ; }