feat(user): 重构用户端竞赛流程,集成后端API并优化交互体验

- 新增 `activityinfo` store 管理活动信息,支持持久化
- 重构 `competition` store,分离数据层与流程控制,新增音效管理
- 新增游戏API模块 (`game.ts`),实现抽题、题目详情、提交结果等接口
- 用户端页面全面对接后端数据:首页轮播展示活动列表,组别/队伍页动态加载,封面页显示题目卡片
- 抽题页改为竹签翻转动画,游戏页集成题目详情与倒计时
- 优化布局组件,支持点击返回首页,统一路由参数传递
- 更新类型定义,支持富文本题目渲染
- 添加 `activityinfo` 拼写检查词条
This commit is contained in:
2026-02-10 08:45:26 +08:00
parent 0fd8ef527a
commit 61a7412fc1
23 changed files with 962 additions and 643 deletions

View File

@ -1,41 +1,216 @@
<script setup lang="ts">
import { computed } from 'vue'
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 { useActivityInfoStore } from '@/store/modules/activityinfo'
import { useCompetitionStore } from '@/store/modules/competition'
import { AudioController } from '@/utils/audio'
const { routerPushByKey } = useRouterPush()
const { routerPushByKey, routerBack } = useRouterPush()
// 获取路由参数
const route = useRoute()
const activityInfoStore = useActivityInfoStore()
const activityId = computed(() => activityInfoStore.activityId || route.query?.activityId as string)
const store = useCompetitionStore()
const currentNode = computed(() => store.currentNode)
function handleDraw() {
routerPushByKey('user_game')
const loading = ref(false)
const question = ref<Api.Competition.QuestionListRecord | 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)
// 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
}
}
catch (error) {
window.$message?.error('获取题目失败,请重试')
console.error(error)
}
finally {
loading.value = false
}
}
onMounted(() => {
initQuestions()
})
async function handleCardClick(item: Api.Competition.QuestionListRecord) {
if (flippedId.value)
return // 防止重复点击
try {
// 1. 播放翻牌音效 (忽略音频错误)
try {
audioController.play('flip')
}
catch (e) {
console.error('Audio play failed', e)
}
// 2. 触发翻转动画
flippedId.value = item.ID
// 3. 延迟1s后跳转
setTimeout(() => {
try {
store.setCurrentQuestionInfo(item)
routerPushByKey('user_game')
}
catch (error) {
console.error('Navigation failed', error)
flippedId.value = null // 跳转失败则重置状态
window.$message?.error('跳转失败,请重试')
}
}, 1000)
}
catch (error) {
console.error(error)
flippedId.value = null
}
}
function handleBack() {
routerPushByKey('user_cover')
routerBack()
}
</script>
<template>
<CompetitionLayout :show-back="true" :show-next="false" :title="currentNode?.moduleName || '抽题'" @back="handleBack">
<CompetitionLayout :show-back="true" :show-next="false" :title="question?.ActitvityQuestionName || '抽题'" @back="handleBack">
<div class="h-full w-full flex flex-col items-center pt-10 font-sans">
<div v-if="currentNode" class="mb-20 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">
{{ currentNode.description }}
{{ question?.ActitvityQuestionName || '' }}
</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="handleDraw"
>
<span>抽题</span>
</button>
<!-- 题目卡片列表 -->
<div v-if="loading" class="text-xl text-gray-500">
加载中...
</div>
<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 }"
@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>
</div>
<!-- 签面 (翻转后显示 - 实际上是放大显示的) -->
<div class="stick-face stick-content">
<span class="writing-mode-vertical-rl text-2xl text-red-600 font-bold">{{ question.ActitvityQuestionName }}</span>
</div>
</div>
</div>
</div>
<div v-if="!loading && !question" class="text-xl text-gray-400">
暂无题目
</div>
</div>
</CompetitionLayout>
</template>
<style scoped>
.lottery-stick-container {
width: 50px;
height: 240px;
perspective: 1000px;
cursor: pointer;
transition: transform 0.3s;
}
.lottery-stick-container:hover {
transform: translateY(-20px);
}
.lottery-stick {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
.stick-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2);
}
/* 竹签样式 */
.stick-body {
background: linear-gradient(to right, #e6cba5, #d4b081, #c69c6d);
border: 1px solid #b08d55;
}
.stick-top-mark {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 20px;
background-color: #ef4444; /* red-500 */
border-radius: 8px 8px 0 0;
}
/* 翻转后的内容面 - 做成类似令牌的样子 */
.stick-content {
background: linear-gradient(to bottom, #fff9f0, #fff);
border: 2px solid #b08d55;
transform: rotateY(180deg);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
/* 选中(抽出)动画状态 */
.lottery-stick.is-selected {
transform: translateY(-100px) scale(2) rotateY(180deg);
z-index: 50;
}
/* 竖排文字 */
.writing-mode-vertical-rl {
writing-mode: vertical-rl;
text-orientation: upright;
}
</style>