feat(user): 重构用户端竞赛流程,集成后端API并优化交互体验
- 新增 `activityinfo` store 管理活动信息,支持持久化 - 重构 `competition` store,分离数据层与流程控制,新增音效管理 - 新增游戏API模块 (`game.ts`),实现抽题、题目详情、提交结果等接口 - 用户端页面全面对接后端数据:首页轮播展示活动列表,组别/队伍页动态加载,封面页显示题目卡片 - 抽题页改为竹签翻转动画,游戏页集成题目详情与倒计时 - 优化布局组件,支持点击返回首页,统一路由参数传递 - 更新类型定义,支持富文本题目渲染 - 添加 `activityinfo` 拼写检查词条
This commit is contained in:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user