@@ -0,0 +1,291 @@
< template >
< n-modal
:show = "show"
@ update : show = "$emit('update:show', $event)"
preset = "card"
title = "提交统计"
style = "max-width: 660px"
:bordered = "false"
>
< div style = "max-height: 75vh; overflow-y: auto" >
< template v-if = "loading" >
< n -flex justify = "center" style = "padding: 40px" >
< n-spin size = "large" / >
< / n-flex >
< / template >
< template v-else-if = "stats" >
< ! - - 班级筛选 - - >
< n-flex align = "center" style = "margin-bottom: 16px; flex-wrap: wrap; gap: 6px" >
< span style = "color: #666; font-size: 12px" > 班级筛选 : < / span >
< n-button
size = "small"
: type = "selectedClass === null ? 'primary' : 'default'"
@click ="selectClass(null)"
> 全部 < / n-button >
< n-button
v-for = "c in stats.classes"
:key = "c"
size = "small"
: type = "selectedClass === c ? 'primary' : 'default'"
@click ="selectClass(c)"
> { { c } } < / n-button >
< / n-flex >
<!-- 关键指标 -- >
< div style = "display: grid; grid-template-columns: repeat(5, 1fr); border: 1px solid #eee; border-radius: 6px; overflow: hidden; margin-bottom: 12px" >
< div
v-for = "(metric, i) in metrics"
:key = "metric.label"
: style = "{
padding: '12px 8px',
textAlign: 'center',
borderRight: i < metrics.length - 1 ? '1px solid #eee' : 'none',
}"
>
< div : style = "{ fontSize: '20px', fontWeight: '700', color: metric.color }" > { { metric . value } } < / div >
< div style = "color: #888; font-size: 11px; margin-top: 2px" > { { metric . label } } < / div >
< / div >
< / div >
<!-- 未提交名单 ( 可折叠 ) -- >
< div style = "border: 1px solid #eee; border-radius: 6px; margin-bottom: 8px; overflow: hidden" >
< div
style = "padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; cursor: pointer; background: #fff8f8"
@click ="showUnsubmitted = !showUnsubmitted"
>
< n-flex align = "center" :size = "6" >
< span style = "width: 7px; height: 7px; background: #d03050; border-radius: 50%; display: inline-block" > < / span >
< span style = "font-weight: 600; color: #d03050; font-size: 12px" > 未提交 ( { { stats . unsubmitted _count } } 人 ) < / span >
< / n-flex >
< Icon : icon = "showUnsubmitted ? 'lucide:chevron-down' : 'lucide:chevron-right'" :width = "14" style = "color: #aaa" / >
< / div >
< div v-if = "showUnsubmitted" style="padding: 10px 14px; background: #fff8f8; display: flex; flex-wrap: wrap; gap: 5px" >
< n -tag
v-for = "u in stats.unsubmitted_users"
:key = "u.username"
size = "small"
:bordered = "true"
style = "border-color: #ffd0d0; background: #fff"
> { { u . username } } < span style = "color: #bbb; margin-left: 4px" > { { u . classname } } < / span > < / n-tag >
< span v-if = "!stats.unsubmitted_users.length" style="color: #aaa; font-size: 12px" > 暂无 < / span >
< / div >
< / div >
< ! - - 未打分名单 ( 可折叠 ) - - >
< div style = "border: 1px solid #eee; border-radius: 6px; margin-bottom: 12px; overflow: hidden" >
< div
style = "padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; cursor: pointer; background: #fff8f8"
@click ="showUnrated = !showUnrated"
>
< n-flex align = "center" :size = "6" >
< span style = "width: 7px; height: 7px; background: #d03050; border-radius: 50%; display: inline-block" > < / span >
< span style = "font-weight: 600; color: #d03050; font-size: 12px" > 未打分 ( { { stats . unrated _count } } 人 ) < / span >
< span style = "font-size: 11px; color: #aaa" > — 还没给任何提交打过分 < / span >
< / n-flex >
< Icon : icon = "showUnrated ? 'lucide:chevron-down' : 'lucide:chevron-right'" :width = "14" style = "color: #aaa" / >
< / div >
< div v-if = "showUnrated" style="padding: 10px 14px; background: #fff8f8; display: flex; flex-wrap: wrap; gap: 5px" >
< n -tag
v-for = "u in stats.unrated_users"
:key = "u.username"
size = "small"
:bordered = "true"
style = "border-color: #ffd0d0; background: #fff"
> { { u . username } } < span style = "color: #bbb; margin-left: 4px" > { { u . classname } } < / span > < / n-tag >
< span v-if = "!stats.unrated_users.length" style="color: #aaa; font-size: 12px" > 暂无 < / span >
< / div >
< / div >
< ! - - 提交次数分布 - - >
< div style = "margin-bottom: 12px" >
< div style = "font-weight: 600; font-size: 13px; margin-bottom: 8px; color: #333" >
提交次数分布
< span style = "font-size: 11px; color: #aaa; font-weight: 400" > ( 已提交的 { { stats . submitted _count } } 人 ) < / span >
< / div >
< div style = "display: flex; gap: 8px" >
< div
v-for = "bucket in countBuckets"
:key = "bucket.label"
: style = "{ flex: 1, border: `1px solid ${bucket.borderColor}`, borderRadius: '6px', padding: '10px 8px', textAlign: 'center', background: bucket.bg }"
>
< div : style = "{ fontSize: '20px', fontWeight: '700', color: bucket.color }" > { { bucket . value } } < / div >
< div style = "color: #aaa; font-size: 11px; margin-top: 2px" > { { bucket . label } } < / div >
< div style = "margin-top: 8px; background: #e8e8e8; border-radius: 3px; height: 4px" >
< div : style = "{ background: bucket.color, height: '4px', borderRadius: '3px', width: bucketPct(bucket.value) }" > < / div >
< / div >
< div style = "color: #bbb; font-size: 10px; margin-top: 3px" > { { bucketPct ( bucket . value ) } } < / div >
< / div >
< / div >
< / div >
<!-- 分数分布 -- >
< div style = "margin-bottom: 12px" >
< div style = "font-weight: 600; font-size: 13px; margin-bottom: 8px; color: #333" >
分数分布
< span style = "font-size: 11px; color: #aaa; font-weight: 400" > ( 1 – 5 星 , 已打分提交 ) < / span >
< / div >
< div style = "display: flex; align-items: flex-end; gap: 6px; height: 80px" >
< div
v-for = "bar in scoreBars"
:key = "bar.label"
style = "flex: 1; display: flex; flex-direction: column; align-items: center"
>
< div style = "color: #aaa; font-size: 11px; margin-bottom: 3px" > { { bar . value } } < / div >
< div : style = "{ background: bar.color, width: '100%', height: bar.height, borderRadius: '3px 3px 0 0', minHeight: '3px' }" > < / div >
< div style = "color: #aaa; font-size: 11px; margin-top: 4px" > { { bar . label } } < / div >
< / div >
< / div >
< / div >
<!-- 人气提交 Top 5 -- >
< div style = "margin-bottom: 12px" >
< div style = "font-weight: 600; font-size: 13px; margin-bottom: 8px; color: #333" >
人气提交 Top 5
< span style = "font-size: 11px; color: #aaa; font-weight: 400" > ( 按打分人数 ) < / span >
< / div >
< div style = "display: flex; flex-direction: column; gap: 5px" >
< div
v-for = "(sub, i) in stats.top_submissions"
:key = "sub.submission_id"
style = "display: flex; align-items: center; gap: 10px; padding: 6px 10px; background: #f8f8f8; border-radius: 6px; cursor: pointer"
@click ="viewSubmission(sub.submission_id)"
>
< div
: style = "{ width: '20px', height: '20px', background: rankColor(i), borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: '700', fontSize: '11px', flexShrink: 0 }"
> { { i + 1 } } < / div >
< div style = "flex: 1" >
< div style = "font-weight: 500; font-size: 13px" > { { sub . username } } < / div >
< div style = "color: #aaa; font-size: 11px" > { { sub . score . toFixed ( 1 ) } } 分 · { { sub . rating _count } } 人打分 · { { sub . classname } } < / div >
< / div >
< div style = "color: #2080f0; font-size: 12px" > 查看 → < / div >
< / div >
< span v-if = "!stats.top_submissions.length" style="color: #aaa; font-size: 12px" > 暂无打分记录 < / span >
< / div >
< / div >
< ! - - 标记统计 - - >
< div >
< div style = "font-weight: 600; font-size: 13px; margin-bottom: 8px; color: #333" > 标记统计 < / div >
< n-flex :size = "8" style = "flex-wrap: wrap" >
< div
v-for = "flag in flagBadges"
:key = "flag.label"
: style = "{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: flag.bg, borderRadius: '4px', border: `1px solid ${flag.border}` }"
>
< span : style = "{ width: '7px', height: '7px', background: flag.color, borderRadius: '50%', display: 'inline-block' }" > < / span >
< span : style = "{ color: flag.color, fontSize: '12px' }" > { { flag . label } } < / span >
< span style = "font-weight: 700; font-size: 13px; color: #333" > { { flag . value } } < / span >
< / div >
< / n-flex >
< / div >
< / template >
< / div >
< / n-modal >
< / template >
< script lang = "ts" setup >
import { ref , computed , watch } from "vue"
import { Icon } from "@iconify/vue"
import { useRouter } from "vue-router"
import { Submission } from "../api"
import type { TaskStatsOut } from "../utils/type"
const props = defineProps < { taskId : number ; show : boolean } > ( )
const emit = defineEmits < { ( e : "update:show" , v : boolean ) : void } > ( )
const router = useRouter ( )
const stats = ref < TaskStatsOut | null > ( null )
const loading = ref ( false )
const selectedClass = ref < string | null > ( null )
const showUnsubmitted = ref ( true )
const showUnrated = ref ( true )
async function load ( classname ? : string ) {
loading . value = true
try {
stats . value = await Submission . getStats ( props . taskId , classname )
} finally {
loading . value = false
}
}
function selectClass ( c : string | null ) {
selectedClass . value = c
load ( c ? ? undefined )
}
function viewSubmission ( id : string ) {
const { href } = router . resolve ( { name : "submission" , params : { id } } )
window . open ( href , "_blank" )
}
function rankColor ( i : number ) {
return ( [ "#f0a020" , "#aaa" , "#cd7f32" , "#ddd" , "#ddd" ] as const ) [ i ] ? ? "#ddd"
}
function bucketPct ( value : number ) {
const total = stats . value ? . submitted _count ? ? 0
if ( ! total ) return "0%"
return Math . round ( ( value / total ) * 100 ) + "%"
}
const metrics = computed ( ( ) => {
if ( ! stats . value ) return [ ]
return [
{ label : "已提交" , value : stats . value . submitted _count , color : "#18a058" } ,
{ label : "未提交" , value : stats . value . unsubmitted _count , color : "#d03050" } ,
{ label : "平均分" , value : stats . value . average _score ? . toFixed ( 1 ) ? ? "—" , color : "#2080f0" } ,
{ label : "未打分" , value : stats . value . unrated _count , color : "#d03050" } ,
{ label : "参与排名" , value : stats . value . nominated _count , color : "#f0a020" } ,
]
} )
const countBuckets = computed ( ( ) => {
if ( ! stats . value ) return [ ]
const d = stats . value . submission _count _distribution
return [
{ label : "仅 1 次" , value : d . count _1 , color : "#888" , bg : "#fafafa" , borderColor : "#e0e0e0" } ,
{ label : "2 次" , value : d . count _2 , color : "#2080f0" , bg : "#f0f7ff" , borderColor : "#d0e8ff" } ,
{ label : "3 次" , value : d . count _3 , color : "#18a058" , bg : "#f0fff4" , borderColor : "#c8e8d0" } ,
{ label : "4 次+" , value : d . count _4 _plus , color : "#f0a020" , bg : "#fffbf0" , borderColor : "#ffe0a0" } ,
]
} )
const scoreBars = computed ( ( ) => {
if ( ! stats . value ) return [ ]
const d = stats . value . score _distribution
const vals = [ d . range _1 _2 , d . range _2 _3 , d . range _3 _4 , d . range _4 _5 , d . range _5 ]
const max = Math . max ( ... vals , 1 )
const colors = [ "#d03050" , "#f0a020" , "#2080f0" , "#18a058" , "#18a058" ]
const labels = [ "★" , "★★" , "★★★" , "★★★★" , "★★★★★" ]
return vals . map ( ( v , i ) => ( {
value : v ,
label : labels [ i ] ,
color : colors [ i ] ,
height : Math . max ( Math . round ( ( v / max ) * 68 ) , 3 ) + "px" ,
} ) )
} )
const flagBadges = computed ( ( ) => {
if ( ! stats . value ) return [ ]
const f = stats . value . flag _stats
return [
{ label : "值得展示" , value : f . red , color : "#d03050" , bg : "#fff0f0" , border : "#ffd0d0" } ,
{ label : "需要讲解" , value : f . blue , color : "#2080f0" , bg : "#f0f7ff" , border : "#c8deff" } ,
{ label : "优秀作品" , value : f . green , color : "#18a058" , bg : "#f0fff4" , border : "#b8e8c8" } ,
{ label : "需要改进" , value : f . yellow , color : "#f0a020" , bg : "#fffbf0" , border : "#ffe8a0" } ,
]
} )
// Load when modal opens
watch (
( ) => props . show ,
( val ) => {
if ( val && props . taskId ) {
selectedClass . value = null
load ( )
}
} ,
)
< / script >