Files
competition-website/src/views/Admin.vue
qubiaobiao 3f8316da47 perf(frontend): RichEditor 异步化拆分 Admin chunk(1021 kB → 232 kB)(BACKLOG C6)
定性:Admin-*.js 达单 chunk 1,021 kB(gzip 340 kB),而其中约 800 kB 是**进入管理端
根本用不到**的富文本编辑器。
- Admin.vue 静态 import RichEditor.vue,后者静态 import @wangeditor/editor
  (dist/index.esm.js = 818,777 B)⇒ 编辑器全量并进 Admin chunk。
- 实测三处使用点(446 大题题干 / 1384 新闻 / 1476 轮播)**全部位于 el-dialog 内**,
  而 el-dialog 初始 rendered=false 不渲染内容 ⇒ 首屏不该付这份成本。
- 全仓反证:RichEditor 只被 Admin.vue 引用;@wangeditor 特征串在入口 chunk 0 命中。

处置:改为 defineAsyncComponent 按需加载(最小改动 16+/2−,只动 Admin.vue)
- src/views/Admin.vue: import RichEditor → defineAsyncComponent({ loader: () => import(...) })
- 加 loadingComponent:用 **render 函数**而非 template 字符串 —— 本工程为 runtime-only
  构建,字符串模板需要运行时编译器,会直接报错。
- vue import 列表补 defineAsyncComponent 与 h。
- 无需 manualChunks:唯一引用点 + 动态 import ⇒ Vite 自动分片。

实测(真 chromium + 静态服务 + /api 反代真实后端):
  Admin-*.js        1,045,614 B → 231,715 B(−77.8%)
  RichEditor-*.js   新增 810,862 B(按需加载;gzip 281,958 B)
  首屏 JS 合计      1,684,778 B → 872,736 B(−48.1%)
  首屏最大单 chunk  1,045,614 B → 283,833 B(−72.9%)

验证:
- 新增 perf-r34/c6-chunk-split-check.mjs:21/21 全绿(首屏不含 wangeditor;打开新闻与
  轮播两个弹窗后编辑器 chunk 按需请求且渲染成功;0 pageerror / 0 Failed to resolve)
- 🔴 判别力(变异测试):对**拆分前**的 dist 跑同一脚本,恰好 5 项判红(A4 首屏含
  wangeditor / A5a 1,684,778 B 超预算 / A5b 1,045,614 B 超 500 kB / B2·B3 无按需请求)
  —— 证明断言真的在守,而非「守卫根本没跑」。
- 回归:perf-r34/dist-admin-check.mjs 9/9(el-table 20 行 / 分页中文 / ElMessageBox
  中文按钮 / 零 Failed to resolve)⇒ 管理端功能零回归。
- TDZ 扫描 37 文件 PASS · vite build exit 0 · .gz 预压缩 70 个(含 RichEditor.js.gz)
2026-09-29 12:49:58 +08:00

4294 lines
168 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="admin-page">
<!-- ==================== 顶部栏 ==================== -->
<div class="admin-top-bar">
<div class="top-left">
<span class="admin-logo">&#9788;</span>
<span class="admin-title">管理后台</span>
</div>
<div class="top-right">
<el-button text @click="$router.push('/')" style="color: rgba(255,255,255,0.85)">返回首页</el-button>
<el-button text v-guard="() => handleLogout()" style="color: rgba(255,255,255,0.85)">退出</el-button>
</div>
</div>
<div class="admin-body">
<!-- ==================== 左侧导航 ==================== -->
<div class="admin-sidebar">
<el-menu
:default-active="activeMenu"
@select="onMenuSelect"
class="sidebar-menu"
>
<el-menu-item index="dashboard">
<el-icon><DataAnalysis /></el-icon>
<span>数据总览</span>
</el-menu-item>
<el-menu-item index="accounts">
<el-icon><User /></el-icon>
<span>选手管理</span>
</el-menu-item>
<el-menu-item index="questions">
<el-icon><EditPen /></el-icon>
<span>试卷管理</span>
</el-menu-item>
<el-menu-item index="materials">
<el-icon><Files /></el-icon>
<span>备赛资料</span>
</el-menu-item>
<el-menu-item index="submissions">
<el-icon><FolderOpened /></el-icon>
<span>作品管理</span>
</el-menu-item>
<el-menu-item index="times">
<el-icon><Clock /></el-icon>
<span>时间设置</span>
</el-menu-item>
<el-menu-item index="scores">
<el-icon><Trophy /></el-icon>
<span>成绩管理</span>
</el-menu-item>
<el-menu-item index="review">
<el-icon><Search /></el-icon>
<span>成绩复核</span>
</el-menu-item>
<el-menu-item index="news">
<el-icon><Notebook /></el-icon>
<span>新闻管理</span>
</el-menu-item>
<el-menu-item index="carousel">
<el-icon><PictureFilled /></el-icon>
<span>轮播管理</span>
</el-menu-item>
</el-menu>
</div>
<!-- ==================== 右侧内容区 ==================== -->
<div class="admin-content">
<!-- ========== 数据总览 ========== -->
<template v-if="activeMenu === 'dashboard'">
<h2 class="section-heading">数据总览</h2>
<el-row :gutter="20" class="stats-row">
<el-col :xs="24" :sm="12" :md="6" v-for="stat in overviewStats" :key="stat.label">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</el-card>
</el-col>
</el-row>
<!-- 按组别统计 -->
<el-row :gutter="20" class="stats-row" v-if="groupStats.length">
<el-col :xs="24" :sm="12" :md="6" v-for="gs in groupStats" :key="gs.group">
<el-card shadow="hover" class="stat-card" :style="{ borderTopColor: gs.color }">
<div class="stat-group-name">{{ gs.name }}</div>
<div class="stat-detail">总人数: {{ gs.total }}</div>
<div class="stat-detail">已激活: {{ gs.activated }}</div>
<div class="stat-detail">已提交: {{ gs.submitted }}</div>
</el-card>
</el-col>
</el-row>
<!-- 统计口径:是否排除测试数据 -->
<div class="stats-scope-bar">
<el-switch
v-model="statsOnlyProduction"
size="small"
inline-prompt
active-text="仅生产"
inactive-text="含测试"
@change="fetchStats"
/>
<span class="reset-hint">
{{ statsOnlyProduction ? '当前仅统计生产数据(已排除测试数据)' : '当前统计含测试数据' }}
</span>
</div>
<!-- 测试数据重置 -->
<div class="reset-section">
<el-popconfirm
title="⚠️ 此操作将重置所有标记为「测试」的选手数据(恢复为「已激活 + 默认口令」,并清空提交记录),确定继续?"
confirm-button-text="确认重置"
cancel-button-text="取消"
confirm-button-type="danger"
@confirm="handleResetTestData"
>
<template #reference>
<el-button type="warning" :loading="resetLoading" plain>
🧪 重置测试数据
</el-button>
</template>
</el-popconfirm>
<span class="reset-hint">仅重置标记为"测试"的选手数据,生产数据不受影响。重置后可直接用默认口令「{{ testDefaultPasswordHint }}」登录重测</span>
</div>
</template>
<!-- ========== 选手管理 ========== -->
<template v-if="activeMenu === 'accounts'">
<h2 class="section-heading">选手管理</h2>
<div class="toolbar">
<el-input v-model="accountSearch" placeholder="搜索姓名/身份证号/手机号" clearable class="search-input" @change="onAccountFilterChange" />
<el-select v-model="accountGroupFilter" placeholder="组别筛选" clearable @change="onAccountFilterChange" class="filter-select">
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
<el-option label="本科组" value="benke" />
<el-option label="教师组" value="jiaoshi" />
</el-select>
<el-select v-model="accountActivatedFilter" placeholder="激活状态" clearable @change="onAccountFilterChange" class="filter-select">
<el-option label="已激活" value="true" />
<el-option label="未激活" value="false" />
</el-select>
<el-date-picker
v-model="accountDateRange"
type="daterange"
range-separator="至"
start-placeholder="注册开始日期"
end-placeholder="注册结束日期"
align="right"
size="small"
class="date-range"
@change="onAccountFilterChange"
/>
<el-checkbox v-model="importAsTest" class="import-test-flag" title="勾选后,本次导入的选手将被标记为「测试数据」,可被「重置测试数据」一键还原,且不计入生产统计">
标记为测试数据
</el-checkbox>
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onExcelImport" accept=".xlsx,.xls">
<el-button type="warning">导入Excel</el-button>
</el-upload>
</div>
<el-table :data="accounts" stripe v-loading="accountsLoading" class="admin-table" style="width: 100%">
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="idNumber" label="身份证号" min-width="180" />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column label="组别" min-width="100" align="center">
<template #default="{ row }">{{ groupMap[row.group] || row.group }}</template>
</el-table-column>
<el-table-column label="状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.activated ? 'success' : 'info'" size="small">{{ row.activated ? '已激活' : '未激活' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="测试数据" min-width="110" align="center">
<template #default="{ row }">
<el-switch
v-model="row.testFlag"
:active-value="1"
:inactive-value="0"
active-text="测试"
inactive-text="生产"
inline-prompt
size="small"
@change="(val) => handleToggleTestFlag(row, val)"
/>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="注册时间" min-width="170" align="center">
<template #default="{ row }">{{ fmtDate(row.createdAt) }}</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="accountsTotal > 0">
<el-pagination
v-model:current-page="accountsPage"
v-model:page-size="accountsPageSize"
:page-sizes="[10, 20, 50, 100]"
:total="accountsTotal"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="fetchAccounts"
@size-change="onAccountSizeChange"
/>
</div>
</template>
<!-- ========== 试卷管理 ========== -->
<template v-if="activeMenu === 'questions'">
<h2 class="section-heading">试卷管理</h2>
<div class="toolbar">
<el-select v-model="questionGroup" placeholder="选择组别" class="filter-select" @change="onQuestionGroupChange">
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
</el-select>
<!-- 客观题工具栏:仅选择题 / 判断题页签可见 -->
<template v-if="activeQuestionType !== 'big'">
<el-input v-model="questionSearch" placeholder="搜索题目内容" clearable size="small" class="search-input" @input="onQuestionSearchChange" />
<el-button type="primary" @click="openQuestionDialog(null, activeQuestionType)">添加题目</el-button>
<el-tag type="info" size="small" class="score-policy-tag">客观题分值口径:每小题固定 1 分</el-tag>
<el-divider direction="vertical" />
<el-button size="small" type="info" plain v-guard="() => downloadQuestionTpl()">下载Excel模板</el-button>
<el-button size="small" type="success" v-guard="() => triggerExcelImport()">Excel批量导入</el-button>
<input ref="excelFileInput" type="file" accept=".xlsx,.xls" style="display:none" @change="onExcelFileChange" />
<el-button size="small" type="danger" :disabled="selectedQuestionRows.length === 0" v-guard="() => batchDeleteSelectedQuestions()">批量删除({{ selectedQuestionRows.length }})</el-button>
<el-tag type="info" class="total-score-tag">客观题总分:{{ objectiveTotalScore }} 分</el-tag>
</template>
<!-- 综合大题工具栏 -->
<template v-else>
<el-input v-model="bigSearch" placeholder="搜索大题标题/题干" clearable size="small" class="search-input" />
<el-button type="primary" v-guard="() => openBigDialog(null)">新增大题</el-button>
<el-button size="small" type="info" plain :loading="bigQuestionsLoading" @click="fetchBigQuestions">刷新</el-button>
<el-button
size="small"
type="warning"
:disabled="bigSelectedIds.length === 0"
:loading="bigExporting"
v-guard="() => exportBigQuestions(bigSelectedIds)"
>导出选中Word({{ bigSelectedIds.length }})</el-button>
<el-button size="small" type="success" :loading="bigExporting" v-guard="() => exportBigQuestions()">导出全部Word</el-button>
<el-tag type="info" class="total-score-tag">
共 {{ bigQuestions.length }} 道大题(每道 {{ bigSubCount }} 小问 × {{ bigScorePerSub }} 分 = {{ bigScorePerSub * bigSubCount }} 分)
</el-tag>
</template>
</div>
<!-- 题型 Tab -->
<el-tabs v-model="activeQuestionType" @tab-change="onQuestionTypeChange">
<el-tab-pane label="选择题" name="choice" />
<el-tab-pane label="判断题" name="judge" />
<el-tab-pane label="综合大题" name="big" />
</el-tabs>
<!-- 选择题表格 -->
<el-table v-if="activeQuestionType === 'choice'" :data="currentQuestions" stripe v-loading="questionsLoading" class="admin-table" @selection-change="onQuestionSelectionChange">
<el-table-column type="selection" width="45" align="center" />
<el-table-column type="index" width="50" label="#" :index="(idx) => (choicePage - 1) * choicePageSize + idx + 1" align="center" />
<el-table-column prop="question" label="题目内容" min-width="280" show-overflow-tooltip />
<el-table-column label="选项" width="200">
<template #default="{ row }">
<div v-if="Array.isArray(row.options)" class="choice-options-list">
<div v-for="opt in row.options" :key="opt.key" class="choice-option-item">
{{ opt.key }}. {{ opt.text }}
</div>
</div>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<el-table-column prop="answer" label="答案" width="80" align="center" />
<el-table-column prop="score" label="分值" width="70" align="center" />
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openQuestionDialog(row, 'choice')">编辑</el-button>
<el-button text type="danger" size="small" v-guard="() => deleteQuestion(row, 'choice')">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="activeQuestionType === 'choice' && filteredQuestions.length > 0">
<el-pagination
v-model:current-page="choicePage"
v-model:page-size="choicePageSize"
:page-sizes="[5, 10, 20, 50]"
:total="filteredQuestions.length"
layout="total, sizes, prev, pager, next, jumper"
size="small"
/>
</div>
<!-- 判断题表格 -->
<el-table v-if="activeQuestionType === 'judge'" :data="currentQuestions" stripe v-loading="questionsLoading" class="admin-table" @selection-change="onQuestionSelectionChange">
<el-table-column type="selection" width="45" align="center" />
<el-table-column type="index" width="50" label="#" :index="(idx) => (judgePage - 1) * judgePageSize + idx + 1" align="center" />
<el-table-column prop="question" label="题目内容" min-width="350" show-overflow-tooltip />
<el-table-column label="答案" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.answer === 'true' ? 'success' : 'danger'" size="small">
{{ row.answer === 'true' ? '正确' : '错误' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="score" label="分值" width="70" align="center" />
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openQuestionDialog(row, 'judge')">编辑</el-button>
<el-button text type="danger" size="small" v-guard="() => deleteQuestion(row, 'judge')">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="activeQuestionType === 'judge' && filteredQuestions.length > 0">
<el-pagination
v-model:current-page="judgePage"
v-model:page-size="judgePageSize"
:page-sizes="[5, 10, 20, 50]"
:total="filteredQuestions.length"
layout="total, sizes, prev, pager, next, jumper"
size="small"
/>
</div>
<!-- 综合大题题库表格 -->
<el-table
v-if="activeQuestionType === 'big'"
ref="bigTableRef"
:data="pagedBigQuestions"
row-key="id"
stripe
v-loading="bigQuestionsLoading"
class="admin-table"
@selection-change="onBigSelectionChange"
>
<el-table-column type="selection" width="45" align="center" :reserve-selection="true" />
<el-table-column type="index" width="50" label="#" :index="(idx) => (bigPage - 1) * bigPageSize + idx + 1" align="center" />
<el-table-column prop="title" label="大题标题" min-width="180" show-overflow-tooltip />
<el-table-column label="题干(富文本,含表格)" min-width="260">
<template #default="{ row }">
<div class="big-preview" v-html="previewHtml(row.description)"></div>
</template>
</el-table-column>
<el-table-column label="小问数" width="80" align="center">
<template #default>{{ bigSubCount }}</template>
</el-table-column>
<el-table-column label="分值" width="110" align="center">
<template #default="{ row }">
{{ row.scorePerSub }} × {{ bigSubCount }} = {{ row.totalScore }}
</template>
</el-table-column>
<el-table-column prop="questionUid" label="题号" width="110" align="center" />
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status ? 'success' : 'info'" size="small">{{ row.status ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openBigDialog(row)">编辑</el-button>
<el-button text type="info" size="small" @click="openBigPreview(row)">预览</el-button>
<el-button text type="danger" size="small" v-guard="() => deleteBigQuestion(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="activeQuestionType === 'big' && filteredBigQuestions.length > 0">
<el-pagination
v-model:current-page="bigPage"
v-model:page-size="bigPageSize"
:page-sizes="[5, 10, 20, 50]"
:total="filteredBigQuestions.length"
layout="total, sizes, prev, pager, next, jumper"
size="small"
/>
</div>
<el-empty v-if="activeQuestionType === 'big' && filteredBigQuestions.length === 0 && !bigQuestionsLoading" description="暂无综合大题,请点击「新增大题」录入" :image-size="80" />
<el-empty v-if="activeQuestionType !== 'big' && currentQuestions.length === 0 && !questionsLoading" :description="'暂无' + questionTypeLabel + '题目'" :image-size="80" />
<!-- 题目编辑弹窗 -->
<el-dialog v-model="questionDialogVisible" :title="dialogTitle" width="640px">
<el-form :model="questionForm" label-position="top">
<el-form-item label="组别">
<el-select v-model="questionForm.group" class="w-full">
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
</el-select>
</el-form-item>
<!-- 选择题 -->
<template v-if="editingQuestionType === 'choice'">
<el-form-item label="题目内容">
<el-input v-model="questionForm.content" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="分值">
<el-input-number v-model="questionForm.score" :min="1" :max="1" disabled />
</el-form-item>
<el-form-item label="选项">
<div class="choice-options">
<el-input v-model="questionForm.optionA" placeholder="选项A" class="choice-input">
<template #prepend>A</template>
</el-input>
<el-input v-model="questionForm.optionB" placeholder="选项B" class="choice-input">
<template #prepend>B</template>
</el-input>
<el-input v-model="questionForm.optionC" placeholder="选项C" class="choice-input">
<template #prepend>C</template>
</el-input>
<el-input v-model="questionForm.optionD" placeholder="选项D" class="choice-input">
<template #prepend>D</template>
</el-input>
</div>
</el-form-item>
<el-form-item label="正确答案">
<el-select v-model="questionForm.answer" class="w-full">
<el-option label="A" value="A" />
<el-option label="B" value="B" />
<el-option label="C" value="C" />
<el-option label="D" value="D" />
</el-select>
</el-form-item>
</template>
<!-- 判断题 -->
<template v-if="editingQuestionType === 'judge'">
<el-form-item label="题目内容">
<el-input v-model="questionForm.content" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="分值">
<el-input-number v-model="questionForm.score" :min="1" :max="1" disabled />
</el-form-item>
<el-form-item label="正确答案">
<el-radio-group v-model="questionForm.answer">
<el-radio value="true">正确</el-radio>
<el-radio value="false">错误</el-radio>
</el-radio-group>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="questionDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="questionSaving" v-guard="() => saveQuestion()">保存</el-button>
</template>
</el-dialog>
<!-- 综合大题:新增/编辑弹窗 -->
<el-dialog
v-model="bigDialogVisible"
:title="bigEditingId ? '编辑综合大题' : '新增综合大题'"
width="820px"
top="6vh"
>
<el-form :model="bigForm" label-position="top">
<el-form-item label="大题标题">
<el-input v-model="bigForm.title" maxlength="200" show-word-limit placeholder="例如:卫星链路预算分析" />
</el-form-item>
<el-form-item label="题干(富文本,支持表格)">
<RichEditor
v-model="bigForm.description"
:height="300"
enable-table
placeholder="请输入题干,可插入表格…"
/>
<div class="big-form-tip">
注意:题干与小问中<b>不得出现</b>「参考答案 / 标准答案 / 评分标准」,也<b>不得给出带具体参数值的指令示例</b>
(如 <code>&gt;OUTMSG,COM1,GGA,1*3B</code>)—— 考生可直接照抄。
如需向考生提供指令手册,请只写<b>格式定义</b>(参数用方括号占位,如
<code>&gt;OUTMSG[,Port],Message,Period*CS</code>)。保存时后端会校验并拦截。
</div>
</el-form-item>
<el-form-item
v-for="i in bigSubCount"
:key="'sub' + i"
:label="'第 ' + i + ' 小问的作答提示(可留空)'"
>
<el-input
v-model="bigForm['subPrompt' + i]"
type="textarea"
:rows="2"
:placeholder="'考生作答区上方显示的提示文字,如:请写出该小问的计算过程'"
/>
</el-form-item>
<el-form-item :label="'每小题分值(大题总分 = 每小题分值 × ' + bigSubCount + ')'">
<el-input-number v-model="bigForm.scorePerSub" :min="1" :max="100" />
<span class="big-total-preview">大题总分:{{ bigForm.scorePerSub * bigSubCount }} 分</span>
</el-form-item>
<el-form-item label="排序(越小越靠前)">
<el-input-number v-model="bigForm.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="bigForm.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
<span class="big-total-preview">停用后该题不会被抽取(已抽到该题的考生不受影响)</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bigDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="bigSaving" v-guard="() => saveBigQuestion()">保存</el-button>
</template>
</el-dialog>
<!-- 综合大题:预览弹窗(按考生视角渲染,确认表格与排版) -->
<el-dialog v-model="bigPreviewVisible" title="大题预览(考生视角)" width="820px" top="6vh">
<h3 class="big-preview-title">{{ bigPreviewData.title }}</h3>
<div class="big-preview-body" v-html="previewHtml(bigPreviewData.description)"></div>
<el-divider />
<div v-for="i in bigSubCount" :key="i" class="big-preview-sub">
<div class="big-preview-sub-head">第 {{ i }} 小问({{ bigScorePerSub }} 分)</div>
<div v-if="bigPreviewData.subPrompts && bigPreviewData.subPrompts[i - 1]" class="big-preview-sub-prompt">
{{ bigPreviewData.subPrompts[i - 1] }}
</div>
<el-input type="textarea" :rows="3" disabled placeholder="考生作答区(预览不可输入)" />
</div>
</el-dialog>
</template>
<!-- ========== 备赛资料管理 ========== -->
<template v-if="activeMenu === 'materials'">
<h2 class="section-heading">备赛资料管理</h2>
<div class="toolbar">
<el-select v-model="materialsGroup" placeholder="选择组别" class="filter-select" @change="onMaterialsGroupChange">
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
<el-option label="本科组" value="benke" />
<el-option label="教师组" value="jiaoshi" />
</el-select>
<el-input v-model="materialSearch" placeholder="搜索文件名" clearable size="small" class="search-input" @input="onMaterialFilterChange" />
<el-date-picker
v-model="materialDateRange"
type="daterange"
range-separator="至"
start-placeholder="上传开始日期"
end-placeholder="上传结束日期"
align="right"
size="small"
class="date-range"
@change="onMaterialFilterChange"
/>
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onMaterialUpload">
<el-button type="primary">上传资料</el-button>
</el-upload>
<el-button text type="primary" v-guard="() => fetchAdminMaterials()" :loading="materialsLoading">刷新</el-button>
</div>
<el-table :data="pagedAdminMaterials" stripe v-loading="materialsLoading" class="admin-table">
<el-table-column prop="fileName" label="文件名" min-width="260" show-overflow-tooltip />
<el-table-column prop="fileSize" label="大小" width="100" align="center">
<template #default="{ row }">{{ formatFileSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column prop="uploadTime" label="上传时间" width="170" align="center">
<template #default="{ row }">{{ row.uploadTime ? fmtDate(row.uploadTime) : '--' }}</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button text type="danger" size="small" v-guard="() => onDeleteMaterial(row.fileName)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="filteredAdminMaterials.length > 0">
<el-pagination
v-model:current-page="materialsPage"
v-model:page-size="materialsPageSize"
:page-sizes="[10, 20, 50]"
:total="filteredAdminMaterials.length"
layout="total, sizes, prev, pager, next, jumper"
size="small"
/>
</div>
<el-empty v-if="!materialsLoading && adminMaterials.length === 0" description="暂无备赛资料" />
</template>
<!-- ========== 作品管理 ========== -->
<template v-if="activeMenu === 'submissions'">
<h2 class="section-heading">作品管理</h2>
<div class="toolbar">
<el-select v-model="submissionGroup" placeholder="选择组别" class="filter-select" @change="onSubmissionGroupChange">
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
<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" />
<!-- R29:中职/高职组已取消 Word 作品上传 ⇒ 这两组不显示「批量下载作品」 -->
<el-button
v-if="submissionGroup === 'benke' || submissionGroup === 'jiaoshi'"
type="success"
v-guard="() => batchDownloadWorks()"
>批量下载作品</el-button>
</div>
<el-table :data="pagedSubmissions" stripe v-loading="submissionsLoading" class="admin-table">
<el-table-column prop="name" label="选手姓名" width="120" />
<el-table-column label="上传文件 / 评分" min-width="420">
<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>
<el-tag size="small" :type="f.kind === 'ppt' ? 'warning' : (f.kind === 'answer' ? 'success' : 'info')">
{{ f.kind === 'ppt' ? 'PPT' : f.kind === 'answer' ? 'Word答案' : '文档' }}
</el-tag>
<el-button text type="primary" size="small" v-guard="() => previewWork(row, f)">预览</el-button>
<el-button text type="primary" size="small" v-guard="() => downloadWork(row, f)">下载</el-button>
<span class="score-inline">
评分
<el-input-number v-model="f._score" :min="0" :max="100" :step="0.25" :precision="2" size="small" controls-position="right" class="score-input" />
</span>
</div>
<span v-if="!row.files || row.files.length === 0" style="color:#909399">未提交</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="filteredSubmissions.length > 0">
<el-pagination
v-model:current-page="submissionsPage"
v-model:page-size="submissionsPageSize"
:page-sizes="[10, 20, 50]"
:total="filteredSubmissions.length"
layout="total, sizes, prev, pager, next, jumper"
size="small"
/>
</div>
<div class="toolbar" v-if="submissions.length">
<el-button type="primary" v-guard="() => batchSaveWorkScores()">批量保存评分</el-button>
</div>
<!-- 文件预览弹窗(PDF 在线预览;docx/ppt 提示下载后查看) -->
<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>
<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">
<template #title>该格式(docx / ppt)建议下载后在本机客户端查看</template>
系统支持 PDF 在线预览;docx / ppt 受鉴权与本地环境限制无法内嵌预览,请点击「下载文件」后在本地查看并评分。
</el-alert>
<el-button type="primary" v-guard="() => downloadWork(previewRow, previewFile)">下载文件</el-button>
</div>
</div>
</el-dialog>
</template>
<!-- ========== 时间设置 ========== -->
<template v-if="activeMenu === 'times'">
<h2 class="section-heading">时间设置</h2>
<div v-loading="timesLoading">
<el-form :model="timesForm" label-position="top" class="times-form">
<el-row :gutter="20">
<el-col :xs="24" :md="12" v-for="g in timeGroups" :key="g.key">
<el-card shadow="never" class="time-group-card">
<template #header>
<span class="time-group-title">{{ g.name }}</span>
</template>
<el-form-item :label="g.isExam ? '考试开始时间' : '提交开始时间'">
<el-date-picker
v-model="timesForm[g.key + 'Start']"
type="datetime"
placeholder="选择时间"
class="w-full"
value-format="YYYY-MM-DDTHH:mm:ss"
/>
</el-form-item>
<el-form-item :label="g.isExam ? '考试结束时间' : '提交截止时间'">
<el-date-picker
v-model="timesForm[g.key + 'End']"
type="datetime"
placeholder="选择时间"
class="w-full"
value-format="YYYY-MM-DDTHH:mm:ss"
/>
</el-form-item>
<template v-if="g.isExam">
<el-form-item label="选择题抽取数量">
<el-input-number v-model="timesForm[g.key + 'ChoiceCount']" :min="1" :max="200" />
<span class="avail-hint">题库实际:{{ timesForm[g.key + 'AvailChoice'] }} 道</span>
</el-form-item>
<el-form-item label="判断题抽取数量">
<el-input-number v-model="timesForm[g.key + 'JudgeCount']" :min="1" :max="200" />
<span class="avail-hint">题库实际:{{ timesForm[g.key + 'AvailJudge'] }} 道</span>
</el-form-item>
</template>
</el-card>
</el-col>
</el-row>
<el-form-item>
<el-button type="primary" :loading="timesSaving" v-guard="() => saveTimes()">保存时间设置</el-button>
</el-form-item>
</el-form>
</div>
</template>
<!-- ========== 成绩管理 ========== -->
<template v-if="activeMenu === 'scores'">
<h2 class="section-heading">成绩管理</h2>
<div class="toolbar">
<el-select v-model="scoreExportGroup" placeholder="选择组别" class="filter-select" @change="onScoreGroupChange">
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
<el-option label="本科组" value="benke" />
<el-option label="教师组" value="jiaoshi" />
</el-select>
<el-input v-model="scoreSearch" placeholder="搜索姓名/身份证号" clearable size="small" class="search-input" @input="onScoreFilterChange" />
<el-select v-model="scoreStatusFilter" placeholder="提交状态" clearable size="small" class="filter-select" @change="onScoreFilterChange">
<el-option label="已提交" value="true" />
<el-option label="未提交" value="false" />
</el-select>
<el-date-picker
v-model="scoreDateRange"
type="daterange"
range-separator="至"
start-placeholder="提交开始日期"
end-placeholder="提交结束日期"
align="right"
size="small"
class="date-range"
@change="onScoreFilterChange"
/>
<el-button :type="scoresPublishedMap[scoreExportGroup] ? 'warning' : 'success'" v-guard="() => togglePublish()" :disabled="!scoreExportGroup">
{{ scoresPublishedMap[scoreExportGroup] ? '撤回成绩' : '发布成绩' }}
</el-button>
<!-- R29:由「导出CSV」改为直接导出真正的 Excel(.xlsx) -->
<el-button type="primary" plain v-guard="() => exportExcel()" :disabled="!scoreExportGroup">导出Excel</el-button>
<!-- R29:中职/高职组已取消 Word 作品上传 ⇒ 这两组不显示「批量下载作品」 -->
<el-button
v-if="!isExamScoreGroup"
type="success"
v-guard="() => batchDownload()"
:disabled="!scoreExportGroup"
>批量下载作品</el-button>
</div>
<!-- 成绩明细表 -->
<el-table v-if="scoreList.length > 0" :data="pagedScoreList" stripe v-loading="scoreListLoading" class="admin-table" style="margin-bottom: 20px">
<el-table-column type="index" label="序号" width="60" :index="(idx) => (scoreListPage - 1) * scoreListPageSize + idx + 1" align="center" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="idNumber" label="身份证号" width="180" show-overflow-tooltip />
<el-table-column label="提交状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.submitted ? 'success' : 'info'" size="small">
{{ row.submitted ? '已提交' : '未提交' }}
</el-tag>
</template>
</el-table-column>
<template v-if="scoreExportGroup === 'zhongzhi' || scoreExportGroup === 'gaozhi'">
<el-table-column prop="answeredCount" label="答题数" width="80" align="center" />
<el-table-column label="卷面分" width="90" align="center">
<template #default="{ row }">
<span :style="{ color: (row.examScore ?? 0) > 0 ? '#67c23a' : '#909399' }">
{{ row.examScore ?? 0 }}
</span>
</template>
</el-table-column>
<el-table-column label="大题分" width="90" align="center">
<template #default="{ row }">
<span :style="{ color: (row.wordScore ?? 0) > 0 ? '#67c23a' : '#909399' }">
{{ row.wordScore ?? 0 }}
</span>
</template>
</el-table-column>
</template>
<template v-else>
<el-table-column label="文档分(Word)" width="100" align="center">
<template #default="{ row }">
<span :style="{ color: (row.wordScore ?? 0) > 0 ? '#67c23a' : '#909399' }">
{{ row.wordScore ?? 0 }}
</span>
</template>
</el-table-column>
<el-table-column label="PPT分" width="100" align="center">
<template #default="{ row }">
<span :style="{ color: (row.pptScore ?? 0) > 0 ? '#67c23a' : '#909399' }">
{{ row.pptScore ?? 0 }}
</span>
</template>
</el-table-column>
</template>
<el-table-column label="总分" width="100" align="center">
<template #default="{ row }">
<span :style="{ color: row.score > 0 ? '#67c23a' : '#909399', fontWeight: 'bold' }">
{{ row.score }}{{ row.submitted ? '分' : '' }}
</span>
</template>
</el-table-column>
<el-table-column prop="submitTime" label="提交时间" width="170" align="center">
<template #default="{ row }">{{ fmtDate(row.submitTime) }}</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="filteredScoreList.length > 0">
<el-pagination
v-model:current-page="scoreListPage"
v-model:page-size="scoreListPageSize"
:page-sizes="[10, 20, 50]"
:total="filteredScoreList.length"
layout="total, sizes, prev, pager, next, jumper"
size="small"
/>
</div>
<el-empty v-else-if="scoreExportGroup && !scoreListLoading" description="暂无成绩数据" :image-size="80" />
<el-result v-if="scoreExportGroup && scoresPublishedMap[scoreExportGroup] != null">
<template #icon>
<el-icon :size="64" :color="scoresPublishedMap[scoreExportGroup] ? '#67c23a' : '#909399'">
<Trophy />
</el-icon>
</template>
<template #title>
{{ scoresPublishedMap[scoreExportGroup] ? '成绩已发布' : '成绩未发布' }}
</template>
</el-result>
<!-- ========== R29:由「试卷管理」迁入(仅中职/高职有客观题与综合大题) ========== -->
<template v-if="isExamScoreGroup">
<!-- 考生答题记录 -->
<el-divider />
<h3 class="section-subheading">考生答题记录</h3>
<div class="toolbar">
<el-input v-model="examSubsSearch" placeholder="搜索选手姓名" clearable size="small" class="search-input" @input="onExamSubsFilterChange" />
<el-date-picker
v-model="examSubsDateRange"
type="daterange"
range-separator="至"
start-placeholder="提交开始日期"
end-placeholder="提交结束日期"
align="right"
size="small"
class="date-range"
@change="onExamSubsFilterChange"
/>
<el-button text type="primary" v-guard="() => fetchExamSubmissions()" :loading="examSubmissionsLoading">刷新</el-button>
</div>
<el-table :data="pagedExamSubmissions" stripe v-loading="examSubmissionsLoading" class="admin-table">
<el-table-column prop="userName" label="选手姓名" width="120" />
<el-table-column label="提交状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.submitted ? 'success' : 'info'" size="small">
{{ row.submitted ? '已提交' : '未提交' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="答题情况" min-width="220" align="center">
<template #default="{ row }">
<span>已答 {{ row.answeredCount || 0 }} / {{ row.totalQuestions || 0 }} 题</span>
<span style="margin-left:10px;color:#1a3a5c;font-weight:600">客观分:{{ row.examScore ?? 0 }}</span>
<span style="margin-left:10px;color:#1a3a5c;font-weight:600">大题分:{{ row.wordScore ?? 0 }}</span>
<span style="margin-left:10px;color:#1a3a5c;font-weight:600">总分:{{ row.totalScore ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="submitTime" label="提交时间" width="160" align="center">
<template #default="{ row }">{{ row.submitTime ? fmtDate(row.submitTime) : '--' }}</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="filteredExamSubmissions.length > 0">
<el-pagination
v-model:current-page="examSubmissionsPage"
v-model:page-size="examSubmissionsPageSize"
:page-sizes="[10, 20, 50]"
:total="filteredExamSubmissions.length"
layout="total, sizes, prev, pager, next, jumper"
size="small"
/>
</div>
<!-- R30:原「考生答题记录 · 评分」弹窗已删除。
原因:大题评分统一在下方「考生大题作答查证」按小问给分
(PUT /api/admin/big-answers/:group/:uid/scores),
避免同一份 word_score 存在两条入口、出现口径不一致。
⛔ 后端 PUT /api/admin/scores/:group 与 adminApi.setScores 均**保留**:
前者被 10+ 个门禁脚本依赖,后者作品管理页(本科/教师)仍在使用。 -->
<!-- ========== 考生大题作答查证 ========== -->
<el-divider />
<h3 class="section-subheading">考生大题作答查证</h3>
<el-alert
type="info"
:closable="false"
show-icon
style="margin-bottom:12px"
title="用于核对考生实际抽到的题目与其提交的答案,处理考生纠纷。题干为该考生抽题时刻的快照,管理员后续修改题库不会影响此处显示。"
/>
<div class="toolbar">
<el-input v-model="bigAnswerSearch" placeholder="搜索考生姓名 / 身份证号" clearable size="small" class="search-input" />
<el-select v-model="bigAnswerFilter" size="small" class="filter-select">
<el-option label="全部考生" value="all" />
<el-option label="已抽题" value="drawn" />
<el-option label="未抽题" value="undrawn" />
</el-select>
<el-button size="small" type="info" plain :loading="bigAnswersLoading" @click="fetchBigAnswers">刷新</el-button>
<el-button
size="small"
type="primary"
:disabled="bigAnswerSelectedIds.length === 0"
:loading="bigAnswersExporting"
v-guard="() => exportBigAnswers(bigAnswerSelectedIds)"
>导出选中 Word({{ bigAnswerSelectedIds.length }})</el-button>
<el-button
size="small"
type="success"
:loading="bigAnswersExporting"
v-guard="() => exportBigAnswers()"
>导出全部 Word</el-button>
<el-tag type="info" class="total-score-tag">
共 {{ bigAnswersTotal }} 人,已抽题 {{ bigAnswersDrawn }} 人
</el-tag>
</div>
<el-table
ref="bigAnswersTableRef"
:data="filteredBigAnswers"
stripe
v-loading="bigAnswersLoading"
class="admin-table"
row-key="contestantId"
@selection-change="onBigAnswerSelectionChange"
>
<el-table-column type="selection" width="44" :selectable="row => row.drawn" />
<el-table-column prop="name" label="姓名" width="110" />
<el-table-column prop="idNumber" label="身份证号" width="180" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column label="抽题" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.drawn ? 'success' : 'info'" size="small">{{ row.drawn ? '已抽题' : '未抽题' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="抽到大题" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.questionSnapshot">{{ row.questionSnapshot.title }}</span>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<el-table-column label="已答小问" width="100" align="center">
<template #default="{ row }">
<span v-if="row.drawn">{{ row.answeredCount }} / {{ bigSubCount }}</span>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<el-table-column label="大题得分" width="110" align="center">
<template #default="{ row }">
<span v-if="row.bigScore !== null && row.bigScore !== undefined">{{ fmtScore(row.bigScore) }} 分</span>
<span v-else style="color:#909399">未评分</span>
</template>
</el-table-column>
<!-- R30:三小问得分逐问展示(数据源 listAnswersForAdmin 的 score1/2/3,
故本列不需要任何后端改动)。按 bigSubCount 顺序以「/」分隔。 -->
<el-table-column label="小问得分" width="150" align="center">
<template #default="{ row }">
<span v-if="hasSubScores(row)" class="big-sub-scores">{{ fmtSubScores(row) }}</span>
<span v-else style="color:#909399">未评分</span>
</template>
</el-table-column>
<el-table-column label="提交时间" width="170">
<template #default="{ row }">
<span v-if="row.submitTime">{{ formatDateTime(row.submitTime) }}</span>
<span v-else style="color:#909399">未提交</span>
</template>
</el-table-column>
<el-table-column label="操作" width="210" align="center">
<template #default="{ row }">
<el-button text type="primary" size="small" :disabled="!row.drawn" @click="openBigAnswerDetail(row)">
{{ row.bigScore === null || row.bigScore === undefined ? '查看/评分' : '查看/改分' }}
</el-button>
<el-button
text
type="success"
size="small"
:disabled="!row.drawn"
:loading="bigAnswersExporting"
@click="exportBigAnswers([row.contestantId])"
>导出Word</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="filteredBigAnswers.length === 0 && !bigAnswersLoading" description="该组暂无考生数据" :image-size="80" />
<!-- 综合大题:作答详情 + 评分弹窗 -->
<el-dialog
v-model="bigAnswerDialogVisible"
:title="'大题作答详情 — ' + (bigAnswerRow.name || '')"
width="880px"
top="5vh"
>
<template v-if="bigAnswerRow.contestantId">
<el-descriptions :column="3" border size="small" class="big-answer-meta">
<el-descriptions-item label="姓名">{{ bigAnswerRow.name }}</el-descriptions-item>
<el-descriptions-item label="身份证号">{{ bigAnswerRow.idNumber }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ bigAnswerRow.phone }}</el-descriptions-item>
<el-descriptions-item label="抽题状态">{{ bigAnswerRow.drawn ? '已抽题' : '未抽题' }}</el-descriptions-item>
<el-descriptions-item label="抽到题号">{{ bigAnswerRow.bigQuestionUid || '--' }}</el-descriptions-item>
<el-descriptions-item label="提交时间">
{{ bigAnswerRow.submitTime ? formatDateTime(bigAnswerRow.submitTime) : '未提交' }}
</el-descriptions-item>
</el-descriptions>
<template v-if="bigAnswerRow.drawn">
<h4 class="big-answer-h4">考生抽到的题目(抽题时刻快照,防纠纷)</h4>
<div class="big-snapshot">
<div class="big-snapshot-title">
{{ bigAnswerRow.questionSnapshot && bigAnswerRow.questionSnapshot.title }}
</div>
<div
class="big-preview-body"
v-html="previewHtml(bigAnswerRow.questionSnapshot && bigAnswerRow.questionSnapshot.description)"
></div>
</div>
<h4 class="big-answer-h4">考生提交的答案与评分</h4>
<div v-for="i in bigSubCount" :key="i" class="big-answer-sub">
<div class="big-answer-sub-head">
<span>第 {{ i }} 小问</span>
<span class="big-answer-chars">
{{ bigAnswerTextOf(i).length }} 字
</span>
</div>
<div v-if="bigSubPromptsOfRow[i - 1]" class="big-answer-prompt">
{{ bigSubPromptsOfRow[i - 1] }}
</div>
<div class="big-answer-text">
<span v-if="bigAnswerTextOf(i).trim()">{{ bigAnswerTextOf(i) }}</span>
<span v-else class="big-answer-empty">(未作答)</span>
</div>
<div class="big-answer-score">
<span class="big-answer-score-label">得分:</span>
<el-input-number
v-model="bigScoreForm[`score_${i}`]"
:min="0"
:max="bigScorePerSub"
:step="0.25"
:precision="2"
size="small"
controls-position="right"
/>
<span class="big-answer-score-max">/ {{ bigScorePerSub }} 分</span>
</div>
</div>
<div class="big-answer-total">
本大题合计:
<strong>{{ fmtScore(bigScoreTotal) }}</strong> 分(满分 {{ bigScorePerSub * bigSubCount }} 分)
<span class="big-answer-tip">保存后将写入该考生「大题评分」,总分 = 客观题得分 + 大题评分</span>
</div>
</template>
<el-alert v-else type="info" :closable="false" show-icon title="该考生尚未抽取综合大题(可能未进入考试)" />
</template>
<template #footer>
<el-button @click="bigAnswerDialogVisible = false">关闭</el-button>
<el-button
v-if="bigAnswerRow.drawn"
type="primary"
:loading="bigScoreSaving"
v-guard="() => saveBigScores()"
>保存评分</el-button>
</template>
</el-dialog>
</template>
</template>
<!-- ========== 成绩复核 ========== -->
<template v-if="activeMenu === 'review'">
<h2 class="section-heading">成绩复核</h2>
<el-alert
type="info"
:closable="false"
show-icon
class="review-privacy-tip"
title="本页会显示客观题的标准答案,仅限管理员内部核对使用,请勿截图外传。"
description="用于考生对成绩提出异议时:按 姓名 / 身份证号 / 电话 任一检索出该考生,核对他的全部题目与实际作答。"
/>
<div class="toolbar review-toolbar">
<el-input
v-model="reviewKeyword"
placeholder="姓名 / 身份证号 / 电话(任一)"
clearable
size="default"
class="search-input"
@keyup.enter="doReviewSearch"
/>
<el-select v-model="reviewGroup" placeholder="全部组别" size="default" class="review-group-select">
<el-option label="全部组别" value="" />
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
<el-option label="本科组" value="benke" />
<el-option label="教师组" value="jiaoshi" />
</el-select>
<el-button type="primary" :loading="reviewSearching" @click="doReviewSearch">
<el-icon><Search /></el-icon>搜索考生
</el-button>
</div>
<el-alert
v-if="reviewTruncated"
type="warning"
:closable="false"
show-icon
class="review-warn"
title="命中超过 50 条,已截断显示。请补充更多字符(例如完整身份证号)以精确定位。"
/>
<el-table
v-if="reviewResults.length"
:data="reviewResults"
stripe
v-loading="reviewSearching"
class="admin-table"
>
<el-table-column prop="name" label="姓名" width="110" />
<el-table-column prop="idNumber" label="身份证号" min-width="190" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column label="组别" width="90" align="center">
<template #default="{ row }">{{ reviewGroupLabel(row.group) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openReviewDetail(row)">查看全卷</el-button>
</template>
</el-table-column>
</el-table>
<el-empty
v-else-if="reviewSearched && !reviewSearching"
description="没有匹配的考生,请检查姓名 / 身份证号 / 电话是否输入正确"
:image-size="80"
/>
<!-- 全卷复核弹窗 -->
<el-dialog
v-model="reviewDialogVisible"
:title="'全卷复核 — ' + (reviewDetail ? reviewDetail.name : '')"
width="980px"
top="4vh"
>
<div v-loading="reviewLoading">
<template v-if="reviewDetail">
<div class="review-meta">
<span>姓名:<b>{{ reviewDetail.name }}</b></span>
<span>身份证号:{{ reviewDetail.idNumber || '--' }}</span>
<span>电话:{{ reviewDetail.phone || '--' }}</span>
<span>组别:{{ reviewGroupLabel(reviewDetail.group) }}</span>
<el-tag :type="reviewDetail.scorePublished ? 'success' : 'info'" size="small">
{{ reviewDetail.scorePublished ? '成绩已发布' : '成绩未发布' }}
</el-tag>
</div>
<el-descriptions :column="4" border size="small" class="review-summary">
<el-descriptions-item label="客观题题数">{{ reviewDetail.summary ? reviewDetail.summary.answered : 0 }}</el-descriptions-item>
<el-descriptions-item label="客观题得分">{{ reviewDetail.summary ? reviewDetail.summary.autoScore : 0 }}</el-descriptions-item>
<el-descriptions-item label="综合大题得分">{{ reviewDetail.summary ? reviewDetail.summary.wordScore : 0 }}</el-descriptions-item>
<el-descriptions-item label="总分">{{ reviewDetail.summary ? reviewDetail.summary.totalScore : 0 }}</el-descriptions-item>
</el-descriptions>
<el-alert
v-for="(w, i) in (reviewDetail.warnings || [])"
:key="'rvw' + i"
type="warning"
:closable="false"
show-icon
class="review-warn"
:title="w"
/>
<!-- 客观题逐题 -->
<template v-if="reviewDetail.objectiveApplicable">
<h3 class="section-subheading">客观题逐题作答(共 {{ reviewDetail.objective.length }} 题)</h3>
<el-alert
v-if="!reviewDetail.questionOrderFixed"
type="info"
:closable="false"
show-icon
class="review-warn"
title="该考生没有固化题目顺序(历史数据),题目按「选择 → 判断」题号升序展示,可能与其当时所见的题序不同。"
/>
<el-table
:data="reviewDetail.objective"
stripe
size="small"
class="admin-table review-table"
max-height="440"
>
<el-table-column label="#" width="50" align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column label="题型" width="68" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.questionType === 'choice' ? 'info' : 'warning'">
{{ row.questionType === 'choice' ? '选择' : '判断' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="题目" min-width="270">
<template #default="{ row }">
<div class="review-question">{{ row.question || '(题库中已找不到该题)' }}</div>
<ul v-if="reviewOptionList(row).length" class="review-options">
<li v-for="(op, oi) in reviewOptionList(row)" :key="op.key || oi">{{ op.key }}. {{ op.text }}</li>
</ul>
</template>
</el-table-column>
<el-table-column label="考生作答" width="130" align="center">
<template #default="{ row }">
<span v-if="row.answered">{{ reviewAnswerCell(row, row.answer) }}</span>
<span v-else class="review-empty">未作答</span>
</template>
</el-table-column>
<el-table-column label="正确答案" width="130" align="center">
<template #default="{ row }">
<span v-if="row.correctAnswer != null && row.correctAnswer !== ''">
{{ reviewAnswerCell(row, row.correctAnswer) }}
</span>
<span v-else class="review-empty">--</span>
</template>
</el-table-column>
<el-table-column label="判定" width="76" align="center">
<template #default="{ row }">
<el-tag v-if="row.isCorrect === true" type="success" size="small">正确</el-tag>
<el-tag v-else-if="row.isCorrect === false" type="danger" size="small">错误</el-tag>
<span v-else class="review-empty">未判</span>
</template>
</el-table-column>
<el-table-column label="得分" width="86" align="center">
<template #default="{ row }">
<span v-if="row.score != null">{{ row.score }}</span>
<span v-else class="review-empty">--</span>
<span class="review-max">/{{ row.maxScore != null ? row.maxScore : '--' }}</span>
</template>
</el-table-column>
<el-table-column label="异常" width="66" align="center">
<template #default="{ row }">
<el-tooltip
v-if="reviewRowAnomaly(row)"
placement="top"
:content="reviewRowAnomaly(row)"
>
<el-tag type="danger" size="small">!</el-tag>
</el-tooltip>
<span v-else class="review-empty">—</span>
</template>
</el-table-column>
</el-table>
</template>
<el-alert
v-else
type="info"
:closable="false"
show-icon
class="review-warn"
title="该组别(本科 / 教师)为作品提交制,没有客观题作答记录。"
/>
<!-- 综合大题 -->
<template v-if="reviewDetail.bigQuestion">
<h3 class="section-subheading">综合大题(抽题快照)</h3>
<div class="review-big">
<div class="review-big-title">{{ reviewDetail.bigQuestion.title || '(无标题)' }}</div>
<div
v-if="reviewDetail.bigQuestion.description"
class="review-big-desc"
v-html="sanitizeHtml(reviewDetail.bigQuestion.description)"
></div>
<div v-for="a in reviewDetail.bigQuestion.answers" :key="'rba' + a.index" class="review-big-sub">
<div class="review-big-prompt">
第 {{ a.index }} 小问<span v-if="a.prompt">:{{ a.prompt }}</span>
</div>
<div class="review-big-answer">
<span v-if="a.answered">{{ a.answer }}</span>
<span v-else class="review-empty">(未作答)</span>
</div>
<div class="review-big-score">
得分:{{ a.score != null ? a.score : '--' }}
<span v-if="reviewDetail.bigQuestion.scorePerSub"> / {{ reviewDetail.bigQuestion.scorePerSub }}</span>
</div>
</div>
<div class="review-big-total">
大题合计:<b>{{ reviewDetail.bigQuestion.totalScore != null ? reviewDetail.bigQuestion.totalScore : '--' }}</b>
<span v-if="reviewDetail.bigQuestion.maxTotal"> / {{ reviewDetail.bigQuestion.maxTotal }}</span>
</div>
</div>
</template>
<!-- 本科/教师:作品文件 -->
<template v-if="reviewDetail.works && reviewDetail.works.length">
<h3 class="section-subheading">作品文件(共 {{ reviewDetail.works.length }} 个)</h3>
<el-table :data="reviewDetail.works" stripe size="small" class="admin-table">
<el-table-column prop="workName" label="作品名称" min-width="150" />
<el-table-column prop="fileName" label="文件名" min-width="190" />
<el-table-column label="大小" width="96" align="center">
<template #default="{ row }">
{{ row.fileSize != null ? formatFileSize(row.fileSize) : '--' }}
</template>
</el-table-column>
<el-table-column label="上传时间" width="160" align="center">
<template #default="{ row }">{{ row.uploadTime ? fmtDate(row.uploadTime) : '--' }}</template>
</el-table-column>
<el-table-column label="得分" width="80" align="center">
<template #default="{ row }">{{ row.score != null ? row.score : '--' }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="downloadReviewWork(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
</template>
</template>
</div>
</el-dialog>
</template>
<!-- ========== 新闻管理 ========== -->
<template v-if="activeMenu === 'news'">
<h2 class="section-heading">新闻管理</h2>
<div class="toolbar">
<el-input v-model="newsSearch" placeholder="搜索标题" clearable size="small" class="search-input" @input="onNewsFilterChange" />
<el-select v-model="newsGroupFilter" placeholder="分组筛选" clearable size="small" class="filter-select" @change="onNewsFilterChange">
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
<el-option label="本科组" value="benke" />
<el-option label="教师组" value="jiaoshi" />
</el-select>
<el-date-picker
v-model="newsDateRange"
type="daterange"
range-separator="至"
start-placeholder="发布开始日期"
end-placeholder="发布结束日期"
align="right"
size="small"
class="date-range"
@change="onNewsFilterChange"
/>
<el-button type="primary" @click="openNewsDialog()">添加新闻</el-button>
<el-button type="primary" plain v-guard="() => saveNewsOrder()" :disabled="adminNews.length < 2">保存排序</el-button>
</div>
<div v-loading="adminNewsLoading" class="news-card-list" ref="newsCardListRef">
<div
v-for="(item, idx) in pagedAdminNews"
:key="item.id"
class="news-card"
:data-id="item.id"
>
<div class="news-card__handle">
<el-icon><Rank /></el-icon>
</div>
<div class="news-card__body">
<div class="news-card__title">{{ item.title }}</div>
<div class="news-card__meta">
<span class="news-card__group">{{ groupMap[item.group] || '全部' }}</span>
<el-tag v-if="item.tags" v-for="tag in (item.tags || '').split(',').filter(Boolean)" :key="tag" size="small" round>{{ tag.trim() }}</el-tag>
<span class="news-card__time">{{ fmtDate(item.publish_time) }}</span>
</div>
</div>
<div class="news-card__actions">
<el-switch v-model="item.pinned" size="small" :active-value="true" :inactive-value="false" @change="(val) => togglePinned(item, val)" />
<el-button text type="primary" size="small" @click="openNewsDialog(item)">编辑</el-button>
<el-button text type="danger" size="small" v-guard="() => deleteAdminNews(item)">删除</el-button>
</div>
</div>
</div>
<div class="pagination-wrap" v-if="filteredAdminNews.length > 0">
<el-pagination
v-model:current-page="adminNewsPage"
v-model:page-size="adminNewsPageSize"
:page-sizes="[10, 20, 50]"
:total="filteredAdminNews.length"
layout="total, sizes, prev, pager, next, jumper"
size="small"
/>
</div>
<!-- 新闻编辑弹窗 -->
<el-dialog v-model="newsDialogVisible" :title="editingNews?.id ? '编辑新闻' : '添加新闻'" width="760px">
<el-form :model="newsForm" label-position="top">
<el-form-item label="标题">
<el-input v-model="newsForm.title" />
</el-form-item>
<el-form-item label="分组可见">
<el-select v-model="newsForm.group" class="w-full" placeholder="全部可见">
<el-option label="全部" value="all" />
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
<el-option label="本科组" value="benke" />
<el-option label="教师组" value="jiaoshi" />
</el-select>
</el-form-item>
<el-form-item label="是否置顶">
<el-switch v-model="newsForm.pinned" />
</el-form-item>
<el-form-item label="标签">
<div class="tag-inputs">
<div v-for="(tag, idx) in newsForm.tags" :key="idx" class="tag-input-row">
<el-input v-model="newsForm.tags[idx]" placeholder="输入标签" size="small" class="tag-input" @keyup.enter="addTag" />
<el-button v-if="newsForm.tags.length > 1" @click="removeTag(idx)" :icon="Delete" circle size="small" type="danger" plain />
</div>
<el-button @click="addTag" :icon="Plus" size="small" type="primary" plain>添加标签</el-button>
</div>
</el-form-item>
<el-form-item label="新闻类型">
<el-radio-group v-model="newsForm.news_type" @change="onNewsTypeChange">
<el-radio-button value="content">内容型新闻</el-radio-button>
<el-radio-button value="wx_link">微信链接型新闻</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="newsForm.news_type === 'content'" label="内容">
<RichEditor v-model="newsForm.content" height="380px" placeholder="请输入新闻内容…" />
</el-form-item>
<el-form-item v-if="newsForm.news_type === 'wx_link'" label="微信公众号链接">
<el-input v-model="newsForm.wx_link" placeholder="请输入微信公众号文章链接(https://mp.weixin.qq.com/...)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="newsDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="newsSaving" v-guard="() => saveNews()">保存</el-button>
</template>
</el-dialog>
</template>
<!-- ========== 轮播管理 ========== -->
<template v-if="activeMenu === 'carousel'">
<h2 class="section-heading">轮播管理</h2>
<div class="toolbar">
<el-button type="primary" @click="openCarouselDialog()">添加轮播</el-button>
<el-button type="primary" plain v-guard="() => saveCarouselOrder()" :disabled="adminCarousels.length < 2">保存排序</el-button>
<span class="drag-hint" v-if="adminCarousels.length > 1">
<el-icon><Rank /></el-icon> 拖拽卡片可调整排序
</span>
</div>
<div v-loading="carouselLoading" class="carousel-card-list" ref="carouselCardListRef">
<div
v-for="(item, idx) in adminCarousels"
:key="item.id"
class="carousel-card"
:class="{ 'carousel-card--disabled': !item.enabled }"
:data-id="item.id"
>
<div class="carousel-card__handle">
<el-icon><Rank /></el-icon>
</div>
<div class="carousel-card__preview">
<img v-if="item.imageUrl" :src="normalizeImageUrl(item.imageUrl)" class="carousel-thumb" />
<div v-else class="carousel-thumb carousel-thumb--gradient">渐变底图</div>
</div>
<div class="carousel-card__info">
<div class="carousel-card__title">{{ item.title || '(无标题)' }}</div>
<div class="carousel-card__meta">
<span class="carousel-card__order">排序: {{ idx + 1 }}</span>
<el-switch v-model="item.enabled" size="small" @change="toggleCarousel(item)" />
</div>
</div>
<div class="carousel-card__actions">
<el-button text type="primary" size="small" @click="openCarouselDialog(item)">编辑</el-button>
<el-button text type="danger" size="small" v-guard="() => deleteCarouselItem(item)">删除</el-button>
</div>
</div>
</div>
<!-- 轮播编辑弹窗 -->
<el-dialog
v-model="carouselDialogVisible"
:title="editingCarousel?.id ? '编辑轮播' : '添加轮播'"
width="760px"
top="6vh"
>
<el-form :model="carouselForm" label-position="top">
<el-form-item label="底图(可选)">
<el-upload
class="carousel-upload"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="onCarouselUploadSuccess"
:before-upload="beforeCarouselUpload"
accept="image/*"
>
<el-button type="primary" :loading="carouselUploading">选择图片上传</el-button>
<template #tip>
<div class="el-upload__tip">支持 PNG/JPG/JPEG/GIF/WebP,≤5MB。留空则使用深蓝渐变背景</div>
</template>
</el-upload>
<div v-if="carouselForm.image_url" class="carousel-form-preview">
<img :src="normalizeImageUrl(carouselForm.image_url)" alt="底图预览" />
<el-button text type="danger" size="small" @click="carouselForm.image_url = ''">清除底图</el-button>
</div>
</el-form-item>
<el-form-item label="底图 URL">
<el-input v-model="carouselForm.image_url" placeholder="上传后自动填充,也可手动输入;留空使用渐变背景" clearable />
</el-form-item>
<el-form-item label="标题(支持简单 HTML,如 &lt;br&gt; 换行)">
<el-input
v-model="carouselForm.title"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder="轮播主标题,可用 <br> 换行"
/>
</el-form-item>
<el-form-item label="正文(富文本)">
<RichEditor v-model="carouselForm.content" height="300px" placeholder="请输入轮播正文内容…" />
</el-form-item>
<el-form-item label="按钮文字(留空则不显示按钮)">
<el-input v-model="carouselForm.button_text" placeholder="如:了解更多 / 立即报名" clearable />
</el-form-item>
<el-form-item label="按钮链接">
<el-radio-group v-model="carouselForm.link_mode" style="margin-bottom: 10px;">
<el-radio-button label="internal">站内页面</el-radio-button>
<el-radio-button label="external">外部链接</el-radio-button>
</el-radio-group>
<el-select
v-if="carouselForm.link_mode === 'internal'"
v-model="carouselForm.button_link"
placeholder="选择跳转的站内页面"
style="width: 100%;"
clearable
>
<el-option
v-for="opt in internalLinkOptions"
:key="opt.value"
:label="`${opt.label}(${opt.value})`"
:value="opt.value"
/>
</el-select>
<el-input
v-else
v-model="carouselForm.button_link"
placeholder="请输入完整外链,如 https://example.com"
clearable
/>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="carouselForm.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="carouselDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="carouselSaving" v-guard="() => saveCarousel()">保存</el-button>
</template>
</el-dialog>
</template>
</div>
</div>
</div>
</template>
<script setup>
/**
* Admin.vue - 管理后台(需要管理员权限)
*
* 功能模块:
* 1. 数据总览 - 统计卡片 + 分组统计
* 2. 选手管理 - 列表/搜索/筛选/分页/Excel导入
* 3. 试卷管理 - 客观题 CRUD(选择/判断);综合大题题库 CRUD + 作答查证/评分
* 4. 作品管理 - 下载/评分/批量保存
* 5. 时间设置 - 各组别时间配置
* 6. 成绩管理 - 发布/撤回/导出/批量下载
* 7. 新闻管理 - CRUD + 置顶/分组
* 8. 轮播管理 - 添加/删除/排序/启用
*/
import { ref, reactive, onMounted, watch, computed, nextTick, defineAsyncComponent, h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
DataAnalysis, User, EditPen, FolderOpened,
Clock, Trophy, Notebook, PictureFilled, Rank, Files, Plus, Delete, Search
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as adminApi from '@/api/admin'
import * as newsApi from '@/api/news'
import * as carouselApi from '@/api/carousel'
import { formatFileSize, normalizeImageUrl } from '@/utils/upload'
import { safeDownload, fetchBlobGuarded } from '@/utils/download'
import { sanitizeHtml, isRichTextEmpty } from '@/utils/richtext'
// 🔴 富文本编辑器**异步加载**(2026-09-29 R41 · BACKLOG C6):
// RichEditor.vue 静态 import 了 @wangeditor/editor(dist 主文件 818,777 B ≈ 800 kB),
// 此前经静态 import 全量并进 Admin chunk ⇒ Admin-*.js 达 1021 kB(gzip 340 kB)。
// 实测三处使用点(446 大题题干 / 1384 新闻 / 1476 轮播)**全部在 el-dialog 内**,
// 而 el-dialog 初始 rendered=false 不渲染内容 ⇒ 进入管理端时本编辑器根本用不到。
// 改为异步后 wangeditor 被剥离为独立 chunk,仅在首次打开这三个弹窗时才按需下载。
// 注意:loadingComponent 用 render 函数而非 template 字符串 —— 本工程为 runtime-only
// 构建,字符串模板需要运行时编译器,会直接报错。
const RichEditor = defineAsyncComponent({
loader: () => import('@/components/RichEditor.vue'),
loadingComponent: {
render: () => h('div', { style: 'padding:24px;color:#909399;font-size:13px;text-align:center' }, '富文本编辑器加载中…'),
},
delay: 120,
})
const authStore = useAuthStore()
// 轮播按钮"站内页面"可选项(公开首页按钮的常用跳转目标)
const internalLinkOptions = [
{ label: '激活账号', value: '/activate' },
{ label: '选手登录', value: '/login' },
{ label: '赛事信息', value: '/competition' },
{ label: '资料下载', value: '/materials' },
{ label: '成绩查询', value: '/score' },
{ label: '返回首页', value: '/' },
]
// ==================== 导航 ====================
const activeMenu = ref(sessionStorage.getItem('admin_activeMenu') || 'dashboard')
function onMenuSelect(index) {
activeMenu.value = index
sessionStorage.setItem('admin_activeMenu', index)
// 切换时加载对应数据
if (index === 'dashboard') fetchStats()
if (index === 'accounts') fetchAccounts()
if (index === 'questions' && questionGroup.value) {
// R29:试卷管理只刷题库(含大题库);「考生答题记录 / 大题作答查证」已迁到成绩管理
refreshQuestionData(true)
}
if (index === 'materials') fetchAdminMaterials()
if (index === 'submissions' && submissionGroup.value) fetchSubmissions()
if (index === 'times') fetchTimes()
if (index === 'scores') {
fetchPublishStatus()
fetchScoreList()
// R29:成绩管理还承载两个由试卷管理迁入的板块(仅中职/高职)
refreshExamCtxData(true)
}
if (index === 'news') fetchAdminNews()
if (index === 'carousel') fetchCarousels()
}
function handleLogout() {
ElMessageBox.confirm('确定要退出吗?', '提示', { type: 'warning' }).then(() => {
authStore.logout()
}).catch(() => {})
}
// ==================== 工具函数 ====================
function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
if (isNaN(d.getTime())) return s
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
}
function fmtDateOnly(d) {
if (!d) return ''
const dt = new Date(d)
if (isNaN(dt.getTime())) return d
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
}
function formatSize(b) { return formatFileSize(b || 0) }
const groupMap = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
// ==================== 1. 数据总览 ====================
const overviewStats = ref([
{ label: '总账号数', value: '--' },
{ label: '已激活', value: '--' },
{ label: '作品提交', value: '--' },
{ label: '激活率', value: '--' },
])
const groupStats = ref([])
// 统计口径:true=仅生产数据(排除测试数据);false=含测试数据(默认)
const statsOnlyProduction = ref(false)
// Excel 导入时是否标记为测试数据(默认勾选,便于一键重置、隔离生产作用域)
const importAsTest = ref(true)
// 默认口令提示(仅用于前端文案展示,真实值由后端 TEST_DEFAULT_PASSWORD 控制)
const testDefaultPasswordHint = ref(import.meta.env.VITE_TEST_DEFAULT_PASSWORD || '123456')
async function fetchStats() {
try {
const data = await adminApi.getStats({ excludeTest: statsOnlyProduction.value })
if (data) {
// 后端返回 { groups: { zhongzhi: {totalAccounts, activatedAccounts, submittedCount, submissionRate}, ... }, totalAccounts, activatedCount, submissionsCount, activationRate }
const groups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi']
const groupData = data.groups || {}
groupStats.value = groups.map(g => {
const s = groupData[g] || { totalAccounts: 0, activatedAccounts: 0, submittedCount: 0, submissionRate: '0' }
return {
group: g,
name: groupMap[g] || g,
color: { zhongzhi: '#3b82f6', gaozhi: '#10b981', benke: '#f59e0b', jiaoshi: '#ef4444' }[g],
total: s.totalAccounts || 0,
activated: s.activatedAccounts || 0,
submitted: s.submittedCount || 0,
}
})
overviewStats.value = [
{ label: '总账号数', value: data.totalAccounts || 0 },
{ label: '已激活', value: data.activatedCount || 0 },
{ label: '作品提交', value: data.submissionsCount || 0 },
{ label: '激活率', value: (data.totalAccounts > 0 ? (data.activatedCount / data.totalAccounts * 100).toFixed(1) : '0') + '%' },
]
}
} catch (e) { console.warn('获取统计失败', e) }
}
// ==================== 测试数据标记与重置 ====================
const resetLoading = ref(false)
async function handleToggleTestFlag(row, val) {
try {
await adminApi.toggleTestFlag(row.uid, val)
const label = val === 1 ? '测试数据' : '生产数据'
ElMessage.success(`选手 ${row.name} 已标记为${label}`)
} catch (e) {
// 回滚开关状态
row.testFlag = val === 1 ? 0 : 1
ElMessage.error('标记失败:' + (e.message || '未知错误'))
}
}
async function handleResetTestData() {
resetLoading.value = true
try {
const data = await adminApi.resetTestData()
if (data.testContestantsFound === 0) {
ElMessage.warning('没有找到测试数据,请先在选手管理中将选手标记为"测试"')
} else {
ElMessage.success(`测试数据已重置:${data.contestantsReset} 名选手,${data.examSubmissionsDeleted + data.workSubmissionsDeleted} 条提交记录`)
}
// 刷新统计数据
await fetchStats()
} catch (e) {
ElMessage.error('重置失败:' + (e.message || '未知错误'))
} finally {
resetLoading.value = false
}
}
// ==================== 2. 选手管理 ====================
const accounts = ref([])
const accountsLoading = ref(false)
const accountsPage = ref(1)
const accountsPageSize = ref(20)
const accountsTotal = ref(0)
const accountSearch = ref('')
const accountGroupFilter = ref('')
const accountActivatedFilter = ref('')
const accountDateRange = ref(null)
async function fetchAccounts() {
accountsLoading.value = true
try {
const params = { page: accountsPage.value, pageSize: accountsPageSize.value }
if (accountSearch.value) params.keyword = accountSearch.value
if (accountGroupFilter.value) params.group = accountGroupFilter.value
if (accountActivatedFilter.value !== '') params.activated = accountActivatedFilter.value
if (accountDateRange.value && accountDateRange.value.length === 2) {
params.startDate = fmtDateOnly(accountDateRange.value[0])
params.endDate = fmtDateOnly(accountDateRange.value[1])
}
const data = await adminApi.getAccounts(params)
if (data?.list) {
accounts.value = data.list.map(item => ({
...item,
idNumber: item.id_number,
testFlag: item.test_flag,
createdAt: item.created_at,
}))
accountsTotal.value = data.total || 0
} else if (Array.isArray(data)) {
accounts.value = data
accountsTotal.value = data.length
}
} catch (e) { console.warn('获取账号列表失败', e) } finally { accountsLoading.value = false }
}
async function onExcelImport(file) {
try {
const formData = new FormData()
// Egg stream 模式要求「文本字段在文件字段之前」,否则 testFlag 被丢弃,
// 导致「标记为测试数据」勾选失效、测试数据被当成生产数据导入(test_flag=0)。
formData.append('testFlag', importAsTest.value ? '1' : '0')
formData.append('file', file.raw)
// 响应拦截器已解包,这里拿到的是后端返回的导入结果 {imported, skipped, errors}
const result = await adminApi.importExcel(formData)
const imported = (result && result.imported) || 0
const skipped = (result && result.skipped) || 0
// 非参赛选手行(报名人/指导教师/领队)按业务规则主动忽略,与「跳过」分开计数并展示
const ignored = (result && result.ignored) || 0
if (ignored && result.ignoredBy) {
console.info('按身份忽略明细:', result.ignoredBy)
}
if (imported === 0 && skipped > 0) {
const detail = ((result && result.errors) || []).slice(0, 3)
.map(e => `第${e.row}行:${e.message}`).join(';')
ElMessage.warning(`导入完成但 0 条成功、跳过 ${skipped} 条。原因:${detail || '见控制台'}`)
} else {
ElMessage.success(`Excel 导入成功:成功 ${imported} 条${importAsTest.value ? '(已标记为测试数据)' : ''}${skipped ? `,跳过 ${skipped} 条` : ''}${ignored ? `,忽略非参赛选手 ${ignored} 条` : ''}`)
}
accountsPage.value = 1
fetchAccounts()
} catch (e) {
const msg = (e && e.response && e.response.data && e.response.data.message)
|| (e && e.message) || '未知错误'
ElMessage.error('Excel 导入失败:' + msg)
}
}
function onAccountFilterChange() {
accountsPage.value = 1
fetchAccounts()
}
function onAccountSizeChange() {
accountsPage.value = 1
fetchAccounts()
}
// ==================== 3. 试卷管理 ====================
const choiceQuestions = ref([])
const judgeQuestions = ref([])
const questionsLoading = ref(false)
const questionGroup = ref('zhongzhi')
const activeQuestionType = ref('choice')
const editingQuestionType = ref('choice')
const questionSearch = ref('')
// 分页
const choicePage = ref(1)
const choicePageSize = ref(10)
const judgePage = ref(1)
const judgePageSize = ref(10)
const questionDialogVisible = ref(false)
const questionSaving = ref(false)
const editingQuestion = ref(null)
/** 客观题(选择/判断)每小题固定分值 —— 与后端 service/admin.js 的 OBJECTIVE_SCORE_PER_QUESTION 同源 */
const OBJECTIVE_SCORE = 1
const questionForm = reactive({ group: 'zhongzhi', content: '', score: OBJECTIVE_SCORE, optionA: '', optionB: '', optionC: '', optionD: '', answer: '', description: '' })
const questionTypeLabel = computed(() => {
const map = { choice: '选择题', judge: '判断题', big: '综合大题' }
return map[activeQuestionType.value] || ''
})
const filteredQuestions = computed(() => {
// 综合大题走独立的 bigQuestions 链路(不同接口与表),不参与客观题筛选
const map = { choice: choiceQuestions, judge: judgeQuestions }
const list = map[activeQuestionType.value]?.value || []
if (!questionSearch.value) return list
const s = questionSearch.value.trim().toLowerCase()
return list.filter(item => (item.question || '').toLowerCase().includes(s))
})
const currentQuestions = computed(() => {
const list = filteredQuestions.value
const page = activeQuestionType.value === 'choice' ? choicePage.value : judgePage.value
const pageSize = activeQuestionType.value === 'choice' ? choicePageSize.value : judgePageSize.value
const start = (page - 1) * pageSize
const end = start + pageSize
return list.slice(start, end)
})
const dialogTitle = computed(() => {
const prefix = editingQuestion.value?.id ? '编辑' : '添加'
return prefix + questionTypeLabel.value
})
// ==================== 3.1 综合大题(题库 + 作答查证) ====================
const bigQuestions = ref([])
const bigQuestionsLoading = ref(false)
const bigSearch = ref('')
const bigPage = ref(1)
const bigPageSize = ref(10)
/** 三小问数量与每小题分值(由后端下发,避免前端硬编码与后端不一致) */
const bigSubCount = ref(3)
const bigScorePerSub = ref(10)
// 新增/编辑弹窗
const bigDialogVisible = ref(false)
const bigEditingId = ref(null)
const bigSaving = ref(false)
const bigForm = reactive({
title: '',
description: '',
subPrompt1: '',
subPrompt2: '',
subPrompt3: '',
scorePerSub: 10,
sortOrder: 0,
status: 1,
})
// 预览弹窗
const bigPreviewVisible = ref(false)
const bigPreviewData = reactive({ title: '', description: '', subPrompts: [] })
// 作答查证
const bigAnswers = ref([])
const bigAnswersLoading = ref(false)
const bigAnswersTotal = ref(0)
const bigAnswersDrawn = ref(0)
const bigAnswerSearch = ref('')
const bigAnswerFilter = ref('all')
const bigAnswerDialogVisible = ref(false)
const bigAnswerRow = ref({})
const bigScoreSaving = ref(false)
const bigScoreForm = reactive({ score_1: 0, score_2: 0, score_3: 0 })
const filteredBigQuestions = computed(() => {
const list = bigQuestions.value
if (!bigSearch.value) return list
const s = bigSearch.value.trim().toLowerCase()
return list.filter(item => {
return (item.title || '').toLowerCase().includes(s)
|| stripHtml(item.description || '').toLowerCase().includes(s)
})
})
const pagedBigQuestions = computed(() => {
const start = (bigPage.value - 1) * bigPageSize.value
return filteredBigQuestions.value.slice(start, start + bigPageSize.value)
})
const filteredBigAnswers = computed(() => {
let list = bigAnswers.value
const s = bigAnswerSearch.value.trim().toLowerCase()
if (s) {
list = list.filter(r => (r.name || '').toLowerCase().includes(s)
|| (r.idNumber || '').toLowerCase().includes(s))
}
if (bigAnswerFilter.value === 'drawn') list = list.filter(r => r.drawn)
else if (bigAnswerFilter.value === 'undrawn') list = list.filter(r => !r.drawn)
return list
})
/** 详情弹窗内该考生的小问提示:优先用抽题快照(防管理员改题干后口径不一致) */
const bigSubPromptsOfRow = computed(() => {
const snap = bigAnswerRow.value && bigAnswerRow.value.questionSnapshot
const prompts = (snap && snap.sub_prompts) || []
const out = []
for (let i = 0; i < bigSubCount.value; i++) out.push(prompts[i] || '')
return out
})
/** 详情弹窗内该考生的每小题满分:优先用快照分值,回退题库配置 */
const bigRowScorePerSub = computed(() => {
const snap = bigAnswerRow.value && bigAnswerRow.value.questionSnapshot
const v = snap && Number(snap.score_per_sub)
return v > 0 ? v : bigScorePerSub.value
})
/**
* 分数显示格式化(2026-09-28):人工评分精确到小数点后**两位**。
* 整数不带小数点(27 而非 27.00),非整数最多两位(27.4 / 8.25);空/非法值回 '--'。
*/
function fmtScore(v) {
if (v === null || v === undefined || v === '') return '--'
const n = Number(v)
if (!Number.isFinite(n)) return '--'
return Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100)
}
/**
* R30:该考生各小问得分数组(按 bigSubCount 顺序)。
*
* 数据源 = 后端 listAnswersForAdmin 返回的 score1 / score2 / score3(未评分时为 null)。
* ⇒ 仅是「把已有数据显示出来」,**不需要任何后端改动**。
*/
function subScoresOf(row) {
const out = []
for (let i = 1; i <= bigSubCount.value; i++) out.push(row ? row[`score${i}`] : null)
return out
}
/**
* 是否已有任一小问得分。
* 用于区分「尚未评分」(false) 与「三小问都给 0 分」(true) —— 二者不能都显示成 0 分。
*/
function hasSubScores(row) {
return subScoresOf(row).some(v => v !== null && v !== undefined && v !== '')
}
/** 「8 / 9.5 / 9.5」形式的小问得分展示(某一小问未给分则该项显示 '--') */
function fmtSubScores(row) {
return subScoresOf(row).map(v => fmtScore(v)).join(' / ')
}
const bigScoreTotal = computed(() => {
let sum = 0
for (let i = 1; i <= bigSubCount.value; i++) {
sum += Number(bigScoreForm[`score_${i}`]) || 0
}
// 浮点求和收敛到两位小数,避免 0.1+0.2=0.30000000000000004 之类的脏值显示
return Math.round(sum * 100) / 100
})
/** 剥标签取纯文本(列表内搜索用,不用于渲染) */
function stripHtml(html) {
return String(html == null ? '' : html).replace(/<[^>]*>/g, '')
}
/** 富文本渲染前的白名单消毒(与考生端同一套实现,避免两端口径不一致) */
function previewHtml(html) {
return sanitizeHtml(html)
}
/** 后端时间(ISO / "YYYY-MM-DD HH:mm:ss")统一格式化为本地可读形式 */
function formatDateTime(v) {
if (!v) return '--'
const d = new Date(v)
if (isNaN(d.getTime())) return String(v)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function fetchBigQuestions() {
// 固定「发起请求时的组别」:切组瞬间的旧响应回来时不得写回,否则会串组显示别组题目
const g = questionGroup.value
bigQuestionsLoading.value = true
try {
const data = await adminApi.getBigQuestions(g)
const result = data?.data || data
if (questionGroup.value !== g) return
bigQuestions.value = result?.list || []
if (Number(result?.subCount) > 0) bigSubCount.value = Number(result.subCount)
if (Number(result?.scorePerSub) > 0) bigScorePerSub.value = Number(result.scorePerSub)
// 当前页超出范围时回退(删除后避免停留在空页)
const maxPage = Math.max(1, Math.ceil(bigQuestions.value.length / bigPageSize.value))
if (bigPage.value > maxPage) bigPage.value = maxPage
} catch (e) {
console.warn('获取综合大题失败', e)
if (questionGroup.value === g) bigQuestions.value = []
} finally {
bigQuestionsLoading.value = false
}
}
function resetBigForm() {
bigForm.title = ''
bigForm.description = ''
for (let i = 1; i <= 3; i++) bigForm[`subPrompt${i}`] = ''
bigForm.scorePerSub = bigScorePerSub.value || 10
bigForm.sortOrder = 0
bigForm.status = 1
}
function openBigDialog(row = null) {
bigEditingId.value = row ? row.id : null
resetBigForm()
if (row) {
bigForm.title = row.title || ''
bigForm.description = row.description || ''
bigForm.subPrompt1 = row.subPrompt1 || ''
bigForm.subPrompt2 = row.subPrompt2 || ''
bigForm.subPrompt3 = row.subPrompt3 || ''
bigForm.scorePerSub = Number(row.scorePerSub) || bigScorePerSub.value
bigForm.sortOrder = Number(row.sortOrder) || 0
bigForm.status = Number(row.status) ? 1 : 0
}
bigDialogVisible.value = true
}
async function saveBigQuestion() {
if (!String(bigForm.title || '').trim()) {
ElMessage.warning('请填写大题标题')
return
}
if (isRichTextEmpty(bigForm.description)) {
ElMessage.warning('请填写题干内容')
return
}
const payload = {
title: bigForm.title.trim(),
description: bigForm.description,
subPrompt1: bigForm.subPrompt1,
subPrompt2: bigForm.subPrompt2,
subPrompt3: bigForm.subPrompt3,
scorePerSub: bigForm.scorePerSub,
sortOrder: bigForm.sortOrder,
status: bigForm.status,
}
bigSaving.value = true
try {
if (bigEditingId.value) {
await adminApi.updateBigQuestion(questionGroup.value, bigEditingId.value, payload)
ElMessage.success('大题已更新(已抽到该题的考生仍显示其抽题快照,不受影响)')
} else {
await adminApi.createBigQuestion(questionGroup.value, payload)
ElMessage.success('大题已新增')
}
bigDialogVisible.value = false
await fetchBigQuestions()
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
bigSaving.value = false
}
}
async function deleteBigQuestion(row) {
try {
await ElMessageBox.confirm(
`确定删除大题「${row.title}」?已被考生抽取过的题目无法删除。`,
'确认删除',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
)
} catch (e) {
return
}
try {
await adminApi.deleteBigQuestion(questionGroup.value, row.id)
ElMessage.success('大题已删除')
await fetchBigQuestions()
// 已删除的行可能仍被勾选(reserve-selection 会保留),导出前清掉避免请求到已删 ID
clearBigSelection()
} catch (e) {
// 409:该题已被抽取,服务端拒绝删除;给出可执行的处理建议
ElMessage.error(e?.message || '删除失败')
}
}
function openBigPreview(row) {
bigPreviewData.title = row.title || ''
bigPreviewData.description = row.description || ''
bigPreviewData.subPrompts = [row.subPrompt1 || '', row.subPrompt2 || '', row.subPrompt3 || '']
bigPreviewVisible.value = true
}
/** 阅卷导出(R28):选中态 + 导出中态 + 表格引用 */
const bigAnswersTableRef = ref(null)
const bigAnswerSelectedIds = ref([])
const bigAnswersExporting = ref(false)
function onBigAnswerSelectionChange(rows) {
bigAnswerSelectedIds.value = (rows || [])
.map(r => Number(r && r.contestantId))
.filter(v => Number.isInteger(v) && v > 0)
}
/**
* 导出考生**大题答卷**(试题快照 + 考生作答 + 逐问得分)为 Word(R28 测试人员需求)。
* · 传数组 ⇒ 导出选中考生;不传 ⇒ 导出该组**全部已抽题**考生;
* · 后端按「每位考生一个独立 .docx」生成,命中多位时打包为 .zip
* (⛔ 不是把所有考生塞进同一个 Word —— 那样无法分别打印 / 分发 / 归档);
* · 命中 1 位 ⇒ 直接下载 .docx。
* ⚠️ 与同页「导出选中/全部 Word」(exportBigQuestions,导出的是**题库题目**)是两个功能,勿混。
*/
async function exportBigAnswers(ids) {
// R29:本板块已迁入「成绩管理」⇒ 组别跟随成绩页
const g = scoreExportGroup.value
const groupLabel = g === 'zhongzhi' ? '中职组' : '高职组'
const isAll = !Array.isArray(ids)
// 兜底:剔除非本组 / 未抽题的残留 ID(与 exportBigQuestions 同源处理)。
// 若不区分「全部」与「选中」,选中项全失效时会静默退化成导出全部,与用户意图相反。
const available = new Set(
bigAnswers.value.filter(r => r && r.drawn).map(r => Number(r.contestantId))
)
const list = (Array.isArray(ids) ? ids : [])
.map(v => Number(v))
.filter(v => Number.isInteger(v) && v > 0 && available.has(v))
if (!isAll && list.length === 0) {
ElMessage.warning('选中的考生未抽题或已失效,请刷新后重试')
return
}
const qs = list.length ? `?ids=${list.join(',')}` : ''
bigAnswersExporting.value = true
try {
await safeDownload(`/api/admin/big-answers/${g}/export${qs}`, {
fallbackName: list.length === 1
? `${groupLabel}_大题答卷.docx`
: `${groupLabel}_大题答卷.zip`,
onSuccess: name => ElMessage.success(`已开始下载:${name}`),
})
} finally {
bigAnswersExporting.value = false
}
}
async function fetchBigAnswers() {
// R29:本板块已由「试卷管理」迁到「成绩管理」⇒ 组别跟随成绩页的 scoreExportGroup(中职/高职)。
// 仍固定发起时的组别,防止切组竞态把别组数据写回。
const g = scoreExportGroup.value
bigAnswersLoading.value = true
try {
const data = await adminApi.getBigAnswers(g)
const result = data?.data || data
if (scoreExportGroup.value !== g) return
bigAnswers.value = result?.list || []
bigAnswersTotal.value = Number(result?.total) || bigAnswers.value.length
bigAnswersDrawn.value = Number(result?.drawnCount) || 0
if (Number(result?.subCount) > 0) bigSubCount.value = Number(result.subCount)
} catch (e) {
console.warn('获取大题作答失败', e)
if (scoreExportGroup.value !== g) return
bigAnswers.value = []
bigAnswersTotal.value = 0
bigAnswersDrawn.value = 0
} finally {
bigAnswersLoading.value = false
}
}
/**
* 「大题库 / 大题作答」数据当前所属组别(2026-09-23)。
*
* 修复「切中职/高职后必须刷新页面才显示新数据」:
* 原实现用 `bigQuestions.length === 0` 作为「要不要拉」的判据,但切组时
* 上一组的数据仍在数组里(长度 ≠ 0)⇒ 永远不会重新拉取。
* 改为按「数据属于哪个组」判定,而不是按「数组是否为空」。
*/
const bigDataGroup = ref('')
/** 保证大题库与当前组别一致(同组且非强制时不重复请求) */
async function ensureBigData(force = false) {
const g = questionGroup.value
if (!force && bigDataGroup.value === g && bigQuestions.value.length > 0) return
await fetchBigQuestions()
if (questionGroup.value === g) bigDataGroup.value = g
}
/**
* 刷新「试卷管理」菜单下的数据(R29 收敛)。
*
* ⚠️ R29 起「考生答题记录 / 大题作答查证」已迁到「成绩管理」,故此处**只刷题库**
* (含大题库);那两块改由 refreshExamCtxData 负责 —— 原来一个入口刷三块,
* 迁走后若仍在这里刷,就会用**试卷页的组别**去拉**成绩页的板块**,必然串组。
*/
async function refreshQuestionData(force = false) {
await Promise.allSettled([fetchQuestions(), ensureBigData(force)])
}
/** 「考生答题记录 / 大题作答查证」数据当前所属组别(用于避免重复拉取) */
const examCtxDataGroup = ref('')
/**
* 刷新成绩管理页内两个由试卷管理迁入的板块(R29)。
* 仅中职/高职有客观题与综合大题;本科/教师是 Word/PPT 作品,无此二块 ⇒ 直接跳过。
*/
async function refreshExamCtxData(force = false) {
const g = scoreExportGroup.value
if (g !== 'zhongzhi' && g !== 'gaozhi') return
if (!force && examCtxDataGroup.value === g) return
await Promise.allSettled([fetchExamSubmissions(), fetchBigAnswers()])
if (scoreExportGroup.value === g) examCtxDataGroup.value = g
}
/**
* 刷新「成绩管理」页全部数据(R29 收敛):成绩明细 + 两个迁入板块。
* 评分保存后必须走这里,否则明细表与答题记录会出现「一处已更新、一处还是旧分」。
*/
async function refreshScorePageData(force = false) {
await Promise.allSettled([fetchScoreList(), refreshExamCtxData(force)])
}
/** ==================== 大题导出 Word(2026-09-23) ==================== */
const bigTableRef = ref(null)
/** 当前勾选的大题 ID(跨页保留;切组时必须清空 —— ID 是组内作用域) */
const bigSelectedIds = ref([])
const bigExporting = ref(false)
function onBigSelectionChange(rows) {
bigSelectedIds.value = (rows || [])
.map(r => Number(r && r.id))
.filter(v => Number.isInteger(v) && v > 0)
}
/** 清空勾选(同时清 el-table 内部状态,否则切组后旧勾选仍显示为选中) */
function clearBigSelection() {
const t = bigTableRef.value
if (t && typeof t.clearSelection === 'function') t.clearSelection()
bigSelectedIds.value = []
}
/**
* 导出大题为 Word。
* · 不传 ids ⇒ 导出该组**全部**
* · 传数组 ⇒ 只导出数组内、且确属当前组别的题目(陈旧 ID 被过滤);
* 数组为空或全部失效 ⇒ 提示而不导出(⛔ 不可退化成「导出全部」)
* · 1 道 ⇒ 直接 .docx;多道 ⇒ .zip(内含每道题一个 .docx)
* 文件名由后端 Content-Disposition 下发(RFC5987 已由 koa 处理),此处只给兜底名。
*/
async function exportBigQuestions(ids) {
const groupLabel = questionGroup.value === 'zhongzhi' ? '中职组' : '高职组'
// 区分「导出全部」与「导出选中」:前者不传参,后者传数组。
// 若不区分,则「选中的题全被过滤掉」时 qs 为空 ⇒ 静默退化成导出全部,与用户意图相反。
const isAll = !Array.isArray(ids)
// 兜底:把「不属于当前组别列表」的残留 ID 过滤掉(删除后前端的勾选可能滞后一拍)
const available = new Set(bigQuestions.value.map(q => Number(q.id)))
const idsArr = Array.isArray(ids) ? ids : []
const list = idsArr
.map(v => Number(v))
.filter(v => Number.isInteger(v) && v > 0 && available.has(v))
if (!isAll && list.length === 0) {
ElMessage.warning('选中的题目已失效(可能已被删除),请刷新后重试')
return
}
const qs = list.length ? `?ids=${list.join(',')}` : ''
bigExporting.value = true
try {
await safeDownload(`/api/admin/big-questions/${questionGroup.value}/export${qs}`, {
fallbackName: list.length === 1
? `${groupLabel}_综合大题.docx`
: `${groupLabel}_综合大题.zip`,
onSuccess: name => ElMessage.success(`已开始下载:${name}`),
})
} finally {
bigExporting.value = false
}
}
/** 详情弹窗内第 i 小问的考生作答原文(空值统一为 '') */
function bigAnswerTextOf(i) {
const v = bigAnswerRow.value['answer' + i]
return v == null ? '' : String(v)
}
function openBigAnswerDetail(row) {
bigAnswerRow.value = row || {}
for (let i = 1; i <= bigSubCount.value; i++) {
const v = row && row[`score${i}`]
bigScoreForm[`score_${i}`] = v == null ? 0 : Number(v)
}
bigAnswerDialogVisible.value = true
}
async function saveBigScores() {
const row = bigAnswerRow.value
if (!row || !row.contestantId) return
// 逐问不能超过该题满分:前端先拦一次,减少无效请求(后端仍会再校验)
for (let i = 1; i <= bigSubCount.value; i++) {
const v = Number(bigScoreForm[`score_${i}`]) || 0
if (v < 0 || v > bigRowScorePerSub.value) {
ElMessage.error(`第 ${i} 小问得分须在 0 ~ ${bigRowScorePerSub.value} 之间`)
return
}
}
bigScoreSaving.value = true
try {
// R29:组别跟随「成绩管理」页(本弹窗已随板块迁入成绩管理)
const res = await adminApi.setBigAnswerScores(scoreExportGroup.value, row.aggregationUid, {
score_1: Number(bigScoreForm.score_1) || 0,
score_2: Number(bigScoreForm.score_2) || 0,
score_3: Number(bigScoreForm.score_3) || 0,
})
ElMessage.success(`评分已保存(大题合计 ${res?.total ?? bigScoreTotal.value} 分)`)
bigAnswerDialogVisible.value = false
// 一次刷新全部相关列表:成绩明细(总分 = 客观分 + 大题分)+ 大题作答 + 考生答题记录。
// 原实现只调 fetchBigAnswers(),测试人员看到另一处仍是旧分只能刷整页。
await refreshScorePageData(true)
} catch (e) {
ElMessage.error(e?.message || '评分保存失败')
} finally {
bigScoreSaving.value = false
}
}
function onQuestionGroupChange() {
// 仅重置分页与勾选;数据刷新统一交给 watch(questionGroup),避免两处各刷一半造成遗漏
choicePage.value = 1
judgePage.value = 1
bigPage.value = 1
clearBigSelection()
}
function onQuestionTypeChange(name) {
// 切到综合大题时按「该数据属于哪个组」判定是否拉取
// (原按 bigQuestions.length === 0 判定:切组后旧数据仍在数组里 ⇒ 永不刷新)
if (name === 'big') ensureBigData()
}
function onQuestionSearchChange() {
if (activeQuestionType.value === 'choice') choicePage.value = 1
else if (activeQuestionType.value === 'judge') judgePage.value = 1
}
async function fetchQuestions() {
questionsLoading.value = true
try {
const data = await adminApi.getQuestions(questionGroup.value)
// 后端返回 { choice: [...], judge: [...] } 或 { code: 0, data: { choice: [...], ... } }
const result = data?.data || data
choiceQuestions.value = result?.choice || []
judgeQuestions.value = result?.judge || []
} catch (e) {
console.warn('获取题目失败', e)
choiceQuestions.value = []
judgeQuestions.value = []
} finally { questionsLoading.value = false }
}
function openQuestionDialog(row = null, qType = activeQuestionType.value) {
editingQuestion.value = row
editingQuestionType.value = qType
if (row) {
questionForm.group = questionGroup.value
questionForm.score = OBJECTIVE_SCORE
if (qType === 'choice') {
questionForm.content = row.question || ''
const opts = Array.isArray(row.options) ? row.options : []
questionForm.optionA = (opts[0] && opts[0].text) || ''
questionForm.optionB = (opts[1] && opts[1].text) || ''
questionForm.optionC = (opts[2] && opts[2].text) || ''
questionForm.optionD = (opts[3] && opts[3].text) || ''
questionForm.answer = row.answer || ''
} else {
questionForm.content = row.question || ''
questionForm.answer = row.answer || 'false'
}
} else {
questionForm.group = questionGroup.value
questionForm.content = ''
questionForm.score = OBJECTIVE_SCORE
questionForm.optionA = ''
questionForm.optionB = ''
questionForm.optionC = ''
questionForm.optionD = ''
questionForm.answer = qType === 'judge' ? 'false' : ''
questionForm.description = ''
}
questionDialogVisible.value = true
}
async function saveQuestion() {
questionSaving.value = true
try {
const qType = editingQuestionType.value
const payload = {
score: questionForm.score,
answer: questionForm.answer,
}
if (qType === 'choice') {
payload.question = questionForm.content
payload.options = [
{ key: 'A', text: questionForm.optionA },
{ key: 'B', text: questionForm.optionB },
{ key: 'C', text: questionForm.optionC },
{ key: 'D', text: questionForm.optionD },
].filter(o => o.text)
} else {
payload.question = questionForm.content
}
const action = editingQuestion.value?.id ? 'update' : 'add'
const questionData = editingQuestion.value?.id
? { ...payload, question_uid: editingQuestion.value.id }
: payload
await adminApi.setQuestions(questionForm.group, qType, {
questions: [questionData],
action,
})
ElMessage.success('保存成功')
questionDialogVisible.value = false
fetchQuestions()
} catch (e) { ElMessage.error('保存失败') } finally { questionSaving.value = false }
}
async function deleteQuestion(row, qType) {
try {
await ElMessageBox.confirm('确定删除该题目?', '确认', { type: 'warning' })
await adminApi.setQuestions(questionGroup.value, qType, {
questions: [{ question_uid: row.id }],
action: 'delete',
})
ElMessage.success('删除成功')
fetchQuestions()
} catch (e) { /* 取消 */ }
}
// 试卷管理:批量删除题目
const selectedQuestionRows = ref([])
function onQuestionSelectionChange(rows) {
selectedQuestionRows.value = rows
}
async function batchDeleteSelectedQuestions() {
const rows = selectedQuestionRows.value
if (!rows.length) return
const qType = activeQuestionType.value
const ids = rows.map(r => r.id)
try {
const label = qType === 'choice' ? '选择' : '判断'
await ElMessageBox.confirm(`确定批量删除选中的 ${ids.length} 道${label}题?`, '确认批量删除', { type: 'warning' })
await adminApi.bulkDeleteQuestions(questionGroup.value, qType, ids)
ElMessage.success(`已删除 ${ids.length} 道题目`)
selectedQuestionRows.value = []
fetchQuestions()
} catch (e) { /* 取消 */ }
}
// ==================== 客观题 Excel 导入 / 模板 ====================
// 注:原「批量设分」已移除 —— 客观题分值口径统一为 1 分,不再可设。
const excelFileInput = ref(null)
// 客观题总分预览:当前组选择题 + 判断题分值之和
const objectiveTotalScore = computed(() => {
const sum = (arr) => (arr || []).reduce((s, q) => s + (Number(q.score) || 0), 0)
return sum(choiceQuestions.value) + sum(judgeQuestions.value)
})
function triggerExcelImport() {
if (excelFileInput.value) excelFileInput.value.click()
}
async function onExcelFileChange(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
const formData = new FormData()
formData.append('file', file)
try {
const res = await adminApi.importQuestionsExcel(questionGroup.value, formData)
const r = res || {}
ElMessage.success(`导入完成:成功 ${r.imported || 0} 条,跳过 ${r.skipped || 0} 条`)
if (r.errors && r.errors.length) {
console.warn('Excel 导入跳过明细:', r.errors)
ElMessage.warning(`有 ${r.errors.length} 行被跳过,详见控制台`)
}
fetchQuestions()
} catch (err) {
const msg = (err && err.response && err.response.data && err.response.data.message)
|| (err && err.message) || '未知错误'
ElMessage.error('Excel 导入失败:' + msg)
} finally {
e.target.value = ''
}
}
async function downloadQuestionTpl() {
// safeDownload 自带「HTTP 状态 + content-type」双重校验,避免把错误 JSON 存成 .xlsx
await safeDownload(`/api/admin/exam/${questionGroup.value}/questions/template`, {
fallbackName: 'objective_questions_template.xlsx',
})
}
// 考生答题记录
const examSubmissions = ref([])
const examSubmissionsLoading = ref(false)
const examSubmissionsPage = ref(1)
const examSubmissionsPageSize = ref(10)
const examSubsSearch = ref('')
const examSubsDateRange = ref(null)
const filteredExamSubmissions = computed(() => {
let list = examSubmissions.value
if (examSubsSearch.value) {
const s = examSubsSearch.value.trim().toLowerCase()
list = list.filter(item => (item.userName || '').toLowerCase().includes(s))
}
if (examSubsDateRange.value && examSubsDateRange.value.length === 2) {
const [start, end] = examSubsDateRange.value
list = list.filter(item => {
if (!item.submitTime) return false
const itemDate = new Date(item.submitTime)
return itemDate >= start && itemDate <= end
})
}
return list
})
const pagedExamSubmissions = computed(() => {
const start = (examSubmissionsPage.value - 1) * examSubmissionsPageSize.value
return filteredExamSubmissions.value.slice(start, start + examSubmissionsPageSize.value)
})
async function fetchExamSubmissions() {
// R29:本板块已迁入「成绩管理」⇒ 组别跟随成绩页
examSubmissionsLoading.value = true
try {
const data = await adminApi.getSubmissions(scoreExportGroup.value)
examSubmissions.value = Array.isArray(data) ? data : (data?.list || [])
} catch (e) {
console.warn('获取答题记录失败', e)
examSubmissions.value = []
} finally { examSubmissionsLoading.value = false }
}
function onExamSubsFilterChange() {
examSubmissionsPage.value = 1
}
// R30:原「考生答题评分弹窗」的 state 与 openExamScoreDialog / saveExamScore 已整体移除。
// 大题评分改由「考生大题作答查证」按小问给分(adminApi.setBigAnswerScores
// → service.bigQuestion.setSubScores → service.admin.updateScores),
// 使 word_score 的**写入入口收敛为唯一一条**,不再出现两个口径。
// ⚠️ adminApi.setScores 本身**保留**:作品管理页 batchSaveWorkScores(本科/教师)仍在使用;
// 后端 PUT /api/admin/scores/:group 亦须保留(10+ 个门禁脚本依赖)。
// 组别切换 → 刷新该组全部相关数据(唯一刷新入口)
// 2026-09-23 修复:原仅刷新 fetchExamSubmissions(),题库 / 大题目录 / 大题作答
// 仍停留在上一组数据,导致测试人员必须手动刷新整页才能看到新组内容。
watch(questionGroup, () => {
if (activeMenu.value === 'questions') refreshQuestionData(true)
})
// ⚠️ R29 修复:此处原有一条 `watch(scoreExportGroup, …)`,已下移到 `scoreExportGroup`
// 声明之后(见「6. 成绩管理」节)。原因:watch() 会**立即执行一次取值**来收集依赖,
// 若写在 ref 声明之前,setup 阶段即抛
// `ReferenceError: Cannot access 'scoreExportGroup' before initialization`(TDZ)。
// ==================== 3.5 备赛资料管理 ====================
const materialsGroup = ref('benke')
const adminMaterials = ref([])
const materialsLoading = ref(false)
const materialsPage = ref(1)
const materialsPageSize = ref(10)
const materialSearch = ref('')
const materialDateRange = ref(null)
const filteredAdminMaterials = computed(() => {
let list = adminMaterials.value
if (materialSearch.value) {
const s = materialSearch.value.trim().toLowerCase()
list = list.filter(item => (item.fileName || '').toLowerCase().includes(s))
}
if (materialDateRange.value && materialDateRange.value.length === 2) {
const [start, end] = materialDateRange.value
list = list.filter(item => {
if (!item.uploadTime) return false
const itemDate = new Date(item.uploadTime)
return itemDate >= start && itemDate <= end
})
}
return list
})
const pagedAdminMaterials = computed(() => {
const start = (materialsPage.value - 1) * materialsPageSize.value
return filteredAdminMaterials.value.slice(start, start + materialsPageSize.value)
})
async function fetchAdminMaterials() {
materialsLoading.value = true
try {
const data = await adminApi.getMaterials(materialsGroup.value)
adminMaterials.value = Array.isArray(data) ? data : (data?.list || [])
} catch (e) {
console.warn('获取备赛资料失败', e)
adminMaterials.value = []
} finally { materialsLoading.value = false }
}
async function onMaterialUpload(file) {
try {
const formData = new FormData()
formData.append('file', file.raw)
await adminApi.uploadMaterial(materialsGroup.value, formData)
ElMessage.success('资料上传成功')
materialsPage.value = 1
fetchAdminMaterials()
} catch (e) { ElMessage.error('上传失败') }
}
function onMaterialsGroupChange() {
materialsPage.value = 1
fetchAdminMaterials()
}
function onMaterialFilterChange() {
materialsPage.value = 1
}
async function onDeleteMaterial(fileName) {
try {
await ElMessageBox.confirm(`确定要删除 "${fileName}" 吗?`, '确认删除', { type: 'warning' })
await adminApi.deleteMaterial(materialsGroup.value, fileName)
ElMessage.success('资料已删除')
materialsPage.value = 1
fetchAdminMaterials()
} catch (e) { /* 取消 */ }
}
// ==================== 4. 作品管理 ====================
const submissions = ref([])
const submissionsLoading = ref(false)
const submissionGroup = ref('benke')
const submissionsPage = ref(1)
const submissionsPageSize = ref(10)
const submissionSearch = ref('')
const filteredSubmissions = computed(() => {
let list = submissions.value
if (submissionSearch.value) {
const s = submissionSearch.value.trim().toLowerCase()
list = list.filter(item => (item.name || '').toLowerCase().includes(s))
}
return list
})
const pagedSubmissions = computed(() => {
const start = (submissionsPage.value - 1) * submissionsPageSize.value
return filteredSubmissions.value.slice(start, start + submissionsPageSize.value)
})
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.value = raw.map(s => ({
contestantId: s.contestantId,
name: s.name,
idNumber: s.idNumber,
phone: s.phone,
group: s.group,
files: (s.files || []).map(f => ({ ...f, _score: f.score != null ? Number(f.score) : null })),
}))
} catch (e) { console.warn('获取作品列表失败', e) } finally { submissionsLoading.value = false }
}
function onSubmissionGroupChange() {
submissionsPage.value = 1
fetchSubmissions()
}
function onSubmissionFilterChange() {
submissionsPage.value = 1
}
// 作品预览 / 下载
const previewVisible = ref(false)
const previewFile = ref(null)
const previewRow = ref(null)
const previewBlobUrl = ref('')
const previewIsPdf = ref(false)
function fileDownloadUrl(group, f) {
// 中职/高职 Word 答案存于 exam_submissions,走专用下载端点
if (f.kind === 'answer') return `/api/admin/submissions/${group}/${f.id}/word`
return `/api/admin/submission-file/${group}/${f.id}`
}
async function downloadWork(row, f) {
const url = fileDownloadUrl(submissionGroup.value, f)
await safeDownload(url, { fallbackName: f.fileName || 'file' })
}
async function previewWork(row, f) {
previewRow.value = row
previewFile.value = f
previewIsPdf.value = /\.pdf$/i.test(f.fileName || '')
previewVisible.value = true
if (previewIsPdf.value) {
const r = await fetchBlobGuarded(fileDownloadUrl(submissionGroup.value, f))
if (r.ok) {
previewBlobUrl.value = URL.createObjectURL(r.blob)
} else if (r.ignored) {
// 同一文件正在预览加载,被去重忽略——静默跳过,不报错
} else {
ElMessage.error(r.message || '预览加载失败')
}
}
}
function closePreview() {
if (previewBlobUrl.value) { URL.revokeObjectURL(previewBlobUrl.value); previewBlobUrl.value = '' }
previewFile.value = null
previewRow.value = null
}
// 批量下载作品(ZIP)
async function batchDownloadWorks() {
if (!submissionGroup.value) return
await safeDownload(`/api/admin/batch-download/${submissionGroup.value}`, {
fallbackName: `${submissionGroup.value}_works.zip`,
timeout: 300000,
})
}
// 批量保存评分(按文件聚合到选手的 wordScore / pptScore)
async function batchSaveWorkScores() {
try {
const perContestant = {}
submissions.value.forEach(s => {
;(s.files || []).forEach(f => {
if (f._score == null || f._score === '') return
const c = (perContestant[s.contestantId] = perContestant[s.contestantId] || {})
if (f.scoreField === 'ppt') c.pptScore = Number(f._score)
else c.wordScore = Number(f._score)
})
})
const scores = Object.entries(perContestant).map(([contestantId, fields]) => ({ contestantId: Number(contestantId), ...fields }))
if (scores.length === 0) { ElMessage.warning('没有需要保存的评分'); return }
await adminApi.setScores(submissionGroup.value, { scores })
ElMessage.success('评分保存成功')
// 保存后立即刷新作品列表 + 考生答题记录,保证两处分数同步可见(评分不同步修复)
await fetchSubmissions()
// R29:「考生答题记录」的组别已改跟随成绩管理页(scoreExportGroup)
if (submissionGroup.value === scoreExportGroup.value) fetchExamSubmissions()
} catch (e) { ElMessage.error('保存失败') }
}
// ==================== 5. 时间设置 ====================
const timesForm = reactive({
zhongzhiStart: '', zhongzhiEnd: '', zhongzhiDeadline: '', zhongzhiChoiceCount: 50, zhongzhiJudgeCount: 20, zhongzhiAvailChoice: 0, zhongzhiAvailJudge: 0,
gaozhiStart: '', gaozhiEnd: '', gaozhiDeadline: '', gaozhiChoiceCount: 50, gaozhiJudgeCount: 20, gaozhiAvailChoice: 0, gaozhiAvailJudge: 0,
benkeStart: '', benkeEnd: '', benkeDeadline: '', benkeChoiceCount: 50, benkeJudgeCount: 20, benkeAvailChoice: 0, benkeAvailJudge: 0,
jiaoshiStart: '', jiaoshiEnd: '', jiaoshiDeadline: '', jiaoshiChoiceCount: 50, jiaoshiJudgeCount: 20, jiaoshiAvailChoice: 0, jiaoshiAvailJudge: 0,
})
const timesSaving = ref(false)
const timesLoading = ref(false)
const timeGroups = [
{ key: 'zhongzhi', name: '中职组', isExam: true },
{ key: 'gaozhi', name: '高职组', isExam: true },
{ key: 'benke', name: '本科组', isExam: false },
{ key: 'jiaoshi', name: '教师组', isExam: false },
]
async function fetchTimes() {
timesLoading.value = true
try {
// 后端返回 { times: [{ group, startTime, endTime, deadline, choiceCount, judgeCount, availableChoice, availableJudge }] }
const res = await adminApi.getTimes()
const list = (res && res.times) || []
const map = {}
for (const t of list) map[t.group] = t
for (const g of timeGroups) {
const t = map[g.key]
if (!t) continue
timesForm[g.key + 'Start'] = t.startTime ? t.startTime : ''
timesForm[g.key + 'End'] = t.endTime ? t.endTime : ''
timesForm[g.key + 'Deadline'] = t.deadline ? t.deadline : ''
timesForm[g.key + 'ChoiceCount'] = t.choiceCount != null ? t.choiceCount : 50
timesForm[g.key + 'JudgeCount'] = t.judgeCount != null ? t.judgeCount : 20
timesForm[g.key + 'AvailChoice'] = t.availableChoice || 0
timesForm[g.key + 'AvailJudge'] = t.availableJudge || 0
}
} catch (e) { console.warn('获取时间配置失败', e) } finally { timesLoading.value = false }
}
async function saveTimes() {
timesSaving.value = true
try {
await adminApi.setTimes({
zhongzhi: { start: timesForm.zhongzhiStart, end: timesForm.zhongzhiEnd, deadline: timesForm.zhongzhiDeadline, choiceCount: timesForm.zhongzhiChoiceCount, judgeCount: timesForm.zhongzhiJudgeCount },
gaozhi: { start: timesForm.gaozhiStart, end: timesForm.gaozhiEnd, deadline: timesForm.gaozhiDeadline, choiceCount: timesForm.gaozhiChoiceCount, judgeCount: timesForm.gaozhiJudgeCount },
benke: { start: timesForm.benkeStart, end: timesForm.benkeEnd, deadline: timesForm.benkeDeadline, choiceCount: timesForm.benkeChoiceCount, judgeCount: timesForm.benkeJudgeCount },
jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount },
})
ElMessage.success('时间设置保存成功')
} catch (e) { ElMessage.error('保存失败') } finally { timesSaving.value = false }
}
// ==================== 6. 成绩管理 ====================
const scoresPublishedMap = reactive({})
const scoreExportGroup = ref('')
/**
* 是否为「考试组」(中职 / 高职)——R29 由「试卷管理」迁入的两个板块
* (考生答题记录 / 考生大题作答查证)只对这两组有意义:
* 本科 / 教师组提交的是 Word / PPT 作品,没有客观题与综合大题数据。
*/
const isExamScoreGroup = computed(() => scoreExportGroup.value === 'zhongzhi' || scoreExportGroup.value === 'gaozhi')
// R29:成绩管理页切组 → 刷新成绩明细 + 两个由试卷管理迁入的板块
// (这两个板块的组别跟随 scoreExportGroup,故其刷新必须挂在这里)
// 🔴 必须写在 `scoreExportGroup` 声明**之后**:watch() 会立即取值收集依赖,
// 前置引用会在 setup 阶段触发 TDZ(Cannot access 'scoreExportGroup' before initialization)。
watch(scoreExportGroup, () => {
if (activeMenu.value === 'scores') refreshExamCtxData(true)
})
const scoreList = ref([])
const scoreListLoading = ref(false)
const scoreListPage = ref(1)
const scoreListPageSize = ref(10)
const scoreSearch = ref('')
const scoreStatusFilter = ref('')
const scoreDateRange = ref(null)
const filteredScoreList = computed(() => {
let list = scoreList.value
if (scoreSearch.value) {
const s = scoreSearch.value.trim().toLowerCase()
list = list.filter(item =>
(item.name || '').toLowerCase().includes(s) ||
(item.idNumber || '').toLowerCase().includes(s)
)
}
if (scoreStatusFilter.value !== '') {
const isSubmitted = scoreStatusFilter.value === 'true'
list = list.filter(item => item.submitted === isSubmitted)
}
if (scoreDateRange.value && scoreDateRange.value.length === 2) {
const [start, end] = scoreDateRange.value
list = list.filter(item => {
if (!item.submitTime) return false
const itemDate = new Date(item.submitTime)
return itemDate >= start && itemDate <= end
})
}
return list
})
const pagedScoreList = computed(() => {
const start = (scoreListPage.value - 1) * scoreListPageSize.value
return filteredScoreList.value.slice(start, start + scoreListPageSize.value)
})
async function onScoreGroupChange() {
scoreListPage.value = 1
await fetchPublishStatus()
fetchScoreList()
}
function onScoreFilterChange() {
scoreListPage.value = 1
}
async function fetchPublishStatus() {
if (!scoreExportGroup.value) return
try {
const data = await adminApi.getPublishStatus(scoreExportGroup.value)
scoresPublishedMap[scoreExportGroup.value] = data?.published ?? false
} catch (e) {
scoresPublishedMap[scoreExportGroup.value] = false
}
}
async function fetchScoreList() {
if (!scoreExportGroup.value) return
scoreListLoading.value = true
try {
const data = await adminApi.getScores(scoreExportGroup.value)
scoreList.value = (Array.isArray(data) ? data : (data?.list || [])).map(item => ({
...item,
idNumber: item.idNumber ?? item.id_number,
submitted: item.submitted ?? false,
submitTime: item.submitTime ?? null,
}))
} catch (e) {
console.warn('获取成绩列表失败', e)
scoreList.value = []
} finally { scoreListLoading.value = false }
}
async function togglePublish() {
const group = scoreExportGroup.value
if (!group) return
const current = scoresPublishedMap[group] ?? false
try {
await adminApi.publishScores(group, !current)
scoresPublishedMap[group] = !current
ElMessage.success(!current ? '成绩已发布' : '成绩已撤回')
} catch (e) { ElMessage.error('操作失败') }
}
async function exportExcel() {
// R29:由 CSV 改为直接导出 .xlsx(CSV 在 Excel 中中文易乱码、身份证/手机号变科学计数法)
// safeDownload 自带「HTTP 状态 + content-type」双重校验,避免把错误 JSON 存成 .xlsx
await safeDownload(`/api/admin/export-excel/${scoreExportGroup.value}`, {
fallbackName: `${scoreExportGroup.value}_scores.xlsx`,
})
}
async function batchDownload() {
await safeDownload(`/api/admin/batch-download/${scoreExportGroup.value}`, {
fallbackName: `${scoreExportGroup.value}_works.zip`,
timeout: 300000,
})
}
// ==================== 7. 新闻管理 ====================
const adminNews = ref([])
const adminNewsLoading = ref(false)
const adminNewsPage = ref(1)
const adminNewsPageSize = ref(10)
const newsSearch = ref('')
const newsGroupFilter = ref('')
const newsDateRange = ref(null)
const newsInitialLoadDone = ref(false)
const newsCardListRef = ref(null)
let newsSortable = null
const filteredAdminNews = computed(() => {
let list = adminNews.value
if (newsSearch.value) {
const s = newsSearch.value.trim().toLowerCase()
list = list.filter(item => (item.title || '').toLowerCase().includes(s))
}
if (newsGroupFilter.value) {
list = list.filter(item => item.group === newsGroupFilter.value)
}
if (newsDateRange.value && newsDateRange.value.length === 2) {
const [start, end] = newsDateRange.value
list = list.filter(item => {
if (!item.publish_time) return false
const itemDate = new Date(item.publish_time)
return itemDate >= start && itemDate <= end
})
}
return list
})
const pagedAdminNews = computed(() => {
const start = (adminNewsPage.value - 1) * adminNewsPageSize.value
return filteredAdminNews.value.slice(start, start + adminNewsPageSize.value)
})
const newsDialogVisible = ref(false)
const newsSaving = ref(false)
const editingNews = ref(null)
const newsForm = reactive({ title: '', group: 'all', pinned: false, tags: [''], news_type: 'content', content: '', wx_link: '' })
async function fetchAdminNews() {
adminNewsLoading.value = true
newsInitialLoadDone.value = false
try {
const data = await adminApi.listNews()
// 兼容后端返回:单层 { list: [...] } 或双层 { list: { list: [...] } }
const listPayload = data?.list
const list = Array.isArray(listPayload)
? listPayload
: (listPayload?.list ?? [])
adminNews.value = list.map(item => ({
...item,
pinned: !!item.pinned,
}))
} catch (e) { console.warn('获取新闻失败', e) } finally { adminNewsLoading.value = false }
// 延迟开启置顶开关,防止 el-switch 初始渲染时触发 @change 事件
await nextTick()
setTimeout(() => { newsInitialLoadDone.value = true }, 100)
initNewsSortable()
}
function openNewsDialog(row = null) {
editingNews.value = row
if (row) {
newsForm.title = row.title || ''
newsForm.group = row.group || 'all'
newsForm.pinned = row.pinned || false
const parsedTags = (row.tags || '').split(',').filter(Boolean)
newsForm.tags = parsedTags.length > 0 ? parsedTags : ['']
newsForm.news_type = row.news_type || 'content'
newsForm.content = row.content || ''
newsForm.wx_link = row.wx_link || ''
} else {
newsForm.title = ''
newsForm.group = 'all'
newsForm.pinned = false
newsForm.tags = ['']
newsForm.news_type = 'content'
newsForm.content = ''
newsForm.wx_link = ''
}
newsDialogVisible.value = true
}
function onNewsTypeChange() {
// 切换类型时不再清空另一边的数据,保留用户已填写的内容
// 后端会根据 news_type 决定使用哪个字段
}
function addTag() {
newsForm.tags.push('')
}
function removeTag(idx) {
if (newsForm.tags.length > 1) {
newsForm.tags.splice(idx, 1)
} else {
newsForm.tags[0] = ''
}
}
async function saveNews() {
newsSaving.value = true
try {
const payload = {
title: newsForm.title,
group: newsForm.group,
pinned: newsForm.pinned,
tags: newsForm.tags.filter(Boolean).join(','),
news_type: newsForm.news_type,
content: newsForm.content,
wx_link: newsForm.wx_link,
}
if (editingNews.value?.id) {
await adminApi.updateNews(editingNews.value.id, payload)
} else {
await adminApi.createNews(payload)
}
ElMessage.success('保存成功')
newsDialogVisible.value = false
fetchAdminNews()
} catch (e) { ElMessage.error('保存失败') } finally { newsSaving.value = false }
}
async function deleteAdminNews(row) {
try {
await ElMessageBox.confirm('确定删除该新闻?', '确认', { type: 'warning' })
await adminApi.deleteNews(row.id)
ElMessage.success('删除成功')
fetchAdminNews()
} catch (e) { /* 取消 */ }
}
async function togglePinned(row, val) {
if (!newsInitialLoadDone.value) return
try {
await adminApi.updateNews(row.id, { pinned: val })
row.pinned = val
ElMessage.success(val ? '已置顶' : '已取消置顶')
} catch (e) {
row.pinned = !val
ElMessage.error('更新失败')
}
}
function onNewsFilterChange() {
adminNewsPage.value = 1
}
/** 保存新闻排序 */
async function saveNewsOrder() {
const orders = adminNews.value.map((item, index) => ({
id: item.id,
sort_order: index + 1,
}))
try {
await adminApi.updateNewsOrder(orders)
ElMessage.success('排序保存成功')
fetchAdminNews()
} catch (e) {
console.error('新闻排序保存失败:', e)
ElMessage.error('排序保存失败,请刷新重试')
}
}
/** 初始化新闻卡片拖拽排序 */
function initNewsSortable() {
nextTick(() => {
const el = newsCardListRef.value
if (!el || !window.Sortable) return
if (newsSortable) newsSortable.destroy()
newsSortable = new window.Sortable(el, {
handle: '.news-card__handle',
animation: 200,
ghostClass: 'news-card--ghost',
onEnd: (evt) => {
const items = el.querySelectorAll('.news-card')
const newOrder = Array.from(items).map((card, index) => ({
id: Number(card.dataset.id),
sort_order: index + 1,
}))
const orderedMap = new Map(newOrder.map(o => [o.id, o]))
const reordered = adminNews.value
.map(item => {
const ord = orderedMap.get(item.id)
if (ord) {
item.sort_order = ord.sort_order
}
return item
})
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
adminNews.value = reordered
},
})
})
}
// ==================== 8. 轮播管理 ====================
const adminCarousels = ref([])
const carouselLoading = ref(false)
const carouselDialogVisible = ref(false)
const carouselSaving = ref(false)
const carouselUploading = ref(false)
const editingCarousel = ref(null)
const carouselForm = reactive({
image_url: '',
link_url: '', // 遗留字段,保留兼容
title: '',
content: '', // 富文本正文(HTML)
button_text: '', // 按钮文字
button_link: '', // 按钮链接(站内路由或外链)
link_mode: 'internal', // UI 用:internal=站内下拉 / external=外链输入
enabled: true,
})
const carouselCardListRef = ref(null)
let carouselSortable = null
const uploadUrl = '/api/admin/upload/image'
const uploadHeaders = computed(() => {
const token = authStore.token
return token ? { Authorization: `Bearer ${token}` } : {}
})
function beforeCarouselUpload(file) {
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
return false
}
carouselUploading.value = true
return true
}
function onCarouselUploadSuccess(response) {
carouselUploading.value = false
if (response && response.code === 0) {
carouselForm.image_url = response.data.imageUrl || response.data.image_url || ''
ElMessage.success('图片上传成功')
} else {
ElMessage.error(response?.message || '上传失败')
}
}
async function fetchCarousels() {
carouselLoading.value = true
try {
const data = await adminApi.listCarousel()
adminCarousels.value = (data?.list || []).map(item => ({
id: item.id,
imageUrl: item.image_url ? normalizeImageUrl(item.image_url) : '',
image_url: item.image_url || '',
linkUrl: item.link_url || '',
link_url: item.link_url || '',
title: item.title || '',
content: item.content || '',
button_text: item.button_text || '',
button_link: item.button_link || '',
sortOrder: item.sort_order ?? 0,
sort_order: item.sort_order ?? 0,
enabled: item.enabled !== false,
}))
initCarouselSortable()
} catch (e) { console.warn('获取轮播失败', e) } finally { carouselLoading.value = false }
}
// 判断链接是否为外链(http/https/协议相对),据此推断编辑器链接模式
function isExternalLink(link) {
return /^(https?:)?\/\//i.test(link || '')
}
function openCarouselDialog(row = null) {
editingCarousel.value = row
if (row) {
// 编辑时底图回填原始存储值(未 normalize),避免把绝对 URL 再次入库
carouselForm.image_url = row.image_url || ''
carouselForm.link_url = row.link_url || row.linkUrl || ''
carouselForm.title = row.title || ''
carouselForm.content = row.content || ''
carouselForm.button_text = row.button_text || ''
carouselForm.button_link = row.button_link || ''
carouselForm.link_mode = isExternalLink(row.button_link) ? 'external' : 'internal'
carouselForm.enabled = row.enabled !== false
} else {
carouselForm.image_url = ''
carouselForm.link_url = ''
carouselForm.title = ''
carouselForm.content = ''
carouselForm.button_text = ''
carouselForm.button_link = ''
carouselForm.link_mode = 'internal'
carouselForm.enabled = true
}
carouselDialogVisible.value = true
}
async function saveCarousel() {
carouselSaving.value = true
try {
// link_mode 仅用于表单 UI,不入库;剥离后再提交
const { link_mode, ...rest } = carouselForm
const payload = {
...rest,
image_url: (carouselForm.image_url || '').trim() || null, // 空底图存 null,前端回退渐变
button_text: (carouselForm.button_text || '').trim(),
button_link: (carouselForm.button_link || '').trim(),
}
if (editingCarousel.value?.id) {
await adminApi.updateCarousel(editingCarousel.value.id, payload)
} else {
await adminApi.addCarousel(payload)
}
ElMessage.success('保存成功')
carouselDialogVisible.value = false
fetchCarousels()
} catch (e) { ElMessage.error('保存失败') } finally { carouselSaving.value = false }
}
async function deleteCarouselItem(row) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await adminApi.deleteCarousel(row.id)
ElMessage.success('删除成功')
fetchCarousels()
} catch (e) { /* 取消 */ }
}
async function toggleCarousel(row) {
try {
await adminApi.updateCarousel(row.id, { enabled: row.enabled })
} catch (e) { ElMessage.error('更新失败') }
}
/** 手动保存轮播排序 */
async function saveCarouselOrder() {
const orders = adminCarousels.value.map((item, index) => ({
id: item.id,
sort_order: index + 1,
}))
try {
await adminApi.updateCarouselOrder(orders)
ElMessage.success('排序保存成功')
} catch (e) {
console.error('轮播排序保存失败:', e)
ElMessage.error('排序保存失败,请刷新重试')
}
}
/** 初始化轮播卡片拖拽排序 */
function initCarouselSortable() {
nextTick(() => {
const el = carouselCardListRef.value
if (!el || !window.Sortable) return
if (carouselSortable) carouselSortable.destroy()
carouselSortable = new window.Sortable(el, {
handle: '.carousel-card__handle',
animation: 200,
ghostClass: 'carousel-card--ghost',
onEnd: (evt) => {
// 仅更新前端 DOM 顺序,不保存数据库
const items = el.querySelectorAll('.carousel-card')
const newOrder = Array.from(items).map((card, index) => ({
id: Number(card.dataset.id),
sort_order: index + 1,
}))
// 重新排列 adminCarousels 数组以匹配 DOM 顺序
const orderedMap = new Map(newOrder.map(o => [o.id, o]))
const reordered = adminCarousels.value
.map(item => {
const ord = orderedMap.get(item.id)
if (ord) {
item.sortOrder = ord.sort_order
item.sort_order = ord.sort_order
}
return item
})
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
adminCarousels.value = reordered
},
})
})
}
/** 保存轮播排序(批量更新,点击"保存排序"按钮触发) */
// ==================== 成绩复核(按姓名/身份证号/电话检索 → 核对全卷) ====================
const REVIEW_GROUP_LABELS = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
const reviewKeyword = ref('')
const reviewGroup = ref('')
const reviewResults = ref([])
const reviewSearching = ref(false)
// 区分「还没搜」与「搜过但无结果」:否则首次进入就显示「没有匹配的考生」,属误导
const reviewSearched = ref(false)
const reviewTruncated = ref(false)
const reviewDialogVisible = ref(false)
const reviewLoading = ref(false)
const reviewDetail = ref(null)
function reviewGroupLabel(g) {
return REVIEW_GROUP_LABELS[g] || g || '--'
}
/**
* 判断题在库里存的是 'true' / 'false',直接展示复核人读不出来,统一映射为「正确 / 错误」。
* 选择题('A' / 'AB')与历史脏值**原样返回** —— 复核要看到库里的真实值,不做清洗。
*/
function reviewDisplayAnswer(ans, type) {
const v = ans == null ? '' : String(ans)
if (type === 'judge') {
const low = v.trim().toLowerCase()
if (low === 'true') return '正确'
if (low === 'false') return '错误'
}
return v
}
/**
* 【R28】客观题选项列表(防御性解包)。
* 后端 options 存于 text 列、内容是 JSON 字符串;曾原样透传 ⇒ `v-for` 对字符串**逐字符**迭代,
* 每个字符渲染成一行空内容(op.key / op.text 皆 undefined),表现即「选择题大量换行导致空白」。
* 后端已修(service/admin.js parseOptions),此处做第二道防线:非数组一律降级为空数组。
*/
function reviewOptionList(row) {
const raw = row && row.options
if (Array.isArray(raw)) return raw
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch (e) {
return []
}
}
return []
}
/** 在选项列表里按字母取选项文本(找不到返回空串) */
function reviewOptionTextOf(options, key) {
const k = String(key == null ? '' : key).trim().toUpperCase()
if (!k || !Array.isArray(options)) return ''
for (const op of options) {
if (op && typeof op === 'object') {
const ok = String(op.key == null ? '' : op.key).trim().toUpperCase()
if (ok === k) return String(op.text == null ? '' : op.text).trim()
}
}
return ''
}
/**
* 选择题「考生作答 / 正确答案」单元格:字母 + 选项文本(如「A. GPS」)。
* 测试人员疑问「只显示正确答案的选项,不知道正确答案具体是哪一个」的正面答复:
* 客观题**选项顺序与考生所见完全一致**(后端只用 RAND() 随机抽题,从不乱序选项;
* 前端 Competition.vue 也按 options 原序渲染)⇒ 字母本就无歧义;再补选项文本即彻底消除疑虑。
* 判断题仍走「正确/错误」;找不到选项文本时退回纯字母。
*/
function reviewAnswerCell(row, ans) {
const base = reviewDisplayAnswer(ans, row && row.questionType)
if (!row || row.questionType !== 'choice' || !base) return base
const text = reviewOptionTextOf(reviewOptionList(row), base)
return text ? `${base}. ${text}` : base
}
/**
* 逐题异常标记(复核时最需要一眼看到的东西)
* - 题目缺失:题库里已找不到该题(被删 / 改到别组)⇒ 题干与标准答案都无法核对
* - 判定矛盾:考生答案与标准答案的比对结果,与系统判定相反 ⇒ 疑似判分异常
* @returns {string} 空串表示无异常
*/
function reviewRowAnomaly(row) {
if (row.questionMissing) {
return '题库中已找不到该题(可能已被删除或改到别的组别),无法核对题干与标准答案'
}
if (row.answerMatches != null && row.isCorrect != null && row.answerMatches !== row.isCorrect) {
return '答案与判定不一致:考生答案与标准答案的比对结果和系统判定相反,疑似判分异常'
}
return ''
}
async function doReviewSearch() {
const kw = reviewKeyword.value.trim()
if (!kw) {
ElMessage.warning('请输入 姓名 / 身份证号 / 电话 中的任意一项')
return
}
reviewSearching.value = true
try {
const data = await adminApi.reviewSearch(kw, reviewGroup.value || undefined)
const result = data?.data || data
reviewResults.value = result?.list || []
reviewTruncated.value = !!result?.truncated
reviewSearched.value = true
} catch (e) {
console.warn('复核检索失败', e)
reviewResults.value = []
reviewTruncated.value = false
reviewSearched.value = true
} finally {
reviewSearching.value = false
}
}
async function openReviewDetail(row) {
reviewDialogVisible.value = true
reviewLoading.value = true
reviewDetail.value = null
try {
// 组别用该考生自身的 group(检索结果里已带),而不是界面筛选器 ——
// 避免「筛选器切到别组后点开旧结果」造成跨组串查。
const data = await adminApi.getContestantScore(row.group, row.contestantId)
reviewDetail.value = data?.data || data
} catch (e) {
console.warn('获取考生全卷明细失败', e)
reviewDialogVisible.value = false
} finally {
reviewLoading.value = false
}
}
async function downloadReviewWork(row) {
if (!reviewDetail.value) return
await safeDownload(`/api/admin/submission-file/${reviewDetail.value.group}/${row.id}`, {
fallbackName: row.fileName || 'file',
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 首次加载时根据当前活动菜单获取对应数据
onMenuSelect(activeMenu.value)
})
</script>
<style scoped>
.admin-page {
min-height: 100vh;
background: #f0f2f5;
display: flex;
flex-direction: column;
}
/* ==================== 顶部栏 ==================== */
.admin-top-bar {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 56px;
position: sticky;
top: 0;
z-index: 99;
}
.top-left { display: flex; align-items: center; gap: 10px; }
.admin-logo { font-size: 22px; color: #c9a84c; }
.admin-title { font-size: 16px; font-weight: 600; }
.top-right { display: flex; gap: 8px; }
/* ==================== 主体 ==================== */
.admin-body {
display: flex;
flex: 1;
}
/* ==================== 侧边栏 ==================== */
.admin-sidebar {
width: 200px;
background: #fff;
border-right: 1px solid #e5e7eb;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar-menu {
border-right: none;
}
/* 增强选中项对比度 */
:deep(.el-menu-item.is-active) {
background-color: #409eff !important;
color: #fff !important;
}
:deep(.el-menu-item:hover) {
background-color: #ecf5ff !important;
}
:deep(.el-menu-item.is-active:hover) {
background-color: #66b1ff !important;
color: #fff !important;
}
/* ==================== 内容区 ==================== */
.admin-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.section-heading {
font-size: 22px;
font-weight: 700;
color: #1a3a5c;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #c9a84c;
}
/* ==================== 工具栏 ==================== */
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
/* R30:`.exam-score-form` 相关样式已随「考生答题记录 · 评分」弹窗一并删除 */
.search-input { width: 220px; }
.filter-select { width: 140px; }
.score-policy-tag { margin-left: 12px; }
.avail-hint { margin-left: 8px; color: #909399; font-size: 12px; }
.total-score-tag { margin-left: 4px; }
/* ==================== 作品管理 ==================== */
.work-file-row { display: flex; align-items: center; gap: 10px; padding: 4px 0; flex-wrap: wrap; }
.work-file-name { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #303133; }
.score-inline { display: inline-flex; align-items: center; gap: 4px; color: #606266; font-size: 13px; }
.score-input { width: 110px; }
.preview-frame { width: 100%; height: 70vh; border: 0; background: #fff; }
.preview-file-name { color: #303133; font-weight: 600; margin-bottom: 8px; }
/* ==================== 统计卡片 ==================== */
.stats-row { margin-bottom: 20px; }
.stat-card {
text-align: center;
border-top: 3px solid #1a3a5c;
margin-bottom: 16px;
}
.stat-value {
font-size: 32px;
font-weight: 800;
color: #1a3a5c;
}
.stat-label {
font-size: 14px;
color: #6b7280;
margin-top: 4px;
}
.stat-group-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.stat-detail {
font-size: 13px;
color: #6b7280;
margin-bottom: 4px;
}
/* ==================== 统计口径切换 ==================== */
.stats-scope-bar {
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
}
.import-test-flag {
margin-right: 4px;
}
/* ==================== 测试数据重置 ==================== */
.reset-section {
margin-top: 16px;
padding: 16px;
background: #fefce8;
border: 1px dashed #f59e0b;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.reset-hint {
font-size: 13px;
color: #92400e;
}
/* ==================== 表格 ==================== */
.admin-table {
background: #fff;
border-radius: 8px;
}
/* 表头和内容对齐优化:表头左对齐,内容也左对齐 */
:deep(.el-table th) {
text-align: left;
}
:deep(.el-table td) {
text-align: left;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 16px;
}
/* ==================== 时间设置 ==================== */
.times-form { max-width: 900px; }
.time-group-card {
margin-bottom: 16px;
}
.time-group-title {
font-size: 16px;
font-weight: 600;
}
/* ==================== 轮播缩略图 ==================== */
.carousel-thumb {
width: 120px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.carousel-thumb--gradient {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f2640 0%, #1a3a5c 30%, #2a5a8c 60%, #0f2640 100%);
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
letter-spacing: 1px;
}
.carousel-upload {
width: 100%;
}
.carousel-form-preview {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
}
.carousel-form-preview img {
width: 160px;
height: 80px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #e4e7ed;
}
/* ==================== 轮播卡片拖拽 ==================== */
.carousel-card-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
min-height: 60px;
}
.carousel-card {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
max-width: 520px;
padding: 12px 16px;
background: #fff;
border: 2px solid #e4e7ed;
border-radius: 8px;
cursor: default;
transition: box-shadow 0.2s, border-color 0.2s;
}
.carousel-card:hover {
border-color: #409eff;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.15);
}
.carousel-card--disabled {
opacity: 0.55;
}
.carousel-card--ghost {
opacity: 0.4;
background: #ecf5ff;
border-color: #409eff;
}
.carousel-card__handle {
cursor: grab;
color: #909399;
font-size: 20px;
flex-shrink: 0;
}
.carousel-card__handle:active {
cursor: grabbing;
}
.carousel-card__preview {
flex-shrink: 0;
}
.carousel-card__preview .carousel-thumb {
width: 120px;
height: 68px;
border-radius: 6px;
}
.carousel-card__info {
flex: 1;
min-width: 0;
}
.carousel-card__title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.carousel-card__meta {
display: flex;
align-items: center;
gap: 12px;
}
/* ==================== 新闻卡片拖拽 ==================== */
.news-card-list {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 60px;
margin-bottom: 16px;
}
.news-card {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
background: #fff;
border: 2px solid #e4e7ed;
border-radius: 8px;
cursor: default;
transition: box-shadow 0.2s, border-color 0.2s;
}
.news-card:hover {
border-color: #409eff;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.15);
}
.news-card--ghost {
opacity: 0.4;
background: #ecf5ff;
border-color: #409eff;
}
.news-card__handle {
cursor: grab;
color: #909399;
font-size: 20px;
flex-shrink: 0;
}
.news-card__handle:active {
cursor: grabbing;
}
.news-card__body {
flex: 1;
min-width: 0;
}
.news-card__title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.news-card__meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.news-card__group {
color: #606266;
font-size: 13px;
}
.news-card__time {
color: #909399;
font-size: 13px;
}
.news-card__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.news-tag {
margin-left: 8px;
}
/* 标签输入框列表 */
.tag-inputs {
display: flex;
flex-direction: column;
gap: 8px;
}
.tag-input-row {
display: flex;
align-items: center;
gap: 8px;
}
.tag-input {
flex: 1;
}
.carousel-card__order {
font-size: 12px;
color: #909399;
}
.carousel-card__actions {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
flex-shrink: 0;
}
.carousel-card__actions .el-button {
margin-left: 0 !important;
justify-content: center;
display: flex;
}
.drag-hint {
font-size: 13px;
color: #909399;
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.section-subheading {
font-size: 18px;
font-weight: 600;
color: #374151;
margin: 24px 0 12px 0;
}
/* ==================== 选择题选项 ==================== */
.choice-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.choice-input {
width: 100%;
}
/* ==================== 综合大题(题库 / 预览 / 作答查证) ==================== */
/* 题库列表内的题干缩略预览:表格需收敛为紧凑样式,避免撑破单元格 */
.big-preview {
max-height: 88px;
overflow: hidden;
font-size: 12px;
line-height: 1.6;
color: #606266;
}
.big-preview :deep(p) {
margin: 0;
}
.big-preview :deep(table) {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.big-preview :deep(th),
.big-preview :deep(td) {
border: 1px solid #dcdfe6;
padding: 2px 4px;
}
.big-preview :deep(img) {
max-width: 100%;
}
.big-total-preview {
margin-left: 12px;
font-size: 13px;
color: #909399;
}
/* 大题录入:题干防泄露提示(2026-09-28 R27 加固) */
.big-form-tip {
margin-top: 6px;
font-size: 12px;
line-height: 1.7;
color: #e6a23c;
}
.big-form-tip code {
padding: 1px 4px;
background: #f5f7fa;
border-radius: 3px;
font-family: Consolas, Monaco, monospace;
color: #606266;
}
/* 预览弹窗:按考生视角还原排版 */
.big-preview-title {
margin: 0 0 10px;
font-size: 17px;
color: #303133;
}
.big-preview-body {
font-size: 14px;
line-height: 1.9;
color: #303133;
word-break: break-word;
}
.big-preview-body :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
.big-preview-body :deep(th),
.big-preview-body :deep(td) {
border: 1px solid #dcdfe6;
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
.big-preview-body :deep(th) {
background: #f5f7fa;
font-weight: 600;
}
.big-preview-body :deep(img) {
max-width: 100%;
}
.big-preview-sub {
margin-top: 14px;
}
.big-preview-sub-head {
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.big-preview-sub-prompt {
font-size: 13px;
color: #606266;
margin-bottom: 6px;
white-space: pre-wrap;
word-break: break-word;
}
/* 作答详情弹窗 */
.big-answer-meta {
margin-bottom: 14px;
}
.big-answer-h4 {
margin: 16px 0 8px;
font-size: 14px;
color: #303133;
border-left: 3px solid #409eff;
padding-left: 8px;
}
.big-snapshot {
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 12px;
}
.big-snapshot-title {
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.big-answer-sub {
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 12px;
}
.big-answer-sub-head {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.big-answer-chars {
font-weight: 400;
font-size: 12px;
color: #909399;
}
.big-answer-prompt {
font-size: 13px;
color: #606266;
margin-bottom: 6px;
white-space: pre-wrap;
word-break: break-word;
}
.big-answer-text {
background: #fcfcfc;
border: 1px dashed #e4e7ed;
border-radius: 4px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.8;
color: #303133;
/* 考生作答按原样换行显示,避免长答案挤成一坨 */
white-space: pre-wrap;
word-break: break-word;
max-height: 260px;
overflow-y: auto;
}
.big-answer-empty {
color: #c0c4cc;
}
.big-answer-score {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.big-answer-score-label {
font-size: 13px;
color: #606266;
}
.big-answer-score-max {
font-size: 12px;
color: #909399;
}
.big-answer-total {
margin-top: 14px;
padding: 10px 12px;
background: #f0f9eb;
border-radius: 6px;
font-size: 14px;
color: #303133;
}
.big-answer-total strong {
color: #67c23a;
font-size: 16px;
}
.big-answer-tip {
display: block;
margin-top: 4px;
font-size: 12px;
color: #909399;
}
/* R30:「考生大题作答查证」列表的「小问得分」列(如 8 / 9.5 / 9.5)。
使用等宽数字,避免切组/刷新时列宽抖动。 */
.big-sub-scores {
font-variant-numeric: tabular-nums;
color: #303133;
}
/* ==================== 评分输入 ==================== */
.score-input {
width: 80px;
}
/* 选择题选项列表 — 每行一条,带背景色 */
.choice-options-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.choice-option-item {
font-size: 13px;
line-height: 1.6;
color: #303133;
padding: 3px 8px;
border-radius: 4px;
background: #ecf5ff;
}
/* ==================== 工具 ==================== */
.w-full { width: 100%; }
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.admin-sidebar { width: 140px; }
.admin-content { padding: 16px; }
.toolbar { flex-direction: column; align-items: stretch; }
.search-input, .filter-select { width: 100%; }
}
@media (max-width: 480px) {
.admin-sidebar { display: none; }
}
/* ==================== 成绩复核 ==================== */
.review-privacy-tip {
margin-bottom: 14px;
}
.review-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.review-toolbar .search-input {
max-width: 300px;
}
.review-group-select {
width: 140px;
}
.review-warn {
margin-bottom: 12px;
}
.review-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 18px;
margin-bottom: 12px;
font-size: 13px;
color: #303133;
}
.review-summary {
margin-bottom: 14px;
}
/* 逐题表格:题干可能很长,限高滚动,避免弹窗被撑爆 */
.review-table :deep(.el-table__cell) {
vertical-align: top;
}
.review-question {
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.review-options {
margin: 6px 0 0;
padding-left: 18px;
color: #606266;
font-size: 12px;
line-height: 1.6;
}
.review-empty {
color: #a8abb2;
}
.review-max {
color: #a8abb2;
font-size: 12px;
margin-left: 2px;
}
/* 综合大题(快照) */
.review-big {
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px 14px;
margin-bottom: 6px;
}
.review-big-title {
font-weight: 600;
margin-bottom: 8px;
color: #1a3a5c;
}
.review-big-desc {
font-size: 13px;
line-height: 1.7;
color: #606266;
margin-bottom: 12px;
word-break: break-word;
}
.review-big-sub {
border-top: 1px dashed #e4e7ed;
padding-top: 10px;
margin-top: 10px;
}
.review-big-prompt {
font-size: 13px;
color: #303133;
margin-bottom: 6px;
}
/* 考生作答按原样换行显示,避免长答案挤成一坨 */
.review-big-answer {
font-size: 13px;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
background: #fafafa;
border-radius: 3px;
padding: 8px 10px;
max-height: 220px;
overflow-y: auto;
}
.review-big-score {
font-size: 12px;
color: #909399;
margin-top: 6px;
}
.review-big-total {
border-top: 1px solid #e4e7ed;
margin-top: 12px;
padding-top: 10px;
text-align: right;
font-size: 13px;
color: #1a3a5c;
}
</style>