diff --git a/apps/admin/package.json b/apps/admin/package.json
index 18b5b05..3870bfd 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -44,7 +44,8 @@
"vue": "3.5.26",
"vue-draggable-plus": "0.6.0",
"vue-i18n": "11.2.7",
- "vue-router": "4.6.4"
+ "vue-router": "4.6.4",
+ "xlsx": "^0.18.5"
},
"devDependencies": {
"@elegant-router/vue": "0.3.8",
diff --git a/apps/admin/src/locales/langs/en-us.ts b/apps/admin/src/locales/langs/en-us.ts
index f790b81..e5f5c44 100644
--- a/apps/admin/src/locales/langs/en-us.ts
+++ b/apps/admin/src/locales/langs/en-us.ts
@@ -230,6 +230,7 @@ const local: App.I18n.Schema = {
'iframe-page': 'Iframe',
'home': 'Home',
'competition': 'Competition',
+ 'question': 'Question',
'competition_competition-add': 'Competition Add',
'competition_competition-detail': 'Competition Detail',
'competition_competition-list': 'Competition List',
diff --git a/apps/admin/src/locales/langs/zh-cn.ts b/apps/admin/src/locales/langs/zh-cn.ts
index 516f495..6b6a6e1 100644
--- a/apps/admin/src/locales/langs/zh-cn.ts
+++ b/apps/admin/src/locales/langs/zh-cn.ts
@@ -226,6 +226,7 @@ const local: App.I18n.Schema = {
'iframe-page': '外链页面',
'home': '首页',
'competition': '比赛',
+ 'question': '题库',
'competition_competition-add': '比赛添加',
'competition_competition-detail': '比赛详情',
'competition_competition-list': '比赛列表',
diff --git a/apps/admin/src/router/elegant/imports.ts b/apps/admin/src/router/elegant/imports.ts
index 541cd60..32dd131 100644
--- a/apps/admin/src/router/elegant/imports.ts
+++ b/apps/admin/src/router/elegant/imports.ts
@@ -24,4 +24,5 @@ export const views: Record Promise import("@/views/competition/competition-detail/index.vue"),
"competition_competition-list": () => import("@/views/competition/competition-list/index.vue"),
home: () => import("@/views/home/index.vue"),
+ question: () => import("@/views/question/index.vue"),
};
diff --git a/apps/admin/src/router/elegant/routes.ts b/apps/admin/src/router/elegant/routes.ts
index 8078c46..ccbd75b 100644
--- a/apps/admin/src/router/elegant/routes.ts
+++ b/apps/admin/src/router/elegant/routes.ts
@@ -116,5 +116,14 @@ export const generatedRoutes: GeneratedRoute[] = [
constant: true,
hideInMenu: true
}
+ },
+ {
+ name: 'question',
+ path: '/question',
+ component: 'layout.base$view.question',
+ meta: {
+ title: 'question',
+ i18nKey: 'route.question'
+ }
}
];
diff --git a/apps/admin/src/router/elegant/transform.ts b/apps/admin/src/router/elegant/transform.ts
index 84d1462..e8d1396 100644
--- a/apps/admin/src/router/elegant/transform.ts
+++ b/apps/admin/src/router/elegant/transform.ts
@@ -172,7 +172,8 @@ const routeMap: RouteMap = {
"competition_competition-list": "/competition/competition-list",
"home": "/home",
"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"
};
/**
diff --git a/apps/admin/src/typings/components.d.ts b/apps/admin/src/typings/components.d.ts
index 172971a..98d8f00 100644
--- a/apps/admin/src/typings/components.d.ts
+++ b/apps/admin/src/typings/components.d.ts
@@ -15,7 +15,6 @@ declare module 'vue' {
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.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']
DarkModeContainer: typeof import('./../components/common/dark-mode-container.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']
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['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']
+ IconIcBaselineCheck: typeof import('~icons/ic/baseline-check')['default']
IconIcBaselineClose: typeof import('~icons/ic/baseline-close')['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']
+ IconIcBaselineDownload: typeof import('~icons/ic/baseline-download')['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']
+ IconIcBaselineRefresh: typeof import('~icons/ic/baseline-refresh')['default']
IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['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']
+ 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']
+ 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']
+ IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['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']
- IconLocalReadLogo: typeof import('~icons/local/read-logo')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
@@ -70,7 +80,6 @@ declare module 'vue' {
NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
- NIcon: typeof import('naive-ui')['NIcon']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputNumber: typeof import('naive-ui')['NInputNumber']
@@ -78,7 +87,6 @@ declare module 'vue' {
NListItem: typeof import('naive-ui')['NListItem']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
NMenu: typeof import('naive-ui')['NMenu']
- NMessage: typeof import('naive-ui')['NMessage']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
@@ -89,7 +97,6 @@ declare module 'vue' {
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
NTabs: typeof import('naive-ui')['NTabs']
- NTag: typeof import('naive-ui')['NTag']
NThing: typeof import('naive-ui')['NThing']
NTooltip: typeof import('naive-ui')['NTooltip']
NWatermark: typeof import('naive-ui')['NWatermark']
@@ -113,7 +120,6 @@ declare global {
const AppProvider: typeof import('./../components/common/app-provider.vue')['default']
const BetterScroll: typeof import('./../components/custom/better-scroll.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 DarkModeContainer: typeof import('./../components/common/dark-mode-container.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 IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['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 IconIcBaselineCheck: typeof import('~icons/ic/baseline-check')['default']
const IconIcBaselineClose: typeof import('~icons/ic/baseline-close')['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 IconIcBaselineDownload: typeof import('~icons/ic/baseline-download')['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 IconIcBaselineRefresh: typeof import('~icons/ic/baseline-refresh')['default']
const IconIcBaselineRocketLaunch: typeof import('~icons/ic/baseline-rocket-launch')['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 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 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 IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['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 IconLocalReadLogo: typeof import('~icons/local/read-logo')['default']
const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
const IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
@@ -168,7 +185,6 @@ declare global {
const NFormItem: typeof import('naive-ui')['NFormItem']
const NGi: typeof import('naive-ui')['NGi']
const NGrid: typeof import('naive-ui')['NGrid']
- const NIcon: typeof import('naive-ui')['NIcon']
const NInput: typeof import('naive-ui')['NInput']
const NInputGroup: typeof import('naive-ui')['NInputGroup']
const NInputNumber: typeof import('naive-ui')['NInputNumber']
@@ -176,7 +192,6 @@ declare global {
const NListItem: typeof import('naive-ui')['NListItem']
const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
const NMenu: typeof import('naive-ui')['NMenu']
- const NMessage: typeof import('naive-ui')['NMessage']
const NMessageProvider: typeof import('naive-ui')['NMessageProvider']
const NModal: typeof import('naive-ui')['NModal']
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
@@ -187,7 +202,6 @@ declare global {
const NSwitch: typeof import('naive-ui')['NSwitch']
const NTab: typeof import('naive-ui')['NTab']
const NTabs: typeof import('naive-ui')['NTabs']
- const NTag: typeof import('naive-ui')['NTag']
const NThing: typeof import('naive-ui')['NThing']
const NTooltip: typeof import('naive-ui')['NTooltip']
const NWatermark: typeof import('naive-ui')['NWatermark']
diff --git a/apps/admin/src/typings/elegant-router.d.ts b/apps/admin/src/typings/elegant-router.d.ts
index 9b33d6d..090fbbb 100644
--- a/apps/admin/src/typings/elegant-router.d.ts
+++ b/apps/admin/src/typings/elegant-router.d.ts
@@ -27,6 +27,7 @@ declare module "@elegant-router/types" {
"home": "/home";
"iframe-page": "/iframe-page/:url";
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
+ "question": "/question";
};
/**
@@ -65,6 +66,7 @@ declare module "@elegant-router/types" {
| "home"
| "iframe-page"
| "login"
+ | "question"
>;
/**
@@ -90,6 +92,7 @@ declare module "@elegant-router/types" {
| "competition_competition-detail"
| "competition_competition-list"
| "home"
+ | "question"
>;
/**
diff --git a/apps/admin/src/views/competition/competition-add/index.vue b/apps/admin/src/views/competition/competition-add/index.vue
index 5ee98e2..2c3e022 100644
--- a/apps/admin/src/views/competition/competition-add/index.vue
+++ b/apps/admin/src/views/competition/competition-add/index.vue
@@ -31,27 +31,17 @@ const competitionData = ref({
groupCount: 10, // 组别数量
teamCount: 4, // 每组队伍数量
poster: '', // 竞赛海报
- questions: { // 赛题包配置
- regular: [// 主赛环节
- { value: 'hanzi', score: 1, time: 15 },
- { value: 'tongyin', score: 1, time: 15 },
- { value: 'pianpang', score: 1, time: 30 },
- { value: 'ciyu', score: 1, time: 30 },
- { value: 'chengyu', score: 1, time: 30 },
- { value: 'fanyi', score: 1, time: 60 },
- { value: 'jinyi', score: 1, time: 30 },
- { value: 'kantu', score: 1, time: 30 },
- { value: 'gushi', score: 1, time: 60 },
- { value: 'mingju', score: 1, time: 60 },
- ],
- pk: [ // 加时赛环节
- { value: 'speed10', score: 1, time: 60 },
- { value: 'speed10', score: 1, time: 60 },
- { value: 'speed10', score: 1, time: 60 },
- { value: 'speed10', score: 1, time: 60 },
- { value: 'speed10', score: 3, time: 60 },
- ],
- },
+ rounds: [ // 赛题包配置
+ {
+ id: 'round_default',
+ name: '第一轮:主赛环节',
+ questions: Array.from({ length: 10 }).map(() => ({
+ value: null,
+ score: 5,
+ time: 30,
+ })),
+ },
+ ],
// 名单录入
groupManagement: [], // 组别管理
// 绑定硬件
@@ -88,6 +78,20 @@ function nextStep() {
currentStep.value++
}
else {
+ // 汇总整理数据
+
+ const { name, startTime, endTime, groupCount, teamCount, poster, rounds } = competitionData.value
+ // eslint-disable-next-line unused-imports/no-unused-vars
+ const params = {
+ name,
+ startTime,
+ endTime,
+ groupCount,
+ teamCount,
+ poster,
+ rounds,
+ }
+
// 提交逻辑
window.$message?.success('比赛发布成功')
emit('success')
@@ -100,12 +104,28 @@ function prevStep() {
currentStep.value -= 1
}
}
+
+function handleReset() {
+ window.$dialog?.warning({
+ title: '重置确认',
+ content: '确定要重置当前步骤的数据吗?此操作无法撤销。',
+ positiveText: '确定重置',
+ negativeText: '取消',
+ onPositiveClick: () => {
+ const componentInstance = stepComponentRef.value
+ if (componentInstance && typeof componentInstance.reset === 'function') {
+ componentInstance.reset()
+ window.$message?.success('重置成功')
+ }
+ },
+ })
+}
-
+
@@ -132,49 +152,58 @@ function prevStep() {
-
- competitionData = val"
- />
+
+
+
-
-
-
- Configuration Progress
-
-
- Step {{ currentStep }} of 4
-
-
-
-
+
+
+
+
+
上一步
+
- {{ currentStep === 4 ? '完成并发布' : '下一步' }}
+ {{ currentStep === 4 ? '确认发布比赛' : '下一步' }}
+
+
+
+
diff --git a/apps/admin/src/views/competition/competition-add/modules/BasicInfo.vue b/apps/admin/src/views/competition/competition-add/modules/BasicInfo.vue
index 06a40d0..8f3d97d 100644
--- a/apps/admin/src/views/competition/competition-add/modules/BasicInfo.vue
+++ b/apps/admin/src/views/competition/competition-add/modules/BasicInfo.vue
@@ -1,7 +1,7 @@
diff --git a/apps/admin/src/views/competition/competition-add/modules/GroupManagement.vue b/apps/admin/src/views/competition/competition-add/modules/GroupManagement.vue
index dce753c..c202bcc 100644
--- a/apps/admin/src/views/competition/competition-add/modules/GroupManagement.vue
+++ b/apps/admin/src/views/competition/competition-add/modules/GroupManagement.vue
@@ -1,6 +1,6 @@
@@ -91,18 +28,30 @@ defineExpose({ validate })
系统已根据您的设置生成了 {{ groups.length }} 个分组。您可以手动填写或批量导入。
-
-
-
-
- Excel 导入名单
-
+
+
+
+
+
+ 下载导入模板
+
+
+
+
+
+ Excel 导入名单
+
+
+
+
-
+
{{ group.name }}
@@ -110,13 +59,12 @@ defineExpose({ validate })
+
TEAM {{ tIndex + 1 }}
+
+
-
+
diff --git a/apps/admin/src/views/competition/competition-add/modules/HardwareBinding.vue b/apps/admin/src/views/competition/competition-add/modules/HardwareBinding.vue
index 0ca6477..681e786 100644
--- a/apps/admin/src/views/competition/competition-add/modules/HardwareBinding.vue
+++ b/apps/admin/src/views/competition/competition-add/modules/HardwareBinding.vue
@@ -1,6 +1,6 @@
-
-
- 点阵笔硬件绑定
-
-
- 请将点阵笔序列号 (SN) 绑定至对应队伍。建议使用扫码枪快速录入。
-
+
+
+
+ 点阵笔硬件绑定
+
+
+ 请将点阵笔序列号 (SN) 绑定至对应队伍。建议使用扫码枪快速录入。
+
+
+
+
+
+
+
+ 下载模板
+
+
+
+
+
+
+ 导入配置
+
+
+
+
+
+
+ 快速顺序生成
+
+
+
+
+
+ 快速随机生成
+
+
@@ -85,7 +73,7 @@ defineExpose({ validate })
-
+
硬件绑定: 第 {{ idx + 1 }} 分组
@@ -102,7 +90,8 @@ defineExpose({ validate })
/>
-
+
+
diff --git a/apps/admin/src/views/competition/competition-add/modules/QuestionConfig.vue b/apps/admin/src/views/competition/competition-add/modules/QuestionConfig.vue
index 191b9a4..f7c08d5 100644
--- a/apps/admin/src/views/competition/competition-add/modules/QuestionConfig.vue
+++ b/apps/admin/src/views/competition/competition-add/modules/QuestionConfig.vue
@@ -1,235 +1,249 @@
-
-
- 比赛赛题包配置
+
+
+
+
+
+ 赛程题包配置
+
+
+
您可以为比赛添加多个阶段的题包,并自定义题目、分值与限时。
+
+
+ 总题数
+ {{ totalStats.count }}
+
+
+
+ 总分
+ {{ totalStats.score }}
+
+
+
+ 总时长
+ {{ totalStats.time }}s
+
+
+
+
+
+
+
+
+ 新增赛程题包
+
-
-
-
-
-
-
-
-
主赛环节
+
+
+
+
+
+
+
+ 暂无配置题包
+
+
+ 请点击右上角按钮开始添加比赛环节
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ round.questions.length }} 题
+
+
+
+ {{ getRoundScore(roundIndex) }} 分
+
+
+
+ {{ getRoundTime(roundIndex) }} 秒
+
+
+
-
- 共 {{ props.modelValue?.questions?.regular?.length || 0 }} 题 / 总计 {{ regularTotalScore }} 分
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
第{{ idx + 1 }}题
+
{{ String(qIdx + 1).padStart(2, '0') }}
-
-
+
+
+
+
-
-
+
+
+ PTS
+
+
-
-
-
答题时间:
+
+
+
+ S
+
+
+
+
+
-
+
+
-
-
-
-
-
-
- 加时PK环节
-
-
- 共 {{ props.modelValue?.questions?.pk?.length || 0 }} 题 / 总计 {{ pkTotalScore }} 分
-
+
+
+
+
+
+
-
-
-
-
PK{{ idx + 1 }}
-
-
-
-
-
-
-
-
-
- 答题时间:
-
-
+
+
+ 新建环节题包
+
+
+ 请输入环节名称与题目预设数量
-
-
+
+
+
+
+
+
+
+
+
+
+
+ 生成题包卡片
+
+
+
diff --git a/apps/admin/src/views/competition/competition-add/modules/useBasicInfo.ts b/apps/admin/src/views/competition/competition-add/modules/useBasicInfo.ts
new file mode 100644
index 0000000..c234989
--- /dev/null
+++ b/apps/admin/src/views/competition/competition-add/modules/useBasicInfo.ts
@@ -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
({ ...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,
+ }
+}
diff --git a/apps/admin/src/views/competition/competition-add/modules/useGroupManagement.ts b/apps/admin/src/views/competition/competition-add/modules/useGroupManagement.ts
new file mode 100644
index 0000000..8bd9235
--- /dev/null
+++ b/apps/admin/src/views/competition/competition-add/modules/useGroupManagement.ts
@@ -0,0 +1,287 @@
+import { exportToExcel, readExcel } from '@sa/utils'
+import { ref, watch } from 'vue'
+
+export function useGroupManagement(props: any) {
+ const groups = ref }>>([])
+
+ // 根据分组数量和队伍数量生成分组数据
+ 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()
+ 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(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()
+ const processedTeamNames = new Set()
+ 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,
+ }
+}
diff --git a/apps/admin/src/views/competition/competition-add/modules/useHardwareBinding.ts b/apps/admin/src/views/competition/competition-add/modules/useHardwareBinding.ts
new file mode 100644
index 0000000..c69b98c
--- /dev/null
+++ b/apps/admin/src/views/competition/competition-add/modules/useHardwareBinding.ts
@@ -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([])
+
+ // 生成硬件分组
+ 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()
+ 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()
+ 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,
+ }
+}
diff --git a/apps/admin/src/views/competition/competition-add/modules/useQuestionConfig.ts b/apps/admin/src/views/competition/competition-add/modules/useQuestionConfig.ts
new file mode 100644
index 0000000..00723c8
--- /dev/null
+++ b/apps/admin/src/views/competition/competition-add/modules/useQuestionConfig.ts
@@ -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,
+ }
+}
diff --git a/apps/admin/src/views/competition/competition-list/index.vue b/apps/admin/src/views/competition/competition-list/index.vue
index f8f81b8..2689bed 100644
--- a/apps/admin/src/views/competition/competition-list/index.vue
+++ b/apps/admin/src/views/competition/competition-list/index.vue
@@ -6,10 +6,11 @@ import CompetitionAddModal from '../competition-add/index.vue'
import CompetitionCard from './modules/competition-card.vue'
import { type CompetitionItem, competitionList } from './modules/data'
-// 引入 store 用于判断移动端布局 (与你提供的风格保持一致)
const appStore = useAppStore()
const gap = computed(() => (appStore.isMobile ? 12 : 26))
+const competitionListRef = ref(competitionList)
+
const showAddModal = ref(false)
function handleAdd() {
@@ -22,8 +23,43 @@ function handleCopy(item: CompetitionItem) {
}
function handleDelete(item: CompetitionItem) {
- // TODO: 实现删除逻辑
- window.$message?.success(`已删除活动:${item.title}`)
+ const d = window.$dialog?.warning({
+ title: '确认删除',
+ content: `确定删除活动:${item.title}吗?`,
+ positiveText: '确认',
+ negativeText: '取消',
+ onPositiveClick: async () => {
+ if (d) {
+ d.loading = true
+ }
+ // 模拟接口请求延迟 2秒
+ await new Promise(resolve => setTimeout(resolve, 2000))
+
+ try {
+ // 模拟 50% 概率失败
+ if (Math.random() < 0.5) {
+ throw new Error('模拟服务器异常,删除失败')
+ }
+
+ // 成功逻辑
+ const index = competitionListRef.value.findIndex(i => i.id === item.id)
+ if (index > -1) {
+ competitionListRef.value.splice(index, 1)
+ window.$message?.success(`已删除活动:${item.title}`)
+ }
+ return true // 返回 true 关闭弹窗
+ }
+ catch (error: any) {
+ window.$message?.error(error.message)
+ return false // 返回 false 阻止关闭弹窗
+ }
+ finally {
+ if (d) {
+ d.loading = false
+ }
+ }
+ },
+ })
}
@@ -40,9 +76,9 @@ function handleDelete(item: CompetitionItem) {
-
+
-
+
新增比赛活动
@@ -51,7 +87,7 @@ function handleDelete(item: CompetitionItem) {
-
+
diff --git a/apps/admin/src/views/competition/competition-list/modules/competition-card.vue b/apps/admin/src/views/competition/competition-list/modules/competition-card.vue
index f02c0b1..8f3ef56 100644
--- a/apps/admin/src/views/competition/competition-list/modules/competition-card.vue
+++ b/apps/admin/src/views/competition/competition-list/modules/competition-card.vue
@@ -80,10 +80,7 @@ const decorationBgClass = computed(() => {
@@ -97,12 +94,8 @@ const decorationBgClass = computed(() => {
@@ -132,7 +125,7 @@ const decorationBgClass = computed(() => {
- {{ item.date }}
+ {{ item.date }} ~ {{ item.endDate }}
diff --git a/apps/admin/src/views/competition/competition-list/modules/data.ts b/apps/admin/src/views/competition/competition-list/modules/data.ts
index 030c142..20cd2d4 100644
--- a/apps/admin/src/views/competition/competition-list/modules/data.ts
+++ b/apps/admin/src/views/competition/competition-list/modules/data.ts
@@ -4,6 +4,7 @@ export interface CompetitionItem {
id: string
title: string
date: string
+ endDate: string
teamCount: number
status: 'ongoing' | 'pending' | 'ended' // ongoing: 进行中, pending: 未开始, ended: 已结束
isHighlight?: boolean // 用于标记第一个卡片的特殊背景
@@ -14,6 +15,7 @@ export const competitionList: CompetitionItem[] = [
id: '1',
title: '阅读之星大赛\n辞海遨游环节', // 使用换行符或在组件中处理换行
date: '2026.01.12',
+ endDate: '2026.01.12',
teamCount: 40,
status: 'ongoing',
isHighlight: true,
@@ -22,6 +24,7 @@ export const competitionList: CompetitionItem[] = [
id: '2',
title: '诗词大会总决赛',
date: '2026.01.12',
+ endDate: '2026.01.12',
teamCount: 32,
status: 'pending',
},
@@ -29,6 +32,7 @@ export const competitionList: CompetitionItem[] = [
id: '3',
title: '趣味百科大作战',
date: '2026.01.12',
+ endDate: '2026.01.12',
teamCount: 32,
status: 'ended',
},
@@ -36,6 +40,7 @@ export const competitionList: CompetitionItem[] = [
id: '4',
title: '经典文学研读周',
date: '2026.01.12',
+ endDate: '2026.01.12',
teamCount: 32,
status: 'pending',
},
@@ -43,6 +48,7 @@ export const competitionList: CompetitionItem[] = [
id: '5',
title: '未来科学家阅读赛',
date: '2026.01.12',
+ endDate: '2026.01.12',
teamCount: 32,
status: 'pending',
},
@@ -50,6 +56,7 @@ export const competitionList: CompetitionItem[] = [
id: '6',
title: '历史人文知识赛',
date: '2026.01.12',
+ endDate: '2026.01.12',
teamCount: 32,
status: 'pending',
},
@@ -57,6 +64,7 @@ export const competitionList: CompetitionItem[] = [
id: '7',
title: '少儿绘本创意营',
date: '2026.01.12',
+ endDate: '2026.01.12',
teamCount: 32,
status: 'pending',
},
@@ -64,6 +72,7 @@ export const competitionList: CompetitionItem[] = [
id: '8',
title: '唐诗三百首挑战',
date: '2026.01.12',
+ endDate: '2026.01.12',
teamCount: 32,
status: 'pending',
},
diff --git a/apps/admin/src/views/question/index.vue b/apps/admin/src/views/question/index.vue
new file mode 100644
index 0000000..d6e995f
--- /dev/null
+++ b/apps/admin/src/views/question/index.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+ {{ $t('page.home.branchDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/views/question/modules/card-data.vue b/apps/admin/src/views/question/modules/card-data.vue
new file mode 100644
index 0000000..01ed718
--- /dev/null
+++ b/apps/admin/src/views/question/modules/card-data.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/views/question/modules/creativity-banner.vue b/apps/admin/src/views/question/modules/creativity-banner.vue
new file mode 100644
index 0000000..796308a
--- /dev/null
+++ b/apps/admin/src/views/question/modules/creativity-banner.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/views/question/modules/header-banner.vue b/apps/admin/src/views/question/modules/header-banner.vue
new file mode 100644
index 0000000..ca6653a
--- /dev/null
+++ b/apps/admin/src/views/question/modules/header-banner.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+

+
+
+
+ {{ $t('page.home.greeting', { userName: authStore.userInfo.userName }) }}
+
+
+ {{ $t('page.home.weatherDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/views/question/modules/line-chart.vue b/apps/admin/src/views/question/modules/line-chart.vue
new file mode 100644
index 0000000..ba2a0ee
--- /dev/null
+++ b/apps/admin/src/views/question/modules/line-chart.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/views/question/modules/pie-chart.vue b/apps/admin/src/views/question/modules/pie-chart.vue
new file mode 100644
index 0000000..82e975c
--- /dev/null
+++ b/apps/admin/src/views/question/modules/pie-chart.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/views/question/modules/project-news.vue b/apps/admin/src/views/question/modules/project-news.vue
new file mode 100644
index 0000000..8dbb379
--- /dev/null
+++ b/apps/admin/src/views/question/modules/project-news.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+ {{ $t('page.home.projectNews.moreNews') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/eslint.config.js b/eslint.config.js
index 48c09b9..f225a14 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -18,6 +18,7 @@ export default readStarConfig(
'vue/component-name-in-template-casing': ['warn', 'PascalCase', { registeredComponentsOnly: false, ignores: ['/^icon-/'] }],
'unocss/order-attributify': 'off',
'regexp/no-unused-capturing-group': 'off',
+ 'unused-imports/no-unused-vars': 'error',
},
},
)
diff --git a/package.json b/package.json
index 4c11852..e50db69 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,7 @@
"typescript": "latest"
},
"simple-git-hooks": {
- "pre-commit": "pnpm lint-staged"
+ "pre-commit": "pnpm lint-staged --no-stash"
},
"lint-staged": {
"*": "eslint --fix"
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 7074bca..fbe8527 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -6,7 +6,9 @@
},
"typesVersions": {
"*": {
- "*": ["./src/*"]
+ "*": [
+ "./src/*"
+ ]
}
},
"dependencies": {
@@ -14,7 +16,8 @@
"crypto-js": "4.2.0",
"klona": "2.0.6",
"localforage": "1.10.0",
- "nanoid": "5.1.6"
+ "nanoid": "5.1.6",
+ "xlsx": "^0.18.5"
},
"devDependencies": {
"@types/crypto-js": "4.2.2"
diff --git a/packages/utils/src/excel.ts b/packages/utils/src/excel.ts
new file mode 100644
index 0000000..c28c01e
--- /dev/null
+++ b/packages/utils/src/excel.ts
@@ -0,0 +1,40 @@
+import * as XLSX from 'xlsx'
+
+/**
+ * 导出 Excel 文件
+ * @param data 数据数组
+ * @param filename 文件名
+ * @param sheetName 工作表名称
+ */
+export function exportToExcel
(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 解析后的数据数组
+ */
+export function readExcel(file: File): Promise {
+ 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(sheet)
+ resolve(json)
+ }
+ catch (error) {
+ reject(error)
+ }
+ }
+ reader.onerror = error => reject(error)
+ reader.readAsArrayBuffer(file)
+ })
+}
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index c85cad7..39e6e86 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -1,4 +1,5 @@
export * from './crypto'
+export * from './excel'
export * from './klona'
export * from './nanoid'
export * from './storage'
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f3aa04a..e9fb85d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -92,6 +92,9 @@ importers:
vue-router:
specifier: 4.6.4
version: 4.6.4(vue@3.5.26(typescript@5.9.3))
+ xlsx:
+ specifier: ^0.18.5
+ version: 0.18.5
devDependencies:
'@elegant-router/vue':
specifier: 0.3.8
@@ -468,6 +471,9 @@ importers:
nanoid:
specifier: 5.1.6
version: 5.1.6
+ xlsx:
+ specifier: ^0.18.5
+ version: 0.18.5
devDependencies:
'@types/crypto-js':
specifier: 4.2.2
@@ -1801,6 +1807,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ adler-32@1.3.1:
+ resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
+ engines: {node: '>=0.8'}
+
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@@ -2025,6 +2035,10 @@ packages:
ccount@2.0.1:
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:
resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
engines: {node: '>=0.10.0'}
@@ -2089,6 +2103,10 @@ packages:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
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:
resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==}
engines: {node: '>=0.10.0'}
@@ -2163,6 +2181,11 @@ packages:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
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:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2836,6 +2859,10 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
+ frac@1.1.2:
+ resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
+ engines: {node: '>=0.8'}
+
fragment-cache@0.2.1:
resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==}
engines: {node: '>=0.10.0'}
@@ -4379,6 +4406,10 @@ packages:
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
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:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'}
@@ -4939,10 +4970,18 @@ packages:
engines: {node: '>= 8'}
hasBin: true
+ wmf@1.0.2:
+ resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
+ engines: {node: '>=0.8'}
+
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
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:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -4959,6 +4998,11 @@ packages:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
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:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
@@ -6396,6 +6440,8 @@ snapshots:
acorn@8.15.0: {}
+ adler-32@1.3.1: {}
+
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -6637,6 +6683,11 @@ snapshots:
ccount@2.0.1: {}
+ cfb@1.2.2:
+ dependencies:
+ adler-32: 1.3.1
+ crc-32: 1.2.2
+
chalk@1.1.3:
dependencies:
ansi-styles: 2.2.1
@@ -6718,6 +6769,8 @@ snapshots:
clone@2.1.2: {}
+ codepage@1.15.0: {}
+
collection-visit@1.0.0:
dependencies:
map-visit: 1.0.0
@@ -6774,6 +6827,8 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
+ crc-32@1.2.2: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -7620,6 +7675,8 @@ snapshots:
format@0.2.2: {}
+ frac@1.1.2: {}
+
fragment-cache@0.2.1:
dependencies:
map-cache: 0.2.2
@@ -9373,6 +9430,10 @@ snapshots:
dependencies:
extend-shallow: 3.0.2
+ ssf@0.11.2:
+ dependencies:
+ frac: 1.1.2
+
stable-hash-x@0.2.0: {}
stable@0.1.8: {}
@@ -10044,8 +10105,12 @@ snapshots:
dependencies:
isexe: 2.0.0
+ wmf@1.0.2: {}
+
word-wrap@1.2.5: {}
+ word@0.3.0: {}
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -10068,6 +10133,16 @@ snapshots:
dependencies:
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: {}
y18n@5.0.8: {}