feat(admin): 新增用户排行榜功能并优化活动流程
- 新增用户排行榜页面和待发布页面,支持查看和发布排行榜 - 新增排行榜相关API接口,包括获取活动排行列表、修改队伍分数、发布排名和获取用户排名列表 - 新增RankPublishStatus枚举,用于管理排行榜发布状态 - 在活动卡片中新增结束活动按钮,点击后调用结束活动接口并生成排行榜数据 - 优化用户活动首页,只显示已发布和处理中的活动 - 调整游戏页面定时刷新间隔从2秒改为3秒,优化性能 - 修复排行榜详情页分数输入最大值限制问题 - 清理未使用的代码文件,移除results-detail-copy相关组件和路由 - 更新路由配置和类型定义,支持新的排行榜页面
This commit is contained in:
32
apps/admin/src/assets/imgs/user/rank-title.svg
Normal file
32
apps/admin/src/assets/imgs/user/rank-title.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 429 KiB |
@ -5,6 +5,11 @@ export enum PublishStatus {
|
||||
Finished = 3,
|
||||
}
|
||||
|
||||
export enum RankPublishStatus {
|
||||
Unpublished = 0,
|
||||
Published = 1,
|
||||
}
|
||||
|
||||
export enum QuestionType {
|
||||
SingleChoice = 'SingleChoice', // 单选题
|
||||
MultipleChoice = 'MultipleChoice', // 多选题
|
||||
|
||||
@ -258,6 +258,7 @@ const local: App.I18n.Schema = {
|
||||
'results_results-list': 'Results List',
|
||||
'results_results-detail': 'Results Detail',
|
||||
'test': 'Test',
|
||||
'user_rank-list': 'User Rank List',
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
|
||||
@ -254,6 +254,7 @@ const local: App.I18n.Schema = {
|
||||
'results_results-list': '实时结果列表',
|
||||
'results_results-detail': '实时结果详情',
|
||||
'test': 'Test',
|
||||
'user_rank-list': '用户排名列表',
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
|
||||
@ -28,7 +28,6 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
"question-store": () => import("@/views/question-store/index.vue"),
|
||||
"rank_rank-detail": () => import("@/views/rank/rank-detail/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-list": () => import("@/views/results/results-list/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_groups: () => import("@/views/user/groups/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_teams: () => import("@/views/user/teams/index.vue"),
|
||||
};
|
||||
|
||||
@ -197,16 +197,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
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',
|
||||
path: '/results/results-list',
|
||||
@ -341,6 +331,24 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
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',
|
||||
path: '/user/rules',
|
||||
|
||||
@ -180,7 +180,6 @@ const routeMap: RouteMap = {
|
||||
"rank_rank-list": "/rank/rank-list",
|
||||
"results": "/results",
|
||||
"results_results-detail": "/results/results-detail",
|
||||
"results_results-detail-copy": "/results/results-detail-copy",
|
||||
"results_results-list": "/results/results-list",
|
||||
"template": "/template",
|
||||
"template_template-detail": "/template/template-detail",
|
||||
@ -193,6 +192,8 @@ const routeMap: RouteMap = {
|
||||
"user_game": "/user/game",
|
||||
"user_groups": "/user/groups",
|
||||
"user_home": "/user/home",
|
||||
"user_rank-list": "/user/rank-list",
|
||||
"user_rank-pending": "/user/rank-pending",
|
||||
"user_rules": "/user/rules",
|
||||
"user_teams": "/user/teams"
|
||||
};
|
||||
|
||||
@ -23,3 +23,40 @@ export function fetchTeamQuestionScore(TeamID: number) {
|
||||
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',
|
||||
})
|
||||
}
|
||||
|
||||
@ -17,3 +17,12 @@ export function updateScore(data: { Id: number, ResultPotins: number, QuestionDe
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 结束活动时候调用 */
|
||||
export function endActivity(ActivityID: number) {
|
||||
return request<App.Service.Response>({
|
||||
url: `/Base/ActivityMain/AddOrUpdateActivity_Main_Total?ActivityID=${ActivityID}`,
|
||||
method: 'post',
|
||||
data: { ActivityID },
|
||||
})
|
||||
}
|
||||
|
||||
51
apps/admin/src/typings/api/rank.d.ts
vendored
51
apps/admin/src/typings/api/rank.d.ts
vendored
@ -31,6 +31,57 @@ declare namespace Api {
|
||||
/** common search params of table */
|
||||
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
|
||||
*
|
||||
|
||||
2
apps/admin/src/typings/components.d.ts
vendored
2
apps/admin/src/typings/components.d.ts
vendored
@ -28,6 +28,7 @@ declare module 'vue' {
|
||||
'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default']
|
||||
'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['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']
|
||||
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['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:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['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 IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||
const IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
|
||||
|
||||
6
apps/admin/src/typings/elegant-router.d.ts
vendored
6
apps/admin/src/typings/elegant-router.d.ts
vendored
@ -34,7 +34,6 @@ declare module "@elegant-router/types" {
|
||||
"rank_rank-list": "/rank/rank-list";
|
||||
"results": "/results";
|
||||
"results_results-detail": "/results/results-detail";
|
||||
"results_results-detail-copy": "/results/results-detail-copy";
|
||||
"results_results-list": "/results/results-list";
|
||||
"template": "/template";
|
||||
"template_template-detail": "/template/template-detail";
|
||||
@ -47,6 +46,8 @@ declare module "@elegant-router/types" {
|
||||
"user_game": "/user/game";
|
||||
"user_groups": "/user/groups";
|
||||
"user_home": "/user/home";
|
||||
"user_rank-list": "/user/rank-list";
|
||||
"user_rank-pending": "/user/rank-pending";
|
||||
"user_rules": "/user/rules";
|
||||
"user_teams": "/user/teams";
|
||||
};
|
||||
@ -123,7 +124,6 @@ declare module "@elegant-router/types" {
|
||||
| "question-store"
|
||||
| "rank_rank-detail"
|
||||
| "rank_rank-list"
|
||||
| "results_results-detail-copy"
|
||||
| "results_results-detail"
|
||||
| "results_results-list"
|
||||
| "template_template-detail"
|
||||
@ -135,6 +135,8 @@ declare module "@elegant-router/types" {
|
||||
| "user_game"
|
||||
| "user_groups"
|
||||
| "user_home"
|
||||
| "user_rank-list"
|
||||
| "user_rank-pending"
|
||||
| "user_rules"
|
||||
| "user_teams"
|
||||
>;
|
||||
|
||||
@ -3,6 +3,7 @@ import { NButton, NGi, NGrid, NSpace } from 'naive-ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
import { fetchCopyActivity, fetchDeleteActivity, fetchGetActivityList } from '@/service/api/competition'
|
||||
import { endActivity } from '@/service/api/result'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
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() } })
|
||||
}
|
||||
|
||||
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())
|
||||
</script>
|
||||
|
||||
@ -122,7 +156,7 @@ onMounted(() => fetchActivityList())
|
||||
<!-- 响应式布局:手机1列,平板2列,中屏3列,大屏4列 -->
|
||||
<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)">
|
||||
<CompetitionCard :item="item" @copy="handleCopy" @delete="handleDelete" />
|
||||
<CompetitionCard :item="item" @copy="handleCopy" @delete="handleDelete" @close="handleCloseActivity" />
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'copy', 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')
|
||||
@ -83,6 +84,20 @@ const decorationBgClass = computed(() => {
|
||||
<div
|
||||
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>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
import { NCard, NDivider, useMessage } from 'naive-ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { fetchRankDetail, fetchTeamQuestionScore } from '@/service/api/rank'
|
||||
import { updateScore as updateScoreApi } from '@/service/api/result'
|
||||
import { RankPublishStatus } from '@/enum/business'
|
||||
import { fetchRankDetail, fetchTeamQuestionScore, publishRank, updateTeamScore } from '@/service/api/rank'
|
||||
import RankHeader from './modules/rank-header.vue'
|
||||
import TeamItem from './modules/team-item.vue'
|
||||
|
||||
@ -12,10 +12,13 @@ import TeamListHeader from './modules/team-list-header.vue'
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const activityId = route.query.activityId as string
|
||||
const id = route.query.id as string
|
||||
|
||||
const Status = ref(Number(route.query.Status))
|
||||
|
||||
const competitionInfo = ref({
|
||||
name: '阅读之星-辞海遨游环节 (2026)',
|
||||
date: '2026.01.12',
|
||||
name: route.query.name as string || '阅读之星-辞海遨游环节 (2026)',
|
||||
date: route.query.date as string || '2026.01.12',
|
||||
})
|
||||
|
||||
interface TeamData {
|
||||
@ -42,6 +45,8 @@ async function getRankDetail() {
|
||||
message.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
// console.log(data, 'data')
|
||||
// 假设后端返回的数据结构需要映射
|
||||
teamList.value = (data?.data || []).map((item: any, index: number) => ({
|
||||
id: item.TeamID,
|
||||
@ -49,8 +54,8 @@ async function getRankDetail() {
|
||||
name: item.Name,
|
||||
group: '默认组', // 接口没有组名,暂时用默认
|
||||
correctCount: '0 / 0',
|
||||
totalScore: item.Point || 0,
|
||||
updateTime: new Date().toLocaleString(), // 暂时用当前时间
|
||||
totalScore: item.TotalPoint || 0,
|
||||
updateTime: '', // 暂时用当前时间
|
||||
isExpanded: false,
|
||||
scores: {},
|
||||
}))
|
||||
@ -125,6 +130,10 @@ async function toggleExpand(team: TeamData) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存队伍的分数
|
||||
* @param team - 队伍数据
|
||||
*/
|
||||
async function saveScores(team: TeamData) {
|
||||
// window.$message?.success(`已保存 ${team.name} 的分数变动`)
|
||||
// console.log(team.scores, 'team.scores');
|
||||
@ -138,8 +147,7 @@ async function saveScores(team: TeamData) {
|
||||
.map(item => ({
|
||||
Id: item.teamQuestionUseId,
|
||||
ResultPotins: item.score,
|
||||
QuestionDetailID: item.questionDetailId,
|
||||
QuestionId: item.questionId,
|
||||
ActivityID: Number(activityId),
|
||||
}))
|
||||
|
||||
if (scoreList.length === 0) {
|
||||
@ -148,21 +156,57 @@ async function saveScores(team: TeamData) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await updateScoreApi(scoreList)
|
||||
const { error } = await updateTeamScore(scoreList, Number(activityId))
|
||||
if (error) {
|
||||
message.error(error.message || '保存分数失败')
|
||||
return
|
||||
}
|
||||
message.success(`已保存 ${team.name} 的分数变动`)
|
||||
team.isExpanded = false
|
||||
team.updateTime = new Date().toLocaleString()
|
||||
await getRankDetail()
|
||||
}
|
||||
catch (e: any) {
|
||||
message.error(e.message || '保存分数失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handlePublish() {
|
||||
window.$message?.success('本场榜单发布成功')
|
||||
async function handlePublish() {
|
||||
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) {
|
||||
|
||||
@ -103,7 +103,7 @@ const rankStyle = computed(() => {
|
||||
<span />
|
||||
</div>
|
||||
<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"
|
||||
@update:value="(val) => emit('updateScore', team, String(key), val)"
|
||||
/>
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
<script setup lang="tsx">
|
||||
import dayjs from 'dayjs'
|
||||
import { NButton, NCard, NTag } from 'naive-ui'
|
||||
import { reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
|
||||
import { fetchRankList } from '@/service/api'
|
||||
import { RankPublishStatus } from '@/enum/business'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
import { useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
|
||||
import { fetchActivityRankList } from '@/service/api/rank'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import RankSearch from './modules/rank-search.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
const searchParams: Api.Rank.UserSearchParams = reactive({
|
||||
currentPage: 1,
|
||||
@ -19,34 +21,33 @@ const searchParams: Api.Rank.UserSearchParams = reactive({
|
||||
|
||||
const rankTitle = '排行榜管理列表'
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
1: '已发布',
|
||||
2: '待审核',
|
||||
const statusMap: Record<number, string> = {
|
||||
[RankPublishStatus.Published]: '已发布',
|
||||
[RankPublishStatus.Unpublished]: '未发布',
|
||||
}
|
||||
|
||||
const { columns, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
||||
api: () => fetchRankList(searchParams as any),
|
||||
api: () => fetchActivityRankList(searchParams.currentPage, searchParams.pageSize),
|
||||
transform: (response) => {
|
||||
const transformed = defaultTransform(response)
|
||||
// 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']
|
||||
const rawData = response.data?.data as unknown as Api.Rank.ActivityRankList || {}
|
||||
|
||||
transformed.data.forEach((item: any, index) => {
|
||||
const mockIndex = index % 3
|
||||
item.competitionActivityName = mockCompetitions[mockIndex] // Competition Name
|
||||
item.competitionActivityDate = mockDates[mockIndex] // Activity Date (using userEmail field)
|
||||
item.competitionTeamScale = mockScales[mockIndex] // Team Scale (using userPhone field)
|
||||
item.competitionMaxScore = mockScores[mockIndex] // Max Score (using nickName field)
|
||||
// item.createTime is typically available, we'll simulate update time
|
||||
item.createTime = mockTimes[mockIndex]
|
||||
// Status: 1 (Published), 2 (Pending)
|
||||
item.status = (index % 2 === 0) ? '2' : '1'
|
||||
})
|
||||
return transformed
|
||||
return {
|
||||
data: (rawData?.Items || []).map((item) => {
|
||||
return {
|
||||
ActivityId: item.ActivityId,
|
||||
id: item.Id,
|
||||
competitionActivityName: item.ActivityName,
|
||||
competitionActivityDate: item.StartTime ? dayjs(item.StartTime).format('YYYY-MM-DD') : '',
|
||||
competitionTeamScale: `${item.TeamNum} 支队伍`,
|
||||
competitionMaxScore: item.MaxPoint,
|
||||
createTime: item.LastUpdatetime ? dayjs(item.LastUpdatetime).format('YYYY-MM-DD HH:mm:ss') : '',
|
||||
status: item.Status,
|
||||
}
|
||||
}),
|
||||
pageNum: rawData?.PageNumber || 1,
|
||||
pageSize: rawData?.PageSize || 10,
|
||||
total: rawData?.TotalRecords || 0,
|
||||
}
|
||||
},
|
||||
onPaginationParamsChange: (params) => {
|
||||
searchParams.currentPage = params.page || 1
|
||||
@ -63,7 +64,7 @@ const { columns, data, getData, getDataByPage, loading, mobilePagination } = use
|
||||
align: 'center',
|
||||
key: 'index',
|
||||
width: 80,
|
||||
render: (row, index) => index + 1,
|
||||
render: (_row, index) => index + 1,
|
||||
},
|
||||
{
|
||||
key: 'competitionActivityName',
|
||||
@ -102,11 +103,10 @@ const { columns, data, getData, getDataByPage, loading, mobilePagination } = use
|
||||
title: '发布状态',
|
||||
align: 'center',
|
||||
render: (row) => {
|
||||
if (row.status === null)
|
||||
if (row.status === null || row.status === undefined)
|
||||
return null
|
||||
const label = statusMap[row.status] || '未知'
|
||||
// 1: Published (Success/Green), 2: Pending (Warning/Orange)
|
||||
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>
|
||||
const label = statusMap[Number(row.status)] || '未知'
|
||||
return <NTag type={Number(row.status) === RankPublishStatus.Published ? 'success' : 'warning'}>{label}</NTag>
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -115,7 +115,7 @@ const { columns, data, getData, getDataByPage, loading, mobilePagination } = use
|
||||
align: 'center',
|
||||
render: row => (
|
||||
<div class="flex-center">
|
||||
<NButton type="primary" text onClick={() => edit(row.id)}>
|
||||
<NButton type="primary" text onClick={() => edit(row)}>
|
||||
进入排行详情
|
||||
</NButton>
|
||||
</div>
|
||||
@ -128,13 +128,20 @@ const {
|
||||
checkedRowKeys,
|
||||
} = useTableOperate(data, 'id', getData)
|
||||
|
||||
function edit(id: number) {
|
||||
router.push({ name: 'rank_rank-detail', query: { id } })
|
||||
function edit(item: any) {
|
||||
routerPushByKey('rank_rank-detail', {
|
||||
query: {
|
||||
activityId: item.ActivityId,
|
||||
name: item.competitionActivityName,
|
||||
date: item.competitionActivityDate,
|
||||
Status: item.status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleBatchPublish() {
|
||||
window.$message?.success('批量发布成功')
|
||||
}
|
||||
// function handleBatchPublish() {
|
||||
// window.$message?.success('批量发布成功')
|
||||
// }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -151,12 +158,12 @@ function handleBatchPublish() {
|
||||
您可以对所有比赛场次的最终积分进行核对、修正,并正式发布结果。
|
||||
</div>
|
||||
</div>
|
||||
<NButton type="primary" ghost class="ml-auto" @click="handleBatchPublish">
|
||||
<!-- <NButton type="primary" ghost class="ml-auto" @click="handleBatchPublish">
|
||||
<template #icon>
|
||||
<icon-ic-round-upload class="text-icon" />
|
||||
</template>
|
||||
批量发布至终端
|
||||
</NButton>
|
||||
</NButton> -->
|
||||
</div>
|
||||
</template>
|
||||
<!-- <template #header-extra>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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(() => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 }, ...]
|
||||
// 需要根据实际接口返回结构调整映射逻辑
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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" />
|
||||
|
||||
268
apps/admin/src/views/user/rank-list/index.vue
Normal file
268
apps/admin/src/views/user/rank-list/index.vue
Normal 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>
|
||||
0
apps/admin/src/views/user/rank-pending/index.vue
Normal file
0
apps/admin/src/views/user/rank-pending/index.vue
Normal file
Reference in New Issue
Block a user