feat(admin): 新增用户排行榜功能并优化活动流程

- 新增用户排行榜页面和待发布页面,支持查看和发布排行榜
- 新增排行榜相关API接口,包括获取活动排行列表、修改队伍分数、发布排名和获取用户排名列表
- 新增RankPublishStatus枚举,用于管理排行榜发布状态
- 在活动卡片中新增结束活动按钮,点击后调用结束活动接口并生成排行榜数据
- 优化用户活动首页,只显示已发布和处理中的活动
- 调整游戏页面定时刷新间隔从2秒改为3秒,优化性能
- 修复排行榜详情页分数输入最大值限制问题
- 清理未使用的代码文件,移除results-detail-copy相关组件和路由
- 更新路由配置和类型定义,支持新的排行榜页面
This commit is contained in:
2026-03-09 08:37:32 +08:00
parent e8cd6732d7
commit 8469e16638
28 changed files with 627 additions and 502 deletions

View File

@ -136,9 +136,9 @@ async function fetchGetGameStatisticsData() {
}
// 定时刷新 检查是否可以下一步
timer.value = setInterval(checkNextStep, 2000)
timer.value = setInterval(checkNextStep, 3000)
// 定时刷新 游戏统计数据
statisticsTimer.value = setInterval(fetchGetGameStatisticsData, 2000)
statisticsTimer.value = setInterval(fetchGetGameStatisticsData, 3000)
/** 销毁 定时刷新 检查是否可以下一步 */
onBeforeUnmount(() => {

View File

@ -2,7 +2,9 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { PublishStatus } from '@/enum/business'
import { useRouterPush } from '@/hooks/common/router'
import { fetchUpdatePublishStatus } from '@/service/api/competition'
import { fetchGetQuestionTableByActivityID } from '@/service/api/game'
import { useActivityInfoStore } from '@/store/modules/activityinfo'
import { AudioController } from '@/utils/audio'
@ -93,7 +95,20 @@ function handleBack() {
routerBack()
}
function handleNext() {
async function handleNext() {
// 封面页的下一步操作,更改活动状态
// 判断状态是否为已发布或处理中,若已发布或处理中则不能更改状态
if (activityInfo.value?.PublishStatus === PublishStatus.Published || activityInfo.value?.PublishStatus === PublishStatus.Processing) {
if (activityId.value) {
try {
await fetchUpdatePublishStatus(Number(activityId.value), PublishStatus.Processing)
}
catch (error) {
console.error('更新活动状态失败', error)
}
}
}
routerPushByKey('user_rules', {
query: {
activityId: activityId.value,
@ -143,24 +158,20 @@ onMounted(async () => {
<template>
<CompetitionLayout
:show-back="true" :show-next="showNextButton" :title="activityName" :title-bg-type="2"
@back="handleBack"
@next="handleNext"
@back="handleBack" @next="handleNext"
>
<div class="h-screen w-full overflow-y-auto font-sans">
<div class="relative z-10 min-h-full w-full">
<div class="min-h-full w-full flex flex-col items-center pb-12 pt-24">
<!-- 卡片列表 -->
<TransitionGroup
tag="div"
name="card-anim"
tag="div" name="card-anim"
class="perspective-container max-w-[1800px] w-full flex flex-wrap justify-center gap-4 px-4 md:gap-10 md:px-12"
>
<div
v-for="(node, index) in showCards ? questions : []"
:key="node.id"
v-for="(node, index) in showCards ? questions : []" :key="node.id"
class="w-[44%] card-wrapper 2xl:w-[14%] lg:w-[21%] md:w-[28%] xl:w-[17%]"
:style="{ animationDelay: `${index * 0.2}s` }"
@click="toggleFlip(index)"
:style="{ animationDelay: `${index * 0.2}s` }" @click="toggleFlip(index)"
>
<div class="flip-card-inner" :class="{ 'is-flipped': flippedCards.has(index) }">
<!-- 正面 (Front) - 显示背面图案 -->
@ -216,16 +227,19 @@ onMounted(async () => {
transform: translate3d(0, 300px, -800px) rotateY(180deg) rotateX(45deg) scale(0.5);
filter: blur(20px); // 深度模糊
}
50% {
opacity: 1;
filter: blur(0); // 聚焦
}
70% {
// [过冲状态]
// 飞到眼前一点点 (Z=50px)
// 角度稍微回弹 (rotateY -15deg)
transform: translate3d(0, -20px, 50px) rotateY(-15deg) rotateX(-10deg) scale(1.05);
}
100% {
// [最终归位]
transform: translate3d(0, 0, 0) rotateY(0) rotateX(0) scale(1);

View File

@ -197,7 +197,7 @@ async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddPa
if (store.currentQuestionInfo && store.currentQuestionDetail) {
try {
const { data } = await fetchGetGameStatistics(_params)
console.log(data, 'data')
// console.log(data, 'data')
// 更新图表数据
// 假设接口返回格式为: [{ TeamName: '组1', Points: 10, ResultPotins: 5 }, ...]
// 需要根据实际接口返回结构调整映射逻辑

View File

@ -29,13 +29,10 @@ async function getGroups() {
if (list && Array.isArray(list)) {
groups.value = list.map((item: any) => ({
...item,
// id: item.Id,
// name: item.Name,
icon: 'activity', // Default icon since API might not provide one
icon: 'activity',
}))
}
isAllEnd.value = list.every((item: Api.Competition.ActivityTeamGroup) => item.IsEnd)
// console.log(groups.value, 'groups.value')
}
finally {
loading.value = false
@ -49,7 +46,7 @@ function handleBack() {
}
function handleNext() {
routerPushByKey('user')
routerPushByKey('user_rank-list', { query: { activityId } })
}
function selectGroup(item: Api.Competition.ActivityTeamGroup) {

View File

@ -3,6 +3,7 @@ import { NCarousel, NImage } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import beacon from '@/assets/imgs/user/event-card-bg-icon.svg'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { PublishStatus } from '@/enum/business'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetActivityList } from '@/service/api/competition'
import { useActivityInfoStore } from '@/store/modules/activityinfo'
@ -32,14 +33,16 @@ async function getEvents() {
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',
}))
events.value = list
.filter((item: any) => item.PublishStatus === PublishStatus.Published || item.PublishStatus === PublishStatus.Processing)
.map((item: any) => ({
...item,
id: item.Id,
title: item.Name,
subTitle: item.Description,
icon: 'activity',
status: 'active',
}))
}
}
finally {
@ -185,7 +188,7 @@ onMounted(() => {
.text-content {
.event-title {
font-size: 1.4rem;
font-size: 1.1rem;
font-weight: bold;
color: $text-color;
margin-bottom: 8px;

View File

@ -81,7 +81,7 @@ watch(
<div class="w-full flex p-6">
<!-- <p>template:{{ template }}</p> -->
<!-- 模板A: 汉字听写1-提示 (拼音+提示) -->
<div v-if="template === QuestionCategoryEnum.ChineseCharacterDictation1" class="text-center">
<div v-if="template === QuestionCategoryEnum.ChineseCharacterDictation1 || template === QuestionCategoryEnum.ChineseCharacterDictation2" class="text-center">
<div class="mb-6 inline-block text-5xl font-bold leading-none">
<div class="ptions-container1 mb-2 text-center text-2xl">
{{ title }}
@ -127,7 +127,7 @@ watch(
</div>
<!-- 模板E: 成语-文字要求 -->
<div v-else-if="template === QuestionCategoryEnum.IdiomWriting1" class="text-center">
<div v-else-if="template === QuestionCategoryEnum.IdiomWriting1 || template === QuestionCategoryEnum.IdiomWriting2" class="text-center">
<div class="mb-8 text-4xl text-gray-800 font-bold">
<!-- {{ content }} -->
<div class="rich-content" v-html="content" />

View File

@ -0,0 +1,268 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { NDataTable, NImage, NTag, useMessage } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import title from '@/assets/imgs/user/rank-title.svg'
import { fetchUserRankList } from '@/service/api/rank'
const route = useRoute()
const message = useMessage()
const MainID = Number(route.query.activityId)
// 定义数据结构
interface TeamData {
id: string
rank: number
teamName: string
[key: string]: number | string // 允许任意数量的题目分数
total: number
}
const columns = ref<DataTableColumns<TeamData>>([
{
title: '排名',
key: 'rank',
align: 'center',
width: 80,
render(row: TeamData) {
const rank = row.rank
if (rank === 1) {
return <NTag type="warning" size="large" round>🏆</NTag>
}
if (rank === 2) {
return <NTag type="info" size="large" round>🥈</NTag>
}
if (rank === 3) {
return <NTag type="success" size="large" round>🥉</NTag>
}
return <span>{rank}</span>
},
},
{ title: '队伍名称', key: 'teamName', align: 'left', width: 150, className: 'team-name' },
{ title: '总积分', key: 'total', align: 'center', className: 'total-score' },
])
const tableData = ref<TeamData[]>([])
async function getUserRankList() {
const { data, error } = await fetchUserRankList(MainID)
if (error) {
message.error(error.message || '获取排行榜失败')
return
}
if (data?.data && data.data.length > 0) {
// 动态生成题目列
const firstTeam = data.data[0]
const questionColumns = firstTeam.Questions.map(q => ({
title: `${q.QuestionNO}`,
key: `q${q.QuestionNO}`,
align: 'center' as const,
}))
// 更新表头
columns.value = [
{
title: '排名',
key: 'rank',
align: 'center',
width: 80,
render(row: TeamData) {
const rank = row.rank
if (rank === 1) {
return <NTag type="warning" size="large" round>🏆</NTag>
}
if (rank === 2) {
return <NTag type="info" size="large" round>🥈</NTag>
}
if (rank === 3) {
return <NTag type="success" size="large" round>🥉</NTag>
}
return <span>{rank}</span>
},
},
{ title: '队伍名称', key: 'teamName', align: 'left', width: 150, className: 'team-name' },
...questionColumns,
{ title: '总积分', key: 'total', align: 'center', className: 'total-score' },
]
tableData.value = data.data.map((item, index) => {
const questions: Record<string, number> = {}
item.Questions.forEach((q) => {
questions[`q${q.QuestionNO}`] = q.ResultPotin
})
return {
id: String(item.TeamID),
rank: index + 1,
teamName: item.Name,
total: item.TotalPoint,
...questions,
} as TeamData
})
}
}
onMounted(() => {
if (MainID) {
getUserRankList()
}
})
// 动态赋予行样式:根据排名和奇偶数设置 class
function rowClassName(row: TeamData) {
const rank = row.rank
const classes = ['rank-row']
if (rank >= 1 && rank <= 3) {
classes.push(`rank-top-${rank}`)
}
return classes.join(' ')
}
</script>
<template>
<CompetitionLayout :show-back="false" :show-next="false" :show-title="false">
<div class="leaderboard-container">
<div class="leaderboard-header">
<NImage :src="title" alt="logo" preview-disabled class="mt--60px" object-fit="contain" />
<p class="mt--20px text-center text-sm text-#6b778d">
"勤学如春起之苗,不见其增,日有所长"
</p>
</div>
<div class="leaderboard-content">
<NDataTable
:columns="columns" :data="tableData" :bordered="false" :bottom-bordered="false"
:row-class-name="rowClassName" class="rank-table"
/>
</div>
</div>
</CompetitionLayout>
</template>
<style scoped>
/* 1. 整体容器和背景 */
.leaderboard-container {
width: 100%;
min-height: 100vh;
background-image: url('https://raw.githubusercontent.com/0x3f3f3f3f/figure-bed/main/img/20240524150352.png');
background-size: cover;
background-position: center;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
font-family: 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
/* width: 100%; */
/* height: 781px;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: url('@/assets/imgs/user/team-bg.svg') no-repeat center center;
background-size: 100% 100%;
transform-origin: center;
padding: 40px 20px;
font-family: 'PingFang SC', 'Helvetica Neue', Arial, sans-serif; */
}
/* 2. 页面标题 */
.leaderboard-header {
text-align: center;
}
.leaderboard-header h1 {
font-size: 2.5rem;
font-weight: 700;
color: #1a2a44;
margin: 0;
}
/* .leaderboard-header p {
font-size: 1rem;
color: #6b778d;
margin-top: 8px;
} */
/* 3. 表格内容区 */
.leaderboard-content {
width: 100%;
/* max-width: 1200px; */
background-color: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(26, 42, 68, 0.05);
overflow: hidden;
}
/* 4. Naive UI 表格深度定制 */
:deep(.rank-table .n-data-table-thead) {
background-color: #f8f9fa;
}
:deep(.rank-table .n-data-table-th) {
font-size: 14px;
font-weight: 600;
color: #495057;
border-bottom: 2px solid #dee2e6 !important;
}
:deep(.rank-table .n-data-table-tr) {
transition: background-color 0.2s;
}
:deep(.rank-table .n-data-table-tr:hover) {
background-color: #f1f3f5 !important;
}
:deep(.rank-table .n-data-table-td) {
font-size: 15px;
color: #333;
border-bottom: 1px solid #e9ecef !important;
padding-top: 16px !important;
padding-bottom: 16px !important;
}
/* 奇偶行交替色 */
:deep(.rank-table .n-data-table-tr:nth-of-type(odd)) {
background-color: #fdfdfd;
}
/* 队伍名称和总分加粗 */
:deep(.team-name) {
font-weight: 600;
color: #2c3e50;
}
:deep(.total-score) {
font-weight: 700;
font-size: 1.1rem;
color: #e67e22;
}
/* 前三名高亮 */
:deep(.rank-top-1 .n-data-table-td) {
background-color: #fff9e6 !important;
}
:deep(.rank-top-2 .n-data-table-td) {
background-color: #f0f4ff !important;
}
:deep(.rank-top-3 .n-data-table-td) {
background-color: #e6f4ea !important;
}
:deep(.rank-top-1 .total-score) {
color: #d4af37;
}
:deep(.rank-top-2 .total-score) {
color: #a0a0a0;
}
:deep(.rank-top-3 .total-score) {
color: #cd7f32;
}
</style>