feat(user): 重构用户端竞赛流程,集成后端API并优化交互体验
- 新增 `activityinfo` store 管理活动信息,支持持久化 - 重构 `competition` store,分离数据层与流程控制,新增音效管理 - 新增游戏API模块 (`game.ts`),实现抽题、题目详情、提交结果等接口 - 用户端页面全面对接后端数据:首页轮播展示活动列表,组别/队伍页动态加载,封面页显示题目卡片 - 抽题页改为竹签翻转动画,游戏页集成题目详情与倒计时 - 优化布局组件,支持点击返回首页,统一路由参数传递 - 更新类型定义,支持富文本题目渲染 - 添加 `activityinfo` 拼写检查词条
This commit is contained in:
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -36,5 +36,8 @@
|
|||||||
"./apps/user",
|
"./apps/user",
|
||||||
"./packages/eslint-config"
|
"./packages/eslint-config"
|
||||||
],
|
],
|
||||||
"totvsLanguageServer.welcomePage": false // `
|
"totvsLanguageServer.welcomePage": false,
|
||||||
|
"cSpell.words": [
|
||||||
|
"activityinfo"
|
||||||
|
] // `
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 7.5 MiB After Width: | Height: | Size: 4.7 MiB |
@ -2,6 +2,7 @@
|
|||||||
import VScaleScreen from 'v-scale-screen'
|
import VScaleScreen from 'v-scale-screen'
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import _defaultBg from '@/assets/imgs/user/home-bg.svg'
|
import _defaultBg from '@/assets/imgs/user/home-bg.svg'
|
||||||
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 是否显示返回按钮 */
|
/** 是否显示返回按钮 */
|
||||||
@ -44,6 +45,8 @@ const emit = defineEmits<{
|
|||||||
(e: 'next'): void
|
(e: 'next'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { routerPushByKey } = useRouterPush()
|
||||||
|
|
||||||
// const router = useRouter()
|
// const router = useRouter()
|
||||||
const innerBgUrl = ref(_defaultBg)
|
const innerBgUrl = ref(_defaultBg)
|
||||||
|
|
||||||
@ -66,8 +69,6 @@ const showContent = ref(false)
|
|||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
emit('back')
|
emit('back')
|
||||||
// 如果没有监听 back 事件,默认行为可以是路由返回
|
|
||||||
// if (!emit('back')) router.back() // 需要判断是否有监听器比较麻烦,这里建议由父组件控制或提供默认行为
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
@ -76,6 +77,11 @@ function handleNext() {
|
|||||||
emit('next')
|
emit('next')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 点击返回首页 */
|
||||||
|
function handleBackHome() {
|
||||||
|
routerPushByKey('user_home')
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchSystemConfig()
|
fetchSystemConfig()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -93,7 +99,7 @@ onMounted(() => {
|
|||||||
<div class="competition-layout">
|
<div class="competition-layout">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="page-header text-[30px] text-[#333333] font-bold">
|
<header class="page-header text-[30px] text-[#333333] font-bold">
|
||||||
<span class="org-name">教育学会</span>
|
<span class="org-name" @click="handleBackHome">教育学会</span>
|
||||||
<span class="org-name">树人研究院</span>
|
<span class="org-name">树人研究院</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -118,7 +124,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- Footer Action -->
|
<!-- Footer Action -->
|
||||||
<footer class="page-footer w-full flex items-center justify-between px-20" :class="[actionPosition, btnTheme]">
|
<footer class="page-footer w-full flex items-center justify-between px-20" :class="[actionPosition, btnTheme]">
|
||||||
<button v-if="showBack" class="action-btn back-btn" :class="{ 'text-btn': backBtnText }" @click="handleBack">
|
<button class="action-btn back-btn" :class="{ 'text-btn': backBtnText }" :style="{ visibility: showBack ? 'visible' : 'hidden' }" @click="handleBack">
|
||||||
<span v-if="backBtnText" class="btn-text">{{ backBtnText }}</span>
|
<span v-if="backBtnText" class="btn-text">{{ backBtnText }}</span>
|
||||||
<SvgIcon v-else icon="mdi:arrow-left" class="arrow-icon" />
|
<SvgIcon v-else icon="mdi:arrow-left" class="arrow-icon" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export function fetchCreateQuestion(data: Api.Competition.CreateQuestionRequest[
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 根据活动ID获取活动题目列表 */
|
/** 根据活动ID获取活动题目列表 */
|
||||||
export function fetchGetQuestionList(id: number) {
|
export function fetchGetQuestionList(id: string) {
|
||||||
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
|
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
|
||||||
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}`,
|
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|||||||
50
apps/admin/src/service/api/game.ts
Normal file
50
apps/admin/src/service/api/game.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { request } from '../request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏相关接口 步骤
|
||||||
|
* 1.GetQuestionByActivityIDAndStatus 抽题时候获取题目大纲信息
|
||||||
|
* 2.GetQuestionListDetailRound 获取抽题目详情
|
||||||
|
* 3.fetchSubmitQuestionResult点击开始答题后,把基础信息数据传给service
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 抽题时候获取题目大纲信息 */
|
||||||
|
export function fetchGetQuestionOutline(ActivityID: number, status: number = 0) {
|
||||||
|
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
|
||||||
|
url: `/Base/ActivityMain/GetQuestionByActivityIDAndStatus/?ActivityID=${ActivityID}&status=${status}`,
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
ActivityID,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 抽题目详情 */
|
||||||
|
export function fetchGetQuestionDetail(ActivityID: string, questionID: number) {
|
||||||
|
return request<App.Service.Response<Api.Competition.QuestionListDetailRound>>({
|
||||||
|
url: `/Base/ActivityMain/GetQuestionListDetailRound/?MainID=${ActivityID}&questionID=${questionID}`,
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
MainID: ActivityID,
|
||||||
|
questionID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交抽题结果 */
|
||||||
|
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}`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取游戏现场统计结果 */
|
||||||
|
export function fetchGetGameStatistics(data: Api.Competition.QuestionAddParams) {
|
||||||
|
return request<App.Service.Response>({
|
||||||
|
url: `/Base/ActivityMain/GetTeamQuestionUseByTeamGroupIDAndQuestionID/?RoomID=${data.RoomID}&MainID=${data.MainID}&TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}&QuestionDetaiID=${data.QuestionDetaiID}`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
47
apps/admin/src/store/modules/activityinfo/index.ts
Normal file
47
apps/admin/src/store/modules/activityinfo/index.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const useActivityInfoStore = defineStore('activity-info', () => {
|
||||||
|
const activityInfo = ref<Api.Competition.ActivityDetail | null>(null)
|
||||||
|
const activityId = ref<number | null>(null)
|
||||||
|
const groupId = ref<number | null>(null)
|
||||||
|
const teamId = ref<number | null>(null)
|
||||||
|
|
||||||
|
function setActivityInfo(info: Api.Competition.ActivityDetail) {
|
||||||
|
activityInfo.value = info
|
||||||
|
activityId.value = info.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivityId(id: number) {
|
||||||
|
activityId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGroupId(id: number) {
|
||||||
|
groupId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTeamId(id: number) {
|
||||||
|
teamId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActivityInfo() {
|
||||||
|
activityInfo.value = null
|
||||||
|
activityId.value = null
|
||||||
|
groupId.value = null
|
||||||
|
teamId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activityInfo,
|
||||||
|
activityId,
|
||||||
|
groupId,
|
||||||
|
teamId,
|
||||||
|
setActivityInfo,
|
||||||
|
setActivityId,
|
||||||
|
setGroupId,
|
||||||
|
setTeamId,
|
||||||
|
clearActivityInfo,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
persist: true,
|
||||||
|
})
|
||||||
@ -1,129 +0,0 @@
|
|||||||
import type { CompetitionData } from '@/views/user/types'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { AudioController } from '@/utils/audio'
|
|
||||||
import { mockData } from '@/views/user/data/mock'
|
|
||||||
|
|
||||||
export const useCompetitionStore = defineStore('competition', () => {
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
|
||||||
|
|
||||||
// --- State ---
|
|
||||||
const data = ref<CompetitionData>(mockData)
|
|
||||||
const currentStep = ref(0) // Node 索引
|
|
||||||
const subStep = ref(0) // Question 索引
|
|
||||||
const timeLeft = ref(0)
|
|
||||||
const timerInterval = ref<number | null>(null)
|
|
||||||
|
|
||||||
// 音效控制器 (支持自定义音频文件,例如:{ start: '/audio/start.mp3' })
|
|
||||||
const audioController = new AudioController()
|
|
||||||
|
|
||||||
// --- Getters ---
|
|
||||||
const currentNode = computed(() => data.value.nodes[currentStep.value])
|
|
||||||
|
|
||||||
const currentQuestion = computed(() => {
|
|
||||||
const questions = currentNode.value?.questions || []
|
|
||||||
return questions[subStep.value] || questions[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLastQuestionInNode = computed(() => {
|
|
||||||
if (!currentNode.value)
|
|
||||||
return true
|
|
||||||
return subStep.value >= currentNode.value.questions.length - 1
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLastNode = computed(() => {
|
|
||||||
return currentStep.value >= data.value.nodes.length - 1
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
|
|
||||||
function initData() {
|
|
||||||
// 这里可以重置或者重新拉取数据
|
|
||||||
// data.value = ...
|
|
||||||
resetProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetProgress() {
|
|
||||||
currentStep.value = 0
|
|
||||||
subStep.value = 0
|
|
||||||
stopTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Audio Helpers ---
|
|
||||||
// 音频相关逻辑已抽离到 @/utils/audio.ts
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动倒计时定时器
|
|
||||||
*/
|
|
||||||
function startTimer() {
|
|
||||||
stopTimer()
|
|
||||||
if (!currentNode.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
timeLeft.value = currentNode.value.config.timeLimit
|
|
||||||
audioController.play('start') // 播放开始音效
|
|
||||||
|
|
||||||
timerInterval.value = window.setInterval(() => {
|
|
||||||
if (timeLeft.value > 0) {
|
|
||||||
timeLeft.value--
|
|
||||||
// 剩余5秒播放倒计时音效
|
|
||||||
if (timeLeft.value <= 5) {
|
|
||||||
audioController.play('tick')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
stopTimer()
|
|
||||||
// 时间到,自动进入解析页
|
|
||||||
routerPushByKey('user_analysis')
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopTimer() {
|
|
||||||
if (timerInterval.value) {
|
|
||||||
clearInterval(timerInterval.value)
|
|
||||||
timerInterval.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 进入下一题或下一环节
|
|
||||||
* @returns 返回下一步的路由名称,方便组件跳转
|
|
||||||
*/
|
|
||||||
function nextStep() {
|
|
||||||
stopTimer()
|
|
||||||
|
|
||||||
// A. 如果当前环节还有题
|
|
||||||
if (!isLastQuestionInNode.value) {
|
|
||||||
subStep.value++
|
|
||||||
// 回到抽题页
|
|
||||||
return 'user_draw'
|
|
||||||
}
|
|
||||||
// B. 当前环节结束,去下一个环节
|
|
||||||
else if (!isLastNode.value) {
|
|
||||||
currentStep.value++
|
|
||||||
subStep.value = 0
|
|
||||||
// 回到抽题页(新环节)
|
|
||||||
return 'user_draw'
|
|
||||||
}
|
|
||||||
// C. 全部结束
|
|
||||||
else {
|
|
||||||
resetProgress()
|
|
||||||
return 'user_cover'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
currentStep,
|
|
||||||
subStep,
|
|
||||||
timeLeft,
|
|
||||||
currentNode,
|
|
||||||
currentQuestion,
|
|
||||||
initData,
|
|
||||||
startTimer,
|
|
||||||
stopTimer,
|
|
||||||
nextStep,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
132
apps/admin/src/store/modules/competition/index.ts
Normal file
132
apps/admin/src/store/modules/competition/index.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { AudioController } from '@/utils/audio'
|
||||||
|
|
||||||
|
export const useCompetitionStore = defineStore('competition', () => {
|
||||||
|
// ==========================================
|
||||||
|
// State - 核心数据
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/** 题目列表数据 */
|
||||||
|
const questionList = ref<Api.Competition.QuestionListRecord[]>([])
|
||||||
|
|
||||||
|
/** 当前选中的题目基础信息 (从抽题页获取) */
|
||||||
|
const currentQuestionInfo = ref<Api.Competition.QuestionListRecord | null>(null)
|
||||||
|
|
||||||
|
/** 当前题目的详细信息 (从详情接口获取) */
|
||||||
|
const currentQuestionDetail = ref<Api.Competition.QuestionListDetailRound | null>(null)
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// State - 流程控制 & 交互状态
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/** 倒计时剩余时间 (秒) */
|
||||||
|
const timeLeft = ref(0)
|
||||||
|
|
||||||
|
/** 定时器引用 */
|
||||||
|
const timerInterval = ref<number | null>(null)
|
||||||
|
|
||||||
|
/** 音效控制器 */
|
||||||
|
const audioController = new AudioController()
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Actions - 数据操作
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置题目列表
|
||||||
|
*/
|
||||||
|
function setQuestionList(list: Api.Competition.QuestionListRecord[]) {
|
||||||
|
questionList.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前题目基础信息
|
||||||
|
*/
|
||||||
|
function setCurrentQuestionInfo(info: Api.Competition.QuestionListRecord) {
|
||||||
|
currentQuestionInfo.value = info
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前题目详情
|
||||||
|
*/
|
||||||
|
function setCurrentQuestionDetail(detail: Api.Competition.QuestionListDetailRound) {
|
||||||
|
currentQuestionDetail.value = detail
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Actions - 流程控制
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动倒计时
|
||||||
|
* @param duration 持续时间(秒)
|
||||||
|
*/
|
||||||
|
function startTimer(duration: number) {
|
||||||
|
stopTimer()
|
||||||
|
timeLeft.value = duration
|
||||||
|
|
||||||
|
// 播放开始音效
|
||||||
|
audioController.play('start')
|
||||||
|
|
||||||
|
timerInterval.value = window.setInterval(() => {
|
||||||
|
if (timeLeft.value > 0) {
|
||||||
|
timeLeft.value--
|
||||||
|
// 剩余5秒播放倒计时音效
|
||||||
|
if (timeLeft.value <= 5) {
|
||||||
|
audioController.play('tick')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
stopTimer()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止倒计时
|
||||||
|
*/
|
||||||
|
function stopTimer() {
|
||||||
|
if (timerInterval.value) {
|
||||||
|
clearInterval(timerInterval.value)
|
||||||
|
timerInterval.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置当前题目流程状态 (不清除题目基础信息)
|
||||||
|
*/
|
||||||
|
function resetRoundState() {
|
||||||
|
currentQuestionDetail.value = null
|
||||||
|
stopTimer()
|
||||||
|
timeLeft.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化/重置所有数据
|
||||||
|
*/
|
||||||
|
function initData() {
|
||||||
|
questionList.value = []
|
||||||
|
currentQuestionInfo.value = null
|
||||||
|
resetRoundState()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
questionList,
|
||||||
|
currentQuestionInfo,
|
||||||
|
currentQuestionDetail,
|
||||||
|
timeLeft,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setQuestionList,
|
||||||
|
setCurrentQuestionInfo,
|
||||||
|
setCurrentQuestionDetail,
|
||||||
|
startTimer,
|
||||||
|
stopTimer,
|
||||||
|
resetRoundState,
|
||||||
|
initData,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
persist: true,
|
||||||
|
})
|
||||||
20
apps/admin/src/typings/api/Competition.d.ts
vendored
20
apps/admin/src/typings/api/Competition.d.ts
vendored
@ -75,6 +75,26 @@ declare namespace Api {
|
|||||||
Name: string
|
Name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** question add params */
|
||||||
|
interface QuestionAddParams {
|
||||||
|
RoomID: number
|
||||||
|
MainID: number
|
||||||
|
TeamGroupID: number
|
||||||
|
QuestionID: number
|
||||||
|
QuestionDetaiID: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuestionListDetailRound {
|
||||||
|
/** 题目 id */
|
||||||
|
Id: number
|
||||||
|
Name: string
|
||||||
|
Answers: string
|
||||||
|
QuestionId: number
|
||||||
|
IsGood: number
|
||||||
|
Image: string
|
||||||
|
ImageUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
/** activity detail */
|
/** activity detail */
|
||||||
interface ActivityDetail {
|
interface ActivityDetail {
|
||||||
/** 背景图片 */
|
/** 背景图片 */
|
||||||
|
|||||||
2
apps/admin/src/typings/app.d.ts
vendored
2
apps/admin/src/typings/app.d.ts
vendored
@ -198,7 +198,7 @@ declare namespace App {
|
|||||||
|
|
||||||
/** The router push options */
|
/** The router push options */
|
||||||
interface RouterPushOptions {
|
interface RouterPushOptions {
|
||||||
query?: Record<string, string>
|
query?: Record<string, string | number>
|
||||||
params?: Record<string, string>
|
params?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
apps/admin/src/typings/components.d.ts
vendored
2
apps/admin/src/typings/components.d.ts
vendored
@ -59,6 +59,7 @@ declare module 'vue' {
|
|||||||
IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
|
IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
|
||||||
IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
||||||
IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
||||||
|
IconIcRoundArrowForward: typeof import('~icons/ic/round-arrow-forward')['default']
|
||||||
IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
|
IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
|
||||||
IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
|
IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
|
||||||
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||||
@ -227,6 +228,7 @@ declare global {
|
|||||||
const IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
|
const IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
|
||||||
const IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
const IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
||||||
const IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
const IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
||||||
|
const IconIcRoundArrowForward: typeof import('~icons/ic/round-arrow-forward')['default']
|
||||||
const IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
|
const IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
|
||||||
const IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
|
const IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
|
||||||
const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||||
|
|||||||
@ -2,12 +2,8 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
import { useCompetitionStore } from '@/store/modules/competition'
|
|
||||||
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
const { routerPushByKey } = useRouterPush()
|
||||||
|
|
||||||
const store = useCompetitionStore()
|
|
||||||
|
|
||||||
const zoomedImage = ref<string | null>(null)
|
const zoomedImage = ref<string | null>(null)
|
||||||
|
|
||||||
// Mock team data
|
// Mock team data
|
||||||
@ -59,20 +55,19 @@ function handleBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
const nextRoute = store.nextStep()
|
routerPushByKey('user_draw')
|
||||||
routerPushByKey(nextRoute)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CompetitionLayout
|
<CompetitionLayout
|
||||||
:show-title="true"
|
:show-title="true"
|
||||||
:show-back="true"
|
:show-back="false"
|
||||||
:show-next="true"
|
:show-next="true"
|
||||||
title="答题图解"
|
title="答题图解"
|
||||||
action-position="top"
|
action-position="top"
|
||||||
back-btn-text="返回"
|
back-btn-text="返回"
|
||||||
next-btn-text="下一题"
|
next-btn-text="继续抽题"
|
||||||
btn-theme="light"
|
btn-theme="light"
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
@next="handleNext"
|
@next="handleNext"
|
||||||
@ -84,7 +79,7 @@ function handleNext() {
|
|||||||
备注:由AI模型评判
|
备注:由AI模型评判
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-4 h-full gap-6 pb-8">
|
<div v-if="teams && teams.length > 0" class="grid grid-cols-4 h-full gap-6 pb-8">
|
||||||
<div
|
<div
|
||||||
v-for="team in teams" :key="team.id"
|
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"
|
class="flex flex-col border-2 border-gray-200 rounded-xl bg-white/90 p-4 shadow-sm"
|
||||||
|
|||||||
@ -1,25 +1,79 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { fetchGetQuestionList } from '@/service/api/competition'
|
||||||
|
import { useActivityInfoStore } from '@/store/modules/activityinfo'
|
||||||
import { AudioController } from '@/utils/audio'
|
import { AudioController } from '@/utils/audio'
|
||||||
import { mockData } from '../data/mock'
|
import { filterRepeat } from '@/utils/data'
|
||||||
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
const activityInfoStore = useActivityInfoStore()
|
||||||
|
const activityInfo = computed(() => activityInfoStore.activityInfo)
|
||||||
|
|
||||||
|
const { routerPushByKey, routerBack } = useRouterPush()
|
||||||
const audioController = new AudioController()
|
const audioController = new AudioController()
|
||||||
|
|
||||||
const activityName = ref('星·辞海遨游')
|
interface QuestionItem {
|
||||||
|
id: number
|
||||||
|
moduleName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = ref<QuestionItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const activityName = computed(() => activityInfo.value?.ActivityTitle || '星·辞海遨游')
|
||||||
|
|
||||||
// 记录已翻转的卡片索引
|
// 记录已翻转的卡片索引
|
||||||
const flippedCards = ref<Set<number>>(new Set())
|
const flippedCards = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
// 计算是否所有卡片都已翻转 (当前显示4张卡片)
|
// 计算是否所有卡片都已翻转 (当前显示4张卡片)
|
||||||
const isAllFlipped = computed(() => flippedCards.value.size === 4)
|
const isAllFlipped = computed(() => flippedCards.value.size === questions.value.length)
|
||||||
|
|
||||||
// 控制下一步按钮的显示(带延迟)
|
// 控制下一步按钮的显示(带延迟)
|
||||||
const showNextButton = ref(false)
|
const showNextButton = ref(false)
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// 获取路由参数
|
||||||
|
const route = useRoute()
|
||||||
|
const activityId = computed(() => activityInfoStore.activityId || Number(route.query?.activityId))
|
||||||
|
const groupsId = computed(() => Number(route.query?.groupsId)) || activityInfoStore.groupId
|
||||||
|
const teamId = computed(() => activityInfoStore.teamId || Number(route.query?.teamId))
|
||||||
|
|
||||||
|
async function getQuestions() {
|
||||||
|
if (!activityId.value || !groupsId.value || !teamId.value) {
|
||||||
|
window?.$message?.error('参数错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存 activityId,groupsId,teamId
|
||||||
|
activityInfoStore.setActivityId(activityId.value)
|
||||||
|
activityInfoStore.setGroupId(groupsId.value)
|
||||||
|
activityInfoStore.setTeamId(teamId.value)
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data, error } = await fetchGetQuestionList(String(activityId.value))
|
||||||
|
if (error) {
|
||||||
|
window?.$message?.error(error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = data?.data || []
|
||||||
|
if (list && Array.isArray(list)) {
|
||||||
|
// 根据ActitvityQuestionName去重
|
||||||
|
const uniqueData = filterRepeat(list as any[], 'ActitvityQuestionName')
|
||||||
|
questions.value = uniqueData.map((item: any) => ({
|
||||||
|
id: item.Id,
|
||||||
|
moduleName: item.ActitvityQuestionName,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(isAllFlipped, (val) => {
|
watch(isAllFlipped, (val) => {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
@ -37,11 +91,11 @@ watch(isAllFlipped, (val) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
routerPushByKey('user_teams')
|
routerBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
routerPushByKey('user_rules')
|
routerPushByKey('user_rules', { query: { activityId: activityId.value, groupsId: groupsId.value, teamId: teamId.value } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFlip(index: number) {
|
function toggleFlip(index: number) {
|
||||||
@ -53,19 +107,24 @@ function toggleFlip(index: number) {
|
|||||||
audioController.play('flip') // 播放翻牌音效
|
audioController.play('flip') // 播放翻牌音效
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getQuestions()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CompetitionLayout :show-back="true" :show-next="showNextButton" :title="activityName" @back="handleBack" @next="handleNext">
|
<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="h-screen w-full overflow-hidden font-sans">
|
||||||
<div class="relative z-10 h-full w-full">
|
<div class="relative z-10 h-full w-full">
|
||||||
<div class="h-full w-full flex flex-col items-center pt-24">
|
<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 class="perspective-container grid grid-cols-4 max-w-7xl w-full gap-10 px-12">
|
||||||
<div
|
<div
|
||||||
v-for="(node, index) in mockData.nodes.slice(0, 4)"
|
v-for="(node, index) in questions.slice(0, 4)" :key="node.id" class="card-wrapper"
|
||||||
:key="node.nodeId"
|
|
||||||
class="card-wrapper"
|
|
||||||
@click="toggleFlip(index)"
|
@click="toggleFlip(index)"
|
||||||
>
|
>
|
||||||
<div class="flip-card-inner" :class="{ 'is-flipped': flippedCards.has(index) }">
|
<div class="flip-card-inner" :class="{ 'is-flipped': flippedCards.has(index) }">
|
||||||
@ -82,12 +141,14 @@ function toggleFlip(index: number) {
|
|||||||
<!-- 内部装饰圈 -->
|
<!-- 内部装饰圈 -->
|
||||||
<div class="inner-circle-decoration">
|
<div class="inner-circle-decoration">
|
||||||
<!-- 文字内容 -->
|
<!-- 文字内容 -->
|
||||||
<!-- <div class="relative z-10 transform text-center">
|
<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">
|
<div
|
||||||
|
class="card-text text-2xl text-[#333333] font-bold leading-tight font-serif drop-shadow-sm"
|
||||||
|
>
|
||||||
<span class="block">{{ node.moduleName.slice(0, 2) }}</span>
|
<span class="block">{{ node.moduleName.slice(0, 2) }}</span>
|
||||||
<span class="block">{{ node.moduleName.slice(2) }}</span>
|
<span class="block">{{ node.moduleName.slice(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -259,10 +320,12 @@ function toggleFlip(index: number) {
|
|||||||
left: -150%;
|
left: -150%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
10% {
|
10% {
|
||||||
// 刚进入时瞬间变亮
|
// 刚进入时瞬间变亮
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
// 扫出视野
|
// 扫出视野
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@ -275,6 +338,7 @@ function toggleFlip(index: number) {
|
|||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +1,216 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { fetchGetQuestionOutline } from '@/service/api/game'
|
||||||
|
import { useActivityInfoStore } from '@/store/modules/activityinfo'
|
||||||
import { useCompetitionStore } from '@/store/modules/competition'
|
import { useCompetitionStore } from '@/store/modules/competition'
|
||||||
|
import { AudioController } from '@/utils/audio'
|
||||||
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
const { routerPushByKey, routerBack } = useRouterPush()
|
||||||
|
|
||||||
|
// 获取路由参数
|
||||||
|
const route = useRoute()
|
||||||
|
const activityInfoStore = useActivityInfoStore()
|
||||||
|
const activityId = computed(() => activityInfoStore.activityId || route.query?.activityId as string)
|
||||||
|
|
||||||
const store = useCompetitionStore()
|
const store = useCompetitionStore()
|
||||||
|
|
||||||
const currentNode = computed(() => store.currentNode)
|
const loading = ref(false)
|
||||||
function handleDraw() {
|
const question = ref<Api.Competition.QuestionListRecord | null>(null)
|
||||||
routerPushByKey('user_game')
|
const flippedId = ref<number | null>(null)
|
||||||
|
const audioController = new AudioController()
|
||||||
|
|
||||||
|
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||||
|
const pageDescription = computed(() => {
|
||||||
|
if (question.value) {
|
||||||
|
return question.value.QuestionSubTitle || ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function initQuestions() {
|
||||||
|
if (!activityId.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data: outlineData, error } = await fetchGetQuestionOutline(Number(activityId.value), 0)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(outlineData, 'outlineData')
|
||||||
|
if (error) {
|
||||||
|
window?.$message?.error(error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接使用返回的对象数据,不进行数组转换
|
||||||
|
if (outlineData && outlineData.data) {
|
||||||
|
question.value = outlineData.data as unknown as Api.Competition.QuestionListRecord
|
||||||
|
store.setQuestionList([question.value]) // Store expects array, so wrap it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
window.$message?.error('获取题目失败,请重试')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initQuestions()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleCardClick(item: Api.Competition.QuestionListRecord) {
|
||||||
|
if (flippedId.value)
|
||||||
|
return // 防止重复点击
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 播放翻牌音效 (忽略音频错误)
|
||||||
|
try {
|
||||||
|
audioController.play('flip')
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error('Audio play failed', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 触发翻转动画
|
||||||
|
flippedId.value = item.ID
|
||||||
|
|
||||||
|
// 3. 延迟1s后跳转
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
store.setCurrentQuestionInfo(item)
|
||||||
|
routerPushByKey('user_game')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Navigation failed', error)
|
||||||
|
flippedId.value = null // 跳转失败则重置状态
|
||||||
|
window.$message?.error('跳转失败,请重试')
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
flippedId.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
routerPushByKey('user_cover')
|
routerBack()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CompetitionLayout :show-back="true" :show-next="false" :title="currentNode?.moduleName || '抽题'" @back="handleBack">
|
<CompetitionLayout :show-back="true" :show-next="false" :title="question?.ActitvityQuestionName || '抽题'" @back="handleBack">
|
||||||
<div class="h-full w-full flex flex-col items-center pt-10 font-sans">
|
<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 v-if="question" class="mb-10 max-w-4xl text-center">
|
||||||
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
|
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
|
||||||
{{ currentNode.description }}
|
{{ question?.ActitvityQuestionName || '' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 抽题按钮 (大圆形) -->
|
<!-- 题目卡片列表 -->
|
||||||
<div class="group relative">
|
<div v-if="loading" class="text-xl text-gray-500">
|
||||||
<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"
|
</div>
|
||||||
@click="handleDraw"
|
<div v-else class="w-full flex flex-1 items-center justify-center pb-10">
|
||||||
>
|
<div v-if="question" class="lottery-stick-container">
|
||||||
<span>抽题</span>
|
<div
|
||||||
</button>
|
class="lottery-stick"
|
||||||
|
:class="{ 'is-selected': flippedId === question.ID }"
|
||||||
|
@click="handleCardClick(question)"
|
||||||
|
>
|
||||||
|
<!-- 签身 (默认显示) -->
|
||||||
|
<div class="stick-face stick-body">
|
||||||
|
<div class="stick-top-mark" />
|
||||||
|
<div class="writing-mode-vertical-rl h-full flex items-center justify-center text-xl text-yellow-900 font-bold tracking-widest opacity-80">
|
||||||
|
抽 题
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 签面 (翻转后显示 - 实际上是放大显示的) -->
|
||||||
|
<div class="stick-face stick-content">
|
||||||
|
<span class="writing-mode-vertical-rl text-2xl text-red-600 font-bold">{{ question.ActitvityQuestionName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && !question" class="text-xl text-gray-400">
|
||||||
|
暂无题目
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CompetitionLayout>
|
</CompetitionLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lottery-stick-container {
|
||||||
|
width: 50px;
|
||||||
|
height: 240px;
|
||||||
|
perspective: 1000px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-stick-container:hover {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lottery-stick {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stick-face {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 竹签样式 */
|
||||||
|
.stick-body {
|
||||||
|
background: linear-gradient(to right, #e6cba5, #d4b081, #c69c6d);
|
||||||
|
border: 1px solid #b08d55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stick-top-mark {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #ef4444; /* red-500 */
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 翻转后的内容面 - 做成类似令牌的样子 */
|
||||||
|
.stick-content {
|
||||||
|
background: linear-gradient(to bottom, #fff9f0, #fff);
|
||||||
|
border: 2px solid #b08d55;
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中(抽出)动画状态 */
|
||||||
|
.lottery-stick.is-selected {
|
||||||
|
transform: translateY(-100px) scale(2) rotateY(180deg);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 竖排文字 */
|
||||||
|
.writing-mode-vertical-rl {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: upright;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,25 +1,59 @@
|
|||||||
|
<!-- eslint-disable no-console -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
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 { useCompetitionStore } from '@/store/modules/competition'
|
||||||
// import { uiTemplatesMapping } from '@/views/user/data/mock'
|
|
||||||
import QuestionRenderer from '../modules/QuestionRenderer.vue'
|
import QuestionRenderer from '../modules/QuestionRenderer.vue'
|
||||||
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
const { routerPushByKey } = useRouterPush()
|
||||||
|
|
||||||
const store = useCompetitionStore()
|
const store = useCompetitionStore()
|
||||||
|
const activityInfoStore = useActivityInfoStore()
|
||||||
|
const { activityId, groupId, activityInfo } = storeToRefs(activityInfoStore)
|
||||||
|
const currentQuestionInfo = computed(() => store.currentQuestionInfo)
|
||||||
|
const currentQuestionDetail = computed(() => store.currentQuestionDetail)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
// 本地倒计时控制(为了在页面上显示“开始答题”还是倒计时)
|
// 本地倒计时控制(为了在页面上显示“开始答题”还是倒计时)
|
||||||
const isStarted = ref(false)
|
const isStarted = ref(false)
|
||||||
|
|
||||||
const currentNode = computed(() => store.currentNode)
|
console.log(activityInfo.value, 'activityInfo.value')
|
||||||
const currentQuestion = computed(() => store.currentQuestion)
|
console.log(currentQuestionInfo.value, 'currentQuestionInfo.value')
|
||||||
const timeLeft = computed(() => store.timeLeft)
|
console.log(currentQuestionDetail.value, 'currentQuestionDetail.value')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchGetQuestionDetailData()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听倒计时,结束时自动跳转
|
||||||
|
watch(() => store.timeLeft, (newVal) => {
|
||||||
|
if (newVal === 0 && isStarted.value) {
|
||||||
|
store.stopTimer()
|
||||||
|
routerPushByKey('user_analysis')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 优先使用 store 中的 API 数据,否则回退到 Mock 数据
|
||||||
|
const template = computed(() => store.currentQuestionInfo?.UIType)
|
||||||
|
const content = computed(() => {
|
||||||
|
if (store.currentQuestionDetail) {
|
||||||
|
const { Name, ImageUrl } = store.currentQuestionDetail
|
||||||
|
return {
|
||||||
|
title: Name,
|
||||||
|
titleImage: ImageUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
const formattedTime = computed(() => {
|
const formattedTime = computed(() => {
|
||||||
const m = Math.floor(timeLeft.value / 60)
|
const time = store.timeLeft
|
||||||
const s = timeLeft.value % 60
|
const m = Math.floor(time / 60)
|
||||||
|
const s = time % 60
|
||||||
const mm = m < 10 ? `0${m}` : m
|
const mm = m < 10 ? `0${m}` : m
|
||||||
const ss = s < 10 ? `0${s}` : s
|
const ss = s < 10 ? `0${s}` : s
|
||||||
return `倒计时 ${mm}:${ss}`
|
return `倒计时 ${mm}:${ss}`
|
||||||
@ -38,45 +72,94 @@ function handleBack() {
|
|||||||
routerPushByKey('user_draw')
|
routerPushByKey('user_draw')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNext() {
|
/**
|
||||||
|
* 获取题目详情
|
||||||
|
*/
|
||||||
|
async function fetchGetQuestionDetailData() {
|
||||||
|
if (store.currentQuestionInfo) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const currentActivityId = String(activityId.value || '')
|
||||||
|
const questionId = store.currentQuestionInfo.QuestionID || (store.currentQuestionInfo as any).Id
|
||||||
|
const { data: detailData } = await fetchGetQuestionDetail(currentActivityId, questionId)
|
||||||
|
console.log(detailData, 'detailData')
|
||||||
|
if (detailData && detailData.data) {
|
||||||
|
store.setCurrentQuestionDetail(detailData.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Failed to fetch question detail', error)
|
||||||
|
window.$message?.error('获取题目详情失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNext() {
|
||||||
|
// 点击开始答题
|
||||||
if (!isStarted.value) {
|
if (!isStarted.value) {
|
||||||
// 点击开始答题
|
|
||||||
isStarted.value = true
|
isStarted.value = true
|
||||||
store.startTimer()
|
store.startTimer(store.currentQuestionInfo?.QuestionTime || 10)
|
||||||
|
|
||||||
|
// 提交题目使用记录
|
||||||
|
try {
|
||||||
|
if (store.currentQuestionInfo && store.currentQuestionDetail) {
|
||||||
|
const params: Api.Competition.QuestionAddParams = {
|
||||||
|
RoomID: activityInfo.value?.RoomID || 0,
|
||||||
|
MainID: activityId.value || 0,
|
||||||
|
TeamGroupID: groupId.value || 0,
|
||||||
|
QuestionID: store.currentQuestionInfo.ID, // 使用列表中的 ID
|
||||||
|
QuestionDetaiID: store.currentQuestionDetail.Id || 0, // 详情 ID
|
||||||
|
}
|
||||||
|
console.log(params, 'params')
|
||||||
|
await fetchSubmitQuestionResult(params)
|
||||||
|
await fetchGetGameStatisticsData(params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Failed to submit question result', error)
|
||||||
|
// 不阻塞流程,仅记录错误
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// 答题中,点击直接进入下一页(或者也可以设计为暂停等,这里按原逻辑是直接跳过)
|
// 备用逻辑:如果已经在答题中(理论上不会触发,因为上面已经跳转),直接跳转
|
||||||
store.stopTimer()
|
store.stopTimer()
|
||||||
routerPushByKey('user_analysis')
|
routerPushByKey('user_analysis')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取游戏现场统计结果 */
|
||||||
|
async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddParams) {
|
||||||
|
if (store.currentQuestionInfo && store.currentQuestionDetail) {
|
||||||
|
try {
|
||||||
|
const { data } = await fetchGetGameStatistics(_params)
|
||||||
|
console.log(data, 'data')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Failed to fetch game statistics', error)
|
||||||
|
window.$message?.error('获取游戏现场统计结果失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CompetitionLayout
|
<CompetitionLayout
|
||||||
action-position="top"
|
action-position="top" :show-back="false" :show-next="true"
|
||||||
:show-back="true"
|
:title="store.currentQuestionInfo?.ActitvityQuestionName || ''" back-btn-text="返回"
|
||||||
:show-next="true"
|
:next-btn-text="isStarted ? formattedTime : '开始答题'" :next-disabled="false" btn-theme="light" @back="handleBack"
|
||||||
:title="currentNode?.moduleName || ''"
|
|
||||||
back-btn-text="返回"
|
|
||||||
:next-btn-text="isStarted ? formattedTime : '开始答题'"
|
|
||||||
:next-disabled="isStarted"
|
|
||||||
btn-theme="light"
|
|
||||||
@back="handleBack"
|
|
||||||
@next="handleNext"
|
@next="handleNext"
|
||||||
>
|
>
|
||||||
<div class="relative h-full w-full flex flex-col items-center px-12 font-sans">
|
<div class="relative h-full w-full flex flex-col items-center overflow-hidden px-12 font-sans">
|
||||||
<!-- 题目说明 -->
|
<!-- 题目说明 -->
|
||||||
<!-- <div v-if="currentNode" class="m-2 text-2xl text-gray-800 font-medium">
|
<!-- <p>{{ store.currentQuestionInfo.ActitvityQuestionName }}</p> -->
|
||||||
{{ currentNode.description }}
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<!-- 题目内容区域 -->
|
<!-- 题目内容区域 -->
|
||||||
<div v-if="currentNode && currentQuestion" class="flex-2 mb-4 mt-12 w-full flex items-center justify-center">
|
<div v-if="template && content" class="w-full flex flex-1 items-center justify-center overflow-hidden py-4">
|
||||||
<QuestionRenderer
|
<div class="max-h-full max-w-full flex items-center justify-center">
|
||||||
:template="currentNode.uiTemplate" :content="currentQuestion.content"
|
<QuestionRenderer :template="template" :content="content" class="origin-center scale-150 transform" />
|
||||||
class="origin-center scale-150 transform"
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 隐藏的 Next 按钮 (方便演示:右上角小区域) -->
|
<!-- 隐藏的 Next 按钮 (方便演示:右上角小区域) -->
|
||||||
@ -90,7 +173,7 @@ function handleNext() {
|
|||||||
|
|
||||||
<!-- 底部图表区域 -->
|
<!-- 底部图表区域 -->
|
||||||
<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"
|
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"
|
||||||
>
|
>
|
||||||
<!-- 图表标题栏 -->
|
<!-- 图表标题栏 -->
|
||||||
<div class="mb-4 flex items-center justify-between px-2">
|
<div class="mb-4 flex items-center justify-between px-2">
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { fetchGetGroupList } from '@/service/api/competition'
|
||||||
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
const { routerPushByKey, routerBack } = useRouterPush()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
interface GroupItem {
|
interface GroupItem {
|
||||||
id: number
|
id: number
|
||||||
@ -11,23 +14,40 @@ interface GroupItem {
|
|||||||
icon: string
|
icon: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = ref<GroupItem[]>([
|
const groups = ref<GroupItem[]>([])
|
||||||
{ id: 1, name: '初赛一组', icon: 'activity' },
|
const loading = ref(false)
|
||||||
{ id: 2, name: '初赛七组', icon: 'cast' },
|
const activityId = route.query.activityId as string
|
||||||
{ id: 3, name: '初赛八组', icon: 'chrome' },
|
|
||||||
{ id: 4, name: '初赛九组', icon: 'heart' },
|
async function getGroups() {
|
||||||
{ id: 5, name: '初赛十组', icon: 'activity' },
|
if (!activityId)
|
||||||
{ id: 6, name: '初赛六组', icon: 'cast' },
|
return
|
||||||
{ id: 7, name: '初赛七组', icon: 'chrome' },
|
|
||||||
{ id: 8, name: '初赛八组', icon: 'heart' },
|
loading.value = true
|
||||||
{ id: 9, name: '初赛九组', icon: 'activity' },
|
try {
|
||||||
{ id: 10, name: '初赛十组', icon: 'cast' },
|
const { data, error } = await fetchGetGroupList(activityId)
|
||||||
])
|
if (error) {
|
||||||
|
window?.$message?.error(error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = data?.data || []
|
||||||
|
if (list && Array.isArray(list)) {
|
||||||
|
groups.value = list.map((item: any) => ({
|
||||||
|
id: item.Id,
|
||||||
|
name: item.Name,
|
||||||
|
icon: 'activity', // Default icon since API might not provide one
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showContent = ref(false)
|
const showContent = ref(false)
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
routerPushByKey('user_home')
|
routerBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
@ -36,12 +56,13 @@ function handleNext() {
|
|||||||
|
|
||||||
function selectGroup(_id: number) {
|
function selectGroup(_id: number) {
|
||||||
// 选择组别逻辑
|
// 选择组别逻辑
|
||||||
routerPushByKey('user_teams', { query: { id: _id.toString() } })
|
routerPushByKey('user_teams', { query: { activityId, groupsId: _id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showContent.value = true
|
showContent.value = true
|
||||||
|
getGroups()
|
||||||
}, 100)
|
}, 100)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,27 +1,63 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { NCarousel } from 'naive-ui'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { fetchGetActivityList } from '@/service/api/competition'
|
||||||
|
import { useActivityInfoStore } from '@/store/modules/activityinfo'
|
||||||
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
const { routerPushByKey } = useRouterPush()
|
||||||
|
const activityInfoStore = useActivityInfoStore()
|
||||||
|
|
||||||
interface EventItem {
|
interface ActivityEvent extends Api.Competition.ActivityDetail {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
subTitle?: string
|
subTitle?: string
|
||||||
icon: string
|
icon: string
|
||||||
status: 'active' | 'pending' | 'finished'
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = ref<EventItem[]>([
|
const events = ref<ActivityEvent[]>([])
|
||||||
{ id: 1, title: '第九届阅读之星', subTitle: '第一轮', icon: 'activity', status: 'active' },
|
const loading = ref(false)
|
||||||
{ 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) {
|
async function getEvents() {
|
||||||
routerPushByKey('user_groups', { query: { id: id.toString() } })
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data, error } = await fetchGetActivityList()
|
||||||
|
if (error) {
|
||||||
|
window.$message?.error(error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = data?.data || []
|
||||||
|
if (list && Array.isArray(list)) {
|
||||||
|
events.value = list.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
id: item.Id,
|
||||||
|
title: item.Name,
|
||||||
|
subTitle: item.Description,
|
||||||
|
icon: 'activity',
|
||||||
|
status: 'active',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group events into chunks of 4 for the carousel
|
||||||
|
const eventChunks = computed(() => {
|
||||||
|
const result = []
|
||||||
|
for (let i = 0; i < events.value.length; i += 4) {
|
||||||
|
result.push(events.value.slice(i, i + 4))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleEnter(item: ActivityEvent) {
|
||||||
|
activityInfoStore.setActivityInfo(item)
|
||||||
|
routerPushByKey('user_groups', { query: { activityId: item.Id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
@ -37,45 +73,58 @@ const showContent = ref(false)
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showContent.value = true
|
showContent.value = true
|
||||||
|
getEvents()
|
||||||
}, 100)
|
}, 100)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CompetitionLayout
|
<CompetitionLayout
|
||||||
:show-back="false"
|
:show-back="false" :show-next="false" title="赛事总览" action-position="top" back-btn-text="返回"
|
||||||
:show-next="false"
|
btn-theme="light" @back="handleBack" @next="handleNext"
|
||||||
title="赛事总览"
|
|
||||||
action-position="top"
|
|
||||||
back-btn-text="返回"
|
|
||||||
btn-theme="light"
|
|
||||||
@back="handleBack"
|
|
||||||
@next="handleNext"
|
|
||||||
>
|
>
|
||||||
<!-- Cards Section -->
|
<!-- Cards Section -->
|
||||||
<div class="cards-container">
|
<div class="cards-container">
|
||||||
<TransitionGroup name="list-anim" tag="div" class="cards-wrapper">
|
<div v-if="loading" class="h-full w-full flex-center">
|
||||||
<div
|
<div class="i-svg-spinners-90-ring-with-bg text-4xl text-primary" />
|
||||||
v-for="(item, index) in events" v-show="showContent" :key="item.id" class="event-card"
|
</div>
|
||||||
:style="{ '--delay': `${index * 0.1}s` }" @click="handleEnter(item.id)"
|
<NCarousel v-else-if="events.length > 0" show-arrow draggable dot-placement="bottom" class="group h-full">
|
||||||
>
|
<template #arrow="{ prev, next }">
|
||||||
<div class="card-top-bar" />
|
<div class="custom-arrow left" @click="prev">
|
||||||
<div class="card-body">
|
<icon-ic-round-arrow-back class="text-4xl text-icon text-white" />
|
||||||
<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>
|
||||||
<div class="tassel" />
|
<div class="custom-arrow right" @click="next">
|
||||||
|
<icon-ic-round-arrow-forward class="text-4xl text-icon text-white" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-for="(chunk, chunkIndex) in eventChunks" :key="chunkIndex" class="cards-wrapper">
|
||||||
|
<TransitionGroup name="list-anim">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in chunk" v-show="showContent" :key="item.id" class="event-card"
|
||||||
|
:style="{ '--delay': `${index * 0.1}s` }" @click="handleEnter(item)"
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</TransitionGroup>
|
</NCarousel>
|
||||||
|
<div v-else class="h-full w-full flex-center text-gray-500">
|
||||||
|
暂无赛事数据
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CompetitionLayout>
|
</CompetitionLayout>
|
||||||
</template>
|
</template>
|
||||||
@ -237,4 +286,40 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background-color: rgba($primary-color, 0.6);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0; // 默认隐藏
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $primary-color;
|
||||||
|
transform: translateY(-50%) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标悬停在轮播图区域时显示箭头
|
||||||
|
:deep(.n-carousel:hover) .custom-arrow,
|
||||||
|
.group:hover .custom-arrow {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ActivityNode } from '../types'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
title: string
|
|
||||||
nodes: ActivityNode[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits(['start'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-full w-full flex flex-col items-center pt-24">
|
|
||||||
<!-- 标题卷轴 -->
|
|
||||||
<div class="relative mb-20">
|
|
||||||
<!-- 卷轴主体 -->
|
|
||||||
<div class="relative z-10 h-24 min-w-[400px] 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-28 w-8 border-r-4 border-yellow-600 rounded-l-md bg-teal-700 shadow-md -left-4 -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-28 w-8 border-l-4 border-yellow-600 rounded-r-md bg-teal-700 shadow-md -right-4 -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>
|
|
||||||
|
|
||||||
<!-- 飘带装饰 -->
|
|
||||||
<div class="absolute h-12 w-24 rotate-12 transform rounded-full bg-green-200/50 blur-xl -right-12 -top-6 -z-10" />
|
|
||||||
<div class="absolute h-12 w-24 transform rounded-full bg-green-200/50 blur-xl -bottom-6 -left-12 -z-10 -rotate-12" />
|
|
||||||
|
|
||||||
<h1 class="text-4xl text-gray-800 font-bold tracking-widest font-serif">
|
|
||||||
{{ title }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 卡片列表 -->
|
|
||||||
<div class="grid grid-cols-4 max-w-7xl w-full gap-8 px-12">
|
|
||||||
<div
|
|
||||||
v-for="(node) in nodes.slice(0, 4)"
|
|
||||||
:key="node.nodeId"
|
|
||||||
class="group relative cursor-default"
|
|
||||||
>
|
|
||||||
<!-- 卡片背景 (仿古风) -->
|
|
||||||
<div class="relative aspect-[3/4] w-full flex flex-col items-center justify-center overflow-hidden border-4 border-orange-200 rounded-2xl bg-orange-50/80 shadow-lg transition-transform hover:-translate-y-2">
|
|
||||||
<!-- 内部装饰圈 -->
|
|
||||||
<div class="absolute inset-4 flex items-center justify-center border-2 border-orange-300/50 rounded-xl border-dashed">
|
|
||||||
<!-- 云纹装饰 (CSS模拟) -->
|
|
||||||
<div class="absolute h-12 w-12 rounded-full bg-teal-100/30 blur-md -left-4 -top-4" />
|
|
||||||
<div class="absolute h-12 w-12 rounded-full bg-teal-100/30 blur-md -bottom-4 -right-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 文字内容 -->
|
|
||||||
<div class="relative z-10 transform text-center -rotate-2">
|
|
||||||
<div class="text-3xl text-gray-800 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 class="fixed bottom-12 right-12">
|
|
||||||
<button
|
|
||||||
class="border-2 border-red-200 rounded-full from-red-400 to-red-500 bg-gradient-to-r px-10 py-3 text-xl text-white font-bold shadow-lg transition active:scale-95 hover:from-red-500 hover:to-red-600"
|
|
||||||
@click="$emit('start')"
|
|
||||||
>
|
|
||||||
准备开始
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 可以添加一些古风字体或特定样式 */
|
|
||||||
</style>
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ActivityNode, Question } from '../types'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { uiTemplatesMapping } from '../data/mock'
|
|
||||||
import QuestionRenderer from './QuestionRenderer.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
node: ActivityNode
|
|
||||||
question: Question
|
|
||||||
subStep: number
|
|
||||||
timeLeft: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits(['back', 'next'])
|
|
||||||
|
|
||||||
const formattedTime = computed(() => {
|
|
||||||
const m = Math.floor(props.timeLeft / 60)
|
|
||||||
const s = props.timeLeft % 60
|
|
||||||
return `${m}:${s < 10 ? `0${s}` : s}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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 },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative h-full w-full flex flex-col items-center px-12 pt-28">
|
|
||||||
<!-- 顶部栏 -->
|
|
||||||
<div class="relative z-10 mb-8 w-full flex items-center justify-between">
|
|
||||||
<!-- 返回按钮 -->
|
|
||||||
<button
|
|
||||||
class="border-2 border-red-400 rounded-full bg-white px-8 py-2 text-lg text-red-500 font-bold shadow-md transition hover:bg-red-50"
|
|
||||||
@click="$emit('back')"
|
|
||||||
>
|
|
||||||
返回
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 标题卷轴 -->
|
|
||||||
<div class="absolute left-1/2 top-0 -translate-x-1/2">
|
|
||||||
<div
|
|
||||||
class="relative 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 }}
|
|
||||||
<span class="text-sm text-red-500 font-bold">({{ uiTemplatesMapping[node.uiTemplate] }})</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 倒计时 -->
|
|
||||||
<div class="border-2 border-red-400 rounded-full bg-white px-6 py-2 shadow-md">
|
|
||||||
<span class="text-xl text-red-500 font-bold">倒计时 {{ formattedTime }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 题目说明 -->
|
|
||||||
<div class="m-14 text-2xl text-gray-800 font-medium">
|
|
||||||
{{ node.description }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 题目内容区域 -->
|
|
||||||
<div class="flex-2 mb-4 w-full flex items-center justify-center">
|
|
||||||
<QuestionRenderer
|
|
||||||
:template="node.uiTemplate" :content="question.content"
|
|
||||||
class="origin-center scale-150 transform"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 隐藏的 Next 按钮 (点击题目区域或键盘操作,这里为了演示保留一个透明层或仅通过逻辑触发,或者暂时放一个不易察觉的按钮用于测试) -->
|
|
||||||
<div class="absolute inset-0 z-0" @click="$emit('next')" /> <!-- 点击背景切换下一题,方便演示 -->
|
|
||||||
|
|
||||||
<!-- 底部图表区域 -->
|
|
||||||
<div
|
|
||||||
class="relative z-20 mb-8 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>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Question Renderer Override if needed */
|
|
||||||
:deep(.question-content) {
|
|
||||||
font-size: 4rem;
|
|
||||||
color: #ef4444;
|
|
||||||
/* red-500 */
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ActivityNode } from '../types'
|
|
||||||
|
|
||||||
defineProps<{ node: ActivityNode }>()
|
|
||||||
defineEmits(['back', 'draw'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-full w-full flex flex-col items-center pt-44">
|
|
||||||
<div class="mb-20 max-w-4xl text-center">
|
|
||||||
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
|
|
||||||
{{ node.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="$emit('draw')"
|
|
||||||
>
|
|
||||||
<span>抽题</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -9,7 +9,11 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const pinyinChars = computed(() => {
|
const pinyinChars = computed(() => {
|
||||||
if (props.template === 'TEMPLATE_WORD_DICTATION' && props.content.title) {
|
if (props.template === 'TEMPLATE_WORD_DICTATION' && props.content.title) {
|
||||||
return props.content.title.split(' ')
|
// 创建临时元素提取HTML文本内容
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = props.content.title
|
||||||
|
const text = div.textContent || ''
|
||||||
|
return text.trim().split(/\s+/).filter(Boolean)
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
@ -19,8 +23,12 @@ const pinyinChars = computed(() => {
|
|||||||
<div class="min-h-[400px] w-full flex items-center justify-center p-6">
|
<div class="min-h-[400px] w-full flex items-center justify-center p-6">
|
||||||
<!-- 模板A: 汉字听写-提示 (拼音+提示) -->
|
<!-- 模板A: 汉字听写-提示 (拼音+提示) -->
|
||||||
<div v-if="template === 'TEMPLATE_DICTATION_HINT'" class="text-center">
|
<div v-if="template === 'TEMPLATE_DICTATION_HINT'" class="text-center">
|
||||||
<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);">
|
<div
|
||||||
“{{ content.title }}”
|
class="mb-6 inline-block text-5xl font-bold leading-none"
|
||||||
|
style="text-shadow: 2px 2px 4px rgba(0,0,0,0.1);"
|
||||||
|
>
|
||||||
|
<div class="rich-content" v-html="content.title" />
|
||||||
|
<!-- “{{ content.title }}” -->
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="text-4xl text-gray-800 font-bold">
|
<!-- <div class="text-4xl text-gray-800 font-bold">
|
||||||
{{ content.meta?.hint }}
|
{{ content.meta?.hint }}
|
||||||
@ -29,15 +37,17 @@ const pinyinChars = computed(() => {
|
|||||||
|
|
||||||
<!-- 模板B: 汉字听写-同音字 (大字) -->
|
<!-- 模板B: 汉字听写-同音字 (大字) -->
|
||||||
<div v-else-if="template === 'TEMPLATE_DICTATION_HOMOPHONE'" class="text-center">
|
<div v-else-if="template === 'TEMPLATE_DICTATION_HOMOPHONE'" class="text-center">
|
||||||
<div class="text-[100px] text-red-600 font-bold">
|
<div class="homophone text-[100px] text-red-600 font-bold">
|
||||||
“{{ content.title }}”
|
<!-- “{{ content.title }}” -->
|
||||||
|
<div class="rich-content" v-html="content.title" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模板C: 汉字加一加 (提示+部件) -->
|
<!-- 模板C: 汉字加一加 (提示+部件) -->
|
||||||
<div v-else-if="template === 'TEMPLATE_COMPONENT_ADD'" class="text-center">
|
<div v-else-if="template === 'TEMPLATE_COMPONENT_ADD'" class="text-center">
|
||||||
<div class="text-[100px] text-red-600 font-bold">
|
<div class="text-[100px] text-red-600 font-bold">
|
||||||
{{ content.title }}
|
<!-- {{ content.title }} -->
|
||||||
|
<div class="rich-content" v-html="content.title" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -45,8 +55,7 @@ const pinyinChars = computed(() => {
|
|||||||
<div v-else-if="template === 'TEMPLATE_WORD_DICTATION'" class="flex flex-col items-center">
|
<div v-else-if="template === 'TEMPLATE_WORD_DICTATION'" class="flex flex-col items-center">
|
||||||
<div class="mb-8 flex flex-wrap justify-center gap-6">
|
<div class="mb-8 flex flex-wrap justify-center gap-6">
|
||||||
<div
|
<div
|
||||||
v-for="(char, index) in pinyinChars"
|
v-for="(char, index) in pinyinChars" :key="index"
|
||||||
:key="index"
|
|
||||||
class="relative h-32 w-32 flex select-none items-center justify-center border-2 border-red-500 bg-white"
|
class="relative h-32 w-32 flex select-none items-center justify-center border-2 border-red-500 bg-white"
|
||||||
>
|
>
|
||||||
<!-- 米字格背景 -->
|
<!-- 米字格背景 -->
|
||||||
@ -71,7 +80,8 @@ const pinyinChars = computed(() => {
|
|||||||
<!-- 模板E: 成语-文字要求 -->
|
<!-- 模板E: 成语-文字要求 -->
|
||||||
<div v-else-if="template === 'TEMPLATE_IDIOM_TEXT_REQ'" class="text-center">
|
<div v-else-if="template === 'TEMPLATE_IDIOM_TEXT_REQ'" class="text-center">
|
||||||
<div class="mb-8 text-4xl text-gray-800 font-bold">
|
<div class="mb-8 text-4xl text-gray-800 font-bold">
|
||||||
{{ content.title }}
|
<!-- {{ content.title }} -->
|
||||||
|
<div class="rich-content" v-html="content.title" />
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="rounded bg-gray-100 px-4 py-2 text-xl text-gray-500">
|
<!-- <div class="rounded bg-gray-100 px-4 py-2 text-xl text-gray-500">
|
||||||
示例:{{ content.meta?.example }}
|
示例:{{ content.meta?.example }}
|
||||||
@ -96,10 +106,7 @@ const pinyinChars = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="options-container">
|
<div class="options-container">
|
||||||
<div
|
<div
|
||||||
v-for="(option, index) in content.options"
|
v-for="(option, index) in content.options" :key="index" class="option-card group" :class="{
|
||||||
:key="index"
|
|
||||||
class="option-card group"
|
|
||||||
:class="{
|
|
||||||
'is-mixed': !option.renderType || option.renderType === 'mixed' || option.renderType === 'image',
|
'is-mixed': !option.renderType || option.renderType === 'mixed' || option.renderType === 'image',
|
||||||
'is-text': option.renderType === 'text',
|
'is-text': option.renderType === 'text',
|
||||||
}"
|
}"
|
||||||
@ -164,16 +171,21 @@ const pinyinChars = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
border-radius: 0.75rem; /* rounded-xl */
|
border-radius: 0.75rem;
|
||||||
padding: 1rem; /* p-4 */
|
/* rounded-xl */
|
||||||
|
padding: 1rem;
|
||||||
|
/* p-4 */
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #f87171; /* border-red-400 */
|
border-color: #f87171;
|
||||||
background-color: #fef2f2; /* bg-red-50 */
|
/* border-red-400 */
|
||||||
|
background-color: #fef2f2;
|
||||||
|
/* bg-red-50 */
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg */
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
/* shadow-lg */
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-mixed {
|
&.is-mixed {
|
||||||
@ -182,7 +194,8 @@ const pinyinChars = computed(() => {
|
|||||||
|
|
||||||
&.is-text {
|
&.is-text {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 1rem; /* gap-4 */
|
gap: 1rem;
|
||||||
|
/* gap-4 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,8 +207,10 @@ const pinyinChars = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 0.5rem; /* rounded-lg */
|
border-radius: 0.5rem;
|
||||||
padding: 0.5rem; /* p-2 */
|
/* rounded-lg */
|
||||||
|
padding: 0.5rem;
|
||||||
|
/* p-2 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-image {
|
.option-image {
|
||||||
@ -211,14 +226,18 @@ const pinyinChars = computed(() => {
|
|||||||
|
|
||||||
.tianzige-wrapper {
|
.tianzige-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 0.75rem; /* mb-3 */
|
margin-bottom: 0.75rem;
|
||||||
height: 8rem; /* h-32 */
|
/* mb-3 */
|
||||||
width: 8rem; /* w-32 */
|
height: 8rem;
|
||||||
|
/* h-32 */
|
||||||
|
width: 8rem;
|
||||||
|
/* w-32 */
|
||||||
display: flex;
|
display: flex;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 2px solid #ef4444; /* border-red-500 */
|
border: 2px solid #ef4444;
|
||||||
|
/* border-red-500 */
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -236,7 +255,8 @@ const pinyinChars = computed(() => {
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-top: 1px dashed #f87171; /* border-red-400 */
|
border-top: 1px dashed #f87171;
|
||||||
|
/* border-red-400 */
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,7 +266,8 @@ const pinyinChars = computed(() => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
border-left: 1px dashed #f87171; /* border-red-400 */
|
border-left: 1px dashed #f87171;
|
||||||
|
/* border-red-400 */
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,43 +279,73 @@ const pinyinChars = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tianzige-text {
|
.tianzige-text {
|
||||||
font-family: 'KaiTi', 'STKaiti', serif; /* font-kaaiti */
|
font-family: 'KaiTi', 'STKaiti', serif;
|
||||||
|
/* font-kaaiti */
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
font-size: 3.75rem; /* text-6xl */
|
font-size: 3.75rem;
|
||||||
|
/* text-6xl */
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1f2937; /* text-gray-800 */
|
color: #1f2937;
|
||||||
|
/* text-gray-800 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-content {
|
.option-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem; /* gap-3 */
|
gap: 0.75rem;
|
||||||
|
/* gap-3 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-label {
|
.option-label {
|
||||||
height: 2rem; /* h-8 */
|
height: 2rem;
|
||||||
width: 2rem; /* w-8 */
|
/* h-8 */
|
||||||
|
width: 2rem;
|
||||||
|
/* w-8 */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 9999px; /* rounded-full */
|
border-radius: 9999px;
|
||||||
background-color: #fee2e2; /* bg-red-100 */
|
/* rounded-full */
|
||||||
color: #dc2626; /* text-red-600 */
|
background-color: #fee2e2;
|
||||||
|
/* bg-red-100 */
|
||||||
|
color: #dc2626;
|
||||||
|
/* text-red-600 */
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
||||||
.group:hover & {
|
.group:hover & {
|
||||||
background-color: #ef4444; /* bg-red-500 */
|
background-color: #ef4444;
|
||||||
|
/* bg-red-500 */
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-text {
|
.option-text {
|
||||||
font-size: 1.25rem; /* text-xl */
|
font-size: 1.25rem;
|
||||||
color: #374151; /* text-gray-700 */
|
/* text-xl */
|
||||||
|
color: #374151;
|
||||||
|
/* text-gray-700 */
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
.group:hover & {
|
.group:hover & {
|
||||||
color: #b91c1c; /* text-red-700 */
|
color: #b91c1c;
|
||||||
|
/* text-red-700 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.homophone {
|
||||||
|
:deep(img) {
|
||||||
|
height: 230px !important;
|
||||||
|
width: auto !important;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content {
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,28 +1,39 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { useActivityInfoStore } from '@/store/modules/activityinfo'
|
||||||
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
const activityInfoStore = useActivityInfoStore()
|
||||||
|
const activityInfo = computed(() => activityInfoStore.activityInfo)
|
||||||
|
|
||||||
|
// 获取路由参数
|
||||||
|
const route = useRoute()
|
||||||
|
const activityId = computed(() => activityInfoStore.activityId || route.query?.activityId as string)
|
||||||
|
const groupsId = computed(() => activityInfoStore.groupId || route.query?.groupsId as string)
|
||||||
|
const teamId = computed(() => activityInfoStore.teamId || route.query?.teamId as string)
|
||||||
|
|
||||||
|
const { routerPushByKey, routerBack } = useRouterPush()
|
||||||
|
|
||||||
const activityName = ref('规则介绍')
|
const activityName = ref('规则介绍')
|
||||||
const rulesContent = 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>
|
const mockContent = computed(() => activityInfo.value?.ActivityContent || '')
|
||||||
`
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
rulesContent.value = mockContent
|
rulesContent.value = mockContent.value
|
||||||
}, 100)
|
}, 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
routerPushByKey('user_cover')
|
// routerPushByKey('user_cover')
|
||||||
|
routerBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
routerPushByKey('user_draw')
|
routerPushByKey('user_draw', { query: { activityId: activityId.value, groupsId: groupsId.value, teamId: teamId.value } })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
import { useRouterPush } from '@/hooks/common/router'
|
||||||
|
import { fetchGetTeamListByGroupId } from '@/service/api/competition'
|
||||||
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
const { routerPushByKey, routerBack } = useRouterPush()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
interface TeamItem {
|
interface TeamItem {
|
||||||
id: number
|
id: number
|
||||||
@ -11,17 +14,40 @@ interface TeamItem {
|
|||||||
icon: string
|
icon: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const teams = ref<TeamItem[]>([
|
const teams = ref<TeamItem[]>([])
|
||||||
{ id: 1, name: '1号代表队', icon: 'star' },
|
const loading = ref(false)
|
||||||
{ id: 2, name: '2号代表队', icon: 'star' },
|
const activityId = route.query.activityId
|
||||||
{ id: 3, name: '3号代表队', icon: 'star' },
|
const groupsId = route.query.groupsId
|
||||||
{ id: 4, name: '4号代表队', icon: 'star' },
|
|
||||||
])
|
async function getTeams() {
|
||||||
|
if (!groupsId)
|
||||||
|
return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data, error } = await fetchGetTeamListByGroupId(Number(groupsId))
|
||||||
|
if (error) {
|
||||||
|
window?.$message?.error(error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = data?.data || []
|
||||||
|
if (list && Array.isArray(list)) {
|
||||||
|
teams.value = list.map((item: any) => ({
|
||||||
|
id: item.Id,
|
||||||
|
name: item.Name,
|
||||||
|
icon: 'star', // Default icon
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showContent = ref(false)
|
const showContent = ref(false)
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
routerPushByKey('user_groups')
|
routerBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
@ -31,12 +57,13 @@ function handleNext() {
|
|||||||
|
|
||||||
function selectTeam(_id: number) {
|
function selectTeam(_id: number) {
|
||||||
// 选择队伍逻辑
|
// 选择队伍逻辑
|
||||||
routerPushByKey('user_cover', { query: { id: _id.toString() } })
|
routerPushByKey('user_cover', { query: { activityId, groupsId, teamId: _id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showContent.value = true
|
showContent.value = true
|
||||||
|
getTeams()
|
||||||
}, 100)
|
}, 100)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@ -61,8 +88,7 @@ onMounted(() => {
|
|||||||
<TransitionGroup name="list-anim" tag="div" class="teams-grid">
|
<TransitionGroup name="list-anim" tag="div" class="teams-grid">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in teams" v-show="showContent" :key="item.id" class="team-item"
|
v-for="(item, index) in teams" v-show="showContent" :key="item.id" class="team-item"
|
||||||
:style="{ '--delay': `${index * 0.1}s` }"
|
:style="{ '--delay': `${index * 0.1}s` }" @click="selectTeam(item.id)"
|
||||||
@click="selectTeam(item.id)"
|
|
||||||
>
|
>
|
||||||
<div class="icon-wrapper">
|
<div class="icon-wrapper">
|
||||||
<img src="@/assets/imgs/user/star-icon.svg" alt="team-icon" class="h-full w-full object-cover">
|
<img src="@/assets/imgs/user/star-icon.svg" alt="team-icon" class="h-full w-full object-cover">
|
||||||
|
|||||||
Reference in New Issue
Block a user