feat(competition): 实现比赛创建功能并集成后端API

- 新增比赛创建页面,包含基本信息、组别管理和题目配置三个步骤
- 添加 OSS 图片上传组件,支持海报上传到阿里云OSS
- 集成后端API:获取房间列表、创建活动、创建队伍和创建题目
- 优化表单数据绑定和验证逻辑,使用防抖减少频繁更新
- 修复组别管理Excel导入的数据填充问题
- 更新类型定义,添加CommonResponse和比赛相关接口类型
This commit is contained in:
2026-01-29 18:03:13 +08:00
parent 82b1dd7ebe
commit beaaee72b4
13 changed files with 493 additions and 147 deletions

View File

@ -1,8 +1,11 @@
<script setup lang="ts">
import { NButton, NModal, NStep, NSteps } from 'naive-ui'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
import { fetchCreateActivity, fetchCreateQuestion, fetchCreateTeamList, fetchGetRoomList } from '@/service/api/competition'
/** 房间相关接口 */
import BasicInfo from './modules/BasicInfo.vue'
import GroupManagement from './modules/GroupManagement.vue'
// import HardwareBinding from './modules/HardwareBinding.vue'
import QuestionConfig from './modules/QuestionConfig.vue'
@ -17,6 +20,8 @@ const emit = defineEmits<{
}>()
const currentStep = ref<number>(1)
/** 房间列表 */
const roomListOptions = ref<{ label: string, value: number }[]>([])
// Reset step when modal opens
watch(() => props.show, (val) => {
@ -27,11 +32,12 @@ watch(() => props.show, (val) => {
const competitionData = ref({
name: '阅读之星年度总决赛', // 竞赛名称
startTime: null, // 竞赛开始时间
endTime: null, // 竞赛结束时间
startTime: null as string | null, // 竞赛开始时间
endTime: null as string | null, // 竞赛结束时间
groupCount: 10, // 组别数量
teamCount: 4, // 每组队伍数量
poster: '', // 竞赛海报
roomId: null, // 关联场地AP
rounds: [ // 赛题包配置
{
id: 'round_default',
@ -48,11 +54,7 @@ const competitionData = ref({
],
// 名单录入
groupManagement: [], // 组别管理
// 绑定硬件
// hardware: [], // 绑定的硬件设备
// 其他配置项...
isPublic: true, // 是否公开
// 是否主动流程(主持人主动流程:需要选择大题)
})
const steps = [
@ -69,7 +71,7 @@ function handleClose() {
emit('update:show', false)
}
function nextStep() {
async function nextStep() {
// 校验当前步骤
const componentInstance = stepComponentRef.value
if (componentInstance && typeof componentInstance.validate === 'function') {
@ -78,25 +80,85 @@ function nextStep() {
return
}
if (currentStep.value < 4) {
if (currentStep.value < 3) {
currentStep.value++
}
else {
// 汇总整理数据
else { // 最后一步 数据提交
// 提交表单
await submitForm()
}
}
const { name, startTime, endTime, groupCount, teamCount, poster, rounds } = competitionData.value
// eslint-disable-next-line unused-imports/no-unused-vars
const params = {
name,
startTime,
endTime,
groupCount,
teamCount,
poster,
rounds,
}
async function submitForm() {
const { name, startTime, endTime, groupCount, teamCount, poster, rounds, groupManagement, roomId } = competitionData.value
// 提交逻辑
if (!startTime || !endTime) {
window.$message?.error('请完善比赛时间')
return
}
// 活动基础信息
const roomParams = {
id: 0,
name,
startTime,
endTime,
teams: groupCount * teamCount,
teamGroupNumber: groupCount,
createdTime: '',
roomID: roomId || 0,
backgroundImg: poster,
}
const teamParams: Api.Competition.CreateTeamListRequest[] = []
// 遍历组别管理
groupManagement.forEach((group: any) => {
// 遍历队伍
(group as any).teams.forEach((team: any, index: number) => {
teamParams.push({
...team,
id: 0,
mainId: 0,
number: `${group.id}-${index + 1}`,
name,
penSerial: `${group.id}-${index + 1}`,
nameList: 'to do?',
schoolName: name,
teamGroupId: group.id,
})
})
})
const questionParams: Api.Competition.CreateQuestionRequest[] = []
// 遍历赛题包配置
rounds.forEach((round: any) => {
// 遍历题目
round.questions.forEach((question: any, index: number) => {
questionParams.push({
id: 0,
activityID: 0,
questionID: question.questionId,
questionIndex: index + 1,
actitvityQuestionName: question.title,
questionTime: question.time,
questionRule: question.scoreType,
uiType: question.uiType,
point: question.score,
templateID: question.templateId,
roundType: round.roundType,
questionSubTitle: question.title,
})
})
})
// 使用promise.all 提交数据
const [competitionRes, teamRes, questionRes] = await Promise.all([
fetchCreateActivity(roomParams),
fetchCreateTeamList(teamParams),
fetchCreateQuestion(questionParams),
])
if (competitionRes && teamRes && questionRes) {
window.$message?.success('比赛发布成功')
emit('success')
emit('update:show', false)
@ -124,6 +186,28 @@ function handleReset() {
},
})
}
/** 获取房间列表 */
async function getRoomList() {
const { data, error } = await fetchGetRoomList()
if (data && !error) {
const dataList = data.data || []
const roomList = dataList.map(item => ({
label: item.Name,
value: item.ID,
}))
roomListOptions.value = roomList
}
}
onMounted(() => {
getRoomList()
})
watch(() => competitionData.value, (val) => {
// eslint-disable-next-line no-console
console.log(val, 'competitionData.value')
}, { deep: true })
</script>
<template>
@ -182,8 +266,8 @@ function handleReset() {
<div class="h-full">
<KeepAlive>
<component
:is="currentComponent" ref="stepComponentRef" v-model="competitionData"
:config="competitionData"
:is="currentComponent" ref="stepComponentRef" v-model="competitionData" :config="competitionData"
:room-list-options="roomListOptions"
/>
</KeepAlive>
</div>

View File

@ -1,24 +1,29 @@
<script setup lang="ts">
import { NButton, NDatePicker, NForm, NInput, NInputNumber, NUpload } from 'naive-ui'
import { NDatePicker, NForm, NInput, NInputNumber, NSelect } from 'naive-ui'
import { useTemplateRef } from 'vue'
import OssImageUpload from '@/components/common/oss-image-upload/index.vue'
import { useBasicInfo } from './useBasicInfo'
const props = defineProps<{
modelValue: {
name: string
startTime: number | null
endTime: number | null
startTime: string | null
endTime: string | null
groupCount: number
teamCount: number
poster: string
roomId: number | null
[key: string]: any
}
/** 房间列表 */
roomListOptions: { label: string, value: number }[]
}>()
const emit = defineEmits(['update:modelValue'])
const formRef = useTemplateRef('formRef')
const { formData, updateCount, handleUpload, validate, reset } = useBasicInfo(props, emit)
const { formData, updateCount, validate, reset } = useBasicInfo(props, emit)
defineExpose({ validate, reset })
</script>
@ -54,15 +59,9 @@ defineExpose({ validate, reset })
<span class="text-red-500">*</span>
</div>
<NSelect
v-model:value="formData.ap" placeholder="选择关联场地AP"
v-model:value="formData.roomId" 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',
}]"
:options="roomListOptions"
size="large"
:theme-overrides="{
peers: {
@ -89,7 +88,8 @@ defineExpose({ validate, reset })
<span class="text-red-500">*</span>
</div>
<NDatePicker
v-model:value="formData.startTime" type="datetime" clearable class="w-full"
v-model:formatted-value="formData.startTime" type="datetime" clearable class="w-full"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="选择开始时间" :theme-overrides="{
peers: {
Input: {
@ -111,7 +111,8 @@ defineExpose({ validate, reset })
<span class="text-red-500">*</span>
</div>
<NDatePicker
v-model:value="formData.endTime" type="datetime" clearable class="w-full" placeholder="选择结束时间"
v-model:formatted-value="formData.endTime" type="datetime" clearable class="w-full" placeholder="选择结束时间"
value-format="yyyy-MM-dd HH:mm:ss"
:theme-overrides="{
peers: {
Input: {
@ -231,33 +232,12 @@ defineExpose({ validate, reset })
</span>
</NAlert>
</div>
<NUpload accept="image/*" :show-file-list="false" :custom-request="handleUpload" class="block w-full">
<div
class="relative h-[400px] w-full flex flex-col cursor-pointer items-center justify-center overflow-hidden rounded-3xl bg-[#F5F8FF] transition-all hover:bg-gray-100"
:class="{ 'border-2 border-dashed border-gray-300': !formData.poster }"
>
<div
v-if="formData.poster"
class="group absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity hover:opacity-100"
>
<div class="flex gap-4">
<!-- NUpload trigger 会自动处理点击事件这里不需要额外的上传逻辑只需要一个按钮作为视觉触发 -->
<NButton ghost color="#fff" size="small">
更换
</NButton>
<NButton ghost color="#ff4d4f" size="small" @click.stop="formData.poster = ''">
删除
</NButton>
</div>
</div>
<img v-if="formData.poster" :src="formData.poster" class="h-full w-full object-cover" alt="Poster">
<div v-else class="flex flex-col items-center text-gray-400">
<div class="i-carbon-add-filled mb-4 text-4xl text-[#3B82F6]" />
<span class="text-sm font-bold">点击上传海报</span>
<span class="mt-2 text-xs opacity-60">支持 JPG/PNG 格式</span>
</div>
</div>
</NUpload>
<OssImageUpload
v-model="formData.poster"
hint="支持 JPG/PNG 格式"
accept="image/*"
:max-size="5120"
/>
</div>
</div>
</template>

View File

@ -1,18 +1,36 @@
<script setup lang="ts">
import { debounce } from '@sa/utils'
import { NButton, NGrid, NGridItem, NInput } from 'naive-ui'
import { toRaw, watch } from 'vue'
import { useExcelExport, useExcelImport, useGroupManagement } from './useGroupManagement'
const props = defineProps<{
config?: {
modelValue: {
groupCount: number
teamCount: number
groupManagement?: any[]
[key: string]: any
}
config: any
}>()
const emit = defineEmits(['update:modelValue'])
const { groups, validate, reset } = useGroupManagement(props)
const { downloadTemplate } = useExcelExport(props, groups)
const { fileInputRef, handleImportClick, handleFileChange } = useExcelImport(props, groups)
const handleUpdate = debounce((val: any) => {
emit('update:modelValue', {
...props.modelValue,
groupManagement: toRaw(val),
})
}, 300)
watch(groups, (val) => {
handleUpdate(val)
}, { deep: true })
defineExpose({ validate, reset })
</script>

View File

@ -1,8 +1,10 @@
<script setup lang="ts">
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
import { ref, watch } from 'vue'
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<{
@ -13,13 +15,13 @@ const props = defineProps<{
roundType: string
questions: Array<{
id: string
value: string | null
questionId: string | null
time: number
title: string
scoreType: Api.Competition.QuestionScoreType
score: number
uiType?: string
templateId?: string
templateId?: number
}>
}>
}
@ -28,7 +30,6 @@ const {
// getRoundScore,
getRoundTime,
totalStats,
questionOptions,
addRound,
removeRound,
addQuestion,
@ -37,24 +38,9 @@ const {
reset,
} = useQuestionConfig(props)
const templateIdOptions: SelectOption[] = [
{
label: '模板1',
value: 't1',
},
{
label: '模版2',
value: 't2',
},
{
label: '模版3',
value: 't3',
},
{
label: '模版4',
value: 't4',
},
]
const questionOptions = ref<SelectOption[]>([])
const templateIdOptions = ref<SelectOption[]>([])
// 模态框控制
const showAddModal = ref(false)
@ -97,10 +83,44 @@ watch(() => props.modelValue, (val) => {
})
})
}
// eslint-disable-next-line no-console
console.log(val, '拖动后的重新顺序')
}, { 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>
@ -271,7 +291,7 @@ defineExpose({ validate, reset })
<!-- 题型 -->
<div class="col-span-4">
<NSelect
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型" size="small"
v-model:value="item.questionId" :options="questionOptions" placeholder="请选择题目类型" size="small"
class="font-medium"
/>
</div>
@ -279,16 +299,16 @@ defineExpose({ validate, reset })
<!-- UItype -->
<div class="col-span-4">
<NSelect
v-model:value="item.uiType" :options="uiTypeOptions" placeholder="请选择UI类型"
size="small" class="font-medium"
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"
v-model:value="item.templateId" :options="templateIdOptions" placeholder="请选择模板" size="small"
class="font-medium"
/>
</div>

View File

@ -1,30 +1,38 @@
import type { UploadCustomRequestOptions } from 'naive-ui'
import { debounce } from '@sa/utils'
import { ref, watch } from 'vue'
export interface BasicInfoModel {
name: string
startTime: number | null
endTime: number | null
startTime: string | null
endTime: string | null
groupCount: number
teamCount: number
poster: string
ap: string
roomId: number | null
}
export function useBasicInfo(props: any, emit: any) {
const formData = ref<BasicInfoModel>({ ...props.modelValue })
// 修复死循环:添加值对比逻辑
watch(() => props.modelValue, (val) => {
if (JSON.stringify(val) !== JSON.stringify(formData.value)) {
formData.value = { ...val }
const newForm: BasicInfoModel = {
name: val.name,
startTime: val.startTime,
endTime: val.endTime,
groupCount: val.groupCount,
teamCount: val.teamCount,
poster: val.poster,
roomId: val.roomId,
}
if (JSON.stringify(newForm) !== JSON.stringify(formData.value)) {
formData.value = newForm
}
}, { deep: true })
// 添加防抖300ms 延迟,避免频繁触发父组件更新
const handleUpdate = debounce((val: typeof formData.value) => {
emit('update:modelValue', { ...val })
emit('update:modelValue', { ...props.modelValue, ...val })
}, 300)
watch(formData, (val) => {
@ -46,17 +54,6 @@ export function useBasicInfo(props: any, emit: any) {
}
}
// 模拟上传处理
function handleUpload({ file, onFinish }: UploadCustomRequestOptions) {
const reader = new FileReader()
reader.readAsDataURL(file.file as File)
reader.onload = () => {
// 模拟上传成功,直接使用 base64 作为图片地址
formData.value.poster = reader.result as string
onFinish()
}
}
// 校验方法
function validate() {
if (!formData.value.name) {
@ -95,14 +92,13 @@ export function useBasicInfo(props: any, emit: any) {
groupCount: 10,
teamCount: 4,
poster: '',
ap: '',
roomId: null,
}
}
return {
formData,
updateCount,
handleUpload,
validate,
reset,
}

View File

@ -13,7 +13,10 @@ export function useGroupManagement(props: any) {
if (!groupCount || groupCount <= 0)
return
const oldGroups = groups.value
// 优先使用现有的 groups.value如果为空则尝试使用 props 中的初始数据
const oldGroups = groups.value.length > 0
? groups.value
: (props.modelValue?.groupManagement || [])
groups.value = Array.from({ length: groupCount }).map((_, index) => {
const i = index + 1
@ -39,7 +42,7 @@ export function useGroupManagement(props: any) {
})
}
watch(() => props.config, generateGroups, { deep: true, immediate: true })
watch(() => [props.config?.groupCount, props.config?.teamCount], generateGroups, { immediate: true, deep: true })
// 提交时候校验表单是否符合要求
function validate() {
@ -253,25 +256,34 @@ export function useExcelImport(props: any, groups: any) {
}
// 填充数据
finalData.forEach((row: any, index) => {
if (index >= groups.value.length)
return
const newGroups = groups.value.map((group: any, index: number) => {
const row = finalData[index]
if (!row)
return group
const group = groups.value[index]
const newGroup = { ...group }
// 分组名称
if (row['分组名称']) {
group.name = String(row['分组名称'])
newGroup.name = String(row['分组名称'])
}
// 队伍名称
group.teams.forEach((team: any, tIndex: number) => {
newGroup.teams = group.teams.map((team: any, tIndex: number) => {
const key = `队伍${tIndex + 1}名称`
if (row[key]) {
team.name = String(row[key])
return {
...team,
name: String(row[key]),
}
}
return team
})
return newGroup
})
groups.value = newGroups
}
catch (error) {
console.error('Excel 解析失败:', error)

View File

@ -3,13 +3,13 @@ import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
export interface QuestionItem {
id: string
value: string | null
questionId: string | null
score: number
time: number
title: string
scoreType: Api.Competition.QuestionScoreType
uiType?: string
templateId?: string
templateId?: number
}
export interface RoundModel {
@ -50,21 +50,6 @@ export function useQuestionConfig(props: any) {
}, { count: 0, score: 0, time: 0 })
})
const questionOptions = [
{ 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' },
{ label: '名句听写', value: 'mingju' },
{ label: '极速成语接龙 - 每题10秒', value: 'speed10' },
{ label: '极速成语接龙 - 每题20秒', value: 'speed20' },
]
// 新增轮次
function addRound(customName?: string, customCount?: number, roundType?: Api.Competition.CompetitionRoundType) {
if (!props.modelValue.rounds) {
@ -80,7 +65,7 @@ export function useQuestionConfig(props: any) {
roundType: roundType || roundTypeOptions[0].value,
questions: Array.from({ length: count }).map(() => ({
id: generateId(),
value: null,
questionId: null,
score: 5,
time: 30,
title: '',
@ -138,7 +123,7 @@ export function useQuestionConfig(props: any) {
const question = round.questions[j]
const questionPrefix = `${round.name}${j + 1}`
if (!question.value) {
if (!question.questionId) {
window.$message?.error(`${questionPrefix}未选择题型`)
return false
}
@ -181,7 +166,6 @@ export function useQuestionConfig(props: any) {
getRoundScore,
getRoundTime,
totalStats,
questionOptions,
addRound,
removeRound,
addQuestion,