feat: 优化竞赛流程并修复题目禁用功能

- 调整倒计时音效触发时间为剩余10秒,并更新音频文件
- 修复题目禁用功能,将IsDisabled类型从number改为boolean
- 优化抽题页面,替换为静态图片并禁用放大预览
- 为卡片添加3D悬停效果,提升交互体验
- 在结果发布前检查倒计时是否结束,防止提前发布
- 优化团队选择页面样式,更新图标和背景
- 修复Typewriter组件内容重复累加的问题
- 更新图表标签从"正确数量"改为"预估得分"
- 优化竞赛布局,统一"下一步"按钮文本
This commit is contained in:
2026-03-10 17:55:45 +08:00
parent 11fb5481d3
commit b9c5886437
24 changed files with 331 additions and 113 deletions

View File

@ -113,7 +113,7 @@ const questionForm = ref({
type: QuestionType.SingleChoice as QuestionType | number, // 题目类型
imageUrl: '',
IsGood: 0,
IsDisabled: 0,
IsDisabled: false,
})
const fileList = ref<UploadFileInfo[]>([])
@ -185,6 +185,7 @@ async function handleBatchDisable(disabled: boolean) {
}
const payload = selectedQuestionIds.value.map(id => ({
id,
Id: id,
IsDisabled: disabled,
}))
@ -266,7 +267,7 @@ function handleAddQuestion() {
type: props.currentCategory?.QuestionType || QuestionType.SingleChoice,
imageUrl: '',
IsGood: 0,
IsDisabled: 0,
IsDisabled: false,
}
fileList.value = []
showQuestionModal.value = true
@ -291,7 +292,7 @@ function handleEditQuestion(id: number) {
type: question.Type,
imageUrl: question.ImageUrl || '',
IsGood: question.IsPriority || 0,
IsDisabled: question.IsDisabled || 0,
IsDisabled: !!question.IsDisabled,
}
fileList.value = []
if (question.ImageUrl) {
@ -344,6 +345,7 @@ async function submitQuestion() {
type: questionForm.value.type as any,
questionId: props.currentCategory.Id,
IsGood: questionForm.value.IsGood,
// 传给后端为 boolean
IsDisabled: questionForm.value.IsDisabled,
}
@ -616,10 +618,10 @@ async function submitQuestion() {
<!-- 是否禁用 -->
<NFormItem label="是否禁用" path="IsDisabled">
<NRadioGroup v-model:value="questionForm.IsDisabled">
<NRadio :value="0">
<NRadio :value="false">
</NRadio>
<NRadio :value="1">
<NRadio :value="true">
</NRadio>
</NRadioGroup>

View File

@ -282,7 +282,7 @@ async function fetchAnswerData() {
// 假设返回的数据结构是数组,取第一条或者根据逻辑取特定的一条
const result = data?.data || []
console.log('接口返回的游戏统计数据:', result)
// console.log('接口返回的游戏统计数据:', result)
// 获取题目详情 ID
let detailId = 0
@ -364,24 +364,11 @@ async function fetchAnswerData() {
// 优先使用 ans 中的图片,如果没有则回退到 answerData 中的图片
const imageUrl = ans.AnswerValuePicture || (parsedAnswers.length === 1 && parsedAnswers[0] === ans ? answerData.UserAnswerPicture : '')
let finalImageUrl = imageUrl
let cleanBaseUrl = ''
let timestamp = new Date().getTime()
const cleanBaseUrl = ''
const timestamp = new Date().getTime()
if (imageUrl) {
// 清理 URL 中的反引号、引号和空格
cleanBaseUrl = imageUrl.replace(/[`'"]/g, '').trim()
// 尝试复用旧的时间戳以防止闪烁
// 只有当 URL 发生变化,或者 AI 状态/分数发生变化时才更新时间戳
// 注意:如果 ans.AnswerValuePicture 发生了变化即使只是参数变化cleanBaseUrl 会变
if (oldAns && oldAns._baseImgUrl === cleanBaseUrl && oldAns._aiStatus === aiStatus && oldAns._aiScore === aiScore && oldAns._t) {
timestamp = oldAns._t
}
// 强制刷新:每次都添加新的时间戳
const separator = cleanBaseUrl.includes('?') ? '&' : '?'
finalImageUrl = `${cleanBaseUrl}${separator}_t=${timestamp}`
}
finalImageUrl = `${finalImageUrl}?_t=${timestamp}`
console.log(finalImageUrl, 'finalImageUrl')
// 判断是否手动干预:
// 1. 本地存储有状态 (hasStoredStatus) 或有分数 (hasStoredScore)
@ -479,6 +466,11 @@ async function handleNexCurrentQuestion() {
}
function handlePublish() {
const endTimeStr = (currentQuestion.value as any)?.EndTime
if (endTimeStr && Date.now() < new Date(endTimeStr).getTime()) {
window.$message?.warning('倒计时未结束,暂不能发布')
return
}
if (currentQuestion.value?.TeamGroupQuestionID) {
// 检查是否有未评分的题目
let hasUnrated = false
@ -586,6 +578,14 @@ function handlePublish() {
* 消费并发布当前题目
*/
const pollingTimer = ref<NodeJS.Timeout | null>(null)
const nowTs = ref(Date.now())
const canPublish = computed(() => {
const endTime = (currentQuestion.value as any)?.EndTime
if (!endTime)
return false
const end = new Date(endTime).getTime()
return nowTs.value >= end
})
function startPolling() {
stopPolling()
@ -611,6 +611,7 @@ function startPolling() {
}
fetchAnswerData() // 立即获取
nowTs.value = Date.now()
pollingTimer.value = setInterval(() => {
// 每次轮询前再次检查是否过期
const currentEndTime = (currentQuestion.value as any)?.EndTime
@ -623,6 +624,7 @@ function startPolling() {
return
}
}
nowTs.value = Date.now()
fetchAnswerData()
}, 3000)
}
@ -714,7 +716,7 @@ onUnmounted(() => {
<NButton secondary strong @click="handleNext">
下一题
</NButton>
<NButton type="tertiary" strong class="px-6" @click="handlePublish">
<NButton type="tertiary" strong class="px-6" :disabled="!canPublish" @click="handlePublish">
发布结果
</NButton>
</div>
@ -776,7 +778,7 @@ onUnmounted(() => {
!item._isManual ? 'border-gray-300 bg-gray-50/30' : (item.Status === 1 ? 'border-green-500 bg-green-50/30' : 'border-red-500 bg-red-50/30'),
]"
>
<div v-if="item.AnswerText" class="text-lg text-gray-800 font-bold">
<div v-if="item.AnswerText" class="border-b border-gray-200 pb-2 text-lg text-gray-800 font-bold">
<span> ai识别出的答题文本</span>
<Typewriter :text="item.AnswerText" />
</div>

View File

@ -17,6 +17,11 @@ function init() {
typeItInstance.destroy()
}
// 清空上一次的内容,避免重复累加显示
if (textRef.value) {
textRef.value.textContent = ''
}
const options: Options = {
strings: props.text || '',
lifeLike: true,

View File

@ -128,8 +128,10 @@ function toggleFlip(index: number) {
}
else {
flippedCards.value.add(index)
audioController.play('flip') // 播放翻牌
audioController.play('flip') // 翻牌
}
// 每次点击都播放翻牌音效(无论翻开还是翻回去)
// audioController.play('flip')
}
async function initSyncRouteParams() {
@ -148,6 +150,39 @@ async function initSyncRouteParams() {
const showCards = ref(false)
// 3D 悬停效果逻辑
const cardStyles = ref<Record<number, any>>({})
function handleMouseMove(e: MouseEvent, index: number) {
const card = e.currentTarget as HTMLElement
const rect = card.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const centerX = rect.width / 2
const centerY = rect.height / 2
// 计算旋转角度
// 鼠标在上方(y < centerY) -> rotateX > 0 (上半部分凸起)
// 鼠标在下方(y > centerY) -> rotateX < 0 (下半部分凸起)
const rotateX = ((centerY - y) / centerY) * 10
// 鼠标在左方(x < centerX) -> rotateY < 0 (左半部分凸起)
// 鼠标在右方(x > centerX) -> rotateY > 0 (右半部分凸起)
const rotateY = ((x - centerX) / centerX) * 10
cardStyles.value[index] = {
transform: `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.02, 1.02, 1.02)`,
transition: 'transform 0.1s ease-out', // 移动时响应要快
}
}
function handleMouseLeave(index: number) {
cardStyles.value[index] = {
transform: 'perspective(1000px) rotateX(0) rotateY(0) scale3d(1, 1, 1)',
transition: 'transform 0.5s ease', // 离开时平滑复位
}
}
onMounted(async () => {
await initSyncRouteParams()
await getQuestions()
@ -160,8 +195,8 @@ onMounted(async () => {
<template>
<CompetitionLayout
:show-back="true" :show-next="showNextButton" :title="activityName" :title-bg-type="2"
@back="handleBack" @next="handleNext"
:show-back="true" :show-next="showNextButton" next-btn-text="下一步" :title="activityName"
:title-bg-type="2" @back="handleBack" @next="handleNext"
>
<div class="h-screen w-full overflow-y-auto font-sans">
<div class="relative z-10 min-h-full w-full">
@ -172,9 +207,12 @@ onMounted(async () => {
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 showCards ? questions : []" :key="node.id"
class="w-[44%] card-wrapper 2xl:w-[14%] lg:w-[21%] md:w-[28%] xl:w-[17%]"
:style="{ animationDelay: `${index * 0.2}s` }" @click="toggleFlip(index)"
v-for="(node, index) in showCards ? questions : []" :key="index"
class="w-[44%] card-wrapper 2xl:w-[14%] lg:w-[21%] md:w-[28%] xl:w-[17%]" :style="{
animationDelay: `${index * 0.2}s`,
...(cardStyles[index] || {}),
}" @click="toggleFlip(index)" @mousemove="(e) => handleMouseMove(e, index)"
@mouseleave="handleMouseLeave(index)"
>
<div class="flip-card-inner" :class="{ 'is-flipped': flippedCards.has(index) }">
<!-- 正面 (Front) - 显示背面图案 -->
@ -259,10 +297,6 @@ onMounted(async () => {
cursor: pointer;
transition: transform 0.3s ease;
box-shadow: none !important;
&:hover {
transform: translateY(-10px); // [悬停效果] 鼠标移上去时卡片轻微上浮
}
}
.flip-card-inner {
@ -278,7 +312,7 @@ onMounted(async () => {
transform-style: preserve-3d; // [3D空间] 确保子元素正反面在3D空间中渲染而不是压平在平面上
border-radius: 20px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.2); // [静态阴影] 默认状态下的投影
// box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.2); // [静态阴影] 默认状态下的投影
&.is-flipped {
// [翻转状态]
@ -300,7 +334,7 @@ onMounted(async () => {
border-radius: 20px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.5); // 增加微妙的边框
// border: 1px solid rgba(255, 255, 255, 0.5); // 增加微妙的边框
}
// 正面(实际是卡背图案)
@ -370,8 +404,8 @@ onMounted(async () => {
}
.inner-circle-decoration {
width: 100px;
height: 100px;
width: 98px;
height: 98px;
border-radius: 50%;
display: flex;
align-items: center;

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import drawImg from '@/assets/imgs/user/draw.svg'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetCurrentQuestion } from '@/service/api/game'
@ -130,29 +131,29 @@ function handleBack() {
}
const questionMainTitle = computed(() => question.value?.QuestionList?.Name || '')
const questionTitle = computed(() => question.value?.QuestionList?.QuestionContent || question.value?.QuestionList?.Name || '')
</script>
<template>
<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">
{{ questionMainTitle || '' }}
{{ questionTitle || '' }}
</div>
</div> -->
</div>
<!-- 题目卡片列表 -->
<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-else class="w-full flex items-center justify-center pb-10">
<div v-if="question" class="lottery-stick-container">
<div
class="lottery-stick" :class="{ 'is-selected': flippedId === question?.Activity_Question?.ID }"
@click="handleCardClick(question)"
>
<!-- 签身 (默认显示) -->
<div class="stick-face stick-body">
<!-- <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"
@ -160,11 +161,12 @@ const questionMainTitle = computed(() => question.value?.QuestionList?.Name || '
</div>
</div>
<!-- 签面 (翻转后显示 - 实际上是放大显示的) -->
<div class="stick-face stick-content">
<span class="writing-mode-vertical-rl text-2xl text-red-600 font-bold">{{ questionMainTitle
}}</span>
</div>
</div> -->
<!-- 禁用放大 -->
<NImage :src="drawImg" class="h-full w-full object-cover" :preview-disabled="true" />
</div>
</div>
</div>
@ -178,8 +180,8 @@ const questionMainTitle = computed(() => question.value?.QuestionList?.Name || '
<style scoped>
.lottery-stick-container {
width: 50px;
height: 240px;
/* width: 50px;
height: 240px; */
perspective: 1000px;
cursor: pointer;
transition: transform 0.3s;
@ -236,7 +238,7 @@ const questionMainTitle = computed(() => question.value?.QuestionList?.Name || '
/* 选中(抽出)动画状态 */
.lottery-stick.is-selected {
transform: translateY(-100px) scale(2) rotateY(180deg);
transform: translateY(-100px) scale(1.4) rotateY(180deg);
z-index: 50;
}

View File

@ -83,7 +83,7 @@ function buildOption(list: BarDatum[]): ECOption {
},
},
{
name: '正确数量',
name: '预估得分',
type: 'bar',
data: correct,
barWidth: 24,

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import startIcon from '@/assets/imgs/user/star-icon.svg'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetGroupListByActivityID } from '@/service/api/game'
@ -94,7 +95,7 @@ onMounted(() => {
<div class="icon-wrapper">
<div class="flower-bg" />
<div class="inner-icon">
<img src="@/assets/imgs/user/star-icon.svg" alt="star" class="star-img">
<NImage :src="startIcon" alt="star" class="star-img" width="180" height="187" :preview-disabled="true" object-fit="contain" />
</div>
</div>
<div class="group-name-tag">
@ -153,8 +154,8 @@ onMounted(() => {
}
.icon-wrapper {
width: 100px;
height: 100px;
// width: 100px;
// height: 100px;
position: relative;
display: flex;
align-items: center;

View File

@ -49,10 +49,7 @@ function handleNext() {
<template>
<CompetitionLayout
:show-back="true"
:show-next="true"
:title="activityName"
@back="handleBack"
:show-back="true" :show-next="true" :title="activityName" next-btn-text="下一步" @back="handleBack"
@next="handleNext"
>
<div class="items-flex-start h-full w-full flex justify-center font-sans">

View File

@ -113,7 +113,7 @@ onMounted(() => {
@click="selectTeam(item.id)"
>
<div class="icon-wrapper">
<img src="@/assets/imgs/user/star-icon.svg" alt="team-icon" class="h-full w-full object-cover">
<img src="@/assets/imgs/user/team-title-icon.svg" alt="team-icon" class="h-full w-full object-cover">
</div>
<div class="team-name">
{{ item.name }}
@ -263,7 +263,7 @@ onMounted(() => {
justify-content: center;
// background: url('@/assets/imgs/team-icon-bg.svg') no-repeat center center;
background-size: contain;
margin-bottom: 20px;
margin-bottom: 10px;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
}
@ -272,6 +272,10 @@ onMounted(() => {
color: #333;
font-weight: bold;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
background: url('@/assets/imgs/user/team-title-bg.svg') no-repeat center center;
background-size: contain;
padding: 15px 30px;
border-radius: 10px;
}
}
</style>