@@ -716,7 +716,9 @@
< el -option label = "本科组" value = "benke" / >
< el-option label = "教师组" value = "jiaoshi" / >
< / el-select >
< el-input v-model = "submissionSearch" placeholder="搜索选手姓名" clearable size="small" class="search-input" @input="onSubmissionFilterChange" / >
<!-- R194 : 列表已按小组聚合 ( 一行 = 一个小组 ) , 搜索口径同步改为小组编号 / 目录名 ,
⛔ 不再按选手姓名过滤 — — 姓名已不在列表展示 , 命中也无法解释 。 -- >
< el-input v-model = "submissionSearch" placeholder="搜索小组编号" clearable size="small" class="search-input" @input="onSubmissionFilterChange" / >
<!-- R87 : 提交状态筛选 。 判据与表格内 「 未提交 」 文案同源 ( row . files . length === 0 ) ;
选项文案与 「 成绩管理 · 成绩总览 」 的提交状态下拉逐字对齐 。 -- >
<!-- R178 : value - on - clear = "" 让 「 清除 」 回到与 ref 初值一致的空字符串
@@ -734,19 +736,24 @@
title = "下载该组全部作品(不受上方筛选影响)"
> 批量下载作品 < / el-button >
< span class = "work-summary" v-if = "submissions.length" >
共 {{ submissionStatusCount.total }} 人 · 已提交 {{ submissionStatusCount.submitted }} · 未提交 {{ submissionStatusCount.unsubmitted }}
< template v-if = "submissionStatusFilter === 'true' || submissionStatusFilter === 'false'" > | 当前筛选出 {{ filteredSubmissions.length }} 人 < / template >
共 {{ submissionStatusCount.total }} 组 · 已提交 {{ submissionStatusCount.submitted }} · 未提交 {{ submissionStatusCount.unsubmitted }}
< template v-if = "submissionStatusFilter === 'true' || submissionStatusFilter === 'false'" > | 当前筛选出 {{ filteredSubmissions.length }} 组 < / template >
< / span >
< / div >
< ! - - 🔴 R194 : 作品管理改 「 小组维度 」 ( 用户实测 : 比赛以小组为准 , 原表格按人平铺 ,
同组 6 人 6 行 、 5 行是 「 未提交 」 , 管理人员看不出小组整体是否已交 ) 。
一行 = 一个小组 : 小组编号 / 作品目录 + 提交状态 + 该组文件与评分 。 - - >
< el-table :data = "pagedSubmissions" stripe v-loading = "submissionsLoading" class="admin-table" >
<el -table -column prop = "name" label = "选手姓名" width = "120" />
<!-- R190 : 小组编号 / 作品目录 。
<! - - R190 / R194 : 小组编号 / 作品目录 。
展示值 = 后端落盘用的目录名 ( teamFolder ) , 与 「 批量下载 」 压缩包内的子目录
以及 「 单组压缩包 」 文件名 * * 完全同源 * * , 管理人员据此对得上号 。 - - >
< el-table-column label = "小组编号 / 作品目录" width = "24 0" >
< el-table-column label = "小组编号 / 作品目录" width = "26 0" >
< template # default = "{ row }" >
< div class = "team-cell" >
< span class = "team-no" :title = "row.teamFolder" > { { row . teamFolder } } < / span >
< span v-if = "row.memberCount > 1" class="team-meta" > {{ row.memberCount }} 人参赛 < / span >
< ! - - R194 : 提交人 ( = 组锁口径下的归属人 , 最早提交者 ) ; 未提交则不显示 - - >
< span v-if = "row.submitterName" class="team-meta" > 提交人 : {{ row.submitterName }} < / span >
< el -button
text
type = "primary"
@@ -758,7 +765,14 @@
< / div >
< / template >
< / el-table-column >
< el-table-column label = "上传文件 / 评分" min - width =" 420 ">
< el-table-column label = "提交状态" width ="110" align = "center ">
< template # default = "{ row }" >
< el-tag size = "small" : type = "isWorkSubmitted(row) ? 'success' : 'info'" >
{ { isWorkSubmitted ( row ) ? '已提交' : '未提交' } }
< / el-tag >
< / template >
< / el-table-column >
< el-table-column label = "上传文件 / 评分" min -width = " 460 " >
< template # default = "{ row }" >
< div v-for = "f in row.files" :key="f.id" class="work-file-row" >
< span class = "work-file-name" :title = "f.fileName" > { { f . fileName } } < / span >
@@ -794,6 +808,13 @@
< el-dialog v-model = "previewVisible" title="文件预览" width="80%" top="5vh" @closed="closePreview" >
< div v-if = "previewFile" >
< p class = "preview-file-name" > { { previewFile . fileName } } ( { { previewFile . kind === 'ppt' ? 'PPT' : previewFile . kind === 'answer' ? 'Word答案' : '文档' } } ) < / p >
<!-- R194 : 空白兜底 。 iframe 渲染 PDF 依赖浏览器内置阅读器 ( Chrome / Edge 内置 ,
被策略禁用 / 精简版系统移除时 iframe 会显示纯白且 * * 不报错 * * ) , 故常驻一条说明 +
「 在新标签打开 」 出口 , 避免管理人员误判 「 系统没加载 」 。 -- >
< div v-if = "previewIsPdf && previewBlobUrl" class="preview-bar" >
< el -button text type = "primary" size = "small" v-guard = "() => openPreviewInNewTab()" > 在新标签打开 < / el -button >
< span class = "preview-hint" > 若下方空白 , 通常是浏览器未启用内置 PDF 阅读器 , 可点左侧按钮或下载后查看 < / span >
< / div >
< iframe v-if = "previewIsPdf && previewBlobUrl" :src="previewBlobUrl" class="preview-frame" > < / iframe >
< div v-else >
< el -alert type = "info" :closable = "false" show -icon style = "margin-bottom:12px" >
@@ -994,6 +1015,23 @@
< / el-button >
<!-- R29 : 由 「 导出CSV 」 改为直接导出真正的 Excel ( . xlsx ) -- >
< el-button type = "primary" plain v-guard = "() => exportExcel()" :disabled="!scoreExportGroup" > 导出Excel < / el -button >
<!-- R220 : 成绩排名表 ( 中职 / 高职专属 ) 。
与 「 导出Excel 」 的区别 : 这个是 * * 排名速览 * * — — 一行 = 一个参赛小组 ,
按小组客观题分降序 * * 全量 * * 导出 ( ⛔ 不截断前 80 , 赞助小组也要在表里 ) ,
整行按客观题分区间上底色 ( ≥ 60 绿 / 50 – 59.99 黄 / < 50 红 ) ,
前 80 行用一个 * * 红色外框整体圈住 * * ( 首行画上边 、 末行画下边 , 行间不画横线 ) ,
表尾给三区间小组数统计 ( 全量 + 全量排名前 80 ) 。
🔴 只出 6 列 : 名次 / 小组编号 / 学校 / 参赛选手 / 人数 / 客观题分 ( R220 -C : 不要大题分与总分 ) 。
⛔ 不含身份证号 / 手机号 , 可直接给评审看 。 - - >
< el-button
v-if = "isExamScoreGroup"
type = "primary"
plain
:loading = "scoreRankExporting"
v-guard = "() => exportScoreRank()"
:disabled = "!scoreExportGroup"
title = "按小组客观题分降序导出全量排名表( 整行按客观题分区间上底色, 前80名用红框圈出, 表尾含各区间小组数统计) "
> 导出排名表 < / el-button >
<!-- R176 : 成绩公示表 ( 半成品 ) 。
与 「 导出Excel 」 的区别 : 导出Excel 是 * * 内部工作用 * * 的全字段明细 ;
公示表是 * * 对外正式文书 * * , 按主办方模板只留 7 列 、 按分数降序 、
@@ -1081,6 +1119,17 @@
class = "date-range"
@change ="onScoreFilterChange"
/ >
<!-- R195 : 默认只显示参赛过的人 ( 有作答 / 有大题文件 / 已交卷 ) 。 中职 / 高职按小组参赛 ,
一组报名多人只一人上场 , 其余组员全 0 分占行 ⇒ 勾选后才列出全部报名者 。 -- >
< el-checkbox
v-if = "isExamScoreGroup"
v-model = "scoreIncludeIdle"
size = "small"
class = "idle-toggle"
@change ="onScoreIncludeIdleChange"
>
显示未参赛
< / el-checkbox >
< / div >
<!-- 成绩明细表 -- >
@@ -1204,6 +1253,15 @@
class = "date-range"
@change ="onExamSubsFilterChange"
/ >
<!-- R195 : 默认只列出参赛过的人 ; 勾选后显示全部报名者 ( 含从未进考场的组员 ) 。 -- >
< el-checkbox
v-model = "examSubsIncludeIdle"
size = "small"
class = "idle-toggle"
@change ="onExamSubsIncludeIdleChange"
>
显示未参赛
< / el-checkbox >
< el-button text type = "primary" v-guard = "() => fetchExamSubmissions()" :loading="examSubmissionsLoading" > 刷新 < / el -button >
< / div >
< el-table :data = "pagedExamSubmissions" stripe v-loading = "examSubmissionsLoading" class="admin-table" >
@@ -4011,6 +4069,8 @@ async function downloadBigQuestionTpl() {
// 考生答题记录
const examSubmissions = ref ( [ ] )
// R195: 同「成绩总览」——默认隐藏从未参赛的报名者( includeIdle=1 才看全量)
const examSubsIncludeIdle = ref ( false )
const examSubmissionsLoading = ref ( false )
const examSubmissionsPage = ref ( 1 )
const examSubmissionsPageSize = ref ( 10 )
@@ -4058,7 +4118,10 @@ async function fetchExamSubmissions() {
// R29: 本板块已迁入「成绩管理」⇒ 组别跟随成绩页
examSubmissionsLoading . value = true
try {
const data = await adminApi . getSubmissions ( scoreExportGroup . value )
const data = await adminApi . getSubmissions (
scoreExportGroup . value ,
examSubsIncludeIdle . value ? { includeIdle : 1 } : undefined
)
examSubmissions . value = Array . isArray ( data ) ? data : ( data ? . list || [ ] )
} catch ( e ) {
console . warn ( '获取答题记录失败' , e )
@@ -4070,6 +4133,12 @@ function onExamSubsFilterChange() {
examSubmissionsPage . value = 1
}
// R195: 服务端过滤( includeIdle) , 切换需重新请求
async function onExamSubsIncludeIdleChange ( ) {
examSubmissionsPage . value = 1
await fetchExamSubmissions ( )
}
// R30: 原「考生答题评分弹窗」的 state 与 openExamScoreDialog / saveExamScore 已整体移除。
// 大题评分改由「考生大题作答查证」按小问给分( adminApi.setBigAnswerScores
// → service.bigQuestion.setSubScores → service.admin.updateScores) ,
@@ -4182,8 +4251,10 @@ const submissionStatusCount = computed(() => {
const filteredSubmissions = computed ( ( ) => {
let list = submissions . value
if ( submissionSearch . value ) {
// R194: 行已按小组聚合 ⇒ 搜索口径改为「小组编号 / 作品目录」(姓名不再展示)
const s = submissionSearch . value . trim ( ) . toLowerCase ( )
list = list . filter ( item => ( item . name || '' ) . toLowerCase ( ) . includes ( s ) )
list = list . filter ( item => String ( item . teamFolder || '' ) . toLowerCase ( ) . includes ( s )
|| String ( item . teamNumber || '' ) . toLowerCase ( ) . includes ( s ) )
}
// 🔴 R178 同「成绩总览」:⛔ 不用 `!== ''` 判断是否启用筛选(清空值可能是 undefined) ,
// 只用白名单;否则点「清除」后会静默变成「只看未提交」。
@@ -4199,13 +4270,77 @@ const pagedSubmissions = computed(() => {
return filteredSubmissions . value . slice ( start , start + submissionsPageSize . value )
} )
/**
* 把后端 getWorks()「按人返回」的结果折叠成「按小组一行」( R194) 。
*
* 【业务来源】用户实测:比赛以小组为准,但管理端把同组 6 个人平铺成 6 行,
* 其中 5 行是「未提交」,管理人员无法一眼判断「这个小组到底交没交」。
* 管理侧只关心:小组编号 → 是否已提交 → 文件与评分。
*
* 【分组键】与 R190 组锁 / 落盘口径**保持一致**,不另立一套:
* teamUid( 有队伍号) → aggregationUid( 无队伍号的单人) → contestantId( 兜底,
* 保证任何一行都不会被吞掉——漏行比多行更难被发现)。
* ⚠️ teamUid 仅**组内**唯一且可重复,但本函数只在单一 group 的列表内聚合
* ( submissionGroup 单选),故不存在跨组串号。
*
* @param {Array<Object>} people - getWorks 的选手级数据
* @returns {Array<Object>} 小组级行
*/
function groupSubmissionsByTeam ( people ) {
const buckets = new Map ( )
people . forEach ( p => {
const key = ( p . teamUid !== null && p . teamUid !== undefined )
? ` t: ${ p . teamUid } `
: ( p . aggregationUid !== null && p . aggregationUid !== undefined )
? ` a: ${ p . aggregationUid } `
: ` c: ${ p . contestantId } `
let g = buckets . get ( key )
if ( ! g ) {
g = {
teamKey : key ,
teamUid : p . teamUid ,
teamNumber : p . teamNumber ,
teamFolder : p . teamFolder ,
aggregationUid : p . aggregationUid ,
memberCount : 0 ,
files : [ ] ,
_zipLoading : false ,
_order : p . contestantId ,
}
buckets . set ( key , g )
}
g . memberCount += 1
// contestantId 必须跟着文件走:批量保存评分要按「人」回写 wordScore / pptScore
// _uploaderName 同理:聚合后要靠它回答「这组是谁提交的」
; ( p . files || [ ] ) . forEach ( f => g . files . push ( { ... f , contestantId : p . contestantId , _uploaderName : p . name } ) )
} )
const list = Array . from ( buckets . values ( ) )
list . forEach ( g => {
// 跨成员合并后重排一次:后端按 upload_time DESC, 合并会打乱顺序
g . files . sort ( ( a , b ) => String ( b . uploadedAt || '' ) . localeCompare ( String ( a . uploadedAt || '' ) ) )
// 🔴 提交人 = 归属人,口径与 R190 组锁一致(**最早提交者**)。
// 组锁下正常只有一人能提交,这里取最早者是为了兼容历史越权上传留下的多份文件。
const owners = g . files . filter ( f => f . _uploaderName )
if ( owners . length ) {
const ts = f => ( f . uploadedAt ? String ( f . uploadedAt ) : '9999-12-31 23:59:59' )
owners . sort ( ( a , b ) => ts ( a ) . localeCompare ( ts ( b ) ) )
g . submitterName = owners [ 0 ] . _uploaderName
} else {
g . submitterName = ''
}
} )
list . sort ( ( a , b ) => a . _order - b . _order )
return list
}
async function fetchSubmissions ( ) {
if ( ! submissionGroup . value ) return
submissionsLoading . value = true
try {
const data = await adminApi . getWorks ( submissionGroup . value )
const raw = Array . isArray ( data ) ? data : ( data ? . list || [ ] )
submissions . valu e = raw . map ( s => ( {
const peopl e = raw . map ( s => ( {
contestantId : s . contestantId ,
name : s . name ,
idNumber : s . idNumber ,
@@ -4218,6 +4353,7 @@ async function fetchSubmissions() {
aggregationUid : s . aggregationUid === undefined ? null : s . aggregationUid ,
files : ( s . files || [ ] ) . map ( f => ( { ... f , _score : f . score != null ? Number ( f . score ) : null } ) ) ,
} ) )
submissions . value = groupSubmissionsByTeam ( people )
} catch ( e ) { console . warn ( '获取作品列表失败' , e ) } finally { submissionsLoading . value = false }
}
@@ -4256,7 +4392,14 @@ async function previewWork(row, f) {
if ( previewIsPdf . value ) {
const r = await fetchBlobGuarded ( fileDownloadUrl ( submissionGroup . value , f ) )
if ( r . ok ) {
previewBlobUrl . value = URL . createObjectURL ( r . blob )
// 🔴 R194( 用户实测: 预览 PDF 时浏览器反复下载「无扩展名文件」):
// 下载端点为了让「点下载」必然落盘,统一声明 Content-Type: application/octet-stream
// (见后端 admin.js#downloadSubmissionFile) 。axios 的 blob 会**继承该响应头**,
// 于是 blob: URL 的 MIME 也是 octet-stream ⇒ iframe 无法渲染、Chrome 直接把它当
// 附件下载,弹窗内就是一片空白。
// 修法:预览通道把同一份字节重新包成 application/pdf( 只改本地 Blob 的 type,
// 不碰后端契约,下载按钮行为不变)。字节完整性已由 fetchBlobGuarded 校验过。
previewBlobUrl . value = URL . createObjectURL ( new Blob ( [ r . blob ] , { type : 'application/pdf' } ) )
} else if ( r . ignored ) {
// 同一文件正在预览加载,被去重忽略——静默跳过,不报错
} else {
@@ -4271,6 +4414,16 @@ function closePreview() {
previewRow . value = null
}
/**
* R194: 把当前预览的 blob 交给新标签页打开( iframe 空白时的确定出口)。
* ⚠️ 不要在这里 revoke: 新标签可能仍在加载, revoke 会让它拿到空文档。
*/
function openPreviewInNewTab ( ) {
if ( ! previewBlobUrl . value ) return
const w = window . open ( previewBlobUrl . value , '_blank' )
if ( ! w ) ElMessage . warning ( '浏览器拦截了新标签,请允许本站弹窗后重试' )
}
// 批量下载作品( ZIP)
async function batchDownloadWorks ( ) {
if ( ! submissionGroup . value ) return
@@ -4312,7 +4465,10 @@ async function batchSaveWorkScores() {
submissions . value . forEach ( s => {
; ( s . files || [ ] ) . forEach ( f => {
if ( f . _score == null || f . _score === '' ) return
const c = ( perContestant [ s . contestantId ] = perContestant [ s . contestantId ] || { } )
// R194: 行已按小组聚合 ⇒ 回写主体不是「行」而是「文件所属选手」,
// contestantId 由 groupSubmissionsByTeam 逐文件注入(⛔ 不要读 s.contestantId)
if ( f . contestantId == null ) return
const c = ( perContestant [ f . contestantId ] = perContestant [ f . contestantId ] || { } )
if ( f . scoreField === 'ppt' ) c . pptScore = Number ( f . _score )
else c . wordScore = Number ( f . _score )
} )
@@ -4536,6 +4692,9 @@ watch(scoreExportGroup, () => {
} )
const scoreList = ref ( [ ] )
// R195: 默认只看「参赛过的人」。中职/高职按小组参赛,一组报名多人只一人上场,
// 未上场组员在成绩表里各占一行 0 分 ⇒ 默认隐藏,需核对全量时打开本开关。
const scoreIncludeIdle = ref ( false )
const scoreListLoading = ref ( false )
const scoreListPage = ref ( 1 )
const scoreListPageSize = ref ( 10 )
@@ -4590,6 +4749,12 @@ function onScoreFilterChange() {
scoreListPage . value = 1
}
// R195: 「显示未参赛」是**服务端过滤**( includeIdle 参数),切换必须重新拉数据,⛔ 不能只在本地过滤。
async function onScoreIncludeIdleChange ( ) {
scoreListPage . value = 1
await fetchScoreList ( )
}
async function fetchPublishStatus ( ) {
if ( ! scoreExportGroup . value ) return
try {
@@ -4604,7 +4769,10 @@ async function fetchScoreList() {
if ( ! scoreExportGroup . value ) return
scoreListLoading . value = true
try {
const data = await adminApi . getScores ( scoreExportGroup . value )
const data = await adminApi . getScores (
scoreExportGroup . value ,
scoreIncludeIdle . value ? { includeIdle : 1 } : undefined
)
scoreList . value = ( Array . isArray ( data ) ? data : ( data ? . list || [ ] ) ) . map ( item => ( {
... item ,
idNumber : item . idNumber ? ? item . id _number ,
@@ -4750,6 +4918,78 @@ async function exportPublicity() {
} finally { publicityExporting . value = false }
}
// ==================== 6.2 成绩排名表( R220) ====================
/**
* 排名表与另外两个导出产物的分工(⛔ 勿合并成同一个按钮):
* · 导出Excel —— **内部核查用**,全字段明细(含身份证号 / 手机号),不排序不裁剪;
* · 下载公示表 —— **对外公示用**,主办方模板 7 列、「获奖等级」列留空;
* · 导出排名表 —— **排名速览用**,一行 = 一个参赛小组、按小组客观题分降序**全量**导出、
* 整行按分数区间上底色、表尾含区间统计,无任何敏感字段。
* 用户明确要求「所有小组都导出」:赞助小组也在表里,⛔ 不截断前 80。
*/
const scoreRankExporting = ref ( false )
/**
* 导出成绩排名表:先预检(?meta=1) 再下载。
*
* 三件事必须在下载前摆到管理员面前,否则下载完才发现要重走一遍:
* ① 有人未归属小组(会各自单独占一行,行数会比预期多);
* ② 本组根本没有可导出的人(导出一张只有表头的空表);
* ③ 整行底色生成失败已降级为无色版(后端会自动降级,但**必须知情**,
* 否则管理员会以为「导出错了」)。
*/
async function exportScoreRank ( ) {
const group = scoreExportGroup . value
if ( ! group ) return
const label = groupMap [ group ] || group
let meta = null
try {
meta = await adminApi . getScoreRankMeta ( group )
} catch ( e ) {
// 预检只是辅助信息,失败不阻断下载;也不再弹提示,避免与下载错误叠加
console . warn ( '成绩排名表预检失败' , e )
}
const warns = [ ]
if ( meta ) {
if ( meta . noTeamCount > 0 ) warns . push ( ` 有 ${ meta . noTeamCount } 名选手未归属任何小组,将各自单独占一行 ` )
if ( ! meta . rowCount ) warns . push ( '本组没有任何可导出的小组,导出的将是一张只有表头的空表' )
if ( meta . colored === false ) {
warns . push ( ` 整行底色生成失败( ${ meta . degradeReason || '未知原因' } ),将导出无底色版本(文件名带「-无底色」) ` )
}
}
if ( warns . length ) {
try {
await ElMessageBox . confirm (
h ( 'div' , null , [
h ( 'p' , { style : 'margin:0 0 8px' } , '导出前发现以下情况,请确认:' ) ,
h ( 'ul' , { style : 'margin:0;padding-left:20px' } , warns . map ( w => h ( 'li' , null , w ) ) ) ,
] ) ,
` 导出「 ${ label } 」成绩排名表 ` ,
{
type : 'warning' ,
confirmButtonText : '仍然导出' ,
cancelButtonText : '取消' ,
distinguishCancelAndClose : true ,
} ,
)
} catch {
return
}
}
scoreRankExporting . value = true
try {
await safeDownload ( adminApi . scoreRankExportUrl ( group ) , {
fallbackName : ` 成绩排名- ${ label } .xlsx ` ,
timeout : 120000 ,
} )
} finally { scoreRankExporting . value = false }
}
// ==================== 7. 新闻管理 ====================
const adminNews = ref ( [ ] )
const adminNewsLoading = ref ( false )
@@ -5548,6 +5788,9 @@ onMounted(async () => {
. score - inline { display : inline - flex ; align - items : center ; gap : 4 px ; color : # 606266 ; font - size : 13 px ; }
. score - input { width : 110 px ; }
. preview - frame { width : 100 % ; height : 70 vh ; border : 0 ; background : # fff ; }
/* R194: 预览操作条( 新标签打开 + 空白兜底说明) */
. preview - bar { display : flex ; align - items : center ; gap : 10 px ; margin - bottom : 6 px ; }
. preview - hint { color : # 909399 ; font - size : 12 px ; line - height : 1.5 ; }
. preview - file - name { color : # 303133 ; font - weight : 600 ; margin - bottom : 8 px ; }
. work - summary { color : # 606266 ; font - size : 13 px ; margin - left : 8 px ; }
@@ -5561,6 +5804,8 @@ onMounted(async () => {
color : # 303133 ;
font - weight : 500 ;
}
/* R194: 小组副信息( 参赛人数) , 只报数量、不列成员姓名 */
. team - meta { color : # 909399 ; font - size : 12 px ; }
/* ==================== 统计卡片 ==================== */
. stats - row { margin - bottom : 20 px ; }