feat(user): 重构用户端竞赛流程并添加新页面

- 将原单页竞赛流程拆分为独立路由页面(首页、封面、规则、抽题、答题、图解、组别、队伍)
- 新增 Pinia store 管理竞赛状态、计时器和音频控制
- 添加 SCSS 变量文件定义主题色
- 更新路由配置和类型定义以支持新页面结构
- 重构 QuestionRenderer 组件优化样式
- 添加图标依赖并更新国际化配置
- 移动图片资源到用户目录并清理旧文件
This commit is contained in:
2026-02-06 17:45:44 +08:00
parent ca42c6a876
commit b71a758474
39 changed files with 1559 additions and 486 deletions

View File

@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { useCompetitionStore } from '@/store/modules/competition'
const { routerPushByKey } = useRouterPush()
const store = useCompetitionStore()
const zoomedImage = ref<string | null>(null)
// Mock team data
const teams = ref([
{
id: 1,
name: '第一队',
writtenCount: 16,
correctCount: 13,
score: 13,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+1+Answer',
},
{
id: 2,
name: '第二队',
writtenCount: 15,
correctCount: 12,
score: 12,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+2+Answer',
},
{
id: 3,
name: '第三队',
writtenCount: 18,
correctCount: 15,
score: 15,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+3+Answer',
},
{
id: 4,
name: '第四队',
writtenCount: 14,
correctCount: 10,
score: 10,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+4+Answer',
},
])
function handleZoom(img: string) {
zoomedImage.value = img
}
function handleBack() {
routerPushByKey('user_game')
}
function handleNext() {
const nextRoute = store.nextStep()
routerPushByKey(nextRoute)
}
</script>
<template>
<CompetitionLayout
:show-title="true"
:show-back="true"
:show-next="true"
title="答题图解"
action-position="top"
back-btn-text="返回"
next-btn-text="下一题"
btn-theme="light"
@back="handleBack"
@next="handleNext"
>
<div class="relative h-full w-full flex flex-col items-center px-12 pt-10 font-sans">
<!-- Content -->
<div class="w-full flex flex-col flex-1 overflow-hidden">
<div class="mb-2 text-xl text-red-600 font-bold">
备注由AI模型评判
</div>
<div 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-2 text-xl text-gray-800 font-bold">
{{ team.name }}
</div>
<!-- Answer Image Area -->
<div
class="relative mb-4 flex-1 cursor-zoom-in overflow-hidden border border-gray-300 rounded bg-gray-50"
@click="handleZoom(team.image)"
>
<!-- 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>
<!-- Simulated Handwriting/Content -->
<div class="absolute inset-0 flex items-center justify-center text-gray-400">
(点击放大查看详情)
</div>
</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 class="flex items-center justify-between">
<span class="text-red-500 font-bold">评委当场评</span>
</div>
<div class="relative">
<input
v-model="team.manualScore"
type="number"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
placeholder="留个输入框,修改正确积分"
>
</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>

View File

@ -0,0 +1,284 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { AudioController } from '@/utils/audio'
import { mockData } from '../data/mock'
const { routerPushByKey } = useRouterPush()
const audioController = new AudioController()
const activityName = ref('星·辞海遨游')
// 记录已翻转的卡片索引
const flippedCards = ref<Set<number>>(new Set())
// 计算是否所有卡片都已翻转 (当前显示4张卡片)
const isAllFlipped = computed(() => flippedCards.value.size === 4)
// 控制下一步按钮的显示(带延迟)
const showNextButton = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
watch(isAllFlipped, (val) => {
if (timer) {
clearTimeout(timer)
timer = null
}
if (val) {
timer = setTimeout(() => {
showNextButton.value = true
}, 1500)
}
else {
showNextButton.value = false
}
})
function handleBack() {
routerPushByKey('user_teams')
}
function handleNext() {
routerPushByKey('user_rules')
}
function toggleFlip(index: number) {
if (flippedCards.value.has(index)) {
flippedCards.value.delete(index) // 可选:如果希望再次点击翻回去,可以取消注释
}
else {
flippedCards.value.add(index)
audioController.play('flip') // 播放翻牌音效
}
}
</script>
<template>
<CompetitionLayout :show-back="true" :show-next="showNextButton" :title="activityName" @back="handleBack" @next="handleNext">
<div class="h-screen w-full overflow-hidden font-sans">
<div class="relative z-10 h-full w-full">
<div class="h-full w-full flex flex-col items-center pt-24">
<!-- 卡片列表 -->
<div class="perspective-container grid grid-cols-4 max-w-7xl w-full gap-10 px-12">
<div
v-for="(node, index) in mockData.nodes.slice(0, 4)"
:key="node.nodeId"
class="card-wrapper"
@click="toggleFlip(index)"
>
<div class="flip-card-inner" :class="{ 'is-flipped': flippedCards.has(index) }">
<!-- 正面 (Front) - 显示背面图案 -->
<div class="flip-card-front card-face">
<!-- 这里是未翻开时的样子即卡片背面 -->
<div class="card-back-design" />
</div>
<!-- 背面 (Back) - 显示内容 -->
<div class="flip-card-back card-face">
<!-- 这里是翻开后的样子即卡片正面内容 -->
<div class="card-content-design">
<!-- 内部装饰圈 -->
<div class="inner-circle-decoration">
<!-- 文字内容 -->
<!-- <div class="relative z-10 transform text-center">
<div class="card-text text-3xl text-[#5d4037] font-bold leading-tight font-serif drop-shadow-sm">
<span class="block">{{ node.moduleName.slice(0, 2) }}</span>
<span class="block">{{ node.moduleName.slice(2) }}</span>
</div>
</div> -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</CompetitionLayout>
</template>
<style scoped lang="scss">
@import '@/styles/scss/variables.scss';
.perspective-container {
perspective: 1000px; // [3D透视] 设置观察者距离数值越小透视感越强产生近大远小的3D效果
}
.card-wrapper {
position: relative;
aspect-ratio: 3/4;
cursor: pointer;
transition: transform 0.3s ease;
&:hover {
transform: translateY(-10px); // [悬停效果] 鼠标移上去时卡片轻微上浮
}
}
.flip-card-inner {
position: relative;
width: 100%;
height: 100%;
text-align: center;
// [动画核心]
// 0.6s: 动画持续时间
// cubic-bezier(...): 贝塞尔曲线,(0.175, 0.885, 0.32, 1.275) 这是一个带有"回弹"效果的曲线
// 也就是卡片翻过去时会稍微过头一点再弹回来,增加生动感
transition: transform 3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform-style: preserve-3d; // [3D空间] 确保子元素正反面在3D空间中渲染而不是压平在平面上
border-radius: 20px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.2); // [静态阴影] 默认状态下的投影
&.is-flipped {
// [翻转状态]
// rotateY(180deg): 绕Y轴旋转180度实现翻面
// scale(1.05): 翻转同时放大1.05倍,产生"向观众逼近"的视觉冲击力
transform: rotateY(180deg) scale(1.06);
box-shadow: 0 20px 40px -5px rgba(0, 0, 0, 0.3); // [动态阴影] 翻转浮起时阴影更深、更扩散
}
}
.card-face {
position: absolute;
width: 100%;
height: 100%;
// [背面隐藏] 关键属性!当元素背面朝向观察者时隐藏。
// 这样保证翻转180度后正面消失背面显示或者反之
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
border-radius: 20px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.5); // 增加微妙的边框
}
// 正面(实际是卡背图案)
.flip-card-front {
background-color: transparent;
.card-back-design {
width: 100%;
height: 100%;
background: url('@/assets/imgs/user/cover-back.svg') no-repeat center center; // 背景面
background-size: contain; // 保持比例
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: filter 1s; // [滤镜动画] 1秒内变化模拟卡片翻转时的光影变化
}
}
// 背面(实际是内容面)
.flip-card-back {
// background-color: #fff;
transform: rotateY(180deg);
// border: 4px solid #fff;
.card-content-design {
width: 100%;
height: 100%;
background: url('@/assets/imgs/user/cover-bg-active.svg') no-repeat center center;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
position: relative;
// 光效叠加
&::before {
content: '';
position: absolute;
top: -150%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
to bottom right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 40%,
rgba(255, 255, 255, 0.6) 50%,
rgba(255, 255, 255, 0) 60%,
rgba(255, 255, 255, 0) 100%
);
transform: rotate(-30deg);
opacity: 0;
transition: none;
pointer-events: none;
}
}
// 翻转后的高光效果 - 扫光动画
.is-flipped & .card-content-design::before {
// [扫光动画触发]
// animation-delay: 0.2s; 确保卡片翻转到一半(正对屏幕)时才开始闪光,
// 模拟光线随着角度变化掠过卡片表面的物理效果
animation: shine-sweep 3s ease-out forwards;
animation-delay: 0.2s;
}
}
.inner-circle-decoration {
width: 100px;
height: 100px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid $accent-color; // 使用金色
// box-shadow: 0 0 15px rgba(212, 175, 55, 0.2);
position: relative;
// 祥云纹理
&::after {
content: '';
position: absolute;
top: -5px;
left: -5px;
right: -5px;
bottom: -5px;
border-radius: 50%;
border: 1px dashed $accent-color;
animation: spin 20s linear infinite;
}
}
.card-text {
font-family: 'Noto Serif SC', serif;
color: $group-text-color;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
}
// [扫光动画关键帧]
// 模拟一道光束从左上角快速扫向右下角
@keyframes shine-sweep {
0% {
top: -150%;
left: -150%;
opacity: 0;
}
10% {
// 刚进入时瞬间变亮
opacity: 0.8;
}
100% {
// 扫出视野
top: 50%;
left: 150%;
opacity: 0;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,5 +1,5 @@
import type { CompetitionData } from '../types'
import img from '@/assets/imgs/q5-img.png'
import img from '@/assets/imgs/user/q5-img.png'
/**
* 模拟数据

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { useCompetitionStore } from '@/store/modules/competition'
const { routerPushByKey } = useRouterPush()
const store = useCompetitionStore()
const currentNode = computed(() => store.currentNode)
function handleDraw() {
routerPushByKey('user_game')
}
function handleBack() {
routerPushByKey('user_cover')
}
</script>
<template>
<CompetitionLayout :show-back="true" :show-next="false" :title="currentNode?.moduleName || '抽题'" @back="handleBack">
<div class="h-full w-full flex flex-col items-center pt-10 font-sans">
<div v-if="currentNode" class="mb-20 max-w-4xl text-center">
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
{{ currentNode.description }}
</div>
</div>
<!-- 抽题按钮 (大圆形) -->
<div class="group relative">
<button
class="h-64 w-64 flex flex-col items-center justify-center border-4 border-blue-400 rounded-full bg-gray-200/80 text-3xl text-gray-700 font-bold shadow-xl transition-all active:scale-95 hover:scale-105"
@click="handleDraw"
>
<span>抽题</span>
</button>
</div>
</div>
</CompetitionLayout>
</template>

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { useCompetitionStore } from '@/store/modules/competition'
// import { uiTemplatesMapping } from '@/views/user/data/mock'
import QuestionRenderer from '../modules/QuestionRenderer.vue'
const { routerPushByKey } = useRouterPush()
const store = useCompetitionStore()
// 本地倒计时控制(为了在页面上显示“开始答题”还是倒计时)
const isStarted = ref(false)
const currentNode = computed(() => store.currentNode)
const currentQuestion = computed(() => store.currentQuestion)
const timeLeft = computed(() => store.timeLeft)
const formattedTime = computed(() => {
const m = Math.floor(timeLeft.value / 60)
const s = timeLeft.value % 60
const mm = m < 10 ? `0${m}` : m
const ss = s < 10 ? `0${s}` : s
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 },
]
function handleBack() {
store.stopTimer()
routerPushByKey('user_draw')
}
function handleNext() {
if (!isStarted.value) {
// 点击开始答题
isStarted.value = true
store.startTimer()
}
else {
// 答题中,点击直接进入下一页(或者也可以设计为暂停等,这里按原逻辑是直接跳过)
store.stopTimer()
routerPushByKey('user_analysis')
}
}
</script>
<template>
<CompetitionLayout
action-position="top"
:show-back="true"
:show-next="true"
:title="currentNode?.moduleName || ''"
back-btn-text="返回"
:next-btn-text="isStarted ? formattedTime : '开始答题'"
:next-disabled="isStarted"
btn-theme="light"
@back="handleBack"
@next="handleNext"
>
<div class="relative h-full w-full flex flex-col items-center px-12 font-sans">
<!-- 题目说明 -->
<!-- <div v-if="currentNode" class="m-2 text-2xl text-gray-800 font-medium">
{{ currentNode.description }}
</div> -->
<!-- 题目内容区域 -->
<div v-if="currentNode && currentQuestion" class="flex-2 mb-4 mt-12 w-full flex items-center justify-center">
<QuestionRenderer
:template="currentNode.uiTemplate" :content="currentQuestion.content"
class="origin-center scale-150 transform"
/>
</div>
<!-- 隐藏的 Next 按钮 (方便演示右上角小区域) -->
<div class="absolute right-0 top-0 z-50 h-20 w-20 cursor-pointer" title="Next Step (Debug)" @click="handleNext">
<div class="h-full w-full flex items-center justify-center">
<div class="h-6 w-6 rotate-45 bg-blue-500">
<icon-ic:baseline-arrow-right-alt class="text-icon text-white" />
</div>
</div>
</div>
<!-- 底部图表区域 -->
<div
class="relative z-20 h-64 max-w-5xl w-full flex flex-col border-2 border-blue-300 rounded-xl bg-white/90 p-4 shadow-lg backdrop-blur"
>
<!-- 图表标题栏 -->
<div class="mb-4 flex items-center justify-between px-2">
<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>
</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>
</div>
</div>
</CompetitionLayout>
</template>
<style scoped>
/* Question Renderer Override if needed */
:deep(.question-content) {
font-size: 4rem;
color: #ef4444;
/* red-500 */
font-weight: bold;
}
</style>

View File

@ -0,0 +1,191 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
const { routerPushByKey } = useRouterPush()
interface GroupItem {
id: number
name: string
icon: string
}
const groups = ref<GroupItem[]>([
{ id: 1, name: '初赛一组', icon: 'activity' },
{ id: 2, name: '初赛七组', icon: 'cast' },
{ id: 3, name: '初赛八组', icon: 'chrome' },
{ id: 4, name: '初赛九组', icon: 'heart' },
{ id: 5, name: '初赛十组', icon: 'activity' },
{ id: 6, name: '初赛六组', icon: 'cast' },
{ id: 7, name: '初赛七组', icon: 'chrome' },
{ id: 8, name: '初赛八组', icon: 'heart' },
{ id: 9, name: '初赛九组', icon: 'activity' },
{ id: 10, name: '初赛十组', icon: 'cast' },
])
const showContent = ref(false)
function handleBack() {
routerPushByKey('user_home')
}
function handleNext() {
routerPushByKey('user_teams')
}
function selectGroup(_id: number) {
// 选择组别逻辑
routerPushByKey('user_teams', { query: { id: _id.toString() } })
}
onMounted(() => {
setTimeout(() => {
showContent.value = true
}, 100)
})
</script>
<template>
<CompetitionLayout
:show-back="true"
:show-next="false"
title="展示组别"
@back="handleBack"
@next="handleNext"
>
<!-- Groups Grid -->
<div class="groups-container">
<TransitionGroup name="list-anim" tag="div" class="groups-grid">
<div
v-for="(item, index) in groups"
v-show="showContent"
:key="item.id"
class="group-item"
:style="{ '--delay': `${index * 0.05}s` }"
@click="selectGroup(item.id)"
>
<div class="icon-wrapper">
<div class="flower-bg" />
<div class="inner-icon">
<img src="@/assets/imgs/user/star-icon.svg" alt="star" class="star-img">
</div>
</div>
<div class="group-name-tag">
<span class="sun-icon"></span>
{{ item.name }}
</div>
</div>
</TransitionGroup>
</div>
</CompetitionLayout>
</template>
<style lang="scss" scoped>
@import '@/styles/scss/variables.scss';
.groups-container {
width: 100%;
padding: 0 60px;
margin-top: 60px;
flex: 1;
display: flex;
justify-content: center;
align-items: flex-start;
overflow-y: auto;
}
.groups-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
width: 100%;
max-width: 1200px;
}
.group-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: transform 0.3s ease;
width: calc(20% - 16px);
margin: 0;
margin-bottom: 30px;
&:hover {
transform: translateY(-5px);
}
.icon-wrapper {
width: 100px;
height: 100px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background-size: contain;
.star-img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.group-name-tag {
margin-top: 10px;
background: #e0f2f1;
padding: 4px 16px;
border-radius: 16px;
font-size: 1rem;
color: $group-text-color;
font-weight: bold;
position: relative;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 2px solid #fff;
.sun-icon {
position: absolute;
right: -8px;
top: -8px;
font-size: 1rem;
}
&::before {
content: '🌸';
position: absolute;
left: -12px;
bottom: -4px;
font-size: 0.9rem;
}
}
}
// 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: translateY(-30px);
}
.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: scale(0.5);
}
/* Responsive styles auto-handled by flex-wrap */
</style>

View File

@ -0,0 +1,240 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
const { routerPushByKey } = useRouterPush()
interface EventItem {
id: number
title: string
subTitle?: string
icon: string
status: 'active' | 'pending' | 'finished'
}
const events = ref<EventItem[]>([
{ id: 1, title: '第九届阅读之星', subTitle: '第一轮', icon: 'activity', status: 'active' },
{ id: 2, title: '第九届阅读之星', subTitle: '第二轮', icon: 'cast', status: 'pending' },
{ id: 3, title: '第一届', subTitle: '星际知识大赛', icon: 'chrome', status: 'active' },
{ id: 4, title: '第九届友谊赛', subTitle: '', icon: 'heart', status: 'finished' },
])
function handleEnter(id: number) {
routerPushByKey('user_groups', { query: { id: id.toString() } })
}
function handleBack() {
// console.log('back')
}
function handleNext() {
// 首页的下一步操作,可以根据需求实现
routerPushByKey('user_groups')
}
const showContent = ref(false)
onMounted(() => {
setTimeout(() => {
showContent.value = true
}, 100)
})
</script>
<template>
<CompetitionLayout
:show-back="false"
:show-next="false"
title="赛事总览"
action-position="top"
back-btn-text="返回"
btn-theme="light"
@back="handleBack"
@next="handleNext"
>
<!-- Cards Section -->
<div class="cards-container">
<TransitionGroup name="list-anim" tag="div" class="cards-wrapper">
<div
v-for="(item, index) in events" v-show="showContent" :key="item.id" class="event-card"
:style="{ '--delay': `${index * 0.1}s` }" @click="handleEnter(item.id)"
>
<div class="card-top-bar" />
<div class="card-body">
<div class="icon-wrapper">
<SvgIcon :local-icon="item.icon" class="event-icon" />
</div>
<div class="text-content">
<h3 class="event-title">
{{ item.title }}
</h3>
<p v-if="item.subTitle" class="event-subtitle">
{{ item.subTitle }}
</p>
</div>
</div>
<div class="tassel" />
</div>
</TransitionGroup>
</div>
</CompetitionLayout>
</template>
<style lang="scss" scoped>
@import '@/styles/scss/variables.scss';
.cards-container {
width: 100%;
padding: 0 40px;
}
.cards-wrapper {
display: flex;
justify-content: center;
gap: 40px;
flex-wrap: wrap;
}
.event-card {
width: 340px;
height: 480px;
background: url('@/assets/imgs/user/event-card-bg.svg') no-repeat center center;
border-radius: 8px 8px 40px 40px;
position: relative;
cursor: pointer;
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
.card-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
}
.icon-wrapper {
width: 100px;
height: 100px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
.event-icon {
font-size: 48px;
color: $secondary-color;
}
}
.text-content {
.event-title {
font-size: 1.4rem;
font-weight: bold;
color: $text-color;
margin-bottom: 8px;
line-height: 1.4;
}
.event-subtitle {
font-size: 1.1rem;
color: lighten($text-color, 15%);
}
}
&:hover {
transform: translateY(-10px);
.icon-wrapper {
transform: scale(1.1) rotate(5deg);
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
}
}
// 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: translateY(-30px);
}
.list-anim-enter-active,
.list-anim-leave-active {
transition: all 0.6s cubic-bezier(0.5, 0, 0.25, 1);
transition-delay: var(--delay);
}
.list-anim-enter-from,
.list-anim-leave-to {
opacity: 0;
transform: translateY(50px);
}
// Responsive
@media (max-width: 1024px) {
.cards-wrapper {
gap: 20px;
}
.event-card {
width: 220px;
height: 320px;
}
}
@media (max-width: 768px) {
.cards-wrapper {
flex-direction: column;
align-items: center;
}
.event-card {
width: 90%;
height: 120px;
flex-direction: row;
border-radius: 8px;
.card-top-bar {
width: 16px;
height: 110%;
top: -5%;
left: -8px;
}
.tassel {
display: none;
}
.card-body {
flex-direction: row;
text-align: left;
padding: 10px 20px;
}
.icon-wrapper {
width: 60px;
height: 60px;
margin-bottom: 0;
margin-right: 20px;
.event-icon {
font-size: 32px;
}
}
}
}
</style>

View File

@ -1,152 +0,0 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { mockData } from './data/mock' // 引入模拟数据
import AnswerAnalysisComp from './modules/AnswerAnalysisComp.vue'
import CoverComp from './modules/CoverComp.vue' // 封面组件
import GameComp from './modules/GameComp.vue'
import IntroComp from './modules/IntroComp.vue'
// 状态定义
type Status = 'COVER' | 'INTRO' | 'GAME' | 'ANSWER_ANALYSIS'
const status = ref<Status>('COVER')
const data = ref(mockData)
// 索引控制
const currentStep = ref(0) // Node 索引
const subStep = ref(0) // Question 索引
// 定时器控制
const timeLeft = ref(0)
let timerInterval: number | null = null
// --- 计算属性 ---
const currentNode = computed(() => data.value.nodes[currentStep.value])
const currentQuestion = computed(() => {
// 保护性代码:防止越界
const questions = currentNode.value.questions
return questions[subStep.value] || questions[0]
})
// --- 动作处理 ---
// 1. 开始比赛 -> 去第一个环节的介绍页
function handleStart() {
currentStep.value = 0
subStep.value = 0
status.value = 'INTRO'
}
// 2. 抽题 -> 去答题页,重置时间
function handleDraw() {
status.value = 'GAME'
timeLeft.value = currentNode.value.config.timeLimit
startTimer()
}
// 3. 定时器逻辑
function startTimer() {
if (timerInterval)
clearInterval(timerInterval)
timerInterval = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value--
}
else {
// 时间到,清除定时器,进入答题图解页面
if (timerInterval)
clearInterval(timerInterval)
status.value = 'ANSWER_ANALYSIS'
}
}, 1000)
}
// 4. 下一题逻辑 (核心流程)
function handleToAnalysis() {
if (timerInterval)
clearInterval(timerInterval)
status.value = 'ANSWER_ANALYSIS'
}
function handleNext() {
if (timerInterval)
clearInterval(timerInterval)
const totalQuestionsInNode = currentNode.value.config.questionCount
// A. 如果当前环节还有题
if (subStep.value < totalQuestionsInNode - 1 && subStep.value < currentNode.value.questions.length - 1) {
subStep.value++
// 回到介绍页(抽题页),等待用户点击“抽题”再次开始
status.value = 'INTRO'
}
// B. 当前环节结束,去下一个环节
else {
if (currentStep.value < data.value.nodes.length - 1) {
currentStep.value++
subStep.value = 0
status.value = 'INTRO' // 回到介绍页
}
else {
// 全部结束
window.$message?.success('全场比赛结束!')
status.value = 'COVER'
currentStep.value = 0
subStep.value = 0
}
}
}
onUnmounted(() => {
if (timerInterval)
clearInterval(timerInterval)
})
</script>
<template>
<CompetitionLayout>
<div class="h-screen w-full overflow-hidden font-sans">
<!-- 主内容区域 -->
<div class="relative z-10 h-full w-full">
<!-- 状态 1: 封面 -->
<CoverComp
v-if="status === 'COVER'"
:title="data.activityInfo.name"
:nodes="data.nodes"
@start="handleStart"
/>
<!-- 状态 2: 介绍页 -->
<IntroComp
v-else-if="status === 'INTRO'"
:node="currentNode"
@back="status = 'COVER'"
@draw="handleDraw"
/>
<!-- 状态 3: 答题页 -->
<GameComp
v-else-if="status === 'GAME'"
:node="currentNode"
:question="currentQuestion"
:sub-step="subStep"
:time-left="timeLeft"
@back="status = 'INTRO'"
@next="handleToAnalysis"
/>
<!-- 状态 4: 答题图解页 -->
<AnswerAnalysisComp
v-else-if="status === 'ANSWER_ANALYSIS'"
:question-id="currentQuestion.id"
@back="status = 'GAME'"
@next="handleNext"
/>
</div>
</div>
</CompetitionLayout>
</template>
<style scoped>
</style>

View File

@ -7,24 +7,6 @@ defineEmits(['back', 'draw'])
<template>
<div class="h-full w-full flex flex-col items-center pt-44">
<!-- 标题卷轴 -->
<div class="relative mb-16">
<div class="relative z-10 h-20 min-w-[300px] flex items-center justify-center border-2 border-orange-200 rounded-lg from-orange-100 to-orange-50 bg-gradient-to-b px-12 shadow-lg">
<div class="absolute top-1/2 h-24 w-6 border-r-4 border-yellow-600 rounded-l-md bg-teal-700 shadow-md -left-3 -translate-y-1/2">
<div class="absolute left-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
<div class="absolute bottom-2 left-1 h-2 w-full rounded-full bg-teal-600/50" />
</div>
<div class="absolute top-1/2 h-24 w-6 border-l-4 border-yellow-600 rounded-r-md bg-teal-700 shadow-md -right-3 -translate-y-1/2">
<div class="absolute right-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
<div class="absolute bottom-2 right-1 h-2 w-full rounded-full bg-teal-600/50" />
</div>
<h1 class="text-3xl text-gray-800 font-bold tracking-widest font-serif">
{{ node.moduleName }}
</h1>
</div>
</div>
<!-- 规则描述 -->
<div class="mb-20 max-w-4xl text-center">
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
{{ node.description }}
@ -40,24 +22,5 @@ defineEmits(['back', 'draw'])
<span>抽题</span>
</button>
</div>
<!-- 底部按钮 -->
<div class="fixed bottom-12 left-12">
<button
class="border-2 border-red-400 rounded-full bg-white px-10 py-2 text-xl text-red-500 font-bold shadow-md transition hover:bg-red-50"
@click="$emit('back')"
>
返回
</button>
</div>
<div class="fixed bottom-12 right-12">
<button
class="rounded-full from-red-400 to-red-500 bg-gradient-to-r px-10 py-2 text-xl text-white font-bold shadow-lg transition hover:from-red-500 hover:to-red-600"
@click="$emit('draw')"
>
开始答题
</button>
</div>
</div>
</template>

View File

@ -16,10 +16,10 @@ const pinyinChars = computed(() => {
</script>
<template>
<div class="min-h-[400px] w-full flex items-center justify-center p-10">
<div class="min-h-[400px] w-full flex items-center justify-center p-6">
<!-- 模板A: 汉字听写-提示 (拼音+提示) -->
<div v-if="template === 'TEMPLATE_DICTATION_HINT'" class="text-center">
<div class="mb-6 inline-block text-[120px] text-red-500 font-bold leading-none" style="text-shadow: 2px 2px 4px rgba(0,0,0,0.1);">
<div class="mb-6 inline-block text-5xl text-red-500 font-bold leading-none" style="text-shadow: 2px 2px 4px rgba(0,0,0,0.1);">
{{ content.title }}
</div>
<!-- <div class="text-4xl text-gray-800 font-bold">
@ -91,7 +91,7 @@ const pinyinChars = computed(() => {
<!-- 模板G: 诗词理解-选择题 (通用选择题模板) -->
<div v-else-if="template === 'TEMPLATE_POETRY_MULTIPLE_CHOICE'" class="w-full flex flex-col items-center">
<div class="mb-8 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="mb-1 text-2xl text-gray-800 font-bold leading-relaxed">
{{ content.title }}
</div>
<div class="options-container">
@ -153,8 +153,8 @@ const pinyinChars = computed(() => {
margin-bottom: 3rem;
.option-card {
width: 320px;
height: 260px;
// width: 320px;
// height: 260px;
}
}
@ -165,7 +165,6 @@ const pinyinChars = computed(() => {
justify-content: center;
border: 2px solid transparent;
border-radius: 0.75rem; /* rounded-xl */
background-color: #f9fafb; /* bg-gray-50 */
padding: 1rem; /* p-4 */
transition: all 0.3s;
@ -188,8 +187,8 @@ const pinyinChars = computed(() => {
}
.option-image-wrapper {
margin-bottom: 1rem;
height: 9rem;
margin-bottom: 0.8rem;
height: 6rem;
width: 100%;
display: flex;
align-items: center;

View File

@ -0,0 +1,80 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
const { routerPushByKey } = useRouterPush()
const activityName = ref('规则介绍')
const rulesContent = ref('')
const mockContent = `<p>本轮环节共有4道题目,分别是“<strong>诗词理解</strong>”、“<strong>联想对对碰</strong>”、“<strong>逆向接诗句</strong>”、“<strong>情景猜诗句</strong>”</p><p>阅读完题目介绍,主持人点击“<strong>开始作答</strong>”后均在答题本田字格上作答</p><p><br></p><p> “<strong>诗词理解</strong>” 根据题目选择正确答案</p><p> “<strong>联想对对碰</strong>” 根据关键信息写出完整诗句</p><p> “<strong>逆向接诗句</strong>” 根据关键信息写出关联诗句</p><p> “<strong>情景猜诗句</strong>” 根据情景写出关联诗句</p><p><br></p><p><span style=\"background-color: rgb(89, 191, 192);\">答题倒计时结束后,界面自动跳转到下一题</span></p>
`
onMounted(() => {
setTimeout(() => {
rulesContent.value = mockContent
}, 100)
})
function handleBack() {
routerPushByKey('user_cover')
}
function handleNext() {
routerPushByKey('user_draw')
}
</script>
<template>
<CompetitionLayout
:show-back="true"
:show-next="true"
:title="activityName"
@back="handleBack"
@next="handleNext"
>
<div class="items-flex-start h-full w-full flex justify-center font-sans">
<div class="rules-content-wrapper">
<div class="rules-container" v-html="rulesContent" />
</div>
</div>
</CompetitionLayout>
</template>
<style lang="scss" scoped>
@import '@/styles/scss/variables.scss';
.rules-content-wrapper {
width: 100%;
max-height: 80%;
display: flex;
align-items: center;
justify-content: center;
color: $text-color;
:deep(.rules-container) {
margin-top: 10px;
font-size: 28px;
line-height: 2;
// color: #5d4037; // Dark brown/grey text
.intro-text {
margin-bottom: 30px;
}
.rules-list {
margin: 40px 0;
p {
margin: 15px 0;
}
}
.footer-text {
margin-top: 40px;
opacity: 0.8;
}
}
}
</style>

View File

@ -0,0 +1,199 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
const { routerPushByKey } = useRouterPush()
interface TeamItem {
id: number
name: string
icon: string
}
const teams = ref<TeamItem[]>([
{ id: 1, name: '1号代表队', icon: 'star' },
{ id: 2, name: '2号代表队', icon: 'star' },
{ id: 3, name: '3号代表队', icon: 'star' },
{ id: 4, name: '4号代表队', icon: 'star' },
])
const showContent = ref(false)
function handleBack() {
routerPushByKey('user_groups')
}
function handleNext() {
// 进入规则页面
routerPushByKey('user_rules')
}
function selectTeam(_id: number) {
// 选择队伍逻辑
routerPushByKey('user_cover', { query: { id: _id.toString() } })
}
onMounted(() => {
setTimeout(() => {
showContent.value = true
}, 100)
})
</script>
<template>
<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>
</div>
</div>
</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>
<div class="team-name">
{{ item.name }}
</div>
</div>
</TransitionGroup>
</div>
<div class="scroll-right-decor" />
</div>
</div>
</CompetitionLayout>
</template>
<style lang="scss" scoped>
.title-section {
position: absolute;
top: 18%; // 使用百分比定位,适应不同比例
left: 7%; // 微调位置,根据 1920x1080 下的视觉效果调整
z-index: 10;
.scroll-bg {
.main-title {
margin: 0;
writing-mode: vertical-rl; // 竖排文字
letter-spacing: 20px;
text-align: center;
font-weight: 900;
font-family: 'KaiTi', 'STKaiti', serif; // 尝试使用楷体增加古风感
}
}
}
// 覆盖默认样式,因为这个页面设计比较特殊(卷轴)
.competition-layout :deep(.main-content) {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.scroll-container {
width: 80%;
height: 70%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
// border: 1px solid red;
}
.scroll-body {
width: 100%;
height: 781px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: url('@/assets/imgs/user/team-bg.svg') no-repeat center center;
background-size: 100% 100%;
&::before {
left: -15px;
}
&::after {
right: -15px;
}
}
.scroll-content {
width: 80%;
height: 80%;
display: flex;
align-items: center;
justify-content: center;
}
.teams-grid {
display: flex;
gap: 60px;
justify-content: center;
}
.team-item {
display: flex;
flex-direction: column;
align-items: center;
.icon-wrapper {
width: 140px;
height: 140px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
// background: url('@/assets/imgs/team-icon-bg.svg') no-repeat center center;
background-size: contain;
margin-bottom: 20px;
}
.team-name {
font-size: 1.2rem;
color: #333;
font-weight: bold;
}
}
// 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>