feat(results): 新增实时结果列表和详情页面,支持评委查看和评分

- 添加 results_results-list 和 results_results-detail 路由及页面
- 实现比赛列表展示,支持跳转到实时结果详情
- 在结果详情页面添加分组和题目切换功能
- 集成游戏统计 API,实时获取队伍答题数据
- 添加 TeamResultCard 和 GroupTabs 可复用组件
- 更新国际化配置和类型定义
This commit is contained in:
2026-02-28 08:46:11 +08:00
parent e70409082a
commit 2b50bd2c71
19 changed files with 870 additions and 102 deletions

View File

@ -255,6 +255,9 @@ const local: App.I18n.Schema = {
'user_rules': 'User Rules',
'user_groups': 'User Groups',
'user_home': 'User Home',
'results_results-list': 'Results List',
'results_results-detail': 'Results Detail',
'test': 'Test',
},
page: {
login: {

View File

@ -251,6 +251,9 @@ const local: App.I18n.Schema = {
'user_rules': '用户规则',
'user_groups': '用户组',
'user_home': '用户首页',
'results_results-list': '实时结果列表',
'results_results-detail': '实时结果详情',
'test': 'Test',
},
page: {
login: {

View File

@ -28,7 +28,9 @@ 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: () => import("@/views/results/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"),
"template_template-list": () => import("@/views/template/template-list/index.vue"),
test: () => import("@/views/test/index.vue"),

View File

@ -179,13 +179,43 @@ export const generatedRoutes: GeneratedRoute[] = [
{
name: 'results',
path: '/results',
component: 'layout.base$view.results',
component: 'layout.base',
meta: {
title: 'results',
i18nKey: 'route.results',
icon: 'mdi:clipboard-check-multiple',
order: 2
}
},
children: [
{
name: 'results_results-detail',
path: '/results/results-detail',
component: 'view.results_results-detail',
meta: {
title: 'results_results-detail',
i18nKey: 'route.results_results-detail',
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'
}
},
{
name: 'results_results-list',
path: '/results/results-list',
component: 'view.results_results-list',
meta: {
title: 'results_results-list',
i18nKey: 'route.results_results-list'
}
}
]
},
{
name: 'template',

View File

@ -179,6 +179,9 @@ const routeMap: RouteMap = {
"rank_rank-detail": "/rank/rank-detail",
"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",
"template_template-list": "/template/template-list",

View File

@ -49,9 +49,9 @@ export function fetchSubmitQuestionResult(data: Api.Competition.QuestionAddParam
}
/** 获取游戏现场统计结果 */
export function fetchGetGameStatistics(data: Api.Competition.QuestionAddParams) {
export function fetchGetGameStatistics(data: { TeamGroupID: number, QuestionID: number, QuestionDetaiID: number }) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/GetTeamQuestionUseByTeamGroupIDAndQuestionID/?RoomID=${data.RoomID}&MainID=${data.MainID}&TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}&QuestionDetaiID=${data.QuestionDetaiID}`,
url: `/Base/ActivityMain/GetTeamQuestionUseByTeamGroupIDAndQuestionID/?TeamGroupID=${data.TeamGroupID}&QuestionID=${data.QuestionID}&QuestionDetaiID=${data.QuestionDetaiID}`,
method: 'post',
data,
})
@ -84,3 +84,14 @@ export function fetchGetQuestionTableByActivityID(ActivityID: string) {
method: 'get',
})
}
/** 根据 roomid查询当前正在比赛的题目 */
export function fetchGetCurrentQuestionByRoomID(RoomID: number) {
return request<App.Service.Response<Api.Competition.CurrentQuestionResponse>>({
url: `/Base/ActivityMain/SelectTask?RoomID=${RoomID}`,
method: 'post',
data: {
RoomID,
},
})
}

View File

@ -67,6 +67,16 @@ declare namespace Api {
Finished = 3,
}
interface CompetitionItem {
Id: number
Name: string
StartTime: string
EndTime: string
TeamGroupNumber: number
PublishStatus: number
RoomID: number
}
/** room */
interface Room {
/** room id */

View File

@ -33,6 +33,9 @@ declare module "@elegant-router/types" {
"rank_rank-detail": "/rank/rank-detail";
"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";
"template_template-list": "/template/template-list";
@ -120,7 +123,9 @@ declare module "@elegant-router/types" {
| "question-store"
| "rank_rank-detail"
| "rank_rank-list"
| "results"
| "results_results-detail-copy"
| "results_results-detail"
| "results_results-list"
| "template_template-detail"
| "template_template-list"
| "test"

View File

@ -150,14 +150,14 @@ async function submitForm() {
activityID: 0,
questionID: question.QuestionID,
questionIndex: index,
actitvityQuestionName: question.ActitvityQuestionName,
actitvityQuestionName: question.ActitvityQuestionName || '',
questionTime: question.QuestionTime,
questionRule: question.QuestionRule,
uiType: question.UIType,
point: question.Point,
templateID: question.TemplateID,
roundType: round.roundType,
questionSubTitle: question.ActitvityQuestionName,
questionSubTitle: question.ActitvityQuestionName || '',
})
})
})

View File

@ -1,25 +1,19 @@
<script setup lang="ts">
// import type { CompetitionItem } from './modules/data'
import { NButton, NGi, NGrid, NSpace } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchDeleteActivity, fetchGetActivityList } from '@/service/api/competition'
import { useAppStore } from '@/store/modules/app'
import CompetitionAddModal from '../competition-add/index.vue'
import CompetitionCard from './modules/competition-card.vue'
export interface CompetitionItem {
Id: number
Name: string
StartTime: string
EndTime: string
TeamGroupNumber: number
PublishStatus: number
}
const { routerPushByKey } = useRouterPush()
const appStore = useAppStore()
const gap = computed(() => (appStore.isMobile ? 12 : 26))
const competitionListRef = ref<CompetitionItem[]>([])
const competitionListRef = ref<Api.Competition.CompetitionItem[]>([])
const showAddModal = ref(false)
@ -27,12 +21,12 @@ function handleAdd() {
showAddModal.value = true
}
function handleCopy(item: CompetitionItem) {
function handleCopy(item: Api.Competition.CompetitionItem) {
// TODO: 实现复制逻辑
window.$message?.success(`已复制活动:${item.Name}`)
}
function handleDelete(item: CompetitionItem) {
function handleDelete(item: Api.Competition.CompetitionItem) {
const d = window.$dialog?.warning({
title: '确认删除',
content: `确定删除活动:${item.Name}吗?`,
@ -82,6 +76,10 @@ async function fetchActivityList() {
}
}
function toDetail(item: Api.Competition.CompetitionItem) {
routerPushByKey('competition_competition-detail', { query: { Id: item.Id.toString() } })
}
onMounted(() => fetchActivityList())
</script>
@ -109,7 +107,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">
<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" />
</NGi>
</NGrid>

View File

@ -1,24 +1,21 @@
<script setup lang="ts">
import type { CompetitionItem } from '../index.vue'
import dayjs from 'dayjs'
import { NButton, NCard, NTag, NTooltip } from 'naive-ui'
import { computed } from 'vue'
import { PublishStatus } from '@/enum/business'
import { useDict } from '@/hooks/business/useDict'
import { useRouterPush } from '@/hooks/common/router'
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'copy', item: CompetitionItem): void
(e: 'delete', item: CompetitionItem): void
(e: 'copy', item: Api.Competition.CompetitionItem): void
(e: 'delete', item: Api.Competition.CompetitionItem): void
}>()
const { options: publishStatusOptions } = useDict('publish_status')
const { routerPushByKey } = useRouterPush()
interface Props {
item: CompetitionItem
item: Api.Competition.CompetitionItem
}
// 状态配置:颜色和文本
@ -43,10 +40,6 @@ const statusConfig = computed(() => {
// 处理标题换行
// const formattedTitle = computed(() => props.item.title.replace(/\n/g, '<br/>'))
function toDetail(item: CompetitionItem) {
routerPushByKey('competition_competition-detail', { query: { Id: item.Id.toString() } })
}
// 随机背景色池
const bgColors = [
'bg-blue-50/80',
@ -78,7 +71,7 @@ const decorationBgClass = computed(() => {
<NCard
:bordered="false"
class="group relative h-full overflow-hidden border border-transparent rounded-2xl transition-all duration-300 hover:scale-[1.02] hover:border-blue-500 hover:shadow-xl hover:-translate-y-2"
content-style="padding: 34px; display: flex; flex-direction: column; height: 100%;" @click="toDetail(item)"
content-style="padding: 34px; display: flex; flex-direction: column; height: 100%;"
>
<!-- 特殊背景装饰 (所有卡片显示随机颜色) -->
<div
@ -138,7 +131,7 @@ const decorationBgClass = computed(() => {
<!-- 日期 -->
<div class="mb-6 flex items-center text-gray-400">
<icon-ic-baseline-calendar-month class="mr-2 text-xl" />
<span class="text-base font-bold">{{ item.StartTime }} {{ item.EndTime }}</span>
<span class="text-base font-bold">{{ dayjs(item.StartTime).format('YYYY-MM-DD HH:mm') }} {{ dayjs(item.EndTime).format('YYYY-MM-DD HH:mm') }}</span>
</div>
</div>

View File

@ -0,0 +1,491 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
import { NButton, NCard, NImage, useMessage } from 'naive-ui'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { QuestionCategoryEnum } from '@/enum/business'
import { fetchGetQuestionList } from '@/service/api/competition'
import { fetchGetCurrentQuestionByRoomID, fetchGetGameStatistics, fetchGetGroupListByActivityID } from '@/service/api/game'
import GroupTabs, { type GroupItem } from './modules/GroupTabs.vue'
const route = useRoute()
const message = useMessage()
const activityId = computed(() => route.query?.activityId as string)
const roomId = computed(() => route.query?.roomId as string)
const currentGroupId = ref<number | string>('') // 当前选中的组别 ID
const currentQuestionId = ref<number | string>('') // 当前选中的题目 ID
const currentQuestionType = ref<string>('') // 当前选中的题目类型
const groups = ref<GroupItem[]>([])
const questionList = ref<(GroupItem & { type: string })[]>([])
const currentQuestion = ref<any>() // 当前正在参赛的题目
// 计算当前题目类型对应的布局
const currentLayout = computed(() => {
if (!currentQuestionType.value)
return 'list' // 默认列表
// 汉字听写、汉字加一加、选择题 -> Grid (多个小方块)
const gridTypes = [
QuestionCategoryEnum.ChineseCharacterDictation,
QuestionCategoryEnum.CharacterRadicalAddition,
QuestionCategoryEnum.PoetryComprehension,
]
return gridTypes.includes(currentQuestionType.value as QuestionCategoryEnum) ? 'grid' : 'list'
})
interface AnswerItem {
id: number
label?: string
imageUrl: string
status: 'correct' | 'wrong'
TeamQuestionUseID: number
Guid: string
}
interface TeamAnswerData {
Id: number
MainId: number
TeamGroupId: number
TeamId: number
QuestionId: number
QuestionDetailId: number | null
UserAnswerPicture: string
UserAnswerFont: string // JSON string
TeamName: string
Points: number
ResultPotins: number
Status: number
}
interface TeamResult {
TeamName: string
UserAnswerPicture: string
Answers: AnswerItem[]
Points: number
TeamId: number
}
// 当前所有队伍的解析结果列表
const teamResults = ref<TeamResult[]>([])
// 切换状态逻辑:点击后 Correct <-> Wrong
function toggleStatus(team: TeamResult, item: AnswerItem) {
item.status = item.status === 'correct' ? 'wrong' : 'correct'
}
function handleNext() {
// “下一步”按钮的逻辑
// 简单示例:切换到下一题
const currentIndex = questionList.value.findIndex(q => q.id === currentQuestionId.value)
if (currentIndex !== -1 && currentIndex < questionList.value.length - 1) {
currentQuestionId.value = questionList.value[currentIndex + 1].id
}
else {
message.info('已经是最后一题了')
}
}
function handlePublish() {
// 发布结果逻辑
message.success('结果已发布')
}
// Mock 数据生成器
// eslint-disable-next-line unused-imports/no-unused-vars
function generateMockData() {
// Mock 分组
groups.value = Array.from({ length: 5 }).map((_, i) => ({
id: i + 1,
name: `${i + 1}`,
}))
if (groups.value.length > 0) {
currentGroupId.value = groups.value[0].id
}
// Mock 题目
// 1. 汉字听写 (Grid)
// 2. 汉字加一加 (Grid)
// 3. 诗词理解/选择题 (Grid)
// 4. 词语听写 (Long)
// 5. 成语写一写 (Long)
const mockTypes = [
QuestionCategoryEnum.ChineseCharacterDictation,
QuestionCategoryEnum.CharacterRadicalAddition,
QuestionCategoryEnum.PoetryComprehension,
QuestionCategoryEnum.WordDictation,
QuestionCategoryEnum.IdiomWriting,
]
// 生成 10 个题目,覆盖所有类型
questionList.value = Array.from({ length: 10 }).map((_, i) => ({
id: i + 1,
name: `题目 ${i + 1}`,
type: mockTypes[i % mockTypes.length],
}))
if (questionList.value.length > 0) {
currentQuestionId.value = questionList.value[0].id
currentQuestionType.value = questionList.value[0].type
}
// Mock 答题卡数据
teamResults.value = Array.from({ length: 4 }).map((_, i) => {
// 根据当前题目类型决定答案数量
// 词语听写(WordDictation)、成语(IdiomWriting) 通常是单个答案块(包含多个字),整体判分
// 汉字听写(ChineseCharacterDictation)、汉字加一加(CharacterRadicalAddition)、诗词(PoetryComprehension) 通常是多个答案块,单独判分
let answerCount = 5
if ([QuestionCategoryEnum.WordDictation, QuestionCategoryEnum.IdiomWriting].includes(currentQuestionType.value as QuestionCategoryEnum)) {
answerCount = 1
}
return {
TeamId: i + 1,
TeamName: `Mock 战队 ${i + 1}`,
UserAnswerPicture: 'https://placehold.co/800x400?text=Original+Answer+Sheet',
Points: 0,
Answers: Array.from({ length: answerCount }).map((__, j) => ({
id: j + 1,
label: answerCount > 1 ? `${j + 1}.` : '', // 只有多个答案时才显示序号
// Grid 类型用方形图List 类型用长方形图
imageUrl: answerCount > 1
? `https://placehold.co/100x100?text=Answer+${j + 1}`
: `https://placehold.co/400x100?text=Long+Answer`,
status: Math.random() > 0.5 ? 'correct' : 'wrong',
TeamQuestionUseID: 123,
Guid: `mock-guid-${j}`,
})),
}
})
}
/**
* 获取分组列表
*/
async function fetchGroups() {
if (!activityId.value) {
window.$message?.error('请先选择比赛')
return
}
try {
const { data, error } = await fetchGetGroupListByActivityID(activityId.value)
if (error) {
window.$message?.error('获取题目失败')
return
}
const list = data?.data || []
groups.value = list.map((item: any) => ({
id: item.Id,
name: item.Name,
}))
// 默认选中第一个分组
if (groups.value.length > 0 && !currentGroupId.value) {
currentGroupId.value = groups.value[0].id
}
}
catch (error: any) {
window.$message?.error(error.message || '获取分组失败')
}
}
/**
* 获取题目列表
*/
async function fetchQuestions() {
if (!activityId.value)
return
try {
const { data, error } = await fetchGetQuestionList(activityId.value)
if (error) {
message.error(error.message)
return
}
const list = data?.data || []
questionList.value = list.map((item: any) => ({
id: item.ID, // 注意大小写,根据接口返回调整
name: item.Name || `题目 ${item.ID}`,
type: item.Type || '', // 假设接口返回 Type
}))
// 默认选中第一个题目
if (questionList.value.length > 0 && !currentQuestionId.value) {
currentQuestionId.value = questionList.value[0].id
currentQuestionType.value = questionList.value[0].type
}
}
catch (error: any) {
message.error(error.message || '获取题目失败')
}
}
/**
* 获取当前正在比赛的题目
*/
async function fetchCurrentQuestion() {
if (!roomId.value)
return
try {
const { data, error } = await fetchGetCurrentQuestionByRoomID(Number(roomId.value))
if (error) {
message.error(error.message)
return
}
currentQuestion.value = data?.data || {}
}
catch (error: any) {
message.error(error.message || '获取当前题目失败')
}
}
/**
* 获取答题卡数据
*/
async function fetchAnswerData() {
if (!currentGroupId.value || !currentQuestionId.value)
return
// 如果是 Mock 环境,重新生成一下随机答案
if (!activityId.value) {
// 复用 generateMockData 中的逻辑来更新 teamResults
// 但这里只需要更新 results 部分,不需要重置 group 和 questionList
teamResults.value = Array.from({ length: 4 }).map((_, i) => {
let answerCount = 5
// Grid 类型 (ChineseCharacterDictation, CharacterRadicalAddition, PoetryComprehension) -> 多图
// List 类型 (WordDictation, IdiomWriting) -> 单图
const isListType = [QuestionCategoryEnum.WordDictation, QuestionCategoryEnum.IdiomWriting].includes(currentQuestionType.value as QuestionCategoryEnum)
if (isListType) {
answerCount = 1
}
return {
TeamId: i + 1,
TeamName: `Mock 战队 ${i + 1}`,
UserAnswerPicture: 'https://placehold.co/800x400?text=Original+Answer+Sheet',
Points: 0,
Answers: Array.from({ length: answerCount }).map((__, j) => ({
id: j + 1,
label: answerCount > 1 ? `${j + 1}.` : '',
imageUrl: answerCount > 1
? `https://placehold.co/100x100?text=Answer+${j + 1}`
: `https://placehold.co/400x100?text=Long+Answer`,
status: Math.random() > 0.5 ? 'correct' : 'wrong',
TeamQuestionUseID: 123,
Guid: `mock-guid-${j}`,
})),
}
})
return
}
try {
// 这里假设 fetchGetGameStatistics 需要的参数是 TeamGroupID, QuestionID, QuestionDetaiID
// 由于接口定义中 QuestionDetaiID 是必须的,但根据需求描述可能只知道 QuestionId
// 这里暂时传 0 或者从 questionList 中获取更多信息如果可用
const params = {
TeamGroupID: Number(currentGroupId.value),
QuestionID: Number(currentQuestionId.value),
QuestionDetaiID: 0, // 暂时传0如果有详情ID需补充
}
const { data, error } = await fetchGetGameStatistics(params)
if (error) {
message.error(error.message)
return
}
// 假设返回的数据结构是数组,取第一条或者根据逻辑取特定的一条
// 根据需求描述,返回的是一个对象结构
const result = data?.data as unknown as TeamAnswerData[]
// 注意fetchGetGameStatistics 返回类型是 App.Service.Responsedata.data 类型未知,这里强制转换以便演示
// 实际接口可能返回数组,取第一个有数据的
if (result && result.length > 0) {
teamResults.value = result.map((answerData) => {
let parsedAnswers: AnswerItem[] = []
try {
const parsedFonts = JSON.parse(answerData.UserAnswerFont)
parsedAnswers = parsedFonts.map((item: any, index: number) => ({
id: index + 1,
label: `${index + 1}.`,
imageUrl: item.AnswerValuePicture, // 注意去除可能存在的反斜杠转义
status: item.Status === 1 ? 'correct' : 'wrong', // 假设 1 是正确0 是错误,根据实际调整
TeamQuestionUseID: item.TeamQuestionUseID,
Guid: item.Guid,
}))
}
catch (e) {
console.error('解析 UserAnswerFont 失败', e)
}
return {
TeamId: answerData.TeamId,
TeamName: answerData.TeamName,
UserAnswerPicture: answerData.UserAnswerPicture,
Points: answerData.Points,
Answers: parsedAnswers,
}
})
}
else {
teamResults.value = []
message.info('暂无答题数据')
}
}
catch (error: any) {
message.error(error.message || '获取答题数据失败')
}
}
// 监听分组和题目的变化,重新获取答题数据
watch([currentGroupId, currentQuestionId], () => {
if (currentGroupId.value && currentQuestionId.value) {
const question = questionList.value.find(q => q.id === currentQuestionId.value)
if (question) {
currentQuestionType.value = question.type
}
fetchAnswerData()
}
})
onMounted(async () => {
await fetchCurrentQuestion()
await fetchGroups()
await fetchQuestions()
// generateMockData()
})
</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
v-model:group-id="currentGroupId" v-model:question-id="currentQuestionId" :groups="groups"
:question-list="questionList"
/>
</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">
当前进度 {{ questionList.findIndex(q => q.id === currentQuestionId) + 1 }}/{{ questionList.length }}
</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-auto">
<div v-if="teamResults.length === 0" class="h-full flex items-center justify-center text-gray-400">
暂无队伍数据
</div>
<div v-else class="flex flex-col gap-6 pb-6">
<NCard
v-for="team in teamResults" :key="team.TeamId" :title="team.TeamName ? `${team.TeamName}` : '队伍信息'"
hoverable
>
<div class="flex flex-row items-start gap-6">
<!-- 学生答题原图 -->
<div class="UserAnswerPicture flex-shrink-0">
<NImage
v-if="team.UserAnswerPicture" :src="team.UserAnswerPicture" width="150" object-fit="contain"
class="block border border-gray-200 rounded-lg"
/>
<div
v-else
class="h-[150px] w-[150px] flex items-center justify-center rounded-lg bg-gray-100 p-2 text-center text-xs text-gray-400"
>
暂无原图
</div>
</div>
<!-- 解析答题里面的题目 -->
<div class="UserAnswerFont flex-1">
<div class="flex flex-wrap gap-x-8 gap-y-4">
<!-- Grid Layout -->
<div v-if="currentLayout === 'grid'" class="flex flex-wrap gap-4">
<div
v-for="item in team.Answers" :key="item.id"
class="relative cursor-pointer transition-all duration-200" @click="toggleStatus(team, item)"
>
<!-- Image Container with Status Border -->
<div
class="relative overflow-hidden border-2 rounded-md border-dashed bg-white p-1" :class="[
item.status === 'correct' ? 'border-green-500' : 'border-red-500',
]"
>
<NImage :src="item.imageUrl" preview-disabled object-fit="contain" class="h-24 w-24" />
<!-- Status Icon Overlay -->
<div class="absolute z-10 rounded-full bg-white -right-3 -top-3">
<Icon
v-if="item.status === 'correct'" icon="mdi:check-circle"
class="text-2xl text-green-500"
/>
<Icon v-else icon="mdi:close-circle" class="text-2xl text-red-500" />
</div>
</div>
<!-- Label if exists -->
<div v-if="item.label" class="mt-1 text-center text-sm text-gray-500">
{{ item.label }}
</div>
</div>
</div>
<!-- List Layout (Long Rectangle for Words/Idioms) -->
<div v-else class="flex flex-wrap gap-x-8 gap-y-4">
<div
v-for="item in team.Answers" :key="item.id"
class="relative cursor-pointer transition-all duration-200" @click="toggleStatus(team, item)"
>
<div class="flex items-center gap-2">
<span v-if="item.label" class="w-6 text-gray-600 font-medium">{{ item.label }}</span>
<div
class="relative overflow-hidden border-2 rounded-md border-dashed bg-white px-2 py-1" :class="[
item.status === 'correct' ? 'border-green-500' : 'border-red-500',
]"
>
<NImage :src="item.imageUrl" preview-disabled object-fit="contain" height="50" />
<!-- Status Icon Overlay -->
<div class="absolute z-10 rounded-full bg-white shadow-sm -right-3 -top-3">
<Icon
v-if="item.status === 'correct'" icon="mdi:check-circle"
class="text-2xl text-green-500"
/>
<Icon v-else icon="mdi:close-circle" class="text-2xl text-red-500" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</NCard>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.n-card__content) {
padding: 0;
}
</style>

View File

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

View File

@ -0,0 +1,114 @@
<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

@ -0,0 +1,64 @@
<script setup lang="ts">
import { NGi, NGrid, NSpace } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { PublishStatus } from '@/enum/business'
import { useRouterPush } from '@/hooks/common/router'
import { fetchGetActivityList } from '@/service/api/competition'
import { useAppStore } from '@/store/modules/app'
import CompetitionCard from '@/views/competition/competition-list/modules/competition-card.vue'
const { routerPushByKey } = useRouterPush()
const appStore = useAppStore()
const gap = computed(() => (appStore.isMobile ? 12 : 26))
const competitionListRef = ref<Api.Competition.CompetitionItem[]>([])
/** 获取活动列表 */
async function fetchActivityList() {
try {
const { response, error } = await fetchGetActivityList()
if (!error) {
const list = response?.data?.data || []
competitionListRef.value = list.filter((item: any) => item.PublishStatus === PublishStatus.Processing || item.PublishStatus === PublishStatus.Published)
}
}
catch (e) {
console.error(e)
}
}
function toDetail(item: Api.Competition.CompetitionItem) {
routerPushByKey('results_results-detail', { query: { activityId: item.Id.toString(), roomId: item.RoomID.toString() } })
}
onMounted(() => fetchActivityList())
</script>
<template>
<NSpace vertical :size="24">
<!-- 顶部 Header 区域 -->
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h2 class="m-0 text-xl text-gray-800 font-bold dark:text-white">
比赛列表
</h2>
<p class="mt-1 text-sm text-gray-500">
评委将对正在进行的比赛进行打分
</p>
</div>
</div>
<!-- 卡片列表区域 -->
<!-- 响应式布局手机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" />
</NGi>
</NGrid>
</NSpace>
</template>
<style scoped>
/* 针对不同屏幕微调间距 */
</style>

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import { NImage } from 'naive-ui'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchCheckNextStep, fetchGetCurrentQuestion } from '@/service/api/game'
import { fetchCheckNextStep, fetchGetCurrentQuestion, fetchGetGameStatistics } from '@/service/api/game'
import { useCompetitionStore } from '@/store/modules/competition'
const store = useCompetitionStore()
@ -15,56 +16,17 @@ const groupsId = computed(() => route.query?.groupsId as string)
const teamId = computed(() => route.query?.teamId as string)
const teamIds = computed(() => route.query?.teamIds as string)
const currentQuestionMainInfo = computed(() => store.currentQuestionMainInfo)
const currentQuestionDetail = computed(() => store.currentQuestionDetail)
const TeamGroup_QuestionID = computed(() => currentQuestionMainInfo.value?.TeamGroup_QuestionID || 0)
const QuestionID = computed(() => currentQuestionMainInfo.value?.Activity_Question.ID || 0)
const QuestionDetaiID = computed(() => currentQuestionDetail.value?.Id || 0)
const zoomedImage = ref<string | null>(null)
const isCompleted = ref(false)
const showNextBtn = ref(false)
// Mock team data
const teams = ref([
{
id: 1,
name: '第一队',
writtenCount: 16,
correctCount: 13,
score: 13,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+1+Answer',
},
{
id: 2,
name: '第二队',
writtenCount: 15,
correctCount: 12,
score: 12,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+2+Answer',
},
{
id: 3,
name: '第三队',
writtenCount: 18,
correctCount: 15,
score: 15,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+3+Answer',
},
{
id: 4,
name: '第四队',
writtenCount: 14,
correctCount: 10,
score: 10,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+4+Answer',
},
])
function handleZoom(img: string) {
zoomedImage.value = img
}
const teams = ref<any[]>([])
function handleBack() {
routerPushByKey('user_game', {
@ -127,6 +89,7 @@ async function initQuestions() {
}
const timer = ref<ReturnType<typeof setInterval> | undefined>(undefined)
const statisticsTimer = ref<ReturnType<typeof setInterval> | undefined>(undefined)
/** 定时刷新 检查评委是否已经完成打分,是否可以下一步 fetchCheckNextStep */
async function checkNextStep() {
@ -153,12 +116,34 @@ async function checkNextStep() {
}
}
/** 获取游戏现场统计结果 */
async function fetchGetGameStatisticsData() {
if (store.currentQuestionInfo && store.currentQuestionDetail) {
try {
const _params = {
TeamGroupID: Number(groupsId.value),
QuestionID: Number(QuestionID.value),
QuestionDetaiID: Number(QuestionDetaiID.value),
}
const { data } = await fetchGetGameStatistics(_params)
teams.value = (data?.data as any[]) || []
}
catch (error: any) {
console.error('Failed to fetch game statistics', error)
window.$message?.error(error.message || '获取游戏现场统计结果失败')
}
}
}
// 定时刷新 检查是否可以下一步
timer.value = setInterval(checkNextStep, 2000)
// 定时刷新 游戏统计数据
statisticsTimer.value = setInterval(fetchGetGameStatisticsData, 2000)
/** 销毁 定时刷新 检查是否可以下一步 */
onBeforeUnmount(() => {
clearInterval(timer.value)
clearInterval(statisticsTimer.value)
})
</script>
@ -172,7 +157,7 @@ onBeforeUnmount(() => {
<!-- Content -->
<div class="w-full flex flex-col flex-1 overflow-hidden">
<div class="mb-2 text-xl text-red-600 font-bold">
备注由AI模型评判
备注评委正在评分中请等待
</div>
<div v-if="teams && teams.length > 0" class="grid grid-cols-4 h-full gap-6 pb-8">
@ -180,43 +165,37 @@ onBeforeUnmount(() => {
v-for="team in teams" :key="team.id"
class="flex flex-col border-2 border-gray-200 rounded-xl bg-white/90 p-4 shadow-sm"
>
<div class="mb-2 text-xl text-gray-800 font-bold">
{{ team.name }}
<div
class="mb-3 flex items-center gap-2 rounded-full from-sky-300 to-cyan-200 bg-gradient-to-r px-4 py-2 shadow-sm"
>
<span class="text-lg text-white font-bold drop-shadow">{{ team.TeamName }}</span>
<span class="ml-2 rounded-full bg-white/40 px-2 py-0.5 text-sm">正确 <span class="text-md text-blue">{{
team.correctCount }}</span> </span>
<span class="text-s rounded-full bg-white/40 px-2 py-0.5">积分 <span class="text-md text-blue">{{ team.Points
}}</span></span>
</div>
<!-- Answer Image Area -->
<div
class="relative mb-4 flex-1 cursor-zoom-in overflow-hidden border border-gray-300 rounded bg-gray-50"
@click="handleZoom(team.image)"
class="relative mb-4 flex-1 overflow-hidden border border-gray-300 rounded bg-gray-50"
>
<!-- Grid Background Simulation -->
<div class="pointer-events-none absolute inset-0 grid grid-cols-8 gap-px bg-gray-200 opacity-20">
<div v-for="n in 64" :key="n" class="bg-white" />
</div>
<!-- Simulated Handwriting/Content -->
<div class="absolute inset-0 flex items-center justify-center text-gray-400">
(点击放大查看详情)
<!-- <div v-for="n in 64" :key="n" class="bg-white" /> -->
</div>
<NImage
:src="team.UserAnswerPicture"
class="h-full w-full"
object-fit="contain"
/>
</div>
<!-- Stats & Score -->
<div class="flex flex-col gap-2">
<!-- <div class="flex flex-col gap-2">
<div class="text-sm text-gray-700 font-medium">
写了数量{{ team.writtenCount }}正确{{ team.correctCount }}积分{{ team.score }}
</div>
<div class="flex items-center justify-between">
<span class="text-red-500 font-bold">评委当场评</span>
</div>
<div class="relative">
<input
v-model="team.manualScore" type="number"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
placeholder="留个输入框,修改正确积分"
>
</div>
</div>
</div> -->
</div>
</div>
</div>