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

@ -273,7 +273,7 @@ onMounted(() => {
justify-content: center; // 让内容决定位置,通常是 margin-top
// margin: 40px auto;
z-index: 10;
padding-bottom: 60px;
padding-bottom: 20px;
width: 100%; // 确保内容宽度
}

View File

@ -203,7 +203,8 @@ export const generatedRoutes: GeneratedRoute[] = [
component: 'view.results_results-detail-copy',
meta: {
title: 'results_results-detail-copy',
i18nKey: 'route.results_results-detail-copy'
i18nKey: 'route.results_results-detail-copy',
hideInMenu: true
}
},
{

View File

@ -42,16 +42,16 @@ export function fetchGetQuestionDetail(ActivityID: string, questionID: number) {
/** 提交抽题结果 */
export function fetchSubmitQuestionResult(data: Api.Competition.QuestionAddParams) {
return request<App.Service.Response<Api.Competition.QuestionListDetailRound>>({
url: `/Base/ActivityMain/AddActivity_TeamQuestionUse/?RoomID=${data.RoomID}&MainID=${data.MainID}&TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}&QuestionDetaiID=${data.QuestionDetaiID}`,
url: `/Base/ActivityMain/AddActivity_TeamQuestionUse/?RoomID=${data.RoomID}&MainID=${data.MainID}&TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}&QuestionDetaiID=${data.QuestionDetaiID}&TeamGroupQuestionID=${data.TeamGroupQuestionID}`,
method: 'post',
data,
})
}
/** 获取游戏现场统计结果 */
export function fetchGetGameStatistics(data: { TeamGroupID: number, QuestionID: number, QuestionDetaiID: number }) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/GetTeamQuestionUseByTeamGroupIDAndQuestionID/?TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}&QuestionDetaiID=${data.QuestionDetaiID}`,
export function fetchGetGameStatistics(data: { TeamGroupID: number, QuestionID: number }) {
return request<App.Service.Response<Api.Competition.TeamAnswerData[]>>({
url: `/Base/ActivityMain/GetTeamQuestionUseByTeamGroupIDAndQuestionID/?TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}`,
method: 'post',
data,
})
@ -87,7 +87,7 @@ export function fetchGetQuestionTableByActivityID(ActivityID: string) {
/** 根据 roomid查询当前正在比赛的题目 */
export function fetchGetCurrentQuestionByRoomID(RoomID: number) {
return request<App.Service.Response<Api.Competition.CurrentQuestionResponse>>({
return request<App.Service.Response<Api.Competition.GetCurrentQuestionInfo>>({
url: `/Base/ActivityMain/SelectTask?RoomID=${RoomID}`,
method: 'post',
data: {

View File

@ -1,7 +1,7 @@
import { request } from '../request'
/** 评委消费题目,表示用了该题目 */
export function fetchUpdateCurrentQuestionUse(TeamGroupID: string) {
export function updateCurrentQuestionUse(TeamGroupID: number) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateCurrentQuestionUse?TeamGroupID=${TeamGroupID}`,
method: 'post',

View File

@ -92,6 +92,7 @@ declare namespace Api {
TeamGroupID: number
QuestionID: number
QuestionDetaiID: number
TeamGroupQuestionID: number
}
interface QuestionListDetailRound {
/** 题目 id */
@ -188,6 +189,36 @@ declare namespace Api {
IsCompleted: boolean
}
/** 获取当前正在比赛的信息 */
interface GetCurrentQuestionInfo {
/** 答案 */
Answer: string
/** 结束时间 */
EndTime: string
/** 高度 */
Height: number
/** 分值 */
Point: number
/** 题目详情 id */
QuestionDetailID: number
/** 题目 id */
QuestionID: number
/** 题目规则 */
QuestionRule: number
/** 开始时间 */
StartTime: string
/** 任务模板列表 */
TaskTemplete: any[]
/** 队伍分组 id */
TeamGroupID: number
/** 队伍分组题目 id */
TeamGroupQuestionID: number
/** 队伍列表 */
TeamList: any[]
/** 宽度 */
Width: number
}
/** update room request */
interface UpdateRoomRequest {
/** 活动 id */
@ -253,6 +284,21 @@ declare namespace Api {
HeadImg?: string
}
interface TeamAnswerData {
Id: number
MainId: number
TeamGroupId: number
TeamId: number
QuestionId: number
QuestionDetailId: number | null
UserAnswerPicture: string
UserAnswerFont: string // JSON string
TeamName: string
Points: number
ResultPotins: number
Status: number
}
interface QuestionListRecord {
/** 活动题目名称 */
ActitvityQuestionName: string

View File

@ -6,6 +6,7 @@ import { useRoute } from 'vue-router'
import { QuestionCategoryEnum } from '@/enum/business'
import { fetchGetQuestionList } from '@/service/api/competition'
import { fetchGetCurrentQuestionByRoomID, fetchGetGameStatistics, fetchGetGroupListByActivityID } from '@/service/api/game'
import { updateCurrentQuestionUse } from '@/service/api/result'
import GroupTabs, { type GroupItem } from './modules/GroupTabs.vue'
const route = useRoute()
@ -19,7 +20,7 @@ const currentQuestionType = ref<string>('') // 当前选中的题目类型
const groups = ref<GroupItem[]>([])
const questionList = ref<(GroupItem & { type: string })[]>([])
const currentQuestion = ref<any>() // 当前正在参赛的题目
const currentQuestion = ref<Api.Competition.GetCurrentQuestionInfo>() // 当前正在参赛的题目
// 计算当前题目类型对应的布局
const currentLayout = computed(() => {
@ -36,34 +37,10 @@ const currentLayout = computed(() => {
return gridTypes.includes(currentQuestionType.value as QuestionCategoryEnum) ? 'grid' : 'list'
})
interface AnswerItem {
id: number
label?: string
imageUrl: string
status: 'correct' | 'wrong'
TeamQuestionUseID: number
Guid: string
}
interface TeamAnswerData {
Id: number
MainId: number
TeamGroupId: number
TeamId: number
QuestionId: number
QuestionDetailId: number | null
UserAnswerPicture: string
UserAnswerFont: string // JSON string
TeamName: string
Points: number
ResultPotins: number
Status: number
}
interface TeamResult {
TeamName: string
UserAnswerPicture: string
Answers: AnswerItem[]
Answers: any[]
Points: number
TeamId: number
}
@ -72,8 +49,8 @@ interface TeamResult {
const teamResults = ref<TeamResult[]>([])
// 切换状态逻辑:点击后 Correct <-> Wrong
function toggleStatus(team: TeamResult, item: AnswerItem) {
item.status = item.status === 'correct' ? 'wrong' : 'correct'
function toggleStatus(team: TeamResult, item: any) {
item.Status = item.Status === 1 ? 0 : 1
}
function handleNext() {
@ -88,9 +65,25 @@ function handleNext() {
}
}
function handlePublish() {
// 发布结果逻辑
message.success('结果已发布')
/**
* 根据房间当前题目,自动同步选中题目和当前正在比赛的组
* 逻辑:
* - 如果后端返回了当前题目的 QuestionID并且题目列表已加载则将 currentQuestionId 设为该题
* - 同时同步 currentQuestionType便于切换布局
* - 若找不到匹配项,则维持现状(由首次加载保持为第一题)
*/
function syncCurrentQuestionSelection() {
const qid = currentQuestion.value?.QuestionID
const gid = currentQuestion.value?.TeamGroupID
if (!qid || questionList.value.length === 0)
return
const hit = questionList.value.find(q => Number(q.id) === Number(qid))
if (gid)
currentGroupId.value = gid
if (hit) {
currentQuestionId.value = hit.id
currentQuestionType.value = (hit as any).type || ''
}
}
// Mock 数据生成器
@ -148,15 +141,12 @@ function generateMockData() {
UserAnswerPicture: 'https://placehold.co/800x400?text=Original+Answer+Sheet',
Points: 0,
Answers: Array.from({ length: answerCount }).map((__, j) => ({
id: j + 1,
label: answerCount > 1 ? `${j + 1}.` : '', // 只有多个答案时才显示序号
// Grid 类型用方形图List 类型用长方形图
imageUrl: answerCount > 1
AnswerValuePicture: answerCount > 1
? `https://placehold.co/100x100?text=Answer+${j + 1}`
: `https://placehold.co/400x100?text=Long+Answer`,
status: Math.random() > 0.5 ? 'correct' : 'wrong',
Status: Math.random() > 0.5 ? 1 : 0,
TeamQuestionUseID: 123,
Guid: `mock-guid-${j}`,
Guid: `mock-guid-${i}-${j}`,
})),
}
})
@ -179,7 +169,7 @@ async function fetchGroups() {
const list = data?.data || []
groups.value = list.map((item: any) => ({
id: item.Id,
name: item.Name,
name: item.Id,
}))
// 默认选中第一个分组
if (groups.value.length > 0 && !currentGroupId.value) {
@ -204,16 +194,20 @@ async function fetchQuestions() {
return
}
const list = data?.data || []
questionList.value = list.map((item: any) => ({
id: item.ID, // 注意大小写,根据接口返回调整
name: item.Name || `题目 ${item.ID}`,
type: item.Type || '', // 假设接口返回 Type
console.warn('question list', list)
questionList.value = list.map((item, idx) => ({
id: item.ID,
name: item.ID || `题目 ${idx + 1}`,
type: item.UIType || '',
}))
// 默认选中第一个题目
if (questionList.value.length > 0 && !currentQuestionId.value) {
currentQuestionId.value = questionList.value[0].id
currentQuestionType.value = questionList.value[0].type
}
// 题目加载完成后尝试同步到“当前进行中的题目”
syncCurrentQuestionSelection()
}
catch (error: any) {
message.error(error.message || '获取题目失败')
@ -234,6 +228,9 @@ async function fetchCurrentQuestion() {
return
}
currentQuestion.value = data?.data || {}
console.warn('current question', currentQuestion.value)
// 拉取当前题目信息后尝试同步选中题
syncCurrentQuestionSelection()
}
catch (error: any) {
message.error(error.message || '获取当前题目失败')
@ -244,7 +241,7 @@ async function fetchCurrentQuestion() {
* 获取答题卡数据
*/
async function fetchAnswerData() {
if (!currentGroupId.value || !currentQuestionId.value)
if (!currentGroupId.value || !currentQuestionId.value || !currentQuestion.value?.QuestionDetailID)
return
// 如果是 Mock 环境,重新生成一下随机答案
@ -267,14 +264,12 @@ async function fetchAnswerData() {
UserAnswerPicture: 'https://placehold.co/800x400?text=Original+Answer+Sheet',
Points: 0,
Answers: Array.from({ length: answerCount }).map((__, j) => ({
id: j + 1,
label: answerCount > 1 ? `${j + 1}.` : '',
imageUrl: answerCount > 1
AnswerValuePicture: answerCount > 1
? `https://placehold.co/100x100?text=Answer+${j + 1}`
: `https://placehold.co/400x100?text=Long+Answer`,
status: Math.random() > 0.5 ? 'correct' : 'wrong',
Status: Math.random() > 0.5 ? 1 : 0,
TeamQuestionUseID: 123,
Guid: `mock-guid-${j}`,
Guid: `mock-guid-${i}-${j}`,
})),
}
})
@ -288,7 +283,7 @@ async function fetchAnswerData() {
const params = {
TeamGroupID: Number(currentGroupId.value),
QuestionID: Number(currentQuestionId.value),
QuestionDetaiID: 0, // 暂时传0如果有详情ID需补充
// QuestionDetaiID: currentQuestion.value?.QuestionDetailID || 0,
}
const { data, error } = await fetchGetGameStatistics(params)
@ -298,24 +293,16 @@ async function fetchAnswerData() {
}
// 假设返回的数据结构是数组,取第一条或者根据逻辑取特定的一条
// 根据需求描述,返回的是一个对象结构
const result = data?.data as unknown as TeamAnswerData[]
// 注意fetchGetGameStatistics 返回类型是 App.Service.Responsedata.data 类型未知,这里强制转换以便演示
// 实际接口可能返回数组,取第一个有数据的
const result = data?.data || []
console.warn('game statistics result', result)
if (result && result.length > 0) {
teamResults.value = result.map((answerData) => {
let parsedAnswers: AnswerItem[] = []
let parsedAnswers: any[] = []
try {
const parsedFonts = JSON.parse(answerData.UserAnswerFont)
parsedAnswers = parsedFonts.map((item: any, index: number) => ({
id: index + 1,
label: `${index + 1}.`,
imageUrl: item.AnswerValuePicture, // 注意去除可能存在的反斜杠转义
status: item.Status === 1 ? 'correct' : 'wrong', // 假设 1 是正确0 是错误,根据实际调整
TeamQuestionUseID: item.TeamQuestionUseID,
Guid: item.Guid,
}))
parsedAnswers = parsedFonts
console.warn('parsed fonts', parsedFonts)
}
catch (e) {
console.error('解析 UserAnswerFont 失败', e)
@ -340,6 +327,47 @@ async function fetchAnswerData() {
}
}
/**
* 调转到当前题目
*/
async function handleNexCurrentQuestion() {
await fetchCurrentQuestion()
if (currentQuestionId.value && currentGroupId.value) {
currentQuestionId.value = currentQuestion.value?.QuestionID || currentQuestionId.value
currentGroupId.value = currentQuestion.value?.TeamGroupID || currentGroupId.value
}
}
function handlePublish() {
if (currentQuestion.value?.TeamGroupQuestionID) {
window.$dialog?.info({
title: '发布确认',
content: '确定要发布当前题目吗?',
positiveText: '发布',
negativeText: '取消',
onPositiveClick: async () => {
await fetchUpdateCurrentQuestionUse(currentQuestion.value?.TeamGroupQuestionID || 0)
},
})
}
}
/**
* 消费并发布当前题目
*/
async function fetchUpdateCurrentQuestionUse(TeamGroupQuestionID: number) {
try {
const { data, error } = await updateCurrentQuestionUse(TeamGroupQuestionID)
console.warn('updateCurrentQuestionUse result', data)
if (!error) {
window?.$message?.success('结果已发布')
}
}
catch (error: any) {
window?.$message?.error(error.message)
}
}
// 监听分组和题目的变化,重新获取答题数据
watch([currentGroupId, currentQuestionId], () => {
if (currentGroupId.value && currentQuestionId.value) {
@ -352,9 +380,10 @@ watch([currentGroupId, currentQuestionId], () => {
})
onMounted(async () => {
await fetchCurrentQuestion()
await fetchGroups()
await fetchQuestions()
// 并行拉取题目与当前题目,完成后做一次同步
await Promise.all([fetchQuestions(), fetchCurrentQuestion()])
syncCurrentQuestionSelection()
// generateMockData()
})
</script>
@ -378,10 +407,13 @@ onMounted(async () => {
<div class="flex items-center rounded-full bg-blue-50 px-4 py-1.5 text-sm text-blue-600 font-bold">
当前进度 {{ questionList.findIndex(q => q.id === currentQuestionId) + 1 }}/{{ questionList.length }}
</div>
<NButton type="primary" strong class="px-6" @click="handleNexCurrentQuestion">
当前题目
</NButton>
<NButton secondary strong @click="handleNext">
下一题
</NButton>
<NButton type="primary" strong class="px-6" @click="handlePublish">
<NButton type="tertiary" strong class="px-6" @click="handlePublish">
发布结果
</NButton>
</div>
@ -398,11 +430,11 @@ onMounted(async () => {
v-for="team in teamResults" :key="team.TeamId" :title="team.TeamName ? `${team.TeamName}` : '队伍信息'"
hoverable
>
<div class="flex flex-row items-start gap-6">
<div class="flex flex-row items-start gap-6 px-4 py-2">
<!-- 学生答题原图 -->
<div class="UserAnswerPicture flex-shrink-0">
<NImage
v-if="team.UserAnswerPicture" :src="team.UserAnswerPicture" width="150" object-fit="contain"
v-if="team.UserAnswerPicture" :src="team.UserAnswerPicture" width="250" object-fit="contain"
class="block border border-gray-200 rounded-lg"
/>
<div
@ -419,30 +451,29 @@ onMounted(async () => {
<!-- Grid Layout -->
<div v-if="currentLayout === 'grid'" class="flex flex-wrap gap-4">
<div
v-for="item in team.Answers" :key="item.id"
v-for="(item, idx) in team.Answers" :key="item.Guid"
class="relative cursor-pointer transition-all duration-200" @click="toggleStatus(team, item)"
>
<!-- Image Container with Status Border -->
<div
class="relative overflow-hidden border-2 rounded-md border-dashed bg-white p-1" :class="[
item.status === 'correct' ? 'border-green-500' : 'border-red-500',
item.Status === 1 ? 'border-green-500' : 'border-red-500',
]"
>
<NImage :src="item.imageUrl" preview-disabled object-fit="contain" class="h-24 w-24" />
<!-- <NImage :src="item.AnswerValuePicture || item.imageUrl" preview-disabled object-fit="contain" class="h-24 w-24" /> -->
<div class="h-24 w-24 flex items-center justify-center">
{{ item.AnswerText }}
</div>
<!-- Status Icon Overlay -->
<div class="absolute z-10 rounded-full bg-white -right-3 -top-3">
<Icon
v-if="item.status === 'correct'" icon="mdi:check-circle"
class="text-2xl text-green-500"
/>
<div class="absolute z-10 rounded-full bg-white -right-3px -top-3px">
<Icon v-if="item.Status === 1" icon="mdi:check-circle" class="text-2xl text-green-500" />
<Icon v-else icon="mdi:close-circle" class="text-2xl text-red-500" />
</div>
</div>
<!-- Label if exists -->
<div v-if="item.label" class="mt-1 text-center text-sm text-gray-500">
{{ item.label }}
<div class="mt-1 text-center text-sm text-gray-500">
{{ idx + 1 }}.
</div>
</div>
</div>
@ -450,25 +481,25 @@ onMounted(async () => {
<!-- List Layout (Long Rectangle for Words/Idioms) -->
<div v-else class="flex flex-wrap gap-x-8 gap-y-4">
<div
v-for="item in team.Answers" :key="item.id"
v-for="(item, idx) in team.Answers" :key="item.Guid"
class="relative cursor-pointer transition-all duration-200" @click="toggleStatus(team, item)"
>
<div class="flex items-center gap-2">
<span v-if="item.label" class="w-6 text-gray-600 font-medium">{{ item.label }}</span>
<span class="w-6 text-gray-600 font-medium">{{ idx + 1 }}.</span>
<div
class="relative overflow-hidden border-2 rounded-md border-dashed bg-white px-2 py-1" :class="[
item.status === 'correct' ? 'border-green-500' : 'border-red-500',
item.Status === 1 ? 'border-green-500' : 'border-red-500',
]"
>
<NImage :src="item.imageUrl" preview-disabled object-fit="contain" height="50" />
<NImage
:src="item.AnswerValuePicture || item.imageUrl" preview-disabled object-fit="contain"
height="50"
/>
<!-- Status Icon Overlay -->
<div class="absolute z-10 rounded-full bg-white shadow-sm -right-3 -top-3">
<Icon
v-if="item.status === 'correct'" icon="mdi:check-circle"
class="text-2xl text-green-500"
/>
<Icon v-if="item.Status === 1" icon="mdi:check-circle" class="text-2xl text-green-500" />
<Icon v-else icon="mdi:close-circle" class="text-2xl text-red-500" />
</div>
</div>

View File

@ -3,12 +3,12 @@ import { NButton } from 'naive-ui'
export interface GroupItem {
id: number | string
name: string
name: string | number
}
export interface QuestionListItem {
id: number | string
name: string
name: string | number
}
defineProps<{

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>