2901 lines
111 KiB
Vue
2901 lines
111 KiB
Vue
|
|
<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>
|
|||
|
|
<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>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 题型 Tab -->
|
|||
|
|
<el-tabs v-model="activeQuestionType" @tab-change="onQuestionTypeChange">
|
|||
|
|
<el-tab-pane label="选择题" name="choice" />
|
|||
|
|
<el-tab-pane label="判断题" name="judge" />
|
|||
|
|
<el-tab-pane label="综合性任务" name="word" />
|
|||
|
|
</el-tabs>
|
|||
|
|
|
|||
|
|
<!-- 选择题表格 -->
|
|||
|
|
<el-table v-if="activeQuestionType === 'choice'" :data="currentQuestions" stripe v-loading="questionsLoading" class="admin-table" @selection-change="onQuestionSelectionChange">
|
|||
|
|
<el-table-column type="selection" width="45" align="center" />
|
|||
|
|
<el-table-column type="index" width="50" label="#" :index="(idx) => (choicePage - 1) * choicePageSize + idx + 1" align="center" />
|
|||
|
|
<el-table-column prop="question" label="题目内容" min-width="280" show-overflow-tooltip />
|
|||
|
|
<el-table-column label="选项" width="200">
|
|||
|
|
<template #default="{ row }">
|
|||
|
|
<div v-if="Array.isArray(row.options)" class="choice-options-list">
|
|||
|
|
<div v-for="opt in row.options" :key="opt.key" class="choice-option-item">
|
|||
|
|
{{ opt.key }}. {{ opt.text }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<span v-else style="color:#909399">--</span>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column prop="answer" label="答案" width="80" align="center" />
|
|||
|
|
<el-table-column prop="score" label="分值" width="70" align="center" />
|
|||
|
|
<el-table-column label="操作" width="160" align="center">
|
|||
|
|
<template #default="{ row }">
|
|||
|
|
<el-button text type="primary" size="small" @click="openQuestionDialog(row, 'choice')">编辑</el-button>
|
|||
|
|
<el-button text type="danger" size="small" v-guard="() => deleteQuestion(row, 'choice')">删除</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
</el-table>
|
|||
|
|
<div class="pagination-wrap" v-if="activeQuestionType === 'choice' && filteredQuestions.length > 0">
|
|||
|
|
<el-pagination
|
|||
|
|
v-model:current-page="choicePage"
|
|||
|
|
v-model:page-size="choicePageSize"
|
|||
|
|
:page-sizes="[5, 10, 20, 50]"
|
|||
|
|
:total="filteredQuestions.length"
|
|||
|
|
layout="total, sizes, prev, pager, next, jumper"
|
|||
|
|
size="small"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 判断题表格 -->
|
|||
|
|
<el-table v-if="activeQuestionType === 'judge'" :data="currentQuestions" stripe v-loading="questionsLoading" class="admin-table" @selection-change="onQuestionSelectionChange">
|
|||
|
|
<el-table-column type="selection" width="45" align="center" />
|
|||
|
|
<el-table-column type="index" width="50" label="#" :index="(idx) => (judgePage - 1) * judgePageSize + idx + 1" align="center" />
|
|||
|
|
<el-table-column prop="question" label="题目内容" min-width="350" show-overflow-tooltip />
|
|||
|
|
<el-table-column label="答案" width="80" align="center">
|
|||
|
|
<template #default="{ row }">
|
|||
|
|
<el-tag :type="row.answer === 'true' ? 'success' : 'danger'" size="small">
|
|||
|
|
{{ row.answer === 'true' ? '正确' : '错误' }}
|
|||
|
|
</el-tag>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column prop="score" label="分值" width="70" align="center" />
|
|||
|
|
<el-table-column label="操作" width="160" align="center">
|
|||
|
|
<template #default="{ row }">
|
|||
|
|
<el-button text type="primary" size="small" @click="openQuestionDialog(row, 'judge')">编辑</el-button>
|
|||
|
|
<el-button text type="danger" size="small" v-guard="() => deleteQuestion(row, 'judge')">删除</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
</el-table>
|
|||
|
|
<div class="pagination-wrap" v-if="activeQuestionType === 'judge' && filteredQuestions.length > 0">
|
|||
|
|
<el-pagination
|
|||
|
|
v-model:current-page="judgePage"
|
|||
|
|
v-model:page-size="judgePageSize"
|
|||
|
|
:page-sizes="[5, 10, 20, 50]"
|
|||
|
|
:total="filteredQuestions.length"
|
|||
|
|
layout="total, sizes, prev, pager, next, jumper"
|
|||
|
|
size="small"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 综合性任务表格 -->
|
|||
|
|
<el-table v-if="activeQuestionType === 'word'" :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) => (wordPage - 1) * wordPageSize + idx + 1" align="center" />
|
|||
|
|
<el-table-column prop="title" label="任务标题" min-width="200" show-overflow-tooltip />
|
|||
|
|
<el-table-column prop="description" label="任务描述" min-width="200" show-overflow-tooltip />
|
|||
|
|
<el-table-column label="附件模板" width="120" align="center">
|
|||
|
|
<template #default="{ row }">
|
|||
|
|
<el-tag :type="row.template_path ? 'success' : 'info'" size="small">
|
|||
|
|
{{ row.template_path ? '已上传' : '未上传' }}
|
|||
|
|
</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, 'word')">编辑</el-button>
|
|||
|
|
<el-button text type="danger" size="small" v-guard="() => deleteQuestion(row, 'word')">删除</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
</el-table>
|
|||
|
|
<div class="pagination-wrap" v-if="activeQuestionType === 'word' && filteredQuestions.length > 0">
|
|||
|
|
<el-pagination
|
|||
|
|
v-model:current-page="wordPage"
|
|||
|
|
v-model:page-size="wordPageSize"
|
|||
|
|
:page-sizes="[5, 10, 20, 50]"
|
|||
|
|
:total="filteredQuestions.length"
|
|||
|
|
layout="total, sizes, prev, pager, next, jumper"
|
|||
|
|
size="small"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<el-empty v-if="currentQuestions.length === 0 && !questionsLoading" :description="'暂无' + questionTypeLabel + '题目'" :image-size="80" />
|
|||
|
|
|
|||
|
|
<!-- 题目编辑弹窗 -->
|
|||
|
|
<el-dialog v-model="questionDialogVisible" :title="dialogTitle" width="640px">
|
|||
|
|
<el-form :model="questionForm" label-position="top">
|
|||
|
|
<el-form-item label="组别">
|
|||
|
|
<el-select v-model="questionForm.group" class="w-full">
|
|||
|
|
<el-option label="中职组" value="zhongzhi" />
|
|||
|
|
<el-option label="高职组" value="gaozhi" />
|
|||
|
|
</el-select>
|
|||
|
|
</el-form-item>
|
|||
|
|
<!-- 选择题 -->
|
|||
|
|
<template v-if="editingQuestionType === 'choice'">
|
|||
|
|
<el-form-item label="题目内容">
|
|||
|
|
<el-input v-model="questionForm.content" type="textarea" :rows="3" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="分值">
|
|||
|
|
<el-input-number v-model="questionForm.score" :min="1" :max="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>
|
|||
|
|
<!-- 综合性任务 -->
|
|||
|
|
<template v-if="editingQuestionType === 'word'">
|
|||
|
|
<el-form-item label="任务标题">
|
|||
|
|
<el-input v-model="questionForm.content" placeholder="综合性任务标题" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="任务描述">
|
|||
|
|
<el-input v-model="questionForm.description" type="textarea" :rows="3" placeholder="综合性任务描述" />
|
|||
|
|
</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 v-if="wordTemplateInfo" class="word-template-info">
|
|||
|
|
<div class="template-name">
|
|||
|
|
<span>{{ wordTemplateInfo.fileName }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 8px; display: flex; gap: 8px; justify-content: flex-end;">
|
|||
|
|
<el-button type="primary" size="small" v-guard="() => uploadWordTemplateInDialog()" :loading="wordTemplateUploading">替换模板</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="word-template-empty">
|
|||
|
|
<span style="color:#909399">暂无附件模板</span>
|
|||
|
|
<el-button type="primary" size="small" v-guard="() => uploadWordTemplateInDialog()" :loading="wordTemplateUploading">上传模板</el-button>
|
|||
|
|
</div>
|
|||
|
|
</el-form-item>
|
|||
|
|
</template>
|
|||
|
|
</el-form>
|
|||
|
|
<template #footer>
|
|||
|
|
<el-button @click="questionDialogVisible = false">取消</el-button>
|
|||
|
|
<el-button type="primary" :loading="questionSaving" v-guard="() => saveQuestion()">保存</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-dialog>
|
|||
|
|
|
|||
|
|
<!-- 考生答题记录 -->
|
|||
|
|
<el-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导入
|
|||
|
|
* 3. 试卷管理 - CRUD + Word模板上传
|
|||
|
|
* 4. 作品管理 - 下载/评分/批量保存
|
|||
|
|
* 5. 时间设置 - 各组别时间配置
|
|||
|
|
* 6. 成绩管理 - 发布/撤回/导出/批量下载
|
|||
|
|
* 7. 新闻管理 - CRUD + 置顶/分组
|
|||
|
|
* 8. 轮播管理 - 添加/删除/排序/启用
|
|||
|
|
*/
|
|||
|
|
import { ref, reactive, onMounted, watch, computed, nextTick } from 'vue'
|
|||
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|||
|
|
import {
|
|||
|
|
DataAnalysis, User, EditPen, FolderOpened,
|
|||
|
|
Clock, Trophy, Notebook, PictureFilled, Rank, Files, Plus, Delete
|
|||
|
|
} from '@element-plus/icons-vue'
|
|||
|
|
import { useAuthStore } from '@/stores/auth'
|
|||
|
|
import * as adminApi from '@/api/admin'
|
|||
|
|
import * as newsApi from '@/api/news'
|
|||
|
|
import * as carouselApi from '@/api/carousel'
|
|||
|
|
import { formatFileSize, normalizeImageUrl } from '@/utils/upload'
|
|||
|
|
import { safeDownload, fetchBlobGuarded } from '@/utils/download'
|
|||
|
|
import RichEditor from '@/components/RichEditor.vue'
|
|||
|
|
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
// 轮播按钮"站内页面"可选项(公开首页按钮的常用跳转目标)
|
|||
|
|
const internalLinkOptions = [
|
|||
|
|
{ label: '激活账号', value: '/activate' },
|
|||
|
|
{ label: '选手登录', value: '/login' },
|
|||
|
|
{ label: '赛事信息', value: '/competition' },
|
|||
|
|
{ label: '资料下载', value: '/materials' },
|
|||
|
|
{ label: '成绩查询', value: '/score' },
|
|||
|
|
{ label: '返回首页', value: '/' },
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
// ==================== 导航 ====================
|
|||
|
|
const activeMenu = ref(sessionStorage.getItem('admin_activeMenu') || 'dashboard')
|
|||
|
|
|
|||
|
|
function onMenuSelect(index) {
|
|||
|
|
activeMenu.value = index
|
|||
|
|
sessionStorage.setItem('admin_activeMenu', index)
|
|||
|
|
// 切换时加载对应数据
|
|||
|
|
if (index === 'dashboard') fetchStats()
|
|||
|
|
if (index === 'accounts') fetchAccounts()
|
|||
|
|
if (index === 'questions' && questionGroup.value) { fetchQuestions(); fetchExamSubmissions() }
|
|||
|
|
if (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 wordQuestions = 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 wordPage = ref(1)
|
|||
|
|
const wordPageSize = ref(10)
|
|||
|
|
const questionDialogVisible = ref(false)
|
|||
|
|
const questionSaving = ref(false)
|
|||
|
|
const editingQuestion = ref(null)
|
|||
|
|
const questionForm = reactive({ group: 'zhongzhi', content: '', score: 5, optionA: '', optionB: '', optionC: '', optionD: '', answer: '', description: '' })
|
|||
|
|
|
|||
|
|
const questionTypeLabel = computed(() => {
|
|||
|
|
const map = { choice: '选择题', judge: '判断题', word: '综合性任务' }
|
|||
|
|
return map[activeQuestionType.value] || ''
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const filteredQuestions = computed(() => {
|
|||
|
|
const map = { choice: choiceQuestions, judge: judgeQuestions, word: wordQuestions }
|
|||
|
|
const list = map[activeQuestionType.value]?.value || []
|
|||
|
|
if (!questionSearch.value) return list
|
|||
|
|
const s = questionSearch.value.trim().toLowerCase()
|
|||
|
|
return list.filter(item => {
|
|||
|
|
const qType = activeQuestionType.value
|
|||
|
|
if (qType === 'word') {
|
|||
|
|
return (item.title || '').toLowerCase().includes(s) || (item.description || '').toLowerCase().includes(s)
|
|||
|
|
}
|
|||
|
|
return (item.question || '').toLowerCase().includes(s)
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const currentQuestions = computed(() => {
|
|||
|
|
const list = filteredQuestions.value
|
|||
|
|
const page = activeQuestionType.value === 'choice' ? choicePage.value :
|
|||
|
|
activeQuestionType.value === 'judge' ? judgePage.value : wordPage.value
|
|||
|
|
const pageSize = activeQuestionType.value === 'choice' ? choicePageSize.value :
|
|||
|
|
activeQuestionType.value === 'judge' ? judgePageSize.value : wordPageSize.value
|
|||
|
|
const start = (page - 1) * pageSize
|
|||
|
|
const end = start + pageSize
|
|||
|
|
return list.slice(start, end)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const dialogTitle = computed(() => {
|
|||
|
|
const prefix = editingQuestion.value?.id ? '编辑' : '添加'
|
|||
|
|
return prefix + questionTypeLabel.value
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Word 模板管理(试题编辑弹窗内)
|
|||
|
|
const wordTemplateInfo = ref(null)
|
|||
|
|
const wordTemplateUploading = ref(false)
|
|||
|
|
|
|||
|
|
// 监听题型变化,Word类型时获取模板信息
|
|||
|
|
watch(editingQuestionType, (type) => {
|
|||
|
|
if (questionDialogVisible.value && type === 'word' && questionForm.group) {
|
|||
|
|
fetchWordTemplateInfo()
|
|||
|
|
} else {
|
|||
|
|
wordTemplateInfo.value = null
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
async function fetchWordTemplateInfo() {
|
|||
|
|
if (!questionForm.group) return
|
|||
|
|
try {
|
|||
|
|
const data = await adminApi.getWordTemplate(questionForm.group)
|
|||
|
|
wordTemplateInfo.value = data?.data || data || null
|
|||
|
|
} catch (e) { wordTemplateInfo.value = null }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function uploadWordTemplateInDialog() {
|
|||
|
|
const input = document.createElement('input')
|
|||
|
|
input.type = 'file'
|
|||
|
|
input.accept = '.doc,.docx'
|
|||
|
|
input.onchange = async (e) => {
|
|||
|
|
const file = e.target.files[0]
|
|||
|
|
if (!file) return
|
|||
|
|
const fd = new FormData()
|
|||
|
|
fd.append('file', file)
|
|||
|
|
wordTemplateUploading.value = true
|
|||
|
|
try {
|
|||
|
|
await adminApi.uploadWordTemplate(questionForm.group, fd)
|
|||
|
|
ElMessage.success('模板上传成功')
|
|||
|
|
await fetchWordTemplateInfo()
|
|||
|
|
} catch (err) {
|
|||
|
|
ElMessage.error('上传失败')
|
|||
|
|
} finally { wordTemplateUploading.value = false }
|
|||
|
|
}
|
|||
|
|
input.click()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onQuestionGroupChange() {
|
|||
|
|
choicePage.value = 1
|
|||
|
|
judgePage.value = 1
|
|||
|
|
wordPage.value = 1
|
|||
|
|
fetchQuestions()
|
|||
|
|
if (activeMenu.value === 'questions') fetchExamSubmissions()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onQuestionTypeChange() {
|
|||
|
|
// Tab 切换时无需重新加载数据
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onQuestionSearchChange() {
|
|||
|
|
if (activeQuestionType.value === 'choice') choicePage.value = 1
|
|||
|
|
else if (activeQuestionType.value === 'judge') judgePage.value = 1
|
|||
|
|
else wordPage.value = 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function fetchQuestions() {
|
|||
|
|
questionsLoading.value = true
|
|||
|
|
try {
|
|||
|
|
const data = await adminApi.getQuestions(questionGroup.value)
|
|||
|
|
// 后端返回 { choice: [...], judge: [...], word: [...] } 或 { code: 0, data: { choice: [...], ... } }
|
|||
|
|
const result = data?.data || data
|
|||
|
|
choiceQuestions.value = result?.choice || []
|
|||
|
|
judgeQuestions.value = result?.judge || []
|
|||
|
|
wordQuestions.value = result?.word || []
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('获取题目失败', e)
|
|||
|
|
choiceQuestions.value = []
|
|||
|
|
judgeQuestions.value = []
|
|||
|
|
wordQuestions.value = []
|
|||
|
|
} finally { questionsLoading.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openQuestionDialog(row = null, qType = activeQuestionType.value) {
|
|||
|
|
editingQuestion.value = row
|
|||
|
|
editingQuestionType.value = qType
|
|||
|
|
wordTemplateInfo.value = null
|
|||
|
|
|
|||
|
|
if (row) {
|
|||
|
|
questionForm.group = questionGroup.value
|
|||
|
|
questionForm.score = row.score || 5
|
|||
|
|
|
|||
|
|
if (qType === 'choice') {
|
|||
|
|
questionForm.content = row.question || ''
|
|||
|
|
const opts = Array.isArray(row.options) ? row.options : []
|
|||
|
|
questionForm.optionA = (opts[0] && opts[0].text) || ''
|
|||
|
|
questionForm.optionB = (opts[1] && opts[1].text) || ''
|
|||
|
|
questionForm.optionC = (opts[2] && opts[2].text) || ''
|
|||
|
|
questionForm.optionD = (opts[3] && opts[3].text) || ''
|
|||
|
|
questionForm.answer = row.answer || ''
|
|||
|
|
} else if (qType === 'judge') {
|
|||
|
|
questionForm.content = row.question || ''
|
|||
|
|
questionForm.answer = row.answer || 'false'
|
|||
|
|
} else {
|
|||
|
|
questionForm.content = row.title || ''
|
|||
|
|
questionForm.description = row.description || ''
|
|||
|
|
fetchWordTemplateInfo()
|
|||
|
|
}
|
|||
|
|
} 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 = ''
|
|||
|
|
if (qType === 'word') fetchWordTemplateInfo()
|
|||
|
|
}
|
|||
|
|
questionDialogVisible.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveQuestion() {
|
|||
|
|
questionSaving.value = true
|
|||
|
|
try {
|
|||
|
|
const qType = editingQuestionType.value
|
|||
|
|
const payload = {
|
|||
|
|
score: questionForm.score,
|
|||
|
|
answer: qType === 'word' ? undefined : questionForm.answer,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (qType === 'choice') {
|
|||
|
|
payload.question = questionForm.content
|
|||
|
|
payload.options = [
|
|||
|
|
{ key: 'A', text: questionForm.optionA },
|
|||
|
|
{ key: 'B', text: questionForm.optionB },
|
|||
|
|
{ key: 'C', text: questionForm.optionC },
|
|||
|
|
{ key: 'D', text: questionForm.optionD },
|
|||
|
|
].filter(o => o.text)
|
|||
|
|
} else if (qType === 'judge') {
|
|||
|
|
payload.question = questionForm.content
|
|||
|
|
} else {
|
|||
|
|
payload.title = questionForm.content
|
|||
|
|
payload.description = questionForm.description
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 {
|
|||
|
|
await ElMessageBox.confirm(`确定批量删除选中的 ${ids.length} 道${qType === 'choice' ? '选择' : qType === 'judge' ? '判断' : '综合性任务'}题?`, '确认批量删除', { type: 'warning' })
|
|||
|
|
await adminApi.bulkDeleteQuestions(questionGroup.value, qType, ids)
|
|||
|
|
ElMessage.success(`已删除 ${ids.length} 道题目`)
|
|||
|
|
selectedQuestionRows.value = []
|
|||
|
|
fetchQuestions()
|
|||
|
|
} catch (e) { /* 取消 */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 客观题批量设分 / Excel 导入 / 模板 ====================
|
|||
|
|
const batchChoiceScore = ref(5)
|
|||
|
|
const batchJudgeScore = ref(5)
|
|||
|
|
const excelFileInput = ref(null)
|
|||
|
|
|
|||
|
|
// 客观题总分预览:当前组选择题 + 判断题分值之和
|
|||
|
|
const objectiveTotalScore = computed(() => {
|
|||
|
|
const sum = (arr) => (arr || []).reduce((s, q) => s + (Number(q.score) || 0), 0)
|
|||
|
|
return sum(choiceQuestions.value) + sum(judgeQuestions.value)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
async function applyTypeScore(qType) {
|
|||
|
|
const score = qType === 'choice' ? batchChoiceScore.value : batchJudgeScore.value
|
|||
|
|
try {
|
|||
|
|
await adminApi.setQuestions(questionGroup.value, qType, { action: 'setTypeScore', score })
|
|||
|
|
ElMessage.success(`已将${qType === 'choice' ? '选择题' : '判断题'}统一设为 ${score} 分`)
|
|||
|
|
fetchQuestions()
|
|||
|
|
} catch (e) { ElMessage.error('批量设分失败') }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function triggerExcelImport() {
|
|||
|
|
if (excelFileInput.value) excelFileInput.value.click()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function onExcelFileChange(e) {
|
|||
|
|
const file = e.target.files && e.target.files[0]
|
|||
|
|
if (!file) return
|
|||
|
|
const formData = new FormData()
|
|||
|
|
formData.append('file', file)
|
|||
|
|
try {
|
|||
|
|
const res = await adminApi.importQuestionsExcel(questionGroup.value, formData)
|
|||
|
|
const r = res || {}
|
|||
|
|
ElMessage.success(`导入完成:成功 ${r.imported || 0} 条,跳过 ${r.skipped || 0} 条`)
|
|||
|
|
if (r.errors && r.errors.length) {
|
|||
|
|
console.warn('Excel 导入跳过明细:', r.errors)
|
|||
|
|
ElMessage.warning(`有 ${r.errors.length} 行被跳过,详见控制台`)
|
|||
|
|
}
|
|||
|
|
fetchQuestions()
|
|||
|
|
} catch (err) {
|
|||
|
|
const msg = (err && err.response && err.response.data && err.response.data.message)
|
|||
|
|
|| (err && err.message) || '未知错误'
|
|||
|
|
ElMessage.error('Excel 导入失败:' + msg)
|
|||
|
|
} finally {
|
|||
|
|
e.target.value = ''
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function downloadQuestionTpl() {
|
|||
|
|
// safeDownload 自带「HTTP 状态 + content-type」双重校验,避免把错误 JSON 存成 .xlsx
|
|||
|
|
await safeDownload(`/api/admin/exam/${questionGroup.value}/questions/template`, {
|
|||
|
|
fallbackName: 'objective_questions_template.xlsx',
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 考生答题记录
|
|||
|
|
const examSubmissions = ref([])
|
|||
|
|
const examSubmissionsLoading = ref(false)
|
|||
|
|
const examSubmissionsPage = ref(1)
|
|||
|
|
const examSubmissionsPageSize = ref(10)
|
|||
|
|
const examSubsSearch = ref('')
|
|||
|
|
const examSubsDateRange = ref(null)
|
|||
|
|
|
|||
|
|
const filteredExamSubmissions = computed(() => {
|
|||
|
|
let list = examSubmissions.value
|
|||
|
|
if (examSubsSearch.value) {
|
|||
|
|
const s = examSubsSearch.value.trim().toLowerCase()
|
|||
|
|
list = list.filter(item => (item.userName || '').toLowerCase().includes(s))
|
|||
|
|
}
|
|||
|
|
if (examSubsDateRange.value && examSubsDateRange.value.length === 2) {
|
|||
|
|
const [start, end] = examSubsDateRange.value
|
|||
|
|
list = list.filter(item => {
|
|||
|
|
if (!item.submitTime) return false
|
|||
|
|
const itemDate = new Date(item.submitTime)
|
|||
|
|
return itemDate >= start && itemDate <= end
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return list
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const pagedExamSubmissions = computed(() => {
|
|||
|
|
const start = (examSubmissionsPage.value - 1) * examSubmissionsPageSize.value
|
|||
|
|
return filteredExamSubmissions.value.slice(start, start + examSubmissionsPageSize.value)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
async function fetchExamSubmissions() {
|
|||
|
|
examSubmissionsLoading.value = true
|
|||
|
|
try {
|
|||
|
|
const data = await adminApi.getSubmissions(questionGroup.value)
|
|||
|
|
examSubmissions.value = Array.isArray(data) ? data : (data?.list || [])
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('获取答题记录失败', e)
|
|||
|
|
examSubmissions.value = []
|
|||
|
|
} finally { examSubmissionsLoading.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function downloadExamWord(row) {
|
|||
|
|
if (!row.hasWordFile) {
|
|||
|
|
ElMessage.warning('该考生未上传 Word 答案')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const url = `/api/admin/submissions/${questionGroup.value}/${row.aggregationUid || row.userId}/word`
|
|||
|
|
// safeDownload 自带「HTTP 状态 + content-type」双重校验,绝不会把后端错误 JSON 存成 .docx
|
|||
|
|
await safeDownload(url, { fallbackName: row.wordFileName || '答题答案.docx' })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onExamSubsFilterChange() {
|
|||
|
|
examSubmissionsPage.value = 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 考生答题评分弹窗
|
|||
|
|
const examScoreDialogVisible = ref(false)
|
|||
|
|
const examScoreDialogRow = ref(null)
|
|||
|
|
const examScoreDialogWord = ref(0)
|
|||
|
|
const examScoreSaving = ref(false)
|
|||
|
|
function openExamScoreDialog(row) {
|
|||
|
|
examScoreDialogRow.value = row
|
|||
|
|
examScoreDialogWord.value = row.wordScore ?? 0
|
|||
|
|
examScoreDialogVisible.value = true
|
|||
|
|
}
|
|||
|
|
async function saveExamScore() {
|
|||
|
|
if (!examScoreDialogRow.value) return
|
|||
|
|
examScoreSaving.value = true
|
|||
|
|
try {
|
|||
|
|
await adminApi.setScores(questionGroup.value, {
|
|||
|
|
scores: [{
|
|||
|
|
contestantId: examScoreDialogRow.value.contestantId,
|
|||
|
|
// 客观分回传弹窗所见值,避免后端按答题记录重算导致与所见不一致
|
|||
|
|
examScore: Number(examScoreDialogRow.value.examScore ?? 0),
|
|||
|
|
wordScore: Number(examScoreDialogWord.value),
|
|||
|
|
}],
|
|||
|
|
})
|
|||
|
|
ElMessage.success('评分已保存')
|
|||
|
|
examScoreDialogVisible.value = false
|
|||
|
|
fetchExamSubmissions()
|
|||
|
|
} catch (e) { ElMessage.error('保存失败') } finally { examScoreSaving.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 监听问题组别切换时自动刷新答题记录
|
|||
|
|
watch(questionGroup, () => {
|
|||
|
|
if (activeMenu.value === 'questions') {
|
|||
|
|
fetchExamSubmissions()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// ==================== 3.5 备赛资料管理 ====================
|
|||
|
|
const materialsGroup = ref('benke')
|
|||
|
|
const adminMaterials = ref([])
|
|||
|
|
const materialsLoading = ref(false)
|
|||
|
|
const materialsPage = ref(1)
|
|||
|
|
const materialsPageSize = ref(10)
|
|||
|
|
const materialSearch = ref('')
|
|||
|
|
const materialDateRange = ref(null)
|
|||
|
|
|
|||
|
|
const filteredAdminMaterials = computed(() => {
|
|||
|
|
let list = adminMaterials.value
|
|||
|
|
if (materialSearch.value) {
|
|||
|
|
const s = materialSearch.value.trim().toLowerCase()
|
|||
|
|
list = list.filter(item => (item.fileName || '').toLowerCase().includes(s))
|
|||
|
|
}
|
|||
|
|
if (materialDateRange.value && materialDateRange.value.length === 2) {
|
|||
|
|
const [start, end] = materialDateRange.value
|
|||
|
|
list = list.filter(item => {
|
|||
|
|
if (!item.uploadTime) return false
|
|||
|
|
const itemDate = new Date(item.uploadTime)
|
|||
|
|
return itemDate >= start && itemDate <= end
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return list
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const pagedAdminMaterials = computed(() => {
|
|||
|
|
const start = (materialsPage.value - 1) * materialsPageSize.value
|
|||
|
|
return filteredAdminMaterials.value.slice(start, start + materialsPageSize.value)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
async function fetchAdminMaterials() {
|
|||
|
|
materialsLoading.value = true
|
|||
|
|
try {
|
|||
|
|
const data = await adminApi.getMaterials(materialsGroup.value)
|
|||
|
|
adminMaterials.value = Array.isArray(data) ? data : (data?.list || [])
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('获取备赛资料失败', e)
|
|||
|
|
adminMaterials.value = []
|
|||
|
|
} finally { materialsLoading.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function onMaterialUpload(file) {
|
|||
|
|
try {
|
|||
|
|
const formData = new FormData()
|
|||
|
|
formData.append('file', file.raw)
|
|||
|
|
await adminApi.uploadMaterial(materialsGroup.value, formData)
|
|||
|
|
ElMessage.success('资料上传成功')
|
|||
|
|
materialsPage.value = 1
|
|||
|
|
fetchAdminMaterials()
|
|||
|
|
} catch (e) { ElMessage.error('上传失败') }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onMaterialsGroupChange() {
|
|||
|
|
materialsPage.value = 1
|
|||
|
|
fetchAdminMaterials()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onMaterialFilterChange() {
|
|||
|
|
materialsPage.value = 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function onDeleteMaterial(fileName) {
|
|||
|
|
try {
|
|||
|
|
await ElMessageBox.confirm(`确定要删除 "${fileName}" 吗?`, '确认删除', { type: 'warning' })
|
|||
|
|
await adminApi.deleteMaterial(materialsGroup.value, fileName)
|
|||
|
|
ElMessage.success('资料已删除')
|
|||
|
|
materialsPage.value = 1
|
|||
|
|
fetchAdminMaterials()
|
|||
|
|
} catch (e) { /* 取消 */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 4. 作品管理 ====================
|
|||
|
|
const submissions = ref([])
|
|||
|
|
const submissionsLoading = ref(false)
|
|||
|
|
const submissionGroup = ref('benke')
|
|||
|
|
const submissionsPage = ref(1)
|
|||
|
|
const submissionsPageSize = ref(10)
|
|||
|
|
const submissionSearch = ref('')
|
|||
|
|
|
|||
|
|
const filteredSubmissions = computed(() => {
|
|||
|
|
let list = submissions.value
|
|||
|
|
if (submissionSearch.value) {
|
|||
|
|
const s = submissionSearch.value.trim().toLowerCase()
|
|||
|
|
list = list.filter(item => (item.name || '').toLowerCase().includes(s))
|
|||
|
|
}
|
|||
|
|
return list
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const pagedSubmissions = computed(() => {
|
|||
|
|
const start = (submissionsPage.value - 1) * submissionsPageSize.value
|
|||
|
|
return filteredSubmissions.value.slice(start, start + submissionsPageSize.value)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
async function fetchSubmissions() {
|
|||
|
|
if (!submissionGroup.value) return
|
|||
|
|
submissionsLoading.value = true
|
|||
|
|
try {
|
|||
|
|
const data = await adminApi.getWorks(submissionGroup.value)
|
|||
|
|
const raw = Array.isArray(data) ? data : (data?.list || [])
|
|||
|
|
submissions.value = raw.map(s => ({
|
|||
|
|
contestantId: s.contestantId,
|
|||
|
|
name: s.name,
|
|||
|
|
idNumber: s.idNumber,
|
|||
|
|
phone: s.phone,
|
|||
|
|
group: s.group,
|
|||
|
|
files: (s.files || []).map(f => ({ ...f, _score: f.score != null ? Number(f.score) : null })),
|
|||
|
|
}))
|
|||
|
|
} catch (e) { console.warn('获取作品列表失败', e) } finally { submissionsLoading.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onSubmissionGroupChange() {
|
|||
|
|
submissionsPage.value = 1
|
|||
|
|
fetchSubmissions()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onSubmissionFilterChange() {
|
|||
|
|
submissionsPage.value = 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 作品预览 / 下载
|
|||
|
|
const previewVisible = ref(false)
|
|||
|
|
const previewFile = ref(null)
|
|||
|
|
const previewRow = ref(null)
|
|||
|
|
const previewBlobUrl = ref('')
|
|||
|
|
const previewIsPdf = ref(false)
|
|||
|
|
|
|||
|
|
function fileDownloadUrl(group, f) {
|
|||
|
|
// 中职/高职 Word 答案存于 exam_submissions,走专用下载端点
|
|||
|
|
if (f.kind === 'answer') return `/api/admin/submissions/${group}/${f.id}/word`
|
|||
|
|
return `/api/admin/submission-file/${group}/${f.id}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function downloadWork(row, f) {
|
|||
|
|
const url = fileDownloadUrl(submissionGroup.value, f)
|
|||
|
|
await safeDownload(url, { fallbackName: f.fileName || 'file' })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function previewWork(row, f) {
|
|||
|
|
previewRow.value = row
|
|||
|
|
previewFile.value = f
|
|||
|
|
previewIsPdf.value = /\.pdf$/i.test(f.fileName || '')
|
|||
|
|
previewVisible.value = true
|
|||
|
|
if (previewIsPdf.value) {
|
|||
|
|
const r = await fetchBlobGuarded(fileDownloadUrl(submissionGroup.value, f))
|
|||
|
|
if (r.ok) {
|
|||
|
|
previewBlobUrl.value = URL.createObjectURL(r.blob)
|
|||
|
|
} else if (r.ignored) {
|
|||
|
|
// 同一文件正在预览加载,被去重忽略——静默跳过,不报错
|
|||
|
|
} else {
|
|||
|
|
ElMessage.error(r.message || '预览加载失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closePreview() {
|
|||
|
|
if (previewBlobUrl.value) { URL.revokeObjectURL(previewBlobUrl.value); previewBlobUrl.value = '' }
|
|||
|
|
previewFile.value = null
|
|||
|
|
previewRow.value = null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 批量下载作品(ZIP)
|
|||
|
|
async function batchDownloadWorks() {
|
|||
|
|
if (!submissionGroup.value) return
|
|||
|
|
await safeDownload(`/api/admin/batch-download/${submissionGroup.value}`, {
|
|||
|
|
fallbackName: `${submissionGroup.value}_works.zip`,
|
|||
|
|
timeout: 300000,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 批量保存评分(按文件聚合到选手的 wordScore / pptScore)
|
|||
|
|
async function batchSaveWorkScores() {
|
|||
|
|
try {
|
|||
|
|
const perContestant = {}
|
|||
|
|
submissions.value.forEach(s => {
|
|||
|
|
;(s.files || []).forEach(f => {
|
|||
|
|
if (f._score == null || f._score === '') return
|
|||
|
|
const c = (perContestant[s.contestantId] = perContestant[s.contestantId] || {})
|
|||
|
|
if (f.scoreField === 'ppt') c.pptScore = Number(f._score)
|
|||
|
|
else c.wordScore = Number(f._score)
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
const scores = Object.entries(perContestant).map(([contestantId, fields]) => ({ contestantId: Number(contestantId), ...fields }))
|
|||
|
|
if (scores.length === 0) { ElMessage.warning('没有需要保存的评分'); return }
|
|||
|
|
await adminApi.setScores(submissionGroup.value, { scores })
|
|||
|
|
ElMessage.success('评分保存成功')
|
|||
|
|
// 保存后立即刷新作品列表 + 考生答题记录,保证两处分数同步可见(评分不同步修复)
|
|||
|
|
await fetchSubmissions()
|
|||
|
|
if (submissionGroup.value === questionGroup.value) fetchExamSubmissions()
|
|||
|
|
} catch (e) { ElMessage.error('保存失败') }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 5. 时间设置 ====================
|
|||
|
|
const timesForm = reactive({
|
|||
|
|
zhongzhiStart: '', zhongzhiEnd: '', zhongzhiDeadline: '', zhongzhiChoiceCount: 50, zhongzhiJudgeCount: 20, zhongzhiAvailChoice: 0, zhongzhiAvailJudge: 0,
|
|||
|
|
gaozhiStart: '', gaozhiEnd: '', gaozhiDeadline: '', gaozhiChoiceCount: 50, gaozhiJudgeCount: 20, gaozhiAvailChoice: 0, gaozhiAvailJudge: 0,
|
|||
|
|
benkeStart: '', benkeEnd: '', benkeDeadline: '', benkeChoiceCount: 50, benkeJudgeCount: 20, benkeAvailChoice: 0, benkeAvailJudge: 0,
|
|||
|
|
jiaoshiStart: '', jiaoshiEnd: '', jiaoshiDeadline: '', jiaoshiChoiceCount: 50, jiaoshiJudgeCount: 20, jiaoshiAvailChoice: 0, jiaoshiAvailJudge: 0,
|
|||
|
|
})
|
|||
|
|
const timesSaving = ref(false)
|
|||
|
|
const timesLoading = ref(false)
|
|||
|
|
|
|||
|
|
const timeGroups = [
|
|||
|
|
{ key: 'zhongzhi', name: '中职组', isExam: true },
|
|||
|
|
{ key: 'gaozhi', name: '高职组', isExam: true },
|
|||
|
|
{ key: 'benke', name: '本科组', isExam: false },
|
|||
|
|
{ key: 'jiaoshi', name: '教师组', isExam: false },
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
async function fetchTimes() {
|
|||
|
|
timesLoading.value = true
|
|||
|
|
try {
|
|||
|
|
// 后端返回 { times: [{ group, startTime, endTime, deadline, choiceCount, judgeCount, availableChoice, availableJudge }] }
|
|||
|
|
const res = await adminApi.getTimes()
|
|||
|
|
const list = (res && res.times) || []
|
|||
|
|
const map = {}
|
|||
|
|
for (const t of list) map[t.group] = t
|
|||
|
|
for (const g of timeGroups) {
|
|||
|
|
const t = map[g.key]
|
|||
|
|
if (!t) continue
|
|||
|
|
timesForm[g.key + 'Start'] = t.startTime ? t.startTime : ''
|
|||
|
|
timesForm[g.key + 'End'] = t.endTime ? t.endTime : ''
|
|||
|
|
timesForm[g.key + 'Deadline'] = t.deadline ? t.deadline : ''
|
|||
|
|
timesForm[g.key + 'ChoiceCount'] = t.choiceCount != null ? t.choiceCount : 50
|
|||
|
|
timesForm[g.key + 'JudgeCount'] = t.judgeCount != null ? t.judgeCount : 20
|
|||
|
|
timesForm[g.key + 'AvailChoice'] = t.availableChoice || 0
|
|||
|
|
timesForm[g.key + 'AvailJudge'] = t.availableJudge || 0
|
|||
|
|
}
|
|||
|
|
} catch (e) { console.warn('获取时间配置失败', e) } finally { timesLoading.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveTimes() {
|
|||
|
|
timesSaving.value = true
|
|||
|
|
try {
|
|||
|
|
await adminApi.setTimes({
|
|||
|
|
zhongzhi: { start: timesForm.zhongzhiStart, end: timesForm.zhongzhiEnd, deadline: timesForm.zhongzhiDeadline, choiceCount: timesForm.zhongzhiChoiceCount, judgeCount: timesForm.zhongzhiJudgeCount },
|
|||
|
|
gaozhi: { start: timesForm.gaozhiStart, end: timesForm.gaozhiEnd, deadline: timesForm.gaozhiDeadline, choiceCount: timesForm.gaozhiChoiceCount, judgeCount: timesForm.gaozhiJudgeCount },
|
|||
|
|
benke: { start: timesForm.benkeStart, end: timesForm.benkeEnd, deadline: timesForm.benkeDeadline, choiceCount: timesForm.benkeChoiceCount, judgeCount: timesForm.benkeJudgeCount },
|
|||
|
|
jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount },
|
|||
|
|
})
|
|||
|
|
ElMessage.success('时间设置保存成功')
|
|||
|
|
} catch (e) { ElMessage.error('保存失败') } finally { timesSaving.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 6. 成绩管理 ====================
|
|||
|
|
const scoresPublishedMap = reactive({})
|
|||
|
|
const scoreExportGroup = ref('')
|
|||
|
|
const scoreList = ref([])
|
|||
|
|
const scoreListLoading = ref(false)
|
|||
|
|
const scoreListPage = ref(1)
|
|||
|
|
const scoreListPageSize = ref(10)
|
|||
|
|
const scoreSearch = ref('')
|
|||
|
|
const scoreStatusFilter = ref('')
|
|||
|
|
const scoreDateRange = ref(null)
|
|||
|
|
|
|||
|
|
const filteredScoreList = computed(() => {
|
|||
|
|
let list = scoreList.value
|
|||
|
|
if (scoreSearch.value) {
|
|||
|
|
const s = scoreSearch.value.trim().toLowerCase()
|
|||
|
|
list = list.filter(item =>
|
|||
|
|
(item.name || '').toLowerCase().includes(s) ||
|
|||
|
|
(item.idNumber || '').toLowerCase().includes(s)
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if (scoreStatusFilter.value !== '') {
|
|||
|
|
const isSubmitted = scoreStatusFilter.value === 'true'
|
|||
|
|
list = list.filter(item => item.submitted === isSubmitted)
|
|||
|
|
}
|
|||
|
|
if (scoreDateRange.value && scoreDateRange.value.length === 2) {
|
|||
|
|
const [start, end] = scoreDateRange.value
|
|||
|
|
list = list.filter(item => {
|
|||
|
|
if (!item.submitTime) return false
|
|||
|
|
const itemDate = new Date(item.submitTime)
|
|||
|
|
return itemDate >= start && itemDate <= end
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return list
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const pagedScoreList = computed(() => {
|
|||
|
|
const start = (scoreListPage.value - 1) * scoreListPageSize.value
|
|||
|
|
return filteredScoreList.value.slice(start, start + scoreListPageSize.value)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
async function onScoreGroupChange() {
|
|||
|
|
scoreListPage.value = 1
|
|||
|
|
await fetchPublishStatus()
|
|||
|
|
fetchScoreList()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onScoreFilterChange() {
|
|||
|
|
scoreListPage.value = 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function fetchPublishStatus() {
|
|||
|
|
if (!scoreExportGroup.value) return
|
|||
|
|
try {
|
|||
|
|
const data = await adminApi.getPublishStatus(scoreExportGroup.value)
|
|||
|
|
scoresPublishedMap[scoreExportGroup.value] = data?.published ?? false
|
|||
|
|
} catch (e) {
|
|||
|
|
scoresPublishedMap[scoreExportGroup.value] = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function fetchScoreList() {
|
|||
|
|
if (!scoreExportGroup.value) return
|
|||
|
|
scoreListLoading.value = true
|
|||
|
|
try {
|
|||
|
|
const data = await adminApi.getScores(scoreExportGroup.value)
|
|||
|
|
scoreList.value = (Array.isArray(data) ? data : (data?.list || [])).map(item => ({
|
|||
|
|
...item,
|
|||
|
|
idNumber: item.idNumber ?? item.id_number,
|
|||
|
|
submitted: item.submitted ?? false,
|
|||
|
|
submitTime: item.submitTime ?? null,
|
|||
|
|
}))
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('获取成绩列表失败', e)
|
|||
|
|
scoreList.value = []
|
|||
|
|
} finally { scoreListLoading.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function togglePublish() {
|
|||
|
|
const group = scoreExportGroup.value
|
|||
|
|
if (!group) return
|
|||
|
|
const current = scoresPublishedMap[group] ?? false
|
|||
|
|
try {
|
|||
|
|
await adminApi.publishScores(group, !current)
|
|||
|
|
scoresPublishedMap[group] = !current
|
|||
|
|
ElMessage.success(!current ? '成绩已发布' : '成绩已撤回')
|
|||
|
|
} catch (e) { ElMessage.error('操作失败') }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function exportCsv() {
|
|||
|
|
// safeDownload 自带「HTTP 状态 + content-type」双重校验,避免把错误 JSON 存成 .csv
|
|||
|
|
await safeDownload(`/api/admin/export-csv/${scoreExportGroup.value}`, {
|
|||
|
|
fallbackName: `${scoreExportGroup.value}_scores.csv`,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function batchDownload() {
|
|||
|
|
await safeDownload(`/api/admin/batch-download/${scoreExportGroup.value}`, {
|
|||
|
|
fallbackName: `${scoreExportGroup.value}_works.zip`,
|
|||
|
|
timeout: 300000,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 7. 新闻管理 ====================
|
|||
|
|
const adminNews = ref([])
|
|||
|
|
const adminNewsLoading = ref(false)
|
|||
|
|
const adminNewsPage = ref(1)
|
|||
|
|
const adminNewsPageSize = ref(10)
|
|||
|
|
const newsSearch = ref('')
|
|||
|
|
const newsGroupFilter = ref('')
|
|||
|
|
const newsDateRange = ref(null)
|
|||
|
|
const newsInitialLoadDone = ref(false)
|
|||
|
|
const newsCardListRef = ref(null)
|
|||
|
|
let newsSortable = null
|
|||
|
|
|
|||
|
|
const filteredAdminNews = computed(() => {
|
|||
|
|
let list = adminNews.value
|
|||
|
|
if (newsSearch.value) {
|
|||
|
|
const s = newsSearch.value.trim().toLowerCase()
|
|||
|
|
list = list.filter(item => (item.title || '').toLowerCase().includes(s))
|
|||
|
|
}
|
|||
|
|
if (newsGroupFilter.value) {
|
|||
|
|
list = list.filter(item => item.group === newsGroupFilter.value)
|
|||
|
|
}
|
|||
|
|
if (newsDateRange.value && newsDateRange.value.length === 2) {
|
|||
|
|
const [start, end] = newsDateRange.value
|
|||
|
|
list = list.filter(item => {
|
|||
|
|
if (!item.publish_time) return false
|
|||
|
|
const itemDate = new Date(item.publish_time)
|
|||
|
|
return itemDate >= start && itemDate <= end
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return list
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const pagedAdminNews = computed(() => {
|
|||
|
|
const start = (adminNewsPage.value - 1) * adminNewsPageSize.value
|
|||
|
|
return filteredAdminNews.value.slice(start, start + adminNewsPageSize.value)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const newsDialogVisible = ref(false)
|
|||
|
|
const newsSaving = ref(false)
|
|||
|
|
const editingNews = ref(null)
|
|||
|
|
const newsForm = reactive({ title: '', group: 'all', pinned: false, tags: [''], news_type: 'content', content: '', wx_link: '' })
|
|||
|
|
|
|||
|
|
async function fetchAdminNews() {
|
|||
|
|
adminNewsLoading.value = true
|
|||
|
|
newsInitialLoadDone.value = false
|
|||
|
|
try {
|
|||
|
|
const data = await adminApi.listNews()
|
|||
|
|
// 兼容后端返回:单层 { list: [...] } 或双层 { list: { list: [...] } }
|
|||
|
|
const listPayload = data?.list
|
|||
|
|
const list = Array.isArray(listPayload)
|
|||
|
|
? listPayload
|
|||
|
|
: (listPayload?.list ?? [])
|
|||
|
|
adminNews.value = list.map(item => ({
|
|||
|
|
...item,
|
|||
|
|
pinned: !!item.pinned,
|
|||
|
|
}))
|
|||
|
|
} catch (e) { console.warn('获取新闻失败', e) } finally { adminNewsLoading.value = false }
|
|||
|
|
// 延迟开启置顶开关,防止 el-switch 初始渲染时触发 @change 事件
|
|||
|
|
await nextTick()
|
|||
|
|
setTimeout(() => { newsInitialLoadDone.value = true }, 100)
|
|||
|
|
initNewsSortable()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openNewsDialog(row = null) {
|
|||
|
|
editingNews.value = row
|
|||
|
|
if (row) {
|
|||
|
|
newsForm.title = row.title || ''
|
|||
|
|
newsForm.group = row.group || 'all'
|
|||
|
|
newsForm.pinned = row.pinned || false
|
|||
|
|
const parsedTags = (row.tags || '').split(',').filter(Boolean)
|
|||
|
|
newsForm.tags = parsedTags.length > 0 ? parsedTags : ['']
|
|||
|
|
newsForm.news_type = row.news_type || 'content'
|
|||
|
|
newsForm.content = row.content || ''
|
|||
|
|
newsForm.wx_link = row.wx_link || ''
|
|||
|
|
} else {
|
|||
|
|
newsForm.title = ''
|
|||
|
|
newsForm.group = 'all'
|
|||
|
|
newsForm.pinned = false
|
|||
|
|
newsForm.tags = ['']
|
|||
|
|
newsForm.news_type = 'content'
|
|||
|
|
newsForm.content = ''
|
|||
|
|
newsForm.wx_link = ''
|
|||
|
|
}
|
|||
|
|
newsDialogVisible.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onNewsTypeChange() {
|
|||
|
|
// 切换类型时不再清空另一边的数据,保留用户已填写的内容
|
|||
|
|
// 后端会根据 news_type 决定使用哪个字段
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function addTag() {
|
|||
|
|
newsForm.tags.push('')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function removeTag(idx) {
|
|||
|
|
if (newsForm.tags.length > 1) {
|
|||
|
|
newsForm.tags.splice(idx, 1)
|
|||
|
|
} else {
|
|||
|
|
newsForm.tags[0] = ''
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveNews() {
|
|||
|
|
newsSaving.value = true
|
|||
|
|
try {
|
|||
|
|
const payload = {
|
|||
|
|
title: newsForm.title,
|
|||
|
|
group: newsForm.group,
|
|||
|
|
pinned: newsForm.pinned,
|
|||
|
|
tags: newsForm.tags.filter(Boolean).join(','),
|
|||
|
|
news_type: newsForm.news_type,
|
|||
|
|
content: newsForm.content,
|
|||
|
|
wx_link: newsForm.wx_link,
|
|||
|
|
}
|
|||
|
|
if (editingNews.value?.id) {
|
|||
|
|
await adminApi.updateNews(editingNews.value.id, payload)
|
|||
|
|
} else {
|
|||
|
|
await adminApi.createNews(payload)
|
|||
|
|
}
|
|||
|
|
ElMessage.success('保存成功')
|
|||
|
|
newsDialogVisible.value = false
|
|||
|
|
fetchAdminNews()
|
|||
|
|
} catch (e) { ElMessage.error('保存失败') } finally { newsSaving.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteAdminNews(row) {
|
|||
|
|
try {
|
|||
|
|
await ElMessageBox.confirm('确定删除该新闻?', '确认', { type: 'warning' })
|
|||
|
|
await adminApi.deleteNews(row.id)
|
|||
|
|
ElMessage.success('删除成功')
|
|||
|
|
fetchAdminNews()
|
|||
|
|
} catch (e) { /* 取消 */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function togglePinned(row, val) {
|
|||
|
|
if (!newsInitialLoadDone.value) return
|
|||
|
|
try {
|
|||
|
|
await adminApi.updateNews(row.id, { pinned: val })
|
|||
|
|
row.pinned = val
|
|||
|
|
ElMessage.success(val ? '已置顶' : '已取消置顶')
|
|||
|
|
} catch (e) {
|
|||
|
|
row.pinned = !val
|
|||
|
|
ElMessage.error('更新失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onNewsFilterChange() {
|
|||
|
|
adminNewsPage.value = 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 保存新闻排序 */
|
|||
|
|
async function saveNewsOrder() {
|
|||
|
|
const orders = adminNews.value.map((item, index) => ({
|
|||
|
|
id: item.id,
|
|||
|
|
sort_order: index + 1,
|
|||
|
|
}))
|
|||
|
|
try {
|
|||
|
|
await adminApi.updateNewsOrder(orders)
|
|||
|
|
ElMessage.success('排序保存成功')
|
|||
|
|
fetchAdminNews()
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('新闻排序保存失败:', e)
|
|||
|
|
ElMessage.error('排序保存失败,请刷新重试')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 初始化新闻卡片拖拽排序 */
|
|||
|
|
function initNewsSortable() {
|
|||
|
|
nextTick(() => {
|
|||
|
|
const el = newsCardListRef.value
|
|||
|
|
if (!el || !window.Sortable) return
|
|||
|
|
if (newsSortable) newsSortable.destroy()
|
|||
|
|
newsSortable = new window.Sortable(el, {
|
|||
|
|
handle: '.news-card__handle',
|
|||
|
|
animation: 200,
|
|||
|
|
ghostClass: 'news-card--ghost',
|
|||
|
|
onEnd: (evt) => {
|
|||
|
|
const items = el.querySelectorAll('.news-card')
|
|||
|
|
const newOrder = Array.from(items).map((card, index) => ({
|
|||
|
|
id: Number(card.dataset.id),
|
|||
|
|
sort_order: index + 1,
|
|||
|
|
}))
|
|||
|
|
const orderedMap = new Map(newOrder.map(o => [o.id, o]))
|
|||
|
|
const reordered = adminNews.value
|
|||
|
|
.map(item => {
|
|||
|
|
const ord = orderedMap.get(item.id)
|
|||
|
|
if (ord) {
|
|||
|
|
item.sort_order = ord.sort_order
|
|||
|
|
}
|
|||
|
|
return item
|
|||
|
|
})
|
|||
|
|
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
|
|||
|
|
adminNews.value = reordered
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 8. 轮播管理 ====================
|
|||
|
|
const adminCarousels = ref([])
|
|||
|
|
const carouselLoading = ref(false)
|
|||
|
|
const carouselDialogVisible = ref(false)
|
|||
|
|
const carouselSaving = ref(false)
|
|||
|
|
const carouselUploading = ref(false)
|
|||
|
|
const editingCarousel = ref(null)
|
|||
|
|
const carouselForm = reactive({
|
|||
|
|
image_url: '',
|
|||
|
|
link_url: '', // 遗留字段,保留兼容
|
|||
|
|
title: '',
|
|||
|
|
content: '', // 富文本正文(HTML)
|
|||
|
|
button_text: '', // 按钮文字
|
|||
|
|
button_link: '', // 按钮链接(站内路由或外链)
|
|||
|
|
link_mode: 'internal', // UI 用:internal=站内下拉 / external=外链输入
|
|||
|
|
enabled: true,
|
|||
|
|
})
|
|||
|
|
const carouselCardListRef = ref(null)
|
|||
|
|
let carouselSortable = null
|
|||
|
|
|
|||
|
|
const uploadUrl = '/api/admin/upload/image'
|
|||
|
|
const uploadHeaders = computed(() => {
|
|||
|
|
const token = authStore.token
|
|||
|
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function beforeCarouselUpload(file) {
|
|||
|
|
const isImage = file.type.startsWith('image/')
|
|||
|
|
if (!isImage) {
|
|||
|
|
ElMessage.error('只能上传图片文件')
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
const isLt5M = file.size / 1024 / 1024 < 5
|
|||
|
|
if (!isLt5M) {
|
|||
|
|
ElMessage.error('图片大小不能超过 5MB')
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
carouselUploading.value = true
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onCarouselUploadSuccess(response) {
|
|||
|
|
carouselUploading.value = false
|
|||
|
|
if (response && response.code === 0) {
|
|||
|
|
carouselForm.image_url = response.data.imageUrl || response.data.image_url || ''
|
|||
|
|
ElMessage.success('图片上传成功')
|
|||
|
|
} else {
|
|||
|
|
ElMessage.error(response?.message || '上传失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function fetchCarousels() {
|
|||
|
|
carouselLoading.value = true
|
|||
|
|
try {
|
|||
|
|
const data = await adminApi.listCarousel()
|
|||
|
|
adminCarousels.value = (data?.list || []).map(item => ({
|
|||
|
|
id: item.id,
|
|||
|
|
imageUrl: item.image_url ? normalizeImageUrl(item.image_url) : '',
|
|||
|
|
image_url: item.image_url || '',
|
|||
|
|
linkUrl: item.link_url || '',
|
|||
|
|
link_url: item.link_url || '',
|
|||
|
|
title: item.title || '',
|
|||
|
|
content: item.content || '',
|
|||
|
|
button_text: item.button_text || '',
|
|||
|
|
button_link: item.button_link || '',
|
|||
|
|
sortOrder: item.sort_order ?? 0,
|
|||
|
|
sort_order: item.sort_order ?? 0,
|
|||
|
|
enabled: item.enabled !== false,
|
|||
|
|
}))
|
|||
|
|
initCarouselSortable()
|
|||
|
|
} catch (e) { console.warn('获取轮播失败', e) } finally { carouselLoading.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 判断链接是否为外链(http/https/协议相对),据此推断编辑器链接模式
|
|||
|
|
function isExternalLink(link) {
|
|||
|
|
return /^(https?:)?\/\//i.test(link || '')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openCarouselDialog(row = null) {
|
|||
|
|
editingCarousel.value = row
|
|||
|
|
if (row) {
|
|||
|
|
// 编辑时底图回填原始存储值(未 normalize),避免把绝对 URL 再次入库
|
|||
|
|
carouselForm.image_url = row.image_url || ''
|
|||
|
|
carouselForm.link_url = row.link_url || row.linkUrl || ''
|
|||
|
|
carouselForm.title = row.title || ''
|
|||
|
|
carouselForm.content = row.content || ''
|
|||
|
|
carouselForm.button_text = row.button_text || ''
|
|||
|
|
carouselForm.button_link = row.button_link || ''
|
|||
|
|
carouselForm.link_mode = isExternalLink(row.button_link) ? 'external' : 'internal'
|
|||
|
|
carouselForm.enabled = row.enabled !== false
|
|||
|
|
} else {
|
|||
|
|
carouselForm.image_url = ''
|
|||
|
|
carouselForm.link_url = ''
|
|||
|
|
carouselForm.title = ''
|
|||
|
|
carouselForm.content = ''
|
|||
|
|
carouselForm.button_text = ''
|
|||
|
|
carouselForm.button_link = ''
|
|||
|
|
carouselForm.link_mode = 'internal'
|
|||
|
|
carouselForm.enabled = true
|
|||
|
|
}
|
|||
|
|
carouselDialogVisible.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveCarousel() {
|
|||
|
|
carouselSaving.value = true
|
|||
|
|
try {
|
|||
|
|
// link_mode 仅用于表单 UI,不入库;剥离后再提交
|
|||
|
|
const { link_mode, ...rest } = carouselForm
|
|||
|
|
const payload = {
|
|||
|
|
...rest,
|
|||
|
|
image_url: (carouselForm.image_url || '').trim() || null, // 空底图存 null,前端回退渐变
|
|||
|
|
button_text: (carouselForm.button_text || '').trim(),
|
|||
|
|
button_link: (carouselForm.button_link || '').trim(),
|
|||
|
|
}
|
|||
|
|
if (editingCarousel.value?.id) {
|
|||
|
|
await adminApi.updateCarousel(editingCarousel.value.id, payload)
|
|||
|
|
} else {
|
|||
|
|
await adminApi.addCarousel(payload)
|
|||
|
|
}
|
|||
|
|
ElMessage.success('保存成功')
|
|||
|
|
carouselDialogVisible.value = false
|
|||
|
|
fetchCarousels()
|
|||
|
|
} catch (e) { ElMessage.error('保存失败') } finally { carouselSaving.value = false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteCarouselItem(row) {
|
|||
|
|
try {
|
|||
|
|
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
|||
|
|
await adminApi.deleteCarousel(row.id)
|
|||
|
|
ElMessage.success('删除成功')
|
|||
|
|
fetchCarousels()
|
|||
|
|
} catch (e) { /* 取消 */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleCarousel(row) {
|
|||
|
|
try {
|
|||
|
|
await adminApi.updateCarousel(row.id, { enabled: row.enabled })
|
|||
|
|
} catch (e) { ElMessage.error('更新失败') }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 手动保存轮播排序 */
|
|||
|
|
async function saveCarouselOrder() {
|
|||
|
|
const orders = adminCarousels.value.map((item, index) => ({
|
|||
|
|
id: item.id,
|
|||
|
|
sort_order: index + 1,
|
|||
|
|
}))
|
|||
|
|
try {
|
|||
|
|
await adminApi.updateCarouselOrder(orders)
|
|||
|
|
ElMessage.success('排序保存成功')
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('轮播排序保存失败:', e)
|
|||
|
|
ElMessage.error('排序保存失败,请刷新重试')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 初始化轮播卡片拖拽排序 */
|
|||
|
|
function initCarouselSortable() {
|
|||
|
|
nextTick(() => {
|
|||
|
|
const el = carouselCardListRef.value
|
|||
|
|
if (!el || !window.Sortable) return
|
|||
|
|
if (carouselSortable) carouselSortable.destroy()
|
|||
|
|
carouselSortable = new window.Sortable(el, {
|
|||
|
|
handle: '.carousel-card__handle',
|
|||
|
|
animation: 200,
|
|||
|
|
ghostClass: 'carousel-card--ghost',
|
|||
|
|
onEnd: (evt) => {
|
|||
|
|
// 仅更新前端 DOM 顺序,不保存数据库
|
|||
|
|
const items = el.querySelectorAll('.carousel-card')
|
|||
|
|
const newOrder = Array.from(items).map((card, index) => ({
|
|||
|
|
id: Number(card.dataset.id),
|
|||
|
|
sort_order: index + 1,
|
|||
|
|
}))
|
|||
|
|
// 重新排列 adminCarousels 数组以匹配 DOM 顺序
|
|||
|
|
const orderedMap = new Map(newOrder.map(o => [o.id, o]))
|
|||
|
|
const reordered = adminCarousels.value
|
|||
|
|
.map(item => {
|
|||
|
|
const ord = orderedMap.get(item.id)
|
|||
|
|
if (ord) {
|
|||
|
|
item.sortOrder = ord.sort_order
|
|||
|
|
item.sort_order = ord.sort_order
|
|||
|
|
}
|
|||
|
|
return item
|
|||
|
|
})
|
|||
|
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
|||
|
|
adminCarousels.value = reordered
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 保存轮播排序(批量更新,点击"保存排序"按钮触发) */
|
|||
|
|
|
|||
|
|
// ==================== 初始化 ====================
|
|||
|
|
onMounted(() => {
|
|||
|
|
// 首次加载时根据当前活动菜单获取对应数据
|
|||
|
|
onMenuSelect(activeMenu.value)
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.admin-page {
|
|||
|
|
min-height: 100vh;
|
|||
|
|
background: #f0f2f5;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 顶部栏 ==================== */
|
|||
|
|
.admin-top-bar {
|
|||
|
|
background: #1a3a5c;
|
|||
|
|
color: #fff;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 0 24px;
|
|||
|
|
height: 56px;
|
|||
|
|
position: sticky;
|
|||
|
|
top: 0;
|
|||
|
|
z-index: 99;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.top-left { display: flex; align-items: center; gap: 10px; }
|
|||
|
|
.admin-logo { font-size: 22px; color: #c9a84c; }
|
|||
|
|
.admin-title { font-size: 16px; font-weight: 600; }
|
|||
|
|
.top-right { display: flex; gap: 8px; }
|
|||
|
|
|
|||
|
|
/* ==================== 主体 ==================== */
|
|||
|
|
.admin-body {
|
|||
|
|
display: flex;
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 侧边栏 ==================== */
|
|||
|
|
.admin-sidebar {
|
|||
|
|
width: 200px;
|
|||
|
|
background: #fff;
|
|||
|
|
border-right: 1px solid #e5e7eb;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.sidebar-menu {
|
|||
|
|
border-right: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 增强选中项对比度 */
|
|||
|
|
:deep(.el-menu-item.is-active) {
|
|||
|
|
background-color: #409eff !important;
|
|||
|
|
color: #fff !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.el-menu-item:hover) {
|
|||
|
|
background-color: #ecf5ff !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.el-menu-item.is-active:hover) {
|
|||
|
|
background-color: #66b1ff !important;
|
|||
|
|
color: #fff !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 内容区 ==================== */
|
|||
|
|
.admin-content {
|
|||
|
|
flex: 1;
|
|||
|
|
padding: 24px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-heading {
|
|||
|
|
font-size: 22px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: #1a3a5c;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
padding-bottom: 10px;
|
|||
|
|
border-bottom: 2px solid #c9a84c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 工具栏 ==================== */
|
|||
|
|
.toolbar {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 12px;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.search-input { width: 220px; }
|
|||
|
|
.filter-select { width: 140px; }
|
|||
|
|
|
|||
|
|
.batch-score-label { color: #606266; font-size: 13px; }
|
|||
|
|
.batch-score-item { display: inline-flex; align-items: center; gap: 4px; color: #606266; font-size: 13px; }
|
|||
|
|
.avail-hint { margin-left: 8px; color: #909399; font-size: 12px; }
|
|||
|
|
.total-score-tag { margin-left: 4px; }
|
|||
|
|
|
|||
|
|
/* ==================== 作品管理 ==================== */
|
|||
|
|
.work-file-row { display: flex; align-items: center; gap: 10px; padding: 4px 0; flex-wrap: wrap; }
|
|||
|
|
.work-file-name { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #303133; }
|
|||
|
|
.score-inline { display: inline-flex; align-items: center; gap: 4px; color: #606266; font-size: 13px; }
|
|||
|
|
.score-input { width: 110px; }
|
|||
|
|
.preview-frame { width: 100%; height: 70vh; border: 0; background: #fff; }
|
|||
|
|
.preview-file-name { color: #303133; font-weight: 600; margin-bottom: 8px; }
|
|||
|
|
|
|||
|
|
/* ==================== 统计卡片 ==================== */
|
|||
|
|
.stats-row { margin-bottom: 20px; }
|
|||
|
|
|
|||
|
|
.stat-card {
|
|||
|
|
text-align: center;
|
|||
|
|
border-top: 3px solid #1a3a5c;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-value {
|
|||
|
|
font-size: 32px;
|
|||
|
|
font-weight: 800;
|
|||
|
|
color: #1a3a5c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-label {
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: #6b7280;
|
|||
|
|
margin-top: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-group-name {
|
|||
|
|
font-size: 16px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-detail {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #6b7280;
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 统计口径切换 ==================== */
|
|||
|
|
.stats-scope-bar {
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-test-flag {
|
|||
|
|
margin-right: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 测试数据重置 ==================== */
|
|||
|
|
.reset-section {
|
|||
|
|
margin-top: 16px;
|
|||
|
|
padding: 16px;
|
|||
|
|
background: #fefce8;
|
|||
|
|
border: 1px dashed #f59e0b;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.reset-hint {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #92400e;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 表格 ==================== */
|
|||
|
|
.admin-table {
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 表头和内容对齐优化:表头左对齐,内容也左对齐 */
|
|||
|
|
:deep(.el-table th) {
|
|||
|
|
text-align: left;
|
|||
|
|
}
|
|||
|
|
:deep(.el-table td) {
|
|||
|
|
text-align: left;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.pagination-wrap {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
margin-top: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 时间设置 ==================== */
|
|||
|
|
.times-form { max-width: 900px; }
|
|||
|
|
|
|||
|
|
.time-group-card {
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.time-group-title {
|
|||
|
|
font-size: 16px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 轮播缩略图 ==================== */
|
|||
|
|
.carousel-thumb {
|
|||
|
|
width: 120px;
|
|||
|
|
height: 60px;
|
|||
|
|
object-fit: cover;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
}
|
|||
|
|
.carousel-thumb--gradient {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
background: linear-gradient(135deg, #0f2640 0%, #1a3a5c 30%, #2a5a8c 60%, #0f2640 100%);
|
|||
|
|
color: rgba(255, 255, 255, 0.9);
|
|||
|
|
font-size: 12px;
|
|||
|
|
letter-spacing: 1px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-upload {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
.carousel-form-preview {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
margin-top: 10px;
|
|||
|
|
}
|
|||
|
|
.carousel-form-preview img {
|
|||
|
|
width: 160px;
|
|||
|
|
height: 80px;
|
|||
|
|
object-fit: cover;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
border: 1px solid #e4e7ed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 轮播卡片拖拽 ==================== */
|
|||
|
|
.carousel-card-list {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 16px;
|
|||
|
|
min-height: 60px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 16px;
|
|||
|
|
width: 100%;
|
|||
|
|
max-width: 520px;
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
background: #fff;
|
|||
|
|
border: 2px solid #e4e7ed;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
cursor: default;
|
|||
|
|
transition: box-shadow 0.2s, border-color 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card:hover {
|
|||
|
|
border-color: #409eff;
|
|||
|
|
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.15);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card--disabled {
|
|||
|
|
opacity: 0.55;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card--ghost {
|
|||
|
|
opacity: 0.4;
|
|||
|
|
background: #ecf5ff;
|
|||
|
|
border-color: #409eff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card__handle {
|
|||
|
|
cursor: grab;
|
|||
|
|
color: #909399;
|
|||
|
|
font-size: 20px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card__handle:active {
|
|||
|
|
cursor: grabbing;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card__preview {
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card__preview .carousel-thumb {
|
|||
|
|
width: 120px;
|
|||
|
|
height: 68px;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card__info {
|
|||
|
|
flex: 1;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card__title {
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #303133;
|
|||
|
|
margin-bottom: 6px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card__meta {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 新闻卡片拖拽 ==================== */
|
|||
|
|
.news-card-list {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 12px;
|
|||
|
|
min-height: 60px;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
background: #fff;
|
|||
|
|
border: 2px solid #e4e7ed;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
cursor: default;
|
|||
|
|
transition: box-shadow 0.2s, border-color 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card:hover {
|
|||
|
|
border-color: #409eff;
|
|||
|
|
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.15);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card--ghost {
|
|||
|
|
opacity: 0.4;
|
|||
|
|
background: #ecf5ff;
|
|||
|
|
border-color: #409eff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card__handle {
|
|||
|
|
cursor: grab;
|
|||
|
|
color: #909399;
|
|||
|
|
font-size: 20px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card__handle:active {
|
|||
|
|
cursor: grabbing;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card__body {
|
|||
|
|
flex: 1;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card__title {
|
|||
|
|
font-size: 15px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #303133;
|
|||
|
|
margin-bottom: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card__meta {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 10px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card__group {
|
|||
|
|
color: #606266;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card__time {
|
|||
|
|
color: #909399;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-card__actions {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.news-tag {
|
|||
|
|
margin-left: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 标签输入框列表 */
|
|||
|
|
.tag-inputs {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
.tag-input-row {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
.tag-input {
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card__order {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #909399;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card__actions {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: stretch;
|
|||
|
|
gap: 4px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-card__actions .el-button {
|
|||
|
|
margin-left: 0 !important;
|
|||
|
|
justify-content: center;
|
|||
|
|
display: flex;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.drag-hint {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #909399;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 4px;
|
|||
|
|
margin-left: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-subheading {
|
|||
|
|
font-size: 18px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #374151;
|
|||
|
|
margin: 24px 0 12px 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== Word 模板管理 ==================== */
|
|||
|
|
/* ==================== 选择题选项 ==================== */
|
|||
|
|
.choice-options {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
.choice-input {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Word 模板管理(弹窗内) */
|
|||
|
|
.word-template-info .template-name {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 12px;
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
background: #f0f9eb;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
word-break: break-all;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.word-template-empty {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 12px;
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
background: #f5f7fa;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 评分输入 ==================== */
|
|||
|
|
.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>
|