feat(user): 新增用户端答题流程页面与相关功能
- 新增用户端答题流程主页面,包含封面、介绍、答题、答题图解四个状态 - 新增用户端组件:封面、介绍页、答题页、答题图解页、题目渲染器 - 新增用户端类型定义、模拟数据及业务常量 - 新增用户路由及类型声明 - 优化题库分类树,改为扁平化列表结构 - 完善比赛创建功能,增加轮次类型、题目分数类型及关联AP字段 - 修复模板删除函数调用参数错误
This commit is contained in:
BIN
apps/admin/src/assets/imgs/q5-img.png
Normal file
BIN
apps/admin/src/assets/imgs/q5-img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
15
apps/admin/src/assets/imgs/tianzige.svg
Normal file
15
apps/admin/src/assets/imgs/tianzige.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 MiB |
@ -26,4 +26,24 @@ export const menuIconTypeRecord: Record<Api.SystemManage.IconType, string> = {
|
||||
2: '本地图标',
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目分数类型
|
||||
*/
|
||||
export const scoreTypeRecord: Record<Api.Competition.QuestionScoreType, string> = {
|
||||
1: '固定分数',
|
||||
2: '答题个数',
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮次类型
|
||||
*/
|
||||
export const roundTypeRecord: Record<Api.Competition.CompetitionRoundType, string> = {
|
||||
1: '题包环节',
|
||||
2: '加时环节',
|
||||
}
|
||||
|
||||
export const roundTypeOptions = transformRecordToOption(roundTypeRecord)
|
||||
|
||||
export const scoreTypeOptions = transformRecordToOption(scoreTypeRecord)
|
||||
|
||||
export const menuIconTypeOptions = transformRecordToOption(menuIconTypeRecord)
|
||||
|
||||
@ -29,4 +29,5 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
"rank_rank-list": () => import("@/views/rank/rank-list/index.vue"),
|
||||
results: () => import("@/views/results/index.vue"),
|
||||
template: () => import("@/views/template/index.vue"),
|
||||
user: () => import("@/views/user/index.vue"),
|
||||
};
|
||||
|
||||
@ -188,5 +188,16 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
icon: 'material-symbols:settings-motion-mode-outline-rounded',
|
||||
order: 4
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
path: '/user',
|
||||
component: 'layout.blank$view.user',
|
||||
meta: {
|
||||
title: 'user',
|
||||
i18nKey: 'route.user',
|
||||
icon: 'material-symbols:account-circle',
|
||||
order: 6
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@ -178,7 +178,8 @@ const routeMap: RouteMap = {
|
||||
"rank_rank-detail": "/rank/rank-detail",
|
||||
"rank_rank-list": "/rank/rank-list",
|
||||
"results": "/results",
|
||||
"template": "/template"
|
||||
"template": "/template",
|
||||
"user": "/user"
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
56
apps/admin/src/typings/api/Competition.d.ts
vendored
Normal file
56
apps/admin/src/typings/api/Competition.d.ts
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Namespace Api
|
||||
*
|
||||
* All backend api type
|
||||
*/
|
||||
declare namespace Api {
|
||||
namespace Competition {
|
||||
/** common params of paginating */
|
||||
interface PaginatingCommonParams {
|
||||
/** current page number */
|
||||
current: number
|
||||
/** page size */
|
||||
size: number
|
||||
/** total count */
|
||||
total: number
|
||||
}
|
||||
|
||||
/** common params of paginating query list data */
|
||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||
records: T[]
|
||||
}
|
||||
|
||||
/** common search params of table */
|
||||
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>
|
||||
|
||||
/**
|
||||
* enable status
|
||||
*
|
||||
* - "1": enabled
|
||||
* - "2": disabled
|
||||
*/
|
||||
type EnableStatus = '1' | '2'
|
||||
|
||||
/** question score type */
|
||||
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
|
||||
|
||||
/** competition round type */
|
||||
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
|
||||
|
||||
/** common record */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
id: number
|
||||
/** record creator */
|
||||
createBy: string
|
||||
/** record create time */
|
||||
createTime: string
|
||||
/** record updater */
|
||||
updateBy: string
|
||||
/** record update time */
|
||||
updateTime: string
|
||||
/** record status */
|
||||
status: EnableStatus | null
|
||||
} & T
|
||||
}
|
||||
}
|
||||
6
apps/admin/src/typings/components.d.ts
vendored
6
apps/admin/src/typings/components.d.ts
vendored
@ -47,10 +47,12 @@ declare module 'vue' {
|
||||
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']
|
||||
IconIcOutlineAttachMoney: typeof import('~icons/ic/outline-attach-money')['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']
|
||||
IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
|
||||
IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
||||
IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
||||
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||
@ -67,6 +69,7 @@ declare module 'vue' {
|
||||
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
||||
'IconStreamlineSharp:typeAreaRemix': typeof import('~icons/streamline-sharp/type-area-remix')['default']
|
||||
IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
|
||||
IconUilSearch: typeof import('~icons/uil/search')['default']
|
||||
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||
@ -180,10 +183,12 @@ declare global {
|
||||
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 IconIcOutlineAttachMoney: typeof import('~icons/ic/outline-attach-money')['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 IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
|
||||
const IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
||||
const IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
||||
const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||
@ -200,6 +205,7 @@ declare global {
|
||||
const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
const IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||
const IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
||||
const 'IconStreamlineSharp:typeAreaRemix': typeof import('~icons/streamline-sharp/type-area-remix')['default']
|
||||
const IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
|
||||
const IconUilSearch: typeof import('~icons/uil/search')['default']
|
||||
const LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||
|
||||
3
apps/admin/src/typings/elegant-router.d.ts
vendored
3
apps/admin/src/typings/elegant-router.d.ts
vendored
@ -33,6 +33,7 @@ declare module "@elegant-router/types" {
|
||||
"rank_rank-list": "/rank/rank-list";
|
||||
"results": "/results";
|
||||
"template": "/template";
|
||||
"user": "/user";
|
||||
};
|
||||
|
||||
/**
|
||||
@ -75,6 +76,7 @@ declare module "@elegant-router/types" {
|
||||
| "rank"
|
||||
| "results"
|
||||
| "template"
|
||||
| "user"
|
||||
>;
|
||||
|
||||
/**
|
||||
@ -105,6 +107,7 @@ declare module "@elegant-router/types" {
|
||||
| "rank_rank-list"
|
||||
| "results"
|
||||
| "template"
|
||||
| "user"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NModal, NStep, NSteps } from 'naive-ui'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
|
||||
import BasicInfo from './modules/BasicInfo.vue'
|
||||
import GroupManagement from './modules/GroupManagement.vue'
|
||||
import HardwareBinding from './modules/HardwareBinding.vue'
|
||||
// import HardwareBinding from './modules/HardwareBinding.vue'
|
||||
import QuestionConfig from './modules/QuestionConfig.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@ -35,17 +36,20 @@ const competitionData = ref({
|
||||
{
|
||||
id: 'round_default',
|
||||
name: '第一轮:主赛环节',
|
||||
roundType: roundTypeOptions[0].value,
|
||||
questions: Array.from({ length: 10 }).map(() => ({
|
||||
value: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
title: '第1题',
|
||||
scoreType: scoreTypeOptions[0].value,
|
||||
})),
|
||||
},
|
||||
],
|
||||
// 名单录入
|
||||
groupManagement: [], // 组别管理
|
||||
// 绑定硬件
|
||||
hardware: [], // 绑定的硬件设备
|
||||
// hardware: [], // 绑定的硬件设备
|
||||
// 其他配置项...
|
||||
isPublic: true, // 是否公开
|
||||
// 是否主动流程(主持人主动流程:需要选择大题)
|
||||
@ -54,7 +58,7 @@ const competitionData = ref({
|
||||
const steps = [
|
||||
{ title: '基础设置', component: BasicInfo },
|
||||
{ title: '名单录入', component: GroupManagement },
|
||||
{ title: '硬件绑定', component: HardwareBinding },
|
||||
// { title: '硬件绑定', component: HardwareBinding },
|
||||
{ title: '题目配置', component: QuestionConfig },
|
||||
]
|
||||
|
||||
|
||||
@ -40,10 +40,45 @@ defineExpose({ validate, reset })
|
||||
<div class="mb-10">
|
||||
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
|
||||
比赛项目名称
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<NInput
|
||||
v-model:value="formData.name" placeholder="例如:2026年度阅读之星-辞海遨游总决赛"
|
||||
class="h-16 rounded-2xl border-none text-xl line-height-16 shadow-[0_2px_10px_rgba(0,0,0,0.02)] !bg-[#FCFCFC]"
|
||||
class="h-[3.4rem] rounded-2xl border-none text-xl leading-[3.4rem] shadow-[0_2px_10px_rgba(0,0,0,0.02)] !bg-[#FCFCFC]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-10">
|
||||
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
|
||||
关联场地AP
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<NSelect
|
||||
v-model:value="formData.ap" placeholder="选择关联场地AP"
|
||||
class="rounded-2xl border-none shadow-[0_2px_10px_rgba(0,0,0,0.02)] !bg-[#FCFCFC]"
|
||||
:options="[{
|
||||
label: 'AP1',
|
||||
value: 'AP1',
|
||||
}, {
|
||||
label: 'AP2',
|
||||
value: 'AP2',
|
||||
}]"
|
||||
size="large"
|
||||
:theme-overrides="{
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
heightLarge: '3.4rem',
|
||||
color: '#FCFCFC',
|
||||
borderRadius: '1rem',
|
||||
fontSizeLarge: '1.25rem',
|
||||
borderHover: 'none',
|
||||
borderFocus: 'none',
|
||||
boxShadowHover: 'none',
|
||||
boxShadowFocus: 'none',
|
||||
boxShadowActive: 'none',
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -51,6 +86,7 @@ defineExpose({ validate, reset })
|
||||
<div>
|
||||
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
|
||||
开始时间
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<NDatePicker
|
||||
v-model:value="formData.startTime" type="datetime" clearable class="w-full"
|
||||
@ -59,7 +95,7 @@ defineExpose({ validate, reset })
|
||||
Input: {
|
||||
borderRadius: '1rem',
|
||||
color: '#FCFCFC',
|
||||
heightLarge: '4rem',
|
||||
heightLarge: '3.4rem',
|
||||
fontSizeLarge: '1.25rem',
|
||||
textColor: '#1f2937',
|
||||
borderHover: 'none',
|
||||
@ -72,6 +108,7 @@ defineExpose({ validate, reset })
|
||||
<div>
|
||||
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
|
||||
结束时间
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<NDatePicker
|
||||
v-model:value="formData.endTime" type="datetime" clearable class="w-full" placeholder="选择结束时间"
|
||||
@ -80,7 +117,7 @@ defineExpose({ validate, reset })
|
||||
Input: {
|
||||
borderRadius: '1rem',
|
||||
color: '#FCFCFC',
|
||||
heightLarge: '4rem',
|
||||
heightLarge: '3.4rem',
|
||||
fontSizeLarge: '1.25rem',
|
||||
textColor: '#1f2937',
|
||||
borderHover: 'none',
|
||||
@ -115,7 +152,7 @@ defineExpose({ validate, reset })
|
||||
Input: {
|
||||
textColor: '#1f2937',
|
||||
fontSizeMedium: '3rem',
|
||||
heightMedium: '4rem',
|
||||
heightMedium: '3rem',
|
||||
color: 'transparent',
|
||||
caretColor: '#3B82F6',
|
||||
},
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect } from 'naive-ui'
|
||||
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
|
||||
import { useQuestionConfig } from './useQuestionConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
@ -8,17 +10,16 @@ const props = defineProps<{
|
||||
rounds: Array<{
|
||||
id: string
|
||||
name: string
|
||||
questions: Array<{ value: string | null, score: number, time: number }>
|
||||
roundType: string
|
||||
questions: Array<{ id: string, value: string | null, time: number, title: string, scoreType: Api.Competition.QuestionScoreType }>
|
||||
}>
|
||||
}
|
||||
}>()
|
||||
|
||||
const {
|
||||
getRoundScore,
|
||||
// getRoundScore,
|
||||
getRoundTime,
|
||||
totalStats,
|
||||
questionOptions,
|
||||
scoreOptions,
|
||||
timeOptions,
|
||||
addRound,
|
||||
removeRound,
|
||||
@ -33,12 +34,14 @@ const showAddModal = ref(false)
|
||||
const newRoundForm = ref({
|
||||
name: '',
|
||||
count: 10,
|
||||
roundType: roundTypeOptions[0].value,
|
||||
})
|
||||
|
||||
function handleOpenAddModal() {
|
||||
newRoundForm.value = {
|
||||
name: `第${(props.modelValue?.rounds?.length || 0) + 1}轮 汉字听写`,
|
||||
count: 10,
|
||||
roundType: roundTypeOptions[0].value,
|
||||
}
|
||||
showAddModal.value = true
|
||||
}
|
||||
@ -48,15 +51,28 @@ function handleConfirmAdd() {
|
||||
window.$message?.warning('请输入环节名称')
|
||||
return
|
||||
}
|
||||
addRound(newRoundForm.value.name, newRoundForm.value.count)
|
||||
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType)
|
||||
showAddModal.value = false
|
||||
}
|
||||
|
||||
// 初始化默认数据
|
||||
watch(() => props.modelValue, () => {
|
||||
// 如果没有任何轮次,显示空状态,不再自动添加
|
||||
// 保持空状态以便用户看到"暂无配置题包"的界面
|
||||
}, { immediate: true })
|
||||
watch(() => props.modelValue, (val) => {
|
||||
// 确保所有题目都有ID,用于拖拽排序
|
||||
if (val?.rounds) {
|
||||
val.rounds.forEach((round) => {
|
||||
if (!round.questions) {
|
||||
return
|
||||
}
|
||||
round.questions.forEach((q: any) => {
|
||||
if (!q.id) {
|
||||
q.id = `q_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(val, '拖动后的重新顺序')
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
defineExpose({ validate, reset })
|
||||
</script>
|
||||
@ -71,16 +87,15 @@ defineExpose({ validate, reset })
|
||||
</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
|
||||
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>
|
||||
@ -89,7 +104,10 @@ defineExpose({ validate, reset })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NButton type="primary" class="shadow-blue-500/20 shadow-lg !h-10 !rounded-lg !px-6 !font-bold" @click="handleOpenAddModal">
|
||||
<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>
|
||||
@ -98,7 +116,10 @@ defineExpose({ validate, reset })
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<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
|
||||
v-if="!props.modelValue?.rounds?.length"
|
||||
class="flex flex-col flex-1 items-center justify-center border-2 border-gray-100 rounded-2xl border-dashed bg-gray-50/30"
|
||||
>
|
||||
<div class="h-34 w-34 flex items-center justify-center rounded-3xl bg-gray-100 text-gray-300">
|
||||
<icon-ic-outline-folder-off class="text-5xl" />
|
||||
</div>
|
||||
@ -112,7 +133,10 @@ defineExpose({ validate, reset })
|
||||
|
||||
<!-- 列表内容 -->
|
||||
<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
|
||||
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" />
|
||||
|
||||
@ -121,23 +145,28 @@ defineExpose({ validate, reset })
|
||||
<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" />
|
||||
<NTag type="primary" class="!text-white">
|
||||
{{ roundTypeOptions.find((item) => item.value === round.roundType)?.label || '未知类型' }}
|
||||
</NTag>
|
||||
<div class="flex items-center gap-3">
|
||||
<NInput
|
||||
v-model:value="round.name"
|
||||
placeholder="请输入环节名称"
|
||||
:bordered="false"
|
||||
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">
|
||||
<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">
|
||||
<!-- <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">
|
||||
</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>
|
||||
@ -146,12 +175,18 @@ defineExpose({ validate, reset })
|
||||
</div>
|
||||
|
||||
<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)">
|
||||
<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)">
|
||||
<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>
|
||||
@ -160,56 +195,73 @@ defineExpose({ validate, reset })
|
||||
</div>
|
||||
|
||||
<!-- 题目列表 -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="(item, qIdx) in round.questions"
|
||||
:key="qIdx"
|
||||
class="group/item flex items-center gap-4 border border-gray-100 rounded-xl bg-white p-2 px-4 transition-all hover:border-blue-200 hover:shadow-sm"
|
||||
<VueDraggable
|
||||
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-2"
|
||||
ghost-class="ghost"
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
v-for="(item, qIdx) in round.questions" :key="item.id"
|
||||
class="group/item flex items-center gap-2 border border-gray-100 rounded-xl bg-white p-2 px-2 transition-all hover:border-blue-200 hover:shadow-sm"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="drag-handle flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition-colors active:cursor-grabbing hover:bg-gray-100 hover:text-blue-500"
|
||||
>
|
||||
<icon-mdi-drag class="text-xl" />
|
||||
</div>
|
||||
<span
|
||||
class="w-6 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>
|
||||
</div>
|
||||
|
||||
<!-- 题型 -->
|
||||
<div class="flex-1">
|
||||
<NSelect
|
||||
v-model:value="item.value"
|
||||
:options="questionOptions"
|
||||
placeholder="请选择题目类型..."
|
||||
:bordered="false"
|
||||
class="font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分值 -->
|
||||
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||
<span class="text-xs text-gray-400 font-bold tracking-wider uppercase">PTS</span>
|
||||
<NSelect
|
||||
v-model:value="item.score"
|
||||
:options="scoreOptions"
|
||||
class="w-26 !bg-transparent"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型..."
|
||||
:bordered="false" class="font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 时间 -->
|
||||
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||
<icon-ic-outline-timer class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间:</span>
|
||||
<NSelect
|
||||
v-model:value="item.time"
|
||||
:options="timeOptions"
|
||||
class="w-26 !bg-transparent"
|
||||
size="small"
|
||||
v-model:value="item.time" :options="timeOptions" class="w-26 !bg-transparent" size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 font-bold">S</span>
|
||||
</div>
|
||||
|
||||
<!-- 分数规则 -->
|
||||
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则:</span>
|
||||
<NSelect
|
||||
v-model:value="item.scoreType" :options="scoreTypeOptions as unknown as SelectOption[]"
|
||||
class="w-28 !bg-transparent" size="small" :bordered="false"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 font-bold">S</span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||
<icon-ic-outline-title class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题:</span>
|
||||
<NInput v-model:value="item.title" class="w-28 !bg-transparent" size="small" :bordered="false" />
|
||||
</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)">
|
||||
<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>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -235,15 +287,30 @@ defineExpose({ validate, reset })
|
||||
<NFormItem label="环节/题包名称">
|
||||
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 汉字听写" />
|
||||
</NFormItem>
|
||||
<NFormItem label="环节/题包类型">
|
||||
<NSelect
|
||||
v-model:value="newRoundForm.roundType" :options="roundTypeOptions as unknown as SelectOption[]"
|
||||
placeholder="请选择环节类型..." :bordered="true" class="font-medium"
|
||||
/>
|
||||
</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
|
||||
type="primary" block size="large" class="mt-4 !h-12 !rounded-xl !text-lg !font-bold"
|
||||
@click="handleConfirmAdd"
|
||||
>
|
||||
生成题包卡片
|
||||
</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ghost {
|
||||
@apply opacity-50 bg-blue-50 border-2 border-dashed border-blue-300;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -9,6 +9,7 @@ export interface BasicInfoModel {
|
||||
groupCount: number
|
||||
teamCount: number
|
||||
poster: string
|
||||
ap: string
|
||||
}
|
||||
|
||||
export function useBasicInfo(props: any, emit: any) {
|
||||
@ -94,6 +95,7 @@ export function useBasicInfo(props: any, emit: any) {
|
||||
groupCount: 10,
|
||||
teamCount: 4,
|
||||
poster: '',
|
||||
ap: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { computed } from 'vue'
|
||||
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
|
||||
|
||||
export interface QuestionItem {
|
||||
id: string
|
||||
value: string | null
|
||||
score: number
|
||||
time: number
|
||||
title: string
|
||||
scoreType: Api.Competition.QuestionScoreType
|
||||
}
|
||||
|
||||
export interface RoundModel {
|
||||
@ -12,8 +16,8 @@ export interface RoundModel {
|
||||
questions: QuestionItem[]
|
||||
}
|
||||
|
||||
export interface QuestionConfigModel {
|
||||
rounds: RoundModel[]
|
||||
function generateId() {
|
||||
return `q_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
export function useQuestionConfig(props: any) {
|
||||
@ -59,14 +63,6 @@ export function useQuestionConfig(props: any) {
|
||||
{ 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 },
|
||||
@ -76,7 +72,7 @@ export function useQuestionConfig(props: any) {
|
||||
]
|
||||
|
||||
// 新增轮次
|
||||
function addRound(customName?: string, customCount?: number) {
|
||||
function addRound(customName?: string, customCount?: number, roundType?: Api.Competition.CompetitionRoundType) {
|
||||
if (!props.modelValue.rounds) {
|
||||
props.modelValue.rounds = []
|
||||
}
|
||||
@ -87,10 +83,14 @@ export function useQuestionConfig(props: any) {
|
||||
props.modelValue.rounds.push({
|
||||
id: `round_${Date.now()}`,
|
||||
name,
|
||||
roundType: roundType || roundTypeOptions[0].value,
|
||||
questions: Array.from({ length: count }).map(() => ({
|
||||
id: generateId(),
|
||||
value: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
title: '',
|
||||
scoreType: scoreTypeOptions[0].value,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@ -109,9 +109,12 @@ export function useQuestionConfig(props: any) {
|
||||
const round = props.modelValue.rounds[roundIndex]
|
||||
if (round) {
|
||||
round.questions.push({
|
||||
id: generateId(),
|
||||
value: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
title: '',
|
||||
scoreType: scoreTypeOptions[0].value,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -158,7 +161,6 @@ export function useQuestionConfig(props: any) {
|
||||
getRoundTime,
|
||||
totalStats,
|
||||
questionOptions,
|
||||
scoreOptions,
|
||||
timeOptions,
|
||||
addRound,
|
||||
removeRound,
|
||||
|
||||
@ -0,0 +1,385 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NButton,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NTree,
|
||||
type TreeOption,
|
||||
useDialog,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { h, ref, watch } from 'vue'
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:category', node: any): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
// Mock Data - 模拟分类树数据
|
||||
const treeData = ref<TreeOption[]>([
|
||||
{
|
||||
key: 'stage-1',
|
||||
label: '汉字听写',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-1',
|
||||
label: '请根据提示书写正确的汉字。',
|
||||
isLeaf: true,
|
||||
},
|
||||
{
|
||||
key: 'q-type-2',
|
||||
label: '请根据拼音书写同音字。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stage-2',
|
||||
label: '汉字加一加',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-3',
|
||||
label: '请写出含有“”的汉字。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'poem',
|
||||
label: '词语大比拼',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-4',
|
||||
label: '词语听写',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-4-1',
|
||||
label: '请根据拼音书写正确的词语。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'q-type-5',
|
||||
label: '成语写一写',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-5-1',
|
||||
label: '请写出含有反义字的四字成语。',
|
||||
isLeaf: true,
|
||||
},
|
||||
{
|
||||
key: 'q-type-5-2',
|
||||
label: '请根据图片书写正确的成语',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stage-extra-1',
|
||||
label: '换字组成语',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-extra-1',
|
||||
label: '请换一个字,组成新的成语。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const expandedKeys = ref<string[]>(['root', 'stage-1'])
|
||||
|
||||
// Modal State
|
||||
const showCategoryModal = ref(false)
|
||||
const categoryModalType = ref<1 | 2>(1) // 1级或2级分类
|
||||
const categoryForm = ref({ name: '' })
|
||||
|
||||
// 记录当前操作类型:add-root, add-child, edit
|
||||
const categoryOperation = ref<'add-root' | 'add-child' | 'edit'>('add-root')
|
||||
// 记录当前操作的目标节点(编辑时为该节点,添加子节点时为父节点)
|
||||
const currentOperationNode = ref<TreeOption | null>(null)
|
||||
|
||||
// 递归查找节点
|
||||
function findNodeByKey(key: string, nodes: TreeOption[]): TreeOption | null {
|
||||
for (const node of nodes) {
|
||||
if (node.key === key)
|
||||
return node
|
||||
if (node.children) {
|
||||
const found = findNodeByKey(key, node.children)
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
watch(selectedKeys, (newKeys) => {
|
||||
if (newKeys.length === 0) {
|
||||
emit('update:category', null)
|
||||
return
|
||||
}
|
||||
const key = newKeys[0]
|
||||
const node = findNodeByKey(key, treeData.value)
|
||||
if (node) {
|
||||
emit('update:category', {
|
||||
label: node.label,
|
||||
level: node.isLeaf ? 3 : 1,
|
||||
key: node.key,
|
||||
isLeaf: node.isLeaf,
|
||||
children: node.children,
|
||||
})
|
||||
}
|
||||
else {
|
||||
emit('update:category', null)
|
||||
}
|
||||
})
|
||||
|
||||
// 打开新增根节点弹窗
|
||||
function handleAddRootCategory() {
|
||||
categoryOperation.value = 'add-root'
|
||||
categoryModalType.value = 1
|
||||
categoryForm.value.name = ''
|
||||
currentOperationNode.value = null
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 打开新增子节点弹窗
|
||||
function handleAddChildCategory(parentNode: TreeOption) {
|
||||
categoryOperation.value = 'add-child'
|
||||
categoryModalType.value = 2 // 视为下一级
|
||||
categoryForm.value.name = ''
|
||||
currentOperationNode.value = parentNode
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 打开编辑节点弹窗
|
||||
function handleEditCategory(node: TreeOption) {
|
||||
categoryOperation.value = 'edit'
|
||||
categoryForm.value.name = node.label as string
|
||||
currentOperationNode.value = node
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 递归删除节点 如果节点有子节点,无法删除
|
||||
function deleteNode(nodes: TreeOption[], key: string | number): boolean {
|
||||
const index = nodes.findIndex(n => n.key === key)
|
||||
if (index !== -1) {
|
||||
// 检查是否有子节点
|
||||
if (nodes[index].children && nodes[index].children.length > 0) {
|
||||
throw new Error('该分类下有子分类,无法删除')
|
||||
}
|
||||
nodes.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.children) {
|
||||
if (deleteNode(node.children, key))
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleDeleteCategory(node: TreeOption) {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: `确定要删除分类 "${node.label}" 吗?此操作无法撤销。`,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
try {
|
||||
deleteNode(treeData.value, node.key!)
|
||||
// 如果删除的是当前选中的节点,清空选中
|
||||
if (selectedKeys.value.includes(node.key as string)) {
|
||||
selectedKeys.value = []
|
||||
}
|
||||
message.success('删除成功')
|
||||
}
|
||||
catch (error: any) {
|
||||
message.error(error.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitCategory() {
|
||||
if (!categoryForm.value.name) {
|
||||
message.error('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (categoryOperation.value === 'add-root') {
|
||||
// 新增根节点
|
||||
const newKey = `root-${Date.now()}`
|
||||
treeData.value.push({
|
||||
key: newKey,
|
||||
label: categoryForm.value.name,
|
||||
children: [],
|
||||
})
|
||||
message.success('分类添加成功')
|
||||
}
|
||||
else if (categoryOperation.value === 'add-child' && currentOperationNode.value) {
|
||||
// 新增子节点
|
||||
if (!currentOperationNode.value.children) {
|
||||
currentOperationNode.value.children = []
|
||||
}
|
||||
// 确保父节点不再是叶子节点,否则无法展开显示子节点
|
||||
if (currentOperationNode.value.isLeaf) {
|
||||
currentOperationNode.value.isLeaf = false
|
||||
}
|
||||
const newKey = `node-${Date.now()}`
|
||||
// 如果是第三级(叶子),标记 isLeaf
|
||||
currentOperationNode.value.children.push({
|
||||
key: newKey,
|
||||
label: categoryForm.value.name,
|
||||
isLeaf: true,
|
||||
})
|
||||
// 展开父节点
|
||||
if (!expandedKeys.value.includes(currentOperationNode.value.key as string)) {
|
||||
expandedKeys.value.push(currentOperationNode.value.key as string)
|
||||
}
|
||||
message.success('子分类添加成功')
|
||||
}
|
||||
else if (categoryOperation.value === 'edit' && currentOperationNode.value) {
|
||||
// 编辑节点
|
||||
currentOperationNode.value.label = categoryForm.value.name
|
||||
message.success('分类修改成功')
|
||||
}
|
||||
|
||||
showCategoryModal.value = false
|
||||
}
|
||||
|
||||
// Tree Rendering
|
||||
function renderPrefix({ option }: { option: TreeOption }) {
|
||||
// 根据层级或类型显示不同图标
|
||||
if (option.children && option.children.length > 0) {
|
||||
return h(SvgIcon, { icon: 'carbon:folder', class: 'text-gray-400 text-lg' })
|
||||
}
|
||||
// 如果明确标记为叶子节点,或者没有 children
|
||||
return h(SvgIcon, { icon: 'carbon:document', class: 'text-gray-400 text-lg' })
|
||||
}
|
||||
|
||||
function renderSuffix({ option }: { option: TreeOption }) {
|
||||
// 悬浮时显示操作按钮
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
onClick: (e: Event) => e.stopPropagation(),
|
||||
},
|
||||
[
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-blue-500 cursor-pointer flex items-center',
|
||||
title: '编辑',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleEditCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:edit', class: 'text-lg' })]),
|
||||
// 允许所有节点添加子节点,如果添加了子节点,它就变成文件夹
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-green-500 cursor-pointer flex items-center',
|
||||
title: '添加子分类',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleAddChildCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:add', class: 'text-lg' })]),
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-red-500 cursor-pointer flex items-center',
|
||||
title: '删除',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:trash-can', class: 'text-lg' })]),
|
||||
],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col border-r border-gray-100 bg-gray-50/30">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-4">
|
||||
<span class="text-lg text-gray-700 font-bold">分类导航树</span>
|
||||
<NButton size="tiny" secondary type="primary" @click="handleAddRootCategory">
|
||||
<template #icon>
|
||||
<SvgIcon icon="ic:baseline-add" class="text-icon" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<NTree
|
||||
block-line :data="treeData" :selected-keys="selectedKeys" :expanded-keys="expandedKeys"
|
||||
:render-prefix="renderPrefix" :render-suffix="renderSuffix" selectable expand-on-click class="px-2"
|
||||
@update:selected-keys="(keys) => (selectedKeys = keys)"
|
||||
@update:expanded-keys="(keys) => (expandedKeys = keys)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 bg-gray-50 p-3 text-xs text-gray-400">
|
||||
提示:请勿随意删除维护原有分类,三级分类为最终题目目录。
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Category Modal -->
|
||||
<NModal
|
||||
v-model:show="showCategoryModal" preset="card"
|
||||
:title="categoryOperation === 'edit' ? '编辑分类' : `新增 ${categoryModalType} 级分类`" class="w-[500px]"
|
||||
>
|
||||
<NForm>
|
||||
<NFormItem label="分类显示名称">
|
||||
<NInput v-model:value="categoryForm.name" placeholder="请输入内容" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<NButton @click="showCategoryModal = false">
|
||||
取消操作
|
||||
</NButton>
|
||||
<NButton type="primary" @click="submitCategory">
|
||||
确认提交
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-tree-node-content__text) {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node--selected) {
|
||||
background-color: #eff6ff !important;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node--selected .n-tree-node-content__text) {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Ensure icons in tree are visible on hover */
|
||||
:deep(.n-tree-node-content:hover .group-hover\:opacity-100) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom group class for tree node content wrapper to handle hover state */
|
||||
:deep(.n-tree-node-content) {
|
||||
@apply group;
|
||||
}
|
||||
</style>
|
||||
@ -5,12 +5,10 @@ import {
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NTree,
|
||||
type TreeOption,
|
||||
useDialog,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { h, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -20,122 +18,54 @@ const emit = defineEmits<{
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
// Mock Data - 模拟分类树数据
|
||||
const treeData = ref<TreeOption[]>([
|
||||
interface CategoryItem {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// Mock Data - 模拟分类列表数据
|
||||
const categoryList = ref<CategoryItem[]>([
|
||||
{
|
||||
key: 'stage-1',
|
||||
label: '汉字听写',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-1',
|
||||
label: '请根据提示书写正确的汉字。',
|
||||
isLeaf: true,
|
||||
},
|
||||
{
|
||||
key: 'q-type-2',
|
||||
label: '请根据拼音书写同音字。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stage-2',
|
||||
label: '汉字加一加',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-3',
|
||||
label: '请写出含有“”的汉字。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
label: '请写出含有“车”的汉字。',
|
||||
},
|
||||
{
|
||||
key: 'poem',
|
||||
label: '词语大比拼',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-4',
|
||||
label: '词语听写',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-4-1',
|
||||
label: '请根据拼音书写正确的词语。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'q-type-5',
|
||||
label: '成语写一写',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-5-1',
|
||||
label: '请写出含有反义字的四字成语。',
|
||||
isLeaf: true,
|
||||
},
|
||||
{
|
||||
key: 'q-type-5-2',
|
||||
label: '请根据图片书写正确的成语',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stage-extra-1',
|
||||
label: '换字组成语',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-extra-1',
|
||||
label: '请换一个字,组成新的成语。',
|
||||
isLeaf: true,
|
||||
label: '请写出含有反义字的四字成语。',
|
||||
},
|
||||
],
|
||||
{
|
||||
key: 'stage-extra-2',
|
||||
label: '请根据图片书写正确的成语。',
|
||||
},
|
||||
])
|
||||
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const expandedKeys = ref<string[]>(['root', 'stage-1'])
|
||||
const selectedKey = ref<string | null>(null)
|
||||
|
||||
// Modal State
|
||||
const showCategoryModal = ref(false)
|
||||
const categoryModalType = ref<1 | 2>(1) // 1级或2级分类
|
||||
const categoryForm = ref({ name: '' })
|
||||
const categoryOperation = ref<'add' | 'edit'>('add')
|
||||
const currentOperationNode = ref<CategoryItem | null>(null)
|
||||
|
||||
// 记录当前操作类型:add-root, add-child, edit
|
||||
const categoryOperation = ref<'add-root' | 'add-child' | 'edit'>('add-root')
|
||||
// 记录当前操作的目标节点(编辑时为该节点,添加子节点时为父节点)
|
||||
const currentOperationNode = ref<TreeOption | null>(null)
|
||||
|
||||
// 递归查找节点
|
||||
function findNodeByKey(key: string, nodes: TreeOption[]): TreeOption | null {
|
||||
for (const node of nodes) {
|
||||
if (node.key === key)
|
||||
return node
|
||||
if (node.children) {
|
||||
const found = findNodeByKey(key, node.children)
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
watch(selectedKeys, (newKeys) => {
|
||||
if (newKeys.length === 0) {
|
||||
watch(selectedKey, (newKey) => {
|
||||
if (!newKey) {
|
||||
emit('update:category', null)
|
||||
return
|
||||
}
|
||||
const key = newKeys[0]
|
||||
const node = findNodeByKey(key, treeData.value)
|
||||
const node = categoryList.value.find(item => item.key === newKey)
|
||||
if (node) {
|
||||
emit('update:category', {
|
||||
label: node.label,
|
||||
level: node.isLeaf ? 3 : 1,
|
||||
key: node.key,
|
||||
isLeaf: node.isLeaf,
|
||||
children: node.children,
|
||||
...node,
|
||||
level: 1, // Treat all as level 1
|
||||
isLeaf: true, // Treat all as leaf (container for questions)
|
||||
})
|
||||
}
|
||||
else {
|
||||
@ -143,70 +73,39 @@ watch(selectedKeys, (newKeys) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 打开新增根节点弹窗
|
||||
function handleAddRootCategory() {
|
||||
categoryOperation.value = 'add-root'
|
||||
categoryModalType.value = 1
|
||||
function handleSelect(key: string) {
|
||||
selectedKey.value = key
|
||||
}
|
||||
|
||||
function handleAddCategory() {
|
||||
categoryOperation.value = 'add'
|
||||
categoryForm.value.name = ''
|
||||
currentOperationNode.value = null
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 打开新增子节点弹窗
|
||||
function handleAddChildCategory(parentNode: TreeOption) {
|
||||
categoryOperation.value = 'add-child'
|
||||
categoryModalType.value = 2 // 视为下一级
|
||||
categoryForm.value.name = ''
|
||||
currentOperationNode.value = parentNode
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 打开编辑节点弹窗
|
||||
function handleEditCategory(node: TreeOption) {
|
||||
function handleEditCategory(item: CategoryItem) {
|
||||
categoryOperation.value = 'edit'
|
||||
categoryForm.value.name = node.label as string
|
||||
currentOperationNode.value = node
|
||||
categoryForm.value.name = item.label
|
||||
currentOperationNode.value = item
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 递归删除节点 如果节点有子节点,无法删除
|
||||
function deleteNode(nodes: TreeOption[], key: string | number): boolean {
|
||||
const index = nodes.findIndex(n => n.key === key)
|
||||
if (index !== -1) {
|
||||
// 检查是否有子节点
|
||||
if (nodes[index].children && nodes[index].children.length > 0) {
|
||||
throw new Error('该分类下有子分类,无法删除')
|
||||
}
|
||||
nodes.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.children) {
|
||||
if (deleteNode(node.children, key))
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleDeleteCategory(node: TreeOption) {
|
||||
function handleDeleteCategory(item: CategoryItem) {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: `确定要删除分类 "${node.label}" 吗?此操作无法撤销。`,
|
||||
content: `确定要删除分类 "${item.label}" 吗?此操作无法撤销。`,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
try {
|
||||
deleteNode(treeData.value, node.key!)
|
||||
// 如果删除的是当前选中的节点,清空选中
|
||||
if (selectedKeys.value.includes(node.key as string)) {
|
||||
selectedKeys.value = []
|
||||
const index = categoryList.value.findIndex(n => n.key === item.key)
|
||||
if (index !== -1) {
|
||||
categoryList.value.splice(index, 1)
|
||||
if (selectedKey.value === item.key) {
|
||||
selectedKey.value = null
|
||||
}
|
||||
message.success('删除成功')
|
||||
}
|
||||
catch (error: any) {
|
||||
message.error(error.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -217,137 +116,103 @@ function submitCategory() {
|
||||
return
|
||||
}
|
||||
|
||||
if (categoryOperation.value === 'add-root') {
|
||||
// 新增根节点
|
||||
const newKey = `root-${Date.now()}`
|
||||
treeData.value.push({
|
||||
if (categoryOperation.value === 'add') {
|
||||
const newKey = `cate-${Date.now()}`
|
||||
categoryList.value.push({
|
||||
key: newKey,
|
||||
label: categoryForm.value.name,
|
||||
children: [],
|
||||
})
|
||||
message.success('分类添加成功')
|
||||
}
|
||||
else if (categoryOperation.value === 'add-child' && currentOperationNode.value) {
|
||||
// 新增子节点
|
||||
if (!currentOperationNode.value.children) {
|
||||
currentOperationNode.value.children = []
|
||||
}
|
||||
// 确保父节点不再是叶子节点,否则无法展开显示子节点
|
||||
if (currentOperationNode.value.isLeaf) {
|
||||
currentOperationNode.value.isLeaf = false
|
||||
}
|
||||
const newKey = `node-${Date.now()}`
|
||||
// 如果是第三级(叶子),标记 isLeaf
|
||||
currentOperationNode.value.children.push({
|
||||
key: newKey,
|
||||
label: categoryForm.value.name,
|
||||
isLeaf: true,
|
||||
})
|
||||
// 展开父节点
|
||||
if (!expandedKeys.value.includes(currentOperationNode.value.key as string)) {
|
||||
expandedKeys.value.push(currentOperationNode.value.key as string)
|
||||
}
|
||||
message.success('子分类添加成功')
|
||||
// Automatically select the new category
|
||||
selectedKey.value = newKey
|
||||
}
|
||||
else if (categoryOperation.value === 'edit' && currentOperationNode.value) {
|
||||
// 编辑节点
|
||||
currentOperationNode.value.label = categoryForm.value.name
|
||||
message.success('分类修改成功')
|
||||
// Trigger update if currently selected
|
||||
if (selectedKey.value === currentOperationNode.value.key) {
|
||||
const node = currentOperationNode.value
|
||||
emit('update:category', {
|
||||
...node,
|
||||
level: 1,
|
||||
isLeaf: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
showCategoryModal.value = false
|
||||
}
|
||||
|
||||
// Tree Rendering
|
||||
function renderPrefix({ option }: { option: TreeOption }) {
|
||||
// 根据层级或类型显示不同图标
|
||||
if (option.children && option.children.length > 0) {
|
||||
return h(SvgIcon, { icon: 'carbon:folder', class: 'text-gray-400 text-lg' })
|
||||
}
|
||||
// 如果明确标记为叶子节点,或者没有 children
|
||||
return h(SvgIcon, { icon: 'carbon:document', class: 'text-gray-400 text-lg' })
|
||||
}
|
||||
|
||||
function renderSuffix({ option }: { option: TreeOption }) {
|
||||
// 悬浮时显示操作按钮
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
onClick: (e: Event) => e.stopPropagation(),
|
||||
},
|
||||
[
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-blue-500 cursor-pointer flex items-center',
|
||||
title: '编辑',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleEditCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:edit', class: 'text-lg' })]),
|
||||
// 允许所有节点添加子节点,如果添加了子节点,它就变成文件夹
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-green-500 cursor-pointer flex items-center',
|
||||
title: '添加子分类',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleAddChildCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:add', class: 'text-lg' })]),
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-red-500 cursor-pointer flex items-center',
|
||||
title: '删除',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:trash-can', class: 'text-lg' })]),
|
||||
],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col border-r border-gray-100 bg-gray-50/30">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-4">
|
||||
<span class="text-lg text-gray-700 font-bold">分类导航树</span>
|
||||
<NButton size="tiny" secondary type="primary" @click="handleAddRootCategory">
|
||||
<span class="text-lg text-gray-700 font-bold">题目分类</span>
|
||||
<NButton size="tiny" secondary type="primary" @click="handleAddCategory">
|
||||
<template #icon>
|
||||
<SvgIcon icon="ic:baseline-add" class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<NTree
|
||||
block-line :data="treeData" :selected-keys="selectedKeys" :expanded-keys="expandedKeys"
|
||||
:render-prefix="renderPrefix" :render-suffix="renderSuffix" selectable expand-on-click class="px-2"
|
||||
@update:selected-keys="(keys) => (selectedKeys = keys)"
|
||||
@update:expanded-keys="(keys) => (expandedKeys = keys)"
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<div v-if="categoryList.length === 0" class="mt-10 text-center text-gray-400">
|
||||
暂无分类,请点击上方添加
|
||||
</div>
|
||||
<div
|
||||
v-for="item in categoryList" :key="item.key"
|
||||
class="group mb-1 flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 transition-all hover:bg-white hover:shadow-sm"
|
||||
:class="selectedKey === item.key ? 'bg-white shadow-sm text-primary' : 'text-gray-600'"
|
||||
@click="handleSelect(item.key)"
|
||||
>
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<SvgIcon
|
||||
icon="carbon:folder" class="flex-shrink-0 text-lg"
|
||||
:class="selectedKey === item.key ? 'text-primary' : 'text-gray-400'"
|
||||
/>
|
||||
<span class="truncate font-medium">{{ item.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div
|
||||
class="flex cursor-pointer items-center p-1 text-gray-400 hover:text-blue-500" title="编辑"
|
||||
@click.stop="handleEditCategory(item)"
|
||||
>
|
||||
<SvgIcon icon="carbon:edit" class="text-base" />
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center p-1 text-gray-400 hover:text-red-500" title="删除"
|
||||
@click.stop="handleDeleteCategory(item)"
|
||||
>
|
||||
<SvgIcon icon="carbon:trash-can" class="text-base" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 bg-gray-50 p-3 text-xs text-gray-400">
|
||||
提示:请勿随意删除维护原有分类,三级分类为最终题目目录。
|
||||
提示:点击分类后,可在右侧管理该分类下的题目。
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Category Modal -->
|
||||
<NModal
|
||||
v-model:show="showCategoryModal" preset="card"
|
||||
:title="categoryOperation === 'edit' ? '编辑分类' : `新增 ${categoryModalType} 级分类`" class="w-[500px]"
|
||||
v-model:show="showCategoryModal" preset="card" :title="categoryOperation === 'edit' ? '编辑分类' : '新增分类'"
|
||||
class="w-[500px]"
|
||||
>
|
||||
<NForm>
|
||||
<NFormItem label="分类显示名称">
|
||||
<NInput v-model:value="categoryForm.name" placeholder="请输入内容" />
|
||||
<NFormItem label="分类名称">
|
||||
<NInput v-model:value="categoryForm.name" placeholder="请输入分类名称" @keyup.enter="submitCategory" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<NButton @click="showCategoryModal = false">
|
||||
取消操作
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" @click="submitCategory">
|
||||
确认提交
|
||||
确认
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
@ -356,30 +221,5 @@ function renderSuffix({ option }: { option: TreeOption }) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-tree-node-content__text) {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node--selected) {
|
||||
background-color: #eff6ff !important;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node--selected .n-tree-node-content__text) {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Ensure icons in tree are visible on hover */
|
||||
:deep(.n-tree-node-content:hover .group-hover\:opacity-100) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom group class for tree node content wrapper to handle hover state */
|
||||
:deep(.n-tree-node-content) {
|
||||
@apply group;
|
||||
}
|
||||
/* No specific styles needed as we use Tailwind classes */
|
||||
</style>
|
||||
|
||||
@ -25,7 +25,7 @@ const props = defineProps<{
|
||||
const questionList = ref([
|
||||
{
|
||||
id: 'Q-0325',
|
||||
categoryKey: 'q-type-1',
|
||||
categoryKey: 'stage-1',
|
||||
content: 'jiū 表示小鸟的叫声',
|
||||
answer: '碧',
|
||||
score: 10,
|
||||
@ -33,7 +33,7 @@ const questionList = ref([
|
||||
},
|
||||
{
|
||||
id: 'Q-0326',
|
||||
categoryKey: 'q-type-1',
|
||||
categoryKey: 'stage-1',
|
||||
content: '“春色满园关不住,一枝红杏出墙来”。请书写“杏”字。',
|
||||
answer: '杏',
|
||||
score: 10,
|
||||
@ -41,7 +41,7 @@ const questionList = ref([
|
||||
},
|
||||
{
|
||||
id: 'Q-0327',
|
||||
categoryKey: 'q-type-1',
|
||||
categoryKey: 'stage-1',
|
||||
content: '“春色满园关不住,一枝红杏出墙来”。请书写“㽱”字。',
|
||||
answer: '㽱',
|
||||
score: 10,
|
||||
@ -49,7 +49,7 @@ const questionList = ref([
|
||||
},
|
||||
{
|
||||
id: 'Q-0328',
|
||||
categoryKey: 'q-type-1',
|
||||
categoryKey: 'stage-1',
|
||||
content: '“春色满园关不住,一枝红杏出墙来”。请书写“不”字。',
|
||||
answer: '不',
|
||||
score: 10,
|
||||
@ -93,9 +93,7 @@ const questionForm = ref({
|
||||
})
|
||||
|
||||
const isLeafSelected = computed(() => {
|
||||
if (!props.currentCategory)
|
||||
return false
|
||||
return !!props.currentCategory.isLeaf || (props.currentCategory.children && props.currentCategory.children.length === 0 && props.currentCategory.level === 3)
|
||||
return !!props.currentCategory
|
||||
})
|
||||
|
||||
const filteredQuestions = computed(() => {
|
||||
@ -203,7 +201,7 @@ function submitQuestion() {
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div v-if="currentCategory && currentCategory.level === 3" class="mt-2 flex items-center justify-between">
|
||||
<div v-if="currentCategory" class="mt-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<NInput v-model:value="searchText" placeholder="输入题目关键字在当前分类下搜索..." class="!w-80">
|
||||
<template #prefix>
|
||||
@ -227,7 +225,7 @@ function submitQuestion() {
|
||||
<div class="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<NEmpty description="暂无数据">
|
||||
<template #extra>
|
||||
请从左侧选择一个最后一级(三级)分类以管理题目数据
|
||||
请从左侧选择一个分类以管理题目数据
|
||||
</template>
|
||||
</NEmpty>
|
||||
</div>
|
||||
|
||||
@ -119,7 +119,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
||||
<NButton size="small" onClick={() => { }}>
|
||||
打印
|
||||
</NButton>
|
||||
<NPopconfirm onPositiveClick={() => handleDelete(row.id)}>
|
||||
<NPopconfirm onPositiveClick={() => handleDelete()}>
|
||||
{{
|
||||
default: () => '确认删除?',
|
||||
trigger: () => (
|
||||
@ -152,8 +152,8 @@ async function handleBatchDelete() {
|
||||
onBatchDeleted()
|
||||
}
|
||||
|
||||
function handleDelete(id: number) {
|
||||
onDeleted(id)
|
||||
function handleDelete() {
|
||||
onDeleted()
|
||||
}
|
||||
|
||||
function edit(id: number) {
|
||||
|
||||
144
apps/admin/src/views/user/data/mock.ts
Normal file
144
apps/admin/src/views/user/data/mock.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import type { CompetitionData } from '../types'
|
||||
import img from '@/assets/imgs/q5-img.png'
|
||||
|
||||
/**
|
||||
* 模拟数据
|
||||
* 真实后端数据可能是:
|
||||
* 活动详情接口,传活动id 获取 流程nodes
|
||||
* 每次可能只返回一个n1,前端先展示n1,用户点击“抽题”后,再请求n2。其中questions 可能不是在n1就返回,而是点击抽提时候 传nodeId 去随机获取后前端保存
|
||||
* 1. 包含 3 个节点(N1, N2, N3)
|
||||
* 2. 每个节点包含 2 个批次(汉字听一听, 汉字加一加)
|
||||
* 3. 每个批次包含 2 个问题(Q1, Q2, Q3, Q4, Q5)
|
||||
*/
|
||||
export const mockData: CompetitionData = {
|
||||
activityInfo: {
|
||||
name: '星·辞海遨游',
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
nodeId: 'N1',
|
||||
roundTitle: '第一轮',
|
||||
moduleName: '汉字听一听',
|
||||
batchName: '根据提示写汉字',
|
||||
description: '请根据汉语拼音提示书写正确的汉字,时间为15秒。',
|
||||
uiTemplate: 'TEMPLATE_DICTATION_HINT',
|
||||
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 1 },
|
||||
questions: [
|
||||
{
|
||||
id: 'Q1',
|
||||
content: { pinyin: 'jiū,表示小鸟的叫声', hint: '表示小鸟的叫声' },
|
||||
answer: ['啾'],
|
||||
},
|
||||
{
|
||||
id: 'Q2',
|
||||
content: { pinyin: 'yāo', hint: '形容草木茂盛美丽' },
|
||||
answer: ['夭'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
nodeId: 'N2',
|
||||
roundTitle: '第二轮',
|
||||
moduleName: '汉字听一听',
|
||||
batchName: '同音字',
|
||||
description: '请根据拼音书写同音字,时间为30秒。',
|
||||
uiTemplate: 'TEMPLATE_DICTATION_HOMOPHONE',
|
||||
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 1 },
|
||||
questions: [
|
||||
{
|
||||
id: 'Q3',
|
||||
content: { mainText: 'táng' },
|
||||
answer: ['唐', '塘', '糖', '搪', '溏', '瑭', '鄌', '螗', '糖', '堂', '膛', '螳', '鄳', '樘', '镗', '棠', '饧'],
|
||||
},
|
||||
{
|
||||
id: 'Q4',
|
||||
content: { mainText: 'jù' },
|
||||
answer: ['剧', '据', '巨', '拒', '聚', '炬', '距', '惧', '具', '俱', '沮', '咀', '矩', '锯'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
nodeId: 'N3',
|
||||
roundTitle: '第三轮',
|
||||
moduleName: '汉字加一加',
|
||||
batchName: '部件组合',
|
||||
description: '请写出含有指定部件的汉字,时间为60秒。',
|
||||
uiTemplate: 'TEMPLATE_COMPONENT_ADD',
|
||||
config: { timeLimit: 5, questionCount: 1, scorePerQuestion: 1 },
|
||||
questions: [
|
||||
{
|
||||
id: 'Q5',
|
||||
content: { component: '车' },
|
||||
answer: ['辆', '轿', '转', '软', '轮', '辐', '输', '轨', '轩', '轻', '轶', '辄', '轴', '轧', '轼', '辊', '辕', '辙', '辍', '辗', '轸', '轭', '辚', '辖', '较', '辘', '轫', '轵', '辋', '轲', '轺', '臻', '软', '辌', '轷', '轻', '辁', '辒', '蚺', '韫', '轱', '辂', '轻', '轹', '辅', '舻', '辐', '斩', '连', '阵', '军', '轰', '库', '辈', '载', '辈', '晕', '辔'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
nodeId: 'N4',
|
||||
roundTitle: '第四轮',
|
||||
moduleName: '词语听写',
|
||||
batchName: '拼音写词',
|
||||
description: '请根据汉语拼音提示书写正确的词语,时间为30秒。',
|
||||
uiTemplate: 'TEMPLATE_WORD_DICTATION',
|
||||
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 2 },
|
||||
questions: [
|
||||
{
|
||||
id: 'Q6',
|
||||
content: { pinyin: 'zhì rè', hint: '形容温度极高' },
|
||||
answer: ['炙热'],
|
||||
},
|
||||
{
|
||||
id: 'Q7',
|
||||
content: { pinyin: 'hào hàn', hint: '形容广大繁多' },
|
||||
answer: ['浩瀚'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
nodeId: 'N5',
|
||||
roundTitle: '第五轮',
|
||||
moduleName: '成语写一写',
|
||||
batchName: '文字要求',
|
||||
description: '请根据要求书写正确的成语,时间为60秒。',
|
||||
uiTemplate: 'TEMPLATE_IDIOM_TEXT_REQ',
|
||||
config: { timeLimit: 5, questionCount: 1, scorePerQuestion: 1 },
|
||||
questions: [
|
||||
{
|
||||
id: 'Q8',
|
||||
content: { requirement: '请写出含有反义字的四字成语。' },
|
||||
answer: ['七上八下', '前因后果', '大同小异', '东张西望', '左顾右盼', '南辕北辙', '深入浅出', '出生入死', '死去活来', '天翻地覆', '黑白分明'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
nodeId: 'N6',
|
||||
roundTitle: '第六轮',
|
||||
moduleName: '成语写一写',
|
||||
batchName: '看图写成语',
|
||||
description: '请根据图片书写正确的成语,时间为30秒。',
|
||||
uiTemplate: 'TEMPLATE_IDIOM_IMAGE',
|
||||
config: { timeLimit: 30, questionCount: 2, scorePerQuestion: 2 },
|
||||
questions: [
|
||||
{
|
||||
id: 'Q9',
|
||||
content: { hint: '比喻事情已定', image: img },
|
||||
answer: ['板上钉钉'],
|
||||
},
|
||||
{
|
||||
id: 'Q10',
|
||||
content: { hint: '比喻事情已定', image: img },
|
||||
answer: ['板上钉钉'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const uiTemplatesMapping = {
|
||||
TEMPLATE_DICTATION_HINT: '汉字听写-提示',
|
||||
TEMPLATE_DICTATION_HOMOPHONE: '汉字听写-同音',
|
||||
TEMPLATE_COMPONENT_ADD: '汉字加一加',
|
||||
TEMPLATE_WORD_DICTATION: '词语听写',
|
||||
TEMPLATE_IDIOM_TEXT_REQ: '成语-文字要求',
|
||||
TEMPLATE_IDIOM_IMAGE: '成语-看图',
|
||||
}
|
||||
BIN
apps/admin/src/views/user/data/流程.png
Normal file
BIN
apps/admin/src/views/user/data/流程.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
168
apps/admin/src/views/user/index.vue
Normal file
168
apps/admin/src/views/user/index.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { mockData } from './data/mock' // 引入模拟数据
|
||||
import AnswerAnalysisComp from './modules/AnswerAnalysisComp.vue'
|
||||
import CoverComp from './modules/CoverComp.vue' // 封面组件
|
||||
import GameComp from './modules/GameComp.vue'
|
||||
import IntroComp from './modules/IntroComp.vue'
|
||||
|
||||
// 状态定义
|
||||
type Status = 'COVER' | 'INTRO' | 'GAME' | 'ANSWER_ANALYSIS'
|
||||
|
||||
const status = ref<Status>('COVER')
|
||||
const data = ref(mockData)
|
||||
|
||||
// 索引控制
|
||||
const currentStep = ref(0) // Node 索引
|
||||
const subStep = ref(0) // Question 索引
|
||||
|
||||
// 定时器控制
|
||||
const timeLeft = ref(0)
|
||||
let timerInterval: number | null = null
|
||||
|
||||
// --- 计算属性 ---
|
||||
const currentNode = computed(() => data.value.nodes[currentStep.value])
|
||||
const currentQuestion = computed(() => {
|
||||
// 保护性代码:防止越界
|
||||
const questions = currentNode.value.questions
|
||||
return questions[subStep.value] || questions[0]
|
||||
})
|
||||
|
||||
// --- 动作处理 ---
|
||||
|
||||
// 1. 开始比赛 -> 去第一个环节的介绍页
|
||||
function handleStart() {
|
||||
currentStep.value = 0
|
||||
subStep.value = 0
|
||||
status.value = 'INTRO'
|
||||
}
|
||||
|
||||
// 2. 抽题 -> 去答题页,重置时间
|
||||
function handleDraw() {
|
||||
status.value = 'GAME'
|
||||
timeLeft.value = currentNode.value.config.timeLimit
|
||||
startTimer()
|
||||
}
|
||||
|
||||
// 3. 定时器逻辑
|
||||
function startTimer() {
|
||||
if (timerInterval)
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = window.setInterval(() => {
|
||||
if (timeLeft.value > 0) {
|
||||
timeLeft.value--
|
||||
}
|
||||
else {
|
||||
// 时间到,清除定时器,进入答题图解页面
|
||||
if (timerInterval)
|
||||
clearInterval(timerInterval)
|
||||
status.value = 'ANSWER_ANALYSIS'
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 4. 下一题逻辑 (核心流程)
|
||||
function handleToAnalysis() {
|
||||
if (timerInterval)
|
||||
clearInterval(timerInterval)
|
||||
status.value = 'ANSWER_ANALYSIS'
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (timerInterval)
|
||||
clearInterval(timerInterval)
|
||||
|
||||
const totalQuestionsInNode = currentNode.value.config.questionCount
|
||||
|
||||
// A. 如果当前环节还有题
|
||||
if (subStep.value < totalQuestionsInNode - 1 && subStep.value < currentNode.value.questions.length - 1) {
|
||||
subStep.value++
|
||||
// 回到介绍页(抽题页),等待用户点击“抽题”再次开始
|
||||
status.value = 'INTRO'
|
||||
}
|
||||
// B. 当前环节结束,去下一个环节
|
||||
else {
|
||||
if (currentStep.value < data.value.nodes.length - 1) {
|
||||
currentStep.value++
|
||||
subStep.value = 0
|
||||
status.value = 'INTRO' // 回到介绍页
|
||||
}
|
||||
else {
|
||||
// 全部结束
|
||||
window.$message?.success('全场比赛结束!')
|
||||
status.value = 'COVER'
|
||||
currentStep.value = 0
|
||||
subStep.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timerInterval)
|
||||
clearInterval(timerInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-app-container relative h-screen w-full overflow-hidden font-sans">
|
||||
<!-- 背景图占位 (实际项目中应为 img 或 background-image) -->
|
||||
<div class="absolute inset-0 z-0 bg-blue-100/50">
|
||||
<!-- 模拟云纹/山水背景 -->
|
||||
<div class="absolute bottom-0 h-1/3 w-full from-teal-200/50 to-transparent bg-gradient-to-t" />
|
||||
<div class="absolute top-0 h-1/4 w-full from-blue-200/50 to-transparent bg-gradient-to-b" />
|
||||
</div>
|
||||
|
||||
<!-- 顶部左右信息 (固定) -->
|
||||
<div class="absolute left-6 top-4 z-20 text-lg text-gray-700 font-medium tracking-wide">
|
||||
教育学会
|
||||
</div>
|
||||
<div class="absolute right-6 top-4 z-20 text-lg text-gray-700 font-medium tracking-wide">
|
||||
树人研究院
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="relative z-10 h-full w-full">
|
||||
<!-- 状态 1: 封面 -->
|
||||
<CoverComp
|
||||
v-if="status === 'COVER'"
|
||||
:title="data.activityInfo.name"
|
||||
:nodes="data.nodes"
|
||||
@start="handleStart"
|
||||
/>
|
||||
|
||||
<!-- 状态 2: 介绍页 -->
|
||||
<IntroComp
|
||||
v-else-if="status === 'INTRO'"
|
||||
:node="currentNode"
|
||||
@back="status = 'COVER'"
|
||||
@draw="handleDraw"
|
||||
/>
|
||||
|
||||
<!-- 状态 3: 答题页 -->
|
||||
<GameComp
|
||||
v-else-if="status === 'GAME'"
|
||||
:node="currentNode"
|
||||
:question="currentQuestion"
|
||||
:sub-step="subStep"
|
||||
:time-left="timeLeft"
|
||||
@back="status = 'INTRO'"
|
||||
@next="handleToAnalysis"
|
||||
/>
|
||||
|
||||
<!-- 状态 4: 答题图解页 -->
|
||||
<AnswerAnalysisComp
|
||||
v-else-if="status === 'ANSWER_ANALYSIS'"
|
||||
:question-id="currentQuestion.id"
|
||||
@back="status = 'GAME'"
|
||||
@next="handleNext"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-app-container {
|
||||
/* 基础背景色,防止图片加载失败时太突兀 */
|
||||
background-color: #eef7ff;
|
||||
}
|
||||
</style>
|
||||
166
apps/admin/src/views/user/modules/AnswerAnalysisComp.vue
Normal file
166
apps/admin/src/views/user/modules/AnswerAnalysisComp.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
questionId?: string
|
||||
}>()
|
||||
|
||||
defineEmits(['back', 'next'])
|
||||
|
||||
const zoomedImage = ref<string | null>(null)
|
||||
|
||||
// Mock team data
|
||||
const teams = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '第一队',
|
||||
writtenCount: 16,
|
||||
correctCount: 13,
|
||||
score: 13,
|
||||
manualScore: null as number | null,
|
||||
image: 'https://via.placeholder.com/300x400?text=Team+1+Answer', // Placeholder for now
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '第二队',
|
||||
writtenCount: 15,
|
||||
correctCount: 12,
|
||||
score: 12,
|
||||
manualScore: null as number | null,
|
||||
image: 'https://via.placeholder.com/300x400?text=Team+2+Answer',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '第三队',
|
||||
writtenCount: 18,
|
||||
correctCount: 15,
|
||||
score: 15,
|
||||
manualScore: null as number | null,
|
||||
image: 'https://via.placeholder.com/300x400?text=Team+3+Answer',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '第四队',
|
||||
writtenCount: 14,
|
||||
correctCount: 10,
|
||||
score: 10,
|
||||
manualScore: null as number | null,
|
||||
image: 'https://via.placeholder.com/300x400?text=Team+4+Answer',
|
||||
},
|
||||
])
|
||||
|
||||
function handleZoom(img: string) {
|
||||
zoomedImage.value = img
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full w-full flex flex-col items-center px-12 pt-28">
|
||||
<!-- Top Bar -->
|
||||
<div class="relative z-10 mb-6 w-full flex items-center justify-between">
|
||||
<!-- Back Button -->
|
||||
<button
|
||||
class="border-2 border-red-400 rounded-full bg-white px-8 py-2 text-lg text-red-500 font-bold shadow-md transition hover:bg-red-50"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
|
||||
<!-- Title Scroll -->
|
||||
<div class="absolute left-1/2 top-0 -translate-x-1/2">
|
||||
<div class="relative h-20 min-w-[300px] flex items-center justify-center border-2 border-orange-200 rounded-lg from-orange-100 to-orange-50 bg-gradient-to-b px-12 shadow-lg">
|
||||
<div class="absolute top-1/2 h-24 w-6 border-r-4 border-yellow-600 rounded-l-md bg-teal-700 shadow-md -left-3 -translate-y-1/2">
|
||||
<div class="absolute left-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
<div class="absolute bottom-2 left-1 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
</div>
|
||||
<div class="absolute top-1/2 h-24 w-6 border-l-4 border-yellow-600 rounded-r-md bg-teal-700 shadow-md -right-3 -translate-y-1/2">
|
||||
<div class="absolute right-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
<div class="absolute bottom-2 right-1 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
</div>
|
||||
<h1 class="text-3xl text-gray-800 font-bold tracking-widest font-serif">
|
||||
答题图解
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Button -->
|
||||
<button
|
||||
class="border-2 border-red-400 rounded-full bg-white px-8 py-2 text-lg text-red-500 font-bold shadow-md transition hover:bg-red-50"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
下一题
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="w-full flex flex-col flex-1 overflow-hidden">
|
||||
<div class="mb-2 text-xl text-red-600 font-bold">
|
||||
备注:由AI模型评判
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 h-full gap-6 pb-8">
|
||||
<div v-for="team in teams" :key="team.id" class="flex flex-col border-2 border-gray-200 rounded-xl bg-white/90 p-4 shadow-sm">
|
||||
<div class="mb-2 text-xl text-gray-800 font-bold">
|
||||
{{ team.name }}
|
||||
</div>
|
||||
|
||||
<!-- Answer Image Area -->
|
||||
<div class="relative mb-4 flex-1 cursor-zoom-in overflow-hidden border border-gray-300 rounded bg-gray-50" @click="handleZoom(team.image)">
|
||||
<!-- Grid Background Simulation -->
|
||||
<div class="pointer-events-none absolute inset-0 grid grid-cols-8 gap-px bg-gray-200 opacity-20">
|
||||
<div v-for="n in 64" :key="n" class="bg-white" />
|
||||
</div>
|
||||
<!-- Simulated Handwriting/Content -->
|
||||
<div class="absolute inset-0 flex items-center justify-center text-gray-400">
|
||||
(点击放大查看详情)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats & Score -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-gray-700 font-medium">
|
||||
写了数量{{ team.writtenCount }},正确{{ team.correctCount }}个,积分{{ team.score }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-red-500 font-bold">评委当场评</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="team.manualScore"
|
||||
type="number"
|
||||
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
placeholder="留个输入框,修改正确积分"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Modal -->
|
||||
<div v-if="zoomedImage" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" @click="zoomedImage = null">
|
||||
<div class="relative max-h-[90vh] max-w-[90vw] p-4">
|
||||
<img :src="zoomedImage" class="max-h-full max-w-full rounded bg-white shadow-2xl" alt="Zoomed Answer">
|
||||
<div class="absolute bottom-4 left-1/2 text-sm text-white/80 -translate-x-1/2">
|
||||
点击任意处关闭
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom Scrollbar for content if needed */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
81
apps/admin/src/views/user/modules/CoverComp.vue
Normal file
81
apps/admin/src/views/user/modules/CoverComp.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActivityNode } from '../types'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
nodes: ActivityNode[]
|
||||
}>()
|
||||
|
||||
defineEmits(['start'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full flex flex-col items-center pt-24">
|
||||
<!-- 标题卷轴 -->
|
||||
<div class="relative mb-20">
|
||||
<!-- 卷轴主体 -->
|
||||
<div class="relative z-10 h-24 min-w-[400px] flex items-center justify-center border-2 border-orange-200 rounded-lg from-orange-100 to-orange-50 bg-gradient-to-b px-12 shadow-lg">
|
||||
<div class="absolute top-1/2 h-28 w-8 border-r-4 border-yellow-600 rounded-l-md bg-teal-700 shadow-md -left-4 -translate-y-1/2">
|
||||
<!-- 卷轴轴头 -->
|
||||
<div class="absolute left-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
<div class="absolute bottom-2 left-1 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
</div>
|
||||
<div class="absolute top-1/2 h-28 w-8 border-l-4 border-yellow-600 rounded-r-md bg-teal-700 shadow-md -right-4 -translate-y-1/2">
|
||||
<!-- 卷轴轴头 -->
|
||||
<div class="absolute right-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
<div class="absolute bottom-2 right-1 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
</div>
|
||||
|
||||
<!-- 飘带装饰 -->
|
||||
<div class="absolute h-12 w-24 rotate-12 transform rounded-full bg-green-200/50 blur-xl -right-12 -top-6 -z-10" />
|
||||
<div class="absolute h-12 w-24 transform rounded-full bg-green-200/50 blur-xl -bottom-6 -left-12 -z-10 -rotate-12" />
|
||||
|
||||
<h1 class="text-4xl text-gray-800 font-bold tracking-widest font-serif">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片列表 -->
|
||||
<div class="grid grid-cols-4 max-w-7xl w-full gap-8 px-12">
|
||||
<div
|
||||
v-for="(node) in nodes.slice(0, 4)"
|
||||
:key="node.nodeId"
|
||||
class="group relative cursor-default"
|
||||
>
|
||||
<!-- 卡片背景 (仿古风) -->
|
||||
<div class="relative aspect-[3/4] w-full flex flex-col items-center justify-center overflow-hidden border-4 border-orange-200 rounded-2xl bg-orange-50/80 shadow-lg transition-transform hover:-translate-y-2">
|
||||
<!-- 内部装饰圈 -->
|
||||
<div class="absolute inset-4 flex items-center justify-center border-2 border-orange-300/50 rounded-xl border-dashed">
|
||||
<!-- 云纹装饰 (CSS模拟) -->
|
||||
<div class="absolute h-12 w-12 rounded-full bg-teal-100/30 blur-md -left-4 -top-4" />
|
||||
<div class="absolute h-12 w-12 rounded-full bg-teal-100/30 blur-md -bottom-4 -right-4" />
|
||||
</div>
|
||||
|
||||
<!-- 文字内容 -->
|
||||
<div class="relative z-10 transform text-center -rotate-2">
|
||||
<div class="text-3xl text-gray-800 font-bold leading-tight font-serif drop-shadow-sm">
|
||||
<!-- 强制换行处理,每两个字换行或根据长度 -->
|
||||
<span class="block">{{ node.moduleName.slice(0, 2) }}</span>
|
||||
<span class="block">{{ node.moduleName.slice(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 准备开始按钮 -->
|
||||
<div class="fixed bottom-12 right-12">
|
||||
<button
|
||||
class="border-2 border-red-200 rounded-full from-red-400 to-red-500 bg-gradient-to-r px-10 py-3 text-xl text-white font-bold shadow-lg transition active:scale-95 hover:from-red-500 hover:to-red-600"
|
||||
@click="$emit('start')"
|
||||
>
|
||||
准备开始
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加一些古风字体或特定样式 */
|
||||
</style>
|
||||
148
apps/admin/src/views/user/modules/GameComp.vue
Normal file
148
apps/admin/src/views/user/modules/GameComp.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActivityNode, Question } from '../types'
|
||||
import { computed } from 'vue'
|
||||
import { uiTemplatesMapping } from '../data/mock'
|
||||
import QuestionRenderer from './QuestionRenderer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
node: ActivityNode
|
||||
question: Question
|
||||
subStep: number
|
||||
timeLeft: number
|
||||
}>()
|
||||
|
||||
defineEmits(['back', 'next'])
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const m = Math.floor(props.timeLeft / 60)
|
||||
const s = props.timeLeft % 60
|
||||
return `${m}:${s < 10 ? `0${s}` : s}`
|
||||
})
|
||||
|
||||
// Mock chart data
|
||||
const chartData = [
|
||||
{ group: '第一组', total: 42, correct: 36 },
|
||||
{ group: '第二组', total: 20, correct: 16 },
|
||||
{ group: '第三组', total: 19, correct: 19 },
|
||||
{ group: '第四组', total: 17, correct: 14 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full w-full flex flex-col items-center px-12 pt-28">
|
||||
<!-- 顶部栏 -->
|
||||
<div class="relative z-10 mb-8 w-full flex items-center justify-between">
|
||||
<!-- 返回按钮 -->
|
||||
<button
|
||||
class="border-2 border-red-400 rounded-full bg-white px-8 py-2 text-lg text-red-500 font-bold shadow-md transition hover:bg-red-50"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
|
||||
<!-- 标题卷轴 -->
|
||||
<div class="absolute left-1/2 top-0 -translate-x-1/2">
|
||||
<div
|
||||
class="relative h-20 min-w-[300px] flex items-center justify-center border-2 border-orange-200 rounded-lg from-orange-100 to-orange-50 bg-gradient-to-b px-12 shadow-lg"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1/2 h-24 w-6 border-r-4 border-yellow-600 rounded-l-md bg-teal-700 shadow-md -left-3 -translate-y-1/2"
|
||||
>
|
||||
<div class="absolute left-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
<div class="absolute bottom-2 left-1 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-1/2 h-24 w-6 border-l-4 border-yellow-600 rounded-r-md bg-teal-700 shadow-md -right-3 -translate-y-1/2"
|
||||
>
|
||||
<div class="absolute right-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
<div class="absolute bottom-2 right-1 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
</div>
|
||||
<h1 class="text-3xl text-gray-800 font-bold tracking-widest font-serif">
|
||||
{{ node.moduleName }}
|
||||
<span class="text-sm text-red-500 font-bold">({{ uiTemplatesMapping[node.uiTemplate] }})</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 倒计时 -->
|
||||
<div class="border-2 border-red-400 rounded-full bg-white px-6 py-2 shadow-md">
|
||||
<span class="text-xl text-red-500 font-bold">倒计时 {{ formattedTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 题目说明 -->
|
||||
<div class="m-14 text-2xl text-gray-800 font-medium">
|
||||
{{ node.description }}
|
||||
</div>
|
||||
|
||||
<!-- 题目内容区域 -->
|
||||
<div class="flex-2 mb-4 w-full flex items-center justify-center">
|
||||
<QuestionRenderer
|
||||
:template="node.uiTemplate" :content="question.content"
|
||||
class="origin-center scale-150 transform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的 Next 按钮 (点击题目区域或键盘操作,这里为了演示保留一个透明层或仅通过逻辑触发,或者暂时放一个不易察觉的按钮用于测试) -->
|
||||
<div class="absolute inset-0 z-0" @click="$emit('next')" /> <!-- 点击背景切换下一题,方便演示 -->
|
||||
|
||||
<!-- 底部图表区域 -->
|
||||
<div
|
||||
class="relative z-20 mb-8 h-64 max-w-5xl w-full flex flex-col border-2 border-blue-300 rounded-xl bg-white/90 p-4 shadow-lg backdrop-blur"
|
||||
>
|
||||
<!-- 图表标题栏 -->
|
||||
<div class="mb-4 flex items-center justify-between px-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rotate-45 bg-orange-400" />
|
||||
<span class="text-lg text-blue-800 font-bold">答题进度</span>
|
||||
</div>
|
||||
<div class="flex gap-6 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded-full bg-blue-400" />
|
||||
<span class="text-gray-600">答题数量</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded-full bg-orange-300" />
|
||||
<span class="text-gray-600">正确数量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 柱状图 -->
|
||||
<div class="flex flex-1 items-end justify-around px-8 pb-2">
|
||||
<div v-for="item in chartData" :key="item.group" class="w-16 flex flex-col items-center gap-2">
|
||||
<div class="h-32 flex items-end gap-2">
|
||||
<!-- Blue Bar -->
|
||||
<div
|
||||
class="relative w-6 rounded-t-md from-blue-500 to-blue-300 bg-gradient-to-t transition-all duration-1000"
|
||||
:style="{ height: `${item.total * 2}px` }"
|
||||
>
|
||||
<span class="absolute left-1/2 text-blue-800 font-bold -top-6 -translate-x-1/2">{{ item.total }}</span>
|
||||
</div>
|
||||
<!-- Orange Bar -->
|
||||
<div
|
||||
class="relative w-6 rounded-t-md from-orange-400 to-orange-200 bg-gradient-to-t transition-all duration-1000"
|
||||
:style="{ height: `${item.correct * 2}px` }"
|
||||
>
|
||||
<span class="absolute left-1/2 text-orange-600 font-bold -top-6 -translate-x-1/2">{{ item.correct
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-blue-800 font-bold">
|
||||
{{ item.group }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Question Renderer Override if needed */
|
||||
:deep(.question-content) {
|
||||
font-size: 4rem;
|
||||
color: #ef4444;
|
||||
/* red-500 */
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
63
apps/admin/src/views/user/modules/IntroComp.vue
Normal file
63
apps/admin/src/views/user/modules/IntroComp.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActivityNode } from '../types'
|
||||
|
||||
defineProps<{ node: ActivityNode }>()
|
||||
defineEmits(['back', 'draw'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full flex flex-col items-center pt-44">
|
||||
<!-- 标题卷轴 -->
|
||||
<div class="relative mb-16">
|
||||
<div class="relative z-10 h-20 min-w-[300px] flex items-center justify-center border-2 border-orange-200 rounded-lg from-orange-100 to-orange-50 bg-gradient-to-b px-12 shadow-lg">
|
||||
<div class="absolute top-1/2 h-24 w-6 border-r-4 border-yellow-600 rounded-l-md bg-teal-700 shadow-md -left-3 -translate-y-1/2">
|
||||
<div class="absolute left-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
<div class="absolute bottom-2 left-1 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
</div>
|
||||
<div class="absolute top-1/2 h-24 w-6 border-l-4 border-yellow-600 rounded-r-md bg-teal-700 shadow-md -right-3 -translate-y-1/2">
|
||||
<div class="absolute right-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
<div class="absolute bottom-2 right-1 h-2 w-full rounded-full bg-teal-600/50" />
|
||||
</div>
|
||||
<h1 class="text-3xl text-gray-800 font-bold tracking-widest font-serif">
|
||||
{{ node.moduleName }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规则描述 -->
|
||||
<div class="mb-20 max-w-4xl text-center">
|
||||
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
|
||||
{{ node.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 抽题按钮 (大圆形) -->
|
||||
<div class="group relative">
|
||||
<button
|
||||
class="h-64 w-64 flex flex-col items-center justify-center border-4 border-blue-400 rounded-full bg-gray-200/80 text-3xl text-gray-700 font-bold shadow-xl transition-all active:scale-95 hover:scale-105"
|
||||
@click="$emit('draw')"
|
||||
>
|
||||
<span>抽题</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="fixed bottom-12 left-12">
|
||||
<button
|
||||
class="border-2 border-red-400 rounded-full bg-white px-10 py-2 text-xl text-red-500 font-bold shadow-md transition hover:bg-red-50"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-12 right-12">
|
||||
<button
|
||||
class="rounded-full from-red-400 to-red-500 bg-gradient-to-r px-10 py-2 text-xl text-white font-bold shadow-lg transition hover:from-red-500 hover:to-red-600"
|
||||
@click="$emit('draw')"
|
||||
>
|
||||
开始答题
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
96
apps/admin/src/views/user/modules/QuestionRenderer.vue
Normal file
96
apps/admin/src/views/user/modules/QuestionRenderer.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import type { QuestionContent, UiTemplateType } from '../types'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
template: UiTemplateType
|
||||
content: QuestionContent
|
||||
}>()
|
||||
|
||||
const pinyinChars = computed(() => {
|
||||
if (props.template === 'TEMPLATE_WORD_DICTATION' && props.content.pinyin) {
|
||||
return props.content.pinyin.split(' ')
|
||||
}
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[400px] w-full flex items-center justify-center p-10">
|
||||
<!-- 模板A: 汉字听写-提示 (拼音+提示) -->
|
||||
<div v-if="template === 'TEMPLATE_DICTATION_HINT'" class="text-center">
|
||||
<div class="mb-6 inline-block text-[120px] text-red-500 font-bold leading-none" style="text-shadow: 2px 2px 4px rgba(0,0,0,0.1);">
|
||||
“{{ content.pinyin }}”
|
||||
</div>
|
||||
<!-- <div class="text-4xl text-gray-800 font-bold">
|
||||
{{ content.hint }}
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<!-- 模板B: 汉字听写-同音字 (大字) -->
|
||||
<div v-else-if="template === 'TEMPLATE_DICTATION_HOMOPHONE'" class="text-center">
|
||||
<div class="text-[100px] text-red-600 font-bold">
|
||||
“{{ content.mainText }}”
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模板C: 汉字加一加 (提示+部件) -->
|
||||
<div v-else-if="template === 'TEMPLATE_COMPONENT_ADD'" class="text-center">
|
||||
<div class="text-[100px] text-red-600 font-bold">
|
||||
{{ content.component }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模板D: 词语听写 (拼音+提示) -->
|
||||
<div v-else-if="template === 'TEMPLATE_WORD_DICTATION'" class="flex flex-col items-center">
|
||||
<div class="mb-8 flex flex-wrap justify-center gap-6">
|
||||
<div
|
||||
v-for="(char, index) in pinyinChars"
|
||||
:key="index"
|
||||
class="relative h-32 w-32 flex select-none items-center justify-center border-2 border-red-500 bg-white"
|
||||
>
|
||||
<!-- 米字格背景 -->
|
||||
<div class="pointer-events-none absolute inset-0 z-0 h-full w-full">
|
||||
<!-- 横虚线 -->
|
||||
<div class="absolute left-0 top-1/2 h-[1px] w-full border-t border-red-400 border-dashed opacity-60" />
|
||||
<!-- 竖虚线 -->
|
||||
<div class="absolute left-1/2 top-0 h-full w-[1px] border-l border-red-400 border-dashed opacity-60" />
|
||||
<!-- 斜虚线 (SVG) -->
|
||||
<svg width="100%" height="100%" class="absolute inset-0 opacity-60">
|
||||
<line x1="0" y1="0" x2="100%" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
|
||||
<line x1="100%" y1="0" x2="0" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 文字 -->
|
||||
<span class="z-10 text-5xl text-gray-800 font-bold font-sans">{{ char }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模板E: 成语-文字要求 -->
|
||||
<div v-else-if="template === 'TEMPLATE_IDIOM_TEXT_REQ'" class="text-center">
|
||||
<div class="mb-8 text-4xl text-gray-800 font-bold">
|
||||
{{ content.requirement }}
|
||||
</div>
|
||||
<!-- <div class="rounded bg-gray-100 px-4 py-2 text-xl text-gray-500">
|
||||
示例:{{ content.example }}
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<!-- 模板F: 成语-看图 (使用Div占位) -->
|
||||
<div v-else-if="template === 'TEMPLATE_IDIOM_IMAGE'" class="flex flex-col items-center text-center">
|
||||
<div class="mb-6 h-60 w-80 flex items-center justify-center">
|
||||
<!-- <span class="text-gray-400">图片展示区域<br></span> -->
|
||||
<img :src="content.image" alt="idiom image" class="h-full w-full object-contain">
|
||||
</div>
|
||||
<!-- <div class="text-xl text-gray-600 font-bold">
|
||||
提示:{{ content.hint }}
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div v-else class="text-red-500">
|
||||
未知的题目模板: {{ template }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
41
apps/admin/src/views/user/readme.md
Normal file
41
apps/admin/src/views/user/readme.md
Normal file
@ -0,0 +1,41 @@
|
||||
### 一、 文字版流程说明
|
||||
|
||||
1. 封面 (COVER)
|
||||
- 动作 :点击“开始比赛”按钮。
|
||||
- 跳转 :进入第一轮的【抽题/介绍页面】。
|
||||
|
||||
2. 抽题/介绍页面 (INTRO)
|
||||
- 展示 :当前环节名称(如“汉字听一听”)、规则说明、大圆形的“抽题”按钮。
|
||||
- 动作 :点击“抽题”或“开始答题”按钮。
|
||||
- 跳转 :进入当前题目的【答题页面】,同时开始倒计时。
|
||||
|
||||
3. 答题页面 (GAME)
|
||||
- 展示 :题目内容(如拼音、图片)、倒计时、实时答题进度图表。
|
||||
- 触发条件 A :倒计时归零。
|
||||
- 触发条件 B :点击隐藏的“下一题”区域(手动结束)。
|
||||
- 跳转 :停止计时,进入当前题目的【答题图解页面】。
|
||||
|
||||
4. 答题图解页面 (ANSWER_ANALYSIS)
|
||||
- 展示 :4个战队的答题原笔迹(支持点击放大)、AI评分结果、人工改分输入框。
|
||||
- 动作 :点击右上角“下一题”按钮。
|
||||
- 逻辑判断 :
|
||||
- 情况 A(本轮还有题) :索引指向下一题 -> 回到【抽题/介绍页面】 (准备抽取下一题)。
|
||||
- 情况 B(本轮已结束,还有下一轮) :索引指向下一轮第一题 -> 回到【抽题/介绍页面】 (展示新环节介绍)。
|
||||
- 情况 C(全场结束) :提示“全场比赛结束” -> 回到【封面】 。
|
||||
|
||||
阅读之星
|
||||
feat(user): 新增用户端答题流程页面与相关功能
|
||||
|
||||
- 新增用户端答题流程主页面,包含封面、介绍、答题、答题图解四个状态
|
||||
|
||||
- 新增用户端组件:封面、介绍页、答题页、答题图解页、题目渲染器
|
||||
|
||||
- 新增用户端类型定义、模拟数据及业务常量
|
||||
|
||||
- 新增用户路由及类型声明
|
||||
|
||||
- 优化题库分类树,改为扁平化列表结构
|
||||
|
||||
- 完善比赛创建功能,增加轮次类型、题目分数类型及关联AP字段
|
||||
|
||||
- 修复模板删除函数调用参数错误
|
||||
47
apps/admin/src/views/user/types.ts
Normal file
47
apps/admin/src/views/user/types.ts
Normal file
@ -0,0 +1,47 @@
|
||||
export type UiTemplateType =
|
||||
| 'TEMPLATE_DICTATION_HINT' // 汉字听写-提示 (jiu)
|
||||
| 'TEMPLATE_DICTATION_HOMOPHONE' // 汉字听写-同音 (tang)
|
||||
| 'TEMPLATE_COMPONENT_ADD' // 汉字加一加 (车)
|
||||
| 'TEMPLATE_WORD_DICTATION' // 词语听写 (zhi re)
|
||||
| 'TEMPLATE_IDIOM_TEXT_REQ' // 成语-文字要求 (反义字)
|
||||
| 'TEMPLATE_IDIOM_IMAGE' // 成语-看图
|
||||
|
||||
export interface QuestionContent {
|
||||
pinyin?: string
|
||||
hint?: string
|
||||
mainText?: string // 大字展示
|
||||
component?: string // 部件
|
||||
requirement?: string // 题目要求
|
||||
example?: string // 例子
|
||||
image?: string // 图片路径
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string
|
||||
content: QuestionContent
|
||||
answer: string[]
|
||||
}
|
||||
|
||||
export interface NodeConfig {
|
||||
timeLimit: number
|
||||
questionCount: number
|
||||
scorePerQuestion: number
|
||||
}
|
||||
|
||||
export interface ActivityNode {
|
||||
nodeId: string
|
||||
roundTitle: string
|
||||
moduleName: string
|
||||
batchName: string
|
||||
description: string
|
||||
uiTemplate: UiTemplateType
|
||||
config: NodeConfig
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
export interface CompetitionData {
|
||||
activityInfo: {
|
||||
name: string
|
||||
}
|
||||
nodes: ActivityNode[]
|
||||
}
|
||||
Reference in New Issue
Block a user