feat(admin): 添加比赛管理页面功能优化
refactor(utils): 为工具函数添加注释并导出新模块 feat(utils): 新增防抖和节流工具函数 docs(utils): 为现有工具类添加中文注释 style(admin): 优化比赛配置页面UI样式 feat(admin): 实现比赛基础信息表单双向绑定 feat(admin): 新增页面头部公共组件 feat(admin): 完善分组管理自动生成逻辑 feat(admin): 增强题目配置功能与交互 fix(admin): 修复表单数据更新死循环问题
This commit is contained in:
35
apps/admin/src/components/common/page-header.vue
Normal file
35
apps/admin/src/components/common/page-header.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
|
||||||
|
defineOptions({ name: 'PageHeader' })
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
showBack: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showBack?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const { routerBack } = useRouterPush()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NCard :bordered="false" class="rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<NButton v-if="showBack" circle secondary @click="routerBack">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-baseline-arrow-back class="text-icon" />
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<slot name="action" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
18
apps/admin/src/typings/components.d.ts
vendored
18
apps/admin/src/typings/components.d.ts
vendored
@ -24,7 +24,15 @@ declare module 'vue' {
|
|||||||
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||||
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||||
|
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||||
IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
|
IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
|
||||||
|
IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default']
|
||||||
|
IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']
|
||||||
|
IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default']
|
||||||
|
IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default']
|
||||||
|
IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
|
||||||
|
IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
|
||||||
|
IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
|
||||||
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||||
IconLocalActivity: typeof import('~icons/local/activity')['default']
|
IconLocalActivity: typeof import('~icons/local/activity')['default']
|
||||||
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||||
@ -78,6 +86,7 @@ declare module 'vue' {
|
|||||||
NThing: typeof import('naive-ui')['NThing']
|
NThing: typeof import('naive-ui')['NThing']
|
||||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
NWatermark: typeof import('naive-ui')['NWatermark']
|
NWatermark: typeof import('naive-ui')['NWatermark']
|
||||||
|
PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
||||||
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||||
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
@ -106,7 +115,15 @@ declare global {
|
|||||||
const IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
const IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||||
const IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
const IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||||
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||||
|
const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||||
const IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
|
const IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
|
||||||
|
const IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default']
|
||||||
|
const IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']
|
||||||
|
const IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default']
|
||||||
|
const IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default']
|
||||||
|
const IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
|
||||||
|
const IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
|
||||||
|
const IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
|
||||||
const IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
const IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||||
const IconLocalActivity: typeof import('~icons/local/activity')['default']
|
const IconLocalActivity: typeof import('~icons/local/activity')['default']
|
||||||
const IconLocalBanner: typeof import('~icons/local/banner')['default']
|
const IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||||
@ -160,6 +177,7 @@ declare global {
|
|||||||
const NThing: typeof import('naive-ui')['NThing']
|
const NThing: typeof import('naive-ui')['NThing']
|
||||||
const NTooltip: typeof import('naive-ui')['NTooltip']
|
const NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
const NWatermark: typeof import('naive-ui')['NWatermark']
|
const NWatermark: typeof import('naive-ui')['NWatermark']
|
||||||
|
const PageHeader: typeof import('./../components/common/page-header.vue')['default']
|
||||||
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||||
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
|||||||
@ -1,55 +1,50 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, NCard, NTabPane, NTabs } from 'naive-ui'
|
import { NTabPane, NTabs } from 'naive-ui'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import BasicInfo from './modules/BasicInfo.vue'
|
import BasicInfo from './modules/BasicInfo.vue'
|
||||||
import GroupManagement from './modules/GroupManagement.vue'
|
import GroupManagement from './modules/GroupManagement.vue'
|
||||||
import HardwareBinding from './modules/HardwareBinding.vue'
|
import HardwareBinding from './modules/HardwareBinding.vue'
|
||||||
import QuestionConfig from './modules/QuestionConfig.vue'
|
import QuestionConfig from './modules/QuestionConfig.vue'
|
||||||
|
|
||||||
const activeTab = ref('group')
|
const activeTab = ref('group')
|
||||||
const { routerBack } = useRouterPush()
|
|
||||||
|
const competitionData = ref({
|
||||||
|
name: '阅读之星年度总决赛',
|
||||||
|
startTime: null,
|
||||||
|
endTime: null,
|
||||||
|
groupCount: 40,
|
||||||
|
teamCount: 4,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="margin-[10px auto] h-full w-100% flex flex-col gap-4 overflow-y-auto p-4">
|
<div class="margin-[10px auto] h-full w-100% flex flex-col gap-4 overflow-y-auto p-4">
|
||||||
<!-- 顶部 Header区域 -->
|
<!-- 顶部 Header区域 -->
|
||||||
<NCard :bordered="false" class="rounded-xl shadow-sm">
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
<div>
|
||||||
<div class="flex items-center gap-4">
|
<h2 class="m-0 text-xl font-bold">
|
||||||
<!-- 返回比赛列表 -->
|
比赛配置编辑
|
||||||
|
</h2>
|
||||||
<NButton circle secondary @click="routerBack">
|
<p class="m-0 text-xs text-gray-400">
|
||||||
<template #icon>
|
正在编辑:阅读之星年度总决赛
|
||||||
<icon-ic-baseline-arrow-back class="text-icon" />
|
</p>
|
||||||
</template>
|
|
||||||
</NButton>
|
|
||||||
<div>
|
|
||||||
<h2 class="m-0 text-xl font-bold">
|
|
||||||
比赛配置编辑
|
|
||||||
</h2>
|
|
||||||
<p class="m-0 text-xs text-gray-400">
|
|
||||||
正在编辑:阅读之星年度总决赛
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<NButton>存为草稿</NButton>
|
|
||||||
<NButton type="primary">
|
|
||||||
发布比赛
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</NCard>
|
<template #action>
|
||||||
|
<NButton>存为草稿</NButton>
|
||||||
|
<NButton type="primary">
|
||||||
|
发布比赛
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- 基础信息组件 -->
|
<!-- 基础信息组件 -->
|
||||||
<BasicInfo />
|
<BasicInfo v-model:model-value="competitionData" />
|
||||||
|
|
||||||
<!-- Tab 切换区域 -->
|
<!-- Tab 切换区域 -->
|
||||||
<NCard :bordered="false" class="flex-1 rounded-xl shadow-sm">
|
<NCard :bordered="false" class="flex-1 rounded-xl shadow-sm">
|
||||||
<NTabs v-model:value="activeTab" type="line" animated>
|
<NTabs v-model:value="activeTab" type="line" animated>
|
||||||
<NTabPane name="group" tab="分组信息管理">
|
<NTabPane name="group" tab="分组信息管理">
|
||||||
<GroupManagement />
|
<GroupManagement :config="competitionData" />
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
<NTabPane name="hardware" tab="智能笔硬件绑定">
|
<NTabPane name="hardware" tab="智能笔硬件绑定">
|
||||||
<HardwareBinding />
|
<HardwareBinding />
|
||||||
|
|||||||
@ -1,16 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { debounce } from '@sa/utils'
|
||||||
import { NCard, NDatePicker, NForm, NFormItemGi, NGrid, NInput, NInputNumber } from 'naive-ui'
|
import { NCard, NDatePicker, NForm, NFormItemGi, NGrid, NInput, NInputNumber } from 'naive-ui'
|
||||||
import { reactive, useTemplateRef } from 'vue'
|
import { ref, useTemplateRef, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: {
|
||||||
|
name: string
|
||||||
|
startTime: number | null
|
||||||
|
endTime: number | null
|
||||||
|
groupCount: number
|
||||||
|
teamCount: number
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
const formRef = useTemplateRef('formRef')
|
const formRef = useTemplateRef('formRef')
|
||||||
|
|
||||||
const formData = reactive({
|
const formData = ref({ ...props.modelValue })
|
||||||
name: '阅读之星年度总决赛',
|
|
||||||
startTime: null,
|
// 修复死循环:添加值对比逻辑
|
||||||
endTime: null,
|
watch(() => props.modelValue, (val) => {
|
||||||
groupCount: 40,
|
if (JSON.stringify(val) !== JSON.stringify(formData.value)) {
|
||||||
teamCount: 4,
|
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 })
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
formRef,
|
formRef,
|
||||||
@ -44,12 +67,26 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
</NFormItemGi>
|
</NFormItemGi>
|
||||||
|
|
||||||
<NFormItemGi label="小组总数">
|
<NFormItemGi label="参赛队伍总数">
|
||||||
<NInputNumber v-model:value="formData.groupCount" class="w-full" />
|
<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>
|
||||||
|
|
||||||
<NFormItemGi label="单组队伍">
|
<NFormItemGi label="单组队伍">
|
||||||
<NInputNumber v-model:value="formData.teamCount" class="w-full" />
|
<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>
|
</NFormItemGi>
|
||||||
</NGrid>
|
</NGrid>
|
||||||
</NForm>
|
</NForm>
|
||||||
|
|||||||
@ -1,36 +1,75 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, NGrid, NGridItem, NInput, NTag } from 'naive-ui'
|
import { NButton, NGrid, NGridItem, NInput, NTag } from 'naive-ui'
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
// 模拟数据
|
const props = defineProps<{
|
||||||
const groups = ref([
|
config?: {
|
||||||
{ name: '第一组 (精英赛区)', id: 'G01' },
|
groupCount: number
|
||||||
{ name: '第二组 (新锐赛区)', id: 'G02' },
|
teamCount: number
|
||||||
{ name: '第三组', id: 'G03' },
|
}
|
||||||
{ name: '第四组', id: 'G04' },
|
}>()
|
||||||
])
|
|
||||||
|
const groups = ref<Array<{ name: string, id: string, teamCount: number }>>([])
|
||||||
|
|
||||||
|
function generateGroups() {
|
||||||
|
if (!props.config)
|
||||||
|
return
|
||||||
|
|
||||||
|
const { groupCount, teamCount } = props.config
|
||||||
|
if (!teamCount || teamCount <= 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
const totalGroups = Math.ceil(groupCount / teamCount)
|
||||||
|
const newGroups = []
|
||||||
|
|
||||||
|
for (let i = 1; i <= totalGroups; i++) {
|
||||||
|
const idStr = String(i).padStart(2, '0')
|
||||||
|
// 计算当前组的队伍数量
|
||||||
|
let currentTeamCount = teamCount
|
||||||
|
// 如果是最后一组,且有余数,则使用余数
|
||||||
|
if (i === totalGroups) {
|
||||||
|
const remainder = groupCount % teamCount
|
||||||
|
if (remainder > 0) {
|
||||||
|
currentTeamCount = remainder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newGroups.push({
|
||||||
|
name: `第${i}组`,
|
||||||
|
id: `G${idStr}`,
|
||||||
|
teamCount: currentTeamCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
groups.value = newGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.config, generateGroups, { deep: true, immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<!-- 操作栏 -->
|
<!-- 操作栏 -->
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-2 w-full flex items-center justify-between">
|
||||||
<div class="text-sm text-gray-400">
|
<div class="w-full flex items-start gap-3 border border-indigo-100 rounded-lg bg-indigo-50 p-4">
|
||||||
提示:您可以通过Excel模版批量导入队伍名称
|
<div class="w-full">
|
||||||
</div>
|
<div class="mt-1 text-sm text-indigo-400">
|
||||||
<div class="flex gap-3">
|
您可以通过Excel模版批量导入队伍名称
|
||||||
<NButton secondary>
|
</div>
|
||||||
<template #icon>
|
</div>
|
||||||
<div class="i-carbon-document-import" />
|
<div class="flex gap-3">
|
||||||
</template>
|
<NButton secondary>
|
||||||
Excel批量导入
|
<template #icon>
|
||||||
</NButton>
|
<icon-ic-baseline-upload-file class="text-icon" />
|
||||||
<NButton type="primary" color="#1a202c">
|
</template>
|
||||||
<template #icon>
|
Excel批量导入
|
||||||
<div class="i-carbon-add" />
|
</NButton>
|
||||||
</template>
|
<NButton type="primary" color="#1a202c">
|
||||||
添加分组
|
<template #icon>
|
||||||
</NButton>
|
<icon-ic-baseline-add class="text-icon" />
|
||||||
|
</template>
|
||||||
|
添加分组
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -46,9 +85,9 @@ const groups = ref([
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NGrid :x-gap="16" :y-gap="16" :cols="2">
|
<NGrid :x-gap="16" :y-gap="16" :cols="2">
|
||||||
<NGridItem v-for="i in 4" :key="i">
|
<NGridItem v-for="i in group.teamCount" :key="i">
|
||||||
<div class="mb-1 text-xs text-gray-400">
|
<div class="mb-1 text-xs text-gray-400">
|
||||||
NO.0{{ i }} 队名
|
NO.{{ String(i).padStart(2, '0') }} 队名
|
||||||
</div>
|
</div>
|
||||||
<NInput placeholder="输入队伍名称" />
|
<NInput placeholder="输入队伍名称" />
|
||||||
</NGridItem>
|
</NGridItem>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NGrid, NGridItem, NSelect } from 'naive-ui'
|
import { NGrid, NGridItem, NSelect } from 'naive-ui'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const questionOptions = [
|
const questionOptions = [
|
||||||
{ label: '汉字听写 - 根据提示书写汉字', value: 'hanzi' },
|
{ label: '汉字听写 - 根据提示书写汉字', value: 'hanzi' },
|
||||||
@ -10,6 +10,9 @@ const questionOptions = [
|
|||||||
{ label: '成语填空 - 四字成语', value: 'chengyu' },
|
{ label: '成语填空 - 四字成语', value: 'chengyu' },
|
||||||
{ label: '反义词挑战', value: 'fanyi' },
|
{ label: '反义词挑战', value: 'fanyi' },
|
||||||
{ label: '近义词挑战', value: 'jinyi' },
|
{ label: '近义词挑战', value: 'jinyi' },
|
||||||
|
{ label: '看图猜字', value: 'kantu' },
|
||||||
|
{ label: '古诗词接龙', value: 'gushi' },
|
||||||
|
{ label: '名句听写', value: 'mingju' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const pkOptions = [
|
const pkOptions = [
|
||||||
@ -17,23 +20,45 @@ const pkOptions = [
|
|||||||
{ label: '极速成语接龙 - 每题20秒', value: 'speed20' },
|
{ 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 regularQuestions = ref([
|
const regularQuestions = ref([
|
||||||
{ value: 'hanzi' },
|
{ value: 'hanzi', score: 1, time: 15 },
|
||||||
{ value: 'tongyin' },
|
{ value: 'tongyin', score: 1, time: 15 },
|
||||||
{ value: 'pianpang' },
|
{ value: 'pianpang', score: 1, time: 30 },
|
||||||
{ value: 'ciyu' },
|
{ value: 'ciyu', score: 1, time: 30 },
|
||||||
{ value: 'chengyu' },
|
{ value: 'chengyu', score: 1, time: 30 },
|
||||||
{ value: 'fanyi' },
|
{ value: 'fanyi', score: 1, time: 60 },
|
||||||
{ value: 'jinyi' },
|
{ value: 'jinyi', score: 1, time: 30 },
|
||||||
|
{ value: 'kantu', score: 1, time: 30 },
|
||||||
|
{ value: 'gushi', score: 1, time: 60 },
|
||||||
|
{ value: 'mingju', score: 1, time: 60 },
|
||||||
])
|
])
|
||||||
|
|
||||||
const pkQuestions = ref([
|
const pkQuestions = ref([
|
||||||
{ value: 'speed10' },
|
{ value: 'speed10', score: 1, time: 60 },
|
||||||
{ value: 'speed10' },
|
{ value: 'speed10', score: 1, time: 60 },
|
||||||
{ value: 'speed10' },
|
{ value: 'speed10', score: 1, time: 60 },
|
||||||
{ value: 'speed10' },
|
{ value: 'speed10', score: 1, time: 60 },
|
||||||
{ value: 'speed10' },
|
{ value: 'speed10', score: 3, time: 60 },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const regularTotalScore = computed(() => regularQuestions.value.reduce((acc, cur) => acc + cur.score, 0))
|
||||||
|
const pkTotalScore = computed(() => pkQuestions.value.reduce((acc, cur) => acc + cur.score, 0))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -41,25 +66,76 @@ const pkQuestions = ref([
|
|||||||
<NGrid :x-gap="48" :y-gap="24" cols="1 l:2" responsive="screen">
|
<NGrid :x-gap="48" :y-gap="24" cols="1 l:2" responsive="screen">
|
||||||
<!-- 左侧:常规赛题包 -->
|
<!-- 左侧:常规赛题包 -->
|
||||||
<NGridItem>
|
<NGridItem>
|
||||||
<div class="h-full rounded-xl bg-gray-50/50 p-6">
|
<div class="h-full rounded-2xl bg-[#F5F9FF] p-6">
|
||||||
<div class="mb-6 flex items-center gap-2 text-gray-800 font-bold">
|
<div class="mb-6 flex items-center justify-between border-b border-blue-100 pb-4">
|
||||||
<div class="i-carbon-notebook text-lg text-blue-500" />
|
<div class="flex items-center gap-2 text-gray-800 font-bold">
|
||||||
<span>常规赛题包</span>
|
<icon-ic-outline-archive class="text-xl text-blue-500" />
|
||||||
|
<span class="text-lg">常规赛题包</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-[#8DA5C3] font-medium">
|
||||||
|
共 {{ regularQuestions.length }} 题 / 总计 {{ regularTotalScore }} 分
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-5">
|
||||||
<div
|
<div
|
||||||
v-for="(item, idx) in regularQuestions"
|
v-for="(item, idx) in regularQuestions"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="flex items-center gap-4"
|
class="flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<span class="w-12 text-xs text-gray-400 font-medium">第{{ idx + 1 }}题</span>
|
<span class="w-12 flex-shrink-0 text-sm text-[#8DA5C3] font-bold">第{{ idx + 1 }}题</span>
|
||||||
|
|
||||||
|
<!-- 题型选择 -->
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="item.value"
|
v-model:value="item.value"
|
||||||
:options="questionOptions"
|
:options="questionOptions"
|
||||||
placeholder="请选择题型"
|
placeholder="请选择题型"
|
||||||
class="flex-1 bg-white"
|
class="min-w-0 flex-[2]"
|
||||||
|
:theme-overrides="{
|
||||||
|
peers: {
|
||||||
|
InternalSelection: {
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
border: '1px solid #EBF1F9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 分数 -->
|
||||||
|
<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 flex-shrink-0 items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-400">答题时间:</span>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="item.time"
|
||||||
|
:options="timeOptions"
|
||||||
|
class="w-22"
|
||||||
|
:theme-overrides="{
|
||||||
|
peers: {
|
||||||
|
InternalSelection: {
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
border: '1px solid #EBF1F9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -67,25 +143,76 @@ const pkQuestions = ref([
|
|||||||
|
|
||||||
<!-- 右侧:加时赛 (PK环节) -->
|
<!-- 右侧:加时赛 (PK环节) -->
|
||||||
<NGridItem>
|
<NGridItem>
|
||||||
<div class="h-full border border-orange-100/50 rounded-xl bg-orange-50/30 p-6">
|
<div class="h-full border border-[#FFF0E0] rounded-2xl bg-[#FFF9F5] p-6">
|
||||||
<div class="mb-6 flex items-center gap-2 text-gray-800 font-bold">
|
<div class="mb-6 flex items-center justify-between border-b border-orange-100 pb-4">
|
||||||
<div class="i-carbon-timer text-lg text-orange-500" />
|
<div class="flex items-center gap-2 text-[#5E3218] font-bold">
|
||||||
<span>加时赛 (PK环节)</span>
|
<icon-ic-outline-timer class="text-xl text-[#D96B23]" />
|
||||||
|
<span class="text-lg">加时赛题包</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-[#D96B23]/60 font-medium">
|
||||||
|
共 {{ pkQuestions.length }} 题 / 总计 {{ pkTotalScore }} 分
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-5">
|
||||||
<div
|
<div
|
||||||
v-for="(item, idx) in pkQuestions"
|
v-for="(item, idx) in pkQuestions"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="flex items-center gap-4"
|
class="flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<span class="w-8 text-xs text-orange-400 font-medium">PK{{ idx + 1 }}</span>
|
<span class="w-12 flex-shrink-0 text-sm text-[#FF8C38] font-bold">PK{{ idx + 1 }}</span>
|
||||||
|
|
||||||
|
<!-- PK规则 -->
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="item.value"
|
v-model:value="item.value"
|
||||||
:options="pkOptions"
|
:options="pkOptions"
|
||||||
placeholder="请选择PK规则"
|
placeholder="请选择PK规则"
|
||||||
class="flex-1 bg-white"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, NCard, NTag, useMessage } from 'naive-ui'
|
import { NButton, NTag, useMessage } from 'naive-ui'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import BasicInfoCard from './modules/BasicInfoCard.vue'
|
import BasicInfoCard from './modules/BasicInfoCard.vue'
|
||||||
@ -31,27 +31,29 @@ function handleSave() {
|
|||||||
<!-- 右侧主要内容区 -->
|
<!-- 右侧主要内容区 -->
|
||||||
<div class="h-full flex flex-col flex-1 overflow-hidden">
|
<div class="h-full flex flex-col flex-1 overflow-hidden">
|
||||||
<!-- 新增:顶部全局操作栏 -->
|
<!-- 新增:顶部全局操作栏 -->
|
||||||
<NCard :bordered="false" class="mb-4 flex-shrink-0 rounded-xl shadow-sm">
|
<PageHeader class="mb-4 flex-shrink-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="text-sm text-gray-500">
|
||||||
<div class="text-sm text-gray-500">
|
当前状态:
|
||||||
当前状态:
|
<NTag :type="isEdit ? 'warning' : 'success'" size="small" :bordered="false">
|
||||||
<NTag :type="isEdit ? 'warning' : 'success'" size="small" :bordered="false">
|
{{ isEdit ? '编辑中' : '预览中' }}
|
||||||
{{ isEdit ? '编辑中' : '预览中' }}
|
</NTag>
|
||||||
</NTag>
|
</div>
|
||||||
</div>
|
<template #action>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<NButton type="error" secondary>
|
<NButton type="error" secondary>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="i-carbon-trash-can" />
|
<icon-ic-baseline-delete class="text-icon" />
|
||||||
</template>
|
</template>
|
||||||
删除
|
删除赛事
|
||||||
</NButton>
|
</NButton>
|
||||||
|
|
||||||
|
<div class="w-1px bg-gray-200" />
|
||||||
|
|
||||||
<NButton v-if="!isEdit" type="primary" secondary @click="isEdit = true">
|
<NButton v-if="!isEdit" type="primary" secondary @click="isEdit = true">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="i-carbon-edit" />
|
<icon-ic-baseline-edit class="text-icon" />
|
||||||
</template>
|
</template>
|
||||||
编辑
|
进入编辑模式
|
||||||
</NButton>
|
</NButton>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@ -60,21 +62,21 @@ function handleSave() {
|
|||||||
</NButton>
|
</NButton>
|
||||||
<NButton type="primary" @click="handleSave">
|
<NButton type="primary" @click="handleSave">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="i-carbon-save" />
|
<icon-ic-baseline-save class="text-icon" />
|
||||||
</template>
|
</template>
|
||||||
保存提交
|
保存更改
|
||||||
</NButton>
|
</NButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<NButton v-if="!isEdit" type="primary" color="#1a202c">
|
<NButton v-if="!isEdit" type="success">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="i-carbon-rocket" />
|
<icon-ic-baseline-rocket-launch class="text-icon" />
|
||||||
</template>
|
</template>
|
||||||
发布
|
立即发布
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</NCard>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- 滚动内容区 -->
|
<!-- 滚动内容区 -->
|
||||||
<div class="no-scrollbar flex flex-col flex-1 gap-4 overflow-y-auto">
|
<div class="no-scrollbar flex flex-col flex-1 gap-4 overflow-y-auto">
|
||||||
|
|||||||
@ -36,26 +36,26 @@ const scoreOptions = [
|
|||||||
<div
|
<div
|
||||||
class="flex items-center justify-between border-b rounded-t-lg px-4 py-3"
|
class="flex items-center justify-between border-b rounded-t-lg px-4 py-3"
|
||||||
:class="type === 'regular'
|
:class="type === 'regular'
|
||||||
? 'bg-gray-50 border-gray-100'
|
? 'bg-blue-50/50 border-blue-100'
|
||||||
: 'bg-orange-50/50 border-orange-100'"
|
: 'bg-orange-50/50 border-orange-100'"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-gray-700 font-bold">
|
<div class="flex items-center gap-2 font-bold" :class="type === 'regular' ? 'text-gray-800' : 'text-[#5E3218]'">
|
||||||
<div :class="type === 'regular' ? 'i-carbon-document-tasks text-blue-500' : 'i-carbon-timer text-orange-500'" />
|
<div :class="type === 'regular' ? 'i-carbon-document-tasks text-blue-500' : 'i-carbon-timer text-[#D96B23]'" />
|
||||||
{{ type === 'regular' ? '常规赛题包' : '加时赛题包' }}
|
{{ type === 'regular' ? '常规赛题包' : '加时赛题包' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs" :class="type === 'regular' ? 'text-gray-400' : 'text-orange-400'">
|
<div class="text-xs font-medium" :class="type === 'regular' ? 'text-[#8DA5C3]' : 'text-[#D96B23]/60'">
|
||||||
配置详情
|
共 {{ type === 'regular' ? 10 : 5 }} 题 / 总计 {{ type === 'regular' ? 10 : 7 }} 分
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 列表区域 -->
|
<!-- 列表区域 -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-3 border border-t-0 rounded-b-lg p-4"
|
class="flex flex-col gap-3 border border-t-0 rounded-b-lg p-4"
|
||||||
:class="type === 'regular' ? 'border-gray-100' : 'border-orange-100 bg-orange-50/10'"
|
:class="type === 'regular' ? 'border-blue-100 bg-[#F5F9FF]' : 'border-orange-100 bg-[#FFF9F5]'"
|
||||||
>
|
>
|
||||||
<div v-for="i in (type === 'regular' ? 10 : 5)" :key="`${type}-${i}`" class="flex items-center gap-2">
|
<div v-for="i in (type === 'regular' ? 10 : 5)" :key="`${type}-${i}`" class="flex items-center gap-2">
|
||||||
<!-- 题号 -->
|
<!-- 题号 -->
|
||||||
<div class="w-16 flex-shrink-0 text-xs font-bold" :class="type === 'regular' ? 'text-gray-400' : 'text-orange-400'">
|
<div class="w-16 flex-shrink-0 text-xs font-bold" :class="type === 'regular' ? 'text-[#8DA5C3]' : 'text-[#FF8C38]'">
|
||||||
{{ type === 'regular' ? `第${i}题` : `PK${i}` }}:
|
{{ type === 'regular' ? `第${i}题` : `PK${i}` }}:
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import CryptoJS from 'crypto-js'
|
import CryptoJS from 'crypto-js'
|
||||||
|
/**
|
||||||
|
* 加密解密工具类
|
||||||
|
*/
|
||||||
export class Crypto<T extends object> {
|
export class Crypto<T extends object> {
|
||||||
/** Secret */
|
/** Secret */
|
||||||
secret: string
|
secret: string
|
||||||
|
|||||||
@ -2,3 +2,4 @@ export * from './crypto'
|
|||||||
export * from './klona'
|
export * from './klona'
|
||||||
export * from './nanoid'
|
export * from './nanoid'
|
||||||
export * from './storage'
|
export * from './storage'
|
||||||
|
export * from './tool'
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
|
/**
|
||||||
|
* 生成 NanoID
|
||||||
|
*/
|
||||||
export { nanoid }
|
export { nanoid }
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
|
/**
|
||||||
|
* 本地存储工具类
|
||||||
|
*/
|
||||||
/** The storage type */
|
/** The storage type */
|
||||||
export type StorageType = 'local' | 'session'
|
export type StorageType = 'local' | 'session'
|
||||||
|
|
||||||
|
|||||||
55
packages/utils/src/tool.ts
Normal file
55
packages/utils/src/tool.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 防抖函数
|
||||||
|
* @param func 需要执行的函数
|
||||||
|
* @param wait 等待时间(毫秒)
|
||||||
|
* @param immediate 是否立即执行
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number,
|
||||||
|
immediate = false,
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
return function (this: any, ...args: Parameters<T>) {
|
||||||
|
// 直接使用 this,避免本地变量别名
|
||||||
|
|
||||||
|
if (timeout)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
const callNow = !timeout
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timeout = null
|
||||||
|
}, wait)
|
||||||
|
if (callNow)
|
||||||
|
func.apply(this, args)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
func.apply(this, args)
|
||||||
|
}, wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节流函数
|
||||||
|
* @param func 需要执行的函数
|
||||||
|
* @param wait 等待时间(毫秒)
|
||||||
|
*/
|
||||||
|
export function throttle<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number,
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let previous = 0
|
||||||
|
|
||||||
|
return function (this: any, ...args: Parameters<T>) {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (now - previous > wait) {
|
||||||
|
func.apply(this, args)
|
||||||
|
previous = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user