6 Commits

Author SHA1 Message Date
rjl
f4c3734639 fix: 修复翻牌音效播放失败问题
移除 AudioController 依赖,直接使用 Audio 对象播放新增的 checkout.mp3 音频文件,避免因 AudioController 实例化或配置问题导致的音效播放失败。
2026-04-10 15:33:43 +08:00
rjl
38a243af15 feat(admin): 添加答题前3秒倒计时动画和音效
在用户点击"开始答题"后,增加3秒倒计时动画和音效提示,提升答题体验
- 添加倒计时遮罩层和动画效果
- 播放倒计时音效文件
- 修改开始答题按钮状态为"准备中..."
- 调整环境配置中的API地址
2026-04-10 14:41:30 +08:00
rjl
0d03fdd782 fix: 优化图片更新逻辑防止闪烁并调整样式
- 在结果详情页和用户分析页引入图片缓存和静默更新机制,通过比较图片大小避免不必要的更新,防止界面闪烁
- 调整用户分析页的布局样式,优化团队名称和评分信息的显示,避免内容溢出
- 禁用折线图动画以提升性能,并更新环境配置中的服务地址
2026-04-02 17:42:19 +08:00
rjl
a76263375a fix: 修复UI样式和轮询逻辑问题
- 修复ActionButton禁用状态颜色错误
- 调整CompetitionLayout布局和z-index层级
- 修正analysis页面评委评分显示位置
- 优化QuestionRenderer字体大小和间距
- 改进cover页面卡片翻转动画效果
- 修复results-detail页面轮询逻辑和时间同步
2026-04-02 15:14:50 +08:00
rjl
9bc58c6270 style(图表): 优化柱状图动画与视觉样式
- 启用平滑动画提升交互体验
- 调整颜色方案增强视觉层次
- 添加阴影效果和间距提升可读性
- 优化标签位置和字体大小
2026-04-01 15:13:23 +08:00
rjl
7b505ec4aa style(admin): 调整侧边栏宽度和文本样式
- 将问题库侧边栏默认宽度从525px调整为535px
- 统一标签页和分类树中的文本字号大小
- 优化文件夹图标的显示尺寸
2026-03-31 10:56:27 +08:00
15 changed files with 255 additions and 90 deletions

View File

@ -6,6 +6,7 @@
# VITE_SERVICE_BASE_URL=http://api.ydzx.qyzhjy.com
VITE_SERVICE_BASE_URL=http://api.ydzx.qyzhjy.com/
# VITE_SERVICE_BASE_URL=http://172.16.10.130:5000
VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://localhost:9528"

Binary file not shown.

Binary file not shown.

View File

@ -151,8 +151,8 @@ function handleClick() {
}
&.is-disabled {
color: #eb5e55;
filter: grayscale(0.5);
color: red;
// filter: grayscale(0.5);
opacity: 0.6;
}

View File

@ -229,12 +229,12 @@ watch(titleBgUrl, (newUrl) => {
<!-- Header -->
<header class="page-header flex items-center px-8 text-[30px] text-[#333333] font-bold">
<!-- 左侧logo -->
<div class="flex cursor-pointer items-center" @click="handleBackHome">
<div class="!z-index-99 flex cursor-pointer items-center" @click="handleBackHome">
<img :src="cqLogo" alt="重庆logo" class="h-18 w-auto">
</div>
<!-- 中间文字 -->
<div class="flex flex-1 justify-center">
<div class="!z-index-99 flex flex-1 justify-center">
<img :src="qwLogo" alt="重庆logo" class="h-14 w-auto">
</div>
@ -450,16 +450,15 @@ watch(titleBgUrl, (newUrl) => {
.page-header {
position: absolute;
top: -50px;
top: -58px;
left: 0;
width: 100%;
height: 180px;
height: 196px;
display: flex;
justify-content: space-between;
padding: 20px 44px;
font-weight: 700;
color: $secondary-color;
z-index: 10;
}
.main-content {

View File

@ -214,7 +214,7 @@ init()
<template #prefix>
<SvgIcon :icon="tab.icon" :local-icon="tab.localIcon" class="inline-block align-text-bottom text-16px" />
</template>
<div class="max-w-240px ellipsis-text">
<div class="max-w-240px ellipsis-text text-14px">
{{ tab.label }}
</div>
</PageTab>

View File

@ -8,7 +8,7 @@ defineOptions({ name: 'QuestionStore' })
const currentCategory = ref<any>(null)
// Resize logic
const siderWidth = ref(525)
const siderWidth = ref(535)
const LOCAL_STORAGE_KEY = 'question-store-sider-width'
const minWidth = 300
const maxWidth = 800

View File

@ -231,10 +231,10 @@ onMounted(() => {
>
<div class="flex items-center gap-2 overflow-hidden">
<SvgIcon
icon="carbon:folder" class="flex-shrink-0 text-lg"
icon="carbon:folder" class="flex-shrink-0 text-sm"
:class="selectedKey === item.Id ? 'text-primary' : 'text-gray-400'"
/>
<span class="truncate font-medium">{{ item.Name }} - {{ item.QuestionContent }}</span>
<span class="truncate text-16px font-medium">{{ item.Name }} - {{ item.QuestionContent }}</span>
</div>
<!-- Action Buttons -->

View File

@ -1,7 +1,7 @@
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
import { useStorage } from '@vueuse/core'
import { useIntervalFn, useStorage } from '@vueuse/core'
import { NButton, NCard, NImage, NInputNumber, NTag, useMessage } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
@ -33,6 +33,9 @@ const questionInfo = ref<Api.Competition.QuestionListDetailRound>() // 题目详
const currentQuestionDetailID = ref<number | string>('') // 当前选中的题目详情 ID
const nowTs = ref(Date.now())
useIntervalFn(() => {
nowTs.value = Date.now()
}, 1000)
// 判断当前activityId是正在比赛的活动
const isActiveActivityId = computed(() => {
if (!currentQuestion.value)
@ -254,7 +257,9 @@ async function fetchCurrentQuestion(shouldSync = true) {
return
}
currentQuestion.value = data?.data || {}
const endTime = new Date(currentQuestion.value.EndTime)
console.warn('current question', currentQuestion.value)
console.log('当前题目结束时间是:', endTime.toLocaleString())
// 只在初始化时同步选中题,轮询时不同步
if (shouldSync) {
syncCurrentQuestionSelection()
@ -265,6 +270,42 @@ async function fetchCurrentQuestion(shouldSync = true) {
}
}
const blobSizeCache = new Map<string, number>()
const objectUrlCache = new Map<string, string>()
async function checkAndUpdateImage(uniqueId: string, originalUrl: string, item: any) {
try {
const timestamp = Date.now()
const separator = originalUrl.includes('?') ? '&' : '?'
const fetchUrl = `${originalUrl}${separator}_t=${timestamp}`
const response = await fetch(fetchUrl)
if (!response.ok)
return
const blob = await response.blob()
const prevSize = blobSizeCache.get(uniqueId)
if (prevSize === blob.size) {
return // 内容大小未变,认为图片没变,不更新 src避免闪烁
}
blobSizeCache.set(uniqueId, blob.size)
const objectUrl = URL.createObjectURL(blob)
const oldUrl = objectUrlCache.get(uniqueId)
if (oldUrl) {
URL.revokeObjectURL(oldUrl)
}
objectUrlCache.set(uniqueId, objectUrl)
// 替换为本地 Blob URL由于数据已在内存中更新时不会闪烁
item.AnswerValuePicture = objectUrl
}
catch (e) {
console.error('Silent image update failed:', e)
}
}
/**
* 获取答题卡数据
*/
@ -366,13 +407,22 @@ async function fetchAnswerData() {
// 强制更新图片 URL防止缓存
// 优先使用 ans 中的图片,如果没有则回退到 answerData 中的图片
const imageUrl = ans.AnswerValuePicture || (parsedAnswers.length === 1 && parsedAnswers[0] === ans ? answerData.UserAnswerPicture : '')
const uniqueId = `${answerData.TeamId}-${ans.Guid}`
let finalImageUrl = imageUrl
if (imageUrl) {
if (oldAns && oldAns._originalUrl === imageUrl) {
finalImageUrl = oldAns.AnswerValuePicture
}
else {
finalImageUrl = imageUrl
}
}
const cleanBaseUrl = ''
const timestamp = new Date().getTime()
finalImageUrl = `${finalImageUrl}?_t=${timestamp}`
console.log(finalImageUrl, 'finalImageUrl')
// 判断是否手动干预:
// 1. 本地存储有状态 (hasStoredStatus) 或有分数 (hasStoredScore)
// 2. 内存中有手动标记 (oldAns._isManual)
@ -402,6 +452,7 @@ async function fetchAnswerData() {
QuestionDetailID: answerData.QuestionDetaiID, // [新增] 注意后端字段拼写
AnswerText: ans.AnswerText,
AnswerValuePicture: finalImageUrl,
_originalUrl: imageUrl,
_baseImgUrl: cleanBaseUrl,
_t: timestamp, // 保存时间戳
Score: finalScore,
@ -410,17 +461,21 @@ async function fetchAnswerData() {
_aiStatus: aiStatus,
_aiScore: aiScore,
}
if (imageUrl) {
checkAndUpdateImage(uniqueId, imageUrl, resultItem)
}
return resultItem
}
// 如果没有手动编辑
return {
const resultItem = {
...ans,
TeamId: answerData.TeamId, // [新增] 注入 TeamId
QuestionId: answerData.QuestionId, // [新增]
QuestionDetailID: answerData.QuestionDetaiID, // [新增] 注意后端字段拼写
AnswerText: ans.AnswerText,
AnswerValuePicture: finalImageUrl,
_originalUrl: imageUrl,
_baseImgUrl: cleanBaseUrl,
_t: timestamp, // 保存时间戳
Status: ans.Status,
@ -429,6 +484,10 @@ async function fetchAnswerData() {
Score: null, // 默认 null
_isManual: false,
}
if (imageUrl) {
checkAndUpdateImage(uniqueId, imageUrl, resultItem)
}
return resultItem
})
return {
@ -466,9 +525,6 @@ async function handleNexCurrentQuestion() {
currentQuestionId.value = currentQuestion.value?.QuestionID || currentQuestionId.value
currentGroupId.value = currentQuestion.value?.TeamGroupID || currentGroupId.value
}
// 更新当前时间戳,以便重新计算 canPublish
nowTs.value = Date.now()
}
function handlePublish() {
@ -617,25 +673,23 @@ function startPolling() {
}
fetchAnswerData() // 立即获取
nowTs.value = Date.now()
pollingTimer.value = setInterval(() => {
// 每次轮询前再次检查是否过期
const currentEndTime = (currentQuestion.value as any)?.EndTime
if (currentEndTime) {
// 增加 5s 缓冲时间
const endTs = new Date(currentEndTime).getTime() + 3000
// 增加 1s 缓冲时间
const endTs = new Date(currentEndTime).getTime() + 1000
if (Date.now() > endTs) {
console.log('题目时间已到(含3s缓冲停止轮询', currentEndTime)
console.log('题目时间已到(含1s缓冲停止轮询', currentEndTime)
stopPolling()
return
}
}
nowTs.value = Date.now()
// 轮询时同时更新当前题目信息(获取最新的 EndTime但不触发同步
fetchCurrentQuestion(false)
fetchAnswerData()
}, 3000)
}, 2000)
}
function stopPolling() {

View File

@ -117,6 +117,42 @@ async function checkNextStep() {
}
}
const blobSizeCache = new Map<string, number>()
const objectUrlCache = new Map<string, string>()
async function checkAndUpdateImage(uniqueId: string, originalUrl: string, team: any) {
try {
const timestamp = Date.now()
const separator = originalUrl.includes('?') ? '&' : '?'
const fetchUrl = `${originalUrl}${separator}_t=${timestamp}`
const response = await fetch(fetchUrl)
if (!response.ok)
return
const blob = await response.blob()
const prevSize = blobSizeCache.get(uniqueId)
if (prevSize === blob.size) {
return // 内容大小未变,认为图片没变,不更新 src避免闪烁
}
blobSizeCache.set(uniqueId, blob.size)
const objectUrl = URL.createObjectURL(blob)
const oldUrl = objectUrlCache.get(uniqueId)
if (oldUrl) {
URL.revokeObjectURL(oldUrl)
}
objectUrlCache.set(uniqueId, objectUrl)
// 替换为本地 Blob URL由于数据已在内存中更新时不会闪烁
team.UserAnswerPicture = objectUrl
}
catch (e) {
console.error('Silent image update failed:', e)
}
}
/** 获取游戏现场统计结果 */
async function fetchGetGameStatisticsData() {
if (store.currentQuestionInfo && store.currentQuestionDetail) {
@ -129,12 +165,25 @@ async function fetchGetGameStatisticsData() {
const { data } = await fetchGetGameStatistics(_params)
const rawTeams = (data?.data as any[]) || []
// 为每个队伍的图片 URL 添加时间戳,防止缓存
teams.value = rawTeams.map((team) => {
const uniqueId = team.TeamID || team.TeamName || Math.random().toString()
team._uniqueId = uniqueId
if (team.UserAnswerPicture) {
const timestamp = Date.now()
const separator = team.UserAnswerPicture.includes('?') ? '&' : '?'
team.UserAnswerPicture = `${team.UserAnswerPicture}${separator}_t=${timestamp}`
const originalUrl = team.UserAnswerPicture
const oldTeam = teams.value.find(t => t._uniqueId === uniqueId)
if (oldTeam && oldTeam.UserAnswerPicture && oldTeam._originalUrl === originalUrl) {
// 保持原有的 URL避免每次轮询都刷新
team.UserAnswerPicture = oldTeam.UserAnswerPicture
}
else {
team.UserAnswerPicture = originalUrl
}
team._originalUrl = originalUrl
// 后台静默获取最新图片并对比
checkAndUpdateImage(uniqueId, originalUrl, team)
}
return team
})
@ -164,11 +213,11 @@ onBeforeUnmount(() => {
back-btn-text="返回" :next-btn-text="isCompleted ? '已完成返选组' : '下一题'" btn-theme="light" @back="handleBack"
@next="handleNext"
>
<div class="relative h-full w-full flex flex-col items-center px-12 pt-10 font-sans">
<div class="relative h-full w-full flex flex-col items-center px-10 pt-10px font-sans">
<!-- Content -->
<div class="w-full flex flex-col flex-1 overflow-hidden">
<div class="mb-2 text-xl text-red-600 font-bold">
备注评委正在评分中请等待
备注评委正在评分中请等待...
</div>
<div v-if="teams && teams.length > 0" class="grid grid-cols-4 h-full gap-6 pb-8">
@ -177,15 +226,17 @@ onBeforeUnmount(() => {
class="flex flex-col border-2 border-gray-200 rounded-xl bg-white/90 p-4 shadow-sm"
>
<div
class="mb-3 flex items-center gap-2 rounded-full from-sky-300 to-cyan-200 bg-gradient-to-r px-4 py-2 shadow-sm"
class="mb-3 flex items-center justify-between gap-2 rounded-full from-sky-300 to-cyan-200 bg-gradient-to-r px-4 py-2 shadow-sm"
>
<span class="text-lg text-white font-bold drop-shadow">{{ team.TeamName }}</span>
<span class="rounded-full bg-orange-100/60 px-5 py-2 text-sm">
AI评分 <span class="text-md text-orange-600 font-bold">{{ team.Points }}</span>
</span>
<span class="rounded-full bg-green-100/60 px-5 py-2 text-sm">
评委评分 <span class="text-md text-green-600 font-bold">{{ team.ResultPotins }}</span>
</span>
<span class="overflow-hidden text-ellipsis whitespace-nowrap text-lg text-white font-bold drop-shadow">{{ team.TeamName }}</span>
<div class="flex shrink-0 gap-2">
<span class="whitespace-nowrap rounded-full bg-green-100/90 px-3 py-1.5 text-sm">
评委评分 <span class="text-lg text-orange-600 font-bold">{{ team.ResultPotins }}</span>
</span>
<span class="whitespace-nowrap rounded-full bg-orange-100/60 px-3 py-1.5 text-sm">
AI评分 <span class="text-md text-orange-600 font-bold">{{ team.Points }}</span>
</span>
</div>
</div>
<!-- Answer Image Area -->

View File

@ -1,20 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import checkoutAudio from '@/assets/audio/checkout.mp3'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { PublishStatus } from '@/enum/business'
import { useRouterPush } from '@/hooks/common/router'
import { fetchUpdatePublishStatus } from '@/service/api/competition'
import { fetchGetQuestionTableByActivityID } from '@/service/api/game'
import { useActivityInfoStore } from '@/store/modules/activityinfo'
import { AudioController } from '@/utils/audio'
import { filterRepeat } from '@/utils/data'
const activityInfoStore = useActivityInfoStore()
const activityInfo = computed(() => activityInfoStore.activityInfo)
const { routerPushByKey, routerBack } = useRouterPush()
const audioController = new AudioController()
interface QuestionItem {
id: number
@ -128,7 +127,8 @@ function toggleFlip(index: number) {
}
else {
flippedCards.value.add(index)
audioController.play('flip') // 翻牌动效
const audio = new Audio(checkoutAudio)
audio.play().catch(e => console.error('Play audio failed', e))
}
// 每次点击都播放翻牌音效(无论翻开还是翻回去)
// audioController.play('flip')
@ -288,7 +288,7 @@ onMounted(async () => {
// [过冲状态]
// 飞到眼前一点点 (Z=50px)
// 角度稍微回弹 (rotateY -15deg)
transform: translate3d(0, -20px, 50px) rotateY(-15deg) rotateX(-10deg) scale(1.05);
transform: translate3d(0, -20px, 50px) rotateY(-15deg) rotateX(-10deg) scale(1.2);
}
100% {
@ -328,8 +328,8 @@ onMounted(async () => {
// [翻转状态]
// rotateY(180deg): 绕Y轴旋转180度实现翻面
// scale(1.05): 翻转同时放大1.05倍,产生"向观众逼近"的视觉冲击力
transform: rotateY(180deg) scale(1.06);
box-shadow: 0 20px 40px -5px rgba(0, 0, 0, 0.3); // [动态阴影] 翻转浮起时阴影更深、更扩散
transform: rotateY(180deg) scale(1.08);
box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.3); // [动态阴影] 翻转浮起时阴影更深、更扩散
}
}
@ -344,7 +344,7 @@ onMounted(async () => {
border-radius: 20px;
overflow: hidden;
// border: 1px solid rgba(255, 255, 255, 0.5); // 增加微妙的边框
// border: 1px solid rgba(231, 215, 144, 0.5); // 增加微妙的边框
}
// 正面(实际是卡背图案)
@ -367,7 +367,7 @@ onMounted(async () => {
// 背面(实际是内容面)
.flip-card-back {
// background-color: #fff;
transform: rotateY(180deg);
transform: rotateY(200deg);
// border: 4px solid #fff;
.card-content-design {
@ -409,7 +409,7 @@ onMounted(async () => {
// animation-delay: 0.2s; 确保卡片翻转到一半(正对屏幕)时才开始闪光,
// 模拟光线随着角度变化掠过卡片表面的物理效果
animation: shine-sweep 3s ease-out forwards;
animation-delay: 0.2s;
animation-delay: 1s;
}
}
@ -455,14 +455,14 @@ onMounted(async () => {
10% {
// 刚进入时瞬间变亮
opacity: 0.8;
opacity: 0.8; // 0.8 是一个半透明值,模拟光束的透明度,值越大越透明
}
100% {
// 扫出视野
top: 50%;
left: 150%;
opacity: 0;
opacity: 0.6;
}
}

View File

@ -2,6 +2,7 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import start3Audio from '@/assets/audio/timeout3.mp3'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetGameStatistics, fetchGetQuestionDetail, fetchSubmitQuestionResult } from '@/service/api/game'
@ -26,6 +27,9 @@ const loading = ref(false)
// 本地倒计时控制(为了在页面上显示“开始答题”还是倒计时)
const isStarted = ref(false)
const isPreparing = ref(false) // 是否正在准备中321倒计时
const showCountdown = ref(false) // 控制倒计时弹窗显示
const countdownNum = ref(3) // 倒计时数字
const pollingTimer = ref<number | null>(null) // 轮询定时器
console.log(activityInfo.value, 'activityInfo.value')
@ -130,19 +134,10 @@ async function fetchGetQuestionDetailData() {
async function handleNext() {
// 点击开始答题
if (!isStarted.value) {
isStarted.value = true
if (!isStarted.value && !isPreparing.value) {
isPreparing.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 = {
@ -153,19 +148,45 @@ async function handleNext() {
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)
await fetchSubmitQuestionResult(params) // 提交题目使用记录
// 调用接口成功后,显示 321 倒计时弹窗并播放音乐
showCountdown.value = true
countdownNum.value = 3
const audio = new Audio(start3Audio)
audio.play().catch(e => console.error('Play audio failed', e))
const timer = setInterval(() => {
countdownNum.value--
if (countdownNum.value === 0) {
clearInterval(timer)
showCountdown.value = false
isPreparing.value = false
isStarted.value = true
// 并在获取失败时尝试从 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)
// 立即拉取一次,然后开启轮询
fetchGetGameStatisticsData(params)
startPolling(params)
}
}, 1000)
}
}
catch (error) {
console.error('Failed to submit question result', error)
isPreparing.value = false
// 不阻塞流程,仅记录错误
}
}
else {
else if (isStarted.value) {
// 备用逻辑:如果已经在答题中(理论上不会触发,因为上面已经跳转),直接跳转
store.stopTimer()
stopPolling()
@ -255,8 +276,8 @@ const titleBgTypeNum = computed(() => RoundType.value === 0 ? 3 : 4) || 3
<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"
:next-btn-text="(isStarted || isPreparing) ? (isPreparing ? '准备中...' : formattedTime) : '开始答题'"
:next-disabled="isStarted || isPreparing" 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">
<!-- 题目说明 -->
@ -270,6 +291,18 @@ const titleBgTypeNum = computed(() => RoundType.value === 0 ? 3 : 4) || 3
</div>
</div>
<!-- 321 倒计时遮罩层 -->
<div
v-if="showCountdown"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<Transition name="scale" mode="out-in">
<div :key="countdownNum" class="text-[300px] color-#dd0315 font-bold">
{{ countdownNum }}
</div>
</Transition>
</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">
@ -285,7 +318,10 @@ const titleBgTypeNum = computed(() => RoundType.value === 0 ? 3 : 4) || 3
>
<!-- 图表标题栏 -->
<div v-if="isStarted" class="z-index-999 relative mb-4">
<img src="@/assets/imgs/user/rank-bg-kuang.png" alt="答题进度" class="z-index-999 absolute left--30px top--25px h-auto max-h-16 w-auto">
<img
src="@/assets/imgs/user/rank-bg-kuang.png" alt="答题进度"
class="z-index-999 absolute left--30px top--25px h-auto max-h-16 w-auto"
>
</div>
<!-- 柱状图ECharts -->
@ -303,4 +339,16 @@ const titleBgTypeNum = computed(() => RoundType.value === 0 ? 3 : 4) || 3
/* red-500 */
font-weight: bold;
}
/* 321 倒计时动画 */
.scale-enter-active,
.scale-leave-active {
transition: all 0.3s ease;
}
.scale-enter-from,
.scale-leave-to {
opacity: 0;
transform: scale(0.5);
}
</style>

View File

@ -40,11 +40,17 @@ function buildOption(list: BarDatum[]): ECOption {
const correct = list.map(i => i.correct)
return {
// 用动画,避免频繁刷新时的抖动
// 用动画,让柱子变化更平滑
animation: false,
animationDuration: 800,
animationEasing: 'linear', // 动画缓动函数平滑过渡cubicOutlinearquadOutquadIn
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#3b82f6',
borderWidth: 1,
textStyle: { color: '#1e3a8a' },
},
legend: {
data: ['答题数量', '预估得分'],
@ -52,17 +58,18 @@ function buildOption(list: BarDatum[]): ECOption {
right: 0,
icon: 'circle',
textStyle: {
color: '#666',
color: '#64748b',
fontSize: 12,
},
},
grid: { left: 16, right: 16, top: 40, bottom: 0, containLabel: true },
grid: { left: 16, right: 16, top: 70, bottom: 0, containLabel: true },
xAxis: {
type: 'category',
data: categories,
axisTick: { show: false }, // 隐藏 x 轴刻度线
axisLine: { show: false }, // 隐藏 x 轴线
axisLabel: {
color: '#003366', // 深蓝色字体
color: '#1e3a8a', // 深蓝色字体
fontWeight: 'bold',
fontSize: 14,
margin: 16,
@ -82,6 +89,7 @@ function buildOption(list: BarDatum[]): ECOption {
type: 'bar',
data: total,
barWidth: 24,
barGap: '40%', // 柱子间的间隙
itemStyle: {
// 从上到下:浅蓝 -> 深蓝
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
@ -89,14 +97,17 @@ function buildOption(list: BarDatum[]): ECOption {
{ offset: 1, color: '#3b82f6' }, // blue-500 (底部深色)
]),
borderRadius: [4, 4, 0, 0],
shadowBlur: 10,
shadowColor: 'rgba(59, 130, 246, 0.2)',
shadowOffsetY: 4,
},
label: {
show: true,
position: 'top',
color: '#003366', // 深蓝色
color: '#1e3a8a', // 深蓝色
fontWeight: 'bold',
fontSize: 18,
offset: [0, 2],
fontSize: 16,
offset: [0, -4],
},
},
{
@ -111,14 +122,17 @@ function buildOption(list: BarDatum[]): ECOption {
{ offset: 1, color: '#f97316' }, // orange-500 (底部深色)
]),
borderRadius: [4, 4, 0, 0],
shadowBlur: 10,
shadowColor: 'rgba(249, 115, 22, 0.2)',
shadowOffsetY: 4,
},
label: {
show: true,
position: 'top',
color: '#ea580c', // orange-600
fontWeight: 'bold',
fontSize: 18,
offset: [0, 2],
fontSize: 16,
offset: [0, -4],
},
},
],

View File

@ -185,7 +185,7 @@ watch(
>
<!-- 上方词组 -->
<div class="flex flex-wrap justify-center gap-4">
<div v-for="(word, index) in associationWords" :key="`word-${index}`" class="association-word text-bold">
<div v-for="(word, index) in associationWords" :key="`word-${index}`" class="association-word text-bold text-center text-3xl">
{{ word }}
</div>
</div>
@ -215,9 +215,9 @@ watch(
</div>
</div>
<!-- 模板: 诗词常识 -->
<!-- 模板: 加时赛诗词常识 -->
<div v-else-if="template === QuestionCategoryEnum.PoetryCommonKnowledge" class="w-full flex flex-col items-center">
<div class="mb-1 p-16 text-3xl text-gray-800 font-bold leading-relaxed line-height-20">
<div class="line-height mb-1 p-16 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
</div>
</div>
@ -445,8 +445,7 @@ watch(
:deep(p),
:deep(span),
:deep(div) {
font-size: 2.2rem;
/* text-5xl */
font-size: 2rem;
}
}
@ -494,15 +493,14 @@ watch(
.association-word {
background: url('@/assets/imgs/user/dui-kuang.png') no-repeat center;
background-size: 100% 100%;
min-width: 160px;
min-width: 170px;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: bold;
color: white;
padding: 0 20px;
letter-spacing: 0.2em;
}
.association-char {

View File

@ -157,13 +157,13 @@ function rowClassName(row: TeamData) {
<template>
<CompetitionLayout :show-back="false" :show-next="false" :show-title="false">
<div class="leaderboard-container h-100%">
<div class="leaderboard-header">
<div class="leaderboard-header !z-index-99">
<NImage :src="title" alt="logo" preview-disabled class="mt--42px" object-fit="contain" />
<!-- <p class="mt--16px text-center text-18px text-#6b778d">
"勤学如春起之苗,不见其增,日有所长"
</p> -->
<ActionButton
v-if="hasExtraTimeBtn" class="z-index-99 position-absolute right-20px top-20px" type="text"
v-if="hasExtraTimeBtn" class="!z-index-100 position-absolute right-20px top-20px" type="text"
text="加时赛" @click="handleAddTime"
/>
</div>