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

@ -0,0 +1,123 @@
<script setup lang="ts">
import type { UploadCustomRequestOptions } from 'naive-ui'
import { NButton, NSpin, NUpload, useMessage } from 'naive-ui'
import { ref } from 'vue'
import { getAliOssTokenAxios } from '@/service/api/upload'
import { browserPathJoin } from '@/utils/date'
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
const props = withDefaults(defineProps<{
/** 图片地址 */
modelValue?: string
/** 上传路径 */
path?: string
/** 提示文字 */
hint?: string
/** 最大文件大小 (KB) */
maxSize?: number
/** 接受的文件类型 */
accept?: string
}>(), {
hint: '支持 JPG/PNG 格式',
accept: 'image/*',
})
const emit = defineEmits(['update:modelValue'])
const loading = ref(false)
const message = useMessage()
async function customRequest({ file, onFinish, onError }: UploadCustomRequestOptions) {
try {
// 校验文件大小
if (props.maxSize && file.file) {
const sizeKB = file.file.size / 1024
if (sizeKB > props.maxSize) {
const maxSizeText = props.maxSize >= 1024 ? `${(props.maxSize / 1024).toFixed(2)}MB` : `${props.maxSize}KB`
throw new Error(`文件大小不能超过 ${maxSizeText}`)
}
}
loading.value = true
// 1. 获取上传凭证
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
if (tokenError || !tokenData) {
throw new Error('获取上传凭证失败')
}
// 2. 初始化 OSS 客户端
const client = initOSSClient(tokenData.data || {})
// 3. 准备路径
let path = props.path || `temp/${Date.now()}`
path = browserPathJoin(path, file.name)
// 4. 上传文件
await uploadFileToOSS(client, file.file as File, path)
// 5. 获取 URL
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
emit('update:modelValue', url)
onFinish()
}
catch (error: any) {
message.error(error.message || '上传失败')
onError()
}
finally {
loading.value = false
}
}
function handleRemove() {
emit('update:modelValue', '')
}
</script>
<template>
<NUpload
:accept="accept"
:show-file-list="false"
:custom-request="customRequest"
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': !modelValue }"
>
<div v-if="loading" class="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
<NSpin size="large" />
</div>
<template v-else>
<div
v-if="modelValue"
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="handleRemove">
删除
</NButton>
</div>
</div>
<img v-if="modelValue" :src="modelValue" 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">{{ hint }}</span>
</div>
</template>
</div>
</NUpload>
</template>
<style scoped>
:deep(.n-upload-trigger) {
width: 100%;
}
</style>

View File

@ -0,0 +1,42 @@
import { request } from '../request'
/** 房间相关接口 */
export function fetchGetRoomList() {
return request<App.Service.Response<Api.Competition.Room[]>>({
url: '/Base/ActivityMain/GetActivity_RoomList',
method: 'get',
})
}
// 现在服务端把活动新建弄成了三个接口:
// 1. 创建活动
// 2. 创建活动房间
// 3. 创建活动队伍
// 但是业务要求最后一步创建
/** 创建活动基础信息 */
export function fetchCreateActivity(data: Api.Competition.CreateRoomRequest) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/AddActivity',
method: 'post',
data,
})
}
/** 创建活动队伍 */
export function fetchCreateTeamList(data: Api.Competition.CreateTeamListRequest[]) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/AddActivity_TeamList',
method: 'post',
data,
})
}
/** 创建活动题目 */
export function fetchCreateQuestion(data: Api.Competition.CreateQuestionRequest[]) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/AddOrUpdateActivity_Question',
method: 'post',
data,
})
}

View File

@ -4,7 +4,7 @@ import { request } from '../request'
* 获取模板列表 * 获取模板列表
*/ */
export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) { export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) {
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({ return request<App.Service.Response<Api.Common.CommonResponse>>({
url: '/Base/ActivityMain/GetTemplete_Pag', url: '/Base/ActivityMain/GetTemplete_Pag',
method: 'get', method: 'get',
params: data || {}, params: data || {},

View File

@ -52,6 +52,83 @@ declare namespace Api {
/** competition round type */ /** competition round type */
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */ type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
/** room */
interface Room {
/** room id */
ID: number
/** room name */
Name: string
}
/** create room request */
interface CreateRoomRequest {
/** 活动 id */
id: number
/** 活动名称 */
name: string
/** 开始时间 */
startTime: string
/** 结束时间 */
endTime: string
/** 队伍数量 */
teams: number
/** 创建时间 */
createdTime: string
/** 房间 id */
roomID: number
/** 队伍分组数量 */
teamGroupNumber: number
/** 背景图片 */
backgroundImg: string
}
/** create team list request */
interface CreateTeamListRequest {
/** 队伍 id */
id: number
/** 主 id */
mainId: number
/** 队伍编号 */
number: string
/** 队伍名称 */
name: string
/** 笔序列号 */
penSerial: string
/** 队员名单 */
nameList: string
/** 学校名称 */
schoolName: string
/** 队伍分组 id */
teamGroupId: number
}
interface CreateQuestionRequest {
/** 题目 id */
id: number
/** 活动 id */
activityID: number
/** 问题 id */
questionID: number
/** 题目序号 */
questionIndex: number
/** 活动题目名称 */
actitvityQuestionName: string
/** 答题时间(秒) */
questionTime: number
/** 题目规则 */
questionRule: number
/** UI 类型 */
uiType: string
/** 分值 */
point: number
/** 题目副标题 */
questionSubTitle: string
/** 题目模板 id */
templateID: QuestionTemplateId
/** 赛题包类型 */
roundType: number
}
/** common record */ /** common record */
type CommonRecord<T = any> = { type CommonRecord<T = any> = {
/** record id */ /** record id */

View File

@ -23,6 +23,14 @@ declare namespace Api {
/** common search params of table */ /** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'> type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
/** common response */
interface CommonResponse {
success: boolean
code: number
msg: string
data: any
}
/** /**
* enable status * enable status
* *

View File

@ -147,6 +147,7 @@ declare module 'vue' {
NTreeSelect: typeof import('naive-ui')['NTreeSelect'] NTreeSelect: typeof import('naive-ui')['NTreeSelect']
NUpload: typeof import('naive-ui')['NUpload'] NUpload: typeof import('naive-ui')['NUpload']
NWatermark: typeof import('naive-ui')['NWatermark'] NWatermark: typeof import('naive-ui')['NWatermark']
OssImageUpload: typeof import('./../components/common/oss-image-upload/index.vue')['default']
PageHeader: typeof import('./../components/common/page-header.vue')['default'] PageHeader: typeof import('./../components/common/page-header.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default'] PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default'] ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
@ -307,6 +308,7 @@ declare global {
const NTreeSelect: typeof import('naive-ui')['NTreeSelect'] const NTreeSelect: typeof import('naive-ui')['NTreeSelect']
const NUpload: typeof import('naive-ui')['NUpload'] const NUpload: typeof import('naive-ui')['NUpload']
const NWatermark: typeof import('naive-ui')['NWatermark'] const NWatermark: typeof import('naive-ui')['NWatermark']
const OssImageUpload: typeof import('./../components/common/oss-image-upload/index.vue')['default']
const PageHeader: typeof import('./../components/common/page-header.vue')['default'] const PageHeader: typeof import('./../components/common/page-header.vue')['default']
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default'] const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default'] const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']

View File

@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NModal, NStep, NSteps } from 'naive-ui' 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 { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
import { fetchCreateActivity, fetchCreateQuestion, fetchCreateTeamList, fetchGetRoomList } from '@/service/api/competition'
/** 房间相关接口 */
import BasicInfo from './modules/BasicInfo.vue' import BasicInfo from './modules/BasicInfo.vue'
import GroupManagement from './modules/GroupManagement.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' import QuestionConfig from './modules/QuestionConfig.vue'
@ -17,6 +20,8 @@ const emit = defineEmits<{
}>() }>()
const currentStep = ref<number>(1) const currentStep = ref<number>(1)
/** 房间列表 */
const roomListOptions = ref<{ label: string, value: number }[]>([])
// Reset step when modal opens // Reset step when modal opens
watch(() => props.show, (val) => { watch(() => props.show, (val) => {
@ -27,11 +32,12 @@ watch(() => props.show, (val) => {
const competitionData = ref({ const competitionData = ref({
name: '阅读之星年度总决赛', // 竞赛名称 name: '阅读之星年度总决赛', // 竞赛名称
startTime: null, // 竞赛开始时间 startTime: null as string | null, // 竞赛开始时间
endTime: null, // 竞赛结束时间 endTime: null as string | null, // 竞赛结束时间
groupCount: 10, // 组别数量 groupCount: 10, // 组别数量
teamCount: 4, // 每组队伍数量 teamCount: 4, // 每组队伍数量
poster: '', // 竞赛海报 poster: '', // 竞赛海报
roomId: null, // 关联场地AP
rounds: [ // 赛题包配置 rounds: [ // 赛题包配置
{ {
id: 'round_default', id: 'round_default',
@ -48,11 +54,7 @@ const competitionData = ref({
], ],
// 名单录入 // 名单录入
groupManagement: [], // 组别管理 groupManagement: [], // 组别管理
// 绑定硬件
// hardware: [], // 绑定的硬件设备
// 其他配置项...
isPublic: true, // 是否公开 isPublic: true, // 是否公开
// 是否主动流程(主持人主动流程:需要选择大题)
}) })
const steps = [ const steps = [
@ -69,7 +71,7 @@ function handleClose() {
emit('update:show', false) emit('update:show', false)
} }
function nextStep() { async function nextStep() {
// 校验当前步骤 // 校验当前步骤
const componentInstance = stepComponentRef.value const componentInstance = stepComponentRef.value
if (componentInstance && typeof componentInstance.validate === 'function') { if (componentInstance && typeof componentInstance.validate === 'function') {
@ -78,25 +80,85 @@ function nextStep() {
return return
} }
if (currentStep.value < 4) { if (currentStep.value < 3) {
currentStep.value++ currentStep.value++
} }
else { else { // 最后一步 数据提交
// 汇总整理数据 // 提交表单
await submitForm()
}
}
const { name, startTime, endTime, groupCount, teamCount, poster, rounds } = competitionData.value async function submitForm() {
// eslint-disable-next-line unused-imports/no-unused-vars const { name, startTime, endTime, groupCount, teamCount, poster, rounds, groupManagement, roomId } = competitionData.value
const params = {
name,
startTime,
endTime,
groupCount,
teamCount,
poster,
rounds,
}
// 提交逻辑 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('比赛发布成功') window.$message?.success('比赛发布成功')
emit('success') emit('success')
emit('update:show', false) 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> </script>
<template> <template>
@ -182,8 +266,8 @@ function handleReset() {
<div class="h-full"> <div class="h-full">
<KeepAlive> <KeepAlive>
<component <component
:is="currentComponent" ref="stepComponentRef" v-model="competitionData" :is="currentComponent" ref="stepComponentRef" v-model="competitionData" :config="competitionData"
:config="competitionData" :room-list-options="roomListOptions"
/> />
</KeepAlive> </KeepAlive>
</div> </div>

View File

@ -1,24 +1,29 @@
<script setup lang="ts"> <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 { useTemplateRef } from 'vue'
import OssImageUpload from '@/components/common/oss-image-upload/index.vue'
import { useBasicInfo } from './useBasicInfo' import { useBasicInfo } from './useBasicInfo'
const props = defineProps<{ const props = defineProps<{
modelValue: { modelValue: {
name: string name: string
startTime: number | null startTime: string | null
endTime: number | null endTime: string | null
groupCount: number groupCount: number
teamCount: number teamCount: number
poster: string poster: string
roomId: number | null
[key: string]: any
} }
/** 房间列表 */
roomListOptions: { label: string, value: number }[]
}>() }>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const formRef = useTemplateRef('formRef') const formRef = useTemplateRef('formRef')
const { formData, updateCount, handleUpload, validate, reset } = useBasicInfo(props, emit) const { formData, updateCount, validate, reset } = useBasicInfo(props, emit)
defineExpose({ validate, reset }) defineExpose({ validate, reset })
</script> </script>
@ -54,15 +59,9 @@ defineExpose({ validate, reset })
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
</div> </div>
<NSelect <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]" class="rounded-2xl border-none shadow-[0_2px_10px_rgba(0,0,0,0.02)] !bg-[#FCFCFC]"
:options="[{ :options="roomListOptions"
label: 'AP1',
value: 'AP1',
}, {
label: 'AP2',
value: 'AP2',
}]"
size="large" size="large"
:theme-overrides="{ :theme-overrides="{
peers: { peers: {
@ -89,7 +88,8 @@ defineExpose({ validate, reset })
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
</div> </div>
<NDatePicker <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="{ placeholder="选择开始时间" :theme-overrides="{
peers: { peers: {
Input: { Input: {
@ -111,7 +111,8 @@ defineExpose({ validate, reset })
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
</div> </div>
<NDatePicker <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="{ :theme-overrides="{
peers: { peers: {
Input: { Input: {
@ -231,33 +232,12 @@ defineExpose({ validate, reset })
</span> </span>
</NAlert> </NAlert>
</div> </div>
<NUpload accept="image/*" :show-file-list="false" :custom-request="handleUpload" class="block w-full"> <OssImageUpload
<div v-model="formData.poster"
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" hint="支持 JPG/PNG 格式"
:class="{ 'border-2 border-dashed border-gray-300': !formData.poster }" accept="image/*"
> :max-size="5120"
<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>
</div> </div>
</div> </div>
</template> </template>

View File

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

View File

@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui' 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 { VueDraggable } from 'vue-draggable-plus'
import { roundTypeOptions, scoreTypeOptions, timeOptions, uiTypeOptions } from '@/constants/business' import { roundTypeOptions, scoreTypeOptions, timeOptions, uiTypeOptions } from '@/constants/business'
import { fetchGetQuestionListAll } from '@/service/api/question'
import { fetchTemplateList } from '@/service/api/template'
import { useQuestionConfig } from './useQuestionConfig' import { useQuestionConfig } from './useQuestionConfig'
const props = defineProps<{ const props = defineProps<{
@ -13,13 +15,13 @@ const props = defineProps<{
roundType: string roundType: string
questions: Array<{ questions: Array<{
id: string id: string
value: string | null questionId: string | null
time: number time: number
title: string title: string
scoreType: Api.Competition.QuestionScoreType scoreType: Api.Competition.QuestionScoreType
score: number score: number
uiType?: string uiType?: string
templateId?: string templateId?: number
}> }>
}> }>
} }
@ -28,7 +30,6 @@ const {
// getRoundScore, // getRoundScore,
getRoundTime, getRoundTime,
totalStats, totalStats,
questionOptions,
addRound, addRound,
removeRound, removeRound,
addQuestion, addQuestion,
@ -37,24 +38,9 @@ const {
reset, reset,
} = useQuestionConfig(props) } = useQuestionConfig(props)
const templateIdOptions: SelectOption[] = [ const questionOptions = ref<SelectOption[]>([])
{
label: '模板1', const templateIdOptions = ref<SelectOption[]>([])
value: 't1',
},
{
label: '模版2',
value: 't2',
},
{
label: '模版3',
value: 't3',
},
{
label: '模版4',
value: 't4',
},
]
// 模态框控制 // 模态框控制
const showAddModal = ref(false) 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 }) }, { 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 }) defineExpose({ validate, reset })
</script> </script>
@ -271,7 +291,7 @@ defineExpose({ validate, reset })
<!-- 题型 --> <!-- 题型 -->
<div class="col-span-4"> <div class="col-span-4">
<NSelect <NSelect
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型" size="small" v-model:value="item.questionId" :options="questionOptions" placeholder="请选择题目类型" size="small"
class="font-medium" class="font-medium"
/> />
</div> </div>
@ -279,16 +299,16 @@ defineExpose({ validate, reset })
<!-- UItype --> <!-- UItype -->
<div class="col-span-4"> <div class="col-span-4">
<NSelect <NSelect
v-model:value="item.uiType" :options="uiTypeOptions" placeholder="请选择UI类型" v-model:value="item.uiType" :options="uiTypeOptions" placeholder="请选择UI类型" size="small"
size="small" class="font-medium" class="font-medium"
/> />
</div> </div>
<!-- templateId --> <!-- templateId -->
<div class="col-span-4"> <div class="col-span-4">
<NSelect <NSelect
v-model:value="item.templateId" :options="templateIdOptions" placeholder="请选择模板" v-model:value="item.templateId" :options="templateIdOptions" placeholder="请选择模板" size="small"
size="small" class="font-medium" class="font-medium"
/> />
</div> </div>

View File

@ -1,30 +1,38 @@
import type { UploadCustomRequestOptions } from 'naive-ui'
import { debounce } from '@sa/utils' import { debounce } from '@sa/utils'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
export interface BasicInfoModel { export interface BasicInfoModel {
name: string name: string
startTime: number | null startTime: string | null
endTime: number | null endTime: string | null
groupCount: number groupCount: number
teamCount: number teamCount: number
poster: string poster: string
ap: string roomId: number | null
} }
export function useBasicInfo(props: any, emit: any) { export function useBasicInfo(props: any, emit: any) {
const formData = ref<BasicInfoModel>({ ...props.modelValue }) const formData = ref<BasicInfoModel>({ ...props.modelValue })
// 修复死循环:添加值对比逻辑
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
if (JSON.stringify(val) !== JSON.stringify(formData.value)) { const newForm: BasicInfoModel = {
formData.value = { ...val } 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 }) }, { deep: true })
// 添加防抖300ms 延迟,避免频繁触发父组件更新 // 添加防抖300ms 延迟,避免频繁触发父组件更新
const handleUpdate = debounce((val: typeof formData.value) => { const handleUpdate = debounce((val: typeof formData.value) => {
emit('update:modelValue', { ...val }) emit('update:modelValue', { ...props.modelValue, ...val })
}, 300) }, 300)
watch(formData, (val) => { 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() { function validate() {
if (!formData.value.name) { if (!formData.value.name) {
@ -95,14 +92,13 @@ export function useBasicInfo(props: any, emit: any) {
groupCount: 10, groupCount: 10,
teamCount: 4, teamCount: 4,
poster: '', poster: '',
ap: '', roomId: null,
} }
} }
return { return {
formData, formData,
updateCount, updateCount,
handleUpload,
validate, validate,
reset, reset,
} }

View File

@ -13,7 +13,10 @@ export function useGroupManagement(props: any) {
if (!groupCount || groupCount <= 0) if (!groupCount || groupCount <= 0)
return 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) => { groups.value = Array.from({ length: groupCount }).map((_, index) => {
const i = index + 1 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() { function validate() {
@ -253,25 +256,34 @@ export function useExcelImport(props: any, groups: any) {
} }
// 填充数据 // 填充数据
finalData.forEach((row: any, index) => { const newGroups = groups.value.map((group: any, index: number) => {
if (index >= groups.value.length) const row = finalData[index]
return if (!row)
return group
const group = groups.value[index] const newGroup = { ...group }
// 分组名称 // 分组名称
if (row['分组名称']) { 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}名称` const key = `队伍${tIndex + 1}名称`
if (row[key]) { if (row[key]) {
team.name = String(row[key]) return {
...team,
name: String(row[key]),
}
} }
return team
}) })
return newGroup
}) })
groups.value = newGroups
} }
catch (error) { catch (error) {
console.error('Excel 解析失败:', error) console.error('Excel 解析失败:', error)

View File

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