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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.7 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 MiB

After

Width:  |  Height:  |  Size: 10 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.0 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

View File

@ -112,6 +112,7 @@ export function fetchUpdateQuestionLibrary(params: Api.Question.AddQuestionLibra
Type: params.type,
IsGood: params.IsGood,
ImageUrl: params.imageUrl,
IsDisabled: params.IsDisabled,
},
})
}

View File

@ -93,9 +93,9 @@ export const useCompetitionStore = defineStore('competition', () => {
timerInterval.value = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value--
// 剩余5秒播放倒计时音效
if (timeLeft.value <= 5) {
audioController.play('tick')
// 剩余10秒播放倒计时音效
if (timeLeft.value <= 10) {
audioController.play('tick10')
}
}
else {

View File

@ -68,8 +68,8 @@ declare namespace Api {
type: QuestionScoreType
/** is priority 0: no 1: yes */
IsGood: number
/** is disabled 0: no 1: yes */
IsDisabled: number
/** is disabled */
IsDisabled: boolean
}
/** common search params of table */

View File

@ -1,10 +1,15 @@
import system10 from '@/assets/audio/boy-10s.mp3'
export class AudioController {
private audioContext: AudioContext | null = null
private soundFiles: Record<string, string> = {}
private audioCache: Record<string, HTMLAudioElement> = {}
constructor(soundFiles: Record<string, string> = {}) {
this.soundFiles = soundFiles
this.soundFiles = {
tick10: system10,
...soundFiles,
}
}
private initAudioContext() {
@ -17,7 +22,7 @@ export class AudioController {
* 播放指定类型的音效
* 优先使用预设的音频文件,如果没有配置或加载失败,则使用 Web Audio API 合成音效
*/
async play(type: 'start' | 'tick' | 'flip') {
async play(type: 'start' | 'tick10' | 'flip') {
// 尝试播放音频文件
if (this.soundFiles[type]) {
try {
@ -54,7 +59,7 @@ export class AudioController {
})
}
private playSynth(type: 'start' | 'tick' | 'flip') {
private playSynth(type: 'start' | 'tick10' | 'flip') {
this.initAudioContext()
if (!this.audioContext)
return
@ -75,7 +80,7 @@ export class AudioController {
osc.start(ctxTime)
osc.stop(ctxTime + 0.3)
}
else if (type === 'tick') {
else if (type === 'tick10') {
const osc = this.audioContext.createOscillator()
osc.connect(gain)
// 倒计时音效:急促的滴答声

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>