feat(competition): 实现比赛创建功能并集成后端API
- 新增比赛创建页面,包含基本信息、组别管理和题目配置三个步骤 - 添加 OSS 图片上传组件,支持海报上传到阿里云OSS - 集成后端API:获取房间列表、创建活动、创建队伍和创建题目 - 优化表单数据绑定和验证逻辑,使用防抖减少频繁更新 - 修复组别管理Excel导入的数据填充问题 - 更新类型定义,添加CommonResponse和比赛相关接口类型
This commit is contained in:
123
apps/admin/src/components/common/oss-image-upload/index.vue
Normal file
123
apps/admin/src/components/common/oss-image-upload/index.vue
Normal 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>
|
||||
42
apps/admin/src/service/api/competition.ts
Normal file
42
apps/admin/src/service/api/competition.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -4,7 +4,7 @@ import { request } from '../request'
|
||||
* 获取模板列表
|
||||
*/
|
||||
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',
|
||||
method: 'get',
|
||||
params: data || {},
|
||||
|
||||
77
apps/admin/src/typings/api/Competition.d.ts
vendored
77
apps/admin/src/typings/api/Competition.d.ts
vendored
@ -52,6 +52,83 @@ declare namespace Api {
|
||||
/** competition round type */
|
||||
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 */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
|
||||
8
apps/admin/src/typings/api/common.d.ts
vendored
8
apps/admin/src/typings/api/common.d.ts
vendored
@ -23,6 +23,14 @@ declare namespace Api {
|
||||
/** common search params of table */
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
|
||||
|
||||
/** common response */
|
||||
interface CommonResponse {
|
||||
success: boolean
|
||||
code: number
|
||||
msg: string
|
||||
data: any
|
||||
}
|
||||
|
||||
/**
|
||||
* enable status
|
||||
*
|
||||
|
||||
2
apps/admin/src/typings/components.d.ts
vendored
2
apps/admin/src/typings/components.d.ts
vendored
@ -147,6 +147,7 @@ declare module 'vue' {
|
||||
NTreeSelect: typeof import('naive-ui')['NTreeSelect']
|
||||
NUpload: typeof import('naive-ui')['NUpload']
|
||||
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']
|
||||
PinToggler: typeof import('./../components/common/pin-toggler.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 NUpload: typeof import('naive-ui')['NUpload']
|
||||
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 PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||
|
||||
@ -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 = {
|
||||
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,
|
||||
groupCount,
|
||||
teamCount,
|
||||
poster,
|
||||
rounds,
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user