feat(user): 新增用户端答题流程页面与相关功能

- 新增用户端答题流程主页面,包含封面、介绍、答题、答题图解四个状态
- 新增用户端组件:封面、介绍页、答题页、答题图解页、题目渲染器
- 新增用户端类型定义、模拟数据及业务常量
- 新增用户路由及类型声明
- 优化题库分类树,改为扁平化列表结构
- 完善比赛创建功能,增加轮次类型、题目分数类型及关联AP字段
- 修复模板删除函数调用参数错误
This commit is contained in:
2026-01-23 18:16:03 +08:00
parent f1dacf7c66
commit 264f600250
28 changed files with 1750 additions and 348 deletions

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import { NButton, NModal, NStep, NSteps } from 'naive-ui'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
import BasicInfo from './modules/BasicInfo.vue'
import GroupManagement from './modules/GroupManagement.vue'
import HardwareBinding from './modules/HardwareBinding.vue'
// import HardwareBinding from './modules/HardwareBinding.vue'
import QuestionConfig from './modules/QuestionConfig.vue'
const props = defineProps<{
@ -35,17 +36,20 @@ const competitionData = ref({
{
id: 'round_default',
name: '第一轮:主赛环节',
roundType: roundTypeOptions[0].value,
questions: Array.from({ length: 10 }).map(() => ({
value: null,
score: 5,
time: 30,
title: '第1题',
scoreType: scoreTypeOptions[0].value,
})),
},
],
// 名单录入
groupManagement: [], // 组别管理
// 绑定硬件
hardware: [], // 绑定的硬件设备
// hardware: [], // 绑定的硬件设备
// 其他配置项...
isPublic: true, // 是否公开
// 是否主动流程(主持人主动流程:需要选择大题)
@ -54,7 +58,7 @@ const competitionData = ref({
const steps = [
{ title: '基础设置', component: BasicInfo },
{ title: '名单录入', component: GroupManagement },
{ title: '硬件绑定', component: HardwareBinding },
// { title: '硬件绑定', component: HardwareBinding },
{ title: '题目配置', component: QuestionConfig },
]

View File

@ -40,10 +40,45 @@ defineExpose({ validate, reset })
<div class="mb-10">
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
比赛项目名称
<span class="text-red-500">*</span>
</div>
<NInput
v-model:value="formData.name" placeholder="例如2026年度阅读之星-辞海遨游总决赛"
class="h-16 rounded-2xl border-none text-xl line-height-16 shadow-[0_2px_10px_rgba(0,0,0,0.02)] !bg-[#FCFCFC]"
class="h-[3.4rem] rounded-2xl border-none text-xl leading-[3.4rem] shadow-[0_2px_10px_rgba(0,0,0,0.02)] !bg-[#FCFCFC]"
/>
</div>
<div class="mb-10">
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
关联场地AP
<span class="text-red-500">*</span>
</div>
<NSelect
v-model:value="formData.ap" placeholder="选择关联场地AP"
class="rounded-2xl border-none shadow-[0_2px_10px_rgba(0,0,0,0.02)] !bg-[#FCFCFC]"
:options="[{
label: 'AP1',
value: 'AP1',
}, {
label: 'AP2',
value: 'AP2',
}]"
size="large"
:theme-overrides="{
peers: {
InternalSelection: {
heightLarge: '3.4rem',
color: '#FCFCFC',
borderRadius: '1rem',
fontSizeLarge: '1.25rem',
borderHover: 'none',
borderFocus: 'none',
boxShadowHover: 'none',
boxShadowFocus: 'none',
boxShadowActive: 'none',
},
},
}"
/>
</div>
@ -51,6 +86,7 @@ defineExpose({ validate, reset })
<div>
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
开始时间
<span class="text-red-500">*</span>
</div>
<NDatePicker
v-model:value="formData.startTime" type="datetime" clearable class="w-full"
@ -59,7 +95,7 @@ defineExpose({ validate, reset })
Input: {
borderRadius: '1rem',
color: '#FCFCFC',
heightLarge: '4rem',
heightLarge: '3.4rem',
fontSizeLarge: '1.25rem',
textColor: '#1f2937',
borderHover: 'none',
@ -72,6 +108,7 @@ defineExpose({ validate, reset })
<div>
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
结束时间
<span class="text-red-500">*</span>
</div>
<NDatePicker
v-model:value="formData.endTime" type="datetime" clearable class="w-full" placeholder="选择结束时间"
@ -80,7 +117,7 @@ defineExpose({ validate, reset })
Input: {
borderRadius: '1rem',
color: '#FCFCFC',
heightLarge: '4rem',
heightLarge: '3.4rem',
fontSizeLarge: '1.25rem',
textColor: '#1f2937',
borderHover: 'none',
@ -115,7 +152,7 @@ defineExpose({ validate, reset })
Input: {
textColor: '#1f2937',
fontSizeMedium: '3rem',
heightMedium: '4rem',
heightMedium: '3rem',
color: 'transparent',
caretColor: '#3B82F6',
},

View File

@ -1,6 +1,8 @@
<script setup lang="ts">
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect } from 'naive-ui'
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 { useQuestionConfig } from './useQuestionConfig'
const props = defineProps<{
@ -8,17 +10,16 @@ const props = defineProps<{
rounds: Array<{
id: string
name: string
questions: Array<{ value: string | null, score: number, time: number }>
roundType: string
questions: Array<{ id: string, value: string | null, time: number, title: string, scoreType: Api.Competition.QuestionScoreType }>
}>
}
}>()
const {
getRoundScore,
// getRoundScore,
getRoundTime,
totalStats,
questionOptions,
scoreOptions,
timeOptions,
addRound,
removeRound,
@ -33,12 +34,14 @@ const showAddModal = ref(false)
const newRoundForm = ref({
name: '',
count: 10,
roundType: roundTypeOptions[0].value,
})
function handleOpenAddModal() {
newRoundForm.value = {
name: `${(props.modelValue?.rounds?.length || 0) + 1}轮 汉字听写`,
count: 10,
roundType: roundTypeOptions[0].value,
}
showAddModal.value = true
}
@ -48,15 +51,28 @@ function handleConfirmAdd() {
window.$message?.warning('请输入环节名称')
return
}
addRound(newRoundForm.value.name, newRoundForm.value.count)
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType)
showAddModal.value = false
}
// 初始化默认数据
watch(() => props.modelValue, () => {
// 如果没有任何轮次,显示空状态,不再自动添加
// 保持空状态以便用户看到"暂无配置题包"的界面
}, { immediate: true })
watch(() => props.modelValue, (val) => {
// 确保所有题目都有ID用于拖拽排序
if (val?.rounds) {
val.rounds.forEach((round) => {
if (!round.questions) {
return
}
round.questions.forEach((q: any) => {
if (!q.id) {
q.id = `q_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
})
})
}
// eslint-disable-next-line no-console
console.log(val, '拖动后的重新顺序')
}, { immediate: true, deep: true })
defineExpose({ validate, reset })
</script>
@ -71,16 +87,15 @@ defineExpose({ validate, reset })
</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
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>
@ -89,7 +104,10 @@ defineExpose({ validate, reset })
</div>
</div>
</div>
<NButton type="primary" class="shadow-blue-500/20 shadow-lg !h-10 !rounded-lg !px-6 !font-bold" @click="handleOpenAddModal">
<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>
@ -98,7 +116,10 @@ defineExpose({ validate, reset })
</div>
<!-- 空状态 -->
<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
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>
@ -112,7 +133,10 @@ defineExpose({ validate, reset })
<!-- 列表内容 -->
<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
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" />
@ -121,23 +145,28 @@ 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">
{{ roundTypeOptions.find((item) => item.value === round.roundType)?.label || '未知类型' }}
</NTag>
<div class="flex items-center gap-3">
<NInput
v-model:value="round.name"
placeholder="请输入环节名称"
:bordered="false"
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">
<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">
<!-- <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">
</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>
@ -146,12 +175,18 @@ defineExpose({ validate, reset })
</div>
<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)">
<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)">
<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>
@ -160,56 +195,73 @@ defineExpose({ validate, reset })
</div>
<!-- 题目列表 -->
<div class="flex flex-col gap-3">
<VueDraggable
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-2"
ghost-class="ghost"
>
<div
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"
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"
>
<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>
<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>
<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-1">
<NSelect
v-model:value="item.value"
:options="questionOptions"
placeholder="请选择题目类型..."
:bordered="false"
class="font-medium"
/>
</div>
<!-- 分值 -->
<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"
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型..."
:bordered="false" 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"
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>
<!-- 分数规则 -->
<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>
<!-- 标题 -->
<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>
<!-- 删除按钮 -->
<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)">
<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>
</VueDraggable>
</div>
</div>
</div>
@ -235,15 +287,30 @@ defineExpose({ validate, reset })
<NFormItem label="环节/题包名称">
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 汉字听写" />
</NFormItem>
<NFormItem label="环节/题包类型">
<NSelect
v-model:value="newRoundForm.roundType" :options="roundTypeOptions as unknown as SelectOption[]"
placeholder="请选择环节类型..." :bordered="true" class="font-medium"
/>
</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
type="primary" block size="large" class="mt-4 !h-12 !rounded-xl !text-lg !font-bold"
@click="handleConfirmAdd"
>
生成题包卡片
</NButton>
</div>
</NModal>
</div>
</template>
<style scoped>
.ghost {
@apply opacity-50 bg-blue-50 border-2 border-dashed border-blue-300;
}
</style>

View File

@ -9,6 +9,7 @@ export interface BasicInfoModel {
groupCount: number
teamCount: number
poster: string
ap: string
}
export function useBasicInfo(props: any, emit: any) {
@ -94,6 +95,7 @@ export function useBasicInfo(props: any, emit: any) {
groupCount: 10,
teamCount: 4,
poster: '',
ap: '',
}
}

View File

@ -1,9 +1,13 @@
import { computed } from 'vue'
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
export interface QuestionItem {
id: string
value: string | null
score: number
time: number
title: string
scoreType: Api.Competition.QuestionScoreType
}
export interface RoundModel {
@ -12,8 +16,8 @@ export interface RoundModel {
questions: QuestionItem[]
}
export interface QuestionConfigModel {
rounds: RoundModel[]
function generateId() {
return `q_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
export function useQuestionConfig(props: any) {
@ -59,14 +63,6 @@ export function useQuestionConfig(props: any) {
{ 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 },
@ -76,7 +72,7 @@ export function useQuestionConfig(props: any) {
]
// 新增轮次
function addRound(customName?: string, customCount?: number) {
function addRound(customName?: string, customCount?: number, roundType?: Api.Competition.CompetitionRoundType) {
if (!props.modelValue.rounds) {
props.modelValue.rounds = []
}
@ -87,10 +83,14 @@ export function useQuestionConfig(props: any) {
props.modelValue.rounds.push({
id: `round_${Date.now()}`,
name,
roundType: roundType || roundTypeOptions[0].value,
questions: Array.from({ length: count }).map(() => ({
id: generateId(),
value: null,
score: 5,
time: 30,
title: '',
scoreType: scoreTypeOptions[0].value,
})),
})
}
@ -109,9 +109,12 @@ export function useQuestionConfig(props: any) {
const round = props.modelValue.rounds[roundIndex]
if (round) {
round.questions.push({
id: generateId(),
value: null,
score: 5,
time: 30,
title: '',
scoreType: scoreTypeOptions[0].value,
})
}
}
@ -158,7 +161,6 @@ export function useQuestionConfig(props: any) {
getRoundTime,
totalStats,
questionOptions,
scoreOptions,
timeOptions,
addRound,
removeRound,