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

@ -30,7 +30,8 @@ const props = defineProps<{
const { options: roundTypeOptions } = useDict('round_type')
const { options: scoreTypeOptions } = useDict('score_type')
const { options: timeOptions } = useDict('time_out')
const { options: uiTypeOptions } = useDict('ui_type', 'string')
// const { options: uiTypeOptions } = useDict('ui_type', 'string')
const { options: uiTypeOptions } = useDict('question_category_name', 'string')
const {
// getRoundScore,

View File

@ -29,7 +29,7 @@ const modelValue = ref<{
ActitvityQuestionName: string
QuestionRule: number
Point: number
UIType?: Api.Competition.QuestionTemplateId
UIType?: Api.Competition.QuestionCategoryName
TemplateID?: number
// 额外字段用于回显时保留原始信息
ID?: number
@ -42,7 +42,8 @@ const modelValue = ref<{
const { options: roundTypeOptions } = useDict('round_type')
const { options: scoreTypeOptions } = useDict('score_type')
const { options: timeOptions } = useDict('time_out')
const { options: uiTypeOptions } = useDict('ui_type', 'string')
// const { options: uiTypeOptions } = useDict('ui_type', 'string')
const { options: uiTypeOptions } = useDict('question_category_name', 'string')
// 我们构造一个包含 modelValue 属性的对象,类似于 props
const hookProps = reactive({

View File

@ -21,6 +21,7 @@ const emit = defineEmits<{
}>()
const { options: questionTypeOptions } = useDict('question_type', 'string')
const { options: categoryOptions } = useDict('question_category_name', 'string')
const message = useMessage()
const dialog = useDialog()
@ -237,7 +238,12 @@ onMounted(() => {
<NForm>
<!-- 分类显示名称 -->
<NFormItem label="分类名称">
<NInput v-model:value="categoryForm.name" placeholder="请输入分类名称" @keyup.enter="submitCategory" />
<NSelect
v-model:value="categoryForm.name"
:options="categoryOptions"
value-field="label"
placeholder="请选择分类名称"
/>
</NFormItem>
<!-- 题目类型 -->
<NFormItem label="题目类型">

View File

@ -6,6 +6,7 @@ import {
NBreadcrumbItem,
NButton,
NCard,
NCheckbox,
NDrawer,
NDrawerContent,
NEmpty,
@ -26,6 +27,13 @@ import SvgIcon from '@/components/custom/svg-icon.vue'
import { QuestionType } from '@/enum/business'
import { fetchAddQuestionLibrary, fetchDeleteQuestionLibrary, fetchGetQuestionLibraryListAll, fetchUpdateQuestionLibrary } from '@/service/api/question'
/**
* 为了user端题目展示的效果以及布局可控指定规则
* 1.普通类型 =》用存文字存 ['汉字加一加','词语听写''汉字听写','成语写一写']
* 2.复杂样式 =》用富文本 ['诗词理解']
*
*/
const props = defineProps<{
currentCategory: any
}>()
@ -112,6 +120,64 @@ const fileList = ref<UploadFileInfo[]>([])
const questionFormRef = ref<FormInst | null>(null)
// 批量选择
const selectedQuestionIds = ref<number[]>([])
// 全选状态
const isAllSelected = computed(() => {
return questionList.value.length > 0 && selectedQuestionIds.value.length === questionList.value.length
})
// 半选状态
const isIndeterminate = computed(() => {
return selectedQuestionIds.value.length > 0 && selectedQuestionIds.value.length < questionList.value.length
})
function handleSelectAll(checked: boolean) {
if (checked) {
selectedQuestionIds.value = questionList.value.map(q => q.Id)
}
else {
selectedQuestionIds.value = []
}
}
function handleSelect(id: number, checked: boolean) {
if (checked) {
selectedQuestionIds.value.push(id)
}
else {
selectedQuestionIds.value = selectedQuestionIds.value.filter(v => v !== id)
}
}
function handleBatchDelete() {
if (selectedQuestionIds.value.length === 0) {
window?.$message?.warning('请先选择要删除的题目')
return
}
window?.$dialog?.warning({
title: '批量删除警告',
content: `确定要删除选中的 ${selectedQuestionIds.value.length} 道题目吗?此操作无法撤销。`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
const { error } = await fetchDeleteQuestionLibrary(selectedQuestionIds.value)
if (!error) {
window?.$message?.success('批量删除成功')
selectedQuestionIds.value = []
if (props.currentCategory?.Id) {
fetchData(props.currentCategory.Id)
}
}
else {
window?.$message?.error('批量删除失败')
}
},
})
}
const rules = {
name: [{ required: true, message: '请输入题目正文内容', trigger: ['blur'] }],
answer: [{ required: true, message: '请输入题目答案', trigger: ['blur'] }],
@ -320,6 +386,13 @@ async function submitQuestion() {
<!-- 搜索框 -->
<div v-if="currentCategory" class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<NCheckbox
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@update:checked="handleSelectAll"
>
全选 ({{ selectedQuestionIds.length }})
</NCheckbox>
<NInput v-model:value="searchText" placeholder="输入题目关键字在当前分类下搜索..." class="!w-80" @keyup.enter="handleSearch">
<template #prefix>
<SvgIcon icon="carbon:search" class="text-gray-400" />
@ -327,12 +400,20 @@ async function submitQuestion() {
</NInput>
</div>
<NButton type="primary" @click="handleAddQuestion">
<template #icon>
<SvgIcon icon="carbon:add" />
</template>
新增题目
</NButton>
<div class="flex gap-3">
<NButton v-if="selectedQuestionIds.length > 0" type="error" secondary @click="handleBatchDelete">
<template #icon>
<SvgIcon icon="carbon:trash-can" />
</template>
批量删除 ({{ selectedQuestionIds.length }})
</NButton>
<NButton type="primary" @click="handleAddQuestion">
<template #icon>
<SvgIcon icon="carbon:add" />
</template>
新增题目
</NButton>
</div>
</div>
</div>
@ -357,9 +438,18 @@ async function submitQuestion() {
<!-- 题目列表 -->
<template v-else>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<NCard v-for="q in questionList" :key="q.Id" size="small" hoverable class="rounded-xl">
<NCard
v-for="q in questionList" :key="q.Id" size="small" hoverable class="rounded-xl transition-all" :class="{
'ring-2 ring-primary-500 bg-primary-50': selectedQuestionIds.includes(q.Id),
}"
@click="handleSelect(q.Id, !selectedQuestionIds.includes(q.Id))"
>
<template #header>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" @click.stop>
<NCheckbox
:checked="selectedQuestionIds.includes(q.Id)"
@update:checked="(val) => handleSelect(q.Id, val)"
/>
<NTag size="small" type="primary" :bordered="false">
ID: {{ q.Id }}
</NTag>
@ -391,7 +481,7 @@ async function submitQuestion() {
<!-- 操作按钮 -->
<template #action>
<div class="flex justify-end gap-2">
<div class="flex justify-end gap-2" @click.stop>
<NButton size="tiny" quaternary type="primary" @click="handleEditQuestion(q.Id)">
<template #icon>
<SvgIcon icon="carbon:edit" />
@ -443,7 +533,10 @@ async function submitQuestion() {
<NForm ref="questionFormRef" label-placement="top" :rules="rules" :model="questionForm" size="small">
<NFormItem label="题目正文内容" path="name">
<div class="w-full overflow-hidden border border-gray-200 rounded-lg">
<WangEditor v-model="questionForm.name" placeholder="请输入题目内容..." height="300px" />
<WangEditor
v-model="questionForm.name" placeholder="请输入题目内容..." height="300px"
:exclude-keys="['header1', 'fontSize', 'fontFamily', 'lineHeight', 'justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify', 'group-image', 'group-video', 'insertTable', 'headerSelect']"
/>
</div>
</NFormItem>
@ -504,7 +597,7 @@ async function submitQuestion() {
<style lang="scss" scoped>
:deep(.n-card__content) {
img {
height: 100px !important ;
height: 100px !important;
width: 100px !important;
object-fit: contain;
}

View File

@ -1,10 +1,16 @@
<script lang="ts" setup>
import { useIntervalFn } from '@vueuse/core'
import { NButton } from 'naive-ui'
import { storeToRefs } from 'pinia'
import { onUnmounted, ref } from 'vue'
import { fetchUpdateCurrentQuestionUse } from '@/service/api/result'
import { useActivityInfoStore } from '@/store/modules/activityinfo'
import GroupTabs, { type GroupItem } from './modules/GroupTabs.vue'
import TeamResultCard, { type AICharacter, type TeamResult } from './modules/TeamResultCard.vue'
const activityInfoStore = useActivityInfoStore()
const { activityId, groupId } = storeToRefs(activityInfoStore)
// --- 模拟数据和状态 ---
const groups = ref<GroupItem[]>(Array.from({ length: 8 }).map((_, i) => ({
@ -17,9 +23,9 @@ const teamsData = ref<TeamResult[]>([]) // 当前组别下的队伍数据
const isPolling = ref(true) // 是否正在轮询
// 初始化某组的队伍结构
function initGroupTeams(groupId: number | string) {
function initGroupTeams(gid: number | string) {
return Array.from({ length: 4 }).map((_, i) => ({
id: `${groupId}-${i + 1}`,
id: `${gid}-${i + 1}`,
name: `${i + 1}`,
correctCount: 0,
score: 0,
@ -167,9 +173,30 @@ function handleNext() {
}
function handlePublish() {
fetchGetQuestionDetailData()
window.$message?.success('结果已发布')
}
async function fetchGetQuestionDetailData() {
const params = {
ActivityID: Number(activityId.value),
GroupID: Number(groupId.value),
RoundType: 0 as Api.Competition.CompetitionRoundType,
}
try {
const { data: outlineData, error } = await fetchUpdateCurrentQuestionUse({ ...params, isUse: true })
// eslint-disable-next-line no-console
console.log(outlineData, 'outlineData')
if (error) {
window?.$message?.error(error.message)
}
}
catch (error: any) {
window?.$message?.error(error.message)
}
}
// 清理
onUnmounted(() => {
pause()
@ -185,11 +212,7 @@ onUnmounted(() => {
<div class="i-carbon-chart-line-data text-2xl text-blue-600" />
实时结果
</h1>
<GroupTabs
:groups="groups"
:model-value="currentGroupId"
@update:model-value="handleGroupChange"
/>
<GroupTabs :groups="groups" :model-value="currentGroupId" @update:model-value="handleGroupChange" />
</div>
<div class="flex gap-4">
@ -209,9 +232,7 @@ onUnmounted(() => {
<div class="flex-1 overflow-hidden">
<div class="grid grid-cols-4 h-full gap-6">
<TeamResultCard
v-for="team in teamsData"
:key="team.id"
:data="team"
v-for="team in teamsData" :key="team.id" :data="team"
@toggle-correct="(charId) => handleToggleCorrect(team.id, charId)"
/>
</div>

View File

@ -1,10 +1,26 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchCheckNextStep, fetchGetCurrentQuestion } from '@/service/api/game'
import { useCompetitionStore } from '@/store/modules/competition'
const store = useCompetitionStore()
const { routerPushByKey } = useRouterPush()
const route = useRoute()
const activityId = computed(() => route.query?.activityId as string)
const groupsId = computed(() => route.query?.groupsId as string)
const teamId = computed(() => route.query?.teamId as string)
const teamIds = computed(() => route.query?.teamIds as string)
const currentQuestionMainInfo = computed(() => store.currentQuestionMainInfo)
const TeamGroup_QuestionID = computed(() => currentQuestionMainInfo.value?.TeamGroup_QuestionID || 0)
const zoomedImage = ref<string | null>(null)
const isCompleted = ref(false)
const showNextBtn = ref(false)
// Mock team data
const teams = ref([
@ -51,25 +67,105 @@ function handleZoom(img: string) {
}
function handleBack() {
routerPushByKey('user_game')
routerPushByKey('user_game', {
query: {
activityId: activityId.value,
groupsId: groupsId.value,
teamId: teamId.value,
teamIds: teamIds.value,
},
})
}
function handleNext() {
routerPushByKey('user_draw')
if (isCompleted.value) {
routerPushByKey('user_groups', {
query: {
activityId: activityId.value,
},
})
return
}
routerPushByKey('user_draw', {
query: {
activityId: activityId.value,
groupsId: groupsId.value,
teamId: teamId.value,
teamIds: teamIds.value,
},
})
// 清空当前题目信息
store.initData()
}
/**
* 获取题目详情
* 检测是否还有题可以抽取如果data为0则提示没有题可抽取
*/
async function initQuestions() {
if (!activityId.value)
return
try {
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)
}
isCompleted.value = outlineData?.data?.IsCompleted || false
}
catch (error) {
window.$message?.error('获取题目失败,请重试')
console.error(error)
}
}
const timer = ref<ReturnType<typeof setInterval> | undefined>(undefined)
/** 定时刷新 检查评委是否已经完成打分,是否可以下一步 fetchCheckNextStep */
async function checkNextStep() {
if (!activityId.value || !groupsId.value)
return
try {
const { data: nextStepData, error } = await fetchCheckNextStep(TeamGroup_QuestionID.value || 0)
// eslint-disable-next-line no-console
console.log(nextStepData, 'nextStepData')
if (error) {
window?.$message?.error(error.message)
}
const isUse = nextStepData?.data?.IsUse || false
showNextBtn.value = isUse
if (isUse) {
await initQuestions()
clearInterval(timer.value)
}
}
catch (error) {
window.$message?.error('检查是否可以下一步失败,请重试')
console.error(error)
}
}
// 定时刷新 检查是否可以下一步
timer.value = setInterval(checkNextStep, 2000)
/** 销毁 定时刷新 检查是否可以下一步 */
onBeforeUnmount(() => {
clearInterval(timer.value)
})
</script>
<template>
<CompetitionLayout
:show-title="true"
:show-back="false"
:show-next="true"
title="答题图解"
action-position="top"
back-btn-text="返回"
next-btn-text="继续抽题"
btn-theme="light"
@back="handleBack"
:show-title="true" :show-back="false" :show-next="showNextBtn" title="答题图解" action-position="top"
back-btn-text="返回" :next-btn-text="isCompleted ? '该组已完成回到选组' : '下一题'" btn-theme="light" @back="handleBack"
@next="handleNext"
>
<div class="relative h-full w-full flex flex-col items-center px-12 pt-10 font-sans">
@ -115,8 +211,7 @@ function handleNext() {
<div class="relative">
<input
v-model="team.manualScore"
type="number"
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="留个输入框,修改正确积分"
>
@ -128,8 +223,7 @@ function handleNext() {
<!-- Zoom Modal -->
<div
v-if="zoomedImage"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
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">

View File

@ -3,7 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetQuestionList } from '@/service/api/competition'
import { fetchGetQuestionTableByActivityID } from '@/service/api/game'
import { useActivityInfoStore } from '@/store/modules/activityinfo'
import { AudioController } from '@/utils/audio'
import { filterRepeat } from '@/utils/data'
@ -39,6 +39,7 @@ const route = useRoute()
const activityId = computed(() => activityInfoStore.activityId || Number(route.query?.activityId))
const groupsId = computed(() => Number(route.query?.groupsId)) || activityInfoStore.groupId
const teamId = computed(() => activityInfoStore.teamId || Number(route.query?.teamId))
const teamIds = computed(() => route.query?.teamIds as string)
async function getQuestions() {
if (!activityId.value || !groupsId.value || !teamId.value) {
@ -46,26 +47,22 @@ async function getQuestions() {
return
}
// 存 activityId,groupsId,teamId
activityInfoStore.setActivityId(activityId.value)
activityInfoStore.setGroupId(groupsId.value)
activityInfoStore.setTeamId(teamId.value)
loading.value = true
try {
const { data, error } = await fetchGetQuestionList(String(activityId.value))
const { data, error } = await fetchGetQuestionTableByActivityID(String(activityId.value))
if (error) {
window?.$message?.error(error.message)
return
}
const list = data?.data || []
if (list && Array.isArray(list)) {
// 根据ActitvityQuestionName去重
const uniqueData = filterRepeat(list as any[], 'ActitvityQuestionName')
const uniqueData = filterRepeat(list as any[], 'Name')
questions.value = uniqueData.map((item: any) => ({
id: item.Id,
moduleName: item.ActitvityQuestionName,
moduleName: item.Name,
}))
}
}
@ -95,7 +92,14 @@ function handleBack() {
}
function handleNext() {
routerPushByKey('user_rules', { query: { activityId: activityId.value, groupsId: groupsId.value, teamId: teamId.value } })
routerPushByKey('user_rules', {
query: {
activityId: activityId.value,
groupsId: groupsId.value,
teamId: teamId.value,
teamIds: teamIds.value,
},
})
}
function toggleFlip(index: number) {
@ -108,8 +112,23 @@ function toggleFlip(index: number) {
}
}
onMounted(() => {
getQuestions()
async function initSyncRouteParams() {
if (route.query.activityId)
activityInfoStore.setActivityId(Number(route.query.activityId))
if (route.query.groupsId)
activityInfoStore.setGroupId(Number(route.query.groupsId))
if (route.query.teamId)
activityInfoStore.setTeamId(Number(route.query.teamId))
if (route.query.teamIds) {
const ids = String(route.query.teamIds).split(',').map(Number).filter(id => !Number.isNaN(id))
if (ids.length > 0)
activityInfoStore.setTeamIds(ids)
}
}
onMounted(async () => {
await initSyncRouteParams()
await getQuestions()
})
</script>
@ -118,13 +137,13 @@ onMounted(() => {
:show-back="true" :show-next="showNextButton" :title="activityName" @back="handleBack"
@next="handleNext"
>
<div class="h-screen w-full overflow-hidden font-sans">
<div class="relative z-10 h-full w-full">
<div class="h-full w-full flex flex-col items-center pt-24">
<div class="h-screen w-full overflow-y-auto font-sans">
<div class="relative z-10 min-h-full w-full">
<div class="min-h-full w-full flex flex-col items-center pb-12 pt-24">
<!-- 卡片列表 -->
<div class="perspective-container grid grid-cols-4 max-w-7xl w-full gap-10 px-12">
<div class="perspective-container max-w-[1800px] w-full flex flex-wrap justify-center gap-4 px-4 md:gap-10 md:px-12">
<div
v-for="(node, index) in questions.slice(0, 4)" :key="node.id" class="card-wrapper"
v-for="(node, index) in questions" :key="node.id" class="w-[44%] card-wrapper 2xl:w-[14%] lg:w-[21%] md:w-[28%] xl:w-[17%]"
@click="toggleFlip(index)"
>
<div class="flip-card-inner" :class="{ 'is-flipped': flippedCards.has(index) }">

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;
}

View File

@ -13,9 +13,13 @@ const { routerPushByKey } = useRouterPush()
const store = useCompetitionStore()
const activityInfoStore = useActivityInfoStore()
const { activityId, groupId, activityInfo } = storeToRefs(activityInfoStore)
// 直接从 Store 解构所需状态,保持单一数据源
const { activityId, groupId, activityInfo, teamId, teamIds } = storeToRefs(activityInfoStore)
const currentQuestionInfo = computed(() => store.currentQuestionInfo)
const currentQuestionDetail = computed(() => store.currentQuestionDetail)
const currentQuestionMainInfo = computed(() => store.currentQuestionMainInfo)
const loading = ref(false)
// 本地倒计时控制(为了在页面上显示“开始答题”还是倒计时)
@ -24,6 +28,11 @@ const isStarted = ref(false)
console.log(activityInfo.value, 'activityInfo.value')
console.log(currentQuestionInfo.value, 'currentQuestionInfo.value')
console.log(currentQuestionDetail.value, 'currentQuestionDetail.value')
console.log(currentQuestionMainInfo.value, 'currentQuestionMainInfo.value')
const mainTitle = computed(() => currentQuestionMainInfo.value?.QuestionList?.Name || '')
// const subTitle = computed(() => currentQuestionMainInfo.value?.QuestionList?.QuestionContent || '')
onMounted(async () => {
await fetchGetQuestionDetailData()
@ -33,7 +42,14 @@ onMounted(async () => {
watch(() => store.timeLeft, (newVal) => {
if (newVal === 0 && isStarted.value) {
store.stopTimer()
routerPushByKey('user_analysis')
routerPushByKey('user_analysis', {
query: {
activityId: String(activityId.value),
groupsId: String(groupId.value),
teamId: String(teamId.value),
teamIds: teamIds.value.join(','),
},
})
}
})
@ -69,7 +85,14 @@ const chartData = [
function handleBack() {
store.stopTimer()
routerPushByKey('user_draw')
routerPushByKey('user_draw', {
query: {
activityId: String(activityId.value),
groupsId: String(groupId.value),
teamId: String(teamId.value),
teamIds: teamIds.value.join(','),
},
})
}
/**
@ -80,12 +103,15 @@ async function fetchGetQuestionDetailData() {
loading.value = true
try {
const currentActivityId = String(activityId.value || '')
const questionId = store.currentQuestionInfo.QuestionID || (store.currentQuestionInfo as any).Id
const questionId = store.currentQuestionInfo?.QuestionID || (store.currentQuestionInfo as any).Id
const { data: detailData } = await fetchGetQuestionDetail(currentActivityId, questionId)
console.log(detailData, 'detailData')
if (detailData && detailData.data) {
store.setCurrentQuestionDetail(detailData.data)
}
else {
window?.$message?.error('题目详情不存在,请联系管理员')
}
}
catch (error) {
console.error('Failed to fetch question detail', error)
@ -126,7 +152,14 @@ async function handleNext() {
else {
// 备用逻辑:如果已经在答题中(理论上不会触发,因为上面已经跳转),直接跳转
store.stopTimer()
routerPushByKey('user_analysis')
routerPushByKey('user_analysis', {
query: {
activityId: String(activityId.value),
groupsId: String(groupId.value),
teamId: String(teamId.value),
teamIds: teamIds.value.join(','),
},
})
}
}
@ -147,18 +180,19 @@ async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddPa
<template>
<CompetitionLayout
action-position="top" :show-back="false" :show-next="true"
:title="store.currentQuestionInfo?.ActitvityQuestionName || ''" back-btn-text="返回"
:next-btn-text="isStarted ? formattedTime : '开始答题'" :next-disabled="false" btn-theme="light" @back="handleBack"
@next="handleNext"
action-position="top" :show-back="false" :show-next="true" :title="mainTitle || ''"
back-btn-text="返回" :next-btn-text="isStarted ? formattedTime : '开始答题'" :next-disabled="isStarted" btn-theme="light"
@back="handleBack" @next="handleNext"
>
<div class="relative h-full w-full flex flex-col items-center overflow-hidden px-12 font-sans">
<!-- 题目说明 -->
<!-- <p>{{ store.currentQuestionInfo.ActitvityQuestionName }}</p> -->
<!-- <p class="mt-8px text-3xl">
{{ subTitle }}
</p> -->
<!-- 题目内容区域 -->
<div v-if="template && content" class="w-full flex flex-1 items-center justify-center overflow-hidden py-4">
<div class="max-h-full max-w-full flex items-center justify-center">
<QuestionRenderer :template="template" :content="content" class="origin-center scale-150 transform" />
<div class="max-h-full max-w-full">
<QuestionRenderer class="origin-center scale-150 transform" />
</div>
</div>

View File

@ -3,20 +3,15 @@ import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetGroupList } from '@/service/api/competition'
import { fetchGetGroupListByActivityID } from '@/service/api/game'
const { routerPushByKey, routerBack } = useRouterPush()
const route = useRoute()
interface GroupItem {
id: number
name: string
icon: string
}
const groups = ref<GroupItem[]>([])
const groups = ref<Api.Competition.ActivityTeamGroup[]>([])
const loading = ref(false)
const activityId = route.query.activityId as string
const isAllEnd = ref(false)
async function getGroups() {
if (!activityId)
@ -24,7 +19,7 @@ async function getGroups() {
loading.value = true
try {
const { data, error } = await fetchGetGroupList(activityId)
const { data, error } = await fetchGetGroupListByActivityID(activityId, 0)
if (error) {
window?.$message?.error(error.message)
return
@ -33,11 +28,14 @@ async function getGroups() {
const list = data?.data || []
if (list && Array.isArray(list)) {
groups.value = list.map((item: any) => ({
id: item.Id,
name: item.Name,
...item,
// id: item.Id,
// name: item.Name,
icon: 'activity', // Default icon since API might not provide one
}))
}
isAllEnd.value = list.every((item: Api.Competition.ActivityTeamGroup) => item.IsEnd)
// console.log(groups.value, 'groups.value')
}
finally {
loading.value = false
@ -51,12 +49,15 @@ function handleBack() {
}
function handleNext() {
routerPushByKey('user_teams')
routerPushByKey('user')
}
function selectGroup(_id: number) {
function selectGroup(item: Api.Competition.ActivityTeamGroup) {
if (item.IsEnd)
return
// 选择组别逻辑
routerPushByKey('user_teams', { query: { activityId, groupsId: _id } })
routerPushByKey('user_teams', { query: { activityId, groupsId: item.Id } })
}
onMounted(() => {
@ -70,7 +71,8 @@ onMounted(() => {
<template>
<CompetitionLayout
:show-back="true"
:show-next="false"
:show-next="isAllEnd"
next-btn-text="查看队伍结果"
title="展示组别"
@back="handleBack"
@next="handleNext"
@ -81,10 +83,11 @@ onMounted(() => {
<div
v-for="(item, index) in groups"
v-show="showContent"
:key="item.id"
:key="item.Id"
class="group-item"
:class="{ 'is-disabled': item.IsEnd }"
:style="{ '--delay': `${index * 0.05}s` }"
@click="selectGroup(item.id)"
@click="selectGroup(item)"
>
<div class="icon-wrapper">
<div class="flower-bg" />
@ -94,7 +97,7 @@ onMounted(() => {
</div>
<div class="group-name-tag">
<span class="sun-icon"></span>
{{ item.name }}
{{ item.Name }}
</div>
</div>
</TransitionGroup>
@ -133,6 +136,16 @@ onMounted(() => {
margin: 0;
margin-bottom: 30px;
&.is-disabled {
opacity: 0.6;
cursor: not-allowed;
filter: grayscale(100%);
&:hover {
transform: none;
}
}
&:hover {
transform: translateY(-5px);
}

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>

View File

@ -13,6 +13,7 @@ const route = useRoute()
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 { routerPushByKey, routerBack } = useRouterPush()
@ -33,7 +34,14 @@ function handleBack() {
}
function handleNext() {
routerPushByKey('user_draw', { query: { activityId: activityId.value, groupsId: groupsId.value, teamId: teamId.value } })
routerPushByKey('user_draw', {
query: {
activityId: activityId.value,
groupsId: groupsId.value,
teamId: teamId.value,
teamIds: teamIds.value,
},
})
}
</script>

View File

@ -16,8 +16,9 @@ interface TeamItem {
const teams = ref<TeamItem[]>([])
const loading = ref(false)
const activityId = route.query.activityId
const groupsId = route.query.groupsId
const activityId = (route.query?.activityId) as string || ''
const groupsId = (route.query?.groupsId) as string || ''
const teamIds = ref<number[]>([])
async function getTeams() {
if (!groupsId)
@ -37,6 +38,7 @@ async function getTeams() {
name: item.Name,
icon: 'star', // Default icon
}))
teamIds.value = list.map((item: any) => item.Id)
}
}
finally {
@ -57,7 +59,7 @@ function handleNext() {
function selectTeam(_id: number) {
// 选择队伍逻辑
routerPushByKey('user_cover', { query: { activityId, groupsId, teamId: _id } })
routerPushByKey('user_cover', { query: { activityId, groupsId, teamId: _id, teamIds: teamIds.value.join(',') } })
}
onMounted(() => {

View File

@ -1,3 +1,6 @@
/**
* 废弃
*/
export type UiTemplateType =
| 'TEMPLATE_DICTATION_HINT' // 汉字听写-提示 (jiu)
| 'TEMPLATE_DICTATION_HOMOPHONE' // 汉字听写-同音 (tang)
@ -7,6 +10,17 @@ export type UiTemplateType =
| 'TEMPLATE_IDIOM_IMAGE' // 成语-看图
| 'TEMPLATE_POETRY_MULTIPLE_CHOICE' // 诗词理解-选择题
/**
* 题库类型
*/
export type QuestionCategoryName =
| 'ChineseCharacterDictation' // 汉字听写-提示 (jiu)
| 'CharacterRadicalAddition' // 汉字加一加 (车)
| 'WordDictation' // 词语听写 (zhi re)
| 'IdiomWriting' // 成语-文字要求 (反义字)
// | 'TEMPLATE_IDIOM_IMAGE' // 成语-看图
| 'PoetryComprehension' // 诗词理解-选择题
export interface QuestionOption {
label: string // A, B, C
text: string // 选项文本/值