feat(user): 新增用户端答题流程页面与相关功能

- 新增用户端答题流程主页面,包含封面、介绍、答题、答题图解四个状态
- 新增用户端组件:封面、介绍页、答题页、答题图解页、题目渲染器
- 新增用户端类型定义、模拟数据及业务常量
- 新增用户路由及类型声明
- 优化题库分类树,改为扁平化列表结构
- 完善比赛创建功能,增加轮次类型、题目分数类型及关联AP字段
- 修复模板删除函数调用参数错误
This commit is contained in:
2026-01-23 18:16:03 +08:00
parent f1dacf7c66
commit 264f600250
28 changed files with 1750 additions and 348 deletions

View 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>

View 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>

View 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>

View 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>

View 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>