@@ -1,9 +1,11 @@
import { useEffect , useState } from 'react' ;
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 , Progress , Row , Select , Space , Statistic , Table , Tag , Typography , message } from 'antd' ;
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 Chat from './Chat ';
import { Batches } from './Batches ';
import { projectLabel , projectsInBatch } from './batchProjects' ;
const Chat = lazy ( ( ) = > import ( './Chat' ) ) ;
const { Header , Sider , Content } = Layout ;
const { Title , Text , Paragraph } = Typography ;
@@ -14,7 +16,7 @@ const nav = [
{ key : 'courses' , icon : < BookOutlined / > , label : '课程与规则' } ,
{ key : 'knowledge' , icon : < FileSearchOutlined / > , label : '知识库资料' } ,
{ key : 'import' , icon : < FolderOpenOutlined / > , label : '目录扫描与导入' } ,
{ key : 'projects' , icon : < TeamOutlined / > , label : '学生项目 ' } ,
{ key : 'projects' , icon : < TeamOutlined / > , label : '学生资料 ' } ,
{ key : 'batches' , icon : < ThunderboltOutlined / > , label : '评分批次' } ,
{ key : 'scores' , icon : < AuditOutlined / > , label : '评分与复核' } ,
{ key : 'exports' , icon : < ExportOutlined / > , label : '导出任务' } ,
@@ -91,19 +93,40 @@ function Knowledge() {
useEffect ( ( ) = > { const id = Number ( new URLSearchParams ( window . location . search ) . get ( 'course_version_id' ) ) ; if ( id ) api ( ` /course-versions/ ${ id } ` ) . then ( v = > { setCourseId ( v . course_id ) ; setVersionId ( id ) ; } ) . catch ( fail ) ; } , [ ] ) ;
useEffect ( ( ) = > { if ( courseId ) load ( ` /courses/ ${ courseId } /versions ` , setVersions ) ; } , [ courseId ] ) ;
useEffect ( ( ) = > { if ( versionId ) load ( ` /knowledge?course_version_id= ${ versionId } ` , setDocs ) ; } , [ versionId ] ) ;
return < > < Title level = { 2 } > 公 共 课 程 知 识 库 < / Title > < Space > < Select placeholder = "课程" style = { { width : 260 } } options = { options ( courses , c = > c . name ) } onChange = { setCourseId } / > < Select placeholder = "课程版本" style = { { width : 220 } } options = { options ( versions , v = > ` v ${ v . version } ${ v . title } ` ) } onChange = { setVersionId } / > < Button type = "primary" disabled = { ! versionId } onClick = { async ( ) = > { try { const result = await post ( ` /knowledge/ingest-course/ ${ versionId } ` ) ; message . info ( ` 入库 ${ result . completed . length } 份;失败 ${ Object . keys ( result . errors ) . length } 份 ` ) ; load ( ` /knowledge?course_version_id= ${ versionId } ` , setDocs ) ; } catch ( e ) { fail ( e ) ; } } } > 入 库 课 程 资 料 < / Button > < / Space > < Table className = "top-gap" rowKey = "id" dataSource = { docs } columns = { [ { title : '资料' , dataIndex : 'title' } , { title : '类型' , dataIndex : 'type' } , { title : '块数' , dataIndex : 'chunks' } , { title : '状态' , dataIndex : 'status' } ] } / > < / > ;
return < > < Title level = { 2 } > 公 共 课 程 知 识 库 < / Title > < Space > < Select placeholder = "课程" style = { { width : 260 } } options = { options ( courses , c = > c . name ) } onChange = { setCourseId } / > < Select placeholder = "课程版本" style = { { width : 220 } } options = { options ( versions , v = > ` v ${ v . version } ${ v . title } ` ) } onChange = { setVersionId } / > < Button type = "primary" disabled = { ! versionId } onClick = { async ( ) = > { try { const result = await post ( ` /knowledge/ingest-course/ ${ versionId } ` ) ; message . info ( ` 入库 ${ result . completed . length } 份;失败 ${ Object . keys ( result . errors ) . length } 份 ` ) ; load ( ` /knowledge?course_version_id= ${ versionId } ` , setDocs ) ; } catch ( e ) { fail ( e ) ; } } } > 入 库 课 程 资 料 < / Button > < / Space > < Table className = "top-gap" rowKey = "id" dataSource = { docs } columns = { [ { title : '资料' , dataIndex : 'title' } , { title : '类型' , dataIndex : 'type' } , { title : '块数' , dataIndex : 'chunks' } , { title : '状态' , render : ( _ , doc ) = > < Tag color = { doc . requires_reindex ? 'gold' : doc . status === 'ready' ? 'green' : 'default' } > { doc . requires_reindex ? '需重建索引' : ( { ready : '可检索' , superseded : '历史版本' , indexing : '索引处理中' } [ doc . status as string ] || doc . status ) } < / Tag > } ] } / > < / > ;
}
function Import() {
function Import ( { openImported , scoreImported } : { openImported : ( ids : number [ ] ) = > void ; scoreImported : ( versionId : number , ids : number [ ] ) = > void } ) {
const [ path , setPath ] = useState ( '.' ) , [ dirs , setDirs ] = useState < any [ ] > ( [ ] ) , [ scanData , setScanData ] = useState < any > ( ) , [ courses , setCourses ] = useState < any [ ] > ( [ ] ) , [ versions , setVersions ] = useState < any [ ] > ( [ ] ) , [ courseId , setCourseId ] = useState < number > ( ) , [ versionId , setVersionId ] = useState < number > ( ) ;
const [ imported , setImported ] = useState < { courseVersionId : number ; projectIds : number [ ] } > ( ) ;
const [ importing , setImporting ] = useState ( false ) ;
useEffect ( ( ) = > { load ( '/courses' , setCourses ) ; } , [ ] ) ;
useEffect ( ( ) = > { load ( ` /storage/directories?relative= ${ encodeURIComponent ( path ) } ` , setDirs ) ; } , [ path ] ) ;
useEffect ( ( ) = > { setScanData ( undefined ) ; setImported ( undefined ) ; 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' } ] } / > ; } } } / > < / > }
const confirm = async ( ) = > {
if ( ! versionId || ! scanData ? . projects ? . length ) return ;
setImporting ( true ) ;
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 } ) ;
setImported ( { courseVersionId : versionId , projectIds : result.project_ids } ) ;
message . success ( ` 已导入 ${ result . project_ids . length } 个项目 ` ) ;
} catch ( error ) { fail ( error ) ; }
finally { setImporting ( false ) ; }
} ;
return < > < Title level = { 2 } > 挂 载 目 录 扫 描 与 导 入 确 认 < / Title > < Paragraph > 浏 览 < code > / data / inbox < / code > 下 的 目 录 , 可 选 择 包 含 学 生 子 文 件 夹 的 总 目 录 , 也 可 直 接 进 入 放 有 PDF 、 DOCX 、 PPTX 的 资 料 目 录 扫 描 。 封 面 信 息 会 预 填 , 请 核 对 学 号 、 姓 名 和 选 题 后 确 认 导 入 。 < / Paragraph > < Space > < Button disabled = { path === '.' } onClick = { ( ) = > setPath ( path . includes ( '/' ) ? path . slice ( 0 , path . lastIndexOf ( '/' ) ) : '.' ) } > 上 一 级 < / Button > < Text code > { path } < / Text > < Button type = "primary" onClick = { async ( ) = > { try { setImported ( undefined ) ; 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 >
{ imported && imported . projectIds . length > 0 && < Alert className = "top-gap" type = "success" showIcon
message = { ` 已导入 ${ imported . projectIds . length } 个学生资料包 ` }
description = "点击查看已导入资料核对文件与解析状态,或去评分选择这次导入的资料。"
action = { < Space wrap > < Button onClick = { ( ) = > openImported ( imported . projectIds ) } > 查 看 已 导 入 资 料 < / Button >
< Button type = "primary" onClick = { ( ) = > scoreImported ( imported . courseVersionId , imported . projectIds ) } > 去 评 分 < / Button > < / Space > } / > }
{ scanData && < > < Divider / > { ! scanData . projects . length && < Alert className = "top-gap" type = "warning" showIcon message = "未找到可导入资料" description = "当前目录及其下一级学生目录中没有 PDF、DOCX 或 PPTX;请进入放有这些文件的目录再扫描。" / > } < Space > < Select aria-label = "导入课程" style = { { width : 260 } } placeholder = "课程" options = { options ( courses , c = > c . name ) } value = { courseId } onChange = { id = > { setCourseId ( id ) ; setVersionId ( undefined ) ; setVersions ( [ ] ) ; setImported ( undefined ) ; } } / > < Select aria-label = "导入课程版本" style = { { width : 270 } } placeholder = "已发布课程版本" options = { options ( versions . filter ( v = > v . published ) , v = > ` v ${ v . version } ${ v . title } ` ) } value = { versionId } onChange = { setVersionId } / > < Button type = "primary" loading = { importing } disabled = { ! versionId || importing || ! scanData . projects . length || scanData . projects . some ( ( p : any ) = > ! /^\d{5,20}$/ . test ( p . student_no ) || ! p . name ? . trim ( ) || ! p . topic ? . trim ( ) || ! p . files . some ( ( f : any ) = > f . status !== 'ignored' ) ) } onClick = { confirm } > 确 认 导 入 < / 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 = { { defaultExpandAllRows : true , 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' } ] } / > ; } } } / > < / > }
< / > ;
}
@@ -112,25 +135,46 @@ function Projects({ openScores }: { openScores: (id: number) => void }) {
useEffect ( ( ) = > { load ( '/projects' , setProjects ) ; } , [ ] ) ;
useEffect ( ( ) = > { const id = Number ( new URLSearchParams ( window . location . search ) . get ( 'project_id' ) ) ; if ( id ) load ( ` /projects/ ${ id } ` , setSelected ) ; } , [ ] ) ;
useEffect ( ( ) = > { if ( selected ? . id ) load ( ` /index-tasks?project_id= ${ selected . id } ` , setIndexTasks ) ; } , [ selected ? . id ] ) ;
return < > < Title level = { 2 } > 学 生 项 目 < / Title > < Table rowKey = "id" dataSource = { projects } columns = { [ { title : '学号' , dataIndex : 'student_no' } , { title : '姓名' , dataIndex : 'name' } , { title : '选题' , dataIndex : 'topic' } , { title : '缺失材料' , render : ( _ , p ) = > p . missing . length ? p . missing . map ( ( c : string ) = > < Tag color = "red" key = { c } > { categories [ c ] } < / Tag > ) : < Tag color = "green" > 齐 全 < / Tag > } , { title : '解析异常' , render : ( _ , p ) = > p . parse_errors . length ? < Badge status = "error" text = { p . parse_errors . length } / > : '—' } , { title : '操作' , render : ( _ , p ) = > < Space > < Button onClick = { ( ) = > load ( ` /projects/ ${ p . id } ` , setSelected ) } > 材 料 详 情 < / Button > < Button type = "link" onClick = { ( ) = > openScores ( p . id ) } > 查 看 评 分 < / Button > < / Space > } ] } / > < Drawer open = { ! ! selected } onClose = { ( ) = > setSelected ( undefined ) } title = { selected ? . topic } width = { 700 } > { selected && < > < Descriptions column = { 1 } items = { [ { key : 'no' , label : '学号' , children : selected.student_no } , { key : 'name' , label : '姓名' , children : selected.name } , { key : 'direction' , label : '选题方向' , children : selected.direction || '未指定' } ] } / > < Table < any > rowKey = "id" size = "small" dataSource = { selected . files } columns = { [ { title : '材料' , render : ( _ , f ) = > categories [ f . category ] } , { title : '文件' , dataIndex : 'relative_path' } , { title : '解析' , render : ( _ , f ) = > < Tag color = { f . parse_status === 'ready' ? 'green' : 'orange' } > { f . parse_status } < / Tag > } , { title : '错误' , dataIndex : 'parse_error' } ] } / > < Title level = { 5 } > 解 析 与 索 引 任 务 < / Title > < Table < any > rowKey = "id" size = "small" pagination = { false } dataSource = { indexTasks } columns = { [ { title : '任务' , dataIndex : 'id' } , { title : '材料 ID' , dataIndex : 'submission_file_id' } , { title : '状态' , render : ( _ , task ) = > < Tag color = { task . status === 'completed' ? 'green' : task . status === 'failed' ? 'red' : 'orange' } > { task . status } < / Tag > } , { title : '错误' , dataIndex : 'error' } , { title : '操作' , render : ( _ , task ) = > task . status === 'failed' && < Button size = "small" onClick = { async ( ) = > { try { await post ( ` /index-tasks/ ${ task . id } /retry ` ) ; load ( ` /index-tasks?project_id= ${ selected . id } ` , setIndexTasks ) ; } catch ( e ) { fail ( e ) ; } } } > 重 试 < / Button > } ] } / > < Title level = { 5 } > 抽 取 事 实 与 一 致 性 < / Title > < pre className = "json-view" > { JSON . stringify ( selected . facts , null , 2 ) } < / pre > < / > } < / Drawer > < / > ;
}
function 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" rowClassName = { ( b : any ) = > Number ( new URLSearchParams ( window . location . search ) . get ( "batch_id" ) ) === b . id ? "linked-row" : "" } dataSource = { batches } columns = { [ { title : '批次' , dataIndex : 'id' } , { title : '课程/规则版本' , render : ( _ , b ) = > ` ${ b . course_version_id } / ${ b . rubric_version_id } ` } , { title : '状态' , render : ( _ , b ) = > < Space wrap > < Tag color = { b . status === 'completed' ? 'green' : b . status === 'review_required' ? 'gold' : b . status === 'partial' || b . status === 'failed' ? 'orange' : 'blue' } > { batchStatusLabel ( b . status ) } < / Tag > { b . needs_review > 0 && < Tag color = "gold" > 待 复 核 { b . needs_review } < / Tag > } < / Space > } , { 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 disabled = { ! [ 'created' , 'failed' , 'partial' , 'review_required' ] . includes ( b . status ) } 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 > } ] } / > < / > ;
return < > < Title level = { 2 } > 已 导 入 学 生 资 料 < / Title > < Table rowKey = "id" dataSource = { projects } columns = { [ { title : '学号' , dataIndex : 'student_no' } , { title : '姓名' , dataIndex : 'name' } , { title : '选题' , dataIndex : 'topic' } , { title : '缺失材料' , render : ( _ , p ) = > p . missing . length ? p . missing . map ( ( c : string ) = > < Tag color = "red" key = { c } > { categories [ c ] } < / Tag > ) : < Tag color = "green" > 齐 全 < / Tag > } , { title : '解析异常' , render : ( _ , p ) = > p . parse_errors . length ? < Badge status = "error" text = { p . parse_errors . length } / > : '—' } , { title : '操作' , render : ( _ , p ) = > < Space > < Button onClick = { ( ) = > load ( ` /projects/ ${ p . id } ` , setSelected ) } > 材 料 详 情 < / Button > < Button type = "link" onClick = { ( ) = > openScores ( p . id ) } > 查 看 评 分 < / Button > < / Space > } ] } / > < Drawer open = { ! ! selected } onClose = { ( ) = > setSelected ( undefined ) } title = { selected ? . topic } width = { 700 } > { selected && < > < Descriptions column = { 1 } items = { [ { key : 'no' , label : '学号' , children : selected.student_no } , { key : 'name' , label : '姓名' , children : selected.name } , { key : 'direction' , label : '选题方向' , children : selected.direction || '未指定' } ] } / > < Table < any > rowKey = "id" size = "small" dataSource = { selected . files } columns = { [ { title : '材料' , render : ( _ , f ) = > categories [ f . category ] } , { title : '文件' , dataIndex : 'relative_path' } , { title : '解析' , render : ( _ , f ) = > < Tag color = { f . parse_status === 'ready' ? 'green' : 'orange' } > { f . parse_status } < / Tag > } , { title : '错误' , dataIndex : 'parse_error' } , { title : '解析提醒' , render : ( _ , f ) = > < Space wrap > { f . warnings ? . map ( ( warning : any , index : number ) = > < Tag color = "gold" key = { index } > 第 { warning . page } 页 : { warning . message } < / Tag > ) } < / Space > } ] } / > < Title level = { 5 } > 解 析 与 索 引 任 务 < / Title > < Table < any > rowKey = "id" size = "small" pagination = { false } dataSource = { indexTasks } columns = { [ { title : '任务' , dataIndex : 'id' } , { title : '材料 ID' , dataIndex : 'submission_file_id' } , { title : '状态' , render : ( _ , task ) = > < Tag color = { task . status === 'completed' ? 'green' : task . status === 'failed' ? 'red' : 'orange' } > { task . status } < / Tag > } , { title : '错误' , dataIndex : 'error' } , { title : '操作' , render : ( _ , task ) = > task . status === 'failed' && < Button size = "small" onClick = { async ( ) = > { try { await post ( ` /index-tasks/ ${ task . id } /retry ` ) ; load ( ` /index-tasks?project_id= ${ selected . id } ` , setIndexTasks ) ; } catch ( e ) { fail ( e ) ; } } } > 重 试 < / Button > } ] } / > < Title level = { 5 } > 抽 取 事 实 与 一 致 性 < / Title > < pre className = "json-view" > { JSON . stringify ( selected . facts , null , 2 ) } < / pre > < / > } < / Drawer > < / > ;
}
function Scores ( { batchId , projectId } : { batchId? : number ; projectId? : number } ) {
const [ batches , setBatches ] = useState < any [ ] > ( [ ] ) , [ projects , setProjects ] = useState < any [ ] > ( [ ] ) , [ batch , setBatch ] = useState < number | undefined > ( batchId ) , [ project , setProject ] = useState < number | undefined > ( projectId ) , [ result , setResult ] = useState < any > ( ) , [ review , setReview ] = useState < any > ( ) , [ revisions , setRevisions ] = useState < any [ ] > ( [ ] ) ;
const [ 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 [ 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 ; } ;
} , [ ] ) ;
const selectedBatch = batches . find ( item = > item . id === batch ) ;
const scopedProjects = projectsInBatch ( selectedBatch , projects ) ;
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 = ( ) = > 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 = { statusColor ( r . status ) } > { statusLabel ( r . status ) } < / Tag > } , { title : '操作' , render : ( _ , r ) = > < Button onClick = { ( ) = > { setReview ( r ) ; form . setFieldsValue ( reviewFormValues ( r ) ) ; } } > 查 看 证 据 / 复 核 < / Button > } ] } / > < / > }
return < > < Title level = { 2 } > 逐 项 评 分 与 教 师 复 核 < / Title >
< Space wrap >
< Select aria-label = "评分批次" style = { { width : 250 } } placeholder = "评分批次" value = { batch }
options = { options ( batches , b = > ` 批次 # ${ b . id } · ${ batchStatusLabel ( b . status ) } ` ) } onChange = { setBatch } / >
< Select aria-label = "学生项目" style = { { width : 460 } } placeholder = "选择该批次中的学生项目" value = { project }
disabled = { ! sourcesReady || ! selectedBatch } options = { options ( scopedProjects , projectLabel ) } onChange = { setProject } / >
< / Space >
{ selectedBatch && < Alert className = "top-gap" type = "info" showIcon
message = { ` 批次 # ${ batch } 包含 ${ scopedProjects . length } 个项目。下方分数仅对应当前选中的学生项目。 ` } / > }
{ result && < > { result . rows . some ( ( r : any ) = > r . status === 'failed' ) && < Alert className = "top-gap" type = "error" showIcon message = { ` 当前项目有 ${ result . rows . filter ( ( r : any ) = > r . status === 'failed' ) . length } 项评分失败 ` } description = "具体原因见下表。网络恢复后,可在评分批次页点击启动/重试;已经成功的评分和教师复核会保留。" / > } < Card className = "top-gap" title = { ` ${ result . student_no } ${ result . name } · ${ result . topic } ` } > < Space wrap > { Object . entries ( result . totals ) . map ( ( [ key , value ] ) = > < Tag key = { key } color = { value === null ? 'orange' : 'blue' } > { key } : { String ( value ? ? '待处理' ) } < / Tag > ) } < / Space > { result . facts ? . consistency_issues ? . length > 0 && < Alert className = "top-gap" type = "warning" message = "跨文档一致性提醒" description = { result . facts . consistency_issues . map ( ( issue : any ) = > issue . description ) . join ( '; ' ) } / > } < / Card > < Table className = "top-gap" rowKey = { ( row : any ) = > row . criterion . criterion_id } dataSource = { 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 ) = > < Space direction = "vertical" > < Tag color = { statusColor ( r . status ) } > { statusLabel ( r . status ) } < / Tag > { r . error && < Text type = "secondary" style = { { maxWidth : 300 } } > { r . error } < / Text > } < / Space > } , { title : '操作' , render : ( _ , r ) = > < Button onClick = { ( ) = > { setReview ( r ) ; form . setFieldsValue ( reviewFormValues ( r ) ) ; } } > 查 看 证 据 / 复 核 < / Button > } ] } / > < / > }
< Modal open = { ! ! review } title = { review ? . criterion ? . name } width = { 850 } onCancel = { ( ) = > setReview ( undefined ) } onOk = { async ( ) = > { try { const values = await form . validateFields ( ) ; await put ( ` /criterion-scores/ ${ review . score_id } /review ` , values ) ; setReview ( undefined ) ; refresh ( ) ; message . success ( '复核记录已保存' ) ; } catch ( e ) { fail ( e ) ; } } } okText = "保存教师复核" okButtonProps = { { disabled : ! review ? . score_id } } > { review && < > { review . status === 'needs_review' && < Alert type = "warning" showIcon className = "top-gap" message = "本项缺少正文证据,需要教师复核" description = { ` 未找到正文证据的目标: ${ ( review . evidence_gaps || [ ] ) . map ( ( gap : any ) = > gap . target_label ) . join ( '、' ) || review . missing_evidence ? . join ( '、' ) || '见缺失证据' } 。系统未给出模型分,也没有按低分处理;请核对材料后直接填写教师评分。 ` } / > } < Descriptions column = { 1 } items = { [ { key : 'model' , label : '模型分数/评语' , children : ` ${ review . model_score ? ? '未给出' } · ${ review . model_comment || '' } ` } , { key : 'confidence' , label : '模型置信度' , children : review.confidence ? ? '—' } , { key : 'missing' , label : '缺失证据' , children : review.missing_evidence?.join ( '; ' ) || '无' } , { key : 'gaps' , label : '缺正文证据的目标' , children : ( review . evidence_gaps || [ ] ) . map ( ( gap : any ) = > ` ${ gap . target_label } (检索范围: ${ ( gap . searched_page_types || [ ] ) . map ( pageTypeLabel ) . join ( '、' ) || '正文' } ) ` ) . join ( '; ' ) || '无' } , { key : 'suggestion' , label : '改进建议' , children : review.suggestion || '无' } , { key : 'error' , label : '异常' , children : review.error || '无' } ] } / > < Divider / > { review . evidence ? . map ( ( ev : any , index : number ) = > < Card size = "small" key = { index } className = "evidence" > < Space wrap > < Text strong > { ev . document } · { evidencePlace ( ev ) } < / Text > < Tag color = { ev . page_type === 'body' || ev . page_type === 'appendix' ? 'green' : 'orange' } > { pageTypeLabel ( ev . page_type ) } < / Tag > { ev . historical && < Tag > 历 史 评 分 引 用 < / Tag > } { ev . target_label && < Tag > { ev . target_label } < / Tag > } < / Space > < Paragraph > { ev . quote } < / Paragraph > < Space wrap > { ev . url && < a href = { ev . url } target = "_blank" rel = "noreferrer" > 查 看 页 面 文 字 < / a > } { ev . image_url && < a href = { ev . image_url } target = "_blank" rel = "noreferrer" > { evidencePreviewLabel ( ev ) } ( 核 对 引 文 ) < / a > } < / Space > < / Card > ) } < Divider / > < Form form = { form } layout = "vertical" > < Form.Item label = "教师评分" name = "score" rules = { [ { required : true , message : '请填写教师评分' } ] } > < InputNumber min = { 0 } max = { review . criterion . max_score } placeholder = "请填写" / > < / Form.Item > < Form.Item label = "教师评语" name = "comment" rules = { [ { required : true } ] } > < Input.TextArea rows = { 3 } / > < / Form.Item > < Form.Item label = "修改说明" name = "reason" > < Input / > < / Form.Item > < / Form > { review . score_id && < Button onClick = { async ( ) = > { try { setRevisions ( await api ( ` /criterion-scores/ ${ review . score_id } /revisions ` ) ) ; } catch ( e ) { fail ( e ) ; } } } > 查 看 修 改 记 录 < / Button > } { revisions . length > 0 && < pre className = "json-view" > { JSON . stringify ( revisions , null , 2 ) } < / pre > } < / > } < / Modal >
< / > ;
}
@@ -164,7 +208,12 @@ export default function App() {
} ;
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 ) = > navigate ( 'scores' , id , projectId ) ;
const pages : Record < string , ReactNode > = { chat : < Chat / > , 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 / > } ;
const openBatch = ( id : number , targetProject? : number ) = > navigate ( 'scores' , id , targetProject ) ;
const scoreImported = ( versionId : number , ids : number [ ] ) = > {
navigate ( 'batches' ) ;
const query = new URLSearchParams ( { page : 'batches' , course_version_id : String ( versionId ) , project_ids : ids.join ( ',' ) } ) ;
window . history . replaceState ( { } , '' , ` /? ${ query . toString ( ) } ` ) ;
} ;
const pages : Record < string , ReactNode > = { chat : < Suspense fallback = { < div > 正 在 加 载 会 话 … < / div > } > < Chat / > < / Suspense > , dashboard : < Dashboard / > , courses : < Courses / > , knowledge : < Knowledge / > , import : < Import openImported = { ids = > navigate ( 'projects' , undefined , ids . length === 1 ? ids [ 0 ] : undefined ) } scoreImported = { scoreImported } / > , projects : < Projects openScores = { openScores } / > , batches : < Batches key = { ` ${ params . get ( "course_version_id" ) } - ${ params . get ( "project_ids" ) } ` } openBatch = { openBatch } / > , scores : < Scores key = { ` ${ batchId } - ${ projectId } ` } batchId = { batchId } projectId = { projectId } / > , exports : < Exports / > , settings : < Settings / > } ;
return < Layout className = "shell" > < Sider breakpoint = "lg" collapsedWidth = "0" width = { 230 } theme = "dark" > < div className = "brand" > < span className = "brand-icon" > 智 < / span > < div > < strong > WiseGrade < / strong > < small > 教 师 工 作 台 < / small > < / div > < / div > < Menu theme = "dark" mode = "inline" selectedKeys = { [ page ] } items = { nav } onClick = { ( { key } ) = > navigate ( key ) } / > < / Sider > < Layout > < Header className = "header" > < div > < strong > 智 评 < / strong > < span className = "header-sub" > 自 动 化 专 业 综 合 实 践 < / span > < / div > < Tag color = "blue" > 单 机 教 师 端 < / Tag > < / Header > < Content className = "content" > < div className = "content-inner" > { pages [ page ] || pages . chat } < / div > < / Content > < / Layout > < / Layout > ;
}