2026-08-17 12:25:42 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="admin-page">
|
|
|
|
|
|
<!-- ==================== 顶部栏 ==================== -->
|
|
|
|
|
|
<div class="admin-top-bar">
|
|
|
|
|
|
<div class="top-left">
|
|
|
|
|
|
<span class="admin-logo">☼</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>
|
2026-09-21 11:17:59 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 客观题工具栏:仅选择题 / 判断题页签可见 -->
|
|
|
|
|
|
<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>
|
2026-08-17 12:25:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 题型 Tab -->
|
|
|
|
|
|
<el-tabs v-model="activeQuestionType" @tab-change="onQuestionTypeChange">
|
|
|
|
|
|
<el-tab-pane label="选择题" name="choice" />
|
|
|
|
|
|
<el-tab-pane label="判断题" name="judge" />
|
2026-09-21 11:17:59 +08:00
|
|
|
|
<el-tab-pane label="综合大题" name="big" />
|
2026-08-17 12:25:42 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-09-21 11:17:59 +08:00
|
|
|
|
<!-- 综合大题题库表格 -->
|
|
|
|
|
|
<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">
|
2026-08-17 12:25:42 +08:00
|
|
|
|
<template #default="{ row }">
|
2026-09-21 11:17:59 +08:00
|
|
|
|
<div class="big-preview" v-html="previewHtml(row.description)"></div>
|
2026-08-17 12:25:42 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
2026-09-21 11:17:59 +08:00
|
|
|
|
<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">
|
2026-08-17 12:25:42 +08:00
|
|
|
|
<template #default="{ row }">
|
2026-09-21 11:17:59 +08:00
|
|
|
|
<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>
|
2026-08-17 12:25:42 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
2026-09-21 11:17:59 +08:00
|
|
|
|
<div class="pagination-wrap" v-if="activeQuestionType === 'big' && filteredBigQuestions.length > 0">
|
2026-08-17 12:25:42 +08:00
|
|
|
|
<el-pagination
|
2026-09-21 11:17:59 +08:00
|
|
|
|
v-model:current-page="bigPage"
|
|
|
|
|
|
v-model:page-size="bigPageSize"
|
2026-08-17 12:25:42 +08:00
|
|
|
|
:page-sizes="[5, 10, 20, 50]"
|
2026-09-21 11:17:59 +08:00
|
|
|
|
:total="filteredBigQuestions.length"
|
2026-08-17 12:25:42 +08:00
|
|
|
|
layout="total, sizes, prev, pager, next, jumper"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-21 11:17:59 +08:00
|
|
|
|
<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>
|
2026-08-17 12:25:42 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 题目编辑弹窗 -->
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-09-21 11:17:59 +08:00
|
|
|
|
<!-- 综合大题:新增/编辑弹窗 -->
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-08-17 12:25:42 +08:00
|
|
|
|
<!-- 考生答题记录 -->
|
|
|
|
|
|
<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,如 <br> 换行)">
|
|
|
|
|
|
<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导入
|
2026-09-21 11:17:59 +08:00
|
|
|
|
* 3. 试卷管理 - 客观题 CRUD(选择/判断);综合大题题库 CRUD + 作答查证/评分
|
2026-08-17 12:25:42 +08:00
|
|
|
|
* 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'
|
2026-09-21 11:17:59 +08:00
|
|
|
|
import { sanitizeHtml, isRichTextEmpty } from '@/utils/richtext'
|
2026-08-17 12:25:42 +08:00
|
|
|
|
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()
|
2026-09-21 11:17:59 +08:00
|
|
|
|
if (index === 'questions' && questionGroup.value) {
|
|
|
|
|
|
fetchQuestions()
|
|
|
|
|
|
fetchExamSubmissions()
|
|
|
|
|
|
// 综合大题页签下同步拉取题库与作答查证数据
|
|
|
|
|
|
if (activeQuestionType.value === 'big') {
|
|
|
|
|
|
fetchBigQuestions()
|
|
|
|
|
|
fetchBigAnswers()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-17 12:25:42 +08:00
|
|
|
|
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(() => {
|
2026-09-21 11:17:59 +08:00
|
|
|
|
const map = { choice: '选择题', judge: '判断题', big: '综合大题' }
|
2026-08-17 12:25:42 +08:00
|
|
|
|
return map[activeQuestionType.value] || ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const filteredQuestions = computed(() => {
|
2026-09-21 11:17:59 +08:00
|
|
|
|
// 综合大题走独立的 bigQuestions 链路(不同接口与表),不参与客观题筛选
|
|
|
|
|
|
const map = { choice: choiceQuestions, judge: judgeQuestions }
|
2026-08-17 12:25:42 +08:00
|
|
|
|
const list = map[activeQuestionType.value]?.value || []
|
|
|
|
|
|
if (!questionSearch.value) return list
|
|
|
|
|
|
const s = questionSearch.value.trim().toLowerCase()
|
2026-09-21 11:17:59 +08:00
|
|
|
|
return list.filter(item => (item.question || '').toLowerCase().includes(s))
|
2026-08-17 12:25:42 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const currentQuestions = computed(() => {
|
|
|
|
|
|
const list = filteredQuestions.value
|
2026-09-21 11:17:59 +08:00
|
|
|
|
const page = activeQuestionType.value === 'choice' ? choicePage.value : judgePage.value
|
|
|
|
|
|
const pageSize = activeQuestionType.value === 'choice' ? choicePageSize.value : judgePageSize.value
|
2026-08-17 12:25:42 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-21 11:17:59 +08:00
|
|
|
|
// ==================== 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,
|
|
|
|
|
|
})
|
2026-08-17 12:25:42 +08:00
|
|
|
|
|
2026-09-21 11:17:59 +08:00
|
|
|
|
// 预览弹窗
|
|
|
|
|
|
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))
|
2026-08-17 12:25:42 +08:00
|
|
|
|
}
|
2026-09-21 11:17:59 +08:00
|
|
|
|
if (bigAnswerFilter.value === 'drawn') list = list.filter(r => r.drawn)
|
|
|
|
|
|
else if (bigAnswerFilter.value === 'undrawn') list = list.filter(r => !r.drawn)
|
|
|
|
|
|
return list
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/** 详情弹窗内该考生的小问提示:优先用抽题快照(防管理员改题干后口径不一致) */
|
|
|
|
|
|
const bigSubPromptsOfRow = computed(() => {
|
|
|
|
|
|
const snap = bigAnswerRow.value && bigAnswerRow.value.questionSnapshot
|
|
|
|
|
|
const prompts = (snap && snap.sub_prompts) || []
|
|
|
|
|
|
const out = []
|
|
|
|
|
|
for (let i = 0; i < bigSubCount.value; i++) out.push(prompts[i] || '')
|
|
|
|
|
|
return out
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/** 详情弹窗内该考生的每小题满分:优先用快照分值,回退题库配置 */
|
|
|
|
|
|
const bigRowScorePerSub = computed(() => {
|
|
|
|
|
|
const snap = bigAnswerRow.value && bigAnswerRow.value.questionSnapshot
|
|
|
|
|
|
const v = snap && Number(snap.score_per_sub)
|
|
|
|
|
|
return v > 0 ? v : bigScorePerSub.value
|
2026-08-17 12:25:42 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-21 11:17:59 +08:00
|
|
|
|
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
|
2026-08-17 12:25:42 +08:00
|
|
|
|
try {
|
2026-09-21 11:17:59 +08:00
|
|
|
|
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
|
2026-08-17 12:25:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onQuestionGroupChange() {
|
|
|
|
|
|
choicePage.value = 1
|
|
|
|
|
|
judgePage.value = 1
|
2026-09-21 11:17:59 +08:00
|
|
|
|
bigPage.value = 1
|
2026-08-17 12:25:42 +08:00
|
|
|
|
fetchQuestions()
|
2026-09-21 11:17:59 +08:00
|
|
|
|
if (activeQuestionType.value === 'big') {
|
|
|
|
|
|
fetchBigQuestions()
|
|
|
|
|
|
fetchBigAnswers()
|
|
|
|
|
|
}
|
2026-08-17 12:25:42 +08:00
|
|
|
|
if (activeMenu.value === 'questions') fetchExamSubmissions()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 11:17:59 +08:00
|
|
|
|
function onQuestionTypeChange(name) {
|
|
|
|
|
|
// 切到综合大题时按需拉取(切换 Tab 不重复请求)
|
|
|
|
|
|
if (name === 'big') {
|
|
|
|
|
|
if (bigQuestions.value.length === 0) fetchBigQuestions()
|
|
|
|
|
|
if (bigAnswers.value.length === 0) fetchBigAnswers()
|
|
|
|
|
|
}
|
2026-08-17 12:25:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-09-21 11:17:59 +08:00
|
|
|
|
// 后端返回 { choice: [...], judge: [...] } 或 { code: 0, data: { choice: [...], ... } }
|
2026-08-17 12:25:42 +08:00
|
|
|
|
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 || ''
|
2026-09-21 11:17:59 +08:00
|
|
|
|
} else {
|
2026-08-17 12:25:42 +08:00
|
|
|
|
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,
|
2026-09-21 11:17:59 +08:00
|
|
|
|
answer: questionForm.answer,
|
2026-08-17 12:25:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-09-21 11:17:59 +08:00
|
|
|
|
payload.question = questionForm.content
|
2026-08-17 12:25:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-09-21 11:17:59 +08:00
|
|
|
|
const label = qType === 'choice' ? '选择' : '判断'
|
|
|
|
|
|
await ElMessageBox.confirm(`确定批量删除选中的 ${ids.length} 道${label}题?`, '确认批量删除', { type: 'warning' })
|
2026-08-17 12:25:42 +08:00
|
|
|
|
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%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 11:17:59 +08:00
|
|
|
|
/* ==================== 综合大题(题库 / 预览 / 作答查证) ==================== */
|
|
|
|
|
|
|
|
|
|
|
|
/* 题库列表内的题干缩略预览:表格需收敛为紧凑样式,避免撑破单元格 */
|
|
|
|
|
|
.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 {
|
2026-08-17 12:25:42 +08:00
|
|
|
|
font-size: 14px;
|
2026-09-21 11:17:59 +08:00
|
|
|
|
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;
|
2026-08-17 12:25:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 11:17:59 +08:00
|
|
|
|
/* 作答详情弹窗 */
|
|
|
|
|
|
.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 {
|
2026-08-17 12:25:42 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
2026-09-21 11:17:59 +08:00
|
|
|
|
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;
|
2026-08-17 12:25:42 +08:00
|
|
|
|
border-radius: 4px;
|
2026-09-21 11:17:59 +08:00
|
|
|
|
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;
|
2026-08-17 12:25:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ==================== 评分输入 ==================== */
|
|
|
|
|
|
.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>
|