feat(admin): 优化用户界面交互与数据展示

- 在队伍选择页面增加卷轴展开动画,优化视觉体验
- 为游戏页面添加实时统计图表,使用 ECharts 展示各队答题进度
- 调整题目渲染器中图片的最大宽度为 200px
- 在路由配置中隐藏 results_results-detail-copy 菜单项
- 修复 API 函数命名和参数类型,更新接口返回类型定义
- 移动拼音听写题目数据文件到 store 目录
- 更新 VSCode 拼写检查配置,添加 ECharts 单词
This commit is contained in:
2026-03-02 09:53:34 +08:00
parent 2b50bd2c71
commit 81643c61ae
20 changed files with 866 additions and 203 deletions

View File

@ -126,9 +126,15 @@ async function initSyncRouteParams() {
}
}
const showCards = ref(false)
onMounted(async () => {
await initSyncRouteParams()
await getQuestions()
// 页面加载 300ms 后显示卡片,开始动画
setTimeout(() => {
showCards.value = true
}, 500)
})
</script>
@ -142,9 +148,16 @@ onMounted(async () => {
<div class="relative z-10 min-h-full w-full">
<div class="min-h-full w-full flex flex-col items-center pb-12 pt-24">
<!-- 卡片列表 -->
<div class="perspective-container max-w-[1800px] w-full flex flex-wrap justify-center gap-4 px-4 md:gap-10 md:px-12">
<TransitionGroup
tag="div"
name="card-anim"
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 questions" :key="node.id" class="w-[44%] card-wrapper 2xl:w-[14%] lg:w-[21%] md:w-[28%] xl:w-[17%]"
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)"
>
<div class="flip-card-inner" :class="{ 'is-flipped': flippedCards.has(index) }">
@ -174,7 +187,7 @@ onMounted(async () => {
</div>
</div>
</div>
</div>
</TransitionGroup>
</div>
</div>
</div>
@ -182,6 +195,41 @@ onMounted(async () => {
</template>
<style scoped lang="scss">
// ... existing styles ...
// 卡片进场动画 - 增强版 3D 纵深翻转入场 (Deep Space Flip-in)
.card-anim-enter-active {
// 增加动画时长到 1.5s,配合复杂的 3D 变换
animation: card-enter-deep 1.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes card-enter-deep {
0% {
opacity: 0;
// [初始状态]
// translateZ(-800px): 从屏幕深处(远离观众)开始
// translateY(300px): 从下方更深处升起
// rotateY(180deg): 背面朝上 (配合 flip-card 结构,视觉上可能是翻转状态)
// rotateX(45deg): 稍微倾斜
transform: translate3d(0, 300px, -800px) rotateY(180deg) rotateX(45deg) scale(0.5);
filter: blur(20px); // 深度模糊
}
50% {
opacity: 1;
filter: blur(0); // 聚焦
}
70% {
// [过冲状态]
// 飞到眼前一点点 (Z=50px)
// 角度稍微回弹 (rotateY -15deg)
transform: translate3d(0, -20px, 50px) rotateY(-15deg) rotateX(-10deg) scale(1.05);
}
100% {
// [最终归位]
transform: translate3d(0, 0, 0) rotateY(0) rotateX(0) scale(1);
}
}
.perspective-container {
perspective: 1000px; // [3D透视] 设置观察者距离数值越小透视感越强产生近大远小的3D效果
}

View File

@ -1,13 +1,14 @@
<!-- eslint-disable no-console -->
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetGameStatistics, fetchGetQuestionDetail, fetchSubmitQuestionResult } from '@/service/api/game'
import { useActivityInfoStore } from '@/store/modules/activityinfo'
import { useCompetitionStore } from '@/store/modules/competition'
import QuestionRenderer from '../modules/QuestionRenderer.vue'
import LineCharts from './modules/LineCharts.vue'
const { routerPushByKey } = useRouterPush()
@ -24,6 +25,7 @@ const loading = ref(false)
// 本地倒计时控制(为了在页面上显示“开始答题”还是倒计时)
const isStarted = ref(false)
const pollingTimer = ref<number | null>(null) // 轮询定时器
console.log(activityInfo.value, 'activityInfo.value')
console.log(currentQuestionInfo.value, 'currentQuestionInfo.value')
@ -38,10 +40,15 @@ onMounted(async () => {
await fetchGetQuestionDetailData()
})
onUnmounted(() => {
stopPolling()
})
// 监听倒计时,结束时自动跳转
watch(() => store.timeLeft, (newVal) => {
if (newVal === 0 && isStarted.value) {
store.stopTimer()
stopPolling()
routerPushByKey('user_analysis', {
query: {
activityId: String(activityId.value),
@ -75,16 +82,12 @@ const formattedTime = computed(() => {
return `倒计时 ${mm}:${ss}`
})
// Mock chart data
const chartData = [
{ group: '第一组', total: 42, correct: 36 },
{ group: '第二组', total: 20, correct: 16 },
{ group: '第三组', total: 19, correct: 19 },
{ group: '第四组', total: 17, correct: 14 },
]
// Chart data
const chartData = ref<any[]>([])
function handleBack() {
store.stopTimer()
stopPolling()
routerPushByKey('user_draw', {
query: {
activityId: String(activityId.value),
@ -138,10 +141,13 @@ async function handleNext() {
TeamGroupID: groupId.value || 0,
QuestionID: store.currentQuestionInfo.ID, // 使用列表中的 ID
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)
}
}
catch (error) {
@ -152,6 +158,7 @@ async function handleNext() {
else {
// 备用逻辑:如果已经在答题中(理论上不会触发,因为上面已经跳转),直接跳转
store.stopTimer()
stopPolling()
routerPushByKey('user_analysis', {
query: {
activityId: String(activityId.value),
@ -163,16 +170,55 @@ async function handleNext() {
}
}
function startPolling(params: Api.Competition.QuestionAddParams) {
stopPolling()
pollingTimer.value = window.setInterval(() => {
fetchGetGameStatisticsData(params)
}, 2000)
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value)
pollingTimer.value = null
}
}
/** 获取游戏现场统计结果 */
async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddParams) {
if (store.currentQuestionInfo && store.currentQuestionDetail) {
try {
const { data } = await fetchGetGameStatistics(_params)
console.log(data, 'data')
// 更新图表数据
// 假设接口返回格式为: [{ TeamName: '组1', Points: 10, ResultPotins: 5 }, ...]
// 需要根据实际接口返回结构调整映射逻辑
const list = (data?.data || []) as any[]
if (list && Array.isArray(list)) {
// 根据 TeamName 分组并去重(如果是同一个小组多条记录,可能需要合并,这里假设直接展示返回列表)
// 如果后端返回的就是每个小组一条汇总数据,直接映射即可
// 根据需求描述,返回的数据可能有多条同名小组记录,或者就是每个小组一条
// 鉴于示例数据中有重复的 TeamName (第一小队, 第二小队),这里做一个简单的去重或聚合
// 实际上 fetchGetGameStatistics 应该是返回统计数据,如果返回了明细,前端需要聚合
// 但看字段 Points 和 ResultPotins 像是统计值这里直接映射展示如果有重复名ECharts category 会自动处理或需要前端聚合
// 为了稳妥,这里按 TeamId 或 TeamName 聚合一下,取最新或求和?
// 假设返回的是当前所有小组的状态,直接映射即可
// 使用 Map 去重,以 TeamId 为准,保留最新的状态
const teamMap = new Map()
list.forEach((item) => {
teamMap.set(item.TeamName, {
group: item.TeamName || '未知队伍',
total: item.Points || 0,
correct: item.ResultPotins || 0,
})
})
chartData.value = Array.from(teamMap.values())
}
}
catch (error) {
console.error('Failed to fetch game statistics', error)
window.$message?.error('获取游戏现场统计结果失败')
// 轮询时出错不频繁弹窗,仅 log
}
}
}
@ -207,6 +253,7 @@ async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddPa
<!-- 底部图表区域 -->
<div
v-if="isStarted"
class="relative z-20 h-64 max-w-5xl w-full flex flex-col shrink-0 border-2 border-blue-300 rounded-xl bg-white/90 p-4 shadow-lg backdrop-blur"
>
<!-- 图表标题栏 -->
@ -214,45 +261,12 @@ async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddPa
<div class="flex items-center gap-2">
<div class="h-3 w-3 rotate-45 bg-orange-400" />
<span class="text-lg text-blue-800 font-bold">答题进度</span>
</div>
<div class="flex gap-6 text-sm">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full bg-blue-400" />
<span class="text-gray-600">答题数量</span>
</div>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full bg-orange-300" />
<span class="text-gray-600">正确数量</span>
</div>
<span class="text-md text-gray-400">仅为ai判定不代表最终结果</span>
</div>
</div>
<!-- 柱状图 -->
<div class="flex flex-1 items-end justify-around px-8 pb-2">
<div v-for="item in chartData" :key="item.group" class="w-16 flex flex-col items-center gap-2">
<div class="h-32 flex items-end gap-2">
<!-- Blue Bar -->
<div
class="relative w-6 rounded-t-md from-blue-500 to-blue-300 bg-gradient-to-t transition-all duration-1000"
:style="{ height: `${item.total * 2}px` }"
>
<span class="absolute left-1/2 text-blue-800 font-bold -top-6 -translate-x-1/2">{{ item.total
}}</span>
</div>
<!-- Orange Bar -->
<div
class="relative w-6 rounded-t-md from-orange-400 to-orange-200 bg-gradient-to-t transition-all duration-1000"
:style="{ height: `${item.correct * 2}px` }"
>
<span class="absolute left-1/2 text-orange-600 font-bold -top-6 -translate-x-1/2">{{ item.correct
}}</span>
</div>
</div>
<div class="mt-2 text-blue-800 font-bold">
{{ item.group }}
</div>
</div>
</div>
<!-- 柱状图ECharts -->
<LineCharts :data="chartData" :height="208" />
</div>
</div>
</CompetitionLayout>

View File

@ -0,0 +1,136 @@
<script setup lang="ts">
import * as echarts from 'echarts/core'
import { watch } from 'vue'
import { type ECOption, useEcharts } from '@/hooks/common/echarts'
interface BarDatum {
group: string | number
total: number
correct: number
}
const props = defineProps<{
data: BarDatum[]
height?: number
}>()
/**
* 构建柱状图配置
* 说明:根据传入的数据生成双系列柱状图(答题数量/正确数量)
*/
function buildOption(list: BarDatum[]): ECOption {
const categories = list.map(i => String(i.group))
const total = list.map(i => i.total)
const correct = list.map(i => i.correct)
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
},
legend: {
data: ['答题数量', '正确数量'],
top: 0,
right: 0,
icon: 'circle',
textStyle: {
color: '#666',
},
},
grid: { left: 16, right: 16, top: 40, bottom: 0, containLabel: true },
xAxis: {
type: 'category',
data: categories,
axisTick: { show: false }, // 隐藏 x 轴刻度线
axisLine: { show: false }, // 隐藏 x 轴线
axisLabel: {
color: '#003366', // 深蓝色字体
fontWeight: 'bold',
fontSize: 14,
margin: 16,
},
},
yAxis: {
type: 'value',
minInterval: 1,
axisTick: { show: false }, // 隐藏 y 轴刻度线
axisLine: { show: false }, // 隐藏 y 轴线
splitLine: { show: false }, // 隐藏 y 轴网格线
axisLabel: { show: false }, // 隐藏 y 轴标签
},
series: [
{
name: '答题数量',
type: 'bar',
data: total,
barWidth: 24,
itemStyle: {
// 从上到下:浅蓝 -> 深蓝
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#e0f2fe' }, // sky-100 (顶部浅色)
{ offset: 1, color: '#3b82f6' }, // blue-500 (底部深色)
]),
borderRadius: [4, 4, 0, 0],
},
label: {
show: true,
position: 'top',
color: '#003366', // 深蓝色
fontWeight: 'bold',
fontSize: 18,
offset: [0, 2],
},
},
{
name: '正确数量',
type: 'bar',
data: correct,
barWidth: 24,
itemStyle: {
// 从上到下:浅橙 -> 深橙
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#fff7ed' }, // orange-50 (顶部极浅)
{ offset: 1, color: '#f97316' }, // orange-500 (底部深色)
]),
borderRadius: [4, 4, 0, 0],
},
label: {
show: true,
position: 'top',
color: '#ea580c', // orange-600
fontWeight: 'bold',
fontSize: 18,
offset: [0, 2],
},
},
],
}
}
// 初始化图表
const { domRef, updateOptions } = useEcharts<ECOption>(() => buildOption(props.data || []))
/**
* 监听数据变化并更新图表
* 说明:当父组件 chartData 更新时,动态刷新柱状图
*/
watch(
() => props.data,
(val) => {
updateOptions(() => buildOption(val || []))
},
{ deep: true },
)
</script>
<template>
<div
ref="domRef"
class="w-full"
:style="{ height: `${props.height ?? 180}px` }"
/>
</template>
<style scoped>
</style>

View File

@ -9,8 +9,6 @@ const currentQuestionDetail = computed(() => store.currentQuestionDetail)
// console.log(currentQuestionMainInfo.value, 'currentQuestionMainInfo')
// 题目
const title = computed(() => currentQuestionMainInfo.value?.QuestionList?.QuestionContent)
// 归属大纲
// const mainTitle = computed(() => currentQuestionMainInfo.value?.QuestionList?.Name)
// 题目内容
const content = computed(() => currentQuestionDetail.value.Name)
// 模版类型
@ -380,7 +378,7 @@ watch(
.rich-content {
:deep(img) {
max-width: 50%;
max-width: 200px;
height: auto;
object-fit: contain;
margin: 0 auto;

View File

@ -46,7 +46,9 @@ async function getTeams() {
}
}
const showContent = ref(false)
const showBackground = ref(false)
const showTitle = ref(false)
const showList = ref(false)
function handleBack() {
routerBack()
@ -63,10 +65,21 @@ function selectTeam(_id: number) {
}
onMounted(() => {
getTeams()
// 1. 背景卷轴展开 - 稍微慢一点开始,给人一种准备的感觉
setTimeout(() => {
showContent.value = true
getTeams()
}, 100)
showBackground.value = true
}, 300)
// 2. 标题浮现 - 等卷轴展开一大半再出来 (卷轴动画约 1.5s,这里设为 1200ms)
setTimeout(() => {
showTitle.value = true
}, 1200)
// 3. 列表出现 - 等卷轴完全展开并定住后再出 (卷轴动画结束需 1.5s,这里给 1600ms)
setTimeout(() => {
showList.value = true
}, 1600)
})
</script>
@ -74,40 +87,90 @@ onMounted(() => {
<CompetitionLayout :show-back="true" :show-next="false" :show-title="false" @back="handleBack" @next="handleNext">
<!-- Scroll Content -->
<div class="scroll-container">
<div class="scroll-body">
<!-- Title Section inside Scroll -->
<Transition name="fade-slide-down" appear>
<div v-if="showContent" class="title-section">
<div class="scroll-bg">
<span class="main-title text-4xl text-#22685a">
队伍名单
</span>
<!-- 卷轴背景动画容器 -->
<Transition name="scroll-expand">
<div v-if="showBackground" class="scroll-body">
<!-- Title Section inside Scroll -->
<Transition name="fade-slide-down">
<div v-if="showTitle" class="title-section">
<div class="scroll-bg">
<span class="main-title text-4xl text-#22685a">
队伍名单
</span>
</div>
</div>
</div>
</Transition>
</Transition>
<div class="scroll-content">
<TransitionGroup name="list-anim" tag="div" class="teams-grid">
<div
v-for="(item, index) in teams" v-show="showContent" :key="item.id" class="team-item"
:style="{ '--delay': `${index * 0.1}s` }" @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">
<div class="scroll-content">
<TransitionGroup name="list-anim" tag="div" class="teams-grid">
<div
v-for="(item, index) in showList ? teams : []"
:key="item.id"
class="team-item"
:style="{ '--delay': `${index * 0.8}s` }"
@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">
</div>
<div class="team-name">
{{ item.name }}
</div>
</div>
<div class="team-name">
{{ item.name }}
</div>
</div>
</TransitionGroup>
</TransitionGroup>
</div>
<div class="scroll-right-decor" />
</div>
<div class="scroll-right-decor" />
</div>
</Transition>
</div>
</CompetitionLayout>
</template>
<style lang="scss" scoped>
// 卷轴展开动画 - 变得更慢更优雅
.scroll-expand-enter-active {
transition: all 1.5s cubic-bezier(0.25, 1, 0.5, 1); // 持续时间加长到 1.5s,曲线更平滑
overflow: hidden;
}
.scroll-expand-leave-active {
transition: all 0.8s ease;
}
.scroll-expand-enter-from,
.scroll-expand-leave-to {
width: 0 !important;
opacity: 0;
transform: scaleX(0.9); // 稍微调整初始缩放
}
// 标题进场动画 - 更加缓慢浮现
.fade-slide-down-enter-active {
transition: all 1.2s ease-out;
}
.fade-slide-down-enter-from {
opacity: 0;
transform: translateY(-40px);
}
// 列表进场动画 (使用 keyframes 实现更丝滑的渐入)
.list-anim-enter-active {
animation: list-enter 2s cubic-bezier(0.23, 1, 0.32, 1) both; // both 模式确保动画前后状态保留
animation-delay: var(--delay);
}
@keyframes list-enter {
0% {
opacity: 0;
transform: translateY(80px) scale(0.9);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.title-section {
position: absolute;
top: 18%; // 使用百分比定位,适应不同比例
@ -153,6 +216,7 @@ onMounted(() => {
justify-content: center;
background: url('@/assets/imgs/user/team-bg.svg') no-repeat center center;
background-size: 100% 100%;
transform-origin: center; // 确保从中心展开
&::before {
left: -15px;
@ -181,6 +245,12 @@ onMounted(() => {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: transform 0.3s;
&:hover {
transform: translateY(-5px);
}
.icon-wrapper {
width: 140px;
@ -192,36 +262,14 @@ onMounted(() => {
// background: url('@/assets/imgs/team-icon-bg.svg') no-repeat center center;
background-size: contain;
margin-bottom: 20px;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
}
.team-name {
font-size: 1.2rem;
font-size: 1.5rem;
color: #333;
font-weight: bold;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
}
}
// Transitions
.fade-slide-down-enter-active,
.fade-slide-down-leave-active {
transition: all 0.8s ease;
}
.fade-slide-down-enter-from,
.fade-slide-down-leave-to {
opacity: 0;
transform: translateX(-70px); // 竖排标题从左侧进入
}
.list-anim-enter-active,
.list-anim-leave-active {
transition: all 0.5s cubic-bezier(0.5, 0, 0.25, 1);
transition-delay: var(--delay);
}
.list-anim-enter-from,
.list-anim-leave-to {
opacity: 0;
transform: translateY(30px);
}
</style>