Files
reader-star/apps/admin/src/views/competition/competition-add/modules/QuestionConfig.vue
rjl beaaee72b4 feat(competition): 实现比赛创建功能并集成后端API
- 新增比赛创建页面,包含基本信息、组别管理和题目配置三个步骤
- 添加 OSS 图片上传组件,支持海报上传到阿里云OSS
- 集成后端API:获取房间列表、创建活动、创建队伍和创建题目
- 优化表单数据绑定和验证逻辑,使用防抖减少频繁更新
- 修复组别管理Excel导入的数据填充问题
- 更新类型定义,添加CommonResponse和比赛相关接口类型
2026-01-29 18:03:13 +08:00

401 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
import { onMounted, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { roundTypeOptions, scoreTypeOptions, timeOptions, uiTypeOptions } from '@/constants/business'
import { fetchGetQuestionListAll } from '@/service/api/question'
import { fetchTemplateList } from '@/service/api/template'
import { useQuestionConfig } from './useQuestionConfig'
const props = defineProps<{
modelValue: {
rounds: Array<{
id: string
name: string
roundType: string
questions: Array<{
id: string
questionId: string | null
time: number
title: string
scoreType: Api.Competition.QuestionScoreType
score: number
uiType?: string
templateId?: number
}>
}>
}
}>()
const {
// getRoundScore,
getRoundTime,
totalStats,
addRound,
removeRound,
addQuestion,
removeQuestion,
validate,
reset,
} = useQuestionConfig(props)
const questionOptions = ref<SelectOption[]>([])
const templateIdOptions = ref<SelectOption[]>([])
// 模态框控制
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
}
function handleConfirmAdd() {
if (!newRoundForm.value.name) {
window.$message?.warning('请输入环节名称')
return
}
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType as Api.Competition.CompetitionRoundType)
showAddModal.value = false
}
// 初始化默认数据
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)}`
}
})
})
}
}, { immediate: true, deep: true })
/** 获取所有题目 */
async function getAllQuestions() {
const { data: res, error } = await fetchGetQuestionListAll()
if (error) {
window.$message?.error(error.message)
return
}
const { data } = res || []
questionOptions.value = data?.map((item: any) => ({
label: `${item.Name}-${item.QuestionContent}`,
value: item.Id,
})) || []
}
/** 获取所有模板 */
async function getAllTemplates() {
const { data: res, error } = await fetchTemplateList()
if (error) {
window.$message?.error(error.message)
return
}
const { data } = res || []
templateIdOptions.value = Array.isArray(data)
? data.map((item: any) => ({
label: item.Name,
value: item.ID,
})) || []
: []
}
onMounted(() => {
getAllQuestions()
getAllTemplates()
})
defineExpose({ validate, reset })
</script>
<template>
<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="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>
<!-- 空状态 -->
<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" />
<NTag type="primary" class="!text-blue-500">
{{ 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"
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="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>
<!-- 题目列表 -->
<VueDraggable
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 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 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>
<!-- 删除按钮 -->
<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="grid grid-cols-12 gap-3">
<!-- 题型 -->
<div class="col-span-4">
<NSelect
v-model:value="item.questionId" :options="questionOptions" placeholder="请选择题目类型" size="small"
class="font-medium"
/>
</div>
<!-- UItype -->
<div class="col-span-4">
<NSelect
v-model:value="item.uiType" :options="uiTypeOptions" placeholder="请选择UI类型" size="small"
class="font-medium"
/>
</div>
<!-- templateId -->
<div class="col-span-4">
<NSelect
v-model:value="item.templateId" :options="templateIdOptions" placeholder="请选择模板" size="small"
class="font-medium"
/>
</div>
<!-- 时间 -->
<div class="col-span-4 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>
<!-- 分数规则 -->
<div class="col-span-4 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"
/>
</div>
<!-- 分数 -->
<div class="col-span-4 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-round-star-border class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数</span>
<NInputNumber
v-model:value="item.score" :min="0" :show-button="false" class="flex-1 !bg-transparent"
size="small" :bordered="false" placeholder="0"
/>
<span class="text-xs text-gray-400 font-bold"></span>
</div>
</div>
</div>
</VueDraggable>
</div>
</div>
</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>
<div class="text-xl text-gray-800 font-bold">
新建环节题包
</div>
<div class="mt-1 text-sm text-gray-500">
请输入环节名称与题目预设数量
</div>
</div>
</div>
<NForm size="large">
<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>
</div>
</NModal>
</div>
</template>
<style scoped>
.ghost {
@apply opacity-50 bg-blue-50 border-2 border-dashed border-blue-300;
}
</style>