feat: 新增题目导入服务并优化用户端答题流程

- 新增 question-importer 服务,支持从 JSON 文件批量导入题目到题库
- 重构用户端答题流程,整合抽题、答题、结果分析页面状态管理
- 将题目分类从 UI 模板类型切换为业务分类(question_category_name)
- 在题库管理页面支持题目批量选择和删除功能
- 优化用户端组别选择,增加“已结束”状态提示和禁用逻辑
- 修复题目新增/更新 API 请求参数格式问题
- 为富文本编辑器配置排除不必要的工具栏按键
This commit is contained in:
2026-02-11 17:27:08 +08:00
parent 61a7412fc1
commit f3136cd9ad
34 changed files with 4154 additions and 225 deletions

View File

@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetQuestionOutline } from '@/service/api/game'
import { fetchGetCurrentQuestion } from '@/service/api/game'
import { useActivityInfoStore } from '@/store/modules/activityinfo'
import { useCompetitionStore } from '@/store/modules/competition'
import { AudioController } from '@/utils/audio'
@ -14,40 +14,43 @@ const { routerPushByKey, routerBack } = useRouterPush()
const route = useRoute()
const activityInfoStore = useActivityInfoStore()
const activityId = computed(() => activityInfoStore.activityId || route.query?.activityId as string)
const groupsId = computed(() => activityInfoStore.groupId || route.query?.groupsId as string)
const teamId = computed(() => activityInfoStore.teamId || route.query?.teamId as string)
const teamIds = computed(() => route.query?.teamIds as string)
const store = useCompetitionStore()
const loading = ref(false)
const question = ref<Api.Competition.QuestionListRecord | null>(null)
const question = ref<Api.Competition.CurrentQuestionResponse | null>(null)
const flippedId = ref<number | null>(null)
const audioController = new AudioController()
// eslint-disable-next-line unused-imports/no-unused-vars
const pageDescription = computed(() => {
if (question.value) {
return question.value.QuestionSubTitle || ''
}
return ''
})
async function initQuestions() {
if (!activityId.value)
return
loading.value = true
try {
const { data: outlineData, error } = await fetchGetQuestionOutline(Number(activityId.value), 0)
const { data: outlineData, error } = await fetchGetCurrentQuestion({
ActivityID: Number(activityId.value),
GroupID: Number(groupsId.value),
RoundType: 0,
})
// eslint-disable-next-line no-console
console.log(outlineData, 'outlineData')
if (error) {
window?.$message?.error(error.message)
return
}
// 直接使用返回的对象数据,不进行数组转换
if (outlineData && outlineData.data) {
question.value = outlineData.data as unknown as Api.Competition.QuestionListRecord
store.setQuestionList([question.value]) // Store expects array, so wrap it
// const object = {
// ...outlineData.data?.Activity_Question || {},
// ...outlineData.data?.QuestionList || {},
// TeamGroup_QuestionID: outlineData.data?.TeamGroup_QuestionID || 0,
// }
question.value = outlineData.data as unknown as Api.Competition.CurrentQuestionResponse
store.setCurrentQuestionMainInfo(question.value)
}
}
catch (error) {
@ -63,7 +66,20 @@ onMounted(() => {
initQuestions()
})
async function handleCardClick(item: Api.Competition.QuestionListRecord) {
async function handleCardClick(item: Api.Competition.CurrentQuestionResponse) {
const { ID } = item?.Activity_Question || {}
if (!ID) {
window.$message?.error('题目ID不存在')
// 回到选组页面
routerPushByKey('user_groups', {
query: {
activityId: activityId.value,
},
})
return
}
if (flippedId.value)
return // 防止重复点击
@ -75,15 +91,21 @@ async function handleCardClick(item: Api.Competition.QuestionListRecord) {
catch (e) {
console.error('Audio play failed', e)
}
// 2. 触发翻转动画
flippedId.value = item.ID
flippedId.value = ID
// 3. 延迟1s后跳转
setTimeout(() => {
try {
store.setCurrentQuestionInfo(item)
routerPushByKey('user_game')
store.setCurrentQuestionInfo(item.Activity_Question || {})
routerPushByKey('user_game', {
query: {
activityId: activityId.value,
groupsId: groupsId.value,
teamId: teamId.value,
teamIds: teamIds.value,
},
})
}
catch (error) {
console.error('Navigation failed', error)
@ -101,16 +123,21 @@ async function handleCardClick(item: Api.Competition.QuestionListRecord) {
function handleBack() {
routerBack()
}
const questionMainTitle = computed(() => question.value?.QuestionList?.Name || '')
</script>
<template>
<CompetitionLayout :show-back="true" :show-next="false" :title="question?.ActitvityQuestionName || '抽题'" @back="handleBack">
<CompetitionLayout
:show-back="true" :show-next="false" :title="questionMainTitle || '抽题'"
@back="handleBack"
>
<div class="h-full w-full flex flex-col items-center pt-10 font-sans">
<div v-if="question" class="mb-10 max-w-4xl text-center">
<!-- <div v-if="question" class="mb-10 max-w-4xl text-center">
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
{{ question?.ActitvityQuestionName || '' }}
{{ questionMainTitle || '' }}
</div>
</div>
</div> -->
<!-- 题目卡片列表 -->
<div v-if="loading" class="text-xl text-gray-500">
@ -119,20 +146,22 @@ function handleBack() {
<div v-else class="w-full flex flex-1 items-center justify-center pb-10">
<div v-if="question" class="lottery-stick-container">
<div
class="lottery-stick"
:class="{ 'is-selected': flippedId === question.ID }"
class="lottery-stick" :class="{ 'is-selected': flippedId === question?.Activity_Question?.ID }"
@click="handleCardClick(question)"
>
<!-- 签身 (默认显示) -->
<div class="stick-face stick-body">
<div class="stick-top-mark" />
<div class="writing-mode-vertical-rl h-full flex items-center justify-center text-xl text-yellow-900 font-bold tracking-widest opacity-80">
<div
class="writing-mode-vertical-rl h-full flex items-center justify-center text-xl text-yellow-900 font-bold tracking-widest opacity-80"
>
</div>
</div>
<!-- 签面 (翻转后显示 - 实际上是放大显示的) -->
<div class="stick-face stick-content">
<span class="writing-mode-vertical-rl text-2xl text-red-600 font-bold">{{ question.ActitvityQuestionName }}</span>
<span class="writing-mode-vertical-rl text-2xl text-red-600 font-bold">{{ questionMainTitle
}}</span>
</div>
</div>
</div>
@ -190,7 +219,8 @@ function handleBack() {
left: 0;
width: 100%;
height: 20px;
background-color: #ef4444; /* red-500 */
background-color: #ef4444;
/* red-500 */
border-radius: 8px 8px 0 0;
}