Files
reader-star/apps/admin/src/views/user/analysis/index.vue
rjl 0d03fdd782 fix: 优化图片更新逻辑防止闪烁并调整样式
- 在结果详情页和用户分析页引入图片缓存和静默更新机制,通过比较图片大小避免不必要的更新,防止界面闪烁
- 调整用户分析页的布局样式,优化团队名称和评分信息的显示,避免内容溢出
- 禁用折线图动画以提升性能,并更新环境配置中的服务地址
2026-04-02 17:42:19 +08:00

292 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<script setup lang="ts">
import { NImage } from 'naive-ui'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchCheckNextStep, fetchGetCurrentQuestion, fetchGetGameStatistics } from '@/service/api/game'
import { useCompetitionStore } from '@/store/modules/competition'
const store = useCompetitionStore()
const { routerPushByKey } = useRouterPush()
const route = useRoute()
const activityId = computed(() => route.query?.activityId as string)
const groupsId = computed(() => route.query?.groupsId as string)
const teamId = computed(() => route.query?.teamId as string)
const teamIds = computed(() => route.query?.teamIds as string)
const currentQuestionMainInfo = computed(() => store.currentQuestionMainInfo)
const currentQuestionDetail = computed(() => store.currentQuestionDetail)
const TeamGroup_QuestionID = computed(() => currentQuestionMainInfo.value?.TeamGroup_QuestionID || 0)
const QuestionID = computed(() => currentQuestionMainInfo.value?.Activity_Question.ID || 0)
const QuestionDetailID = computed(() => currentQuestionDetail.value?.Id || 0)
const zoomedImage = ref<string | null>(null)
const isCompleted = ref(false)
const showNextBtn = ref(false)
const teams = ref<any[]>([])
function handleBack() {
routerPushByKey('user_game', {
query: {
activityId: activityId.value,
groupsId: groupsId.value,
teamId: teamId.value,
teamIds: teamIds.value,
},
})
}
function handleNext() {
if (isCompleted.value) {
routerPushByKey('user_groups', {
query: {
activityId: activityId.value,
},
})
return
}
routerPushByKey('user_draw', {
query: {
activityId: activityId.value,
groupsId: groupsId.value,
teamId: teamId.value,
teamIds: teamIds.value,
RoundType: Number(currentQuestionMainInfo.value?.Activity_Question.RoundType) || 0,
},
})
// 清空当前题目信息
store.initData()
}
/**
* 获取题目详情
* 检测是否还有题可以抽取如果data为0则提示没有题可抽取
*/
async function initQuestions() {
if (!activityId.value)
return
try {
const { data: outlineData, error } = await fetchGetCurrentQuestion({
ActivityID: Number(activityId.value),
GroupID: Number(groupsId.value),
RoundType: (currentQuestionMainInfo.value?.Activity_Question.RoundType) || 0,
})
// eslint-disable-next-line no-console
console.log(outlineData, 'outlineData')
if (error) {
window?.$message?.error(error.message)
}
isCompleted.value = outlineData?.data?.IsCompleted || false
}
catch (error) {
window.$message?.error('获取题目失败,请重试')
console.error(error)
}
}
const timer = ref<ReturnType<typeof setInterval> | undefined>(undefined)
const statisticsTimer = ref<ReturnType<typeof setInterval> | undefined>(undefined)
/** 定时刷新 检查评委是否已经完成打分,是否可以下一步 fetchCheckNextStep */
async function checkNextStep() {
if (!activityId.value || !groupsId.value)
return
try {
const { data: nextStepData, error } = await fetchCheckNextStep(TeamGroup_QuestionID.value || 0)
// eslint-disable-next-line no-console
console.log(nextStepData, 'nextStepData')
if (error) {
window?.$message?.error(error.message)
}
const isUse = nextStepData?.data?.IsUse || false
showNextBtn.value = isUse
if (isUse) {
await initQuestions()
clearInterval(timer.value)
}
}
catch (error) {
window.$message?.error('检查是否可以下一步失败,请重试')
console.error(error)
}
}
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) {
try {
const _params = {
TeamGroupID: Number(groupsId.value),
QuestionID: Number(QuestionID.value),
QuestionDetaiID: Number(QuestionDetailID.value),
}
const { data } = await fetchGetGameStatistics(_params)
const rawTeams = (data?.data as any[]) || []
teams.value = rawTeams.map((team) => {
const uniqueId = team.TeamID || team.TeamName || Math.random().toString()
team._uniqueId = uniqueId
if (team.UserAnswerPicture) {
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
})
}
catch (error: any) {
console.error('Failed to fetch game statistics', error)
window.$message?.error(error.message || '获取游戏现场统计结果失败')
}
}
}
// 定时刷新 检查是否可以下一步
timer.value = setInterval(checkNextStep, 3000)
// 定时刷新 游戏统计数据
statisticsTimer.value = setInterval(fetchGetGameStatisticsData, 3000)
/** 销毁 定时刷新 检查是否可以下一步 */
onBeforeUnmount(() => {
clearInterval(timer.value)
clearInterval(statisticsTimer.value)
})
</script>
<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"
@next="handleNext"
>
<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">
<div
v-for="team in teams" :key="team.id"
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 justify-between gap-2 rounded-full from-sky-300 to-cyan-200 bg-gradient-to-r px-4 py-2 shadow-sm"
>
<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 -->
<div class="relative mb-4 flex-1 overflow-hidden border border-gray-300 rounded bg-gray-50">
<!-- Grid Background Simulation -->
<div class="pointer-events-none absolute inset-0 grid grid-cols-8 gap-px bg-gray-200 opacity-20">
<!-- <div v-for="n in 64" :key="n" class="bg-white" /> -->
</div>
<NImage :src="team.UserAnswerPicture" class="h-full w-full" object-fit="contain" />
</div>
<!-- Stats & Score -->
<!-- <div class="flex flex-col gap-2">
<div class="text-sm text-gray-700 font-medium">
写了数量{{ team.writtenCount }}正确{{ team.correctCount }}积分{{ team.score }}
</div>
</div> -->
</div>
</div>
</div>
<!-- Zoom Modal -->
<div
v-if="zoomedImage" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
@click="zoomedImage = null"
>
<div class="relative max-h-[90vh] max-w-[90vw] p-4">
<img :src="zoomedImage" class="max-h-full max-w-full rounded bg-white shadow-2xl" alt="Zoomed Answer">
<div class="absolute bottom-4 left-1/2 text-sm text-white/80 -translate-x-1/2">
点击任意处关闭
</div>
</div>
</div>
</div>
</CompetitionLayout>
</template>
<style scoped>
/* Custom Scrollbar for content if needed */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
</style>