feat(competition): 新增比赛管理功能并优化UI交互

- 添加比赛创建向导,支持分步配置基础信息、分组管理、硬件绑定和题目配置
- 实现比赛卡片组件,支持复制和删除操作
- 优化比赛详情页的表单交互和样式
- 新增全局组件类型定义和图标
- 添加比赛海报上传功能
- 完善比赛状态管理,新增"已结束"状态
This commit is contained in:
2026-01-21 08:41:36 +08:00
parent e6346a771e
commit 3270e24c25
12 changed files with 923 additions and 315 deletions

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { debounce } from '@sa/utils'
import { NCard, NDatePicker, NForm, NFormItemGi, NGrid, NInput, NInputNumber } from 'naive-ui'
import { NButton, NDatePicker, NForm, NInput, NInputNumber, NUpload, type UploadCustomRequestOptions } from 'naive-ui'
import { ref, useTemplateRef, watch } from 'vue'
const props = defineProps<{
@ -10,6 +10,7 @@ const props = defineProps<{
endTime: number | null
groupCount: number
teamCount: number
poster: string
}
}>()
@ -35,60 +36,274 @@ watch(formData, (val) => {
handleUpdate(val)
}, { deep: true })
defineExpose({
formRef,
formData,
})
function updateCount(type: 'group' | 'team', delta: number) {
if (type === 'group') {
const newVal = (formData.value.groupCount || 0) + delta
if (newVal >= 1 && newVal <= 999) {
formData.value.groupCount = newVal
}
}
else {
const newVal = (formData.value.teamCount || 0) + delta
if (newVal >= 1 && newVal <= 999) {
formData.value.teamCount = newVal
}
}
}
// 模拟上传处理
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) {
window.$message?.error('请输入比赛项目名称')
return false
}
if (!formData.value.startTime) {
window.$message?.error('请选择开始时间')
return false
}
if (!formData.value.endTime) {
window.$message?.error('请选择结束时间')
return false
}
if (formData.value.startTime >= formData.value.endTime) {
window.$message?.error('结束时间必须晚于开始时间')
return false
}
if (!formData.value.groupCount || formData.value.groupCount < 1) {
window.$message?.error('参赛小组数量必须为正整数')
return false
}
if (!formData.value.teamCount || formData.value.teamCount < 1) {
window.$message?.error('每组队伍数量必须为正整数')
return false
}
return true
}
defineExpose({ validate })
</script>
<template>
<NCard :bordered="false" class="rounded-xl shadow-sm">
<div class="mb-4 flex items-center gap-2 border-l-4 border-blue-600 pl-3">
<span class="text-base font-bold">基础活动信息</span>
<div class="h-full flex gap-12">
<!-- 左侧表单区域 -->
<div class="flex-1">
<div class="mb-12">
<h2 class="mb-4 text-4xl text-gray-800 font-bold">
第一步 基础定义
</h2>
<p class="text-lg text-gray-400 font-bold">
请确认比赛名称与参与规模这将作为系统自动生成分组与终端连接数的依据
</p>
</div>
<NForm ref="formRef" label-placement="top" :show-feedback="false">
<div class="mb-10">
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
比赛项目名称
</div>
<NInput
v-model:value="formData.name" placeholder="例如2026年度阅读之星-辞海遨游总决赛"
class="h-16 rounded-2xl border-none text-xl line-height-16 shadow-[0_2px_10px_rgba(0,0,0,0.02)] !bg-[#FCFCFC]"
/>
</div>
<div class="grid grid-cols-2 mb-10 gap-8">
<div>
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
开始时间
</div>
<NDatePicker
v-model:value="formData.startTime" type="datetime" clearable class="w-full"
placeholder="选择开始时间" :theme-overrides="{
peers: {
Input: {
borderRadius: '1rem',
color: '#FCFCFC',
heightLarge: '4rem',
fontSizeLarge: '1.25rem',
textColor: '#1f2937',
borderHover: 'none',
borderFocus: 'none',
},
},
}" size="large"
/>
</div>
<div>
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
结束时间
</div>
<NDatePicker
v-model:value="formData.endTime" type="datetime" clearable class="w-full" placeholder="选择结束时间"
:theme-overrides="{
peers: {
Input: {
borderRadius: '1rem',
color: '#FCFCFC',
heightLarge: '4rem',
fontSizeLarge: '1.25rem',
textColor: '#1f2937',
borderHover: 'none',
borderFocus: 'none',
},
},
}" size="large"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-8">
<!-- 参赛小组数量卡片 -->
<div class="rounded-3xl bg-[#F5F8FF] p-8">
<div class="mb-6 text-sm text-[#3B82F6] font-bold tracking-wide uppercase">
参赛小组总数 (GROUPS)
</div>
<div class="mb-6 flex items-center justify-between">
<button
class="h-14 w-14 flex items-center justify-center rounded-full bg-white text-3xl text-[#3B82F6] shadow-md transition-transform active:scale-95 hover:bg-gray-50"
@click="updateCount('group', -1)"
>
<div class="mb-1 font-bold">
-
</div>
</button>
<div class="w-32">
<NInputNumber
v-model:value="formData.groupCount" :min="1" :max="999" :show-button="false"
class="text-center" :theme-overrides="{
peers: {
Input: {
textColor: '#1f2937',
fontSizeMedium: '3rem',
heightMedium: '4rem',
color: 'transparent',
caretColor: '#3B82F6',
},
},
}"
/>
</div>
<button
class="h-14 w-14 flex items-center justify-center rounded-full bg-white text-3xl text-[#3B82F6] shadow-md transition-transform active:scale-95 hover:bg-gray-50"
@click="updateCount('group', 1)"
>
<div class="mb-1 font-bold">
+
</div>
</button>
</div>
<div class="text-center text-sm text-[#3B82F6]/60 font-medium italic">
系统将预设 {{ formData.groupCount }} 个队伍
</div>
</div>
<!-- 每组队伍数量卡片 -->
<div class="rounded-3xl bg-[#F5F8FF] p-8">
<div class="mb-6 text-sm text-[#3B82F6] font-bold tracking-wide uppercase">
单组包含队伍 (TEAMS)
</div>
<div class="mb-6 flex items-center justify-between">
<button
class="h-14 w-14 flex items-center justify-center rounded-full bg-white text-3xl text-[#3B82F6] shadow-md transition-transform active:scale-95 hover:bg-gray-50"
@click="updateCount('team', -1)"
>
<div class="mb-1 font-bold">
-
</div>
</button>
<div class="w-32">
<NInputNumber
v-model:value="formData.teamCount" :min="1" :max="999" :show-button="false"
class="text-center" :theme-overrides="{
peers: {
Input: {
textColor: '#1f2937',
fontSizeMedium: '3rem',
heightMedium: '4rem',
color: 'transparent',
caretColor: '#3B82F6',
},
},
}"
/>
</div>
<button
class="h-14 w-14 flex items-center justify-center rounded-full bg-white text-3xl text-[#3B82F6] shadow-md transition-transform active:scale-95 hover:bg-gray-50"
@click="updateCount('team', 1)"
>
<div class="mb-1 font-bold">
+
</div>
</button>
</div>
<div class="text-center text-sm text-[#3B82F6]/60 font-medium italic">
每组将有 {{ formData.teamCount }} 支队伍同时进行比赛
</div>
</div>
</div>
</NForm>
</div>
<NForm ref="formRef" label-placement="top" :show-feedback="false">
<NGrid :x-gap="24" :y-gap="24" cols="1 s:2 m:4 l:5" responsive="screen">
<NFormItemGi span="1" label="比赛名称">
<NInput v-model:value="formData.name" placeholder="请输入比赛名称" />
</NFormItemGi>
<NFormItemGi label="开始时间">
<NDatePicker
v-model:value="formData.startTime" type="datetime" clearable class="w-full"
placeholder="年 / 月 / 日 --:--"
/>
</NFormItemGi>
<NFormItemGi label="结束时间">
<NDatePicker
v-model:value="formData.endTime" type="datetime" clearable class="w-full"
placeholder="年 / 月 / 日 --:--"
/>
</NFormItemGi>
<NFormItemGi label="参赛队伍总数">
<NInputNumber
v-model:value="formData.groupCount"
class="w-full"
:min="1"
:max="999"
:precision="0"
/>
<span class="text-xs text-gray-400"></span>
</NFormItemGi>
<NFormItemGi label="单组队伍">
<NInputNumber
v-model:value="formData.teamCount"
class="w-full"
:min="1"
:max="formData.groupCount"
:precision="0"
/>
<span class="text-xs text-gray-400"></span>
</NFormItemGi>
</NGrid>
</NForm>
</NCard>
<!-- 右侧海报上传区域 -->
<div class="w-99 flex flex-col pt-32">
<div class="mb-3 w-full text-sm text-[#8DA5C3] font-bold">
活动海报
<NAlert type="info" class="mt-2 text-xs text-[#616263] font-normal">
<span class="text-xs font-normal">
建议上传 16:9 4:3 比例的图片以获得最佳的全屏显示效果
</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>
</div>
</div>
</template>
<style scoped>
:deep(.n-input-number .n-input__input-el) {
text-align: center;
font-family: inherit;
font-weight: 700;
}
:deep(.n-upload-trigger) {
width: 100%;
}
</style>