Files
competition-website/src/views/Admin.vue

3501 lines
133 KiB
Vue
Raw Normal View History

<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="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-divider direction="vertical" />
<span class="batch-score-label">批量设分:</span>
<span class="batch-score-item">选择题<el-input-number v-model="batchChoiceScore" :min="1" :max="100" size="small" controls-position="right" /></span>
<el-button size="small" v-guard="() => applyTypeScore('choice')">应用</el-button>
<span class="batch-score-item">判断题<el-input-number v-model="batchJudgeScore" :min="1" :max="100" size="small" controls-position="right" /></span>
<el-button size="small" v-guard="() => applyTypeScore('judge')">应用</el-button>
<el-divider direction="vertical" />
<el-button size="small" 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" :loading="bigQuestionsLoading" @click="fetchBigQuestions">刷新</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'" :data="pagedBigQuestions" stripe v-loading="bigQuestionsLoading" class="admin-table">
<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" />
<!-- ========== 综合大题:考生作答查证 ========== -->
<template v-if="activeQuestionType === 'big'">
<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" :loading="bigAnswersLoading" @click="fetchBigAnswers">刷新</el-button>
<el-tag type="info" class="total-score-tag">
共 {{ bigAnswersTotal }} 人,已抽题 {{ bigAnswersDrawn }} 人
</el-tag>
</div>
<el-table :data="filteredBigAnswers" stripe v-loading="bigAnswersLoading" class="admin-table">
<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">{{ row.bigScore }} 分</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="130" 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>
</template>
</el-table-column>
</el-table>
<el-empty v-if="filteredBigAnswers.length === 0 && !bigAnswersLoading" description="该组暂无考生数据" :image-size="80" />
</template>
<!-- 题目编辑弹窗 -->
<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="100" />
</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="100" />
</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="请输入题干,可插入表格…"
/>
</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>
<!-- 综合大题:作答详情 + 评分弹窗 -->
<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"
size="small"
controls-position="right"
/>
<span class="big-answer-score-max">/ {{ bigScorePerSub }} 分</span>
</div>
</div>
<div class="big-answer-total">
本大题合计:
<strong>{{ 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>
<!-- 考生答题记录 -->
<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">Word分:{{ 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 label="Word 答案" width="180" align="center">
<template #default="{ row }">
<span v-if="row.hasWordFile" style="color:#67c23a;font-size:12px">已上传</span>
<span v-else style="color:#909399;font-size:12px">未上传</span>
<el-button
v-if="row.hasWordFile"
text
type="primary"
size="small"
style="margin-left:8px"
v-guard="() => downloadExamWord(row)"
>下载</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button text type="warning" size="small" @click="openExamScoreDialog(row)">评分</el-button>
</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>
<!-- 考生答题评分弹窗(Objective 客观分只读,Word 答案分可改) -->
<el-dialog v-model="examScoreDialogVisible" title="评分" width="460px">
<el-form label-width="100px" v-if="examScoreDialogRow">
<el-form-item label="选手">{{ examScoreDialogRow.userName }}</el-form-item>
<el-form-item label="客观分(卷面)">
<span>{{ examScoreDialogRow.examScore ?? 0 }} 分(系统自动评分,不可修改)</span>
</el-form-item>
<el-form-item label="Word 答案分">
<el-input-number v-model="examScoreDialogWord" :min="0" :max="100" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="examScoreDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="examScoreSaving" v-guard="() => saveExamScore()">保存评分</el-button>
</template>
</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" />
<el-button 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" 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>
<el-button v-guard="() => exportCsv()" :disabled="!scoreExportGroup">导出CSV</el-button>
<el-button 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="Word文件" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.hasWordFile ? 'success' : 'info'" size="small">
{{ row.hasWordFile ? '已上传' : '未上传' }}
</el-tag>
</template>
</el-table-column>
<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="Word分" 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>
</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 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 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 } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
DataAnalysis, User, EditPen, FolderOpened,
Clock, Trophy, Notebook, PictureFilled, Rank, Files, Plus, Delete
} 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'
import RichEditor from '@/components/RichEditor.vue'
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) {
fetchQuestions()
fetchExamSubmissions()
// 综合大题页签下同步拉取题库与作答查证数据
if (activeQuestionType.value === 'big') {
fetchBigQuestions()
fetchBigAnswers()
}
}
if (index === 'materials') fetchAdminMaterials()
if (index === 'submissions' && submissionGroup.value) fetchSubmissions()
if (index === 'times') fetchTimes()
if (index === 'scores') { fetchPublishStatus(); fetchScoreList() }
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
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} 条` : ''}`)
}
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)
const questionForm = reactive({ group: 'zhongzhi', content: '', score: 5, 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
})
const bigScoreTotal = computed(() => {
let sum = 0
for (let i = 1; i <= bigSubCount.value; i++) {
sum += Number(bigScoreForm[`score_${i}`]) || 0
}
return sum
})
/** 剥标签取纯文本(列表内搜索用,不用于渲染) */
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() {
bigQuestionsLoading.value = true
try {
const data = await adminApi.getBigQuestions(questionGroup.value)
const result = data?.data || data
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)
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()
} 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
}
async function fetchBigAnswers() {
bigAnswersLoading.value = true
try {
const data = await adminApi.getBigAnswers(questionGroup.value)
const result = data?.data || data
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)
bigAnswers.value = []
bigAnswersTotal.value = 0
bigAnswersDrawn.value = 0
} finally {
bigAnswersLoading.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 {
const res = await adminApi.setBigAnswerScores(questionGroup.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
await fetchBigAnswers()
} catch (e) {
ElMessage.error(e?.message || '评分保存失败')
} finally {
bigScoreSaving.value = false
}
}
function onQuestionGroupChange() {
choicePage.value = 1
judgePage.value = 1
bigPage.value = 1
fetchQuestions()
if (activeQuestionType.value === 'big') {
fetchBigQuestions()
fetchBigAnswers()
}
if (activeMenu.value === 'questions') fetchExamSubmissions()
}
function onQuestionTypeChange(name) {
// 切到综合大题时按需拉取(切换 Tab 不重复请求)
if (name === 'big') {
if (bigQuestions.value.length === 0) fetchBigQuestions()
if (bigAnswers.value.length === 0) fetchBigAnswers()
}
}
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 = row.score || 5
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 = 5
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 导入 / 模板 ====================
const batchChoiceScore = ref(5)
const batchJudgeScore = ref(5)
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)
})
async function applyTypeScore(qType) {
const score = qType === 'choice' ? batchChoiceScore.value : batchJudgeScore.value
try {
await adminApi.setQuestions(questionGroup.value, qType, { action: 'setTypeScore', score })
ElMessage.success(`已将${qType === 'choice' ? '选择题' : '判断题'}统一设为 ${score} 分`)
fetchQuestions()
} catch (e) { ElMessage.error('批量设分失败') }
}
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() {
examSubmissionsLoading.value = true
try {
const data = await adminApi.getSubmissions(questionGroup.value)
examSubmissions.value = Array.isArray(data) ? data : (data?.list || [])
} catch (e) {
console.warn('获取答题记录失败', e)
examSubmissions.value = []
} finally { examSubmissionsLoading.value = false }
}
async function downloadExamWord(row) {
if (!row.hasWordFile) {
ElMessage.warning('该考生未上传 Word 答案')
return
}
const url = `/api/admin/submissions/${questionGroup.value}/${row.aggregationUid || row.userId}/word`
// safeDownload 自带「HTTP 状态 + content-type」双重校验,绝不会把后端错误 JSON 存成 .docx
await safeDownload(url, { fallbackName: row.wordFileName || '答题答案.docx' })
}
function onExamSubsFilterChange() {
examSubmissionsPage.value = 1
}
// 考生答题评分弹窗
const examScoreDialogVisible = ref(false)
const examScoreDialogRow = ref(null)
const examScoreDialogWord = ref(0)
const examScoreSaving = ref(false)
function openExamScoreDialog(row) {
examScoreDialogRow.value = row
examScoreDialogWord.value = row.wordScore ?? 0
examScoreDialogVisible.value = true
}
async function saveExamScore() {
if (!examScoreDialogRow.value) return
examScoreSaving.value = true
try {
await adminApi.setScores(questionGroup.value, {
scores: [{
contestantId: examScoreDialogRow.value.contestantId,
// 客观分回传弹窗所见值,避免后端按答题记录重算导致与所见不一致
examScore: Number(examScoreDialogRow.value.examScore ?? 0),
wordScore: Number(examScoreDialogWord.value),
}],
})
ElMessage.success('评分已保存')
examScoreDialogVisible.value = false
fetchExamSubmissions()
} catch (e) { ElMessage.error('保存失败') } finally { examScoreSaving.value = false }
}
// 监听问题组别切换时自动刷新答题记录
watch(questionGroup, () => {
if (activeMenu.value === 'questions') {
fetchExamSubmissions()
}
})
// ==================== 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()
if (submissionGroup.value === questionGroup.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('')
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 exportCsv() {
// safeDownload 自带「HTTP 状态 + content-type」双重校验,避免把错误 JSON 存成 .csv
await safeDownload(`/api/admin/export-csv/${scoreExportGroup.value}`, {
fallbackName: `${scoreExportGroup.value}_scores.csv`,
})
}
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
},
})
})
}
/** 保存轮播排序(批量更新,点击"保存排序"按钮触发) */
// ==================== 初始化 ====================
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;
}
.search-input { width: 220px; }
.filter-select { width: 140px; }
.batch-score-label { color: #606266; font-size: 13px; }
.batch-score-item { display: inline-flex; align-items: center; gap: 4px; color: #606266; font-size: 13px; }
.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;
}
/* 预览弹窗:按考生视角还原排版 */
.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;
}
/* ==================== 评分输入 ==================== */
.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; }
}
</style>