@@ -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 > ;
}