feat(admin): 新增题库模块并优化比赛管理功能
新增题库模块,包含题目管理页面和Excel导入导出功能 优化比赛管理功能,包括分组管理、硬件绑定和题目配置 重构比赛添加流程,增加重置功能和数据校验 添加Excel工具函数,支持读取和导出Excel文件 更新依赖,添加xlsx库支持
This commit is contained in:
@ -31,27 +31,17 @@ const competitionData = ref({
|
||||
groupCount: 10, // 组别数量
|
||||
teamCount: 4, // 每组队伍数量
|
||||
poster: '', // 竞赛海报
|
||||
questions: { // 赛题包配置
|
||||
regular: [// 主赛环节
|
||||
{ value: 'hanzi', score: 1, time: 15 },
|
||||
{ value: 'tongyin', score: 1, time: 15 },
|
||||
{ value: 'pianpang', score: 1, time: 30 },
|
||||
{ value: 'ciyu', score: 1, time: 30 },
|
||||
{ value: 'chengyu', score: 1, time: 30 },
|
||||
{ value: 'fanyi', score: 1, time: 60 },
|
||||
{ value: 'jinyi', score: 1, time: 30 },
|
||||
{ value: 'kantu', score: 1, time: 30 },
|
||||
{ value: 'gushi', score: 1, time: 60 },
|
||||
{ value: 'mingju', score: 1, time: 60 },
|
||||
],
|
||||
pk: [ // 加时赛环节
|
||||
{ value: 'speed10', score: 1, time: 60 },
|
||||
{ value: 'speed10', score: 1, time: 60 },
|
||||
{ value: 'speed10', score: 1, time: 60 },
|
||||
{ value: 'speed10', score: 1, time: 60 },
|
||||
{ value: 'speed10', score: 3, time: 60 },
|
||||
],
|
||||
},
|
||||
rounds: [ // 赛题包配置
|
||||
{
|
||||
id: 'round_default',
|
||||
name: '第一轮:主赛环节',
|
||||
questions: Array.from({ length: 10 }).map(() => ({
|
||||
value: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
})),
|
||||
},
|
||||
],
|
||||
// 名单录入
|
||||
groupManagement: [], // 组别管理
|
||||
// 绑定硬件
|
||||
@ -88,6 +78,20 @@ function nextStep() {
|
||||
currentStep.value++
|
||||
}
|
||||
else {
|
||||
// 汇总整理数据
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// 提交逻辑
|
||||
window.$message?.success('比赛发布成功')
|
||||
emit('success')
|
||||
@ -100,12 +104,28 @@ function prevStep() {
|
||||
currentStep.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
window.$dialog?.warning({
|
||||
title: '重置确认',
|
||||
content: '确定要重置当前步骤的数据吗?此操作无法撤销。',
|
||||
positiveText: '确定重置',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
const componentInstance = stepComponentRef.value
|
||||
if (componentInstance && typeof componentInstance.reset === 'function') {
|
||||
componentInstance.reset()
|
||||
window.$message?.success('重置成功')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="show" :mask-closable="false" @update:show="emit('update:show', $event)">
|
||||
<NModal :show="show" :mask-closable="false" @update:show="handleClose">
|
||||
<div
|
||||
class="relative h-[85vh] max-h-[900px] max-w-[95vw] w-[1280px] flex flex-col overflow-hidden rounded-[2rem] bg-white shadow-2xl transition-all"
|
||||
class="relative h-[90vh] max-h-[980px] max-w-[95vw] w-[1280px] flex flex-col overflow-hidden rounded-[2rem] bg-white shadow-2xl transition-all"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-10 py-8">
|
||||
@ -132,49 +152,58 @@ function prevStep() {
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
class="h-10 w-10 flex items-center justify-center rounded-full text-gray-300 transition-colors hover:scale-110 hover:bg-gray-100 hover:text-gray-500"
|
||||
@click="handleClose"
|
||||
>
|
||||
<NButton
|
||||
type="primary" size="small"
|
||||
class="h-8 w-8 rounded-full text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
|
||||
>
|
||||
<icon-ic-baseline-close class="text-icon" />
|
||||
<div class="flex items-center gap-3">
|
||||
<NButton secondary type="warning" size="small" @click="handleReset">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-refresh class="text-icon" />
|
||||
</template>
|
||||
重置当前步骤
|
||||
</NButton>
|
||||
</button>
|
||||
<button
|
||||
class="h-10 w-10 flex items-center justify-center rounded-full text-gray-300 transition-colors hover:scale-110 hover:bg-gray-100 hover:text-gray-500"
|
||||
@click="handleClose"
|
||||
>
|
||||
<NButton
|
||||
type="primary" size="small"
|
||||
class="h-8 w-8 rounded-full text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
|
||||
>
|
||||
<icon-ic-baseline-close class="text-icon" />
|
||||
</NButton>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-10">
|
||||
<div class="h-full">
|
||||
<component
|
||||
:is="currentComponent" ref="stepComponentRef" v-model:model-value="competitionData"
|
||||
:config="competitionData" @update:config="(val: typeof competitionData) => competitionData = val"
|
||||
/>
|
||||
<KeepAlive>
|
||||
<component
|
||||
:is="currentComponent" ref="stepComponentRef" v-model="competitionData"
|
||||
:config="competitionData"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-10 py-8">
|
||||
<div class="mr-auto flex flex-col">
|
||||
<span class="text-xs text-gray-300 font-bold tracking-widest uppercase">
|
||||
Configuration Progress
|
||||
</span>
|
||||
<span class="text-sm text-gray-800 font-bold">
|
||||
Step {{ currentStep }} of 4
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<NButton v-if="currentStep > 1" quaternary size="large" class="px-8 text-gray-500 font-bold" @click="prevStep">
|
||||
<div class="flex items-center justify-between border-t border-gray-100 bg-gray-50/50 px-10 py-6">
|
||||
<NButton v-if="currentStep > 1" secondary type="primary" size="large" class="px-8 font-bold" @click="prevStep">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-arrow-back class="text-icon" />
|
||||
</template>
|
||||
上一步
|
||||
</NButton>
|
||||
<div v-else />
|
||||
|
||||
<NButton
|
||||
type="primary" size="large"
|
||||
class="h-12 rounded-xl bg-[#2563EB] px-10 text-base font-bold shadow-blue-500/30 shadow-lg" @click="nextStep"
|
||||
type="primary" size="large" class="px-10 text-lg font-bold shadow-blue-500/30 shadow-lg"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ currentStep === 4 ? '完成并发布' : '下一步' }}
|
||||
{{ currentStep === 4 ? '确认发布比赛' : '下一步' }}
|
||||
<template #icon>
|
||||
<icon-ic-baseline-arrow-forward v-if="currentStep < 4" class="text-icon" />
|
||||
<icon-ic-baseline-check v-else class="text-icon" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from '@sa/utils'
|
||||
import { NButton, NDatePicker, NForm, NInput, NInputNumber, NUpload, type UploadCustomRequestOptions } from 'naive-ui'
|
||||
import { ref, useTemplateRef, watch } from 'vue'
|
||||
import { NButton, NDatePicker, NForm, NInput, NInputNumber, NUpload } from 'naive-ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useBasicInfo } from './useBasicInfo'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: {
|
||||
@ -18,80 +18,9 @@ const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const formRef = useTemplateRef('formRef')
|
||||
|
||||
const formData = ref({ ...props.modelValue })
|
||||
const { formData, updateCount, handleUpload, validate, reset } = useBasicInfo(props, emit)
|
||||
|
||||
// 修复死循环:添加值对比逻辑
|
||||
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 })
|
||||
|
||||
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 })
|
||||
defineExpose({ validate, reset })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NGrid, NGridItem, NInput } from 'naive-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useExcelExport, useExcelImport, useGroupManagement } from './useGroupManagement'
|
||||
|
||||
const props = defineProps<{
|
||||
config?: {
|
||||
@ -9,74 +9,11 @@ const props = defineProps<{
|
||||
}
|
||||
}>()
|
||||
|
||||
const groups = ref<Array<{ name: string, id: string, teams: Array<{ name: string }> }>>([])
|
||||
const { groups, validate, reset } = useGroupManagement(props)
|
||||
const { downloadTemplate } = useExcelExport(props, groups)
|
||||
const { fileInputRef, handleImportClick, handleFileChange } = useExcelImport(props, groups)
|
||||
|
||||
function generateGroups() {
|
||||
if (!props.config)
|
||||
return
|
||||
|
||||
const { groupCount, teamCount } = props.config
|
||||
if (!groupCount || groupCount <= 0)
|
||||
return
|
||||
|
||||
const oldGroups = groups.value
|
||||
|
||||
groups.value = Array.from({ length: groupCount }).map((_, index) => {
|
||||
const i = index + 1
|
||||
const idStr = String(i).padStart(2, '0')
|
||||
const existingGroup = oldGroups[index]
|
||||
|
||||
// 复用现有名称或生成默认名称
|
||||
const groupName = existingGroup ? existingGroup.name : `第${i}组`
|
||||
|
||||
// 生成或复用队伍数据
|
||||
const teams = Array.from({ length: teamCount }).map((__, tIndex) => {
|
||||
const existingTeam = existingGroup?.teams?.[tIndex]
|
||||
return {
|
||||
name: existingTeam ? existingTeam.name : '',
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
name: groupName,
|
||||
id: `G${idStr}`,
|
||||
teams,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.config, generateGroups, { deep: true, immediate: true })
|
||||
|
||||
function validate() {
|
||||
// 简单校验:是否有分组
|
||||
if (groups.value.length === 0) {
|
||||
window.$message?.error('暂无参赛分组,请检查基础设置')
|
||||
return false
|
||||
}
|
||||
|
||||
// 校验分组和队伍
|
||||
// const isAllValid = groups.value.every((group) => {
|
||||
// if (!group.name?.trim()) {
|
||||
// window.$message?.error(`分组 ${group.id.replace('G', '')} 名称不能为空`)
|
||||
// return false
|
||||
// }
|
||||
|
||||
// const isGroupTeamsValid = group.teams.every((team, i) => {
|
||||
// if (!team.name?.trim()) {
|
||||
// window.$message?.error(`分组 ${group.name} 的 TEAM ${i + 1} 名称不能为空`)
|
||||
// return false
|
||||
// }
|
||||
// return true
|
||||
// })
|
||||
|
||||
// return isGroupTeamsValid
|
||||
// })
|
||||
|
||||
// return isAllValid
|
||||
return true
|
||||
}
|
||||
|
||||
defineExpose({ validate })
|
||||
defineExpose({ validate, reset })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -91,18 +28,30 @@ defineExpose({ validate })
|
||||
系统已根据您的设置生成了 {{ groups.length }} 个分组。您可以手动填写或批量导入。
|
||||
</p>
|
||||
</div>
|
||||
<NButton type="primary" color="#1a202c" size="large">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-upload-file class="text-icon" />
|
||||
</template>
|
||||
Excel 导入名单
|
||||
</NButton>
|
||||
<div class="flex gap-3">
|
||||
<NButton secondary type="primary" @click="downloadTemplate">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-download class="text-icon" />
|
||||
</template>
|
||||
下载导入模板
|
||||
</NButton>
|
||||
<NButton type="primary" color="#1a202c" @click="handleImportClick">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-upload-file class="text-icon" />
|
||||
</template>
|
||||
Excel 导入名单
|
||||
</NButton>
|
||||
<!-- 隐藏的文件上传 Input -->
|
||||
<input ref="fileInputRef" type="file" class="hidden" accept=".xlsx, .xls" @change="handleFileChange">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组卡片列表 -->
|
||||
<NGrid :x-gap="24" :y-gap="24" cols="1 m:2 l:3" responsive="screen">
|
||||
<NGridItem v-for="(group, index) in groups" :key="index">
|
||||
<div class="h-full border border-gray-100 rounded-2xl bg-[#F8FAFC] p-6 transition-all hover:bg-white hover:shadow-lg">
|
||||
<div
|
||||
class="h-full border border-gray-100 rounded-2xl bg-[#F8FAFC] p-6 transition-all hover:bg-white hover:shadow-lg"
|
||||
>
|
||||
<div class="mb-6 flex items-center justify-between gap-4">
|
||||
<span class="shrink-0 text-lg text-gray-800 font-bold">{{ group.name }}</span>
|
||||
</div>
|
||||
@ -110,13 +59,12 @@ defineExpose({ validate })
|
||||
<NGrid :x-gap="16" :y-gap="16" :cols="2">
|
||||
<NGridItem v-for="(team, tIndex) in group.teams" :key="tIndex">
|
||||
<div class="mb-1 text-xs text-gray-400 font-bold">
|
||||
<!-- 显示分组序号,从1到teamCount -->
|
||||
TEAM {{ tIndex + 1 }}
|
||||
<!-- // 显示分组序号,从1到groups.length*teamCount -->
|
||||
<!-- TEAM {{ (index + 1) * (props.config?.teamCount ?? 0) - (props.config?.teamCount ?? 0) + tIndex + 1 }} -->
|
||||
</div>
|
||||
<NInput
|
||||
v-model:value="team.name"
|
||||
placeholder="输入队伍名称"
|
||||
class="rounded-lg !bg-white"
|
||||
/>
|
||||
<NInput v-model:value="team.name" placeholder="输入队伍名称" class="rounded-lg !bg-white" />
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { NGrid, NGridItem, NInput } from 'naive-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { NButton, NGrid, NGridItem, NInput, NUpload } from 'naive-ui'
|
||||
import { useHardwareBinding, useHardwareExcel, useQuickGenerate } from './useHardwareBinding'
|
||||
|
||||
const props = defineProps<{
|
||||
config?: {
|
||||
@ -9,64 +9,52 @@ const props = defineProps<{
|
||||
}
|
||||
}>()
|
||||
|
||||
interface HardwareGroup {
|
||||
groupId: string
|
||||
pens: Array<{ label: string, sn: string, online: boolean }>
|
||||
}
|
||||
const { hardwareGroups, validate, reset } = useHardwareBinding(props)
|
||||
const { handleDownloadTemplate, handleUpload } = useHardwareExcel(props, hardwareGroups)
|
||||
const { quickGenerate } = useQuickGenerate(props, hardwareGroups)
|
||||
|
||||
const hardwareGroups = ref<HardwareGroup[]>([])
|
||||
|
||||
function generateHardwareGroups() {
|
||||
if (!props.config)
|
||||
return
|
||||
|
||||
const { groupCount, teamCount } = props.config
|
||||
if (!groupCount || groupCount <= 0)
|
||||
return
|
||||
|
||||
const totalGroups = groupCount
|
||||
const newGroups: HardwareGroup[] = []
|
||||
|
||||
for (let i = 1; i <= totalGroups; i++) {
|
||||
const idStr = String(i).padStart(2, '0')
|
||||
const currentTeamCount = teamCount
|
||||
|
||||
const pens = []
|
||||
for (let j = 1; j <= currentTeamCount; j++) {
|
||||
pens.push({
|
||||
label: `T${j}`,
|
||||
sn: '',
|
||||
online: false, // 模拟初始状态
|
||||
})
|
||||
}
|
||||
|
||||
newGroups.push({
|
||||
groupId: idStr,
|
||||
pens,
|
||||
})
|
||||
}
|
||||
hardwareGroups.value = newGroups
|
||||
}
|
||||
|
||||
watch(() => props.config, generateHardwareGroups, { deep: true, immediate: true })
|
||||
|
||||
function validate() {
|
||||
// 暂时不需要强制校验硬件绑定,允许跳过
|
||||
return true
|
||||
}
|
||||
|
||||
defineExpose({ validate })
|
||||
defineExpose({ validate, reset })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<div class="mb-8">
|
||||
<h2 class="mb-2 text-3xl text-gray-800 font-bold">
|
||||
点阵笔硬件绑定
|
||||
</h2>
|
||||
<p class="text-gray-400">
|
||||
请将点阵笔序列号 (SN) 绑定至对应队伍。建议使用扫码枪快速录入。
|
||||
</p>
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="mb-2 text-3xl text-gray-800 font-bold">
|
||||
点阵笔硬件绑定
|
||||
</h2>
|
||||
<p class="text-gray-400">
|
||||
请将点阵笔序列号 (SN) 绑定至对应队伍。建议使用扫码枪快速录入。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<NButton secondary type="primary" @click="handleDownloadTemplate">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-download class="text-icon" />
|
||||
</template>
|
||||
下载模板
|
||||
</NButton>
|
||||
<NUpload :show-file-list="false" :custom-request="handleUpload" accept=".xlsx, .xls">
|
||||
<NButton type="primary" color="#1a202c">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-upload class="text-icon" />
|
||||
</template>
|
||||
导入配置
|
||||
</NButton>
|
||||
</NUpload>
|
||||
<NButton secondary type="success" @click="quickGenerate('order')">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-settings-backup-restore class="text-icon" />
|
||||
</template>
|
||||
快速顺序生成
|
||||
</NButton>
|
||||
<NButton type="info" @click="quickGenerate('random')">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-settings-backup-restore class="text-icon" />
|
||||
</template>
|
||||
快速随机生成
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态提示栏 -->
|
||||
@ -85,7 +73,7 @@ defineExpose({ validate })
|
||||
<!-- 硬件状态卡片 -->
|
||||
<NGrid :x-gap="24" :y-gap="24" cols="1 m:2 l:3" responsive="screen">
|
||||
<NGridItem v-for="(group, idx) in hardwareGroups" :key="idx">
|
||||
<div class="border border-gray-100 rounded-2xl bg-[#F8FAFC] p-6">
|
||||
<div class="border border-gray-100 rounded-2xl bg-[#F8FAFC] p-4">
|
||||
<div class="mb-4 text-gray-800 font-bold">
|
||||
硬件绑定: 第 {{ idx + 1 }} 分组
|
||||
</div>
|
||||
@ -102,7 +90,8 @@ defineExpose({ validate })
|
||||
/>
|
||||
</div>
|
||||
<!-- 扫描图标 -->
|
||||
<div class="i-carbon-qr-code text-gray-300" />
|
||||
<!-- <div class="i-carbon-qr-code text-gray-300" /> -->
|
||||
<icon-ic-baseline-edit class="text-xl text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,235 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { NGrid, NGridItem, NSelect } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect } from 'naive-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useQuestionConfig } from './useQuestionConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: {
|
||||
questions: {
|
||||
regular: Array<{ value: string, score: number, time: number }>
|
||||
pk: Array<{ value: string, score: number, time: number }>
|
||||
}
|
||||
rounds: Array<{
|
||||
id: string
|
||||
name: string
|
||||
questions: Array<{ value: string | null, score: number, time: number }>
|
||||
}>
|
||||
}
|
||||
}>()
|
||||
|
||||
const questionOptions = [
|
||||
{ label: '汉字听写 - 根据提示书写汉字', 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' },
|
||||
]
|
||||
const {
|
||||
getRoundScore,
|
||||
getRoundTime,
|
||||
totalStats,
|
||||
questionOptions,
|
||||
scoreOptions,
|
||||
timeOptions,
|
||||
addRound,
|
||||
removeRound,
|
||||
addQuestion,
|
||||
removeQuestion,
|
||||
validate,
|
||||
reset,
|
||||
} = useQuestionConfig(props)
|
||||
|
||||
const pkOptions = [
|
||||
{ label: '极速成语接龙 - 每题10秒', value: 'speed10' },
|
||||
{ label: '极速成语接龙 - 每题20秒', value: 'speed20' },
|
||||
]
|
||||
|
||||
const scoreOptions = [
|
||||
{ label: '1分', value: 1 },
|
||||
{ label: '2分', value: 2 },
|
||||
{ label: '3分', value: 3 },
|
||||
{ label: '5分', value: 5 },
|
||||
{ label: '10分', value: 10 },
|
||||
]
|
||||
|
||||
const timeOptions = [
|
||||
{ label: '15s', value: 15 },
|
||||
{ label: '30s', value: 30 },
|
||||
{ label: '45s', value: 45 },
|
||||
{ label: '60s', value: 60 },
|
||||
{ label: '90s', value: 90 },
|
||||
]
|
||||
|
||||
const regularTotalScore = computed(() => {
|
||||
return props.modelValue?.questions?.regular?.reduce((acc, cur) => acc + cur.score, 0) || 0
|
||||
// 模态框控制
|
||||
const showAddModal = ref(false)
|
||||
const newRoundForm = ref({
|
||||
name: '',
|
||||
count: 10,
|
||||
})
|
||||
|
||||
const pkTotalScore = computed(() => {
|
||||
return props.modelValue?.questions?.pk?.reduce((acc, cur) => acc + cur.score, 0) || 0
|
||||
})
|
||||
|
||||
function validate() {
|
||||
// 简单校验:主赛环节是否有题目
|
||||
if (!props.modelValue?.questions?.regular?.length) {
|
||||
window.$message?.error('请至少配置一道主赛题目')
|
||||
return false
|
||||
function handleOpenAddModal() {
|
||||
newRoundForm.value = {
|
||||
name: `第${(props.modelValue?.rounds?.length || 0) + 1}轮 汉字听写`,
|
||||
count: 10,
|
||||
}
|
||||
// 检查是否所有题目都选了题型
|
||||
const invalidRegular = props.modelValue.questions.regular.some(q => !q.value)
|
||||
if (invalidRegular) {
|
||||
window.$message?.error('请完善主赛环节的题型配置')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
defineExpose({ validate })
|
||||
function handleConfirmAdd() {
|
||||
if (!newRoundForm.value.name) {
|
||||
window.$message?.warning('请输入环节名称')
|
||||
return
|
||||
}
|
||||
addRound(newRoundForm.value.name, newRoundForm.value.count)
|
||||
showAddModal.value = false
|
||||
}
|
||||
|
||||
// 初始化默认数据
|
||||
watch(() => props.modelValue, () => {
|
||||
// 如果没有任何轮次,显示空状态,不再自动添加
|
||||
// 保持空状态以便用户看到"暂无配置题包"的界面
|
||||
}, { immediate: true })
|
||||
|
||||
defineExpose({ validate, reset })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4">
|
||||
<div class="mb-8 text-xl text-gray-800 font-bold">
|
||||
比赛赛题包配置
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-2xl text-gray-800 font-bold tracking-tight">
|
||||
赛程题包配置
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>您可以为比赛添加多个阶段的题包,并自定义题目、分值与限时。</span>
|
||||
<div v-if="totalStats.count > 0" class="flex items-center gap-3 border border-gray-100 rounded-lg bg-gray-50 px-3 py-1 text-xs font-bold">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-400">总题数</span>
|
||||
<span class="text-sm text-gray-700">{{ totalStats.count }}</span>
|
||||
</div>
|
||||
<div class="h-3 w-[1px] bg-gray-200" />
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-400">总分</span>
|
||||
<span class="text-sm text-orange-500">{{ totalStats.score }}</span>
|
||||
</div>
|
||||
<div class="h-3 w-[1px] bg-gray-200" />
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-400">总时长</span>
|
||||
<span class="text-sm text-blue-500">{{ totalStats.time }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NButton type="primary" class="shadow-blue-500/20 shadow-lg !h-10 !rounded-lg !px-6 !font-bold" @click="handleOpenAddModal">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus />
|
||||
</template>
|
||||
新增赛程题包
|
||||
</NButton>
|
||||
</div>
|
||||
<NGrid :x-gap="48" :y-gap="24" cols="1 l:2" responsive="screen">
|
||||
<!-- 左侧:主赛环节 -->
|
||||
<NGridItem>
|
||||
<div class="h-full rounded-2xl bg-[#F5F9FF] p-6">
|
||||
<div class="mb-6 flex items-center justify-between border-b border-blue-100 pb-4">
|
||||
<div class="flex items-center gap-2 text-gray-800 font-bold">
|
||||
<icon-ic-outline-archive class="text-xl text-blue-500" />
|
||||
<span class="text-lg">主赛环节</span>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!props.modelValue?.rounds?.length" class="flex flex-col flex-1 items-center justify-center border-2 border-gray-100 rounded-2xl border-dashed bg-gray-50/30">
|
||||
<div class="h-34 w-34 flex items-center justify-center rounded-3xl bg-gray-100 text-gray-300">
|
||||
<icon-ic-outline-folder-off class="text-5xl" />
|
||||
</div>
|
||||
<div class="mt-6 text-lg text-gray-400 font-bold">
|
||||
暂无配置题包
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
请点击右上角按钮开始添加比赛环节
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表内容 -->
|
||||
<div v-else class="flex flex-col gap-6 pb-10">
|
||||
<div v-for="(round, roundIndex) in props.modelValue?.rounds" :key="round.id" class="group relative overflow-hidden border border-gray-100 rounded-2xl bg-white p-1 shadow-sm transition-all hover:shadow-md">
|
||||
<!-- 侧边装饰条 -->
|
||||
<div class="absolute bottom-0 left-0 top-0 w-1.5 bg-blue-500" />
|
||||
|
||||
<div class="p-6 pl-8">
|
||||
<!-- 头部信息 -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-8 w-1.5 rounded-full bg-blue-500" />
|
||||
<div class="flex items-center gap-3">
|
||||
<NInput
|
||||
v-model:value="round.name"
|
||||
placeholder="请输入环节名称"
|
||||
:bordered="false"
|
||||
class="border transition-all !w-80 !border-transparent !rounded-lg !bg-transparent !text-xl !font-bold focus-within:shadow-sm focus-within:!border-blue-500 hover:!border-gray-200 focus-within:!bg-white"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500 font-bold">
|
||||
<icon-ic-outline-format-list-numbered class="text-sm" />
|
||||
{{ round.questions.length }} 题
|
||||
</div>
|
||||
<div class="flex items-center gap-1 border border-orange-100 rounded-full bg-orange-50 px-3 py-1 text-xs text-orange-500 font-bold">
|
||||
<icon-ic-round-star-border class="text-sm" />
|
||||
{{ getRoundScore(roundIndex) }} 分
|
||||
</div>
|
||||
<div class="flex items-center gap-1 border border-blue-100 rounded-full bg-blue-50 px-3 py-1 text-xs text-blue-500 font-bold">
|
||||
<icon-ic-outline-access-time class="text-sm" />
|
||||
{{ getRoundTime(roundIndex) }} 秒
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-[#8DA5C3] font-medium">
|
||||
共 {{ props.modelValue?.questions?.regular?.length || 0 }} 题 / 总计 {{ regularTotalScore }} 分
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<NButton secondary circle class="!text-gray-400 hover:!bg-blue-50 hover:!text-blue-500" @click="addQuestion(roundIndex)">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="!text-xl" />
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton secondary circle class="!text-gray-400 hover:!bg-red-50 hover:!text-red-500" @click="removeRound(roundIndex)">
|
||||
<template #icon>
|
||||
<icon-ic-outline-delete class="!text-xl" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- 题目列表 -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="(item, idx) in props.modelValue?.questions?.regular"
|
||||
:key="idx"
|
||||
class="flex items-center gap-3"
|
||||
v-for="(item, qIdx) in round.questions"
|
||||
:key="qIdx"
|
||||
class="group/item flex items-center gap-4 border border-gray-100 rounded-xl bg-white p-2 px-4 transition-all hover:border-blue-200 hover:shadow-sm"
|
||||
>
|
||||
<span class="w-12 flex-shrink-0 text-sm text-[#8DA5C3] font-bold">第{{ idx + 1 }}题</span>
|
||||
<span class="w-8 flex-shrink-0 text-center text-sm text-gray-300 font-bold transition-colors group-hover/item:text-blue-500">{{ String(qIdx + 1).padStart(2, '0') }}</span>
|
||||
|
||||
<!-- 题型选择 -->
|
||||
<NSelect
|
||||
v-model:value="item.value"
|
||||
:options="questionOptions"
|
||||
placeholder="请选择题型"
|
||||
class="min-w-0 flex-[2]"
|
||||
:theme-overrides="{
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
borderRadius: '8px',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid #EBF1F9',
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
<!-- 题型 -->
|
||||
<div class="flex-1">
|
||||
<NSelect
|
||||
v-model:value="item.value"
|
||||
:options="questionOptions"
|
||||
placeholder="请选择题目类型..."
|
||||
:bordered="false"
|
||||
class="font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分数 -->
|
||||
<NSelect
|
||||
v-model:value="item.score"
|
||||
:options="scoreOptions"
|
||||
class="w-20 flex-shrink-0"
|
||||
:theme-overrides="{
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
borderRadius: '8px',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid #EBF1F9',
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
<!-- 分值 -->
|
||||
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||
<span class="text-xs text-gray-400 font-bold tracking-wider uppercase">PTS</span>
|
||||
<NSelect
|
||||
v-model:value="item.score"
|
||||
:options="scoreOptions"
|
||||
class="w-26 !bg-transparent"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 答题时间 -->
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<span class="text-xs text-gray-400">答题时间:</span>
|
||||
<!-- 时间 -->
|
||||
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||
<icon-ic-outline-timer class="text-gray-400" />
|
||||
<NSelect
|
||||
v-model:value="item.time"
|
||||
:options="timeOptions"
|
||||
class="w-22"
|
||||
:theme-overrides="{
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
borderRadius: '8px',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid #EBF1F9',
|
||||
},
|
||||
},
|
||||
}"
|
||||
class="w-26 !bg-transparent"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 font-bold">S</span>
|
||||
</div>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<NButton text class="ml-2 opacity-0 transition-opacity !text-gray-300 group-hover/item:opacity-100 hover:!text-red-500" @click="removeQuestion(roundIndex, qIdx)">
|
||||
<icon-ic-outline-delete class="!text-xl" />
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:加时PK环节 -->
|
||||
<NGridItem>
|
||||
<div class="h-full border border-[#FFF0E0] rounded-2xl bg-[#FFF9F5] p-6">
|
||||
<div class="mb-6 flex items-center justify-between border-b border-orange-100 pb-4">
|
||||
<div class="flex items-center gap-2 text-[#5E3218] font-bold">
|
||||
<icon-ic-outline-timer class="text-xl text-[#D96B23]" />
|
||||
<span class="text-lg">加时PK环节</span>
|
||||
</div>
|
||||
<div class="text-sm text-[#D96B23]/60 font-medium">
|
||||
共 {{ props.modelValue?.questions?.pk?.length || 0 }} 题 / 总计 {{ pkTotalScore }} 分
|
||||
</div>
|
||||
<!-- 新增题包弹窗 -->
|
||||
<NModal v-model:show="showAddModal" transform-origin="center">
|
||||
<div class="w-[480px] rounded-2xl bg-white p-8 shadow-2xl">
|
||||
<div class="mb-8 flex flex-col items-center gap-4 text-center">
|
||||
<div class="h-14 w-14 flex items-center justify-center rounded-2xl bg-blue-50 text-blue-500">
|
||||
<icon-ic-round-library-add class="text-3xl" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<div
|
||||
v-for="(item, idx) in props.modelValue?.questions?.pk"
|
||||
:key="idx"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span class="w-12 flex-shrink-0 text-sm text-[#FF8C38] font-bold">PK{{ idx + 1 }}</span>
|
||||
|
||||
<!-- PK规则 -->
|
||||
<NSelect
|
||||
v-model:value="item.value"
|
||||
:options="pkOptions"
|
||||
placeholder="请选择PK规则"
|
||||
class="min-w-0 flex-[2]"
|
||||
:theme-overrides="{
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
borderRadius: '8px',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid #FFEDD5',
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 分数 -->
|
||||
<NSelect
|
||||
v-model:value="item.score"
|
||||
:options="scoreOptions"
|
||||
class="w-20 flex-shrink-0"
|
||||
:theme-overrides="{
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
borderRadius: '8px',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid #FFEDD5',
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 答题时间 -->
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<span class="text-xs text-[#D96B23]/60">答题时间:</span>
|
||||
<NSelect
|
||||
v-model:value="item.time"
|
||||
:options="timeOptions"
|
||||
class="w-22"
|
||||
:theme-overrides="{
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
borderRadius: '8px',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid #FFEDD5',
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl text-gray-800 font-bold">
|
||||
新建环节题包
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
请输入环节名称与题目预设数量
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<NForm size="large">
|
||||
<NFormItem label="环节/题包名称">
|
||||
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 汉字听写" />
|
||||
</NFormItem>
|
||||
<NFormItem label="初始化题目数量">
|
||||
<NInputNumber v-model:value="newRoundForm.count" :min="1" :max="50" class="w-full" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<NButton type="primary" block size="large" class="mt-4 !h-12 !rounded-xl !text-lg !font-bold" @click="handleConfirmAdd">
|
||||
生成题包卡片
|
||||
</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -0,0 +1,107 @@
|
||||
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
|
||||
groupCount: number
|
||||
teamCount: number
|
||||
poster: string
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 添加防抖:300ms 延迟,避免频繁触发父组件更新
|
||||
const handleUpdate = debounce((val: typeof formData.value) => {
|
||||
emit('update:modelValue', { ...val })
|
||||
}, 300)
|
||||
|
||||
watch(formData, (val) => {
|
||||
handleUpdate(val)
|
||||
}, { deep: true })
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 重置方法
|
||||
function reset() {
|
||||
formData.value = {
|
||||
name: '阅读之星年度总决赛',
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
groupCount: 10,
|
||||
teamCount: 4,
|
||||
poster: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
formData,
|
||||
updateCount,
|
||||
handleUpload,
|
||||
validate,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,287 @@
|
||||
import { exportToExcel, readExcel } from '@sa/utils'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export function useGroupManagement(props: any) {
|
||||
const groups = ref<Array<{ name: string, id: string, teams: Array<{ name: string }> }>>([])
|
||||
|
||||
// 根据分组数量和队伍数量生成分组数据
|
||||
function generateGroups() {
|
||||
if (!props.config)
|
||||
return
|
||||
|
||||
const { groupCount, teamCount } = props.config
|
||||
if (!groupCount || groupCount <= 0)
|
||||
return
|
||||
|
||||
const oldGroups = groups.value
|
||||
|
||||
groups.value = Array.from({ length: groupCount }).map((_, index) => {
|
||||
const i = index + 1
|
||||
const idStr = String(i).padStart(2, '0')
|
||||
const existingGroup = oldGroups[index]
|
||||
|
||||
// 复用现有名称或生成默认名称
|
||||
const groupName = existingGroup ? existingGroup.name : `第${i}组`
|
||||
|
||||
// 生成或复用队伍数据
|
||||
const teams = Array.from({ length: teamCount }).map((__, tIndex) => {
|
||||
const existingTeam = existingGroup?.teams?.[tIndex]
|
||||
return {
|
||||
name: existingTeam ? existingTeam.name : '',
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
name: groupName,
|
||||
id: `G${idStr}`,
|
||||
teams,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.config, generateGroups, { deep: true, immediate: true })
|
||||
|
||||
// 提交时候校验表单是否符合要求
|
||||
function validate() {
|
||||
// 简单校验:是否有分组
|
||||
if (groups.value.length === 0) {
|
||||
window.$message?.error('暂无参赛分组,请检查基础设置')
|
||||
return false
|
||||
}
|
||||
|
||||
// 校验每个分组是否有队伍
|
||||
for (const group of groups.value) {
|
||||
if (group.teams.length === 0) {
|
||||
window.$message?.error(`分组 ${group.name} 暂无队伍,请检查`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 校验每个分组队伍是否有名称
|
||||
for (const group of groups.value) {
|
||||
for (const team of group.teams) {
|
||||
if (!team.name.trim()) {
|
||||
window.$message?.error(`分组 ${group.name} 队伍 ${team.name} 暂无名称,请检查`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 校验分组队伍名称是否重复,并找出是第几组重复
|
||||
const nameMap = new Map<string, string[]>()
|
||||
let hasDuplicate = false
|
||||
|
||||
groups.value.forEach((group, groupIndex) => {
|
||||
group.teams.forEach((team, teamIndex) => {
|
||||
const name = team.name.trim()
|
||||
if (!name)
|
||||
return // 跳过空名字,前面已经校验过空了
|
||||
|
||||
if (nameMap.has(name)) {
|
||||
nameMap.get(name)?.push(`第${groupIndex + 1}组-队伍${teamIndex + 1}`)
|
||||
hasDuplicate = true
|
||||
}
|
||||
else {
|
||||
nameMap.set(name, [`第${groupIndex + 1}组-队伍${teamIndex + 1}`])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (hasDuplicate) {
|
||||
const duplicates: string[] = []
|
||||
nameMap.forEach((locations, name) => {
|
||||
if (locations.length > 1) {
|
||||
duplicates.push(`队伍名 "${name}" 重复于: ${locations.join(', ')}`)
|
||||
}
|
||||
})
|
||||
|
||||
// 显示前3条重复信息,避免弹窗过大
|
||||
const msg = duplicates.slice(0, 3).join('; ') + (duplicates.length > 3 ? ' 等...' : '')
|
||||
window.$message?.error(`发现重复队伍名称:${msg}`, { duration: 8000 })
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 重置方法
|
||||
function reset() {
|
||||
// 清空现有数据,触发 generateGroups 重新生成默认数据
|
||||
groups.value = []
|
||||
generateGroups()
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
validate,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 Excel 模板
|
||||
*/
|
||||
export function useExcelExport(props: any, groups: any) {
|
||||
function downloadTemplate() {
|
||||
if (!props.config)
|
||||
return
|
||||
|
||||
// 1. 构建数据行(根据当前分组预填充分组名称)
|
||||
const data = groups.value.map((g: any) => {
|
||||
const row: any = {
|
||||
分组名称: g.name,
|
||||
}
|
||||
// 为队伍预留空位,或者如果已有数据也填充进去(方便用户修改)
|
||||
g.teams.forEach((t: any, i: number) => {
|
||||
row[`队伍${i + 1}名称`] = t.name
|
||||
})
|
||||
return row
|
||||
})
|
||||
|
||||
exportToExcel(data, '参赛名单导入模板', '参赛名单')
|
||||
}
|
||||
|
||||
return {
|
||||
downloadTemplate,
|
||||
}
|
||||
}
|
||||
|
||||
export function useExcelImport(props: any, groups: any) {
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/**
|
||||
* 触发文件选择
|
||||
*/
|
||||
function handleImportClick() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件上传与解析
|
||||
*/
|
||||
async function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file)
|
||||
return
|
||||
|
||||
// 重置 input value,允许重复选择同一文件
|
||||
target.value = ''
|
||||
|
||||
try {
|
||||
const jsonData = await readExcel(file)
|
||||
|
||||
// 校验表头:检查第一行数据是否包含预期的列名
|
||||
// 预期列名:分组名称, 队伍1名称, 队伍2名称...
|
||||
if (jsonData.length === 0) {
|
||||
window.$message?.warning('Excel 文件内容为空')
|
||||
return
|
||||
}
|
||||
|
||||
const firstRow = jsonData[0] as any
|
||||
const expectedKeys = ['分组名称']
|
||||
if (props.config?.teamCount) {
|
||||
for (let i = 1; i <= props.config.teamCount; i++) {
|
||||
expectedKeys.push(`队伍${i}名称`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否包含所有必要的列
|
||||
// 这里我们检查第一行是否有这些 key
|
||||
const hasValidHeader = expectedKeys.every(key => Object.prototype.hasOwnProperty.call(firstRow, key))
|
||||
|
||||
if (!hasValidHeader) {
|
||||
window.$message?.error('Excel 模板格式错误,请下载最新模板并重新填写')
|
||||
return
|
||||
}
|
||||
|
||||
const limit = props.config?.groupCount || 0
|
||||
let validData = jsonData
|
||||
|
||||
// 校验条数上限
|
||||
if (validData.length > limit) {
|
||||
window.$message?.warning(`导入数据共 ${validData.length} 条,已截取前 ${limit} 条,超出部分将被忽略`, { duration: 4000 })
|
||||
validData = validData.slice(0, limit)
|
||||
}
|
||||
else {
|
||||
window.$message?.success(`成功解析 ${validData.length} 条数据`, { duration: 3000 })
|
||||
}
|
||||
|
||||
// 重写校验逻辑,清晰化
|
||||
const finalData: any[] = []
|
||||
const processedGroupNames = new Set<string>()
|
||||
const processedTeamNames = new Set<string>()
|
||||
const duplicates: string[] = []
|
||||
|
||||
validData.forEach((row: any, index) => {
|
||||
const newRow = { ...row }
|
||||
|
||||
// 1. 校验分组名称
|
||||
const groupName = row['分组名称'] ? String(row['分组名称']).trim() : ''
|
||||
if (groupName) {
|
||||
if (processedGroupNames.has(groupName)) {
|
||||
duplicates.push(`第 ${index + 2} 行分组 "${groupName}" 重复,已跳过该行`)
|
||||
return // 分组名重复,跳过整行
|
||||
}
|
||||
processedGroupNames.add(groupName)
|
||||
}
|
||||
|
||||
// 2. 校验队伍名称
|
||||
if (props.config?.teamCount) {
|
||||
for (let i = 1; i <= props.config.teamCount; i++) {
|
||||
const key = `队伍${i}名称`
|
||||
const teamName = row[key] ? String(row[key]).trim() : ''
|
||||
|
||||
if (teamName) {
|
||||
if (processedTeamNames.has(teamName)) {
|
||||
duplicates.push(`第 ${index + 2} 行队伍 "${teamName}" 重复,已清空该项`)
|
||||
newRow[key] = '' // 剔除重复项
|
||||
}
|
||||
else {
|
||||
processedTeamNames.add(teamName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finalData.push(newRow)
|
||||
})
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
// 提示重复信息,限制显示条数避免弹窗过大
|
||||
const msg = `发现重复数据:\n${duplicates.slice(0, 5).join('\n')}${duplicates.length > 5 ? '\n...' : ''}`
|
||||
window.$message?.warning(msg, { duration: 5000 })
|
||||
}
|
||||
|
||||
// 填充数据
|
||||
finalData.forEach((row: any, index) => {
|
||||
if (index >= groups.value.length)
|
||||
return
|
||||
|
||||
const group = groups.value[index]
|
||||
|
||||
// 分组名称
|
||||
if (row['分组名称']) {
|
||||
group.name = String(row['分组名称'])
|
||||
}
|
||||
|
||||
// 队伍名称
|
||||
group.teams.forEach((team: any, tIndex: number) => {
|
||||
const key = `队伍${tIndex + 1}名称`
|
||||
if (row[key]) {
|
||||
team.name = String(row[key])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Excel 解析失败:', error)
|
||||
window.$message?.error('Excel 解析失败,请检查文件格式')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fileInputRef,
|
||||
handleImportClick,
|
||||
handleFileChange,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
import type { UploadCustomRequestOptions } from 'naive-ui'
|
||||
import { exportToExcel, readExcel } from '@sa/utils'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export interface HardwareGroup {
|
||||
groupId: string
|
||||
pens: Array<{ label: string, sn: string, online: boolean }>
|
||||
}
|
||||
|
||||
export function useHardwareBinding(props: any) {
|
||||
const hardwareGroups = ref<HardwareGroup[]>([])
|
||||
|
||||
// 生成硬件分组
|
||||
function generateHardwareGroups() {
|
||||
if (!props.config)
|
||||
return
|
||||
|
||||
const { groupCount, teamCount } = props.config
|
||||
if (!groupCount || groupCount <= 0)
|
||||
return
|
||||
|
||||
const totalGroups = groupCount
|
||||
// 仅在初始化或配置完全改变时重新生成,避免覆盖用户已输入的数据
|
||||
if (hardwareGroups.value.length === totalGroups && hardwareGroups.value[0]?.pens.length === teamCount) {
|
||||
return
|
||||
}
|
||||
|
||||
const newGroups: HardwareGroup[] = []
|
||||
|
||||
for (let i = 1; i <= totalGroups; i++) {
|
||||
const idStr = String(i).padStart(2, '0')
|
||||
const currentTeamCount = teamCount
|
||||
|
||||
const pens = []
|
||||
for (let j = 1; j <= currentTeamCount; j++) {
|
||||
pens.push({
|
||||
label: `T${j}`,
|
||||
sn: '',
|
||||
online: true, // 模拟初始状态
|
||||
})
|
||||
}
|
||||
|
||||
newGroups.push({
|
||||
groupId: idStr,
|
||||
pens,
|
||||
})
|
||||
}
|
||||
hardwareGroups.value = newGroups
|
||||
}
|
||||
|
||||
watch(() => props.config, generateHardwareGroups, { deep: true, immediate: true })
|
||||
|
||||
// 校验硬件绑定是否符合要求
|
||||
function validate() {
|
||||
// 1. 校验每个分组是否都有队伍绑定
|
||||
for (const group of hardwareGroups.value) {
|
||||
if (group.pens.some(pen => !pen.sn)) {
|
||||
window.$message?.error(`分组 ${group.groupId} 存在未绑定的队伍`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 校验重复 SN
|
||||
const snMap = new Map<string, string[]>()
|
||||
let hasDuplicate = false
|
||||
|
||||
hardwareGroups.value.forEach((group) => {
|
||||
group.pens.forEach((pen, teamIndex) => {
|
||||
const sn = pen.sn.trim()
|
||||
if (!sn)
|
||||
return
|
||||
|
||||
const location = `第${group.groupId}组-T${teamIndex + 1}`
|
||||
if (snMap.has(sn)) {
|
||||
snMap.get(sn)?.push(location)
|
||||
hasDuplicate = true
|
||||
}
|
||||
else {
|
||||
snMap.set(sn, [location])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (hasDuplicate) {
|
||||
const duplicates: string[] = []
|
||||
snMap.forEach((locations, sn) => {
|
||||
if (locations.length > 1) {
|
||||
duplicates.push(`SN "${sn}" 重复于: ${locations.join(', ')}`)
|
||||
}
|
||||
})
|
||||
const msg = duplicates.slice(0, 3).join('; ') + (duplicates.length > 3 ? ' 等...' : '')
|
||||
window.$message?.error(`发现重复 SN:${msg}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
// 强制重新生成
|
||||
if (props.config) {
|
||||
hardwareGroups.value = [] // 清空以触发重新生成
|
||||
generateHardwareGroups()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hardwareGroups,
|
||||
generateHardwareGroups,
|
||||
validate,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export function useHardwareExcel(props: any, hardwareGroups: any) {
|
||||
// 下载模板
|
||||
function handleDownloadTemplate() {
|
||||
if (!props.config)
|
||||
return
|
||||
|
||||
const { groupCount, teamCount } = props.config
|
||||
const data = []
|
||||
|
||||
for (let i = 1; i <= groupCount; i++) {
|
||||
for (let j = 1; j <= teamCount; j++) {
|
||||
data.push({
|
||||
分组名称: `第 ${i} 组`,
|
||||
队伍编号: j,
|
||||
点阵笔SN: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
exportToExcel(data, '硬件绑定模板', '硬件绑定')
|
||||
}
|
||||
|
||||
// 导入 Excel
|
||||
async function handleUpload({ file }: UploadCustomRequestOptions) {
|
||||
try {
|
||||
if (!file.file)
|
||||
return
|
||||
const data = await readExcel(file.file)
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
window.$message?.warning('Excel 文件内容为空')
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 识别表头映射 (兼容多种表头格式)
|
||||
const firstRow = data[0] as any
|
||||
const keys = Object.keys(firstRow)
|
||||
|
||||
const groupKey = keys.find(k => k.includes('分组') || k.includes('Group'))
|
||||
const teamKey = keys.find(k => k.includes('队伍') || k.includes('Team') || k.includes('点阵笔号'))
|
||||
const snKey = keys.find(k => k.includes('SN') || k.includes('SN') || k.includes('sn') || k.includes('点阵笔'))
|
||||
|
||||
if (!groupKey || !teamKey || !snKey) {
|
||||
window.$message?.error('Excel 模板格式错误,未找到“分组”、“队伍”或“SN”相关列')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 校验导入数据中的重复项
|
||||
const snMap = new Map<string, string[]>()
|
||||
const importDuplicates: string[] = []
|
||||
|
||||
data.forEach((row: any) => {
|
||||
const sn = row[snKey] ? String(row[snKey]).trim() : ''
|
||||
if (!sn)
|
||||
return
|
||||
|
||||
const groupNum = row[groupKey]
|
||||
const teamNum = row[teamKey]
|
||||
const location = `第${groupNum}组-T${teamNum}`
|
||||
|
||||
if (!snMap.has(sn)) {
|
||||
snMap.set(sn, [])
|
||||
}
|
||||
snMap.get(sn)?.push(location)
|
||||
})
|
||||
|
||||
snMap.forEach((locations, sn) => {
|
||||
if (locations.length > 1) {
|
||||
importDuplicates.push(`SN "${sn}" 重复于: ${locations.join(', ')}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (importDuplicates.length > 0) {
|
||||
const msg = importDuplicates.slice(0, 3).join('; ') + (importDuplicates.length > 3 ? ' 等...' : '')
|
||||
window.$message?.error(`导入文件中存在重复 SN,请检查:${msg}`, { duration: 5000 })
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 解析数据并填充
|
||||
let successCount = 0
|
||||
const newGroups = [...hardwareGroups.value] // 创建浅拷贝以触发响应式更新
|
||||
|
||||
// 辅助函数:解析索引
|
||||
const parseIndex = (val: any) => {
|
||||
if (typeof val === 'number')
|
||||
return val
|
||||
if (typeof val === 'string') {
|
||||
const match = val.match(/\d+/)
|
||||
return match ? Number(match[0]) : Number.NaN
|
||||
}
|
||||
return Number.NaN
|
||||
}
|
||||
|
||||
data.forEach((row: any) => {
|
||||
const groupIdx = parseIndex(row[groupKey]) - 1
|
||||
const teamIdx = parseIndex(row[teamKey]) - 1
|
||||
const sn = row[snKey] ? String(row[snKey]).trim() : ''
|
||||
|
||||
if (
|
||||
!Number.isNaN(groupIdx) && groupIdx >= 0 && groupIdx < newGroups.length
|
||||
&& newGroups[groupIdx]
|
||||
&& !Number.isNaN(teamIdx) && teamIdx >= 0 && teamIdx < newGroups[groupIdx].pens.length
|
||||
) {
|
||||
// 直接修改对象属性
|
||||
newGroups[groupIdx].pens[teamIdx].sn = `${sn}号笔`
|
||||
successCount++
|
||||
}
|
||||
})
|
||||
|
||||
// 强制触发更新
|
||||
hardwareGroups.value = newGroups
|
||||
|
||||
if (successCount > 0) {
|
||||
window.$message?.success(`成功导入 ${successCount} 条数据`)
|
||||
}
|
||||
else {
|
||||
console.warn('导入数据匹配失败', data)
|
||||
window.$message?.warning('未匹配到任何有效数据,请检查分组/队伍编号是否正确')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
window.$message?.error('导入失败,请检查文件格式')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleDownloadTemplate,
|
||||
handleUpload,
|
||||
}
|
||||
}
|
||||
|
||||
export function useQuickGenerate(props: any, hardwareGroups: any) {
|
||||
// 快速生成笔号
|
||||
function quickGenerate(type: 'order' | 'random' = 'order') {
|
||||
if (!props.config)
|
||||
return
|
||||
|
||||
// 1. 收集所有笔的引用
|
||||
const allPens: any[] = []
|
||||
hardwareGroups.value.forEach((group: HardwareGroup) => {
|
||||
group.pens.forEach((pen) => {
|
||||
allPens.push(pen)
|
||||
})
|
||||
})
|
||||
|
||||
if (allPens.length === 0)
|
||||
return
|
||||
|
||||
// 2. 生成序号数组
|
||||
const numbers = Array.from({ length: allPens.length }, (_, i) => i + 1)
|
||||
|
||||
// 3. 如果是随机模式,打乱数组 (Fisher-Yates shuffle)
|
||||
if (type === 'random') {
|
||||
for (let i = numbers.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[numbers[i], numbers[j]] = [numbers[j], numbers[i]]
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 依次赋值
|
||||
allPens.forEach((pen, index) => {
|
||||
pen.sn = `${numbers[index]}号笔`
|
||||
})
|
||||
|
||||
window.$message?.success(type === 'random' ? '已随机生成笔号' : '已顺序生成笔号')
|
||||
}
|
||||
|
||||
return {
|
||||
quickGenerate,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface QuestionItem {
|
||||
value: string | null
|
||||
score: number
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface RoundModel {
|
||||
id: string
|
||||
name: string
|
||||
questions: QuestionItem[]
|
||||
}
|
||||
|
||||
export interface QuestionConfigModel {
|
||||
rounds: RoundModel[]
|
||||
}
|
||||
|
||||
export function useQuestionConfig(props: any) {
|
||||
// 计算每个轮次的分数
|
||||
const getRoundScore = (roundIndex: number) => {
|
||||
const round = props.modelValue?.rounds?.[roundIndex]
|
||||
if (!round)
|
||||
return 0
|
||||
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.score) || 0), 0)
|
||||
}
|
||||
|
||||
// 计算每个轮次的时长
|
||||
const getRoundTime = (roundIndex: number) => {
|
||||
const round = props.modelValue?.rounds?.[roundIndex]
|
||||
if (!round)
|
||||
return 0
|
||||
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.time) || 0), 0)
|
||||
}
|
||||
|
||||
// 总计统计
|
||||
const totalStats = computed(() => {
|
||||
const rounds = props.modelValue?.rounds || []
|
||||
return rounds.reduce((acc: any, round: any) => {
|
||||
acc.count += round.questions.length
|
||||
acc.score += round.questions.reduce((sum: number, item: any) => sum + (Number(item.score) || 0), 0)
|
||||
acc.time += round.questions.reduce((sum: number, item: any) => sum + (Number(item.time) || 0), 0)
|
||||
return acc
|
||||
}, { count: 0, score: 0, time: 0 })
|
||||
})
|
||||
|
||||
const questionOptions = [
|
||||
{ label: '汉字听写 - 根据提示书写汉字', 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' },
|
||||
]
|
||||
|
||||
const scoreOptions = [
|
||||
{ label: '1分', value: 1 },
|
||||
{ label: '2分', value: 2 },
|
||||
{ label: '3分', value: 3 },
|
||||
{ label: '5分', value: 5 },
|
||||
{ label: '10分', value: 10 },
|
||||
]
|
||||
|
||||
const timeOptions = [
|
||||
{ label: '15s', value: 15 },
|
||||
{ label: '30s', value: 30 },
|
||||
{ label: '45s', value: 45 },
|
||||
{ label: '60s', value: 60 },
|
||||
{ label: '90s', value: 90 },
|
||||
]
|
||||
|
||||
// 新增轮次
|
||||
function addRound(customName?: string, customCount?: number) {
|
||||
if (!props.modelValue.rounds) {
|
||||
props.modelValue.rounds = []
|
||||
}
|
||||
const index = props.modelValue.rounds.length + 1
|
||||
const count = typeof customCount === 'number' ? customCount : 10
|
||||
const name = typeof customName === 'string' ? customName : `第${index}轮:比赛环节`
|
||||
|
||||
props.modelValue.rounds.push({
|
||||
id: `round_${Date.now()}`,
|
||||
name,
|
||||
questions: Array.from({ length: count }).map(() => ({
|
||||
value: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// 删除轮次
|
||||
function removeRound(index: number) {
|
||||
props.modelValue.rounds.splice(index, 1)
|
||||
// 重命名剩余轮次
|
||||
props.modelValue.rounds.forEach((round: RoundModel, idx: number) => {
|
||||
round.name = round.name.replace(/第\d+轮/, `第${idx + 1}轮`)
|
||||
})
|
||||
}
|
||||
|
||||
// 新增题目
|
||||
function addQuestion(roundIndex: number) {
|
||||
const round = props.modelValue.rounds[roundIndex]
|
||||
if (round) {
|
||||
round.questions.push({
|
||||
value: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除题目
|
||||
function removeQuestion(roundIndex: number, questionIndex: number) {
|
||||
const round = props.modelValue.rounds[roundIndex]
|
||||
if (round) {
|
||||
round.questions.splice(questionIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const rounds = props.modelValue?.rounds || []
|
||||
if (rounds.length === 0) {
|
||||
window.$message?.error('请至少配置一个比赛环节')
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < rounds.length; i++) {
|
||||
const round = rounds[i]
|
||||
if (round.questions.length === 0) {
|
||||
window.$message?.error(`${round.name} 至少需要配置一道题目`)
|
||||
return false
|
||||
}
|
||||
for (let j = 0; j < round.questions.length; j++) {
|
||||
if (!round.questions[j].value) {
|
||||
window.$message?.error(`${round.name} 第 ${j + 1} 题未选择题型`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
// 默认重置为1个轮次,10个题目
|
||||
props.modelValue.rounds = []
|
||||
addRound()
|
||||
}
|
||||
|
||||
return {
|
||||
getRoundScore,
|
||||
getRoundTime,
|
||||
totalStats,
|
||||
questionOptions,
|
||||
scoreOptions,
|
||||
timeOptions,
|
||||
addRound,
|
||||
removeRound,
|
||||
addQuestion,
|
||||
removeQuestion,
|
||||
validate,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@ -6,10 +6,11 @@ import CompetitionAddModal from '../competition-add/index.vue'
|
||||
import CompetitionCard from './modules/competition-card.vue'
|
||||
import { type CompetitionItem, competitionList } from './modules/data'
|
||||
|
||||
// 引入 store 用于判断移动端布局 (与你提供的风格保持一致)
|
||||
const appStore = useAppStore()
|
||||
const gap = computed(() => (appStore.isMobile ? 12 : 26))
|
||||
|
||||
const competitionListRef = ref(competitionList)
|
||||
|
||||
const showAddModal = ref(false)
|
||||
|
||||
function handleAdd() {
|
||||
@ -22,8 +23,43 @@ function handleCopy(item: CompetitionItem) {
|
||||
}
|
||||
|
||||
function handleDelete(item: CompetitionItem) {
|
||||
// TODO: 实现删除逻辑
|
||||
window.$message?.success(`已删除活动:${item.title}`)
|
||||
const d = window.$dialog?.warning({
|
||||
title: '确认删除',
|
||||
content: `确定删除活动:${item.title}吗?`,
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
if (d) {
|
||||
d.loading = true
|
||||
}
|
||||
// 模拟接口请求延迟 2秒
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
try {
|
||||
// 模拟 50% 概率失败
|
||||
if (Math.random() < 0.5) {
|
||||
throw new Error('模拟服务器异常,删除失败')
|
||||
}
|
||||
|
||||
// 成功逻辑
|
||||
const index = competitionListRef.value.findIndex(i => i.id === item.id)
|
||||
if (index > -1) {
|
||||
competitionListRef.value.splice(index, 1)
|
||||
window.$message?.success(`已删除活动:${item.title}`)
|
||||
}
|
||||
return true // 返回 true 关闭弹窗
|
||||
}
|
||||
catch (error: any) {
|
||||
window.$message?.error(error.message)
|
||||
return false // 返回 false 阻止关闭弹窗
|
||||
}
|
||||
finally {
|
||||
if (d) {
|
||||
d.loading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -40,9 +76,9 @@ function handleDelete(item: CompetitionItem) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NButton type="primary" size="medium" class="px-6 font-medium" @click="handleAdd">
|
||||
<NButton type="primary" size="large" class="px-6 font-medium" @click="handleAdd">
|
||||
<template #icon>
|
||||
<div class="i-carbon-add text-lg" />
|
||||
<icon-ic-outline-add-to-photos class="text-icon" />
|
||||
</template>
|
||||
新增比赛活动
|
||||
</NButton>
|
||||
@ -51,7 +87,7 @@ function handleDelete(item: CompetitionItem) {
|
||||
<!-- 卡片列表区域 -->
|
||||
<!-- 响应式布局:手机1列,平板2列,中屏3列,大屏4列 -->
|
||||
<NGrid :x-gap="gap" :y-gap="gap" responsive="screen" item-responsive>
|
||||
<NGi v-for="item in competitionList" :key="item.id" span="24 s:12 m:8 l:6">
|
||||
<NGi v-for="item in competitionListRef" :key="item.id" span="24 s:12 m:8 l:6">
|
||||
<CompetitionCard :item="item" @copy="handleCopy" @delete="handleDelete" />
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
@ -80,10 +80,7 @@ const decorationBgClass = computed(() => {
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
circle
|
||||
secondary
|
||||
size="small"
|
||||
class="shadow-sm backdrop-blur-sm !bg-white/80 hover:!bg-white"
|
||||
circle secondary size="small" class="shadow-sm backdrop-blur-sm !bg-white/80 hover:!bg-white"
|
||||
@click.stop="emit('copy', item)"
|
||||
>
|
||||
<template #icon>
|
||||
@ -97,12 +94,8 @@ const decorationBgClass = computed(() => {
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
circle
|
||||
secondary
|
||||
size="small"
|
||||
type="error"
|
||||
class="shadow-sm backdrop-blur-sm !bg-white/80 hover:!bg-red-50"
|
||||
@click.stop="emit('delete', item)"
|
||||
circle secondary size="small" type="error"
|
||||
class="shadow-sm backdrop-blur-sm !bg-white/80 hover:!bg-red-50" @click.stop="emit('delete', item)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-baseline-delete class="text-icon" />
|
||||
@ -132,7 +125,7 @@ const decorationBgClass = computed(() => {
|
||||
<!-- 日期 -->
|
||||
<div class="mb-6 flex items-center text-gray-400">
|
||||
<icon-ic-baseline-calendar-month class="mr-2 text-xl" />
|
||||
<span class="text-base font-bold">{{ item.date }}</span>
|
||||
<span class="text-base font-bold">{{ item.date }} ~ {{ item.endDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ export interface CompetitionItem {
|
||||
id: string
|
||||
title: string
|
||||
date: string
|
||||
endDate: string
|
||||
teamCount: number
|
||||
status: 'ongoing' | 'pending' | 'ended' // ongoing: 进行中, pending: 未开始, ended: 已结束
|
||||
isHighlight?: boolean // 用于标记第一个卡片的特殊背景
|
||||
@ -14,6 +15,7 @@ export const competitionList: CompetitionItem[] = [
|
||||
id: '1',
|
||||
title: '阅读之星大赛\n辞海遨游环节', // 使用换行符或在组件中处理换行
|
||||
date: '2026.01.12',
|
||||
endDate: '2026.01.12',
|
||||
teamCount: 40,
|
||||
status: 'ongoing',
|
||||
isHighlight: true,
|
||||
@ -22,6 +24,7 @@ export const competitionList: CompetitionItem[] = [
|
||||
id: '2',
|
||||
title: '诗词大会总决赛',
|
||||
date: '2026.01.12',
|
||||
endDate: '2026.01.12',
|
||||
teamCount: 32,
|
||||
status: 'pending',
|
||||
},
|
||||
@ -29,6 +32,7 @@ export const competitionList: CompetitionItem[] = [
|
||||
id: '3',
|
||||
title: '趣味百科大作战',
|
||||
date: '2026.01.12',
|
||||
endDate: '2026.01.12',
|
||||
teamCount: 32,
|
||||
status: 'ended',
|
||||
},
|
||||
@ -36,6 +40,7 @@ export const competitionList: CompetitionItem[] = [
|
||||
id: '4',
|
||||
title: '经典文学研读周',
|
||||
date: '2026.01.12',
|
||||
endDate: '2026.01.12',
|
||||
teamCount: 32,
|
||||
status: 'pending',
|
||||
},
|
||||
@ -43,6 +48,7 @@ export const competitionList: CompetitionItem[] = [
|
||||
id: '5',
|
||||
title: '未来科学家阅读赛',
|
||||
date: '2026.01.12',
|
||||
endDate: '2026.01.12',
|
||||
teamCount: 32,
|
||||
status: 'pending',
|
||||
},
|
||||
@ -50,6 +56,7 @@ export const competitionList: CompetitionItem[] = [
|
||||
id: '6',
|
||||
title: '历史人文知识赛',
|
||||
date: '2026.01.12',
|
||||
endDate: '2026.01.12',
|
||||
teamCount: 32,
|
||||
status: 'pending',
|
||||
},
|
||||
@ -57,6 +64,7 @@ export const competitionList: CompetitionItem[] = [
|
||||
id: '7',
|
||||
title: '少儿绘本创意营',
|
||||
date: '2026.01.12',
|
||||
endDate: '2026.01.12',
|
||||
teamCount: 32,
|
||||
status: 'pending',
|
||||
},
|
||||
@ -64,6 +72,7 @@ export const competitionList: CompetitionItem[] = [
|
||||
id: '8',
|
||||
title: '唐诗三百首挑战',
|
||||
date: '2026.01.12',
|
||||
endDate: '2026.01.12',
|
||||
teamCount: 32,
|
||||
status: 'pending',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user