2026-01-19 18:03:45 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-01-20 10:58:42 +08:00
|
|
|
|
import { debounce } from '@sa/utils'
|
2026-01-21 08:41:36 +08:00
|
|
|
|
import { NButton, NDatePicker, NForm, NInput, NInputNumber, NUpload, type UploadCustomRequestOptions } from 'naive-ui'
|
2026-01-20 10:58:42 +08:00
|
|
|
|
import { ref, useTemplateRef, watch } from 'vue'
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
modelValue: {
|
|
|
|
|
|
name: string
|
|
|
|
|
|
startTime: number | null
|
|
|
|
|
|
endTime: number | null
|
|
|
|
|
|
groupCount: number
|
|
|
|
|
|
teamCount: number
|
2026-01-21 08:41:36 +08:00
|
|
|
|
poster: string
|
2026-01-20 10:58:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
2026-01-19 18:03:45 +08:00
|
|
|
|
|
|
|
|
|
|
const formRef = useTemplateRef('formRef')
|
|
|
|
|
|
|
2026-01-20 10:58:42 +08:00
|
|
|
|
const formData = ref({ ...props.modelValue })
|
|
|
|
|
|
|
|
|
|
|
|
// 修复死循环:添加值对比逻辑
|
|
|
|
|
|
watch(() => props.modelValue, (val) => {
|
|
|
|
|
|
if (JSON.stringify(val) !== JSON.stringify(formData.value)) {
|
|
|
|
|
|
formData.value = { ...val }
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { deep: true })
|
|
|
|
|
|
|
|
|
|
|
|
// 添加防抖:300ms 延迟,避免频繁触发父组件更新
|
|
|
|
|
|
const handleUpdate = debounce((val: typeof formData.value) => {
|
|
|
|
|
|
emit('update:modelValue', { ...val })
|
|
|
|
|
|
}, 300)
|
|
|
|
|
|
|
|
|
|
|
|
watch(formData, (val) => {
|
|
|
|
|
|
handleUpdate(val)
|
|
|
|
|
|
}, { deep: true })
|
2026-01-19 18:03:45 +08:00
|
|
|
|
|
2026-01-21 08:41:36 +08:00
|
|
|
|
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 })
|
2026-01-19 18:03:45 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-01-21 08:41:36 +08:00
|
|
|
|
<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>
|
2026-01-19 18:03:45 +08:00
|
|
|
|
|
2026-01-21 08:41:36 +08:00
|
|
|
|
<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]"
|
2026-01-19 18:03:45 +08:00
|
|
|
|
/>
|
2026-01-21 08:41:36 +08:00
|
|
|
|
</div>
|
2026-01-19 18:03:45 +08:00
|
|
|
|
|
2026-01-21 08:41:36 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 右侧海报上传区域 -->
|
|
|
|
|
|
<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>
|
2026-01-19 18:03:45 +08:00
|
|
|
|
</template>
|
2026-01-21 08:41:36 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|