Files
reader-star/apps/admin/src/views/user/game/index.vue
rjl 02b4720b34 feat(admin): 优化用户界面组件并新增自定义按钮样式
- 新增按钮背景图片资源(btn-bg.png、progress-title.png、title-bg3.png、title-bg4.png)
- 扩展 ActionButton 组件支持自定义按钮类型,新增 custom 类型用于背景图片驱动的按钮
- 添加 btnBg 和 fontSize 属性到 ActionButton,支持动态背景和字体大小配置
- 重构 ActionButton 样式结构,分离 standard-btn 和 custom-btn 两种按钮样式
- 优化按钮禁用状态的样式处理和交互反馈
- 更新 CompetitionLayout 组件支持新的标题背景类型(titleBg3、titleBg4)
- 新增 titleTextColor 和 fontSize 属性到 CompetitionLayout,增强标题和按钮的定制化能力
- 更新所有用户页面组件(cover、draw、game、groups、rules、teams)以适配新的按钮和布局配置
2026-03-17 17:51:12 +08:00

311 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- eslint-disable no-console -->
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetGameStatistics, fetchGetQuestionDetail, fetchSubmitQuestionResult } from '@/service/api/game'
import { useActivityInfoStore } from '@/store/modules/activityinfo'
import { useCompetitionStore } from '@/store/modules/competition'
import QuestionRenderer from '../modules/QuestionRenderer.vue'
import LineCharts from './modules/LineCharts.vue'
const { routerPushByKey } = useRouterPush()
const store = useCompetitionStore()
const activityInfoStore = useActivityInfoStore()
// 直接从 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 RoundType = computed(() => store.currentQuestionMainInfo?.Activity_Question?.RoundType)
const loading = ref(false)
// 本地倒计时控制(为了在页面上显示“开始答题”还是倒计时)
const isStarted = ref(false)
const pollingTimer = ref<number | null>(null) // 轮询定时器
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()
})
onUnmounted(() => {
store.stopTimer()
stopPolling()
})
// 监听倒计时,结束时自动跳转
watch(() => store.timeLeft, (newVal) => {
if (newVal === 0 && isStarted.value) {
store.stopTimer()
stopPolling()
routerPushByKey('user_analysis', {
query: {
activityId: String(activityId.value),
groupsId: String(groupId.value),
teamId: String(teamId.value),
teamIds: teamIds.value.join(','),
},
})
}
})
// 优先使用 store 中的 API 数据,否则回退到 Mock 数据
const template = computed(() => store.currentQuestionInfo?.UIType)
const content = computed(() => {
if (store.currentQuestionDetail) {
const { Name, ImageUrl } = store.currentQuestionDetail
return {
title: Name,
titleImage: ImageUrl,
}
}
return null
})
const formattedTime = computed(() => {
const time = store.timeLeft
const m = Math.floor(time / 60)
const s = time % 60
const mm = m < 10 ? `0${m}` : m
const ss = s < 10 ? `0${s}` : s
return `倒计时 ${mm}:${ss}`
})
// Chart data
const chartData = ref<any[]>([])
function handleBack() {
store.stopTimer()
stopPolling()
routerPushByKey('user_draw', {
query: {
activityId: String(activityId.value),
groupsId: String(groupId.value),
teamId: String(teamId.value),
teamIds: teamIds.value.join(','),
},
})
}
/**
* 获取题目详情
*/
async function fetchGetQuestionDetailData() {
if (store.currentQuestionInfo) {
loading.value = true
try {
const currentActivityId = String(activityId.value || '')
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)
window.$message?.error('获取题目详情失败')
}
finally {
loading.value = false
}
}
}
async function handleNext() {
// 点击开始答题
if (!isStarted.value) {
isStarted.value = true
// 获取题目时间,优先从 currentQuestionInfo 获取,兼容大小写
// 并在获取失败时尝试从 detail 获取(如果后端在详情接口也返回了时间)
const infoTime = store.currentQuestionInfo?.QuestionTime || (store.currentQuestionInfo as any)?.questionTime
const detailTime = (store.currentQuestionDetail as any)?.QuestionTime || (store.currentQuestionDetail as any)?.questionTime
const duration = Number(infoTime || detailTime || 120)
console.log('开始倒计时,时长:', duration, 'InfoTime:', infoTime, 'DetailTime:', detailTime)
store.startTimer(duration)
// 提交题目使用记录
try {
if (store.currentQuestionInfo && store.currentQuestionDetail) {
const params: Api.Competition.QuestionAddParams = {
RoomID: activityInfo.value?.RoomID || 0,
MainID: activityId.value || 0,
TeamGroupID: groupId.value || 0,
QuestionID: store.currentQuestionInfo.ID, // 使用列表中的 ID
QuestionDetaiID: store.currentQuestionDetail.Id || 0, // 详情 ID
TeamGroupQuestionID: store.currentQuestionMainInfo?.TeamGroup_QuestionID || 0, // 题目在分组中的 ID
}
console.log(params, 'params')
await fetchSubmitQuestionResult(params)
// 立即拉取一次,然后开启轮询
await fetchGetGameStatisticsData(params)
startPolling(params)
}
}
catch (error) {
console.error('Failed to submit question result', error)
// 不阻塞流程,仅记录错误
}
}
else {
// 备用逻辑:如果已经在答题中(理论上不会触发,因为上面已经跳转),直接跳转
store.stopTimer()
stopPolling()
routerPushByKey('user_analysis', {
query: {
activityId: String(activityId.value),
groupsId: String(groupId.value),
teamId: String(teamId.value),
teamIds: teamIds.value.join(','),
},
})
}
}
function startPolling(params: Api.Competition.QuestionAddParams) {
stopPolling()
pollingTimer.value = window.setInterval(() => {
fetchGetGameStatisticsData(params)
}, 2000)
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value)
pollingTimer.value = null
}
}
/** 获取游戏现场统计结果 */
async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddParams) {
if (store.currentQuestionInfo && store.currentQuestionDetail) {
try {
const { data } = await fetchGetGameStatistics(_params)
// console.log(data, 'data')
// 更新图表数据
const list = (data?.data || []) as any[]
if (list && Array.isArray(list)) {
// 使用 Map 去重,以 TeamId 为准,保留最新的状态
const teamMap = new Map()
list.forEach((item) => {
let answerCount = 0
try {
if (item.UserAnswerFont) {
const parsed = JSON.parse(item.UserAnswerFont)
if (Array.isArray(parsed) && parsed.length > 0) {
parsed.forEach((p: any) => {
if (p.AnswerText) {
answerCount += String(p.AnswerText).length
}
})
}
}
}
catch (e) {
console.error('解析 UserAnswerFont 失败', e)
}
teamMap.set(item.TeamName, {
group: item.TeamName || '未知队伍',
total: answerCount, // 答题数量
correct: item.Points || 0, // 预估得分 (使用外层 Points)
})
})
const newChartData = Array.from(teamMap.values())
// 只有数据真正变化时才更新
const currentDataStr = JSON.stringify(chartData.value)
const newDataStr = JSON.stringify(newChartData)
if (currentDataStr !== newDataStr) {
chartData.value = newChartData
}
}
}
catch (error) {
console.error('Failed to fetch game statistics', error)
// 轮询时出错不频繁弹窗,仅 log
}
}
}
const titleBgTypeNum = computed(() => RoundType.value === 0 ? 3 : 4) || 3
</script>
<template>
<CompetitionLayout
action-position="top" :show-back="false" :show-next="true" :title="mainTitle || ''"
:title-bg-type="titleBgTypeNum" title-text-color="#ffffff" 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 class="mt-8px text-3xl">
{{ subTitle }}
</p> -->
<!-- 题目内容区域 -->
<div v-if="template && content" class="w-full flex flex-1 items-start justify-center overflow-hidden py-4">
<div class="max-h-full max-w-full">
<QuestionRenderer class="origin-center transform" />
</div>
</div>
<!-- 隐藏的 Next 按钮 (方便演示右上角小区域) -->
<div class="absolute right-0 top-0 z-50 h-20 w-20 cursor-pointer" title="Next Step (Debug)" @click="handleNext">
<div class="h-full w-full flex items-center justify-center">
<div class="h-6 w-6 rotate-45 bg-blue-500">
<icon-ic:baseline-arrow-right-alt class="text-icon text-white" />
</div>
</div>
</div>
<!-- 底部图表区域 -->
<div
class="relative z-20 h-64 max-w-5xl w-full flex flex-col shrink-0 border-2 border-blue-300 rounded-xl bg-white/90 p-4 shadow-lg backdrop-blur"
>
<!-- 图表标题栏 -->
<div v-if="isStarted" 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>
<span class="text-md text-gray-400">仅为ai判定不代表最终结果</span>
</div>
</div>
<!-- 柱状图ECharts -->
<LineCharts :data="chartData" :height="208" />
</div>
</div>
</CompetitionLayout>
</template>
<style scoped>
/* Question Renderer Override if needed */
:deep(.question-content) {
font-size: 4rem;
color: #ef4444;
/* red-500 */
font-weight: bold;
}
</style>