8 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
rjl
a7cf35e6bf chore(admin): 更新上传图片的基础URL为生产环境
将开发环境的上传地址替换为生产环境的API域名,确保生产构建使用正确的服务端点。
2026-03-31 10:26:47 +08:00
rjl
c617307c79 feat(admin): 更新用户界面组件并优化样式
- 移除 uno.config.ts 中的注释以简化字体配置
- 更新 .env.dev 文件中的服务基础 URL
- 新增用户排名标题背景图片并优化相关组件样式
- 调整 ActionButton 组件的最小宽度
- 修改 CompetitionLayout 组件中的图像和样式以提升视觉效果
- 更新全局 CSS 文件以增加基础字体大小
- 优化 results-detail 视图中的发布逻辑延迟
- 调整用户分析和封面页面的文本样式
- 更新团队页面的团队名称样式以适应设计规范
2026-03-26 15:18:45 +08:00
21 changed files with 338 additions and 160 deletions

View File

@ -10,7 +10,7 @@ VITE_APP_TITLE="阅读比赛配置后台"
VITE_BASE_OSS_URL = https://oss.qyzhjy.com/
# 上传图片的URL
VITE_BASE_UPLOAD_URL="http://192.168.20.150:8080"
VITE_BASE_UPLOAD_URL="https://api.qyzhjy.com"
VITE_APP_DESC="阅读比赛配置"

View File

@ -5,7 +5,8 @@
# VITE_SERVICE_BASE_URL=http://api.ydzx.qyzhjy.com
VITE_SERVICE_BASE_URL=http://172.16.10.130:5000
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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

After

Width:  |  Height:  |  Size: 177 KiB

View File

@ -135,7 +135,7 @@ function handleClick() {
.custom-btn {
background: v-bind(customBg);
background-color: transparent;
min-width: 210px;
min-width: 220px;
width: auto;
height: 60px;
padding: 0 20px;
@ -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">
<img :src="cqLogo" alt="重庆logo" class="h-20 w-auto">
<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>
@ -249,17 +249,23 @@ watch(titleBgUrl, (newUrl) => {
<section v-if="showTitle" class="title-section">
<Transition name="scroll-unfold" appear>
<div v-if="showContent" class="title-wrapper">
<div class="scroll-bg">
<div
class="scroll-bg"
:style="{ width: props.titleBgType === 3 ? '400px' : props.titleBgType === 4 ? '420px' : '480px' }"
>
<!-- 预渲染所有背景图 opacity 切换避免加载抖动 -->
<img
v-for="(url, _key) in titleBgUrlMapping" :key="_key" :src="url" class="scroll-bg-img" :class="{
active: titleBgUrl === url && imageLoadStatus[url] !== false,
loading: imageLoadStatus[url] === undefined,
}" :style="{ visibility: imageLoadStatus[url] === false ? 'hidden' : 'visible' }" aria-hidden="true"
@load="imageLoadStatus[url] = true" @error="imageLoadStatus[url] = false"
}" :style="{ visibility: imageLoadStatus[url] === false ? 'hidden' : 'visible' }"
aria-hidden="true" @load="imageLoadStatus[url] = true" @error="imageLoadStatus[url] = false"
>
<Transition name="fade-in-delay" appear>
<h1 v-if="showContent" class="main-title" :style="{ color: titleTextColor }">
<h1
v-if="showContent" class="main-title"
:style="{ color: titleTextColor, letterSpacing: props.titleBgType === 2 ? '6px' : props.titleBgType === 4 ? '1px' : '4px', fontSize: props.titleBgType === 4 ? '38px' : '' }"
>
{{ title }}
</h1>
</Transition>
@ -285,7 +291,7 @@ watch(titleBgUrl, (newUrl) => {
class="back-btn" :style="{ visibility: showBack ? 'visible' : 'hidden' }" type="custom"
:text="backBtnText || ''" :theme="btnTheme" :font-size="fontSize" @click="handleBack"
>
<SvgIcon v-if="!backBtnText" icon="mdi:arrow-left" size="40" class="arrow-icon" />
<SvgIcon v-if="!backBtnText" icon="mdi:arrow-left" size="42" class="arrow-icon" />
</ActionButton>
</div>
@ -294,14 +300,14 @@ watch(titleBgUrl, (newUrl) => {
v-if="showNext" type="custom" :text="nextBtnText || '下一步'" :theme="btnTheme"
:disabled="nextDisabled" :font-size="fontSize" @click="handleNext"
>
<SvgIcon v-if="!nextBtnText" icon="mdi:arrow-right" class="arrow-icon" size="40" />
<SvgIcon v-if="!nextBtnText" icon="mdi:arrow-right" class="arrow-icon" size="42" />
</ActionButton>
<ActionButton
v-if="showNextBtn2" :type="nextBtnText2 ? 'text' : 'button'" :text="nextBtnText2 || '下一步'"
:theme="btnTheme" :disabled="nextDisabled" @click="handleNext2"
>
<SvgIcon v-if="!nextBtnText2" icon="mdi:arrow-right" class="arrow-icon" size="40" />
<SvgIcon v-if="!nextBtnText2" icon="mdi:arrow-right" class="arrow-icon" size="42" />
</ActionButton>
</div>
</footer>
@ -330,9 +336,9 @@ watch(titleBgUrl, (newUrl) => {
}
.scroll-bg {
width: 400px !important;
width: 400px;
/* Fixed width to prevent squishing during animation */
height: 155px !important;
height: 180px !important;
position: relative;
display: flex;
align-items: center;
@ -363,10 +369,10 @@ watch(titleBgUrl, (newUrl) => {
}
.main-title {
font-size: 2.6rem;
font-size: 2.66rem;
color: $text-color;
margin: 0;
letter-spacing: 3px;
letter-spacing: 2px;
text-align: center;
white-space: nowrap;
/* Prevent text wrapping */
@ -444,15 +450,15 @@ watch(titleBgUrl, (newUrl) => {
.page-header {
position: absolute;
top: -20px;
top: -58px;
left: 0;
width: 100%;
height: 196px;
display: flex;
justify-content: space-between;
padding: 20px 40px;
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

@ -7,6 +7,7 @@ body,
#app {
height: 100%;
text-autospace: normal;
font-size: 18px;
}
html {

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,14 +525,11 @@ async function handleNexCurrentQuestion() {
currentQuestionId.value = currentQuestion.value?.QuestionID || currentQuestionId.value
currentGroupId.value = currentQuestion.value?.TeamGroupID || currentGroupId.value
}
// 更新当前时间戳,以便重新计算 canPublish
nowTs.value = Date.now()
}
function handlePublish() {
const endTimeStr = (currentQuestion.value as any)?.EndTime
if (endTimeStr && Date.now() < new Date(endTimeStr).getTime() + 5000) {
if (endTimeStr && Date.now() < new Date(endTimeStr).getTime() + 1000) {
window.$message?.warning('倒计时未结束需等待5秒延迟暂不能发布')
return
}
@ -589,7 +645,7 @@ const canPublish = computed(() => {
if (!endTime)
return true
// 结束时间 + 5秒延迟才能发布
const end = new Date(endTime).getTime() + 5000
const end = new Date(endTime).getTime() + 2000
return nowTs.value >= end
})
@ -599,8 +655,8 @@ function startPolling() {
// 检查是否已经结束
const endTime = (currentQuestion.value as any)?.EndTime
if (endTime) {
// 增加 12s 缓冲时间
const end = new Date(endTime).getTime() + 12000
// 增加 5s 缓冲时间
const end = new Date(endTime).getTime() + 3000
const now = new Date().getTime()
if (now > end) {
console.log('当前题目已结束含12s缓冲仅获取一次最终结果不启动轮询', endTime)
@ -617,25 +673,23 @@ function startPolling() {
}
fetchAnswerData() // 立即获取
nowTs.value = Date.now()
pollingTimer.value = setInterval(() => {
// 每次轮询前再次检查是否过期
const currentEndTime = (currentQuestion.value as any)?.EndTime
if (currentEndTime) {
// 增加 12s 缓冲时间
const endTs = new Date(currentEndTime).getTime() + 12000
// 增加 1s 缓冲时间
const endTs = new Date(currentEndTime).getTime() + 1000
if (Date.now() > endTs) {
console.log('题目时间已到含12s缓冲停止轮询', 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
})
@ -161,14 +210,14 @@ onBeforeUnmount(() => {
<template>
<CompetitionLayout
:show-title="true" :show-back="false" :show-next="showNextBtn" :title-bg-type="3" title-text-color="#ffffff" title="答题图解" action-position="top"
back-btn-text="返回" :next-btn-text="isCompleted ? '该组已完成回到选组' : '下一题'" btn-theme="light" @back="handleBack"
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')
@ -240,7 +240,7 @@ onMounted(async () => {
<!-- 文字内容 -->
<div class="relative z-10 transform text-center">
<div
class="card-text text-2xl text-[#333333] font-bold leading-tight font-serif drop-shadow-sm"
class="card-text text-26px text-[#333333] font-bold leading-tight font-serif drop-shadow-sm"
>
<span class="block">{{ node.moduleName.slice(0, 2) }}</span>
<span class="block">{{ node.moduleName.slice(2) }}</span>
@ -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--26px top--22px 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

@ -126,7 +126,7 @@ watch(
{{ title }}
</div>
<div class="mb-1 text-gray-800 font-bold leading-relaxed">
<div class="rich-content content-with-bg pinyin-text ml-3 mt-4 flex text-7xl !max-w-100% !px-16 !py-8 !tracking-[0.08em]">
<div class="rich-content content-with-bg pinyin-text ml-3 mt-4 flex text-7xl !max-w-100% !px-16 !py-4 !tracking-[0.08em]">
{{ content }}
</div>
</div>
@ -135,8 +135,8 @@ watch(
<!-- 模板C: 汉字加一加 (提示+部件) -->
<div v-else-if="template === QuestionCategoryEnum.CharacterRadicalAddition" class="text-center">
<div class="mb-6 inline-block text-5xl font-bold leading-none">
<div class="rich-content ml-3 mt-4 flex gap-1 text-6xl tracking-[0.06em]">
请写出含有 <span class="text-color-red !text-6xl">{{ content }}</span>的汉字书写时间为60秒
<div class="rich-content ml-3 mt-4 flex gap-1 text-5xl tracking-[0.06em]">
请写出含有 <span class="text-color-red !text-5xl">{{ content }}</span>的汉字书写时间为60秒
</div>
</div>
</div>
@ -153,7 +153,7 @@ watch(
<!-- 模板E: 成语-文字要求 -->
<div v-else-if="template === QuestionCategoryEnum.IdiomWriting2" class="text-center">
<div class="mb-2">
<img :src="ImageUrl" preview-disabled class="max-h-380px max-w-700px" object-fit="cover">
<img :src="ImageUrl" preview-disabled class="max-h-368px max-w-700px" object-fit="cover">
</div>
</div>
@ -173,8 +173,8 @@ watch(
<!-- 模板: 场景猜诗句 -->
<div v-else-if="template === QuestionCategoryEnum.SceneBasedPoemGuessing" 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="rich-content" v-html="content" />
<div class="mb-1px p-16 text-3xl text-gray-800 font-bold leading-relaxed line-height-16">
<div class="rich-content indent-[2em]" v-html="content" />
</div>
</div>
@ -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

@ -3,7 +3,7 @@ import type { DataTableColumns } from 'naive-ui'
import { NDataTable, NImage, NTag, useMessage } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import title from '@/assets/imgs/user/rank-title.svg'
import title from '@/assets/imgs/user/rank-title.png'
import ActionButton from '@/components/custom/user/ActionButton.vue'
import { fetchUserRankList } from '@/service/api/rank'
import ExtraTimeModal from './components/ExtraTimeModal.vue'
@ -57,6 +57,23 @@ const columns = ref<DataTableColumns<TeamData>>([
const tableData = ref<TeamData[]>([])
// eslint-disable-next-line unused-imports/no-unused-vars
const mockUserRankList: Api.Rank.ActivityRankResList = Array.from({ length: 13 }, (_, teamIndex) => {
// eslint-disable-next-line ts/no-shadow
const questions = Array.from({ length: 10 }, (_, questionIndex) => {
const QuestionNO = questionIndex + 1
const ResultPotin = Math.max(0, 10 - teamIndex - (questionIndex % 3))
return { QuestionNO, ResultPotin }
})
return {
TeamID: 1001 + teamIndex,
Name: `测试队伍${teamIndex + 1}`,
TotalPoint: questions.reduce((sum, item) => sum + item.ResultPotin, 0),
Questions: questions,
}
})
async function getUserRankList() {
const { data, error } = await fetchUserRankList(MainID, RoundType)
if (error) {
@ -64,9 +81,11 @@ async function getUserRankList() {
return
}
if (data?.data && data.data.length > 0) {
const rankSource = data?.data || []
if (rankSource.length > 0) {
// 动态生成题目列
const firstTeam = data.data[0]
const firstTeam = rankSource[0]
const questionColumns = firstTeam.Questions.map(q => ({
title: `${q.QuestionNO}`,
key: `q${q.QuestionNO}`,
@ -94,12 +113,12 @@ async function getUserRankList() {
return <span>{rank}</span>
},
},
{ title: '队伍名称', key: 'teamName', align: 'left', width: 150, className: 'team-name' },
{ title: '队伍名称', key: 'teamName', align: 'left', width: 190, className: 'team-name' },
...questionColumns,
{ title: '总积分', key: 'total', align: 'center', className: 'total-score' },
]
tableData.value = data.data.map((item, index) => {
tableData.value = rankSource.map((item, index) => {
const questions: Record<string, number> = {}
item.Questions.forEach((q) => {
questions[`q${q.QuestionNO}`] = q.ResultPotin
@ -114,7 +133,7 @@ async function getUserRankList() {
} as TeamData
})
teamOptions.value = data.data.map(item => ({ label: item.Name, value: item.TeamID }))
teamOptions.value = rankSource.map(item => ({ label: item.Name, value: item.TeamID }))
}
}
@ -137,21 +156,21 @@ function rowClassName(row: TeamData) {
<template>
<CompetitionLayout :show-back="false" :show-next="false" :show-title="false">
<div class="leaderboard-container">
<div class="leaderboard-header">
<NImage :src="title" alt="logo" preview-disabled class="mt--60px" object-fit="contain" />
<p class="mt--20px text-center text-lg text-#6b778d">
<div class="leaderboard-container h-100%">
<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>
</p> -->
<ActionButton
v-if="hasExtraTimeBtn" class="position-absolute bottom-20px right-20px" type="text" text="加时赛"
@click="handleAddTime"
v-if="hasExtraTimeBtn" class="!z-index-100 position-absolute right-20px top-20px" type="text"
text="加时赛" @click="handleAddTime"
/>
</div>
<div class="leaderboard-content">
<NDataTable
:columns="columns" :data="tableData" :bordered="false" :bottom-bordered="false" size="large"
:row-class-name="rowClassName" class="rank-table"
:columns="columns" :data="tableData" :bordered="false" max-height="100%" :bottom-bordered="false"
size="large" :row-class-name="rowClassName" class="rank-table !h-100%"
/>
</div>
</div>
@ -163,7 +182,7 @@ function rowClassName(row: TeamData) {
/* 1. 整体容器和背景 */
.leaderboard-container {
width: 100%;
min-height: 100vh;
height: 100%;
background-image: url('https://raw.githubusercontent.com/0x3f3f3f3f/figure-bed/main/img/20240524150352.png');
background-size: cover;
background-position: center;
@ -193,19 +212,12 @@ function rowClassName(row: TeamData) {
}
.leaderboard-header h1 {
font-size: 2.5rem;
font-size: 2.2rem;
font-weight: 700;
color: #1a2a44;
margin: 0;
}
/* .leaderboard-header p {
font-size: 1rem;
color: #6b778d;
margin-top: 8px;
} */
/* 3. 表格内容区 */
.leaderboard-content {
width: 100%;
/* max-width: 1200px; */
@ -213,6 +225,7 @@ function rowClassName(row: TeamData) {
border-radius: 12px;
box-shadow: 0 8px 24px rgba(26, 42, 68, 0.05);
overflow: hidden;
height: calc(100% - 220px);
}
/* 4. Naive UI 表格深度定制 */
@ -221,7 +234,7 @@ function rowClassName(row: TeamData) {
}
:deep(.rank-table .n-data-table-th) {
font-size: 14px;
font-size: 24px;
font-weight: 600;
color: #495057;
border-bottom: 2px solid #dee2e6 !important;
@ -236,11 +249,11 @@ function rowClassName(row: TeamData) {
}
:deep(.rank-table .n-data-table-td) {
font-size: 15px;
font-size: 24px;
color: #333;
border-bottom: 1px solid #e9ecef !important;
padding-top: 16px !important;
padding-bottom: 16px !important;
/* padding-top: 16px !important; */
/* padding-bottom: 16px !important; */
}
/* 奇偶行交替色 */
@ -256,7 +269,7 @@ function rowClassName(row: TeamData) {
:deep(.total-score) {
font-weight: 700;
font-size: 1.1rem;
font-size: 2.1rem;
color: #e67e22;
}
@ -284,4 +297,11 @@ function rowClassName(row: TeamData) {
:deep(.rank-top-3 .total-score) {
color: #cd7f32;
}
/* 强制表格容器占满父级 */
.leaderboard-content :deep(.n-data-table) {
height: 100% !important;
max-height: 100% !important;
overflow-y: auto;
}
</style>

View File

@ -116,7 +116,7 @@ onMounted(() => {
<div class="icon-wrapper">
<img src="@/assets/imgs/user/team-title-icon.webp" alt="team-icon" class="h-full w-full object-cover">
</div>
<div class="team-name text-3xl">
<div class="team-name text-2xl">
{{ item.name }}
</div>
</div>

View File

@ -23,21 +23,6 @@ export default defineConfig<Theme>({
'icon-xl': '2rem',
// 针对 2048x1080 设计基准优化的字体大小
// 相比默认值适当放大,适配大屏显示
// 'xs': '0.8rem', // 12.8px (原 0.75rem = 12px)
// 'sm': '0.95rem', // 15.2px (原 0.875rem = 14px)
// 'base': '1.1rem', // 17.6px (原 1rem = 16px)
// 'lg': '1.3rem', // 20.8px (原 1.125rem = 18px)
// 'xl': '1.5rem', // 24px (原 1.25rem = 20px)
// '2xl': '1.75rem', // 28px (原 1.5rem = 24px)
// '3xl': '2.1rem', // 33.6px (原 1.875rem = 30px)
// '4xl': '2.5rem', // 40px (原 2.25rem = 36px)
// '5xl': '3.2rem', // 51.2px (原 3rem = 48px)
// '6xl': '4rem', // 64px (原 3.75rem = 60px)
// '7xl': '5rem', // 80px (原 4.5rem = 72px)
// '8xl': '6.5rem', // 104px (原 6rem = 96px)
// '9xl': '8.5rem', // 136px (原 8rem = 128px)
'xs': '0.875rem', // 原 0.75rem
'sm': '1rem', // 原 0.875rem
'base': '1.125rem', // 原 1rem