feat(admin): 优化用户界面交互与数据展示
- 在队伍选择页面增加卷轴展开动画,优化视觉体验 - 为游戏页面添加实时统计图表,使用 ECharts 展示各队答题进度 - 调整题目渲染器中图片的最大宽度为 200px - 在路由配置中隐藏 results_results-detail-copy 菜单项 - 修复 API 函数命名和参数类型,更新接口返回类型定义 - 移动拼音听写题目数据文件到 store 目录 - 更新 VSCode 拼写检查配置,添加 ECharts 单词
This commit is contained in:
@ -273,7 +273,7 @@ onMounted(() => {
|
||||
justify-content: center; // 让内容决定位置,通常是 margin-top
|
||||
// margin: 40px auto;
|
||||
z-index: 10;
|
||||
padding-bottom: 60px;
|
||||
padding-bottom: 20px;
|
||||
width: 100%; // 确保内容宽度
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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',
|
||||
|
||||
46
apps/admin/src/typings/api/Competition.d.ts
vendored
46
apps/admin/src/typings/api/Competition.d.ts
vendored
@ -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
|
||||
|
||||
@ -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.Response,data.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>
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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效果
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
136
apps/admin/src/views/user/game/modules/LineCharts.vue
Normal file
136
apps/admin/src/views/user/game/modules/LineCharts.vue
Normal 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>
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user