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

@ -1,58 +1,131 @@
<script setup lang="ts">
import type { QuestionContent, UiTemplateType } from '../types'
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { QuestionCategoryEnum } from '@/enum/business'
import { useCompetitionStore } from '@/store/modules/competition'
const props = defineProps<{
template: UiTemplateType
content: QuestionContent
}>()
// const props = defineProps<{
// template: QuestionCategoryName
// content: QuestionContent
// }>()
const store = useCompetitionStore()
const currentQuestionMainInfo = computed(() => store.currentQuestionMainInfo)
const currentQuestionDetail = computed(() => store.currentQuestionDetail)
// console.log(currentQuestionMainInfo.value, 'currentQuestionMainInfo')
// 题目
const title = computed(() => currentQuestionMainInfo.value?.QuestionList?.QuestionContent)
// 归属大纲
// const mainTitle = computed(() => currentQuestionMainInfo.value?.QuestionList?.Name)
// 题目内容
const content = computed(() => currentQuestionDetail.value.Name)
// 模版类型
const template = computed(() => currentQuestionMainInfo.value?.Activity_Question?.UIType)
/**
* 组合后的标题(用于汉字加一加模板)
* 将 content 插入到 title 的引号中,并拆分以支持不同样式
*/
const parsedComponentAddTitle = computed(() => {
const t = title.value || ''
const c = content.value || ''
// 尝试替换全角空引号
if (t.includes('“”')) {
const [prefix, suffix] = t.split('“”')
return {
type: 'quote_full',
prefix,
content: c,
suffix,
}
}
// 尝试替换半角空引号
if (t.includes('""')) {
const [prefix, suffix] = t.split('""')
return {
type: 'quote_half',
prefix,
content: c,
suffix,
}
}
// 如果没有找到空引号,直接追加
return {
type: 'normal',
prefix: t,
content: c,
suffix: '',
}
})
/** 获取富文本里面的text */
const pinyinChars = computed(() => {
if (props.template === 'TEMPLATE_WORD_DICTATION' && props.content.title) {
if (template.value === QuestionCategoryEnum.WordDictation && content.value) {
// 创建临时元素提取HTML文本内容
const div = document.createElement('div')
div.innerHTML = props.content.title
div.innerHTML = content.value
const text = div.textContent || ''
return text.trim().split(/\s+/).filter(Boolean)
}
return []
})
watch(
() => [currentQuestionMainInfo, currentQuestionDetail],
() => {
// eslint-disable-next-line no-console
console.log('Current currentQuestionMainInfo:', currentQuestionMainInfo.value)
// eslint-disable-next-line no-console
console.log('Current currentQuestionMainInfo:', currentQuestionMainInfo)
},
{ deep: true, immediate: true },
)
</script>
<template>
<div class="min-h-[400px] w-full flex items-center justify-center p-6">
<div class="w-full flex p-6" style="border: 1px solid red;">
<!-- template:{{ template }} -->
<!-- 模板A: 汉字听写-提示 (拼音+提示) -->
<div v-if="template === 'TEMPLATE_DICTATION_HINT'" class="text-center">
<div
class="mb-6 inline-block text-5xl font-bold leading-none"
style="text-shadow: 2px 2px 4px rgba(0,0,0,0.1);"
>
<div class="rich-content" v-html="content.title" />
<!-- {{ content.title }} -->
<div v-if="template === QuestionCategoryEnum.ChineseCharacterDictation" class="text-center">
<div class="mb-6 inline-block text-5xl font-bold leading-none">
<div class="ptions-container1 text-2xl">
{{ title }}
</div>
<div class="rich-content1">
{{ content }}
</div>
</div>
<!-- <div class="text-4xl text-gray-800 font-bold">
{{ content.meta?.hint }}
</div> -->
</div>
<!-- 模板B: 汉字听写-同音字 (大字) -->
<div v-else-if="template === 'TEMPLATE_DICTATION_HOMOPHONE'" class="text-center">
<div class="homophone text-[100px] text-red-600 font-bold">
<!-- {{ content.title }} -->
<div class="rich-content" v-html="content.title" />
</div>
</div>
<!-- 暂时未匹配到枚举保持原样或删除 -->
<!-- <div v-else-if="template === 'TEMPLATE_DICTATION_HOMOPHONE'" class="text-center"> -->
<!-- 模板C: 汉字加一加 (提示+部件) -->
<div v-else-if="template === 'TEMPLATE_COMPONENT_ADD'" class="text-center">
<div class="text-[100px] text-red-600 font-bold">
<!-- {{ content.title }} -->
<div class="rich-content" v-html="content.title" />
<div v-else-if="template === QuestionCategoryEnum.CharacterRadicalAddition" class="text-center">
<div class="text-[50px] font-bold">
<template v-if="parsedComponentAddTitle.type === 'quote_full'">
<span class="text-black">{{ parsedComponentAddTitle.prefix }}</span>
<span class="text-red-600">{{ parsedComponentAddTitle.content }}</span>
<span class="text-black">{{ parsedComponentAddTitle.suffix }}</span>
</template>
<template v-else-if="parsedComponentAddTitle.type === 'quote_half'">
<span class="text-black">{{ parsedComponentAddTitle.prefix }}"</span>
<span class="text-red-600">{{ parsedComponentAddTitle.content }}</span>
<span class="text-black">"{{ parsedComponentAddTitle.suffix }}</span>
</template>
<template v-else>
<span class="text-black">{{ parsedComponentAddTitle.prefix }}</span>
<span class="ml-4 text-red-600">{{ parsedComponentAddTitle.content }}</span>
</template>
</div>
</div>
<!-- 模板D: 词语听写 (拼音+提示) -->
<div v-else-if="template === 'TEMPLATE_WORD_DICTATION'" class="flex flex-col items-center">
<div v-else-if="template === QuestionCategoryEnum.WordDictation" 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"
@ -78,69 +151,30 @@ const pinyinChars = computed(() => {
</div>
<!-- 模板E: 成语-文字要求 -->
<div v-else-if="template === 'TEMPLATE_IDIOM_TEXT_REQ'" class="text-center">
<div v-else-if="template === QuestionCategoryEnum.IdiomWriting" class="text-center">
<div class="mb-8 text-4xl text-gray-800 font-bold">
<!-- {{ content.title }} -->
<div class="rich-content" v-html="content.title" />
<div class="rich-content" v-html="content" />
</div>
<!-- <div class="rounded bg-gray-100 px-4 py-2 text-xl text-gray-500">
示例{{ content.meta?.example }}
</div> -->
</div>
<!-- 模板F: 成语-看图 (使用Div占位) -->
<div v-else-if="template === 'TEMPLATE_IDIOM_IMAGE'" class="flex flex-col items-center text-center">
<!-- 模板F: 诗词理解 -->
<!-- <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.titleImage" alt="idiom image" class="h-full w-full object-contain">
</div>
<!-- <div class="text-xl text-gray-600 font-bold">
提示{{ content.meta?.hint }}
</div> -->
</div>
</div> -->
<!-- 模板G: 诗词理解-选择题 (通用选择题模板) -->
<div v-else-if="template === 'TEMPLATE_POETRY_MULTIPLE_CHOICE'" class="w-full flex flex-col items-center">
<div class="mb-1 text-2xl text-gray-800 font-bold leading-relaxed">
{{ content.title }}
<div v-else-if="template === QuestionCategoryEnum.PoetryComprehension" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="options-container">
<div
v-for="(option, index) in content.options" :key="index" class="option-card group" :class="{
'is-mixed': !option.renderType || option.renderType === 'mixed' || option.renderType === 'image',
'is-text': option.renderType === 'text',
}"
>
<!-- 1. 图片渲染模式 -->
<div v-if="option.image" class="option-image-wrapper">
<img :src="option.image" :alt="option.label" class="option-image">
</div>
<!-- 2. 田字格渲染模式 -->
<div v-if="option.renderType === 'tianzige'" class="tianzige-wrapper">
<!-- 米字格背景 -->
<div class="tianzige-bg">
<div class="line-horizontal" />
<div class="line-vertical" />
<svg width="100%" height="100%" class="line-diagonal">
<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="tianzige-text">{{ option.text }}</span>
</div>
<!-- 选项标签和文字 -->
<div class="option-content">
<div class="option-label">
{{ option.label }}
</div>
<!-- 如果不是田字格模式显示普通文本 -->
<span v-if="option.renderType !== 'tianzige'" class="option-text">
{{ option.text }}
</span>
</div>
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
</div>
</div>