feat(admin): 新增模板管理功能并完善用户认证
- 新增模板管理模块,包含模板列表和详情页面 - 重构用户认证逻辑,支持用户ID存储和API调用 - 新增排行榜API接口和类型定义 - 更新环境配置,添加开发环境配置 - 优化表格操作钩子,增强类型安全性 - 新增文件上传工具类和验证工具函数 - 更新路由配置,支持模板详情页面导航 - 修复登录表单默认值问题
This commit is contained in:
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user