feat(admin): 新增题库模块并优化比赛管理功能
新增题库模块,包含题目管理页面和Excel导入导出功能 优化比赛管理功能,包括分组管理、硬件绑定和题目配置 重构比赛添加流程,增加重置功能和数据校验 添加Excel工具函数,支持读取和导出Excel文件 更新依赖,添加xlsx库支持
This commit is contained in:
@ -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",
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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': '比赛列表',
|
||||||
|
|||||||
@ -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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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"
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
46
apps/admin/src/typings/components.d.ts
vendored
46
apps/admin/src/typings/components.d.ts
vendored
@ -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']
|
||||||
|
|||||||
3
apps/admin/src/typings/elegant-router.d.ts
vendored
3
apps/admin/src/typings/elegant-router.d.ts
vendored
@ -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"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -0,0 +1,107 @@
|
|||||||
|
import type { UploadCustomRequestOptions } from 'naive-ui'
|
||||||
|
import { debounce } from '@sa/utils'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export interface BasicInfoModel {
|
||||||
|
name: string
|
||||||
|
startTime: number | null
|
||||||
|
endTime: number | null
|
||||||
|
groupCount: number
|
||||||
|
teamCount: number
|
||||||
|
poster: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBasicInfo(props: any, emit: any) {
|
||||||
|
const formData = ref<BasicInfoModel>({ ...props.modelValue })
|
||||||
|
|
||||||
|
// 修复死循环:添加值对比逻辑
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (JSON.stringify(val) !== JSON.stringify(formData.value)) {
|
||||||
|
formData.value = { ...val }
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 添加防抖:300ms 延迟,避免频繁触发父组件更新
|
||||||
|
const handleUpdate = debounce((val: typeof formData.value) => {
|
||||||
|
emit('update:modelValue', { ...val })
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
watch(formData, (val) => {
|
||||||
|
handleUpdate(val)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
function updateCount(type: 'group' | 'team', delta: number) {
|
||||||
|
if (type === 'group') {
|
||||||
|
const newVal = (formData.value.groupCount || 0) + delta
|
||||||
|
if (newVal >= 1 && newVal <= 999) {
|
||||||
|
formData.value.groupCount = newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const newVal = (formData.value.teamCount || 0) + delta
|
||||||
|
if (newVal >= 1 && newVal <= 999) {
|
||||||
|
formData.value.teamCount = newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟上传处理
|
||||||
|
function handleUpload({ file, onFinish }: UploadCustomRequestOptions) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.readAsDataURL(file.file as File)
|
||||||
|
reader.onload = () => {
|
||||||
|
// 模拟上传成功,直接使用 base64 作为图片地址
|
||||||
|
formData.value.poster = reader.result as string
|
||||||
|
onFinish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验方法
|
||||||
|
function validate() {
|
||||||
|
if (!formData.value.name) {
|
||||||
|
window.$message?.error('请输入比赛项目名称')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!formData.value.startTime) {
|
||||||
|
window.$message?.error('请选择开始时间')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!formData.value.endTime) {
|
||||||
|
window.$message?.error('请选择结束时间')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (formData.value.startTime >= formData.value.endTime) {
|
||||||
|
window.$message?.error('结束时间必须晚于开始时间')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!formData.value.groupCount || formData.value.groupCount < 1) {
|
||||||
|
window.$message?.error('参赛小组数量必须为正整数')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!formData.value.teamCount || formData.value.teamCount < 1) {
|
||||||
|
window.$message?.error('每组队伍数量必须为正整数')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置方法
|
||||||
|
function reset() {
|
||||||
|
formData.value = {
|
||||||
|
name: '阅读之星年度总决赛',
|
||||||
|
startTime: null,
|
||||||
|
endTime: null,
|
||||||
|
groupCount: 10,
|
||||||
|
teamCount: 4,
|
||||||
|
poster: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
formData,
|
||||||
|
updateCount,
|
||||||
|
handleUpload,
|
||||||
|
validate,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,287 @@
|
|||||||
|
import { exportToExcel, readExcel } from '@sa/utils'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export function useGroupManagement(props: any) {
|
||||||
|
const groups = ref<Array<{ name: string, id: string, teams: Array<{ name: string }> }>>([])
|
||||||
|
|
||||||
|
// 根据分组数量和队伍数量生成分组数据
|
||||||
|
function generateGroups() {
|
||||||
|
if (!props.config)
|
||||||
|
return
|
||||||
|
|
||||||
|
const { groupCount, teamCount } = props.config
|
||||||
|
if (!groupCount || groupCount <= 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
const oldGroups = groups.value
|
||||||
|
|
||||||
|
groups.value = Array.from({ length: groupCount }).map((_, index) => {
|
||||||
|
const i = index + 1
|
||||||
|
const idStr = String(i).padStart(2, '0')
|
||||||
|
const existingGroup = oldGroups[index]
|
||||||
|
|
||||||
|
// 复用现有名称或生成默认名称
|
||||||
|
const groupName = existingGroup ? existingGroup.name : `第${i}组`
|
||||||
|
|
||||||
|
// 生成或复用队伍数据
|
||||||
|
const teams = Array.from({ length: teamCount }).map((__, tIndex) => {
|
||||||
|
const existingTeam = existingGroup?.teams?.[tIndex]
|
||||||
|
return {
|
||||||
|
name: existingTeam ? existingTeam.name : '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: groupName,
|
||||||
|
id: `G${idStr}`,
|
||||||
|
teams,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.config, generateGroups, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
// 提交时候校验表单是否符合要求
|
||||||
|
function validate() {
|
||||||
|
// 简单校验:是否有分组
|
||||||
|
if (groups.value.length === 0) {
|
||||||
|
window.$message?.error('暂无参赛分组,请检查基础设置')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验每个分组是否有队伍
|
||||||
|
for (const group of groups.value) {
|
||||||
|
if (group.teams.length === 0) {
|
||||||
|
window.$message?.error(`分组 ${group.name} 暂无队伍,请检查`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验每个分组队伍是否有名称
|
||||||
|
for (const group of groups.value) {
|
||||||
|
for (const team of group.teams) {
|
||||||
|
if (!team.name.trim()) {
|
||||||
|
window.$message?.error(`分组 ${group.name} 队伍 ${team.name} 暂无名称,请检查`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验分组队伍名称是否重复,并找出是第几组重复
|
||||||
|
const nameMap = new Map<string, string[]>()
|
||||||
|
let hasDuplicate = false
|
||||||
|
|
||||||
|
groups.value.forEach((group, groupIndex) => {
|
||||||
|
group.teams.forEach((team, teamIndex) => {
|
||||||
|
const name = team.name.trim()
|
||||||
|
if (!name)
|
||||||
|
return // 跳过空名字,前面已经校验过空了
|
||||||
|
|
||||||
|
if (nameMap.has(name)) {
|
||||||
|
nameMap.get(name)?.push(`第${groupIndex + 1}组-队伍${teamIndex + 1}`)
|
||||||
|
hasDuplicate = true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
nameMap.set(name, [`第${groupIndex + 1}组-队伍${teamIndex + 1}`])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasDuplicate) {
|
||||||
|
const duplicates: string[] = []
|
||||||
|
nameMap.forEach((locations, name) => {
|
||||||
|
if (locations.length > 1) {
|
||||||
|
duplicates.push(`队伍名 "${name}" 重复于: ${locations.join(', ')}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 显示前3条重复信息,避免弹窗过大
|
||||||
|
const msg = duplicates.slice(0, 3).join('; ') + (duplicates.length > 3 ? ' 等...' : '')
|
||||||
|
window.$message?.error(`发现重复队伍名称:${msg}`, { duration: 8000 })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置方法
|
||||||
|
function reset() {
|
||||||
|
// 清空现有数据,触发 generateGroups 重新生成默认数据
|
||||||
|
groups.value = []
|
||||||
|
generateGroups()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
validate,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载 Excel 模板
|
||||||
|
*/
|
||||||
|
export function useExcelExport(props: any, groups: any) {
|
||||||
|
function downloadTemplate() {
|
||||||
|
if (!props.config)
|
||||||
|
return
|
||||||
|
|
||||||
|
// 1. 构建数据行(根据当前分组预填充分组名称)
|
||||||
|
const data = groups.value.map((g: any) => {
|
||||||
|
const row: any = {
|
||||||
|
分组名称: g.name,
|
||||||
|
}
|
||||||
|
// 为队伍预留空位,或者如果已有数据也填充进去(方便用户修改)
|
||||||
|
g.teams.forEach((t: any, i: number) => {
|
||||||
|
row[`队伍${i + 1}名称`] = t.name
|
||||||
|
})
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
|
||||||
|
exportToExcel(data, '参赛名单导入模板', '参赛名单')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
downloadTemplate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExcelImport(props: any, groups: any) {
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发文件选择
|
||||||
|
*/
|
||||||
|
function handleImportClick() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文件上传与解析
|
||||||
|
*/
|
||||||
|
async function handleFileChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
if (!file)
|
||||||
|
return
|
||||||
|
|
||||||
|
// 重置 input value,允许重复选择同一文件
|
||||||
|
target.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonData = await readExcel(file)
|
||||||
|
|
||||||
|
// 校验表头:检查第一行数据是否包含预期的列名
|
||||||
|
// 预期列名:分组名称, 队伍1名称, 队伍2名称...
|
||||||
|
if (jsonData.length === 0) {
|
||||||
|
window.$message?.warning('Excel 文件内容为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRow = jsonData[0] as any
|
||||||
|
const expectedKeys = ['分组名称']
|
||||||
|
if (props.config?.teamCount) {
|
||||||
|
for (let i = 1; i <= props.config.teamCount; i++) {
|
||||||
|
expectedKeys.push(`队伍${i}名称`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含所有必要的列
|
||||||
|
// 这里我们检查第一行是否有这些 key
|
||||||
|
const hasValidHeader = expectedKeys.every(key => Object.prototype.hasOwnProperty.call(firstRow, key))
|
||||||
|
|
||||||
|
if (!hasValidHeader) {
|
||||||
|
window.$message?.error('Excel 模板格式错误,请下载最新模板并重新填写')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = props.config?.groupCount || 0
|
||||||
|
let validData = jsonData
|
||||||
|
|
||||||
|
// 校验条数上限
|
||||||
|
if (validData.length > limit) {
|
||||||
|
window.$message?.warning(`导入数据共 ${validData.length} 条,已截取前 ${limit} 条,超出部分将被忽略`, { duration: 4000 })
|
||||||
|
validData = validData.slice(0, limit)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
window.$message?.success(`成功解析 ${validData.length} 条数据`, { duration: 3000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写校验逻辑,清晰化
|
||||||
|
const finalData: any[] = []
|
||||||
|
const processedGroupNames = new Set<string>()
|
||||||
|
const processedTeamNames = new Set<string>()
|
||||||
|
const duplicates: string[] = []
|
||||||
|
|
||||||
|
validData.forEach((row: any, index) => {
|
||||||
|
const newRow = { ...row }
|
||||||
|
|
||||||
|
// 1. 校验分组名称
|
||||||
|
const groupName = row['分组名称'] ? String(row['分组名称']).trim() : ''
|
||||||
|
if (groupName) {
|
||||||
|
if (processedGroupNames.has(groupName)) {
|
||||||
|
duplicates.push(`第 ${index + 2} 行分组 "${groupName}" 重复,已跳过该行`)
|
||||||
|
return // 分组名重复,跳过整行
|
||||||
|
}
|
||||||
|
processedGroupNames.add(groupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 校验队伍名称
|
||||||
|
if (props.config?.teamCount) {
|
||||||
|
for (let i = 1; i <= props.config.teamCount; i++) {
|
||||||
|
const key = `队伍${i}名称`
|
||||||
|
const teamName = row[key] ? String(row[key]).trim() : ''
|
||||||
|
|
||||||
|
if (teamName) {
|
||||||
|
if (processedTeamNames.has(teamName)) {
|
||||||
|
duplicates.push(`第 ${index + 2} 行队伍 "${teamName}" 重复,已清空该项`)
|
||||||
|
newRow[key] = '' // 剔除重复项
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
processedTeamNames.add(teamName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalData.push(newRow)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
// 提示重复信息,限制显示条数避免弹窗过大
|
||||||
|
const msg = `发现重复数据:\n${duplicates.slice(0, 5).join('\n')}${duplicates.length > 5 ? '\n...' : ''}`
|
||||||
|
window.$message?.warning(msg, { duration: 5000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充数据
|
||||||
|
finalData.forEach((row: any, index) => {
|
||||||
|
if (index >= groups.value.length)
|
||||||
|
return
|
||||||
|
|
||||||
|
const group = groups.value[index]
|
||||||
|
|
||||||
|
// 分组名称
|
||||||
|
if (row['分组名称']) {
|
||||||
|
group.name = String(row['分组名称'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 队伍名称
|
||||||
|
group.teams.forEach((team: any, tIndex: number) => {
|
||||||
|
const key = `队伍${tIndex + 1}名称`
|
||||||
|
if (row[key]) {
|
||||||
|
team.name = String(row[key])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Excel 解析失败:', error)
|
||||||
|
window.$message?.error('Excel 解析失败,请检查文件格式')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileInputRef,
|
||||||
|
handleImportClick,
|
||||||
|
handleFileChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,286 @@
|
|||||||
|
import type { UploadCustomRequestOptions } from 'naive-ui'
|
||||||
|
import { exportToExcel, readExcel } from '@sa/utils'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export interface HardwareGroup {
|
||||||
|
groupId: string
|
||||||
|
pens: Array<{ label: string, sn: string, online: boolean }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHardwareBinding(props: any) {
|
||||||
|
const hardwareGroups = ref<HardwareGroup[]>([])
|
||||||
|
|
||||||
|
// 生成硬件分组
|
||||||
|
function generateHardwareGroups() {
|
||||||
|
if (!props.config)
|
||||||
|
return
|
||||||
|
|
||||||
|
const { groupCount, teamCount } = props.config
|
||||||
|
if (!groupCount || groupCount <= 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
const totalGroups = groupCount
|
||||||
|
// 仅在初始化或配置完全改变时重新生成,避免覆盖用户已输入的数据
|
||||||
|
if (hardwareGroups.value.length === totalGroups && hardwareGroups.value[0]?.pens.length === teamCount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGroups: HardwareGroup[] = []
|
||||||
|
|
||||||
|
for (let i = 1; i <= totalGroups; i++) {
|
||||||
|
const idStr = String(i).padStart(2, '0')
|
||||||
|
const currentTeamCount = teamCount
|
||||||
|
|
||||||
|
const pens = []
|
||||||
|
for (let j = 1; j <= currentTeamCount; j++) {
|
||||||
|
pens.push({
|
||||||
|
label: `T${j}`,
|
||||||
|
sn: '',
|
||||||
|
online: true, // 模拟初始状态
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
newGroups.push({
|
||||||
|
groupId: idStr,
|
||||||
|
pens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hardwareGroups.value = newGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.config, generateHardwareGroups, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
// 校验硬件绑定是否符合要求
|
||||||
|
function validate() {
|
||||||
|
// 1. 校验每个分组是否都有队伍绑定
|
||||||
|
for (const group of hardwareGroups.value) {
|
||||||
|
if (group.pens.some(pen => !pen.sn)) {
|
||||||
|
window.$message?.error(`分组 ${group.groupId} 存在未绑定的队伍`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 校验重复 SN
|
||||||
|
const snMap = new Map<string, string[]>()
|
||||||
|
let hasDuplicate = false
|
||||||
|
|
||||||
|
hardwareGroups.value.forEach((group) => {
|
||||||
|
group.pens.forEach((pen, teamIndex) => {
|
||||||
|
const sn = pen.sn.trim()
|
||||||
|
if (!sn)
|
||||||
|
return
|
||||||
|
|
||||||
|
const location = `第${group.groupId}组-T${teamIndex + 1}`
|
||||||
|
if (snMap.has(sn)) {
|
||||||
|
snMap.get(sn)?.push(location)
|
||||||
|
hasDuplicate = true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
snMap.set(sn, [location])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasDuplicate) {
|
||||||
|
const duplicates: string[] = []
|
||||||
|
snMap.forEach((locations, sn) => {
|
||||||
|
if (locations.length > 1) {
|
||||||
|
duplicates.push(`SN "${sn}" 重复于: ${locations.join(', ')}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const msg = duplicates.slice(0, 3).join('; ') + (duplicates.length > 3 ? ' 等...' : '')
|
||||||
|
window.$message?.error(`发现重复 SN:${msg}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
// 强制重新生成
|
||||||
|
if (props.config) {
|
||||||
|
hardwareGroups.value = [] // 清空以触发重新生成
|
||||||
|
generateHardwareGroups()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hardwareGroups,
|
||||||
|
generateHardwareGroups,
|
||||||
|
validate,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHardwareExcel(props: any, hardwareGroups: any) {
|
||||||
|
// 下载模板
|
||||||
|
function handleDownloadTemplate() {
|
||||||
|
if (!props.config)
|
||||||
|
return
|
||||||
|
|
||||||
|
const { groupCount, teamCount } = props.config
|
||||||
|
const data = []
|
||||||
|
|
||||||
|
for (let i = 1; i <= groupCount; i++) {
|
||||||
|
for (let j = 1; j <= teamCount; j++) {
|
||||||
|
data.push({
|
||||||
|
分组名称: `第 ${i} 组`,
|
||||||
|
队伍编号: j,
|
||||||
|
点阵笔SN: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportToExcel(data, '硬件绑定模板', '硬件绑定')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入 Excel
|
||||||
|
async function handleUpload({ file }: UploadCustomRequestOptions) {
|
||||||
|
try {
|
||||||
|
if (!file.file)
|
||||||
|
return
|
||||||
|
const data = await readExcel(file.file)
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
window.$message?.warning('Excel 文件内容为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 识别表头映射 (兼容多种表头格式)
|
||||||
|
const firstRow = data[0] as any
|
||||||
|
const keys = Object.keys(firstRow)
|
||||||
|
|
||||||
|
const groupKey = keys.find(k => k.includes('分组') || k.includes('Group'))
|
||||||
|
const teamKey = keys.find(k => k.includes('队伍') || k.includes('Team') || k.includes('点阵笔号'))
|
||||||
|
const snKey = keys.find(k => k.includes('SN') || k.includes('SN') || k.includes('sn') || k.includes('点阵笔'))
|
||||||
|
|
||||||
|
if (!groupKey || !teamKey || !snKey) {
|
||||||
|
window.$message?.error('Excel 模板格式错误,未找到“分组”、“队伍”或“SN”相关列')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 校验导入数据中的重复项
|
||||||
|
const snMap = new Map<string, string[]>()
|
||||||
|
const importDuplicates: string[] = []
|
||||||
|
|
||||||
|
data.forEach((row: any) => {
|
||||||
|
const sn = row[snKey] ? String(row[snKey]).trim() : ''
|
||||||
|
if (!sn)
|
||||||
|
return
|
||||||
|
|
||||||
|
const groupNum = row[groupKey]
|
||||||
|
const teamNum = row[teamKey]
|
||||||
|
const location = `第${groupNum}组-T${teamNum}`
|
||||||
|
|
||||||
|
if (!snMap.has(sn)) {
|
||||||
|
snMap.set(sn, [])
|
||||||
|
}
|
||||||
|
snMap.get(sn)?.push(location)
|
||||||
|
})
|
||||||
|
|
||||||
|
snMap.forEach((locations, sn) => {
|
||||||
|
if (locations.length > 1) {
|
||||||
|
importDuplicates.push(`SN "${sn}" 重复于: ${locations.join(', ')}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (importDuplicates.length > 0) {
|
||||||
|
const msg = importDuplicates.slice(0, 3).join('; ') + (importDuplicates.length > 3 ? ' 等...' : '')
|
||||||
|
window.$message?.error(`导入文件中存在重复 SN,请检查:${msg}`, { duration: 5000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 解析数据并填充
|
||||||
|
let successCount = 0
|
||||||
|
const newGroups = [...hardwareGroups.value] // 创建浅拷贝以触发响应式更新
|
||||||
|
|
||||||
|
// 辅助函数:解析索引
|
||||||
|
const parseIndex = (val: any) => {
|
||||||
|
if (typeof val === 'number')
|
||||||
|
return val
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
const match = val.match(/\d+/)
|
||||||
|
return match ? Number(match[0]) : Number.NaN
|
||||||
|
}
|
||||||
|
return Number.NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach((row: any) => {
|
||||||
|
const groupIdx = parseIndex(row[groupKey]) - 1
|
||||||
|
const teamIdx = parseIndex(row[teamKey]) - 1
|
||||||
|
const sn = row[snKey] ? String(row[snKey]).trim() : ''
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isNaN(groupIdx) && groupIdx >= 0 && groupIdx < newGroups.length
|
||||||
|
&& newGroups[groupIdx]
|
||||||
|
&& !Number.isNaN(teamIdx) && teamIdx >= 0 && teamIdx < newGroups[groupIdx].pens.length
|
||||||
|
) {
|
||||||
|
// 直接修改对象属性
|
||||||
|
newGroups[groupIdx].pens[teamIdx].sn = `${sn}号笔`
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 强制触发更新
|
||||||
|
hardwareGroups.value = newGroups
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
window.$message?.success(`成功导入 ${successCount} 条数据`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.warn('导入数据匹配失败', data)
|
||||||
|
window.$message?.warning('未匹配到任何有效数据,请检查分组/队伍编号是否正确')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
window.$message?.error('导入失败,请检查文件格式')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleDownloadTemplate,
|
||||||
|
handleUpload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQuickGenerate(props: any, hardwareGroups: any) {
|
||||||
|
// 快速生成笔号
|
||||||
|
function quickGenerate(type: 'order' | 'random' = 'order') {
|
||||||
|
if (!props.config)
|
||||||
|
return
|
||||||
|
|
||||||
|
// 1. 收集所有笔的引用
|
||||||
|
const allPens: any[] = []
|
||||||
|
hardwareGroups.value.forEach((group: HardwareGroup) => {
|
||||||
|
group.pens.forEach((pen) => {
|
||||||
|
allPens.push(pen)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (allPens.length === 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
// 2. 生成序号数组
|
||||||
|
const numbers = Array.from({ length: allPens.length }, (_, i) => i + 1)
|
||||||
|
|
||||||
|
// 3. 如果是随机模式,打乱数组 (Fisher-Yates shuffle)
|
||||||
|
if (type === 'random') {
|
||||||
|
for (let i = numbers.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[numbers[i], numbers[j]] = [numbers[j], numbers[i]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 依次赋值
|
||||||
|
allPens.forEach((pen, index) => {
|
||||||
|
pen.sn = `${numbers[index]}号笔`
|
||||||
|
})
|
||||||
|
|
||||||
|
window.$message?.success(type === 'random' ? '已随机生成笔号' : '已顺序生成笔号')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
quickGenerate,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
export interface QuestionItem {
|
||||||
|
value: string | null
|
||||||
|
score: number
|
||||||
|
time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundModel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
questions: QuestionItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionConfigModel {
|
||||||
|
rounds: RoundModel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQuestionConfig(props: any) {
|
||||||
|
// 计算每个轮次的分数
|
||||||
|
const getRoundScore = (roundIndex: number) => {
|
||||||
|
const round = props.modelValue?.rounds?.[roundIndex]
|
||||||
|
if (!round)
|
||||||
|
return 0
|
||||||
|
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.score) || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算每个轮次的时长
|
||||||
|
const getRoundTime = (roundIndex: number) => {
|
||||||
|
const round = props.modelValue?.rounds?.[roundIndex]
|
||||||
|
if (!round)
|
||||||
|
return 0
|
||||||
|
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.time) || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 总计统计
|
||||||
|
const totalStats = computed(() => {
|
||||||
|
const rounds = props.modelValue?.rounds || []
|
||||||
|
return rounds.reduce((acc: any, round: any) => {
|
||||||
|
acc.count += round.questions.length
|
||||||
|
acc.score += round.questions.reduce((sum: number, item: any) => sum + (Number(item.score) || 0), 0)
|
||||||
|
acc.time += round.questions.reduce((sum: number, item: any) => sum + (Number(item.time) || 0), 0)
|
||||||
|
return acc
|
||||||
|
}, { count: 0, score: 0, time: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionOptions = [
|
||||||
|
{ label: '汉字听写 - 根据提示书写汉字', value: 'hanzi' },
|
||||||
|
{ label: '同音字辨析 - 根据拼音书写', value: 'tongyin' },
|
||||||
|
{ label: '偏旁部首 - 按要求写字', value: 'pianpang' },
|
||||||
|
{ label: '词语听写 - 综合练习', value: 'ciyu' },
|
||||||
|
{ label: '成语填空 - 四字成语', value: 'chengyu' },
|
||||||
|
{ label: '反义词挑战', value: 'fanyi' },
|
||||||
|
{ label: '近义词挑战', value: 'jinyi' },
|
||||||
|
{ label: '看图猜字', value: 'kantu' },
|
||||||
|
{ label: '古诗词接龙', value: 'gushi' },
|
||||||
|
{ label: '名句听写', value: 'mingju' },
|
||||||
|
{ label: '极速成语接龙 - 每题10秒', value: 'speed10' },
|
||||||
|
{ label: '极速成语接龙 - 每题20秒', value: 'speed20' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const scoreOptions = [
|
||||||
|
{ label: '1分', value: 1 },
|
||||||
|
{ label: '2分', value: 2 },
|
||||||
|
{ label: '3分', value: 3 },
|
||||||
|
{ label: '5分', value: 5 },
|
||||||
|
{ label: '10分', value: 10 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const timeOptions = [
|
||||||
|
{ label: '15s', value: 15 },
|
||||||
|
{ label: '30s', value: 30 },
|
||||||
|
{ label: '45s', value: 45 },
|
||||||
|
{ label: '60s', value: 60 },
|
||||||
|
{ label: '90s', value: 90 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 新增轮次
|
||||||
|
function addRound(customName?: string, customCount?: number) {
|
||||||
|
if (!props.modelValue.rounds) {
|
||||||
|
props.modelValue.rounds = []
|
||||||
|
}
|
||||||
|
const index = props.modelValue.rounds.length + 1
|
||||||
|
const count = typeof customCount === 'number' ? customCount : 10
|
||||||
|
const name = typeof customName === 'string' ? customName : `第${index}轮:比赛环节`
|
||||||
|
|
||||||
|
props.modelValue.rounds.push({
|
||||||
|
id: `round_${Date.now()}`,
|
||||||
|
name,
|
||||||
|
questions: Array.from({ length: count }).map(() => ({
|
||||||
|
value: null,
|
||||||
|
score: 5,
|
||||||
|
time: 30,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除轮次
|
||||||
|
function removeRound(index: number) {
|
||||||
|
props.modelValue.rounds.splice(index, 1)
|
||||||
|
// 重命名剩余轮次
|
||||||
|
props.modelValue.rounds.forEach((round: RoundModel, idx: number) => {
|
||||||
|
round.name = round.name.replace(/第\d+轮/, `第${idx + 1}轮`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增题目
|
||||||
|
function addQuestion(roundIndex: number) {
|
||||||
|
const round = props.modelValue.rounds[roundIndex]
|
||||||
|
if (round) {
|
||||||
|
round.questions.push({
|
||||||
|
value: null,
|
||||||
|
score: 5,
|
||||||
|
time: 30,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除题目
|
||||||
|
function removeQuestion(roundIndex: number, questionIndex: number) {
|
||||||
|
const round = props.modelValue.rounds[roundIndex]
|
||||||
|
if (round) {
|
||||||
|
round.questions.splice(questionIndex, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
const rounds = props.modelValue?.rounds || []
|
||||||
|
if (rounds.length === 0) {
|
||||||
|
window.$message?.error('请至少配置一个比赛环节')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < rounds.length; i++) {
|
||||||
|
const round = rounds[i]
|
||||||
|
if (round.questions.length === 0) {
|
||||||
|
window.$message?.error(`${round.name} 至少需要配置一道题目`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (let j = 0; j < round.questions.length; j++) {
|
||||||
|
if (!round.questions[j].value) {
|
||||||
|
window.$message?.error(`${round.name} 第 ${j + 1} 题未选择题型`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
// 默认重置为1个轮次,10个题目
|
||||||
|
props.modelValue.rounds = []
|
||||||
|
addRound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getRoundScore,
|
||||||
|
getRoundTime,
|
||||||
|
totalStats,
|
||||||
|
questionOptions,
|
||||||
|
scoreOptions,
|
||||||
|
timeOptions,
|
||||||
|
addRound,
|
||||||
|
removeRound,
|
||||||
|
addQuestion,
|
||||||
|
removeQuestion,
|
||||||
|
validate,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,10 +6,11 @@ import CompetitionAddModal from '../competition-add/index.vue'
|
|||||||
import CompetitionCard from './modules/competition-card.vue'
|
import 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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
46
apps/admin/src/views/question/index.vue
Normal file
46
apps/admin/src/views/question/index.vue
Normal 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>
|
||||||
112
apps/admin/src/views/question/modules/card-data.vue
Normal file
112
apps/admin/src/views/question/modules/card-data.vue
Normal 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>
|
||||||
17
apps/admin/src/views/question/modules/creativity-banner.vue
Normal file
17
apps/admin/src/views/question/modules/creativity-banner.vue
Normal 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>
|
||||||
68
apps/admin/src/views/question/modules/header-banner.vue
Normal file
68
apps/admin/src/views/question/modules/header-banner.vue
Normal 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>
|
||||||
152
apps/admin/src/views/question/modules/line-chart.vue
Normal file
152
apps/admin/src/views/question/modules/line-chart.vue
Normal 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>
|
||||||
109
apps/admin/src/views/question/modules/pie-chart.vue
Normal file
109
apps/admin/src/views/question/modules/pie-chart.vue
Normal 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>
|
||||||
40
apps/admin/src/views/question/modules/project-news.vue
Normal file
40
apps/admin/src/views/question/modules/project-news.vue
Normal 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>
|
||||||
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
40
packages/utils/src/excel.ts
Normal file
40
packages/utils/src/excel.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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
75
pnpm-lock.yaml
generated
@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user