feat(admin): 新增模板管理功能并完善用户认证

- 新增模板管理模块,包含模板列表和详情页面
- 重构用户认证逻辑,支持用户ID存储和API调用
- 新增排行榜API接口和类型定义
- 更新环境配置,添加开发环境配置
- 优化表格操作钩子,增强类型安全性
- 新增文件上传工具类和验证工具函数
- 更新路由配置,支持模板详情页面导航
- 修复登录表单默认值问题
This commit is contained in:
2026-01-27 09:48:42 +08:00
parent 264f600250
commit 5a48a65ef8
56 changed files with 2907 additions and 434 deletions

View File

@ -35,7 +35,7 @@ const competitionData = ref({
rounds: [ // 赛题包配置
{
id: 'round_default',
name: '第一轮:主赛环节',
name: '星·辞海遨游',
roundType: roundTypeOptions[0].value,
questions: Array.from({ length: 10 }).map(() => ({
value: null,
@ -203,7 +203,7 @@ function handleReset() {
type="primary" size="large" class="px-10 text-lg font-bold shadow-blue-500/30 shadow-lg"
@click="nextStep"
>
{{ currentStep === 4 ? '确认发布比赛' : '下一步' }}
{{ currentStep === 3 ? '确认发布比赛' : '下一步' }}
<template #icon>
<icon-ic-baseline-arrow-forward v-if="currentStep < 4" class="text-icon" />
<icon-ic-baseline-check v-else class="text-icon" />

View File

@ -2,7 +2,7 @@
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
import { ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
import { roundTypeOptions, scoreTypeOptions, timeOptions, uiTypeOptions } from '@/constants/business'
import { useQuestionConfig } from './useQuestionConfig'
const props = defineProps<{
@ -11,7 +11,15 @@ const props = defineProps<{
id: string
name: string
roundType: string
questions: Array<{ id: string, value: string | null, time: number, title: string, scoreType: Api.Competition.QuestionScoreType }>
questions: Array<{
id: string
value: string | null
time: number
title: string
scoreType: Api.Competition.QuestionScoreType
uiType?: string
templateId?: string
}>
}>
}
}>()
@ -20,7 +28,6 @@ const {
getRoundTime,
totalStats,
questionOptions,
timeOptions,
addRound,
removeRound,
addQuestion,
@ -29,6 +36,25 @@ const {
reset,
} = useQuestionConfig(props)
const templateIdOptions: SelectOption[] = [
{
label: '模板1',
value: 't1',
},
{
label: '模版2',
value: 't2',
},
{
label: '模版3',
value: 't3',
},
{
label: '模版4',
value: 't4',
},
]
// 模态框控制
const showAddModal = ref(false)
const newRoundForm = ref({
@ -39,7 +65,7 @@ const newRoundForm = ref({
function handleOpenAddModal() {
newRoundForm.value = {
name: `${(props.modelValue?.rounds?.length || 0) + 1}汉字听写`,
name: `${(props.modelValue?.rounds?.length || 0) + 1}星·诗词大会`,
count: 10,
roundType: roundTypeOptions[0].value,
}
@ -51,7 +77,7 @@ function handleConfirmAdd() {
window.$message?.warning('请输入环节名称')
return
}
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType)
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType as Api.Competition.CompetitionRoundType)
showAddModal.value = false
}
@ -145,7 +171,7 @@ defineExpose({ validate, reset })
<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" />
<NTag type="primary" class="!text-white">
<NTag type="primary" class="!text-blue-500">
{{ roundTypeOptions.find((item) => item.value === round.roundType)?.label || '未知类型' }}
</NTag>
<div class="flex items-center gap-3">
@ -196,70 +222,97 @@ defineExpose({ validate, reset })
<!-- 题目列表 -->
<VueDraggable
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-2"
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-3"
ghost-class="ghost"
>
<div
v-for="(item, qIdx) in round.questions" :key="item.id"
class="group/item flex items-center gap-2 border border-gray-100 rounded-xl bg-white p-2 px-2 transition-all hover:border-blue-200 hover:shadow-sm"
class="group/item flex flex-col gap-3 border border-gray-100 rounded-xl bg-white p-3 transition-all hover:border-blue-200 hover:shadow-sm"
>
<div class="flex items-center gap-1">
<div
class="drag-handle flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition-colors active:cursor-grabbing hover:bg-gray-100 hover:text-blue-500"
>
<icon-mdi-drag class="text-xl" />
<!-- 第一行序号标题删除按钮 -->
<div class="flex items-center justify-between gap-3">
<div class="flex flex-1 items-center gap-3">
<div class="flex items-center gap-2">
<div
class="drag-handle flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition-colors active:cursor-grabbing hover:bg-gray-100 hover:text-blue-500"
>
<icon-mdi-drag class="text-xl" />
</div>
<span
class="w-6 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>
</div>
<!-- 标题 -->
<div class="flex flex-1 items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-title class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题</span>
<NInput
v-model:value="item.title" class="flex-1 !bg-transparent" size="small" :bordered="false"
placeholder="请输入题目标题"
/>
</div>
</div>
<span
class="w-6 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>
<!-- 删除按钮 -->
<NButton
text class="transition-opacity !text-gray-300 hover:!text-red-500"
@click="removeQuestion(roundIndex, qIdx)"
>
<icon-ic-outline-delete class="!text-xl" />
</NButton>
</div>
<!-- 题型 -->
<div class="flex-1">
<NSelect
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型..."
:bordered="false" class="font-medium"
/>
</div>
<!-- 第二行配置项 -->
<div class="grid grid-cols-12 gap-3">
<!-- 题型 -->
<div class="col-span-4">
<NSelect
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型" size="small"
class="font-medium"
/>
</div>
<!-- 时间 -->
<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" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间</span>
<NSelect
v-model:value="item.time" :options="timeOptions" class="w-26 !bg-transparent" size="small"
:bordered="false"
/>
<span class="text-xs text-gray-400 font-bold">S</span>
</div>
<!-- UItype -->
<div class="col-span-4">
<NSelect
v-model:value="item.uiType" :options="uiTypeOptions" placeholder="请选择UI类型"
size="small" class="font-medium"
/>
</div>
<!-- 分数规则 -->
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则</span>
<NSelect
v-model:value="item.scoreType" :options="scoreTypeOptions as unknown as SelectOption[]"
class="w-28 !bg-transparent" size="small" :bordered="false"
/>
<span class="text-xs text-gray-400 font-bold">S</span>
</div>
<!-- templateId -->
<div class="col-span-4">
<NSelect
v-model:value="item.templateId" :options="templateIdOptions" placeholder="请选择模板"
size="small" class="font-medium"
/>
</div>
<!-- 标题 -->
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-title class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题</span>
<NInput v-model:value="item.title" class="w-28 !bg-transparent" size="small" :bordered="false" />
</div>
<!-- 时间 -->
<div class="col-span-6 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-timer class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间</span>
<NSelect
v-model:value="item.time" :options="timeOptions" class="flex-1 !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 class="col-span-6 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则</span>
<NSelect
v-model:value="item.scoreType" :options="scoreTypeOptions as unknown as SelectOption[]"
class="flex-1 !bg-transparent" size="small" :bordered="false"
/>
<span class="text-xs text-gray-400 font-bold"></span>
</div>
</div>
</div>
</VueDraggable>
</div>
@ -285,7 +338,7 @@ defineExpose({ validate, reset })
<NForm size="large">
<NFormItem label="环节/题包名称">
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 汉字听写" />
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 星·诗词大会" />
</NFormItem>
<NFormItem label="环节/题包类型">
<NSelect

View File

@ -8,6 +8,8 @@ export interface QuestionItem {
time: number
title: string
scoreType: Api.Competition.QuestionScoreType
uiType?: string
templateId?: string
}
export interface RoundModel {
@ -49,12 +51,12 @@ export function useQuestionConfig(props: any) {
})
const questionOptions = [
{ label: '汉字听写 - 根据提示书写汉字', value: 'hanzi' },
{ label: '同音字辨析 - 根据拼音书写', value: 'tongyin' },
{ label: '偏旁部首 - 按要求写字', value: 'pianpang' },
{ label: '词语听写 - 综合练习', value: 'ciyu' },
{ label: '成语填空 - 四字成语', value: 'chengyu' },
{ label: '反义词挑战', value: 'fanyi' },
{ label: '请根据提示书写正确的汉字-jiū 表示小鸟的叫声。', 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' },
@ -63,14 +65,6 @@ export function useQuestionConfig(props: any) {
{ label: '极速成语接龙 - 每题20秒', value: 'speed20' },
]
const timeOptions = [
{ label: '15s', value: 15 },
{ label: '30s', value: 30 },
{ label: '45s', value: 45 },
{ label: '60s', value: 60 },
{ label: '90s', value: 90 },
]
// 新增轮次
function addRound(customName?: string, customCount?: number, roundType?: Api.Competition.CompetitionRoundType) {
if (!props.modelValue.rounds) {
@ -141,8 +135,35 @@ export function useQuestionConfig(props: any) {
return false
}
for (let j = 0; j < round.questions.length; j++) {
if (!round.questions[j].value) {
window.$message?.error(`${round.name}${j + 1}未选择题型`)
const question = round.questions[j]
const questionPrefix = `${round.name}${j + 1}`
if (!question.value) {
window.$message?.error(`${questionPrefix}未选择题型`)
return false
}
if (!question.uiType) {
window.$message?.error(`${questionPrefix}未选择UI类型`)
return false
}
if (!question.templateId) {
window.$message?.error(`${questionPrefix}未选择模板`)
return false
}
if (!question.time) {
window.$message?.error(`${questionPrefix}未设置答题时间`)
return false
}
if (!question.scoreType) {
window.$message?.error(`${questionPrefix}未设置分数规则`)
return false
}
if (!question.title) {
window.$message?.error(`${questionPrefix}未填写标题`)
return false
}
if (question.score === null || question.score === undefined) {
window.$message?.error(`${questionPrefix}未设置分数`)
return false
}
}
@ -161,7 +182,6 @@ export function useQuestionConfig(props: any) {
getRoundTime,
totalStats,
questionOptions,
timeOptions,
addRound,
removeRound,
addQuestion,