@@ -0,0 +1,1270 @@
import { useEffect , useRef , useState } from 'react' ;
import { useParams } from 'react-router-dom' ;
import { api } from '../services/api' ;
async function downloadPdf ( url : string , options ? : { silent? : boolean } ) {
try {
const res = await fetch ( url , { credentials : 'include' } ) ;
if ( ! res . ok ) {
let msg = '下载失败' ;
try { msg = ( await res . json ( ) ) . error || msg ; } catch { /* 非 JSON 响应 */ }
alert ( msg ) ;
return false ;
}
const blob = await res . blob ( ) ;
// 优先 filename*( RFC 5987);其次 filename(剥离引号);最后兜底
const disposition = res . headers . get ( 'Content-Disposition' ) || '' ;
let filename = '' ;
const star = disposition . match ( /filename\*=(?:UTF-8'')?([^;\s]+)/i ) ;
if ( star ) {
try { filename = decodeURIComponent ( star [ 1 ] ) ; } catch { filename = star [ 1 ] ; }
} else {
const plain = disposition . match ( /filename="?([^";\s]+)"?/i ) ;
filename = plain ? plain [ 1 ] : '' ;
}
if ( ! filename ) filename = url . includes ( 'summary' ) ? '汇总报告.pdf' : url . includes ( 'deliverables' ) ? '成果物清单.csv' : '报告.pdf' ;
const a = document . createElement ( 'a' ) ;
a . href = URL . createObjectURL ( blob ) ;
a . download = filename ;
document . body . appendChild ( a ) ;
a . click ( ) ;
document . body . removeChild ( a ) ;
setTimeout ( ( ) = > URL . revokeObjectURL ( a . href ) , 1000 ) ;
if ( ! options ? . silent ) alert ( ` 已开始下载: ${ filename } (保存到浏览器下载目录) ` ) ;
return true ;
} catch ( e : any ) {
alert ( '下载失败: ' + ( e ? . message || e ) ) ;
return false ;
}
}
type Tab = 'standards' | 'entries' | 'deliverables' | 'summary' ;
export default function ProjectView() {
const { id } = useParams < { id : string } > ( ) ;
const [ project , setProject ] = useState < any > ( null ) ;
const [ projectError , setProjectError ] = useState ( '' ) ;
const [ tab , setTab ] = useState < Tab > ( 'entries' ) ;
const [ editingName , setEditingName ] = useState ( false ) ;
const [ newName , setNewName ] = useState ( '' ) ;
useEffect ( ( ) = > {
if ( id ) {
setProjectError ( '' ) ;
let cancelled = false ;
api . getProject ( id ) . then ( p = > { if ( ! cancelled ) setProject ( p ) ; } ) . catch ( ( err : any ) = > {
if ( ! cancelled ) {
setProjectError ( err . message || '项目不存在' ) ;
setProject ( null ) ;
}
} ) ;
return ( ) = > { cancelled = true ; } ;
}
} , [ id ] ) ;
if ( projectError ) return < div className = "project-view" > < div className = "empty" > { projectError } < / div > < / div > ;
if ( ! project ) return < div className = "loading" > 加 载 中 . . . < / div > ;
return (
< div className = "project-view" >
< div className = "project-header" >
< div className = "flex justify-between items-center" >
< div className = "flex items-center gap-10" >
{ editingName ? (
< input value = { newName } onChange = { e = > setNewName ( e . target . value ) } autoFocus
style = { { fontSize : 22 , fontWeight : 700 , padding : '4px 10px' , border : '2px solid var(--primary)' , borderRadius : 8 , outline : 'none' , width : 400 } }
onKeyDown = { async e = > {
if ( e . key === 'Enter' && newName . trim ( ) ) {
try {
await api . request ( 'PUT' , ` /projects/ ${ id } ` , { name : newName.trim ( ) } ) ;
setProject ( { . . . project , name : newName.trim ( ) } ) ;
setEditingName ( false ) ;
} catch ( err : any ) { alert ( err . message || '重命名失败' ) ; }
}
if ( e . key === 'Escape' ) { setNewName ( project . name ) ; setEditingName ( false ) ; }
} }
onBlur = { async ( ) = > {
if ( newName . trim ( ) && newName . trim ( ) !== project . name ) {
try {
await api . request ( 'PUT' , ` /projects/ ${ id } ` , { name : newName.trim ( ) } ) ;
setProject ( { . . . project , name : newName.trim ( ) } ) ;
} catch ( err : any ) { alert ( err . message || '重命名失败' ) ; }
}
setEditingName ( false ) ;
} }
/ >
) : (
< h2 style = { { cursor : 'pointer' } } onClick = { ( ) = > { setNewName ( project . name ) ; setEditingName ( true ) ; } } title = "点击重命名" > { project . name } ✎ < / h2 >
) }
{ project . track && < span className = "badge" style = { { background : '#6366f1' , marginLeft : 8 , fontSize : 12 } } > { project . track } < / span > }
< / div >
< button className = "btn-danger-outline" onClick = { async ( ) = > { if ( confirm ( ` 删除项目" ${ project . name } "?所有关联数据将丢失 ` ) ) { try { await api . request ( 'DELETE' , ` /projects/ ${ id } ?force=true ` ) ; window . location . href = '/' ; } catch ( err : any ) { alert ( err . message ) ; } } } } > 删 除 项 目 < / button >
< / div >
< div className = "project-meta" >
< span > 总 计 { project . total } < / span >
< span > ✓ { project . reviewed || 0 } < / span >
< span > ▶ { project . active || 0 } < / span >
< span > ✕ { project . failed || 0 } < / span >
< / div >
< / div >
< div className = "tabs" >
< button className = { ` tab ${ tab === 'standards' ? 'active' : '' } ` } onClick = { ( ) = > setTab ( 'standards' ) } > 标 准 < / button >
< button className = { ` tab ${ tab === 'entries' ? 'active' : '' } ` } onClick = { ( ) = > setTab ( 'entries' ) } > 条 目 < / button >
< button className = { ` tab ${ tab === 'deliverables' ? 'active' : '' } ` } onClick = { ( ) = > setTab ( 'deliverables' ) } > 成 果 物 < / button >
< button className = { ` tab ${ tab === 'summary' ? 'active' : '' } ` } onClick = { ( ) = > setTab ( 'summary' ) } > 汇 总 < / button >
< / div >
< div className = "tab-content" >
{ tab === 'standards' && < StandardsManager projectId = { id ! } / > }
{ tab === 'entries' && < EntryManager projectId = { id ! } track = { project . track } / > }
{ tab === 'deliverables' && < DeliverablesView projectId = { id ! } / > }
{ tab === 'summary' && < SummaryView projectId = { id ! } / > }
< / div >
< / div >
) ;
}
function StandardsManager ( { projectId } : { projectId : string } ) {
const [ standards , setStandards ] = useState < any [ ] > ( [ ] ) ;
const [ showForm , setShowForm ] = useState ( false ) ;
const [ name , setName ] = useState ( '' ) ;
const [ content , setContent ] = useState ( '' ) ;
const [ catTag , setCatTag ] = useState ( '' ) ;
const [ maxScore , setMaxScore ] = useState ( '150' ) ;
const load = ( ) = > api . listStandards ( projectId ) . then ( setStandards ) . catch ( ( ) = > { } ) ;
useEffect ( ( ) = > { load ( ) ; } , [ projectId ] ) ;
const create = async ( ) = > {
if ( ! name . trim ( ) || ! content . trim ( ) ) return ;
try {
await api . createStandard ( projectId , { name : name.trim ( ) , content , category_tag : catTag , max_score : parseInt ( maxScore ) || 150 } ) ;
setName ( '' ) ; setContent ( '' ) ; setCatTag ( '' ) ; setMaxScore ( '150' ) ; setShowForm ( false ) ; await load ( ) ;
} catch ( err : any ) { alert ( err . message || '保存失败,请检查服务端' ) ; }
} ;
const remove = async ( sid : string ) = > {
if ( ! confirm ( '确定删除?' ) ) return ;
try { await api . deleteStandard ( projectId , sid ) ; await load ( ) ; } catch ( err : any ) { alert ( err . message || '删除失败' ) ; }
} ;
return (
< div >
< div className = "section-header" > < h3 > 评 审 标 准 ( { standards . length } ) < / h3 > < button onClick = { ( ) = > setShowForm ( true ) } > + 上 传 标 准 < / button > < / div >
{ showForm && (
< div className = "standard-form" >
< input value = { name } onChange = { e = > setName ( e . target . value ) } placeholder = "标准名称" / >
< input value = { catTag } onChange = { e = > setCatTag ( e . target . value ) } placeholder = "分类标签(留空为默认标准)" / >
< input value = { maxScore } onChange = { e = > setMaxScore ( e . target . value ) } placeholder = "总分上限" type = "number" min = { 1 } style = { { width : 120 , padding : '10px 14px' , border : '2px solid var(--border)' , borderRadius : 8 , fontSize : 14 , outline : 'none' } } / >
< textarea value = { content } onChange = { e = > setContent ( e . target . value ) } placeholder = { '## 维度名(分值)\n评审要点\n可选:文件关键词: data,report,benchmark(限定该维度只看这些文件,留空则按系统规则)' } rows = { 8 } / >
< div className = "form-actions" >
< button onClick = { create } > 保 存 < / button >
< button onClick = { ( ) = > setShowForm ( false ) } > 取 消 < / button >
< / div >
< / div >
) }
< div className = "standard-list" >
{ standards . length === 0 && < div className = "empty" > 暂 无 评 审 标 准 < / div > }
{ standards . map ( s = > (
< div key = { s . id } className = "standard-card" style = { { flexDirection : 'column' , alignItems : 'stretch' } } >
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'flex-start' , marginBottom : 8 } } >
< div >
< strong style = { { fontSize : 15 } } > { s . name } < / strong >
{ s . category_tag && < span className = "tag" > { s . category_tag } < / span > }
< span className = "tag" style = { { background : '#e0e7ff' , color : '#4338ca' } } > 上 限 { s . max_score || 150 } 分 < / span >
< / div >
< button className = "btn-danger" onClick = { ( ) = > remove ( s . id ) } > 删 除 < / button >
< / div >
< details style = { { fontSize : 13 } } >
< summary style = { { cursor : 'pointer' , color : 'var(--primary)' , fontWeight : 500 , marginBottom : 4 } } >
查 看 维 度 详 情 ( { s . dimensions ? . length || 0 } 个 维 度 )
< / summary >
{ s . dimensions ? . map ( ( d : any , i : number ) = > (
< div key = { i } style = { { padding : '10px 12px' , margin : '6px 0' , background : 'var(--bg-subtle)' , borderRadius : 8 , border : '1px solid var(--border)' } } >
< div style = { { fontWeight : 600 , marginBottom : 4 } } > { d . name } ( { d . maxScore } 分 ) < / div >
{ d . group && d . group !== 'common' && < span className = "tag" style = { { background : '#f3e8ff' , color : '#7c3aed' , marginBottom : 4 , display : 'inline-block' } } > { d . group } < / span > }
< div style = { { whiteSpace : 'pre-wrap' , color : 'var(--text-secondary)' , lineHeight : 1.6 , fontSize : 12 } } > { d . content || '无详细说明' } < / div >
< / div >
) ) }
< / details >
< / div >
) ) }
< / div >
< / div >
) ;
}
function EntryManager ( { projectId , track } : { projectId : string ; track? : string } ) {
const [ entries , setEntries ] = useState < any [ ] > ( [ ] ) ;
const [ total , setTotal ] = useState ( 0 ) ;
const [ status , setStatus ] = useState ( '' ) ;
const [ search , setSearch ] = useState ( '' ) ;
const [ selected , setSelected ] = useState < Set < string > > ( new Set ( ) ) ;
const [ detail , setDetail ] = useState < any > ( null ) ;
const [ showImport , setShowImport ] = useState ( false ) ;
const [ csvText , setCsvText ] = useState ( '' ) ;
const [ importResult , setImportResult ] = useState < any > ( null ) ;
const [ editEntry , setEditEntry ] = useState < any > ( null ) ;
const [ showAdd , setShowAdd ] = useState ( false ) ;
const [ addForm , setAddForm ] = useState ( { title : '' , repo_url : '' , participant : '' , sub_type : '' , branch : '' , question_id : '' } ) ;
const [ offset , setOffset ] = useState ( 0 ) ;
const [ questionFilter , setQuestionFilter ] = useState ( '' ) ;
const PAGE_LIMIT = 50 ;
const latestSearchRef = useRef ( '' ) ;
const doSearch = ( term : string , pageOffset = offset , qFilter = questionFilter ) = > {
latestSearchRef . current = term ;
const params : any = { limit : PAGE_LIMIT , offset : pageOffset } ;
if ( status ) params . status = status ;
if ( term ) params . search = term ;
if ( qFilter ) params . question_id = qFilter ;
api . listEntries ( projectId , params ) . then ( r = > {
if ( latestSearchRef . current === term ) {
setEntries ( r . items ) ;
setTotal ( r . total ) ;
}
} ) . catch ( ( ) = > { } ) ;
} ;
const load = ( ) = > doSearch ( search , offset , questionFilter ) ;
const goPage = ( newOffset : number ) = > {
setOffset ( newOffset ) ;
setTimeout ( ( ) = > doSearch ( search , newOffset , questionFilter ) , 0 ) ;
} ;
useEffect ( ( ) = > { setOffset ( 0 ) ; doSearch ( search , 0 , questionFilter ) ; } , [ projectId , status , search , questionFilter ] ) ;
const toggleSelect = ( id : string ) = > {
const next = new Set ( selected ) ;
if ( next . has ( id ) ) next . delete ( id ) ; else next . add ( id ) ;
setSelected ( next ) ;
} ;
const doAction = async ( action : string , entryId : string ) = > {
try {
if ( action === 'start' ) await api . request ( 'POST' , ` /projects/ ${ projectId } /entries/ ${ entryId } /start ` ) ;
else if ( action === 'cancel' ) await api . request ( 'POST' , ` /projects/ ${ projectId } /entries/ ${ entryId } /cancel ` ) ;
else if ( action === 'retry' ) await api . request ( 'POST' , ` /projects/ ${ projectId } /entries/ ${ entryId } /retry ` ) ;
await load ( ) ;
} catch ( err : any ) { alert ( err . message || '操作失败' ) ; }
} ;
// §2.2 人工构建确认:a_done 提供「确认构建完成 / 构建失败」两按钮,结果传给 /verify
const doVerify = async ( entryId : string , buildStatus : 'done' | 'failed' ) = > {
try {
await api . request ( 'POST' , ` /projects/ ${ projectId } /entries/ ${ entryId } /verify ` , { build_status : buildStatus } ) ;
await load ( ) ;
} catch ( err : any ) { alert ( err . message || '启动系统验证失败' ) ; }
} ;
const doDelete = async ( entryId : string , title : string ) = > {
if ( ! confirm ( ` 删除条目" ${ title } "? ` ) ) return ;
try { await api . request ( 'DELETE' , ` /projects/ ${ projectId } /entries/ ${ entryId } ` ) ; await load ( ) ; } catch ( err : any ) { alert ( err . message || '删除失败' ) ; }
} ;
const doEditSave = async ( ) = > {
if ( ! editEntry ) return ;
try {
await api . request ( 'PUT' , ` /projects/ ${ projectId } /entries/ ${ editEntry . id } ` , {
title : editEntry.title ,
repo_url : editEntry.repo_url ,
participant : editEntry.participant ,
branch : editEntry.branch ,
sub_type : editEntry.sub_type ,
question_id : editEntry.question_id ,
service_url : editEntry.service_url ,
build_status : editEntry.build_status ,
} ) ;
setEditEntry ( null ) ;
await load ( ) ;
} catch ( err : any ) { alert ( err . message || '保存失败' ) ; }
} ;
const openEdit = ( e : any ) = > setEditEntry ( {
id : e.id , title : e.title , repo_url : e.repo_url , participant : e.participant || '' ,
sub_type : e.sub_type || '' , question_id : e.question_id || '' , branch : e.branch || '' ,
service_url : e.service_url || '' , build_status : e.build_status || '' ,
} ) ;
const doAdd = async ( ) = > {
if ( ! addForm . title . trim ( ) || ! addForm . repo_url . trim ( ) ) return ;
try {
await api . request ( 'POST' , ` /projects/ ${ projectId } /entries ` , addForm ) ;
setAddForm ( { title : '' , repo_url : '' , participant : '' , sub_type : '' , branch : '' , question_id : '' } ) ;
setShowAdd ( false ) ;
await load ( ) ;
} catch ( err : any ) { alert ( err . message || '添加失败' ) ; }
} ;
const batchStart = async ( ) = > {
const ids = Array . from ( selected ) ;
if ( ids . length === 0 ) return ;
try { await api . request ( 'POST' , ` /projects/ ${ projectId } /entries/batch-start ` , { entryIds : ids } ) ; await load ( ) ; } catch ( err : any ) { alert ( err . message || '启动失败' ) ; }
} ;
const doImport = async ( ) = > {
const lines = csvText . trim ( ) . split ( '\n' ) ;
if ( lines . length < 2 ) return ;
const headers = lines [ 0 ] . split ( ',' ) . map ( h = > h . trim ( ) ) ;
const items = lines . slice ( 1 ) . map ( line = > {
const vals = line . split ( ',' ) . map ( v = > v . trim ( ) ) ;
const item : any = { } ;
headers . forEach ( ( h , i ) = > { if ( vals [ i ] ) item [ h ] = vals [ i ] ; } ) ;
return item ;
} ) ;
try {
const r = await api . batchImport ( projectId , items ) ;
setImportResult ( r ) ;
setCsvText ( '' ) ;
if ( r . imported > 0 ) await load ( ) ;
} catch ( err : any ) { alert ( err . message || '导入失败' ) ; }
} ;
const downloadTemplate = ( ) = > {
const header = [ 'title' , 'repo_url' , 'participant' , 'branch' , 'service_url' , 'base_branch' ] ;
if ( track === '赛道一' ) header . push ( 'sub_type' ) ;
if ( track === '人才测评' ) header . push ( 'question_id' ) ;
const example = header . map ( h = >
h === 'repo_url' ? 'https://github.com/user/repo' :
h === 'question_id' ? 'Q1' :
h === 'sub_type' ? '新規' : ''
) . join ( ',' ) ;
const blob = new Blob ( [ '\uFEFF' + header . join ( ',' ) + '\n' + example ] , { type : 'text/csv;charset=utf-8' } ) ;
const a = document . createElement ( 'a' ) ;
a . href = URL . createObjectURL ( blob ) ;
a . download = 'entry-import-template.csv' ;
a . click ( ) ;
URL . revokeObjectURL ( a . href ) ;
} ;
const openDetail = async ( eid : string ) = > {
try { const d = await api . getEntry ( projectId , eid ) ; setDetail ( d ) ; } catch ( err : any ) { alert ( err . message || '加载详情失败' ) ; }
} ;
const statusBadge = ( s : string ) = > {
const map : Record < string , string > = {
pending : 'badge-gray' , queued : 'badge-amber' , cloning : 'badge-blue' , analyzing : 'badge-blue' ,
a_done : 'badge-purple' , verifying : 'badge-blue' ,
review_done : 'badge-green' , admin_reviewed : 'badge-green' ,
clone_fail : 'badge-red' , analysis_fail : 'badge-red' , failed : 'badge-red' , cancelled : 'badge-gray' ,
} ;
const label : Record < string , string > = {
pending : '待评审' , queued : '排队中' , cloning : '克隆中' , analyzing : '分析中' ,
a_done : 'A阶段完成' , verifying : '系统验证中' ,
review_done : '已完成' , admin_reviewed : '已修正' ,
clone_fail : '克隆失败' , analysis_fail : '分析失败' , failed : '失败' , cancelled : '已取消' ,
} ;
const cls = map [ s ] || 'badge-gray' ;
return < span className = { ` badge ${ cls } ` } > { label [ s ] || s } < / span > ;
} ;
const scoreColor = ( _s : string , score : number | null | undefined ) = > {
if ( score === null || score === undefined ) return 'score-empty' ;
if ( score >= 60 ) return 'score-ok' ;
if ( score >= 40 ) return 'score-warn' ;
return 'score-low' ;
} ;
return (
< div >
< div className = "section-header" >
< h3 > 评 审 条 目 ( { total } ) < / h3 >
< div style = { { display : 'flex' , gap : 8 } } >
< select value = { status } onChange = { e = > { setStatus ( e . target . value ) ; setOffset ( 0 ) ; } } className = "filter-select" >
< option value = "" > 全 部 < / option >
< option value = "pending" > 待 评 审 < / option >
< option value = "queued" > 排 队 中 < / option >
< option value = "cloning" > 克 隆 中 < / option >
< option value = "analyzing" > 分 析 中 < / option >
< option value = "a_done" > A阶段完成 < / option >
< option value = "verifying" > 系 统 验 证 中 < / option >
< option value = "review_done" > 已 完 成 < / option >
< option value = "admin_reviewed" > 已 修 正 < / option >
< option value = "clone_fail" > 克 隆 失 败 < / option >
< option value = "analysis_fail" > 分 析 失 败 < / option >
< option value = "failed" > 失 败 < / option >
< / select >
{ track === '人才测评' && (
< select value = { questionFilter } onChange = { e = > { setQuestionFilter ( e . target . value ) ; setOffset ( 0 ) ; } } className = "filter-select" >
< option value = "" > 全 部 题 目 < / option >
< option value = "Q1" > Q1 满 意 度 调 查 < / option >
< option value = "Q2" > Q2 面 谈 问 卷 < / option >
< option value = "Q3" > Q3 RAG检索 < / option >
< option value = "Q4" > Q4 合 同 审 查 < / option >
< option value = "Q5" > Q5 法 规 爬 虫 < / option >
< option value = "Q6" > Q6 风 险 情 报 < / option >
< / select >
) }
< input value = { search } onChange = { e = > setSearch ( e . target . value ) } placeholder = "搜索标题..." className = "search-input" onKeyDown = { e = > e . key === 'Enter' && doSearch ( e . currentTarget . value ) } / >
< button onClick = { ( ) = > setShowAdd ( true ) } className = "btn-primary" > + 添 加 条 目 < / button >
< button onClick = { batchStart } disabled = { selected . size === 0 } className = "btn-primary" > 启 动 选 中 ( { selected . size } ) < / button >
< button onClick = { ( ) = > setShowImport ( ! showImport ) } className = "btn-secondary" > 批 量 导 入 < / button >
< / div >
< / div >
{ showAdd && (
< div className = "import-panel" style = { { animation : 'slideDown 0.2s ease-out' } } >
< div className = "flex-col gap-10" >
< input value = { addForm . title } onChange = { e = > setAddForm ( { . . . addForm , title : e.target.value } ) } placeholder = "条目标题 *" className = "input-field" autoFocus / >
< input value = { addForm . repo_url } onChange = { e = > setAddForm ( { . . . addForm , repo_url : e.target.value } ) } placeholder = "Git仓库URL * (https://github.com/...)" className = "input-field" / >
< input value = { addForm . branch } onChange = { e = > setAddForm ( { . . . addForm , branch : e.target.value } ) } placeholder = "分支 (可选,默认 main)" className = "input-field" / >
< div className = "flex gap-8" >
< input value = { addForm . participant } onChange = { e = > setAddForm ( { . . . addForm , participant : e.target.value } ) } placeholder = "参赛者" className = "input-field flex-1" / >
{ track ? (
< span className = "badge badge-blue" style = { { padding : '10px 14px' , borderRadius : 8 , fontSize : 14 , whiteSpace : 'nowrap' } } > { track } < / span >
) : (
< span style = { { padding : '10px 14px' , borderRadius : 8 , fontSize : 13 , background : '#f3f4f6' , color : '#9ca3af' , whiteSpace : 'nowrap' } } > 未 设 置 赛 道 < / span >
) }
< / div >
{ track === '赛道一' && (
< select value = { addForm . sub_type } onChange = { e = > setAddForm ( { . . . addForm , sub_type : e.target.value } ) } className = "select-field" >
< option value = "" > 选 择 子 类 型 < / option >
< option value = "新規" > 新 規 开 发 < / option >
< option value = "修正" > 修 正 / 升 级 < / option >
< / select >
) }
{ track === '人才测评' && (
< >
< select value = { addForm . question_id } onChange = { e = > setAddForm ( { . . . addForm , question_id : e.target.value } ) } className = "select-field" >
< option value = "" > 选 择 题 目 * < / option >
< option value = "Q1" > Q1 满 意 度 调 查 ( ★ ★ L2のみ ) < / option >
< option value = "Q2" > Q2 面 谈 问 卷 ( ★ ★ ★ L3可 ) < / option >
< option value = "Q3" > Q3 RAG检索 ( ★ ★ ★ ★ L3可 ) < / option >
< option value = "Q4" > Q4 合 同 审 查 ( ★ ★ ★ L3可 ) < / option >
< option value = "Q5" > Q5 法 规 爬 虫 ( ★ ★ ★ L3可 ) < / option >
< option value = "Q6" > Q6 风 险 情 报 ( ★ ★ ★ L3可 ) < / option >
< / select >
{ addForm . question_id && (
< div style = { { fontSize : 12 , color : 'var(--text-secondary)' , padding : '4px 2px' } } >
{ addForm . question_id === 'Q1' ? '仅L2评审(共通100分)' : 'L2共通100分 + L3追加评审' }
< / div >
) }
< / >
) }
< div className = "form-actions" >
< button onClick = { doAdd } disabled = { ! addForm . title . trim ( ) || ! addForm . repo_url . trim ( ) } > 添 加 < / button >
< button onClick = { ( ) = > setShowAdd ( false ) } > 取 消 < / button >
< / div >
< / div >
< / div >
) }
{ showImport && (
< div className = "import-panel" >
< textarea value = { csvText } onChange = { e = > setCsvText ( e . target . value ) } placeholder = { '首行为列头(UTF-8 编码,推荐带 BOM):\ntitle,repo_url,participant,branch,service_url\n张三月结,https://gitea/zhang-01,张三,main,' } rows = { 5 } / >
< div className = "form-actions" >
< button onClick = { doImport } > 导 入 < / button >
< button onClick = { downloadTemplate } className = "btn-secondary" > 下 载 模 板 < / button >
< button onClick = { ( ) = > setShowImport ( false ) } > 取 消 < / button >
< / div >
{ importResult && (
< div className = "import-result" >
成 功 { importResult . imported } 条 { importResult . errors ? . length > 0 ? ` ,失败 ${ importResult . errors . length } 条 ` : '' }
{ importResult . errors ? . slice ( 0 , 5 ) . map ( ( e : any , i : number ) = > < div key = { i } className = "error-row" > 第 { e . row } 行 : { e . reason } < / div > ) }
< / div >
) }
< / div >
) }
< table className = "entry-table" >
< thead >
< tr >
< th > < input type = "checkbox" onChange = { e = > { if ( e . target . checked ) setSelected ( new Set ( entries . map ( x = > x . id ) ) ) ; else setSelected ( new Set ( ) ) ; } } checked = { selected . size === entries . length && entries . length > 0 } / > < / th >
< th > 标 题 < / th >
< th > 参 赛 者 < / th >
< th > 赛 道 < / th >
< th > 状 态 < / th >
< th > 分 数 < / th >
< th > 操 作 < / th >
< / tr >
< / thead >
< tbody >
{ entries . length === 0 && < tr > < td colSpan = { 7 } className = "empty-row" > 暂 无 条 目 < / td > < / tr > }
{ entries . map ( e = > (
< tr key = { e . id } className = { e . id === detail ? . id ? 'active-row' : '' } >
< td > < input type = "checkbox" checked = { selected . has ( e . id ) } onChange = { ( ) = > toggleSelect ( e . id ) } / > < / td >
< td className = "title-cell" onClick = { ( ) = > openDetail ( e . id ) } > { e . title } < / td >
< td > { e . participant } < / td >
< td >
< span className = "badge badge-blue" > { e . category_tag } < / span >
{ e . sub_type && < span className = "badge badge-purple ml-8" > { e . sub_type } < / span > }
{ e . question_id && < span className = "badge badge-gray ml-8" > { e . question_id } < / span > }
{ e . final_level && (
< span className = { ` badge ml-8 ${ e . final_level === 'L3' ? 'badge-purple' : e . final_level === 'L2' ? 'badge-green' : 'badge-red' } ` } >
{ e . final_level }
< / span >
) }
< / td >
< td > { statusBadge ( e . status ) } < / td >
< td className = { ` score-cell ${ scoreColor ( e . status , e . final_score ? ? e . raw_score ? ? ( e . status === 'a_done' ? e.score_a : null ) ) } ` } >
{ e . status === 'a_done'
? ( e . score_a != null ? ` A: ${ e . score_a } ` : 'A:-' )
: ( e . aggregate_count > 0
? ` ${ e . aggregate_score } ${ e . is_formal ? ` (聚合 ${ e . aggregate_count } 次) ` : ` (初评 ${ e . aggregate_count } 次) ` } `
: ( e . final_score ? ? e . raw_score ? ? '-' ) ) }
< / td >
< td className = "action-cell" >
{ e . status === 'pending' && < button className = "btn-action" onClick = { ( ) = > doAction ( 'start' , e . id ) } > 启 动 < / button > }
{ e . status === 'pending' && < button className = "btn-action" onClick = { ( ) = > openEdit ( e ) } > 编 辑 < / button > }
{ e . status === 'pending' && < button className = "btn-action" style = { { borderColor : '#ef4444' , color : '#ef4444' } } onClick = { ( ) = > doDelete ( e . id , e . title ) } > 删 除 < / button > }
{ [ 'queued' , 'cloning' , 'analyzing' ] . includes ( e . status ) && < button className = "btn-action warn" onClick = { ( ) = > doAction ( 'cancel' , e . id ) } > 取 消 < / button > }
{ e . status === 'a_done' && < button className = "btn-action" onClick = { ( ) = > openEdit ( e ) } > 编 辑 < / button > }
{ e . status === 'a_done' && < button className = "btn-action" style = { { borderColor : '#10b981' , color : '#10b981' } } onClick = { ( ) = > doVerify ( e . id , 'done' ) } > 构 建 完 成 < / button > }
{ e . status === 'a_done' && < button className = "btn-action" style = { { borderColor : '#ef4444' , color : '#ef4444' } } onClick = { ( ) = > doVerify ( e . id , 'failed' ) } > 构 建 失 败 < / button > }
{ e . status === 'a_done' && < button className = "btn-action" onClick = { ( ) = > openDetail ( e . id ) } > 详 情 < / button > }
{ e . status === 'verifying' && < button className = "btn-action warn" onClick = { ( ) = > doAction ( 'cancel' , e . id ) } > 取 消 < / button > }
{ [ 'clone_fail' , 'analysis_fail' , 'failed' ] . includes ( e . status ) && < button className = "btn-action" onClick = { ( ) = > doAction ( 'retry' , e . id ) } > 重 试 < / button > }
{ [ 'review_done' , 'admin_reviewed' ] . includes ( e . status ) && < button className = "btn-action" onClick = { ( ) = > openDetail ( e . id ) } > 详 情 < / button > }
{ [ 'review_done' , 'admin_reviewed' ] . includes ( e . status ) && < button className = "btn-action" onClick = { ( ) = > doAction ( 'start' , e . id ) } > 重 新 评 审 < / button > }
< / td >
< / tr >
) ) }
< / tbody >
< / table >
{ total > PAGE_LIMIT && (
< div className = "pagination" style = { { display : 'flex' , justifyContent : 'center' , alignItems : 'center' , gap : 8 , marginTop : 16 } } >
< button disabled = { offset === 0 } onClick = { ( ) = > goPage ( offset - PAGE_LIMIT ) } className = "btn-action" style = { { padding : '6px 14px' } } > ← 上 一 页 < / button >
< span style = { { fontSize : 13 , color : 'var(--text-secondary)' } } >
{ Math . floor ( offset / PAGE_LIMIT ) + 1 } / { Math . ceil ( total / PAGE_LIMIT ) } 页 ( 共 { total } 条 )
< / span >
< button disabled = { offset + PAGE_LIMIT >= total } onClick = { ( ) = > goPage ( offset + PAGE_LIMIT ) } className = "btn-action" style = { { padding : '6px 14px' } } > 下 一 页 → < / button >
< / div >
) }
{ detail && < DetailPanel entry = { detail } projectId = { projectId } onClose = { ( ) = > setDetail ( null ) } onSave = { ( ) = > { load ( ) ; openDetail ( detail . id ) ; } } / > }
{ editEntry && (
< div className = "detail-overlay" onClick = { ( ) = > setEditEntry ( null ) } >
< div className = "detail-panel" onClick = { e = > e . stopPropagation ( ) } style = { { width : 480 } } >
< div className = "detail-header" >
< h3 > 编 辑 条 目 < / h3 >
< button className = "btn-close" onClick = { ( ) = > setEditEntry ( null ) } > × < / button >
< / div >
< div className = "flex-col gap-12" >
< input value = { editEntry . title } onChange = { e = > setEditEntry ( { . . . editEntry , title : e.target.value } ) } placeholder = "标题" className = "input-field" / >
< input value = { editEntry . repo_url } onChange = { e = > setEditEntry ( { . . . editEntry , repo_url : e.target.value } ) } placeholder = "仓库URL" className = "input-field" / >
< input value = { editEntry . branch } onChange = { e = > setEditEntry ( { . . . editEntry , branch : e.target.value } ) } placeholder = "分支" className = "input-field" / >
< input value = { editEntry . service_url || '' } onChange = { e = > setEditEntry ( { . . . editEntry , service_url : e.target.value } ) } placeholder = "服务地址(B阶段验证用,如 http://8.8.8.8:8080/app) " className = "input-field" / >
< select value = { editEntry . build_status || '' } onChange = { e = > setEditEntry ( { . . . editEntry , build_status : e.target.value } ) } className = "select-field" >
< option value = "" > 构 建 确 认 : 未 设 置 ( 系 统 自 动 构 建 ) < / option >
< option value = "done" > 构 建 确 认 : 成 功 ( 跳 过 自 动 构 建 ) < / option >
< option value = "failed" > 构 建 确 认 : 失 败 ( 跳 过 自 动 构 建 , 注 入 失 败 证 据 ) < / option >
< / select >
< div className = "flex gap-8" >
< input value = { editEntry . participant } onChange = { e = > setEditEntry ( { . . . editEntry , participant : e.target.value } ) } placeholder = "参赛者" className = "input-field flex-1" / >
{ track && < span className = "badge badge-blue" style = { { padding : '10px 14px' , borderRadius : 8 , fontSize : 14 , whiteSpace : 'nowrap' } } > { track } < / span > }
< / div >
{ track === '赛道一' && (
< select value = { editEntry . sub_type || '' } onChange = { e = > setEditEntry ( { . . . editEntry , sub_type : e.target.value } ) } className = "select-field" >
< option value = "" > 选 择 子 类 型 < / option >
< option value = "新規" > 新 規 开 发 < / option >
< option value = "修正" > 修 正 / 升 级 < / option >
< / select >
) }
{ track === '人才测评' && (
< >
< select value = { editEntry . question_id || '' } onChange = { e = > setEditEntry ( { . . . editEntry , question_id : e.target.value } ) } className = "select-field" >
< option value = "" > 选 择 题 目 * < / option >
< option value = "Q1" > Q1 满 意 度 调 查 ( ★ ★ L2のみ ) < / option >
< option value = "Q2" > Q2 面 谈 问 卷 ( ★ ★ ★ L3可 ) < / option >
< option value = "Q3" > Q3 RAG检索 ( ★ ★ ★ ★ L3可 ) < / option >
< option value = "Q4" > Q4 合 同 审 查 ( ★ ★ ★ L3可 ) < / option >
< option value = "Q5" > Q5 法 规 爬 虫 ( ★ ★ ★ L3可 ) < / option >
< option value = "Q6" > Q6 风 险 情 报 ( ★ ★ ★ L3可 ) < / option >
< / select >
{ editEntry . question_id && (
< div style = { { fontSize : 12 , color : 'var(--text-secondary)' , padding : '4px 2px' } } >
{ editEntry . question_id === 'Q1' ? '仅L2评审(共通100分)' : 'L2共通100分 + L3追加评审' }
< / div >
) }
< / >
) }
< div className = "form-actions" >
< button onClick = { doEditSave } > 保 存 < / button >
< button onClick = { ( ) = > setEditEntry ( null ) } > 取 消 < / button >
< / div >
< / div >
< / div >
< / div >
) }
< / div >
) ;
}
function DetailPanel ( { entry , projectId , onClose , onSave } : { entry : any ; projectId : string ; onClose : ( ) = > void ; onSave : ( ) = > void } ) {
const [ dims , setDims ] = useState < any [ ] > ( [ ] ) ;
const [ dimsAgg , setDimsAgg ] = useState < any > ( null ) ;
const [ overview , setOverview ] = useState ( '' ) ;
const [ overall , setOverall ] = useState < any > ( null ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ editingComment , setEditingComment ] = useState < number | null > ( null ) ;
const [ deliverables , setDeliverables ] = useState < any [ ] > ( ( ) = > {
try { return JSON . parse ( entry . deliverables || '[]' ) ; } catch { return [ ] ; }
} ) ;
useEffect ( ( ) = > {
if ( entry . ai_report ) {
try {
const r = JSON . parse ( entry . ai_report ) ;
setDims ( r . dimensions || [ ] ) ;
setOverview ( r . overview || '' ) ;
setOverall ( r . overall || null ) ;
} catch { setDims ( entry . dimensions || [ ] ) ; }
} else {
setDims ( entry . dimensions || [ ] ) ;
}
setDimsAgg ( ( entry as any ) . dimsAgg || null ) ;
try { const d = JSON . parse ( entry . deliverables || '[]' ) ; if ( d . length > 0 ) setDeliverables ( d ) ; } catch { }
} , [ entry ] ) ;
const totalScore = dims . reduce ( ( s : number , d : any ) = > s + ( Number ( d . score ) || 0 ) , 0 ) ;
const maxTotal = dims . reduce ( ( s : number , d : any ) = > s + ( d . maxScore || 0 ) , 0 ) ;
const pct = maxTotal > 0 ? Math . round ( ( totalScore / maxTotal ) * 100 ) : 0 ;
// Old ai_report dimensions lack `group` → derive from standard_snapshot dims by name
const stdGroupMap : Record < string , string > = { } ;
for ( const d of ( entry . dimensions || [ ] ) ) stdGroupMap [ d . name ] = d . group || 'common' ;
const dimGroup = ( d : any ) = > d . group || stdGroupMap [ d . name ] || 'common' ;
const l2Dims = dims . filter ( ( d : any ) = > dimGroup ( d ) === 'common' ) ;
const l3Dims = dims . filter ( ( d : any ) = > dimGroup ( d ) !== 'common' ) ;
const l2Score = l2Dims . reduce ( ( s , d ) = > s + ( Number ( d . score ) || 0 ) , 0 ) ;
const l2Max = l2Dims . reduce ( ( s , d ) = > s + ( d . maxScore || 0 ) , 0 ) ;
const l3Score = l3Dims . reduce ( ( s , d ) = > s + ( Number ( d . score ) || 0 ) , 0 ) ;
const l3Max = l3Dims . reduce ( ( s , d ) = > s + ( d . maxScore || 0 ) , 0 ) ;
// 维度级聚合历史(2026-08-19):dimsAgg.perRun 含各次维度分
const dimHistory = ( name : string ) : string = > {
if ( ! dimsAgg ? . perRun ? . length ) return '' ;
const scores = dimsAgg . perRun
. map ( ( r : any ) = > r . dims ? . find ( ( d : any ) = > d . name === name ) ? . score )
. filter ( ( s : any ) = > s !== undefined && s !== null ) ;
return scores . length > 1 ? ` (历次 ${ scores . join ( ' / ' ) } ) ` : '' ;
} ;
const finalLevel = entry . final_level || '' ;
const updateDim = ( i : number , field : string , value : any ) = > {
const next = [ . . . dims ] ;
( next [ i ] as any ) [ field ] = value ;
setDims ( next ) ;
} ;
const saveDeliverables = async ( ) = > {
try {
await api . request ( 'PUT' , ` /projects/ ${ projectId } /entries/ ${ entry . id } /deliverables ` , { deliverables } ) ;
} catch { }
} ;
const toggleDeliverable = ( idx : number ) = > {
const next = [ . . . deliverables ] ;
next [ idx ] = { . . . next [ idx ] , submitted : ! next [ idx ] . submitted } ;
setDeliverables ( next ) ;
saveDeliverables ( ) ;
} ;
const saveReport = async ( ) = > {
setSaving ( true ) ;
try {
await api . request ( 'PUT' , ` /projects/ ${ projectId } /entries/ ${ entry . id } /report ` , { dimensions : dims } ) ;
onSave ( ) ;
} catch ( err : any ) { alert ( err . message ) ; }
setSaving ( false ) ;
} ;
return (
< div className = "detail-overlay" onClick = { onClose } >
< div className = "detail-panel" onClick = { e = > e . stopPropagation ( ) } >
< div className = "detail-header" >
< h3 > { entry . title } < / h3 >
< button className = "btn-close" onClick = { onClose } > ✕ < / button >
< / div >
< div className = "detail-meta" >
< div > 仓 库 : < code > { entry . repo_url } < / code > < / div >
< div > 参 赛 者 : { entry . participant || '-' } | 及 格 线 : { entry . pass_line || '-' } 分 { entry . question_id ? ` | 选题: ${ entry . question_id } ` : '' } < / div >
< div style = { { display : 'flex' , gap : 12 , alignItems : 'center' , flexWrap : 'wrap' } } >
{ finalLevel && (
< span className = { ` badge ${ finalLevel === 'L3' ? 'badge-purple' : finalLevel === 'L2' ? 'badge-green' : 'badge-red' } ` } >
{ finalLevel === 'L3' ? '🏆 L3合格' : finalLevel === 'L2' ? '✅ L2合格' : '❌ 不合格' }
< / span >
) }
< span style = { { fontSize : 15 , fontWeight : 600 , color : finalLevel === 'L3' ? '#8b5cf6' : pct >= ( entry . pass_line || 60 ) ? '#10b981' : '#ef4444' } } >
{ l3Max > 0 ? ` L2: ${ l2Score } / ${ l2Max } | L3: ${ l3Score } / ${ l3Max } | 总分: ${ totalScore } / ${ maxTotal } ( ${ pct } %) ` : ` 得分: ${ totalScore } / ${ maxTotal } ( ${ pct } %) ` }
< / span >
{ entry . late_days > 0 && < span style = { { color : '#f59e0b' } } > 迟 交 { entry . late_days } 天 < / span > }
< / div >
{ entry . score_a != null && ( entry . score_b != null || entry . status === 'a_done' || entry . status === 'verifying' ) && (
< div style = { { marginTop : 6 , fontSize : 13 , color : 'var(--text-secondary)' } } >
A阶段 { entry . score_a } 分
{ entry . score_b != null && ` · B阶段 ${ entry . score_b } 分 ` }
{ entry . stage_b_status && entry . stage_b_status !== 'pending' && entry . stage_b_status !== 'done' && (
< span style = { { color : entry.stage_b_status === 'failed' ? '#ef4444' : '#f59e0b' , marginLeft : 8 } } >
· B阶段状态 : { entry . stage_b_status === 'failed' ? '验证失败' : entry . stage_b_status === 'skipped' ? '已跳过' : entry . stage_b_status }
< / span >
) }
< / div >
) }
{ entry . attempt > 1 && < div className = "retake-badge" > 第 { entry . attempt } 次 提 交 < / div > }
< / div >
{ ( overview || overall ) && (
< div className = "overview-section" style = { { paddingLeft : 12 } } >
< h4 style = { { marginBottom : 8 } } > 整 体 评 价 < / h4 >
{ overview && (
< div style = { { marginBottom : 8 } } >
< div style = { { color : 'var(--primary, #6aa1f7)' , fontWeight : 600 , marginBottom : 4 } } > 项 目 总 览 < / div >
< div style = { { fontSize : 13 , color : 'var(--text-secondary)' , marginLeft : 12 , marginBottom : 4 , lineHeight : 1.7 , whiteSpace : 'pre-wrap' } } > { overview } < / div >
< / div >
) }
{ overall && Array . isArray ( overall . highlights ) && overall . highlights . length > 0 && (
< div style = { { marginBottom : 8 } } >
< div style = { { color : 'var(--success, #2e7d32)' , fontWeight : 600 , marginBottom : 4 } } > 核 心 亮 点 点 评 < / div >
{ overall . highlights . map ( ( h : any , i : number ) = > (
< div key = { i } style = { { fontSize : 13 , color : 'var(--text-secondary)' , marginLeft : 12 , marginBottom : 4 } } >
< span style = { { color : 'var(--text-primary)' , fontWeight : 500 } } > { h . point } < / span >
{ h . review ? < span style = { { opacity : 0.85 } } > — — { h . review } < / span > : null }
< / div >
) ) }
< / div >
) }
{ overall && Array . isArray ( overall . weaknesses ) && overall . weaknesses . length > 0 && (
< div style = { { marginBottom : 8 } } >
< div style = { { color : 'var(--danger, #c62828)' , fontWeight : 600 , marginBottom : 4 } } > 主 要 不 足 点 评 < / div >
{ overall . weaknesses . map ( ( w : any , i : number ) = > (
< div key = { i } style = { { fontSize : 13 , color : 'var(--text-secondary)' , marginLeft : 12 , marginBottom : 4 } } >
< span style = { { color : 'var(--text-primary)' , fontWeight : 500 } } > { w . point } < / span >
{ w . review ? < span style = { { opacity : 0.85 } } > — — { w . review } < / span > : null }
< / div >
) ) }
< / div >
) }
{ overall && overall . verdict && < p style = { { lineHeight : 1.7 , color : 'var(--text-secondary)' , fontSize : 14 , whiteSpace : 'pre-wrap' } } > { overall . verdict } < / p > }
< / div >
) }
{ entry . progress_log && ( ( ) = > {
try {
const logs = JSON . parse ( entry . progress_log ) ;
if ( ! Array . isArray ( logs ) || logs . length === 0 ) return null ;
return (
< details className = "history-section" style = { { marginBottom : 16 } } >
< summary > 评 审 进 度 ( { logs . length } 步 ) < / summary >
{ logs . map ( ( l : any , i : number ) = > (
< div key = { i } className = "history-item" >
< div style = { { fontSize : 12 , color : '#666' } } >
{ l . time ? new Date ( l . time ) . toLocaleString ( ) : '' } { l . status ? ` · ${ l . status } ` : '' }
< / div >
{ l . msg && < div style = { { fontSize : 12 } } > { l . msg } < / div > }
< / div >
) ) }
< / details >
) ;
} catch { return null ; }
} ) ( ) }
{ /* deliverables checklist */ }
< div style = { { marginBottom : 16 , padding : 16 , background : 'var(--bg-subtle)' , borderRadius : 'var(--radius)' , border : '1px solid var(--border)' } } >
< h4 style = { { fontSize : 14 , fontWeight : 600 , marginBottom : 8 } } > 成 果 物 确 认 < / h4 >
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 6 } } >
{ ( deliverables . length > 0 ? deliverables : DEFAULT_DELIVERABLES ) . map ( ( d : any , i : number ) = > (
< label key = { i } style = { { display : 'flex' , alignItems : 'center' , gap : 8 , cursor : 'pointer' , fontSize : 13 } } >
< input type = "checkbox" checked = { d . submitted } onChange = { ( ) = > toggleDeliverable ( i ) } / >
< span style = { { textDecoration : d.submitted ? 'line-through' : 'none' , color : d.submitted ? 'var(--success)' : 'var(--text)' } } >
{ d . name }
< / span >
{ d . required && < span style = { { color : 'var(--danger)' , fontSize : 11 } } > ( 必 须 ) < / span > }
{ ! d . required && < span style = { { color : 'var(--muted)' , fontSize : 11 } } > ( 可 选 ) < / span > }
< / label >
) ) }
< / div >
< div style = { { marginTop : 8 , fontSize : 12 , color : 'var(--text-secondary)' } } >
已 提 交 { deliverables . filter ( ( d : any ) = > d . submitted ) . length } / { deliverables . length || DEFAULT_DELIVERABLES . length }
{ deliverables . filter ( ( d : any ) = > d . required && ! d . submitted ) . length > 0 && (
< span style = { { color : 'var(--danger)' , marginLeft : 8 } } >
缺 少 { deliverables . filter ( ( d : any ) = > d . required && ! d . submitted ) . length } 项 必 须 成 果 物
< / span >
) }
< / div >
< / div >
{ dims . length > 0 && < RadarChart dims = { dims } size = { 360 } / > }
{ l2Dims . length > 0 && (
< div style = { { marginBottom : 16 } } >
< h4 style = { { fontSize : 14 , fontWeight : 600 , marginBottom : 8 , color : 'var(--text)' } } > L2共通评分 ( { l2Score } / { l2Max } ) < / h4 >
< div style = { { overflowX : 'auto' } } >
< table className = "detail-dims" >
< thead > < tr > < th > 评 审 项 < / th > < th style = { { textAlign : 'center' } } > 得 分 < / th > < th > 评 语 < / th > < th > 建 议 < / th > < / tr > < / thead >
< tbody >
{ l2Dims . map ( ( d , i ) = > {
const idx = dims . indexOf ( d ) ;
return (
< tr key = { i } >
< td >
< div className = "dim-name" > { d . name } < / div >
{ dimHistory ( d . name ) && < div style = { { fontSize : 11 , color : 'var(--text-secondary)' , opacity : 0.75 } } > { dimHistory ( d . name ) } < / div > }
< DimBar score = { Number ( d . score ) || 0 } max = { d . maxScore } / >
< / td >
< td style = { { textAlign : 'center' , verticalAlign : 'middle' } } >
< input type = "number" value = { Number ( d . score ) || 0 } min = { 0 } max = { d . maxScore } onChange = { e = > updateDim ( idx , 'score' , e . target . value === '' ? 0 : Number ( e . target . value ) ) } className = "score-input" / >
< span className = "score-max" > 满 分 { d . maxScore } < / span >
< / td >
< td >
{ editingComment === idx ? (
< textarea value = { d . comment || '' } onChange = { e = > updateDim ( idx , 'comment' , e . target . value ) } onBlur = { ( ) = > setEditingComment ( null ) } autoFocus rows = { 3 } className = "comment-input" placeholder = "评语" / >
) : (
< span className = { ` dim-comment-btn ${ d . comment ? '' : 'is-empty' } ` } onClick = { ( ) = > setEditingComment ( idx ) } title = "点击编辑评语" > { d . comment || '点击填写评语' } < / span >
) }
{ d . verifiability ? . note && < div style = { { fontSize : 11 , color : 'var(--text-secondary)' , opacity : 0.8 , marginTop : 4 } } > { d . verifiability . note } < / div > }
< / td >
< td > < div className = "suggestion-cell" > { d . suggestion || '-' } < / div > < / td >
< / tr >
) ;
} ) }
< / tbody >
< / table >
< / div >
< / div >
) }
{ l3Dims . length > 0 && (
< div style = { { marginBottom : 16 } } >
< h4 style = { { fontSize : 14 , fontWeight : 600 , marginBottom : 8 , color : '#8b5cf6' } } > L3追加评分 ( { l3Score } / { l3Max } ) < / h4 >
< div style = { { overflowX : 'auto' } } >
< table className = "detail-dims" >
< thead > < tr > < th > 评 审 项 < / th > < th style = { { textAlign : 'center' } } > 得 分 < / th > < th > 评 语 < / th > < th > 建 议 < / th > < / tr > < / thead >
< tbody >
{ l3Dims . map ( ( d , i ) = > {
const idx = dims . indexOf ( d ) ;
return (
< tr key = { i } >
< td >
< div className = "dim-name" style = { { color : '#8b5cf6' } } > { d . name } < / div >
{ dimHistory ( d . name ) && < div style = { { fontSize : 11 , color : 'var(--text-secondary)' , opacity : 0.75 } } > { dimHistory ( d . name ) } < / div > }
< DimBar score = { Number ( d . score ) || 0 } max = { d . maxScore } / >
< / td >
< td style = { { textAlign : 'center' , verticalAlign : 'middle' } } >
< input type = "number" value = { Number ( d . score ) || 0 } min = { 0 } max = { d . maxScore } onChange = { e = > updateDim ( idx , 'score' , e . target . value === '' ? 0 : Number ( e . target . value ) ) } className = "score-input" / >
< span className = "score-max" > 满 分 { d . maxScore } < / span >
< / td >
< td >
{ editingComment === idx ? (
< textarea value = { d . comment || '' } onChange = { e = > updateDim ( idx , 'comment' , e . target . value ) } onBlur = { ( ) = > setEditingComment ( null ) } autoFocus rows = { 3 } className = "comment-input" placeholder = "评语" / >
) : (
< span className = { ` dim-comment-btn ${ d . comment ? '' : 'is-empty' } ` } onClick = { ( ) = > setEditingComment ( idx ) } title = "点击编辑评语" > { d . comment || '点击填写评语' } < / span >
) }
{ d . verifiability ? . note && < div style = { { fontSize : 11 , color : 'var(--text-secondary)' , opacity : 0.8 , marginTop : 4 } } > { d . verifiability . note } < / div > }
< / td >
< td > < div className = "suggestion-cell" > { d . suggestion || '-' } < / div > < / td >
< / tr >
) ;
} ) }
< / tbody >
< / table >
< / div >
< / div >
) }
{ entry . snapshots ? . length > 0 && (
< details className = "history-section" >
< summary > 评 审 快 照 ( { entry . snapshots . length } 次 ) < / summary >
{ entry . snapshots . map ( ( s : any ) = > (
< div key = { s . id } className = "history-item" >
第 { s . attempt } 次 · { new Date ( s . created_at ) . toLocaleString ( ) }
< / div >
) ) }
< / details >
) }
{ entry . revisions ? . length > 0 && (
< details className = "history-section" >
< summary > 修 正 历 史 ( { entry . revisions . length } 次 ) < / summary >
{ entry . revisions . map ( ( r : any ) = > {
let oldScores = '' , newScores = '' ;
try { oldScores = JSON . parse ( r . comments || '[]' ) . map ( ( x : any ) = > ` ${ x . name } : ${ x . score } ` ) . join ( '; ' ) ; } catch { }
try { newScores = JSON . parse ( r . scores || '[]' ) . map ( ( x : any ) = > ` ${ x . name } : ${ x . score } ` ) . join ( '; ' ) ; } catch { }
return (
< div key = { r . id } className = "history-item" >
< div style = { { fontSize : 12 , color : '#666' } } > { new Date ( r . created_at ) . toLocaleString ( ) } < / div >
{ oldScores && < div style = { { fontSize : 12 } } > 修 正 前 : { oldScores } < / div > }
{ newScores && < div style = { { fontSize : 12 } } > 修 正 后 : { newScores } < / div > }
< / div >
) ;
} ) }
< / details >
) }
< div className = "detail-actions" >
< button onClick = { ( ) = > downloadPdf ( ` /api/projects/ ${ projectId } /entries/ ${ entry . id } /report/export ` ) . catch ( ( ) = > { } ) } className = "btn-secondary" title = "下载PDF报告" > 下 载 报 告 < / button >
< button onClick = { saveReport } disabled = { saving } className = "btn-primary" > { saving ? '保存中...' : '保存修正' } < / button >
< / div >
< / div >
< / div >
) ;
}
function DimBar ( { score , max } : { score : number ; max : number } ) {
const pct = max > 0 ? Math . max ( 0 , Math . min ( 100 , ( Number ( score ) / max ) * 100 ) ) : 0 ;
const color = pct >= 80 ? '#10b981' : pct >= 60 ? '#6366f1' : pct >= 40 ? '#f59e0b' : '#ef4444' ;
return (
< div className = "dim-bar-row" >
< div className = "dim-bar" >
< div className = "dim-bar-fill" style = { { width : ` ${ pct } % ` , background : color } } / >
< / div >
< span className = "dim-pct" > { Math . round ( pct ) } % < / span >
< / div >
) ;
}
function RadarChart ( { dims , size = 260 } : { dims : any [ ] ; size? : number } ) {
const n = dims . length ;
if ( n === 0 ) return null ;
const cx = size / 2 , cy = size / 2 , r = size * 0.38 ;
const labelR = r + size * 0.07 ;
const fontSize = size <= 280 ? 10 : 11 ;
const angle = ( i : number ) = > ( 2 * Math . PI * i ) / n - Math . PI / 2 ;
const pt = ( i : number , radius : number ) = > {
const a = angle ( i ) ;
return { x : cx + radius * Math . cos ( a ) , y : cy + radius * Math . sin ( a ) } ;
} ;
const scorePts = dims . map ( ( d , i ) = > {
const pct = Math . max ( 0 , Math . min ( 1 , d . maxScore > 0 ? Number ( d . score ) / d.maxScore : 0 ) ) ;
const p = pt ( i , r * pct ) ;
return ` ${ i === 0 ? 'M' : 'L' } ${ p . x } ${ p . y } ` ;
} ) . join ( ' ' ) + ' Z' ;
return (
< div style = { { textAlign : 'center' , margin : '16px 0' } } >
< h4 style = { { marginBottom : 8 , color : '#666' , fontSize : 13 } } > 维 度 评 分 < / h4 >
< svg width = { size + 20 } height = { size + 20 } viewBox = { ` 0 0 ${ size } ${ size } ` } style = { { maxWidth : '100%' } } >
< g >
{ [ 25 , 50 , 75 , 100 ] . map ( pct = > {
const rr = r * pct / 100 ;
const pts = Array . from ( { length : n } , ( _ , i ) = > pt ( i , rr ) ) . map ( p = > ` ${ p . x } , ${ p . y } ` ) . join ( ' ' ) ;
return < g key = { pct } >
< polygon points = { pts } fill = "none" stroke = "#e5e7eb" strokeWidth = "1" strokeDasharray = "3,3" / >
< text x = { cx } y = { cy - rr } fontSize = "9" fill = "#999" textAnchor = "middle" dominantBaseline = "middle" > { pct } % < / text >
< / g > ;
} ) }
{ Array . from ( { length : n } , ( _ , i ) = > {
const p = pt ( i , r ) ;
return < line key = { ` ax- ${ i } ` } x1 = { cx } y1 = { cy } x2 = { p . x } y2 = { p . y } stroke = "#e5e7eb" strokeWidth = "1" / > ;
} ) }
{ dims . map ( ( d , i ) = > {
const pct = Math . max ( 0 , Math . min ( 1 , d . maxScore > 0 ? Number ( d . score ) / d.maxScore : 0 ) ) ;
const p = pt ( i , r * pct ) ;
return < circle key = { ` dot- ${ i } ` } cx = { p . x } cy = { p . y } r = "3" fill = "#4f46e5" / > ;
} ) }
{ dims . map ( ( d , i ) = > {
const p = pt ( i , labelR ) ;
const anchor = p . x > cx + 5 ? 'start' : p . x < cx - 5 ? 'end' : 'middle' ;
return < text key = { ` lb- ${ i } ` } x = { p . x } y = { p . y } fontSize = { fontSize } fill = "#333" textAnchor = { anchor } dominantBaseline = "middle" > { d . name } < / text > ;
} ) }
< / g >
< path d = { scorePts } fill = "rgba(79,70,229,0.15)" stroke = "#4f46e5" strokeWidth = "2" / >
< / svg >
< / div >
) ;
}
function BarChart ( { entries , title } : { entries : any [ ] ; title? : string } ) {
if ( entries . length === 0 ) return null ;
const barH = 28 , gap = 8 , labelW = 160 , chartW = 400 ;
const h = entries . length * ( barH + gap ) + 30 ;
const maxScore = Math . max ( . . . entries . map ( e = > e . score || 0 ) ) ;
const bars = entries . map ( ( e , i ) = > {
const y = 30 + i * ( barH + gap ) ;
const w = maxScore > 0 ? ( ( e . score || 0 ) / maxScore ) * chartW : 0 ;
const color = e . passed ? '#10b981' : '#ef4444' ;
return (
< g key = { e . id } >
< text x = { labelW - 6 } y = { y + barH / 2 } fontSize = "11" fill = "#333" textAnchor = "end" dominantBaseline = "middle" > { e . title } < / text >
< rect x = { labelW } y = { y } width = { Math . max ( w , 2 ) } height = { barH } rx = { 4 } fill = { color } opacity = { 0.85 } / >
< text x = { labelW + w + 4 } y = { y + barH / 2 } fontSize = "11" fill = { color } dominantBaseline = "middle" > { e . score } 分 < / text >
< / g >
) ;
} ) ;
return (
< div style = { { textAlign : 'center' , margin : '20px 0' , overflowX : 'auto' } } >
{ title && < h4 style = { { marginBottom : 8 , color : '#666' , fontSize : 13 } } > { title } < / h4 > }
< svg width = { Math . min ( labelW + chartW + 60 , 800 ) } height = { h } viewBox = { ` 0 0 ${ Math . min ( labelW + chartW + 60 , 800 ) } ${ h } ` } style = { { maxWidth : '100%' } } >
< text x = { labelW } y = "16" fontSize = "12" fill = "#999" > 分 数 分 布 < / text >
{ bars }
< / svg >
< / div >
) ;
}
const DEFAULT_DELIVERABLES = [
{ name : '源代码' , required : true , submitted : false } ,
{ name : 'README' , required : true , submitted : false } ,
{ name : '设计文档' , required : true , submitted : false } ,
{ name : '测试用例与测试结果' , required : true , submitted : false } ,
{ name : 'AGENTS.md' , required : true , submitted : false } ,
{ name : '样本数据' , required : true , submitted : false } ,
{ name : '演示录屏' , required : false , submitted : false } ,
] ;
function DeliverablesView ( { projectId } : { projectId : string } ) {
const [ entries , setEntries ] = useState < any [ ] > ( [ ] ) ;
const [ deliverableMap , setDeliverableMap ] = useState < Record < string , any [ ] > > ( { } ) ;
const [ loading , setLoading ] = useState ( false ) ;
const loadAll = async ( ) = > {
setLoading ( true ) ;
try {
const r = await api . request < { items : any [ ] } > ( 'GET' , ` /projects/ ${ projectId } /entries?limit=250 ` ) ;
const items = r . items || [ ] ;
setEntries ( items ) ;
const map : Record < string , any [ ] > = { } ;
for ( const e of items ) {
let d : any [ ] = [ ] ;
try { d = JSON . parse ( e . deliverables || '[]' ) ; } catch { }
if ( d . length === 0 ) {
d = DEFAULT_DELIVERABLES . map ( x = > ( { . . . x , submitted : false } ) ) ;
}
map [ e . id ] = d ;
}
setDeliverableMap ( map ) ;
} catch { }
setLoading ( false ) ;
} ;
useEffect ( ( ) = > { loadAll ( ) ; } , [ projectId ] ) ;
// Initialize all entries with default deliverables
const initAll = async ( ) = > {
try {
const r = await fetch ( ` /api/projects/ ${ projectId } /entries/deliverables/init ` , {
method : 'PUT' ,
credentials : 'include'
} ) ;
const result = await r . json ( ) ;
console . log ( 'Initialized:' , result . initialized ) ;
} catch { }
await loadAll ( ) ;
} ;
// Toggle a single deliverable
const toggle = async ( entryId : string , idx : number ) = > {
const d = [ . . . ( deliverableMap [ entryId ] || [ ] ) ] ;
d [ idx ] = { . . . d [ idx ] , submitted : ! d [ idx ] . submitted } ;
setDeliverableMap ( { . . . deliverableMap , [ entryId ] : d } ) ;
try {
await fetch ( ` /api/projects/ ${ projectId } /entries/ ${ entryId } /deliverables ` , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' } ,
credentials : 'include' ,
body : JSON.stringify ( { deliverables : d } )
} ) ;
} catch { }
} ;
// Calculate summary
const colNames = DEFAULT_DELIVERABLES . map ( d = > d . name ) ;
const summary = DEFAULT_DELIVERABLES . map ( d = > {
let submitted = 0 , total = 0 ;
for ( const eid of Object . keys ( deliverableMap ) ) {
const items = deliverableMap [ eid ] || [ ] ;
const found = items . find ( ( x : any ) = > x . name === d . name ) ;
if ( found ) { total ++ ; if ( found . submitted ) submitted ++ ; }
}
return { . . . d , submitted , total } ;
} ) ;
const totalRequired = summary . filter ( s = > s . required ) . reduce ( ( s , x ) = > s + x . total , 0 ) ;
const totalSubmitted = summary . filter ( s = > s . required ) . reduce ( ( s , x ) = > s + x . submitted , 0 ) ;
const rate = totalRequired > 0 ? Math . round ( ( totalSubmitted / totalRequired ) * 100 ) : 0 ;
if ( loading && entries . length === 0 ) return < div className = "loading" > 加 载 中 . . . < / div > ;
return (
< div >
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , marginBottom : 16 } } >
< div >
< h3 style = { { margin : 0 } } > 成 果 物 确 认 < / h3 >
< div style = { { fontSize : 13 , color : 'var(--text-secondary)' , marginTop : 4 } } >
{ entries . length } 个 条 目 · 提 交 率 { rate } % ( { totalSubmitted } / { totalRequired } )
< / div >
< / div >
< div style = { { display : 'flex' , gap : 8 } } >
< button onClick = { initAll } className = "btn-secondary" > 初 始 化 一 覧 < / button >
< button onClick = { ( ) = > downloadPdf ( ` /api/projects/ ${ projectId } /entries/deliverables/export ` ) . catch ( ( ) = > { } ) } className = "btn-secondary" > 下 载 CSV < / button >
< / div >
< / div >
{ /* Summary cards */ }
< div style = { { display : 'flex' , gap : 8 , flexWrap : 'wrap' , marginBottom : 16 } } >
{ summary . map ( ( s : any ) = > (
< div key = { s . name } style = { { padding : '8px 14px' , background : 'var(--card)' , borderRadius : 'var(--radius)' , border : '1px solid var(--border)' , fontSize : 13 } } >
< div style = { { fontWeight : 600 , fontSize : 12 } } > { s . name } < / div >
< div style = { { color : s.submitted === s . total ? 'var(--success)' : 'var(--danger)' , fontSize : 15 , fontWeight : 700 } } >
{ s . submitted } / { s . total }
< / div >
< div style = { { fontSize : 11 , color : 'var(--muted)' } } > { s . required ? '必须' : '可选' } < / div >
< / div >
) ) }
< / div >
{ /* Checklist table */ }
< div className = "deliverables-scroll" style = { { overflowX : 'auto' , overflowY : 'auto' , maxHeight : 'calc(100vh - 430px)' } } >
< table className = "entry-table" style = { { minWidth : 1000 } } >
< thead >
< tr >
< th style = { { position : 'sticky' , left : 0 , top : 0 , background : 'var(--bg-subtle)' , zIndex : 2 , width : 130 } } > 参 赛 者 < / th >
< th style = { { position : 'sticky' , left : 130 , top : 0 , background : 'var(--bg-subtle)' , zIndex : 2 , width : 200 } } > 标 题 < / th >
{ colNames . map ( ( name , ci ) = > (
< th key = { ci } style = { { position : 'sticky' , top : 0 , fontSize : 11 , textAlign : 'center' , minWidth : 80 } } >
{ name . replace ( '与测试结果' , '' ) }
< div style = { { fontSize : 10 , color : 'var(--muted)' , fontWeight : 400 } } >
{ summary [ ci ] ? . submitted } / { summary [ ci ] ? . total }
< / div >
< / th >
) ) }
< / tr >
< / thead >
< tbody >
{ entries . map ( ( e ) = > {
const d = deliverableMap [ e . id ] || [ ] ;
const hasDetected = d . some ( ( x : any ) = > x . submitted ) ;
return (
< tr key = { e . id } style = { { opacity : d.length === 0 ? 0.5 : 1 } } >
< td style = { { position : 'sticky' , left : 0 , background : 'var(--card)' , zIndex : 1 , fontWeight : 500 , width : 130 } } >
{ e . participant || e . title . substring ( 0 , 8 ) }
< / td >
< td style = { { position : 'sticky' , left : 130 , background : 'var(--card)' , zIndex : 1 , fontSize : 12 , width : 200 , overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' } } >
{ e . title }
{ hasDetected && (
< span style = { { marginLeft : 6 , fontSize : 10 , color : 'var(--success)' , border : '1px solid var(--success)' , borderRadius : 4 , padding : '0 4px' , flexShrink : 0 } } >
已 检 测
< / span >
) }
< / td >
{ colNames . map ( ( name , ci ) = > {
const item = d . find ( ( x : any ) = > x . name === name ) ;
const checked = item ? . submitted || false ;
const isReq = DEFAULT_DELIVERABLES [ ci ] ? . required ;
return (
< td key = { ci } style = { { textAlign : 'center' } } >
< input
type = "checkbox"
checked = { checked }
onChange = { ( ) = > {
const idx = d . findIndex ( ( x : any ) = > x . name === name ) ;
if ( idx >= 0 ) toggle ( e . id , idx ) ;
} }
style = { { cursor : 'pointer' , width : 16 , height : 16 , accentColor : isReq ? 'var(--success)' : undefined } }
/ >
< / td >
) ;
} ) }
< / tr >
) ;
} ) }
< / tbody >
< / table >
< / div >
{ entries . length === 0 && (
< div className = "empty" style = { { padding : '60px 40px' } } >
< div style = { { fontSize : 32 , marginBottom : 16 } } > 📦 < / div >
< div style = { { fontSize : 16 , fontWeight : 600 , marginBottom : 8 } } > 暂 无 条 目 < / div >
< div style = { { fontSize : 14 , color : 'var(--text-secondary)' , maxWidth : 400 , margin : '0 auto' , lineHeight : 1.7 } } >
请 先 在 「 条 目 」 标 签 页 中 添 加 参 赛 条 目 。 < br / >
添 加 后 返 回 此 页 , 点 击 「 初 始 化 一 覧 」 按 钮 为 所 有 条 目 设 置 成 果 物 清 单 , 然 后 逐 一 确 认 提 交 状 态 。
< / div >
< / div >
) }
{ entries . length > 0 && totalRequired === 0 && (
< div className = "empty" style = { { padding : '40px 40px' , marginTop : 16 } } >
< div style = { { fontSize : 14 , color : 'var(--text-secondary)' , marginBottom : 12 } } >
条 目 已 存 在 , 但 尚 未 初 始 化 成 果 物 清 单 。
< / div >
< button onClick = { initAll } className = "btn-primary" style = { { padding : '8px 24px' } } > 初 始 化 一 覧 < / button >
< / div >
) }
< / div >
) ;
}
function SummaryView ( { projectId } : { projectId : string } ) {
const [ summary , setSummary ] = useState < any > ( null ) ;
useEffect ( ( ) = > { api . request ( 'GET' , ` /projects/ ${ projectId } /summary ` ) . then ( setSummary ) . catch ( ( ) = > { } ) ; } , [ projectId ] ) ;
const isTalent = summary ? . categories ? . some ( ( c : any ) = > /^Q\d$/ . test ( c . category ) ) ;
if ( ! summary ) return < div className = "loading" > 加 载 中 . . . < / div > ;
return (
< div >
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , marginBottom : 16 } } >
< h3 style = { { margin : 0 } } > 汇 总 排 名 { isTalent ? '(人才测评)' : '' } < / h3 >
< button onClick = { ( ) = > downloadPdf ( ` /api/projects/ ${ projectId } /summary/export ` ) . catch ( ( ) = > { } ) } className = "btn-secondary" > 下 载 汇 总 PDF < / button >
< / div >
{ /* Category groups */ }
{ summary . categories ? . map ( ( cat : any ) = > {
const isQ = /^Q\d$/ . test ( cat . category ) ;
const showLevel = isQ || cat . category === '人才测评' ;
return (
< div key = { cat . category } className = "summary-section" >
< h4 >
{ isQ ? (
< > < span className = "badge badge-blue" style = { { marginRight : 6 } } > { cat . category } < / span > { cat . entries . length } 个 条 目 < / >
) : (
cat . category
) }
< / h4 >
< table className = "entry-table" >
< thead > < tr > < th > 排 名 < / th > < th > 标 题 < / th > < th > 参 赛 者 < / th > < th > 得 分 < / th > < th > 及 格 线 < / th > < th > { showLevel ? '认定' : '结果' } < / th > < / tr > < / thead >
< tbody >
{ cat . entries . map ( ( e : any ) = > (
< tr key = { e . id } >
< td className = "rank" > # { e . rank } < / td >
< td > { e . title } < / td >
< td > { e . participant } < / td >
< td style = { { fontWeight : 600 } } > { e . score } { e . aggregate_count > 0 ? ( e . is_formal ? ` (聚合 ${ e . aggregate_count } 次) ` : ` (初评 ${ e . aggregate_count } 次) ` ) : '' } < / td >
< td > { e . pass_line } < / td >
< td >
{ showLevel && e . final_level ? (
< span className = { ` badge ${ e . final_level === 'L3' ? 'badge-purple' : e . final_level === 'L2' ? 'badge-green' : 'badge-red' } ` } >
{ e . final_level }
< / span >
) : (
e . passed ? < span className = "pass" > ✅ 通 过 < / span > : < span className = "fail" > ❌ 未 达 线 < / span >
) }
< / td >
< / tr >
) ) }
< / tbody >
< / table >
{ cat . entries . length > 1 && < BarChart entries = { cat . entries } / > }
< / div >
) ;
} ) }
{ /* Participant summary */ }
{ summary . participants ? . length > 0 && (
< >
< h4 style = { { marginTop : 24 } } > 参 赛 者 合 格 判 定 < / h4 >
< table className = "entry-table" >
< thead > < tr > < th > 参 赛 者 < / th > < th > 题 目 < / th > < th > 得 分 < / th > < th > 及 格 线 < / th > < th > 总 评 < / th > < / tr > < / thead >
< tbody >
{ summary . participants . map ( ( p : any ) = > (
< tr key = { p . participant } >
< td > < strong > { p . participant } < / strong > < / td >
< td > { p . entries . map ( ( e : any ) = > e . title ) . join ( ', ' ) } < / td >
< td > { p . entries . map ( ( e : any ) = > e . score ) . join ( ' / ' ) } < / td >
< td > { p . entries . map ( ( e : any ) = > e . pass_line ) . join ( ' / ' ) } < / td >
< td > { p . passed ? < span className = "pass" > ✅ 通 过 < / span > : < span className = "fail" > ❌ 未 通 过 < / span > } < / td >
< / tr >
) ) }
< / tbody >
< / table >
< / >
) }
< / div >
) ;
}