feat(admin): 新增题库模块并优化比赛管理功能

新增题库模块,包含题目管理页面和Excel导入导出功能
优化比赛管理功能,包括分组管理、硬件绑定和题目配置
重构比赛添加流程,增加重置功能和数据校验
添加Excel工具函数,支持读取和导出Excel文件
更新依赖,添加xlsx库支持
This commit is contained in:
2026-01-21 16:29:38 +08:00
parent 3270e24c25
commit 89773bd49b
33 changed files with 1988 additions and 496 deletions

View File

@ -1,235 +1,249 @@
<script setup lang="ts">
import { NGrid, NGridItem, NSelect } from 'naive-ui'
import { computed } from 'vue'
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect } from 'naive-ui'
import { ref, watch } from 'vue'
import { useQuestionConfig } from './useQuestionConfig'
const props = defineProps<{
modelValue: {
questions: {
regular: Array<{ value: string, score: number, time: number }>
pk: Array<{ value: string, score: number, time: number }>
}
rounds: Array<{
id: string
name: string
questions: Array<{ value: string | null, score: number, time: number }>
}>
}
}>()
const questionOptions = [
{ label: '汉字听写 - 根据提示书写汉字', value: 'hanzi' },
{ label: '同音字辨析 - 根据拼音书写', value: 'tongyin' },
{ label: '偏旁部首 - 按要求写字', value: 'pianpang' },
{ label: '词语听写 - 综合练习', value: 'ciyu' },
{ label: '成语填空 - 四字成语', value: 'chengyu' },
{ label: '反义词挑战', value: 'fanyi' },
{ label: '近义词挑战', value: 'jinyi' },
{ label: '看图猜字', value: 'kantu' },
{ label: '古诗词接龙', value: 'gushi' },
{ label: '名句听写', value: 'mingju' },
]
const {
getRoundScore,
getRoundTime,
totalStats,
questionOptions,
scoreOptions,
timeOptions,
addRound,
removeRound,
addQuestion,
removeQuestion,
validate,
reset,
} = useQuestionConfig(props)
const pkOptions = [
{ label: '极速成语接龙 - 每题10秒', value: 'speed10' },
{ label: '极速成语接龙 - 每题20秒', value: 'speed20' },
]
const scoreOptions = [
{ label: '1分', value: 1 },
{ label: '2分', value: 2 },
{ label: '3分', value: 3 },
{ label: '5分', value: 5 },
{ label: '10分', value: 10 },
]
const timeOptions = [
{ label: '15s', value: 15 },
{ label: '30s', value: 30 },
{ label: '45s', value: 45 },
{ label: '60s', value: 60 },
{ label: '90s', value: 90 },
]
const regularTotalScore = computed(() => {
return props.modelValue?.questions?.regular?.reduce((acc, cur) => acc + cur.score, 0) || 0
// 模态框控制
const showAddModal = ref(false)
const newRoundForm = ref({
name: '',
count: 10,
})
const pkTotalScore = computed(() => {
return props.modelValue?.questions?.pk?.reduce((acc, cur) => acc + cur.score, 0) || 0
})
function validate() {
// 简单校验:主赛环节是否有题目
if (!props.modelValue?.questions?.regular?.length) {
window.$message?.error('请至少配置一道主赛题目')
return false
function handleOpenAddModal() {
newRoundForm.value = {
name: `${(props.modelValue?.rounds?.length || 0) + 1}轮 汉字听写`,
count: 10,
}
// 检查是否所有题目都选了题型
const invalidRegular = props.modelValue.questions.regular.some(q => !q.value)
if (invalidRegular) {
window.$message?.error('请完善主赛环节的题型配置')
return false
}
return true
showAddModal.value = true
}
defineExpose({ validate })
function handleConfirmAdd() {
if (!newRoundForm.value.name) {
window.$message?.warning('请输入环节名称')
return
}
addRound(newRoundForm.value.name, newRoundForm.value.count)
showAddModal.value = false
}
// 初始化默认数据
watch(() => props.modelValue, () => {
// 如果没有任何轮次,显示空状态,不再自动添加
// 保持空状态以便用户看到"暂无配置题包"的界面
}, { immediate: true })
defineExpose({ validate, reset })
</script>
<template>
<div class="py-4">
<div class="mb-8 text-xl text-gray-800 font-bold">
比赛赛题包配置
<div class="h-full flex flex-col">
<!-- 顶部标题栏 -->
<div class="mb-8 flex items-center justify-between">
<div>
<div class="text-2xl text-gray-800 font-bold tracking-tight">
赛程题包配置
</div>
<div class="mt-2 flex items-center gap-4 text-sm text-gray-500">
<span>您可以为比赛添加多个阶段的题包并自定义题目分值与限时</span>
<div v-if="totalStats.count > 0" class="flex items-center gap-3 border border-gray-100 rounded-lg bg-gray-50 px-3 py-1 text-xs font-bold">
<div class="flex items-center gap-1">
<span class="text-gray-400">总题数</span>
<span class="text-sm text-gray-700">{{ totalStats.count }}</span>
</div>
<div class="h-3 w-[1px] bg-gray-200" />
<div class="flex items-center gap-1">
<span class="text-gray-400">总分</span>
<span class="text-sm text-orange-500">{{ totalStats.score }}</span>
</div>
<div class="h-3 w-[1px] bg-gray-200" />
<div class="flex items-center gap-1">
<span class="text-gray-400">总时长</span>
<span class="text-sm text-blue-500">{{ totalStats.time }}s</span>
</div>
</div>
</div>
</div>
<NButton type="primary" class="shadow-blue-500/20 shadow-lg !h-10 !rounded-lg !px-6 !font-bold" @click="handleOpenAddModal">
<template #icon>
<icon-ic-round-plus />
</template>
新增赛程题包
</NButton>
</div>
<NGrid :x-gap="48" :y-gap="24" cols="1 l:2" responsive="screen">
<!-- 左侧主赛环节 -->
<NGridItem>
<div class="h-full rounded-2xl bg-[#F5F9FF] p-6">
<div class="mb-6 flex items-center justify-between border-b border-blue-100 pb-4">
<div class="flex items-center gap-2 text-gray-800 font-bold">
<icon-ic-outline-archive class="text-xl text-blue-500" />
<span class="text-lg">主赛环节</span>
<!-- 空状态 -->
<div v-if="!props.modelValue?.rounds?.length" class="flex flex-col flex-1 items-center justify-center border-2 border-gray-100 rounded-2xl border-dashed bg-gray-50/30">
<div class="h-34 w-34 flex items-center justify-center rounded-3xl bg-gray-100 text-gray-300">
<icon-ic-outline-folder-off class="text-5xl" />
</div>
<div class="mt-6 text-lg text-gray-400 font-bold">
暂无配置题包
</div>
<div class="mt-2 text-sm text-gray-500">
请点击右上角按钮开始添加比赛环节
</div>
</div>
<!-- 列表内容 -->
<div v-else class="flex flex-col gap-6 pb-10">
<div v-for="(round, roundIndex) in props.modelValue?.rounds" :key="round.id" class="group relative overflow-hidden border border-gray-100 rounded-2xl bg-white p-1 shadow-sm transition-all hover:shadow-md">
<!-- 侧边装饰条 -->
<div class="absolute bottom-0 left-0 top-0 w-1.5 bg-blue-500" />
<div class="p-6 pl-8">
<!-- 头部信息 -->
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="h-8 w-1.5 rounded-full bg-blue-500" />
<div class="flex items-center gap-3">
<NInput
v-model:value="round.name"
placeholder="请输入环节名称"
:bordered="false"
class="border transition-all !w-80 !border-transparent !rounded-lg !bg-transparent !text-xl !font-bold focus-within:shadow-sm focus-within:!border-blue-500 hover:!border-gray-200 focus-within:!bg-white"
/>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500 font-bold">
<icon-ic-outline-format-list-numbered class="text-sm" />
{{ round.questions.length }}
</div>
<div class="flex items-center gap-1 border border-orange-100 rounded-full bg-orange-50 px-3 py-1 text-xs text-orange-500 font-bold">
<icon-ic-round-star-border class="text-sm" />
{{ getRoundScore(roundIndex) }}
</div>
<div class="flex items-center gap-1 border border-blue-100 rounded-full bg-blue-50 px-3 py-1 text-xs text-blue-500 font-bold">
<icon-ic-outline-access-time class="text-sm" />
{{ getRoundTime(roundIndex) }}
</div>
</div>
</div>
</div>
<div class="text-sm text-[#8DA5C3] font-medium">
{{ props.modelValue?.questions?.regular?.length || 0 }} / 总计 {{ regularTotalScore }}
<div class="flex items-center gap-3">
<NButton secondary circle class="!text-gray-400 hover:!bg-blue-50 hover:!text-blue-500" @click="addQuestion(roundIndex)">
<template #icon>
<icon-ic-round-plus class="!text-xl" />
</template>
</NButton>
<NButton secondary circle class="!text-gray-400 hover:!bg-red-50 hover:!text-red-500" @click="removeRound(roundIndex)">
<template #icon>
<icon-ic-outline-delete class="!text-xl" />
</template>
</NButton>
</div>
</div>
<div class="flex flex-col gap-5">
<!-- 题目列表 -->
<div class="flex flex-col gap-3">
<div
v-for="(item, idx) in props.modelValue?.questions?.regular"
:key="idx"
class="flex items-center gap-3"
v-for="(item, qIdx) in round.questions"
:key="qIdx"
class="group/item flex items-center gap-4 border border-gray-100 rounded-xl bg-white p-2 px-4 transition-all hover:border-blue-200 hover:shadow-sm"
>
<span class="w-12 flex-shrink-0 text-sm text-[#8DA5C3] font-bold">{{ idx + 1 }}</span>
<span class="w-8 flex-shrink-0 text-center text-sm text-gray-300 font-bold transition-colors group-hover/item:text-blue-500">{{ String(qIdx + 1).padStart(2, '0') }}</span>
<!-- 题型选择 -->
<NSelect
v-model:value="item.value"
:options="questionOptions"
placeholder="请选择题型"
class="min-w-0 flex-[2]"
:theme-overrides="{
peers: {
InternalSelection: {
borderRadius: '8px',
color: '#FFFFFF',
border: '1px solid #EBF1F9',
},
},
}"
/>
<!-- 题型 -->
<div class="flex-1">
<NSelect
v-model:value="item.value"
:options="questionOptions"
placeholder="请选择题目类型..."
:bordered="false"
class="font-medium"
/>
</div>
<!-- -->
<NSelect
v-model:value="item.score"
:options="scoreOptions"
class="w-20 flex-shrink-0"
:theme-overrides="{
peers: {
InternalSelection: {
borderRadius: '8px',
color: '#FFFFFF',
border: '1px solid #EBF1F9',
},
},
}"
/>
<!-- -->
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<span class="text-xs text-gray-400 font-bold tracking-wider uppercase">PTS</span>
<NSelect
v-model:value="item.score"
:options="scoreOptions"
class="w-26 !bg-transparent"
size="small"
:bordered="false"
/>
</div>
<!-- 答题时间 -->
<div class="flex flex-shrink-0 items-center gap-2">
<span class="text-xs text-gray-400">答题时间:</span>
<!-- 时间 -->
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-timer class="text-gray-400" />
<NSelect
v-model:value="item.time"
:options="timeOptions"
class="w-22"
:theme-overrides="{
peers: {
InternalSelection: {
borderRadius: '8px',
color: '#FFFFFF',
border: '1px solid #EBF1F9',
},
},
}"
class="w-26 !bg-transparent"
size="small"
:bordered="false"
/>
<span class="text-xs text-gray-400 font-bold">S</span>
</div>
<!-- 删除按钮 -->
<NButton text class="ml-2 opacity-0 transition-opacity !text-gray-300 group-hover/item:opacity-100 hover:!text-red-500" @click="removeQuestion(roundIndex, qIdx)">
<icon-ic-outline-delete class="!text-xl" />
</NButton>
</div>
</div>
</div>
</NGridItem>
</div>
</div>
<!-- 右侧加时PK环节 -->
<NGridItem>
<div class="h-full border border-[#FFF0E0] rounded-2xl bg-[#FFF9F5] p-6">
<div class="mb-6 flex items-center justify-between border-b border-orange-100 pb-4">
<div class="flex items-center gap-2 text-[#5E3218] font-bold">
<icon-ic-outline-timer class="text-xl text-[#D96B23]" />
<span class="text-lg">加时PK环节</span>
</div>
<div class="text-sm text-[#D96B23]/60 font-medium">
{{ props.modelValue?.questions?.pk?.length || 0 }} / 总计 {{ pkTotalScore }}
</div>
<!-- 新增题包弹窗 -->
<NModal v-model:show="showAddModal" transform-origin="center">
<div class="w-[480px] rounded-2xl bg-white p-8 shadow-2xl">
<div class="mb-8 flex flex-col items-center gap-4 text-center">
<div class="h-14 w-14 flex items-center justify-center rounded-2xl bg-blue-50 text-blue-500">
<icon-ic-round-library-add class="text-3xl" />
</div>
<div class="flex flex-col gap-5">
<div
v-for="(item, idx) in props.modelValue?.questions?.pk"
:key="idx"
class="flex items-center gap-3"
>
<span class="w-12 flex-shrink-0 text-sm text-[#FF8C38] font-bold">PK{{ idx + 1 }}</span>
<!-- PK规则 -->
<NSelect
v-model:value="item.value"
:options="pkOptions"
placeholder="请选择PK规则"
class="min-w-0 flex-[2]"
:theme-overrides="{
peers: {
InternalSelection: {
borderRadius: '8px',
color: '#FFFFFF',
border: '1px solid #FFEDD5',
},
},
}"
/>
<!-- 分数 -->
<NSelect
v-model:value="item.score"
:options="scoreOptions"
class="w-20 flex-shrink-0"
:theme-overrides="{
peers: {
InternalSelection: {
borderRadius: '8px',
color: '#FFFFFF',
border: '1px solid #FFEDD5',
},
},
}"
/>
<!-- 答题时间 -->
<div class="flex flex-shrink-0 items-center gap-2">
<span class="text-xs text-[#D96B23]/60">答题时间:</span>
<NSelect
v-model:value="item.time"
:options="timeOptions"
class="w-22"
:theme-overrides="{
peers: {
InternalSelection: {
borderRadius: '8px',
color: '#FFFFFF',
border: '1px solid #FFEDD5',
},
},
}"
/>
</div>
<div>
<div class="text-xl text-gray-800 font-bold">
新建环节题包
</div>
<div class="mt-1 text-sm text-gray-500">
请输入环节名称与题目预设数量
</div>
</div>
</div>
</NGridItem>
</NGrid>
<NForm size="large">
<NFormItem label="环节/题包名称">
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 汉字听写" />
</NFormItem>
<NFormItem label="初始化题目数量">
<NInputNumber v-model:value="newRoundForm.count" :min="1" :max="50" class="w-full" />
</NFormItem>
</NForm>
<NButton type="primary" block size="large" class="mt-4 !h-12 !rounded-xl !text-lg !font-bold" @click="handleConfirmAdd">
生成题包卡片
</NButton>
</div>
</NModal>
</div>
</template>