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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 429 KiB

View File

@ -5,6 +5,11 @@ export enum PublishStatus {
Finished = 3, Finished = 3,
} }
export enum RankPublishStatus {
Unpublished = 0,
Published = 1,
}
export enum QuestionType { export enum QuestionType {
SingleChoice = 'SingleChoice', // 单选题 SingleChoice = 'SingleChoice', // 单选题
MultipleChoice = 'MultipleChoice', // 多选题 MultipleChoice = 'MultipleChoice', // 多选题

View File

@ -258,6 +258,7 @@ const local: App.I18n.Schema = {
'results_results-list': 'Results List', 'results_results-list': 'Results List',
'results_results-detail': 'Results Detail', 'results_results-detail': 'Results Detail',
'test': 'Test', 'test': 'Test',
'user_rank-list': 'User Rank List',
}, },
page: { page: {
login: { login: {

View File

@ -254,6 +254,7 @@ const local: App.I18n.Schema = {
'results_results-list': '实时结果列表', 'results_results-list': '实时结果列表',
'results_results-detail': '实时结果详情', 'results_results-detail': '实时结果详情',
'test': 'Test', 'test': 'Test',
'user_rank-list': '用户排名列表',
}, },
page: { page: {
login: { login: {

View File

@ -28,7 +28,6 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"question-store": () => import("@/views/question-store/index.vue"), "question-store": () => import("@/views/question-store/index.vue"),
"rank_rank-detail": () => import("@/views/rank/rank-detail/index.vue"), "rank_rank-detail": () => import("@/views/rank/rank-detail/index.vue"),
"rank_rank-list": () => import("@/views/rank/rank-list/index.vue"), "rank_rank-list": () => import("@/views/rank/rank-list/index.vue"),
"results_results-detail-copy": () => import("@/views/results/results-detail-copy/index.vue"),
"results_results-detail": () => import("@/views/results/results-detail/index.vue"), "results_results-detail": () => import("@/views/results/results-detail/index.vue"),
"results_results-list": () => import("@/views/results/results-list/index.vue"), "results_results-list": () => import("@/views/results/results-list/index.vue"),
"template_template-detail": () => import("@/views/template/template-detail/index.vue"), "template_template-detail": () => import("@/views/template/template-detail/index.vue"),
@ -40,6 +39,8 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
user_game: () => import("@/views/user/game/index.vue"), user_game: () => import("@/views/user/game/index.vue"),
user_groups: () => import("@/views/user/groups/index.vue"), user_groups: () => import("@/views/user/groups/index.vue"),
user_home: () => import("@/views/user/home/index.vue"), user_home: () => import("@/views/user/home/index.vue"),
"user_rank-list": () => import("@/views/user/rank-list/index.vue"),
"user_rank-pending": () => import("@/views/user/rank-pending/index.vue"),
user_rules: () => import("@/views/user/rules/index.vue"), user_rules: () => import("@/views/user/rules/index.vue"),
user_teams: () => import("@/views/user/teams/index.vue"), user_teams: () => import("@/views/user/teams/index.vue"),
}; };

View File

@ -197,16 +197,6 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true hideInMenu: true
} }
}, },
{
name: 'results_results-detail-copy',
path: '/results/results-detail-copy',
component: 'view.results_results-detail-copy',
meta: {
title: 'results_results-detail-copy',
i18nKey: 'route.results_results-detail-copy',
hideInMenu: true
}
},
{ {
name: 'results_results-list', name: 'results_results-list',
path: '/results/results-list', path: '/results/results-list',
@ -341,6 +331,24 @@ export const generatedRoutes: GeneratedRoute[] = [
constant: true constant: true
} }
}, },
{
name: 'user_rank-list',
path: '/user/rank-list',
component: 'view.user_rank-list',
meta: {
title: 'user_rank-list',
i18nKey: 'route.user_rank-list'
}
},
{
name: 'user_rank-pending',
path: '/user/rank-pending',
component: 'view.user_rank-pending',
meta: {
title: 'user_rank-pending',
i18nKey: 'route.user_rank-pending'
}
},
{ {
name: 'user_rules', name: 'user_rules',
path: '/user/rules', path: '/user/rules',

View File

@ -180,7 +180,6 @@ const routeMap: RouteMap = {
"rank_rank-list": "/rank/rank-list", "rank_rank-list": "/rank/rank-list",
"results": "/results", "results": "/results",
"results_results-detail": "/results/results-detail", "results_results-detail": "/results/results-detail",
"results_results-detail-copy": "/results/results-detail-copy",
"results_results-list": "/results/results-list", "results_results-list": "/results/results-list",
"template": "/template", "template": "/template",
"template_template-detail": "/template/template-detail", "template_template-detail": "/template/template-detail",
@ -193,6 +192,8 @@ const routeMap: RouteMap = {
"user_game": "/user/game", "user_game": "/user/game",
"user_groups": "/user/groups", "user_groups": "/user/groups",
"user_home": "/user/home", "user_home": "/user/home",
"user_rank-list": "/user/rank-list",
"user_rank-pending": "/user/rank-pending",
"user_rules": "/user/rules", "user_rules": "/user/rules",
"user_teams": "/user/teams" "user_teams": "/user/teams"
}; };

View File

@ -23,3 +23,40 @@ export function fetchTeamQuestionScore(TeamID: number) {
method: 'get', method: 'get',
}) })
} }
/** 获取活动排行列表 */
export function fetchActivityRankList(PageIndex: number, PageSize: number = 10) {
return request<App.Service.Response<Api.Rank.ActivityRankList>>({
url: `/Base/ActivityMain/GetActivity_Main_TotalPager?PageIndex=${PageIndex}&PageSizes=${PageSize}`,
method: 'get',
})
}
/** 修改排名的队伍分数 */
export function updateTeamScore(data: Api.Rank.UpdateTeamScoreParams[], activityId: number) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateTeamQuestionResultRecore/?ActivityID=${activityId}`,
method: 'post',
data,
})
}
/** 发布排名,更新状态 */
export function publishRank(Id: number, Status = 1) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateResultRecoreStatus`,
method: 'post',
data: {
Id,
Status,
},
})
}
/** 获取用户排名列表 */
export function fetchUserRankList(MainID: number, RoundType: number = 0) {
return request<App.Service.Response<Api.Rank.ActivityRankResList>>({
url: `/Base/ActivityMain/GetTeamsTotalList?MainID=${MainID}&RoundType=${RoundType}`,
method: 'get',
})
}

View File

@ -17,3 +17,12 @@ export function updateScore(data: { Id: number, ResultPotins: number, QuestionDe
data, data,
}) })
} }
/** 结束活动时候调用 */
export function endActivity(ActivityID: number) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/AddOrUpdateActivity_Main_Total?ActivityID=${ActivityID}`,
method: 'post',
data: { ActivityID },
})
}

View File

@ -31,6 +31,57 @@ declare namespace Api {
/** common search params of table */ /** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'> type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'currentPage' | 'pageSize'>
interface ActivityRankResList extends Array<ActivityRankItem> {}
interface ActivityRankItem {
TeamID: number
Name: string
TotalPoint: number
Questions: QuestionScore[]
}
interface QuestionScore {
QuestionNO: number
ResultPotin: number
}
interface ActivityRankList {
Items: ActivityRank[]
TotalRecords: number
PageNumber: number
PageSize: number
TotalPages: number
}
interface UpdateTeamScoreParams {
/** 主活动 ID */
ActivityID: number
Id: number
/** 结果得分 */
ResultPotins: number
}
/** activity rank */
interface ActivityRank {
/** activity id */
Id: number
/** activity id (MainID) */
ActivityId: number
/** activity name */
ActivityName: string
/** start time */
StartTime: string
/** team number */
TeamNum: number
/** max point */
MaxPoint: number
/** last update time */
LastUpdatetime: string
/** is publish */
Status: number
}
/** /**
* enable status * enable status
* *

View File

@ -28,6 +28,7 @@ declare module 'vue' {
'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default'] 'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default']
'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default'] 'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default']
'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default'] 'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
'IconIc:roundStopCircle': typeof import('~icons/ic/round-stop-circle')['default']
'IconIc:twotoneRule': typeof import('~icons/ic/twotone-rule')['default'] 'IconIc:twotoneRule': typeof import('~icons/ic/twotone-rule')['default']
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default'] IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default'] IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
@ -201,6 +202,7 @@ declare global {
const 'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default'] const 'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default']
const 'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default'] const 'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default']
const 'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default'] const 'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
const 'IconIc:roundStopCircle': typeof import('~icons/ic/round-stop-circle')['default']
const 'IconIc:twotoneRule': typeof import('~icons/ic/twotone-rule')['default'] const 'IconIc:twotoneRule': typeof import('~icons/ic/twotone-rule')['default']
const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default'] const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
const IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default'] const IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']

View File

@ -34,7 +34,6 @@ declare module "@elegant-router/types" {
"rank_rank-list": "/rank/rank-list"; "rank_rank-list": "/rank/rank-list";
"results": "/results"; "results": "/results";
"results_results-detail": "/results/results-detail"; "results_results-detail": "/results/results-detail";
"results_results-detail-copy": "/results/results-detail-copy";
"results_results-list": "/results/results-list"; "results_results-list": "/results/results-list";
"template": "/template"; "template": "/template";
"template_template-detail": "/template/template-detail"; "template_template-detail": "/template/template-detail";
@ -47,6 +46,8 @@ declare module "@elegant-router/types" {
"user_game": "/user/game"; "user_game": "/user/game";
"user_groups": "/user/groups"; "user_groups": "/user/groups";
"user_home": "/user/home"; "user_home": "/user/home";
"user_rank-list": "/user/rank-list";
"user_rank-pending": "/user/rank-pending";
"user_rules": "/user/rules"; "user_rules": "/user/rules";
"user_teams": "/user/teams"; "user_teams": "/user/teams";
}; };
@ -123,7 +124,6 @@ declare module "@elegant-router/types" {
| "question-store" | "question-store"
| "rank_rank-detail" | "rank_rank-detail"
| "rank_rank-list" | "rank_rank-list"
| "results_results-detail-copy"
| "results_results-detail" | "results_results-detail"
| "results_results-list" | "results_results-list"
| "template_template-detail" | "template_template-detail"
@ -135,6 +135,8 @@ declare module "@elegant-router/types" {
| "user_game" | "user_game"
| "user_groups" | "user_groups"
| "user_home" | "user_home"
| "user_rank-list"
| "user_rank-pending"
| "user_rules" | "user_rules"
| "user_teams" | "user_teams"
>; >;

View File

@ -3,6 +3,7 @@ import { NButton, NGi, NGrid, NSpace } from 'naive-ui'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouterPush } from '@/hooks/common/router' import { useRouterPush } from '@/hooks/common/router'
import { fetchCopyActivity, fetchDeleteActivity, fetchGetActivityList } from '@/service/api/competition' import { fetchCopyActivity, fetchDeleteActivity, fetchGetActivityList } from '@/service/api/competition'
import { endActivity } from '@/service/api/result'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import CompetitionAddModal from '../competition-add/index.vue' import CompetitionAddModal from '../competition-add/index.vue'
@ -94,6 +95,39 @@ function toDetail(item: Api.Competition.CompetitionItem) {
routerPushByKey('competition_competition-detail', { query: { Id: item.Id.toString() } }) routerPushByKey('competition_competition-detail', { query: { Id: item.Id.toString() } })
} }
async function handleCloseActivity(item: Api.Competition.CompetitionItem) {
const d = window.$dialog?.warning({
title: '确认结束',
content: `确定结束活动:${item.Name}吗?结束活动后将生成排行榜数据。`,
positiveText: '确认结束',
negativeText: '取消',
onPositiveClick: async () => {
if (d) {
d.loading = true
}
try {
const { error } = await endActivity(item.Id)
if (error) {
window.$message?.error(error.message || '结束活动失败')
return false
}
window.$message?.success(`已结束活动:${item.Name}`)
fetchActivityList()
return true
}
catch (e: any) {
window.$message?.error(e.message || '操作失败')
return false
}
finally {
if (d) {
d.loading = false
}
}
},
})
}
onMounted(() => fetchActivityList()) onMounted(() => fetchActivityList())
</script> </script>
@ -122,7 +156,7 @@ onMounted(() => fetchActivityList())
<!-- 响应式布局手机1列平板2列中屏3列大屏4列 --> <!-- 响应式布局手机1列平板2列中屏3列大屏4列 -->
<NGrid :x-gap="gap" :y-gap="gap" responsive="screen" item-responsive> <NGrid :x-gap="gap" :y-gap="gap" responsive="screen" item-responsive>
<NGi v-for="item in competitionListRef" :key="item.Id" span="24 s:12 m:8 l:6" @click="toDetail(item)"> <NGi v-for="item in competitionListRef" :key="item.Id" span="24 s:12 m:8 l:6" @click="toDetail(item)">
<CompetitionCard :item="item" @copy="handleCopy" @delete="handleDelete" /> <CompetitionCard :item="item" @copy="handleCopy" @delete="handleDelete" @close="handleCloseActivity" />
</NGi> </NGi>
</NGrid> </NGrid>

View File

@ -10,6 +10,7 @@ const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'copy', item: Api.Competition.CompetitionItem): void (e: 'copy', item: Api.Competition.CompetitionItem): void
(e: 'delete', item: Api.Competition.CompetitionItem): void (e: 'delete', item: Api.Competition.CompetitionItem): void
(e: 'close', item: Api.Competition.CompetitionItem): void
}>() }>()
const { options: publishStatusOptions } = useDict('publish_status') const { options: publishStatusOptions } = useDict('publish_status')
@ -83,6 +84,20 @@ const decorationBgClass = computed(() => {
<div <div
class="absolute right-4 top-4 z-20 flex gap-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100" class="absolute right-4 top-4 z-20 flex gap-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
> >
<NTooltip v-if="[PublishStatus.Published, PublishStatus.Processing].includes(item.PublishStatus)">
<template #trigger>
<NButton
circle secondary size="small" type="warning"
class="shadow-sm backdrop-blur-sm !bg-white/80 hover:!bg-orange-50" @click.stop="emit('close', item)"
>
<template #icon>
<icon-ic:round-stop-circle class="text-icon" />
</template>
</NButton>
</template>
结束活动
</NTooltip>
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NButton <NButton

View File

@ -2,8 +2,8 @@
import { NCard, NDivider, useMessage } from 'naive-ui' import { NCard, NDivider, useMessage } from 'naive-ui'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { fetchRankDetail, fetchTeamQuestionScore } from '@/service/api/rank' import { RankPublishStatus } from '@/enum/business'
import { updateScore as updateScoreApi } from '@/service/api/result' import { fetchRankDetail, fetchTeamQuestionScore, publishRank, updateTeamScore } from '@/service/api/rank'
import RankHeader from './modules/rank-header.vue' import RankHeader from './modules/rank-header.vue'
import TeamItem from './modules/team-item.vue' import TeamItem from './modules/team-item.vue'
@ -12,10 +12,13 @@ import TeamListHeader from './modules/team-list-header.vue'
const route = useRoute() const route = useRoute()
const message = useMessage() const message = useMessage()
const activityId = route.query.activityId as string const activityId = route.query.activityId as string
const id = route.query.id as string
const Status = ref(Number(route.query.Status))
const competitionInfo = ref({ const competitionInfo = ref({
name: '阅读之星-辞海遨游环节 (2026)', name: route.query.name as string || '阅读之星-辞海遨游环节 (2026)',
date: '2026.01.12', date: route.query.date as string || '2026.01.12',
}) })
interface TeamData { interface TeamData {
@ -42,6 +45,8 @@ async function getRankDetail() {
message.error(error.message) message.error(error.message)
return return
} }
// console.log(data, 'data')
// 假设后端返回的数据结构需要映射 // 假设后端返回的数据结构需要映射
teamList.value = (data?.data || []).map((item: any, index: number) => ({ teamList.value = (data?.data || []).map((item: any, index: number) => ({
id: item.TeamID, id: item.TeamID,
@ -49,8 +54,8 @@ async function getRankDetail() {
name: item.Name, name: item.Name,
group: '默认组', // 接口没有组名,暂时用默认 group: '默认组', // 接口没有组名,暂时用默认
correctCount: '0 / 0', correctCount: '0 / 0',
totalScore: item.Point || 0, totalScore: item.TotalPoint || 0,
updateTime: new Date().toLocaleString(), // 暂时用当前时间 updateTime: '', // 暂时用当前时间
isExpanded: false, isExpanded: false,
scores: {}, scores: {},
})) }))
@ -125,6 +130,10 @@ async function toggleExpand(team: TeamData) {
} }
} }
/**
* 保存队伍的分数
* @param team - 队伍数据
*/
async function saveScores(team: TeamData) { async function saveScores(team: TeamData) {
// window.$message?.success(`已保存 ${team.name} 的分数变动`) // window.$message?.success(`已保存 ${team.name} 的分数变动`)
// console.log(team.scores, 'team.scores'); // console.log(team.scores, 'team.scores');
@ -138,8 +147,7 @@ async function saveScores(team: TeamData) {
.map(item => ({ .map(item => ({
Id: item.teamQuestionUseId, Id: item.teamQuestionUseId,
ResultPotins: item.score, ResultPotins: item.score,
QuestionDetailID: item.questionDetailId, ActivityID: Number(activityId),
QuestionId: item.questionId,
})) }))
if (scoreList.length === 0) { if (scoreList.length === 0) {
@ -148,21 +156,57 @@ async function saveScores(team: TeamData) {
} }
try { try {
const { error } = await updateScoreApi(scoreList) const { error } = await updateTeamScore(scoreList, Number(activityId))
if (error) { if (error) {
message.error(error.message || '保存分数失败') message.error(error.message || '保存分数失败')
return return
} }
message.success(`已保存 ${team.name} 的分数变动`) message.success(`已保存 ${team.name} 的分数变动`)
team.isExpanded = false team.isExpanded = false
team.updateTime = new Date().toLocaleString()
await getRankDetail()
} }
catch (e: any) { catch (e: any) {
message.error(e.message || '保存分数失败') message.error(e.message || '保存分数失败')
} }
} }
function handlePublish() { async function handlePublish() {
window.$message?.success('本场榜单发布成功') if (Status.value === RankPublishStatus.Published) {
message.warning('本场榜单已发布,请勿重复发布')
return
}
const d = window.$dialog?.warning({
title: '确认发布',
content: '确定要发布本场榜单吗?发布后终端将可见结果。',
positiveText: '确认发布',
negativeText: '取消',
onPositiveClick: async () => {
if (d) {
d.loading = true
}
try {
const { error } = await publishRank(Number(id))
if (error) {
message.error(error.message || '发布失败')
return false
}
message.success('本场榜单发布成功')
Status.value = RankPublishStatus.Published
return true
}
catch (e: any) {
message.error(e.message || '发布失败')
return false
}
finally {
if (d) {
d.loading = false
}
}
},
})
} }
function updateScore(team: TeamData, key: string, value: number | null) { function updateScore(team: TeamData, key: string, value: number | null) {

View File

@ -103,7 +103,7 @@ const rankStyle = computed(() => {
<span /> <span />
</div> </div>
<NInputNumber <NInputNumber
:value="item.score" :min="0" :max="10" button-placement="both" :value="item.score" :min="0" :max="100" button-placement="both"
class="text-center text-primary font-bold" class="text-center text-primary font-bold"
@update:value="(val) => emit('updateScore', team, String(key), val)" @update:value="(val) => emit('updateScore', team, String(key), val)"
/> />

View File

@ -1,14 +1,16 @@
<script setup lang="tsx"> <script setup lang="tsx">
import dayjs from 'dayjs'
import { NButton, NCard, NTag } from 'naive-ui' import { NButton, NCard, NTag } from 'naive-ui'
import { reactive } from 'vue' import { reactive } from 'vue'
import { useRouter } from 'vue-router' import { RankPublishStatus } from '@/enum/business'
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table' import { useRouterPush } from '@/hooks/common/router'
import { fetchRankList } from '@/service/api' import { useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
import { fetchActivityRankList } from '@/service/api/rank'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import RankSearch from './modules/rank-search.vue' import RankSearch from './modules/rank-search.vue'
const appStore = useAppStore() const appStore = useAppStore()
const router = useRouter() const { routerPushByKey } = useRouterPush()
const searchParams: Api.Rank.UserSearchParams = reactive({ const searchParams: Api.Rank.UserSearchParams = reactive({
currentPage: 1, currentPage: 1,
@ -19,34 +21,33 @@ const searchParams: Api.Rank.UserSearchParams = reactive({
const rankTitle = '排行榜管理列表' const rankTitle = '排行榜管理列表'
const statusMap: Record<string, string> = { const statusMap: Record<number, string> = {
1: '已发布', [RankPublishStatus.Published]: '已发布',
2: '待审核', [RankPublishStatus.Unpublished]: '未发布',
} }
const { columns, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({ const { columns, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
api: () => fetchRankList(searchParams as any), api: () => fetchActivityRankList(searchParams.currentPage, searchParams.pageSize),
transform: (response) => { transform: (response) => {
const transformed = defaultTransform(response) const rawData = response.data?.data as unknown as Api.Rank.ActivityRankList || {}
// Mocking data for Rank Management based on User API response
const mockCompetitions = ['阅读之星-辞海遨游环节 (2026)', '古诗词大会年度精英巅峰赛', '趣味百科常识挑战周']
const mockDates = ['2026.01.12', '2026.01.15', '2026.01.18']
const mockScales = ['40 支队伍', '24 支队伍', '60 支队伍']
const mockScores = ['145', '180', '120']
const mockTimes = ['2026-01-22 16:30:12', '2026-01-22 12:00:00', '2026-01-21 18:22:45']
transformed.data.forEach((item: any, index) => { return {
const mockIndex = index % 3 data: (rawData?.Items || []).map((item) => {
item.competitionActivityName = mockCompetitions[mockIndex] // Competition Name return {
item.competitionActivityDate = mockDates[mockIndex] // Activity Date (using userEmail field) ActivityId: item.ActivityId,
item.competitionTeamScale = mockScales[mockIndex] // Team Scale (using userPhone field) id: item.Id,
item.competitionMaxScore = mockScores[mockIndex] // Max Score (using nickName field) competitionActivityName: item.ActivityName,
// item.createTime is typically available, we'll simulate update time competitionActivityDate: item.StartTime ? dayjs(item.StartTime).format('YYYY-MM-DD') : '',
item.createTime = mockTimes[mockIndex] competitionTeamScale: `${item.TeamNum} 支队伍`,
// Status: 1 (Published), 2 (Pending) competitionMaxScore: item.MaxPoint,
item.status = (index % 2 === 0) ? '2' : '1' createTime: item.LastUpdatetime ? dayjs(item.LastUpdatetime).format('YYYY-MM-DD HH:mm:ss') : '',
}) status: item.Status,
return transformed }
}),
pageNum: rawData?.PageNumber || 1,
pageSize: rawData?.PageSize || 10,
total: rawData?.TotalRecords || 0,
}
}, },
onPaginationParamsChange: (params) => { onPaginationParamsChange: (params) => {
searchParams.currentPage = params.page || 1 searchParams.currentPage = params.page || 1
@ -63,7 +64,7 @@ const { columns, data, getData, getDataByPage, loading, mobilePagination } = use
align: 'center', align: 'center',
key: 'index', key: 'index',
width: 80, width: 80,
render: (row, index) => index + 1, render: (_row, index) => index + 1,
}, },
{ {
key: 'competitionActivityName', key: 'competitionActivityName',
@ -102,11 +103,10 @@ const { columns, data, getData, getDataByPage, loading, mobilePagination } = use
title: '发布状态', title: '发布状态',
align: 'center', align: 'center',
render: (row) => { render: (row) => {
if (row.status === null) if (row.status === null || row.status === undefined)
return null return null
const label = statusMap[row.status] || '未知' const label = statusMap[Number(row.status)] || '未知'
// 1: Published (Success/Green), 2: Pending (Warning/Orange) return <NTag type={Number(row.status) === RankPublishStatus.Published ? 'success' : 'warning'}>{label}</NTag>
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>
}, },
}, },
{ {
@ -115,7 +115,7 @@ const { columns, data, getData, getDataByPage, loading, mobilePagination } = use
align: 'center', align: 'center',
render: row => ( render: row => (
<div class="flex-center"> <div class="flex-center">
<NButton type="primary" text onClick={() => edit(row.id)}> <NButton type="primary" text onClick={() => edit(row)}>
进入排行详情 进入排行详情
</NButton> </NButton>
</div> </div>
@ -128,13 +128,20 @@ const {
checkedRowKeys, checkedRowKeys,
} = useTableOperate(data, 'id', getData) } = useTableOperate(data, 'id', getData)
function edit(id: number) { function edit(item: any) {
router.push({ name: 'rank_rank-detail', query: { id } }) routerPushByKey('rank_rank-detail', {
query: {
activityId: item.ActivityId,
name: item.competitionActivityName,
date: item.competitionActivityDate,
Status: item.status,
},
})
} }
function handleBatchPublish() { // function handleBatchPublish() {
window.$message?.success('批量发布成功') // window.$message?.success('批量发布成功')
} // }
</script> </script>
<template> <template>
@ -151,12 +158,12 @@ function handleBatchPublish() {
您可以对所有比赛场次的最终积分进行核对修正并正式发布结果 您可以对所有比赛场次的最终积分进行核对修正并正式发布结果
</div> </div>
</div> </div>
<NButton type="primary" ghost class="ml-auto" @click="handleBatchPublish"> <!-- <NButton type="primary" ghost class="ml-auto" @click="handleBatchPublish">
<template #icon> <template #icon>
<icon-ic-round-upload class="text-icon" /> <icon-ic-round-upload class="text-icon" />
</template> </template>
批量发布至终端 批量发布至终端
</NButton> </NButton> -->
</div> </div>
</template> </template>
<!-- <template #header-extra> <!-- <template #header-extra>

View File

@ -1,248 +0,0 @@
<script lang="ts" setup>
import { useIntervalFn } from '@vueuse/core'
import { NButton } from 'naive-ui'
import { storeToRefs } from 'pinia'
import { computed, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchUpdateCurrentQuestionUse } from '@/service/api/result'
import { useActivityInfoStore } from '@/store/modules/activityinfo'
import GroupTabs, { type GroupItem } from './modules/GroupTabs.vue'
import TeamResultCard, { type AICharacter, type TeamResult } from './modules/TeamResultCard.vue'
const activityInfoStore = useActivityInfoStore()
// eslint-disable-next-line unused-imports/no-unused-vars
const { activityId, groupId } = storeToRefs(activityInfoStore)
const route = useRoute()
const TeamGroupID = computed(() => route.query?.TeamGroupID as string)
// --- 模拟数据和状态 ---
const groups = ref<GroupItem[]>(Array.from({ length: 8 }).map((_, i) => ({
id: i + 1,
name: `${i + 1}`,
})))
const currentGroupId = ref<number | string>(1) // 当前选中的组别 ID
const teamsData = ref<TeamResult[]>([]) // 当前组别下的队伍数据
const isPolling = ref(true) // 是否正在轮询
// 初始化某组的队伍结构
function initGroupTeams(gid: number | string) {
return Array.from({ length: 4 }).map((_, i) => ({
id: `${gid}-${i + 1}`,
name: `${i + 1}`,
correctCount: 0,
score: 0,
imageUrl: 'https://oss.qyzhjy.com/temp/1769653751992/a4.jpg',
status: 'pending' as const,
aiData: [],
}))
}
// 初始化数据
teamsData.value = initGroupTeams(currentGroupId.value)
// --- 轮询 / 模拟逻辑 ---
// 存储人工修正记录,以便在更新时保留
// Map<TeamID, Map<CharID, boolean>>
const manualCorrections = ref<Record<string, Record<string, boolean>>>({})
function mergeAIResults(currentData: TeamResult[], incomingData: Record<string, AICharacter[]>) {
currentData.forEach((team) => {
const newChars = incomingData[team.id] || []
if (newChars.length === 0)
return
team.status = 'analyzing'
// 合并逻辑:
// 1. 现有字符:如果需要更新,但要保留人工修正的状态?
// 目前假设 AI 返回的结果是稳定的,但可能会有更多字符。
// 2. 新字符:追加。
// 简单方法:替换列表但应用人工覆盖
const mergedChars = newChars.map((char) => {
const teamCorrections = manualCorrections.value[team.id] || {}
if (typeof teamCorrections[char.id] !== 'undefined') {
return { ...char, isCorrect: teamCorrections[char.id] }
}
return char
})
team.aiData = mergedChars
// 重新计算分数/数量
team.correctCount = mergedChars.filter(c => c.isCorrect).length
team.score = team.correctCount * 1.5 // 假设每个字符 1.5 分
// 如果数据足够(模拟结束条件)
if (team.aiData.length >= 20) {
team.status = 'done'
}
})
}
// 模拟 API 轮询
// eslint-disable-next-line unused-imports/no-unused-vars
const { pause, resume } = useIntervalFn(() => {
if (!isPolling.value)
return
// 模拟获取当前队伍的数据
// 实际中: const res = await api.getResults(currentGroupId.value)
// 模拟增量更新
const incomingMockData: Record<string, AICharacter[]> = {}
teamsData.value.forEach((team) => {
if (team.status === 'done') {
incomingMockData[team.id] = team.aiData
return
}
// 添加 1-2 个随机字符
const currentLen = team.aiData.length
const nextLen = Math.min(currentLen + Math.floor(Math.random() * 3), 25)
const newChars: AICharacter[] = []
for (let i = 0; i < nextLen; i++) {
// 如果已存在,保留字符,否则生成新的
if (i < currentLen) {
// 我们使用上一次模拟的原始字符(尚未应用人工修正,
// 实际上我们是在模拟“服务器”视图,它还不知道本地编辑)
// 实际上为了模拟简单,我们就生成“新”列表。
newChars.push({
id: `char-${i}`,
char: ['龙', '年', '大', '吉', '万', '事', '如', '意', '恭', '喜', '发', '财'][i % 12],
isCorrect: i % 5 !== 0, // 模拟一些错误
})
}
else {
newChars.push({
id: `char-${i}`,
char: ['龙', '年', '大', '吉', '万', '事', '如', '意', '恭', '喜', '发', '财'][i % 12],
isCorrect: Math.random() > 0.2, // 80% 正确率
})
}
}
incomingMockData[team.id] = newChars
})
mergeAIResults(teamsData.value, incomingMockData)
}, 2000)
// --- 事件处理 ---
function handleGroupChange(id: number | string) {
currentGroupId.value = id
// 重置新组的数据
teamsData.value = initGroupTeams(id)
// 清除还是保留人工修正?通常新一轮会清除,但如果是重新访问可能会保留?
// 用户未指定。我将保留在以 teamID 为键的 map 中groupId-teamIdx
// 如果停止了轮询,是否恢复?
// 在实际应用中,可能需要为该组重新启动轮询。
}
function handleToggleCorrect(teamId: number | string, charId: string) {
const team = teamsData.value.find(t => t.id === teamId)
if (!team)
return
const char = team.aiData.find(c => c.id === charId)
if (!char)
return
// 切换状态
char.isCorrect = !char.isCorrect
// 更新计数
team.correctCount = team.aiData.filter(c => c.isCorrect).length
team.score = team.correctCount * 1.5
// 保存到人工修正 map
if (!manualCorrections.value[team.id]) {
manualCorrections.value[team.id] = {}
}
manualCorrections.value[team.id][charId] = char.isCorrect
}
function handleNext() {
// “下一步”按钮的逻辑(也许是下一组?)
const idx = groups.value.findIndex(g => g.id === currentGroupId.value)
if (idx !== -1 && idx < groups.value.length - 1) {
handleGroupChange(groups.value[idx + 1].id)
}
}
function handlePublish() {
if (TeamGroupID.value) {
fetchGetQuestionDetailData()
window.$message?.success('结果已发布')
}
}
async function fetchGetQuestionDetailData() {
try {
const { data: outlineData, error } = await fetchUpdateCurrentQuestionUse(TeamGroupID.value)
// eslint-disable-next-line no-console
console.log(outlineData, 'outlineData')
if (error) {
window?.$message?.error(error.message)
}
}
catch (error: any) {
window?.$message?.error(error.message)
}
}
// 清理
onUnmounted(() => {
pause()
})
</script>
<template>
<div class="h-full flex flex-col gap-2 overflow-hidden bg-[#F9FAFB] p-6">
<!-- Top Bar -->
<div class="flex items-center justify-between rounded-2xl bg-white px-6 py-4 shadow-sm">
<div class="flex items-center gap-8">
<h1 class="m-0 flex items-center gap-2 text-xl text-gray-800 font-bold">
<div class="i-carbon-chart-line-data text-2xl text-blue-600" />
实时结果
</h1>
<GroupTabs :groups="groups" :model-value="currentGroupId" @update:model-value="handleGroupChange" />
</div>
<div class="flex gap-4">
<div class="flex items-center rounded-full bg-blue-50 px-4 py-1.5 text-sm text-blue-600 font-bold">
当前进度 1/10
</div>
<NButton secondary strong @click="handleNext">
下一题
</NButton>
<NButton type="primary" strong class="px-6" @click="handlePublish">
发布结果
</NButton>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 overflow-hidden">
<div class="grid grid-cols-4 h-full gap-6">
<TeamResultCard
v-for="team in teamsData" :key="team.id" :data="team"
@toggle-correct="(charId) => handleToggleCorrect(team.id, charId)"
/>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.n-card__content) {
padding: 0;
}
</style>

View File

@ -1,45 +0,0 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
export interface GroupItem {
id: number | string
name: string
}
defineProps<{
groups: GroupItem[]
modelValue: number | string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', id: number | string): void
}>()
</script>
<template>
<div class="scrollbar-hide flex gap-3 overflow-x-auto pb-2">
<NButton
v-for="group in groups"
:key="group.id"
round
size="large"
:type="modelValue === group.id ? 'primary' : 'default'"
:secondary="modelValue !== group.id"
class="px-6 font-bold transition-all"
:class="{ 'shadow-lg shadow-blue-500/30': modelValue === group.id }"
@click="emit('update:modelValue', group.id)"
>
{{ group.name }}
</NButton>
</div>
</template>
<style scoped>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@ -1,114 +0,0 @@
<script setup lang="ts">
import { NImage } from 'naive-ui'
export interface AICharacter {
id: string
char: string
isCorrect: boolean
}
export interface TeamResult {
id: number | string
name: string
correctCount: number
score: number
imageUrl: string
status: 'pending' | 'analyzing' | 'done'
aiData: AICharacter[]
}
// eslint-disable-next-line unused-imports/no-unused-vars
const props = defineProps<{
data: TeamResult
}>()
const emit = defineEmits<{
(e: 'toggle-correct', charId: string): void
(e: 'update-char', charId: string, newChar: string): void
}>()
// 切换正确状态
function handleCharClick(item: AICharacter) {
// eslint-disable-next-line vue/custom-event-name-casing
emit('toggle-correct', item.id)
}
</script>
<template>
<div class="h-full flex flex-col overflow-hidden border border-gray-100 rounded-xl bg-white shadow-sm">
<!-- 头部信息 -->
<div class="flex items-center justify-between border-b border-gray-100 bg-gray-50 px-4 py-3">
<h3 class="m-0 text-lg text-gray-800 font-bold">
{{ data.name }}
</h3>
<div class="flex gap-3 text-sm">
<span class="text-green-600 font-bold">正确 {{ data.correctCount }} </span>
<span class="text-blue-600 font-bold">积分 {{ data.score }} </span>
</div>
</div>
<!-- 图片区域 -->
<div class="relative aspect-[3/4] w-full flex items-center justify-center overflow-hidden border-b border-gray-100 bg-gray-100">
<!-- 图片居中 -->
<NImage
v-if="data.imageUrl"
:src="data.imageUrl"
class="h-full w-full flex items-center justify-center"
:img-props="{ class: 'w-full h-full object-contain object-center' }"
preview-disabled
/>
<div v-else class="h-full w-full flex items-center justify-center text-gray-400">
等待上传答题卡...
</div>
<!-- 加载遮罩 -->
<!-- <div
v-if="data.status === 'analyzing'"
class="absolute inset-0 bg-black/20 flex items-center justify-center backdrop-blur-[1px]"
>
<NSpin size="large" />
</div> -->
</div>
<!-- AI 结果网格 -->
<div class="min-h-[200px] flex-1 overflow-y-auto p-5">
<div v-if="data.aiData.length === 0" class="h-full flex items-center justify-center text-sm text-gray-400">
{{ data.status === 'pending' ? '等待识别...' : '暂无数据' }}
</div>
<div v-else class="grid grid-cols-8 gap-2">
<!--
grid-cols-5 控制宽度 (宽度由网格列数控制)
h-10 控制高度 (高度由固定高度类控制)
-->
<div
v-for="item in data.aiData"
:key="item.id"
class="h-14 w-14 flex cursor-pointer select-none items-center justify-center rounded-lg text-lg font-bold transition-all active:scale-95 hover:scale-105"
:class="[
item.isCorrect
? 'bg-green-100 text-green-600 border border-green-200'
: 'bg-red-100 text-red-600 border border-red-200',
]"
@click="handleCharClick(item)"
>
{{ item.char }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Custom scrollbar for the grid area if needed */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 2px;
}
</style>

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(() => { onBeforeUnmount(() => {

View File

@ -2,7 +2,9 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue' import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { PublishStatus } from '@/enum/business'
import { useRouterPush } from '@/hooks/common/router' import { useRouterPush } from '@/hooks/common/router'
import { fetchUpdatePublishStatus } from '@/service/api/competition'
import { fetchGetQuestionTableByActivityID } from '@/service/api/game' import { fetchGetQuestionTableByActivityID } from '@/service/api/game'
import { useActivityInfoStore } from '@/store/modules/activityinfo' import { useActivityInfoStore } from '@/store/modules/activityinfo'
import { AudioController } from '@/utils/audio' import { AudioController } from '@/utils/audio'
@ -93,7 +95,20 @@ function handleBack() {
routerBack() 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', { routerPushByKey('user_rules', {
query: { query: {
activityId: activityId.value, activityId: activityId.value,
@ -143,24 +158,20 @@ onMounted(async () => {
<template> <template>
<CompetitionLayout <CompetitionLayout
:show-back="true" :show-next="showNextButton" :title="activityName" :title-bg-type="2" :show-back="true" :show-next="showNextButton" :title="activityName" :title-bg-type="2"
@back="handleBack" @back="handleBack" @next="handleNext"
@next="handleNext"
> >
<div class="h-screen w-full overflow-y-auto font-sans"> <div class="h-screen w-full overflow-y-auto font-sans">
<div class="relative z-10 min-h-full w-full"> <div class="relative z-10 min-h-full w-full">
<div class="min-h-full w-full flex flex-col items-center pb-12 pt-24"> <div class="min-h-full w-full flex flex-col items-center pb-12 pt-24">
<!-- 卡片列表 --> <!-- 卡片列表 -->
<TransitionGroup <TransitionGroup
tag="div" tag="div" name="card-anim"
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" class="perspective-container max-w-[1800px] w-full flex flex-wrap justify-center gap-4 px-4 md:gap-10 md:px-12"
> >
<div <div
v-for="(node, index) in showCards ? questions : []" v-for="(node, index) in showCards ? questions : []" :key="node.id"
:key="node.id"
class="w-[44%] card-wrapper 2xl:w-[14%] lg:w-[21%] md:w-[28%] xl:w-[17%]" class="w-[44%] card-wrapper 2xl:w-[14%] lg:w-[21%] md:w-[28%] xl:w-[17%]"
:style="{ animationDelay: `${index * 0.2}s` }" :style="{ animationDelay: `${index * 0.2}s` }" @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) }">
<!-- 正面 (Front) - 显示背面图案 --> <!-- 正面 (Front) - 显示背面图案 -->
@ -216,16 +227,19 @@ onMounted(async () => {
transform: translate3d(0, 300px, -800px) rotateY(180deg) rotateX(45deg) scale(0.5); transform: translate3d(0, 300px, -800px) rotateY(180deg) rotateX(45deg) scale(0.5);
filter: blur(20px); // 深度模糊 filter: blur(20px); // 深度模糊
} }
50% { 50% {
opacity: 1; opacity: 1;
filter: blur(0); // 聚焦 filter: blur(0); // 聚焦
} }
70% { 70% {
// [过冲状态] // [过冲状态]
// 飞到眼前一点点 (Z=50px) // 飞到眼前一点点 (Z=50px)
// 角度稍微回弹 (rotateY -15deg) // 角度稍微回弹 (rotateY -15deg)
transform: translate3d(0, -20px, 50px) rotateY(-15deg) rotateX(-10deg) scale(1.05); transform: translate3d(0, -20px, 50px) rotateY(-15deg) rotateX(-10deg) scale(1.05);
} }
100% { 100% {
// [最终归位] // [最终归位]
transform: translate3d(0, 0, 0) rotateY(0) rotateX(0) scale(1); 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) { if (store.currentQuestionInfo && store.currentQuestionDetail) {
try { try {
const { data } = await fetchGetGameStatistics(_params) const { data } = await fetchGetGameStatistics(_params)
console.log(data, 'data') // console.log(data, 'data')
// 更新图表数据 // 更新图表数据
// 假设接口返回格式为: [{ TeamName: '组1', Points: 10, ResultPotins: 5 }, ...] // 假设接口返回格式为: [{ TeamName: '组1', Points: 10, ResultPotins: 5 }, ...]
// 需要根据实际接口返回结构调整映射逻辑 // 需要根据实际接口返回结构调整映射逻辑

View File

@ -29,13 +29,10 @@ async function getGroups() {
if (list && Array.isArray(list)) { if (list && Array.isArray(list)) {
groups.value = list.map((item: any) => ({ groups.value = list.map((item: any) => ({
...item, ...item,
// id: item.Id, icon: 'activity',
// name: item.Name,
icon: 'activity', // Default icon since API might not provide one
})) }))
} }
isAllEnd.value = list.every((item: Api.Competition.ActivityTeamGroup) => item.IsEnd) isAllEnd.value = list.every((item: Api.Competition.ActivityTeamGroup) => item.IsEnd)
// console.log(groups.value, 'groups.value')
} }
finally { finally {
loading.value = false loading.value = false
@ -49,7 +46,7 @@ function handleBack() {
} }
function handleNext() { function handleNext() {
routerPushByKey('user') routerPushByKey('user_rank-list', { query: { activityId } })
} }
function selectGroup(item: Api.Competition.ActivityTeamGroup) { 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 { computed, onMounted, ref } from 'vue'
import beacon from '@/assets/imgs/user/event-card-bg-icon.svg' import beacon from '@/assets/imgs/user/event-card-bg-icon.svg'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue' import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { PublishStatus } from '@/enum/business'
import { useRouterPush } from '@/hooks/common/router' import { useRouterPush } from '@/hooks/common/router'
import { fetchGetActivityList } from '@/service/api/competition' import { fetchGetActivityList } from '@/service/api/competition'
import { useActivityInfoStore } from '@/store/modules/activityinfo' import { useActivityInfoStore } from '@/store/modules/activityinfo'
@ -32,14 +33,16 @@ async function getEvents() {
const list = data?.data || [] const list = data?.data || []
if (list && Array.isArray(list)) { if (list && Array.isArray(list)) {
events.value = list.map((item: any) => ({ events.value = list
...item, .filter((item: any) => item.PublishStatus === PublishStatus.Published || item.PublishStatus === PublishStatus.Processing)
id: item.Id, .map((item: any) => ({
title: item.Name, ...item,
subTitle: item.Description, id: item.Id,
icon: 'activity', title: item.Name,
status: 'active', subTitle: item.Description,
})) icon: 'activity',
status: 'active',
}))
} }
} }
finally { finally {
@ -185,7 +188,7 @@ onMounted(() => {
.text-content { .text-content {
.event-title { .event-title {
font-size: 1.4rem; font-size: 1.1rem;
font-weight: bold; font-weight: bold;
color: $text-color; color: $text-color;
margin-bottom: 8px; margin-bottom: 8px;

View File

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