feat(admin): 新增题库模块并优化比赛管理功能

新增题库模块,包含题目管理页面和Excel导入导出功能
优化比赛管理功能,包括分组管理、硬件绑定和题目配置
重构比赛添加流程,增加重置功能和数据校验
添加Excel工具函数,支持读取和导出Excel文件
更新依赖,添加xlsx库支持
This commit is contained in:
2026-01-21 16:29:38 +08:00
parent 3270e24c25
commit 89773bd49b
33 changed files with 1988 additions and 496 deletions

View File

@ -44,7 +44,8 @@
"vue": "3.5.26", "vue": "3.5.26",
"vue-draggable-plus": "0.6.0", "vue-draggable-plus": "0.6.0",
"vue-i18n": "11.2.7", "vue-i18n": "11.2.7",
"vue-router": "4.6.4" "vue-router": "4.6.4",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@elegant-router/vue": "0.3.8", "@elegant-router/vue": "0.3.8",

View File

@ -230,6 +230,7 @@ const local: App.I18n.Schema = {
'iframe-page': 'Iframe', 'iframe-page': 'Iframe',
'home': 'Home', 'home': 'Home',
'competition': 'Competition', 'competition': 'Competition',
'question': 'Question',
'competition_competition-add': 'Competition Add', 'competition_competition-add': 'Competition Add',
'competition_competition-detail': 'Competition Detail', 'competition_competition-detail': 'Competition Detail',
'competition_competition-list': 'Competition List', 'competition_competition-list': 'Competition List',

View File

@ -226,6 +226,7 @@ const local: App.I18n.Schema = {
'iframe-page': '外链页面', 'iframe-page': '外链页面',
'home': '首页', 'home': '首页',
'competition': '比赛', 'competition': '比赛',
'question': '题库',
'competition_competition-add': '比赛添加', 'competition_competition-add': '比赛添加',
'competition_competition-detail': '比赛详情', 'competition_competition-detail': '比赛详情',
'competition_competition-list': '比赛列表', 'competition_competition-list': '比赛列表',

View File

@ -24,4 +24,5 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"competition_competition-detail": () => import("@/views/competition/competition-detail/index.vue"), "competition_competition-detail": () => import("@/views/competition/competition-detail/index.vue"),
"competition_competition-list": () => import("@/views/competition/competition-list/index.vue"), "competition_competition-list": () => import("@/views/competition/competition-list/index.vue"),
home: () => import("@/views/home/index.vue"), home: () => import("@/views/home/index.vue"),
question: () => import("@/views/question/index.vue"),
}; };

View File

@ -116,5 +116,14 @@ export const generatedRoutes: GeneratedRoute[] = [
constant: true, constant: true,
hideInMenu: true hideInMenu: true
} }
},
{
name: 'question',
path: '/question',
component: 'layout.base$view.question',
meta: {
title: 'question',
i18nKey: 'route.question'
}
} }
]; ];

View File

@ -172,7 +172,8 @@ const routeMap: RouteMap = {
"competition_competition-list": "/competition/competition-list", "competition_competition-list": "/competition/competition-list",
"home": "/home", "home": "/home",
"iframe-page": "/iframe-page/:url", "iframe-page": "/iframe-page/:url",
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?" "login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
"question": "/question"
}; };
/** /**

View File

@ -15,7 +15,6 @@ declare module 'vue' {
AppProvider: typeof import('./../components/common/app-provider.vue')['default'] AppProvider: typeof import('./../components/common/app-provider.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default'] BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default'] ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
copy: typeof import('./../components/custom/wave-bg copy.vue')['default']
CountTo: typeof import('./../components/custom/count-to.vue')['default'] CountTo: typeof import('./../components/custom/count-to.vue')['default']
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default'] DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
ExceptionBase: typeof import('./../components/common/exception-base.vue')['default'] ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
@ -26,23 +25,34 @@ declare module 'vue' {
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default'] IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['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']
IconIcBaselineCalendar: typeof import('~icons/ic/baseline-calendar')['default'] IconIcBaselineArrowForward: typeof import('~icons/ic/baseline-arrow-forward')['default']
IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default'] IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default']
IconIcBaselineCheck: typeof import('~icons/ic/baseline-check')['default']
IconIcBaselineClose: typeof import('~icons/ic/baseline-close')['default'] IconIcBaselineClose: typeof import('~icons/ic/baseline-close')['default']
IconIcBaselineContentCopy: typeof import('~icons/ic/baseline-content-copy')['default'] IconIcBaselineContentCopy: typeof import('~icons/ic/baseline-content-copy')['default']
IconIcBaselineCopy: typeof import('~icons/ic/baseline-copy')['default']
IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default'] IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default']
IconIcBaselineDownload: typeof import('~icons/ic/baseline-download')['default']
IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default'] IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']
IconIcBaselineFlash: typeof import('~icons/ic/baseline-flash')['default']
IconIcBaselineGroup: typeof import('~icons/ic/baseline-group')['default'] IconIcBaselineGroup: typeof import('~icons/ic/baseline-group')['default']
IconIcBaselineRefresh: typeof import('~icons/ic/baseline-refresh')['default']
IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default'] IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default']
IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default'] IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default']
IconIcBaselineScan: typeof import('~icons/ic/baseline-scan')['default']
IconIcBaselineSettingsBackupRestore: typeof import('~icons/ic/baseline-settings-backup-restore')['default']
IconIcBaselineUpload: typeof import('~icons/ic/baseline-upload')['default']
IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default'] IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
IconIcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
IconIcOutlineAddToPhotos: typeof import('~icons/ic/outline-add-to-photos')['default']
IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default'] IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
IconIcOutlineDelete: typeof import('~icons/ic/outline-delete')['default']
IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default'] IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default'] IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
IconLocalActivity: typeof import('~icons/local/activity')['default'] IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default'] IconLocalBanner: typeof import('~icons/local/banner')['default']
IconLocalReadLogo: typeof import('~icons/local/read-logo')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default'] IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default'] IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default'] IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
@ -70,7 +80,6 @@ declare module 'vue' {
NFormItem: typeof import('naive-ui')['NFormItem'] NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi'] NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid'] NGrid: typeof import('naive-ui')['NGrid']
NIcon: typeof import('naive-ui')['NIcon']
NInput: typeof import('naive-ui')['NInput'] NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup'] NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputNumber: typeof import('naive-ui')['NInputNumber'] NInputNumber: typeof import('naive-ui')['NInputNumber']
@ -78,7 +87,6 @@ declare module 'vue' {
NListItem: typeof import('naive-ui')['NListItem'] NListItem: typeof import('naive-ui')['NListItem']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider'] NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
NMenu: typeof import('naive-ui')['NMenu'] NMenu: typeof import('naive-ui')['NMenu']
NMessage: typeof import('naive-ui')['NMessage']
NMessageProvider: typeof import('naive-ui')['NMessageProvider'] NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal'] NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
@ -89,7 +97,6 @@ declare module 'vue' {
NSwitch: typeof import('naive-ui')['NSwitch'] NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab'] NTab: typeof import('naive-ui')['NTab']
NTabs: typeof import('naive-ui')['NTabs'] NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
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']
@ -113,7 +120,6 @@ declare global {
const AppProvider: typeof import('./../components/common/app-provider.vue')['default'] const AppProvider: typeof import('./../components/common/app-provider.vue')['default']
const BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default'] const BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
const ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default'] const ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
const copy: typeof import('./../components/custom/wave-bg copy.vue')['default']
const CountTo: typeof import('./../components/custom/count-to.vue')['default'] const CountTo: typeof import('./../components/custom/count-to.vue')['default']
const DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default'] const DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
const ExceptionBase: typeof import('./../components/common/exception-base.vue')['default'] const ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
@ -124,23 +130,34 @@ declare global {
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 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 IconIcBaselineCalendar: typeof import('~icons/ic/baseline-calendar')['default'] const IconIcBaselineArrowForward: typeof import('~icons/ic/baseline-arrow-forward')['default']
const IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default'] const IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default']
const IconIcBaselineCheck: typeof import('~icons/ic/baseline-check')['default']
const IconIcBaselineClose: typeof import('~icons/ic/baseline-close')['default'] const IconIcBaselineClose: typeof import('~icons/ic/baseline-close')['default']
const IconIcBaselineContentCopy: typeof import('~icons/ic/baseline-content-copy')['default'] const IconIcBaselineContentCopy: typeof import('~icons/ic/baseline-content-copy')['default']
const IconIcBaselineCopy: typeof import('~icons/ic/baseline-copy')['default']
const IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default'] const IconIcBaselineDelete: typeof import('~icons/ic/baseline-delete')['default']
const IconIcBaselineDownload: typeof import('~icons/ic/baseline-download')['default']
const IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default'] const IconIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']
const IconIcBaselineFlash: typeof import('~icons/ic/baseline-flash')['default']
const IconIcBaselineGroup: typeof import('~icons/ic/baseline-group')['default'] const IconIcBaselineGroup: typeof import('~icons/ic/baseline-group')['default']
const IconIcBaselineRefresh: typeof import('~icons/ic/baseline-refresh')['default']
const IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default'] const IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['default']
const IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default'] const IconIcBaselineSave: typeof import('~icons/ic/baseline-save')['default']
const IconIcBaselineScan: typeof import('~icons/ic/baseline-scan')['default']
const IconIcBaselineSettingsBackupRestore: typeof import('~icons/ic/baseline-settings-backup-restore')['default']
const IconIcBaselineUpload: typeof import('~icons/ic/baseline-upload')['default']
const IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default'] const IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
const IconIcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
const IconIcOutlineAddToPhotos: typeof import('~icons/ic/outline-add-to-photos')['default']
const IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default'] const IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
const IconIcOutlineDelete: typeof import('~icons/ic/outline-delete')['default']
const IconIcOutlineFolderOff: typeof import('~icons/ic/outline-folder-off')['default']
const IconIcOutlineFormatListNumbered: typeof import('~icons/ic/outline-format-list-numbered')['default']
const IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default'] const IconIcOutlineTimer: typeof import('~icons/ic/outline-timer')['default']
const IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['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 IconIcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
const IconLocalBanner: typeof import('~icons/local/banner')['default'] const IconLocalBanner: typeof import('~icons/local/banner')['default']
const IconLocalReadLogo: typeof import('~icons/local/read-logo')['default']
const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default'] const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
const IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default'] const IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default'] const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
@ -168,7 +185,6 @@ declare global {
const NFormItem: typeof import('naive-ui')['NFormItem'] const NFormItem: typeof import('naive-ui')['NFormItem']
const NGi: typeof import('naive-ui')['NGi'] const NGi: typeof import('naive-ui')['NGi']
const NGrid: typeof import('naive-ui')['NGrid'] const NGrid: typeof import('naive-ui')['NGrid']
const NIcon: typeof import('naive-ui')['NIcon']
const NInput: typeof import('naive-ui')['NInput'] const NInput: typeof import('naive-ui')['NInput']
const NInputGroup: typeof import('naive-ui')['NInputGroup'] const NInputGroup: typeof import('naive-ui')['NInputGroup']
const NInputNumber: typeof import('naive-ui')['NInputNumber'] const NInputNumber: typeof import('naive-ui')['NInputNumber']
@ -176,7 +192,6 @@ declare global {
const NListItem: typeof import('naive-ui')['NListItem'] const NListItem: typeof import('naive-ui')['NListItem']
const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider'] const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
const NMenu: typeof import('naive-ui')['NMenu'] const NMenu: typeof import('naive-ui')['NMenu']
const NMessage: typeof import('naive-ui')['NMessage']
const NMessageProvider: typeof import('naive-ui')['NMessageProvider'] const NMessageProvider: typeof import('naive-ui')['NMessageProvider']
const NModal: typeof import('naive-ui')['NModal'] const NModal: typeof import('naive-ui')['NModal']
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
@ -187,7 +202,6 @@ declare global {
const NSwitch: typeof import('naive-ui')['NSwitch'] const NSwitch: typeof import('naive-ui')['NSwitch']
const NTab: typeof import('naive-ui')['NTab'] const NTab: typeof import('naive-ui')['NTab']
const NTabs: typeof import('naive-ui')['NTabs'] const NTabs: typeof import('naive-ui')['NTabs']
const NTag: typeof import('naive-ui')['NTag']
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']

View File

@ -27,6 +27,7 @@ declare module "@elegant-router/types" {
"home": "/home"; "home": "/home";
"iframe-page": "/iframe-page/:url"; "iframe-page": "/iframe-page/:url";
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?"; "login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
"question": "/question";
}; };
/** /**
@ -65,6 +66,7 @@ declare module "@elegant-router/types" {
| "home" | "home"
| "iframe-page" | "iframe-page"
| "login" | "login"
| "question"
>; >;
/** /**
@ -90,6 +92,7 @@ declare module "@elegant-router/types" {
| "competition_competition-detail" | "competition_competition-detail"
| "competition_competition-list" | "competition_competition-list"
| "home" | "home"
| "question"
>; >;
/** /**

View File

@ -31,27 +31,17 @@ const competitionData = ref({
groupCount: 10, // 组别数量 groupCount: 10, // 组别数量
teamCount: 4, // 每组队伍数量 teamCount: 4, // 每组队伍数量
poster: '', // 竞赛海报 poster: '', // 竞赛海报
questions: { // 赛题包配置 rounds: [ // 赛题包配置
regular: [// 主赛环节 {
{ value: 'hanzi', score: 1, time: 15 }, id: 'round_default',
{ value: 'tongyin', score: 1, time: 15 }, name: '第一轮:主赛环节',
{ value: 'pianpang', score: 1, time: 30 }, questions: Array.from({ length: 10 }).map(() => ({
{ value: 'ciyu', score: 1, time: 30 }, value: null,
{ value: 'chengyu', score: 1, time: 30 }, score: 5,
{ value: 'fanyi', score: 1, time: 60 }, time: 30,
{ 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 },
],
},
// 名单录入 // 名单录入
groupManagement: [], // 组别管理 groupManagement: [], // 组别管理
// 绑定硬件 // 绑定硬件
@ -88,6 +78,20 @@ function nextStep() {
currentStep.value++ currentStep.value++
} }
else { 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('比赛发布成功') window.$message?.success('比赛发布成功')
emit('success') emit('success')
@ -100,12 +104,28 @@ function prevStep() {
currentStep.value -= 1 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> </script>
<template> <template>
<NModal :show="show" :mask-closable="false" @update:show="emit('update:show', $event)"> <NModal :show="show" :mask-closable="false" @update:show="handleClose">
<div <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 --> <!-- Header -->
<div class="flex items-center justify-between px-10 py-8"> <div class="flex items-center justify-between px-10 py-8">
@ -132,49 +152,58 @@ function prevStep() {
</div> </div>
<!-- Close Button --> <!-- Close Button -->
<button <div class="flex items-center gap-3">
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" <NButton secondary type="warning" size="small" @click="handleReset">
@click="handleClose" <template #icon>
> <icon-ic-baseline-refresh class="text-icon" />
<NButton </template>
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> </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> </div>
<!-- Content --> <!-- Content -->
<div class="flex-1 overflow-y-auto px-10"> <div class="flex-1 overflow-y-auto px-10">
<div class="h-full"> <div class="h-full">
<component <KeepAlive>
:is="currentComponent" ref="stepComponentRef" v-model:model-value="competitionData" <component
:config="competitionData" @update:config="(val: typeof competitionData) => competitionData = val" :is="currentComponent" ref="stepComponentRef" v-model="competitionData"
/> :config="competitionData"
/>
</KeepAlive>
</div> </div>
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="flex items-center justify-end gap-4 px-10 py-8"> <div class="flex items-center justify-between border-t border-gray-100 bg-gray-50/50 px-10 py-6">
<div class="mr-auto flex flex-col"> <NButton v-if="currentStep > 1" secondary type="primary" size="large" class="px-8 font-bold" @click="prevStep">
<span class="text-xs text-gray-300 font-bold tracking-widest uppercase"> <template #icon>
Configuration Progress <icon-ic-baseline-arrow-back class="text-icon" />
</span> </template>
<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">
上一步 上一步
</NButton> </NButton>
<div v-else />
<NButton <NButton
type="primary" size="large" type="primary" size="large" class="px-10 text-lg font-bold shadow-blue-500/30 shadow-lg"
class="h-12 rounded-xl bg-[#2563EB] px-10 text-base font-bold shadow-blue-500/30 shadow-lg" @click="nextStep" @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> </NButton>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { debounce } from '@sa/utils' import { NButton, NDatePicker, NForm, NInput, NInputNumber, NUpload } from 'naive-ui'
import { NButton, NDatePicker, NForm, NInput, NInputNumber, NUpload, type UploadCustomRequestOptions } from 'naive-ui' import { useTemplateRef } from 'vue'
import { ref, useTemplateRef, watch } from 'vue' import { useBasicInfo } from './useBasicInfo'
const props = defineProps<{ const props = defineProps<{
modelValue: { modelValue: {
@ -18,80 +18,9 @@ const emit = defineEmits(['update:modelValue'])
const formRef = useTemplateRef('formRef') const formRef = useTemplateRef('formRef')
const formData = ref({ ...props.modelValue }) const { formData, updateCount, handleUpload, validate, reset } = useBasicInfo(props, emit)
// 修复死循环:添加值对比逻辑 defineExpose({ validate, reset })
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 })
</script> </script>
<template> <template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NGrid, NGridItem, NInput } from 'naive-ui' import { NButton, NGrid, NGridItem, NInput } from 'naive-ui'
import { ref, watch } from 'vue' import { useExcelExport, useExcelImport, useGroupManagement } from './useGroupManagement'
const props = defineProps<{ const props = defineProps<{
config?: { 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() { defineExpose({ validate, reset })
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 })
</script> </script>
<template> <template>
@ -91,18 +28,30 @@ defineExpose({ validate })
系统已根据您的设置生成了 {{ groups.length }} 个分组您可以手动填写或批量导入 系统已根据您的设置生成了 {{ groups.length }} 个分组您可以手动填写或批量导入
</p> </p>
</div> </div>
<NButton type="primary" color="#1a202c" size="large"> <div class="flex gap-3">
<template #icon> <NButton secondary type="primary" @click="downloadTemplate">
<icon-ic-baseline-upload-file class="text-icon" /> <template #icon>
</template> <icon-ic-baseline-download class="text-icon" />
Excel 导入名单 </template>
</NButton> 下载导入模板
</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> </div>
<!-- 分组卡片列表 --> <!-- 分组卡片列表 -->
<NGrid :x-gap="24" :y-gap="24" cols="1 m:2 l:3" responsive="screen"> <NGrid :x-gap="24" :y-gap="24" cols="1 m:2 l:3" responsive="screen">
<NGridItem v-for="(group, index) in groups" :key="index"> <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"> <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> <span class="shrink-0 text-lg text-gray-800 font-bold">{{ group.name }}</span>
</div> </div>
@ -110,13 +59,12 @@ defineExpose({ validate })
<NGrid :x-gap="16" :y-gap="16" :cols="2"> <NGrid :x-gap="16" :y-gap="16" :cols="2">
<NGridItem v-for="(team, tIndex) in group.teams" :key="tIndex"> <NGridItem v-for="(team, tIndex) in group.teams" :key="tIndex">
<div class="mb-1 text-xs text-gray-400 font-bold"> <div class="mb-1 text-xs text-gray-400 font-bold">
<!-- 显示分组序号从1到teamCount -->
TEAM {{ tIndex + 1 }} TEAM {{ tIndex + 1 }}
<!-- // 显示分组序号从1到groups.length*teamCount -->
<!-- TEAM {{ (index + 1) * (props.config?.teamCount ?? 0) - (props.config?.teamCount ?? 0) + tIndex + 1 }} -->
</div> </div>
<NInput <NInput v-model:value="team.name" placeholder="输入队伍名称" class="rounded-lg !bg-white" />
v-model:value="team.name"
placeholder="输入队伍名称"
class="rounded-lg !bg-white"
/>
</NGridItem> </NGridItem>
</NGrid> </NGrid>
</div> </div>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { NGrid, NGridItem, NInput } from 'naive-ui' import { NButton, NGrid, NGridItem, NInput, NUpload } from 'naive-ui'
import { ref, watch } from 'vue' import { useHardwareBinding, useHardwareExcel, useQuickGenerate } from './useHardwareBinding'
const props = defineProps<{ const props = defineProps<{
config?: { config?: {
@ -9,64 +9,52 @@ const props = defineProps<{
} }
}>() }>()
interface HardwareGroup { const { hardwareGroups, validate, reset } = useHardwareBinding(props)
groupId: string const { handleDownloadTemplate, handleUpload } = useHardwareExcel(props, hardwareGroups)
pens: Array<{ label: string, sn: string, online: boolean }> const { quickGenerate } = useQuickGenerate(props, hardwareGroups)
}
const hardwareGroups = ref<HardwareGroup[]>([]) defineExpose({ validate, reset })
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 })
</script> </script>
<template> <template>
<div class="h-full"> <div class="h-full">
<div class="mb-8"> <div class="mb-8 flex items-center justify-between">
<h2 class="mb-2 text-3xl text-gray-800 font-bold"> <div>
点阵笔硬件绑定 <h2 class="mb-2 text-3xl text-gray-800 font-bold">
</h2> 点阵笔硬件绑定
<p class="text-gray-400"> </h2>
请将点阵笔序列号 (SN) 绑定至对应队伍建议使用扫码枪快速录入 <p class="text-gray-400">
</p> 请将点阵笔序列号 (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> </div>
<!-- 状态提示栏 --> <!-- 状态提示栏 -->
@ -85,7 +73,7 @@ defineExpose({ validate })
<!-- 硬件状态卡片 --> <!-- 硬件状态卡片 -->
<NGrid :x-gap="24" :y-gap="24" cols="1 m:2 l:3" responsive="screen"> <NGrid :x-gap="24" :y-gap="24" cols="1 m:2 l:3" responsive="screen">
<NGridItem v-for="(group, idx) in hardwareGroups" :key="idx"> <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"> <div class="mb-4 text-gray-800 font-bold">
硬件绑定: {{ idx + 1 }} 分组 硬件绑定: {{ idx + 1 }} 分组
</div> </div>
@ -102,7 +90,8 @@ defineExpose({ validate })
/> />
</div> </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> </div>
</div> </div>

View File

@ -1,235 +1,249 @@
<script setup lang="ts"> <script setup lang="ts">
import { NGrid, NGridItem, NSelect } from 'naive-ui' import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect } from 'naive-ui'
import { computed } from 'vue' import { ref, watch } from 'vue'
import { useQuestionConfig } from './useQuestionConfig'
const props = defineProps<{ const props = defineProps<{
modelValue: { modelValue: {
questions: { rounds: Array<{
regular: Array<{ value: string, score: number, time: number }> id: string
pk: Array<{ value: string, score: number, time: number }> name: string
} questions: Array<{ value: string | null, score: number, time: number }>
}>
} }
}>() }>()
const questionOptions = [ const {
{ label: '汉字听写 - 根据提示书写汉字', value: 'hanzi' }, getRoundScore,
{ label: '同音字辨析 - 根据拼音书写', value: 'tongyin' }, getRoundTime,
{ label: '偏旁部首 - 按要求写字', value: 'pianpang' }, totalStats,
{ label: '词语听写 - 综合练习', value: 'ciyu' }, questionOptions,
{ label: '成语填空 - 四字成语', value: 'chengyu' }, scoreOptions,
{ label: '反义词挑战', value: 'fanyi' }, timeOptions,
{ label: '近义词挑战', value: 'jinyi' }, addRound,
{ label: '看图猜字', value: 'kantu' }, removeRound,
{ label: '古诗词接龙', value: 'gushi' }, addQuestion,
{ label: '名句听写', value: 'mingju' }, removeQuestion,
] validate,
reset,
} = useQuestionConfig(props)
const pkOptions = [ // 模态框控制
{ label: '极速成语接龙 - 每题10秒', value: 'speed10' }, const showAddModal = ref(false)
{ label: '极速成语接龙 - 每题20秒', value: 'speed20' }, const newRoundForm = ref({
] name: '',
count: 10,
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 pkTotalScore = computed(() => { function handleOpenAddModal() {
return props.modelValue?.questions?.pk?.reduce((acc, cur) => acc + cur.score, 0) || 0 newRoundForm.value = {
}) name: `${(props.modelValue?.rounds?.length || 0) + 1}轮 汉字听写`,
count: 10,
function validate() {
// 简单校验:主赛环节是否有题目
if (!props.modelValue?.questions?.regular?.length) {
window.$message?.error('请至少配置一道主赛题目')
return false
} }
// 检查是否所有题目都选了题型 showAddModal.value = true
const invalidRegular = props.modelValue.questions.regular.some(q => !q.value)
if (invalidRegular) {
window.$message?.error('请完善主赛环节的题型配置')
return false
}
return 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> </script>
<template> <template>
<div class="py-4"> <div class="h-full flex flex-col">
<div class="mb-8 text-xl text-gray-800 font-bold"> <!-- 顶部标题栏 -->
比赛赛题包配置 <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> </div>
<NGrid :x-gap="48" :y-gap="24" cols="1 l:2" responsive="screen">
<!-- 左侧主赛环节 --> <!-- 空状态 -->
<NGridItem> <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-full rounded-2xl bg-[#F5F9FF] p-6"> <div class="h-34 w-34 flex items-center justify-center rounded-3xl bg-gray-100 text-gray-300">
<div class="mb-6 flex items-center justify-between border-b border-blue-100 pb-4"> <icon-ic-outline-folder-off class="text-5xl" />
<div class="flex items-center gap-2 text-gray-800 font-bold"> </div>
<icon-ic-outline-archive class="text-xl text-blue-500" /> <div class="mt-6 text-lg text-gray-400 font-bold">
<span class="text-lg">主赛环节</span> 暂无配置题包
</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>
<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> </div>
<div class="flex flex-col gap-5"> <!-- 题目列表 -->
<div class="flex flex-col gap-3">
<div <div
v-for="(item, idx) in props.modelValue?.questions?.regular" v-for="(item, qIdx) in round.questions"
:key="idx" :key="qIdx"
class="flex items-center gap-3" 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 <div class="flex-1">
v-model:value="item.value" <NSelect
:options="questionOptions" v-model:value="item.value"
placeholder="请选择题型" :options="questionOptions"
class="min-w-0 flex-[2]" placeholder="请选择题目类型..."
:theme-overrides="{ :bordered="false"
peers: { class="font-medium"
InternalSelection: { />
borderRadius: '8px', </div>
color: '#FFFFFF',
border: '1px solid #EBF1F9',
},
},
}"
/>
<!-- --> <!-- -->
<NSelect <div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
v-model:value="item.score" <span class="text-xs text-gray-400 font-bold tracking-wider uppercase">PTS</span>
:options="scoreOptions" <NSelect
class="w-20 flex-shrink-0" v-model:value="item.score"
:theme-overrides="{ :options="scoreOptions"
peers: { class="w-26 !bg-transparent"
InternalSelection: { size="small"
borderRadius: '8px', :bordered="false"
color: '#FFFFFF', />
border: '1px solid #EBF1F9', </div>
},
},
}"
/>
<!-- 答题时间 --> <!-- 时间 -->
<div class="flex flex-shrink-0 items-center gap-2"> <div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<span class="text-xs text-gray-400">答题时间:</span> <icon-ic-outline-timer class="text-gray-400" />
<NSelect <NSelect
v-model:value="item.time" v-model:value="item.time"
:options="timeOptions" :options="timeOptions"
class="w-22" class="w-26 !bg-transparent"
:theme-overrides="{ size="small"
peers: { :bordered="false"
InternalSelection: {
borderRadius: '8px',
color: '#FFFFFF',
border: '1px solid #EBF1F9',
},
},
}"
/> />
<span class="text-xs text-gray-400 font-bold">S</span>
</div> </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> </div>
</div> </div>
</NGridItem> </div>
</div>
<!-- 右侧加时PK环节 --> <!-- 新增题包弹窗 -->
<NGridItem> <NModal v-model:show="showAddModal" transform-origin="center">
<div class="h-full border border-[#FFF0E0] rounded-2xl bg-[#FFF9F5] p-6"> <div class="w-[480px] rounded-2xl bg-white p-8 shadow-2xl">
<div class="mb-6 flex items-center justify-between border-b border-orange-100 pb-4"> <div class="mb-8 flex flex-col items-center gap-4 text-center">
<div class="flex items-center gap-2 text-[#5E3218] font-bold"> <div class="h-14 w-14 flex items-center justify-center rounded-2xl bg-blue-50 text-blue-500">
<icon-ic-outline-timer class="text-xl text-[#D96B23]" /> <icon-ic-round-library-add class="text-3xl" />
<span class="text-lg">加时PK环节</span>
</div>
<div class="text-sm text-[#D96B23]/60 font-medium">
{{ props.modelValue?.questions?.pk?.length || 0 }} / 总计 {{ pkTotalScore }}
</div>
</div> </div>
<div>
<div class="flex flex-col gap-5"> <div class="text-xl text-gray-800 font-bold">
<div 新建环节题包
v-for="(item, idx) in props.modelValue?.questions?.pk" </div>
:key="idx" <div class="mt-1 text-sm text-gray-500">
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>
</div> </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> </div>
</template> </template>

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -6,10 +6,11 @@ import CompetitionAddModal from '../competition-add/index.vue'
import CompetitionCard from './modules/competition-card.vue' import CompetitionCard from './modules/competition-card.vue'
import { type CompetitionItem, competitionList } from './modules/data' import { type CompetitionItem, competitionList } from './modules/data'
// 引入 store 用于判断移动端布局 (与你提供的风格保持一致)
const appStore = useAppStore() const appStore = useAppStore()
const gap = computed(() => (appStore.isMobile ? 12 : 26)) const gap = computed(() => (appStore.isMobile ? 12 : 26))
const competitionListRef = ref(competitionList)
const showAddModal = ref(false) const showAddModal = ref(false)
function handleAdd() { function handleAdd() {
@ -22,8 +23,43 @@ function handleCopy(item: CompetitionItem) {
} }
function handleDelete(item: CompetitionItem) { function handleDelete(item: CompetitionItem) {
// TODO: 实现删除逻辑 const d = window.$dialog?.warning({
window.$message?.success(`已删除活动:${item.title}`) 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> </script>
@ -40,9 +76,9 @@ function handleDelete(item: CompetitionItem) {
</p> </p>
</div> </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> <template #icon>
<div class="i-carbon-add text-lg" /> <icon-ic-outline-add-to-photos class="text-icon" />
</template> </template>
新增比赛活动 新增比赛活动
</NButton> </NButton>
@ -51,7 +87,7 @@ function handleDelete(item: CompetitionItem) {
<!-- 卡片列表区域 --> <!-- 卡片列表区域 -->
<!-- 响应式布局手机1列平板2列中屏3列大屏4列 --> <!-- 响应式布局手机1列平板2列中屏3列大屏4列 -->
<NGrid :x-gap="gap" :y-gap="gap" responsive="screen" item-responsive> <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" /> <CompetitionCard :item="item" @copy="handleCopy" @delete="handleDelete" />
</NGi> </NGi>
</NGrid> </NGrid>

View File

@ -80,10 +80,7 @@ const decorationBgClass = computed(() => {
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NButton <NButton
circle circle secondary size="small" class="shadow-sm backdrop-blur-sm !bg-white/80 hover:!bg-white"
secondary
size="small"
class="shadow-sm backdrop-blur-sm !bg-white/80 hover:!bg-white"
@click.stop="emit('copy', item)" @click.stop="emit('copy', item)"
> >
<template #icon> <template #icon>
@ -97,12 +94,8 @@ const decorationBgClass = computed(() => {
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NButton <NButton
circle circle secondary size="small" type="error"
secondary class="shadow-sm backdrop-blur-sm !bg-white/80 hover:!bg-red-50" @click.stop="emit('delete', item)"
size="small"
type="error"
class="shadow-sm backdrop-blur-sm !bg-white/80 hover:!bg-red-50"
@click.stop="emit('delete', item)"
> >
<template #icon> <template #icon>
<icon-ic-baseline-delete class="text-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"> <div class="mb-6 flex items-center text-gray-400">
<icon-ic-baseline-calendar-month class="mr-2 text-xl" /> <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>
</div> </div>

View File

@ -4,6 +4,7 @@ export interface CompetitionItem {
id: string id: string
title: string title: string
date: string date: string
endDate: string
teamCount: number teamCount: number
status: 'ongoing' | 'pending' | 'ended' // ongoing: 进行中, pending: 未开始, ended: 已结束 status: 'ongoing' | 'pending' | 'ended' // ongoing: 进行中, pending: 未开始, ended: 已结束
isHighlight?: boolean // 用于标记第一个卡片的特殊背景 isHighlight?: boolean // 用于标记第一个卡片的特殊背景
@ -14,6 +15,7 @@ export const competitionList: CompetitionItem[] = [
id: '1', id: '1',
title: '阅读之星大赛\n辞海遨游环节', // 使用换行符或在组件中处理换行 title: '阅读之星大赛\n辞海遨游环节', // 使用换行符或在组件中处理换行
date: '2026.01.12', date: '2026.01.12',
endDate: '2026.01.12',
teamCount: 40, teamCount: 40,
status: 'ongoing', status: 'ongoing',
isHighlight: true, isHighlight: true,
@ -22,6 +24,7 @@ export const competitionList: CompetitionItem[] = [
id: '2', id: '2',
title: '诗词大会总决赛', title: '诗词大会总决赛',
date: '2026.01.12', date: '2026.01.12',
endDate: '2026.01.12',
teamCount: 32, teamCount: 32,
status: 'pending', status: 'pending',
}, },
@ -29,6 +32,7 @@ export const competitionList: CompetitionItem[] = [
id: '3', id: '3',
title: '趣味百科大作战', title: '趣味百科大作战',
date: '2026.01.12', date: '2026.01.12',
endDate: '2026.01.12',
teamCount: 32, teamCount: 32,
status: 'ended', status: 'ended',
}, },
@ -36,6 +40,7 @@ export const competitionList: CompetitionItem[] = [
id: '4', id: '4',
title: '经典文学研读周', title: '经典文学研读周',
date: '2026.01.12', date: '2026.01.12',
endDate: '2026.01.12',
teamCount: 32, teamCount: 32,
status: 'pending', status: 'pending',
}, },
@ -43,6 +48,7 @@ export const competitionList: CompetitionItem[] = [
id: '5', id: '5',
title: '未来科学家阅读赛', title: '未来科学家阅读赛',
date: '2026.01.12', date: '2026.01.12',
endDate: '2026.01.12',
teamCount: 32, teamCount: 32,
status: 'pending', status: 'pending',
}, },
@ -50,6 +56,7 @@ export const competitionList: CompetitionItem[] = [
id: '6', id: '6',
title: '历史人文知识赛', title: '历史人文知识赛',
date: '2026.01.12', date: '2026.01.12',
endDate: '2026.01.12',
teamCount: 32, teamCount: 32,
status: 'pending', status: 'pending',
}, },
@ -57,6 +64,7 @@ export const competitionList: CompetitionItem[] = [
id: '7', id: '7',
title: '少儿绘本创意营', title: '少儿绘本创意营',
date: '2026.01.12', date: '2026.01.12',
endDate: '2026.01.12',
teamCount: 32, teamCount: 32,
status: 'pending', status: 'pending',
}, },
@ -64,6 +72,7 @@ export const competitionList: CompetitionItem[] = [
id: '8', id: '8',
title: '唐诗三百首挑战', title: '唐诗三百首挑战',
date: '2026.01.12', date: '2026.01.12',
endDate: '2026.01.12',
teamCount: 32, teamCount: 32,
status: 'pending', status: 'pending',
}, },

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/store/modules/app'
import CardData from './modules/card-data.vue'
import CreativityBanner from './modules/creativity-banner.vue'
import HeaderBanner from './modules/header-banner.vue'
import LineChart from './modules/line-chart.vue'
import PieChart from './modules/pie-chart.vue'
import ProjectNews from './modules/project-news.vue'
const appStore = useAppStore()
const gap = computed(() => (appStore.isMobile ? 0 : 16))
</script>
<template>
<NSpace vertical :size="16">
<NAlert :title="$t('common.tip')" type="warning">
{{ $t('page.home.branchDesc') }}
</NAlert>
<HeaderBanner />
<CardData />
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
<NGi span="24 s:24 m:14">
<NCard :bordered="false" class="card-wrapper">
<LineChart />
</NCard>
</NGi>
<NGi span="24 s:24 m:10">
<NCard :bordered="false" class="card-wrapper">
<PieChart />
</NCard>
</NGi>
</NGrid>
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
<NGi span="24 s:24 m:14">
<ProjectNews />
</NGi>
<NGi span="24 s:24 m:10">
<CreativityBanner />
</NGi>
</NGrid>
</NSpace>
</template>
<style scoped></style>

View File

@ -0,0 +1,112 @@
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core'
import { computed } from 'vue'
import { $t } from '@/locales'
import { useThemeStore } from '@/store/modules/theme'
defineOptions({
name: 'CardData',
})
interface CardData {
key: string
title: string
value: number
unit: string
color: {
start: string
end: string
}
icon: string
}
const cardData = computed<CardData[]>(() => [
{
key: 'visitCount',
title: $t('page.home.visitCount'),
value: 9725,
unit: '',
color: {
start: '#ec4786',
end: '#b955a4',
},
icon: 'ant-design:bar-chart-outlined',
},
{
key: 'turnover',
title: $t('page.home.turnover'),
value: 1026,
unit: '$',
color: {
start: '#865ec0',
end: '#5144b4',
},
icon: 'ant-design:money-collect-outlined',
},
{
key: 'downloadCount',
title: $t('page.home.downloadCount'),
value: 970925,
unit: '',
color: {
start: '#56cdf3',
end: '#719de3',
},
icon: 'carbon:document-download',
},
{
key: 'dealCount',
title: $t('page.home.dealCount'),
value: 9527,
unit: '',
color: {
start: '#fcbc25',
end: '#f68057',
},
icon: 'ant-design:trademark-circle-outlined',
},
])
interface GradientBgProps {
gradientColor: string
}
const [DefineGradientBg, GradientBg] = createReusableTemplate<GradientBgProps>()
const themeStore = useThemeStore()
function getGradientColor(color: CardData['color']) {
return `linear-gradient(to bottom right, ${color.start}, ${color.end})`
}
</script>
<template>
<NCard :bordered="false" size="small" class="card-wrapper">
<DefineGradientBg v-slot="{ $slots, gradientColor }">
<div
class="px-16px pb-4px pt-8px text-white"
:style="{ backgroundImage: gradientColor, borderRadius: `${themeStore.themeRadius}px` }"
>
<component :is="$slots.default" />
</div>
</DefineGradientBg>
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="16" :y-gap="16">
<NGi v-for="item in cardData" :key="item.key">
<GradientBg :gradient-color="getGradientColor(item.color)" class="flex-1">
<h3 class="text-16px">
{{ item.title }}
</h3>
<div class="flex justify-between pt-12px">
<SvgIcon :icon="item.icon" class="text-32px" />
<CountTo
:prefix="item.unit" :start-value="1" :end-value="item.value"
class="text-30px text-white dark:text-dark"
/>
</div>
</GradientBg>
</NGi>
</NGrid>
</NCard>
</template>
<style scoped></style>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import { $t } from '@/locales'
defineOptions({
name: 'CreativityBanner',
})
</script>
<template>
<NCard :title="$t('page.home.creativity')" :bordered="false" size="small" class="h-full card-wrapper">
<div class="h-full flex-center">
<icon-local-banner class="text-400px text-primary sm:text-320px" />
</div>
</NCard>
</template>
<style scoped></style>

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue'
import { $t } from '@/locales'
import { useAppStore } from '@/store/modules/app'
import { useAuthStore } from '@/store/modules/auth'
defineOptions({
name: 'HeaderBanner',
})
const appStore = useAppStore()
const authStore = useAuthStore()
const gap = computed(() => (appStore.isMobile ? 0 : 16))
interface StatisticData {
id: number
label: string
value: string
}
const statisticData = computed<StatisticData[]>(() => [
{
id: 0,
label: $t('page.home.projectCount'),
value: '25',
},
{
id: 1,
label: $t('page.home.todo'),
value: '4/16',
},
{
id: 2,
label: $t('page.home.message'),
value: '12',
},
])
</script>
<template>
<NCard :bordered="false" class="card-wrapper">
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
<NGi span="24 s:24 m:18">
<div class="flex-y-center">
<div class="size-72px shrink-0 overflow-hidden rd-1/2">
<img src="@/assets/imgs/reader-star.jpg" class="size-full">
</div>
<div class="pl-12px">
<h3 class="text-18px font-semibold">
{{ $t('page.home.greeting', { userName: authStore.userInfo.userName }) }}
</h3>
<p class="text-#999 leading-30px">
{{ $t('page.home.weatherDesc') }}
</p>
</div>
</div>
</NGi>
<NGi span="24 s:24 m:6">
<NSpace :size="24" justify="end">
<NStatistic v-for="item in statisticData" :key="item.id" class="whitespace-nowrap" v-bind="item" />
</NSpace>
</NGi>
</NGrid>
</NCard>
</template>
<style scoped></style>

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useEcharts } from '@/hooks/common/echarts'
import { $t } from '@/locales'
import { useAppStore } from '@/store/modules/app'
defineOptions({
name: 'LineChart',
})
const appStore = useAppStore()
const { domRef, updateOptions } = useEcharts(() => ({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: {
data: [$t('page.home.downloadCount'), $t('page.home.registerCount')],
top: '0',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
},
xAxis: {
type: 'category',
boundaryGap: false,
data: [] as string[],
},
yAxis: {
type: 'value',
},
series: [
{
color: '#8e9dff',
name: $t('page.home.downloadCount'),
type: 'line',
smooth: true,
stack: 'Total',
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0.25,
color: '#8e9dff',
},
{
offset: 1,
color: '#fff',
},
],
},
},
emphasis: {
focus: 'series',
},
data: [] as number[],
},
{
color: '#26deca',
name: $t('page.home.registerCount'),
type: 'line',
smooth: true,
stack: 'Total',
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0.25,
color: '#26deca',
},
{
offset: 1,
color: '#fff',
},
],
},
},
emphasis: {
focus: 'series',
},
data: [],
},
],
}))
async function mockData() {
await new Promise((resolve) => {
setTimeout(resolve, 1000)
})
updateOptions((opts) => {
opts.xAxis.data = ['06:00', '08:00', '10:00', '12:00', '14:00', '16:00', '18:00', '20:00', '22:00', '24:00']
opts.series[0].data = [4623, 6145, 6268, 6411, 1890, 4251, 2978, 3880, 3606, 4311]
opts.series[1].data = [2208, 2016, 2916, 4512, 8281, 2008, 1963, 2367, 2956, 678]
return opts
})
}
function updateLocale() {
updateOptions((opts, factory) => {
const originOpts = factory()
opts.legend.data = originOpts.legend.data
opts.series[0].name = originOpts.series[0].name
opts.series[1].name = originOpts.series[1].name
return opts
})
}
async function init() {
mockData()
}
watch(
() => appStore.locale,
() => {
updateLocale()
},
)
// init
init()
</script>
<template>
<NCard :bordered="false" class="card-wrapper">
<div ref="domRef" class="h-360px overflow-hidden" />
</NCard>
</template>
<style scoped></style>

View File

@ -0,0 +1,109 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useEcharts } from '@/hooks/common/echarts'
import { $t } from '@/locales'
import { useAppStore } from '@/store/modules/app'
defineOptions({
name: 'PieChart',
})
const appStore = useAppStore()
const { domRef, updateOptions } = useEcharts(() => ({
tooltip: {
trigger: 'item',
},
legend: {
bottom: '1%',
left: 'center',
itemStyle: {
borderWidth: 0,
},
},
series: [
{
color: ['#5da8ff', '#8e9dff', '#fedc69', '#26deca'],
name: $t('page.home.schedule'),
type: 'pie',
radius: ['45%', '75%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 1,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: '12',
},
},
labelLine: {
show: false,
},
data: [] as { name: string, value: number }[],
},
],
}))
async function mockData() {
await new Promise((resolve) => {
setTimeout(resolve, 1000)
})
updateOptions((opts) => {
opts.series[0].data = [
{ name: $t('page.home.study'), value: 20 },
{ name: $t('page.home.entertainment'), value: 10 },
{ name: $t('page.home.work'), value: 40 },
{ name: $t('page.home.rest'), value: 30 },
]
return opts
})
}
function updateLocale() {
updateOptions((opts, factory) => {
const originOpts = factory()
opts.series[0].name = originOpts.series[0].name
opts.series[0].data = [
{ name: $t('page.home.study'), value: 20 },
{ name: $t('page.home.entertainment'), value: 10 },
{ name: $t('page.home.work'), value: 40 },
{ name: $t('page.home.rest'), value: 30 },
]
return opts
})
}
async function init() {
mockData()
}
watch(
() => appStore.locale,
() => {
updateLocale()
},
)
// init
init()
</script>
<template>
<NCard :bordered="false" class="card-wrapper">
<div ref="domRef" class="h-360px overflow-hidden" />
</NCard>
</template>
<style scoped></style>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { $t } from '@/locales'
defineOptions({
name: 'ProjectNews',
})
interface NewsItem {
id: number
content: string
time: string
}
const newses = computed<NewsItem[]>(() => [
{ id: 1, content: $t('page.home.projectNews.desc1'), time: '2021-05-28 22:22:22' },
{ id: 2, content: $t('page.home.projectNews.desc2'), time: '2021-10-27 10:24:54' },
{ id: 3, content: $t('page.home.projectNews.desc3'), time: '2021-10-31 22:43:12' },
{ id: 4, content: $t('page.home.projectNews.desc4'), time: '2021-11-03 20:33:31' },
{ id: 5, content: $t('page.home.projectNews.desc5'), time: '2021-11-07 22:45:32' },
])
</script>
<template>
<NCard :title="$t('page.home.projectNews.title')" :bordered="false" size="small" segmented class="card-wrapper">
<template #header-extra>
<a class="text-primary" href="javascript:;">{{ $t('page.home.projectNews.moreNews') }}</a>
</template>
<NList>
<NListItem v-for="item in newses" :key="item.id">
<template #prefix>
<ReaderStarAvatar class="size-48px!" />
</template>
<NThing :title="item.content" :description="item.time" />
</NListItem>
</NList>
</NCard>
</template>
<style scoped></style>

View File

@ -18,6 +18,7 @@ export default readStarConfig(
'vue/component-name-in-template-casing': ['warn', 'PascalCase', { registeredComponentsOnly: false, ignores: ['/^icon-/'] }], 'vue/component-name-in-template-casing': ['warn', 'PascalCase', { registeredComponentsOnly: false, ignores: ['/^icon-/'] }],
'unocss/order-attributify': 'off', 'unocss/order-attributify': 'off',
'regexp/no-unused-capturing-group': 'off', 'regexp/no-unused-capturing-group': 'off',
'unused-imports/no-unused-vars': 'error',
}, },
}, },
) )

View File

@ -20,7 +20,7 @@
"typescript": "latest" "typescript": "latest"
}, },
"simple-git-hooks": { "simple-git-hooks": {
"pre-commit": "pnpm lint-staged" "pre-commit": "pnpm lint-staged --no-stash"
}, },
"lint-staged": { "lint-staged": {
"*": "eslint --fix" "*": "eslint --fix"

View File

@ -6,7 +6,9 @@
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {
"*": ["./src/*"] "*": [
"./src/*"
]
} }
}, },
"dependencies": { "dependencies": {
@ -14,7 +16,8 @@
"crypto-js": "4.2.0", "crypto-js": "4.2.0",
"klona": "2.0.6", "klona": "2.0.6",
"localforage": "1.10.0", "localforage": "1.10.0",
"nanoid": "5.1.6" "nanoid": "5.1.6",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "4.2.2" "@types/crypto-js": "4.2.2"

View File

@ -0,0 +1,40 @@
import * as XLSX from 'xlsx'
/**
* 导出 Excel 文件
* @param data 数据数组
* @param filename 文件名
* @param sheetName 工作表名称
*/
export function exportToExcel<T = any>(data: T[], filename: string, sheetName: string = 'Sheet1') {
const ws = XLSX.utils.json_to_sheet(data) // 转换为工作表
const wb = XLSX.utils.book_new() // 创建新工作簿
XLSX.utils.book_append_sheet(wb, ws, sheetName) // 将工作表添加到工作簿
XLSX.writeFile(wb, `${filename}.xlsx`) // 写入文件
}
/**
* 读取 Excel 文件
* @param file 文件对象
* @returns Promise<T[]> 解析后的数据数组
*/
export function readExcel<T = any>(file: File): Promise<T[]> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = e.target?.result
const workbook = XLSX.read(data, { type: 'binary' })
const sheetName = workbook.SheetNames[0]
const sheet = workbook.Sheets[sheetName]
const json = XLSX.utils.sheet_to_json<T>(sheet)
resolve(json)
}
catch (error) {
reject(error)
}
}
reader.onerror = error => reject(error)
reader.readAsArrayBuffer(file)
})
}

View File

@ -1,4 +1,5 @@
export * from './crypto' export * from './crypto'
export * from './excel'
export * from './klona' export * from './klona'
export * from './nanoid' export * from './nanoid'
export * from './storage' export * from './storage'

75
pnpm-lock.yaml generated
View File

@ -92,6 +92,9 @@ importers:
vue-router: vue-router:
specifier: 4.6.4 specifier: 4.6.4
version: 4.6.4(vue@3.5.26(typescript@5.9.3)) version: 4.6.4(vue@3.5.26(typescript@5.9.3))
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies: devDependencies:
'@elegant-router/vue': '@elegant-router/vue':
specifier: 0.3.8 specifier: 0.3.8
@ -468,6 +471,9 @@ importers:
nanoid: nanoid:
specifier: 5.1.6 specifier: 5.1.6
version: 5.1.6 version: 5.1.6
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies: devDependencies:
'@types/crypto-js': '@types/crypto-js':
specifier: 4.2.2 specifier: 4.2.2
@ -1801,6 +1807,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
ajv@6.12.6: ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@ -2025,6 +2035,10 @@ packages:
ccount@2.0.1: ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chalk@1.1.3: chalk@1.1.3:
resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2089,6 +2103,10 @@ packages:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
collection-visit@1.0.0: collection-visit@1.0.0:
resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==} resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2163,6 +2181,11 @@ packages:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -2836,6 +2859,10 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'} engines: {node: '>=0.4.x'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fragment-cache@0.2.1: fragment-cache@0.2.1:
resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==} resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -4379,6 +4406,10 @@ packages:
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stable-hash-x@0.2.0: stable-hash-x@0.2.0:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -4939,10 +4970,18 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -4959,6 +4998,11 @@ packages:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'} engines: {node: '>=18'}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml-name-validator@4.0.0: xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -6396,6 +6440,8 @@ snapshots:
acorn@8.15.0: {} acorn@8.15.0: {}
adler-32@1.3.1: {}
ajv@6.12.6: ajv@6.12.6:
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
@ -6637,6 +6683,11 @@ snapshots:
ccount@2.0.1: {} ccount@2.0.1: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chalk@1.1.3: chalk@1.1.3:
dependencies: dependencies:
ansi-styles: 2.2.1 ansi-styles: 2.2.1
@ -6718,6 +6769,8 @@ snapshots:
clone@2.1.2: {} clone@2.1.2: {}
codepage@1.15.0: {}
collection-visit@1.0.0: collection-visit@1.0.0:
dependencies: dependencies:
map-visit: 1.0.0 map-visit: 1.0.0
@ -6774,6 +6827,8 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
vary: 1.1.2 vary: 1.1.2
crc-32@1.2.2: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -7620,6 +7675,8 @@ snapshots:
format@0.2.2: {} format@0.2.2: {}
frac@1.1.2: {}
fragment-cache@0.2.1: fragment-cache@0.2.1:
dependencies: dependencies:
map-cache: 0.2.2 map-cache: 0.2.2
@ -9373,6 +9430,10 @@ snapshots:
dependencies: dependencies:
extend-shallow: 3.0.2 extend-shallow: 3.0.2
ssf@0.11.2:
dependencies:
frac: 1.1.2
stable-hash-x@0.2.0: {} stable-hash-x@0.2.0: {}
stable@0.1.8: {} stable@0.1.8: {}
@ -10044,8 +10105,12 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
wmf@1.0.2: {}
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
word@0.3.0: {}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@ -10068,6 +10133,16 @@ snapshots:
dependencies: dependencies:
is-wsl: 3.1.0 is-wsl: 3.1.0
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml-name-validator@4.0.0: {} xml-name-validator@4.0.0: {}
y18n@5.0.8: {} y18n@5.0.8: {}