feat: 添加排行榜分数编辑和题目详情展示功能

- 在排行榜详情页中,添加队伍题目分数编辑功能,支持批量保存
- 新增打字机组件用于题目答案的动态展示
- 优化游戏页面的答题数量统计逻辑,使用 UserAnswerFont 解析
- 更新题目导入工具,支持更多 JSON 格式
- 调整游戏倒计时逻辑,优先使用题目详情中的时间
- 移除排行榜中的部分冗余列,优化界面显示
This commit is contained in:
2026-03-05 18:08:17 +08:00
parent ed3c815cd7
commit e8cd6732d7
29 changed files with 1879 additions and 1238 deletions

View File

@ -0,0 +1,45 @@
<script lang="ts" setup>
import type { Options } from 'typeit'
import type { El } from 'typeit/dist/types'
import TypeIt from 'typeit'
import { onMounted, shallowRef } from 'vue'
const textRef = shallowRef<El>()
function init() {
if (!textRef.value)
return
const options: Options = {
strings: 'SoybeanAdmin是一个清新优雅、高颜值且功能强大的后台管理模板',
lifeLike: true,
speed: 120,
loop: true,
}
const initTypeIt = new TypeIt(textRef.value, options)
initTypeIt.go()
}
onMounted(() => {
init()
})
</script>
<template>
<div>
<NCard title="打字机 插件" :bordered="false" class="h-full card-wrapper">
<NSpace :vertical="true">
<GithubLink link="https://github.com/alexmacarthur/typeit" />
<WebSiteLink label="文档地址:" link="https://www.typeitjs.com/docs/vanilla/usage/" />
</NSpace>
<NDivider title-placement="left">
基本示例
</NDivider>
<span ref="textRef" class="text-18px" />
</NCard>
</div>
</template>
<style scoped></style>

View File

@ -95,3 +95,11 @@ export function fetchGetCurrentQuestionByRoomID(RoomID: number) {
},
})
}
/** 根据QuestionDetaiID查询题目详情 */
export function fetchGetQuestionDetailByID(QuestionDetailID: number) {
return request<App.Service.Response<Api.Competition.QuestionListDetailRound>>({
url: `/Base/ActivityMain/GetQuestionListDetailByID/?QuestionID=${QuestionDetailID}`,
method: 'get',
})
}

View File

@ -1,10 +1,25 @@
import { request } from '../request'
/** get user list */
export function fetchRankList(data?: Api.Rank.UserSearchParams) {
export function fetchRankList(MainID: number, RoundType = 0) {
return request<Api.Rank.CommonRecord>({
url: '/admin/v1/class/pagelist',
method: 'post',
data: data || {},
url: `/Base/ActivityMain/GetTeamsTotal/list?MainID=${MainID}&RoundType=${RoundType}`,
method: 'get',
})
}
/** 获取队伍排名详情 */
export function fetchRankDetail(MainID: number, RoundType = 0) {
return request<Api.Rank.CommonRecord>({
url: `/Base/ActivityMain/GetTeamsTotal?MainID=${MainID}&RoundType=${RoundType}`,
method: 'get',
})
}
/** 根据队伍 id获取具体的题目分数 */
export function fetchTeamQuestionScore(TeamID: number) {
return request<Api.Rank.CommonRecord>({
url: `/Base/ActivityMain/GetTeamQuestionUseByTeamID?TeamID=${TeamID}`,
method: 'get',
})
}

View File

@ -10,19 +10,10 @@ export function updateCurrentQuestionUse(TeamGroupID: number) {
}
/** 评委对其进行打分 */
export function updateScore(Id: number, ResultPotins: number) {
export function updateScore(data: { Id: number, ResultPotins: number, QuestionDetailID: number, QuestionId: number }[]) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateTeamQuestionUseOne?Id=${Id}&ResultPotins=${ResultPotins}`,
url: `/Base/ActivityMain/UpdateTeamQuestionResultList`,
method: 'post',
data: { Id, ResultPotins },
})
}
/** 评委更新状态 (Correct/Wrong) */
export function updateScoreStatus(Id: number, Status: number) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateTeamQuestionUseOneStatus?Id=${Id}&Status=${Status}`,
method: 'post',
data: { Id, Status },
data,
})
}

View File

@ -105,6 +105,7 @@ declare namespace Api {
IsGood: number
Image: string
ImageUrl: string
Answer: string
}
/** get current question params */
@ -203,6 +204,7 @@ declare namespace Api {
Point: number
/** 题目详情 id */
QuestionDetailID: number
QuestionDetaiID: number
/** 题目 id */
QuestionID: number
/** 题目规则 */
@ -219,6 +221,8 @@ declare namespace Api {
TeamList: any[]
/** 宽度 */
Width: number
ActivityID: number
Answer: string
}
/** update room request */
@ -299,6 +303,10 @@ declare namespace Api {
Points: number
ResultPotins: number
Status: number
// 兼容字段(用于兜底)
AnswerValuePicture?: string
Guid?: string
QuestionDetaiID: number | null
}
interface QuestionListRecord {

View File

@ -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: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']
IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
@ -48,6 +49,7 @@ declare module 'vue' {
IconIcBaselineSettingsBackupRestore: typeof import('~icons/ic/baseline-settings-backup-restore')['default']
IconIcBaselineUpload: typeof import('~icons/ic/baseline-upload')['default']
IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
IconIcCarbonUserAvatarFilledAlt: typeof import('~icons/ic/carbon-user-avatar-filled-alt')['default']
IconIcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
IconIcOutlineAddToPhotos: typeof import('~icons/ic/outline-add-to-photos')['default']
IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
@ -174,6 +176,7 @@ declare module 'vue' {
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
TianZiGe: typeof import('./../components/custom/user/TianZiGe.vue')['default']
Typeit: typeof import('./../components/custom/typeit/index.vue')['default']
UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
WangEditor: typeof import('./../components/common/wang-editor.vue')['default']
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
@ -198,6 +201,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: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']
const IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
@ -218,6 +222,7 @@ declare global {
const IconIcBaselineSettingsBackupRestore: typeof import('~icons/ic/baseline-settings-backup-restore')['default']
const IconIcBaselineUpload: typeof import('~icons/ic/baseline-upload')['default']
const IconIcBaselineUploadFile: typeof import('~icons/ic/baseline-upload-file')['default']
const IconIcCarbonUserAvatarFilledAlt: typeof import('~icons/ic/carbon-user-avatar-filled-alt')['default']
const IconIcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
const IconIcOutlineAddToPhotos: typeof import('~icons/ic/outline-add-to-photos')['default']
const IconIcOutlineArchive: typeof import('~icons/ic/outline-archive')['default']
@ -344,6 +349,7 @@ declare global {
const TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
const ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
const TianZiGe: typeof import('./../components/custom/user/TianZiGe.vue')['default']
const Typeit: typeof import('./../components/custom/typeit/index.vue')['default']
const UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
const WangEditor: typeof import('./../components/common/wang-editor.vue')['default']
const WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']

View File

@ -61,7 +61,7 @@ watch(() => props.visible, (val) => {
const columns: DataTableColumns<any> = [
{ title: 'ID', key: 'ID', width: 80 },
{ title: '键 (Key)', key: 'UI_Key' },
{ title: '值 (Value)', key: 'UI_Value' },
{ title: '值 (Value)', key: 'UI_Value', width: 100 },
{ title: '备注', key: 'BakValue' },
{
title: '操作',

View File

@ -1,14 +1,22 @@
<script setup lang="tsx">
import { NCard, NDivider } from 'naive-ui'
import { ref } from 'vue'
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 RankHeader from './modules/rank-header.vue'
import TeamItem from './modules/team-item.vue'
import TeamListHeader from './modules/team-list-header.vue'
const competitionInfo = {
const route = useRoute()
const message = useMessage()
const activityId = route.query.activityId as string
const competitionInfo = ref({
name: '阅读之星-辞海遨游环节 (2026)',
date: '2026.01.12',
}
})
interface TeamData {
id: number
@ -19,88 +27,138 @@ interface TeamData {
totalScore: number
updateTime: string
isExpanded: boolean
scores: Record<string, number>
scores: Record<string, { score: number, questionId: number, teamQuestionUseId: number, questionDetailId: number }>
}
const teamList = ref<TeamData[]>([
{
id: 1,
rank: 1,
name: '清华学霸团',
group: '第一组',
correctCount: '15 / 15',
totalScore: 150,
updateTime: '2026-01-22 16:28:55',
isExpanded: true,
scores: {
q1: 10,
q2: 10,
q3: 10,
q4: 10,
q5: 10,
q6: 10,
q7: 10,
q8: 5,
q9: 10,
q10: 10,
q11: 15,
q12: 10,
q13: 15,
q14: 10,
q15: 5,
},
},
{
id: 2,
rank: 2,
name: '无敌先锋队',
group: '第一组',
correctCount: '13 / 15',
totalScore: 145,
updateTime: '2026-01-22 16:30:12',
isExpanded: false,
scores: {},
},
{
id: 3,
rank: 3,
name: '明日之星社',
group: '第二组',
correctCount: '13 / 15',
totalScore: 130,
updateTime: '2026-01-22 16:25:22',
isExpanded: false,
scores: {},
},
{
id: 4,
rank: 4,
name: '火箭竞技团',
group: '第一组',
correctCount: '13 / 15',
totalScore: 125,
updateTime: '2026-01-22 16:22:10',
isExpanded: false,
scores: {},
},
])
const teamList = ref<TeamData[]>([])
function toggleExpand(team: TeamData) {
// Collapse others if needed, or allow multiple. Screenshot shows one.
// Let's toggle.
team.isExpanded = !team.isExpanded
// If expanding, maybe populate scores if empty (mock logic)
if (team.isExpanded && Object.keys(team.scores).length === 0) {
for (let i = 1; i <= 15; i++) {
team.scores[`q${i}`] = 10
/** 获取排行榜详情 */
async function getRankDetail() {
if (!activityId)
return
try {
const { data, error } = await fetchRankDetail(Number(activityId))
if (error) {
message.error(error.message)
return
}
// 假设后端返回的数据结构需要映射
teamList.value = (data?.data || []).map((item: any, index: number) => ({
id: item.TeamID,
rank: index + 1,
name: item.Name,
group: '默认组', // 接口没有组名,暂时用默认
correctCount: '0 / 0',
totalScore: item.Point || 0,
updateTime: new Date().toLocaleString(), // 暂时用当前时间
isExpanded: false,
scores: {},
}))
}
catch (err: any) {
message.error(err.message || '获取排行榜详情失败')
}
}
function saveScores(team: TeamData) {
window.$message?.success(`已保存 ${team.name} 的分数变动`)
team.isExpanded = false
/** 获取队伍具体的题目分数 */
async function getTeamQuestionScore(team: TeamData) {
try {
const { data, error } = await fetchTeamQuestionScore(team.id)
if (error) {
message.error(error.message)
return
}
// 映射接口数据到 scores
// 接口返回的是一个数组,每个元素对应一个题目
// 我们使用 QuestionId 或者 index 作为 key
const scores: Record<string, { score: number, questionId: number, teamQuestionUseId: number, questionDetailId: number }> = {}
let totalScore = 0
let correctCount = 0
let questionCount = 0
const list = data?.data || []
list.forEach((item: any, index: number) => {
// 使用 q + (index + 1) 作为 key例如 q1, q2...
const key = `q${index + 1}`
// ResultPotins 是最终得分
const score = item.ResultPotins || 0
scores[key] = {
score,
questionId: item.QuestionId || 0,
teamQuestionUseId: item.Id || 0, // 使用接口返回的 Id 作为 TeamQuestionUseID
questionDetailId: item.QuestionDetaiID || 0, // 注意后端字段拼写 QuestionDetaiID
}
totalScore += score
// 统计正确数:如果分数 > 0 视为正确,或者根据 Status 判断
// 这里暂时以分数 > 0 为准,或者 ResultPotins === Points (满分)
// 根据示例数据Points是题目分值ResultPotins是得分。
// 只要得分 > 0 就算对?或者得分 == Points
// 简单起见,得分 > 0 算对
if (score > 0) {
correctCount++
}
questionCount++
})
team.scores = scores
team.totalScore = totalScore
team.correctCount = `${correctCount} / ${questionCount}`
}
catch (err: any) {
message.error(err.message || '获取队伍题目分数失败')
}
}
onMounted(() => {
getRankDetail()
})
async function toggleExpand(team: TeamData) {
team.isExpanded = !team.isExpanded
if (team.isExpanded && Object.keys(team.scores).length === 0) {
await getTeamQuestionScore(team)
}
}
async function saveScores(team: TeamData) {
// window.$message?.success(`已保存 ${team.name} 的分数变动`)
// console.log(team.scores, 'team.scores');
// 构建提交数据
// 我们只提交有 teamQuestionUseId 的分数
// 假设所有分数都需要提交,或者可以只提交变化的分数(如果能追踪变化)
// 这里简单起见,提交该队伍所有题目的当前分数
const scoreList = Object.values(team.scores)
.filter(item => item.teamQuestionUseId)
.map(item => ({
Id: item.teamQuestionUseId,
ResultPotins: item.score,
QuestionDetailID: item.questionDetailId,
QuestionId: item.questionId,
}))
if (scoreList.length === 0) {
message.warning('没有可保存的分数')
return
}
try {
const { error } = await updateScoreApi(scoreList)
if (error) {
message.error(error.message || '保存分数失败')
return
}
message.success(`已保存 ${team.name} 的分数变动`)
team.isExpanded = false
}
catch (e: any) {
message.error(e.message || '保存分数失败')
}
}
function handlePublish() {
@ -109,7 +167,32 @@ function handlePublish() {
function updateScore(team: TeamData, key: string, value: number | null) {
if (value !== null) {
team.scores[key] = value
if (team.scores[key]) {
team.scores[key].score = value
}
else {
// 这是一个新key的情况应该不太可能发生但为了类型安全
// 这里我们无法得知 questionId所以暂时设为 0 或者需要修改逻辑传递 questionId
// 实际上 updateScore 是由 UI 触发的UI 遍历的是现有的 keys
team.scores[key] = { score: value, questionId: 0, teamQuestionUseId: 0, questionDetailId: 0 }
}
// 重新计算总分和正确题数
let totalScore = 0
let correctCount = 0
const keys = Object.keys(team.scores)
const questionCount = keys.length
keys.forEach((k) => {
const s = team.scores[k].score
totalScore += s
if (s > 0) {
correctCount++
}
})
team.totalScore = totalScore
team.correctCount = `${correctCount} / ${questionCount}`
}
}
</script>
@ -118,11 +201,7 @@ function updateScore(team: TeamData, key: string, value: number | null) {
<div class="flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<NCard :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<!-- Header -->
<RankHeader
:title="competitionInfo.name"
:date="competitionInfo.date"
@publish="handlePublish"
/>
<RankHeader :title="competitionInfo.name" :date="competitionInfo.date" @publish="handlePublish" />
<NDivider />
@ -133,12 +212,8 @@ function updateScore(team: TeamData, key: string, value: number | null) {
<div class="flex flex-col gap-4">
<!-- Data Rows -->
<TeamItem
v-for="team in teamList"
:key="team.id"
:team="team"
@toggle-expand="toggleExpand"
@save-scores="saveScores"
@update-score="updateScore"
v-for="team in teamList" :key="team.id" :team="team" @toggle-expand="toggleExpand"
@save-scores="saveScores" @update-score="updateScore"
/>
</div>
</NCard>

View File

@ -11,7 +11,7 @@ interface TeamData {
totalScore: number
updateTime: string
isExpanded: boolean
scores: Record<string, number>
scores: Record<string, { score: number, questionId: number, teamQuestionUseId: number, questionDetailId: number }>
}
const props = defineProps<{
@ -59,14 +59,14 @@ const rankStyle = computed(() => {
</div>
<!-- Group -->
<div class="text-gray-500">
<!-- <div class="text-gray-500">
{{ team.group }}
</div>
</div> -->
<!-- Correct Count -->
<div class="font-bold font-mono">
<!-- <div class="font-bold font-mono">
{{ team.correctCount }}
</div>
</div> -->
<!-- Score -->
<div class="text-16px text-primary font-bold">
@ -96,16 +96,16 @@ const rankStyle = computed(() => {
</div>
<NGrid :x-gap="16" :y-gap="16" :cols="5">
<NGridItem v-for="i in 15" :key="i">
<NGridItem v-for="(item, key, index) in team.scores" :key="key">
<div class="border rounded bg-white p-3">
<div class="mb-2 flex justify-between text-12px text-gray-400">
<span>Q{{ i }} SCORE</span>
<span>/ 10</span>
<span>{{ item.questionId }}-Q{{ index + 1 }} SCORE</span>
<span />
</div>
<NInputNumber
:value="team.scores[`q${i}`]" :min="0" :max="10" button-placement="both"
:value="item.score" :min="0" :max="10" button-placement="both"
class="text-center text-primary font-bold"
@update:value="(val) => emit('updateScore', team, `q${i}`, val)"
@update:value="(val) => emit('updateScore', team, String(key), val)"
/>
</div>
</NGridItem>

View File

@ -1,5 +1,4 @@
<script setup lang="ts">
import { NTooltip } from 'naive-ui'
</script>
<template>
@ -19,7 +18,7 @@ import { NTooltip } from 'naive-ui'
>
<div>排名</div>
<div>队伍名称</div>
<div>所属小组</div>
<!-- <div>所属小组</div>
<div class="flex items-center">
正确题数
<NTooltip placement="top" trigger="hover">
@ -30,7 +29,7 @@ import { NTooltip } from 'naive-ui'
</template>
做题数量因加时赛可能有所不同
</NTooltip>
</div>
</div> -->
<div>总积分 (Total)</div>
<div>更新时间</div>
<div class="text-right">

View File

@ -7,12 +7,15 @@ import { computed, onMounted, onUnmounted, 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 { fetchGetCurrentQuestionByRoomID, fetchGetGameStatistics, fetchGetGroupListByActivityID, fetchGetQuestionDetailByID } from '@/service/api/game'
import { updateCurrentQuestionUse, updateScore } from '@/service/api/result'
import GroupTabs, { type GroupItem } from './modules/GroupTabs.vue'
import Typewriter from './modules/Typewriter.vue'
// 本地持久化存储手动修改的状态 (key: roomId-questionId-guid)
// 本地存储手动修改的状态 (key: roomId-questionId-teamId-guid, value: status)
const manualStatusStorage = useStorage<Record<string, number>>('read-star-manual-status', {})
// 本地存储手动修改的分数 (key: roomId-questionId-teamId-guid, value: score)
const manualScoreStorage = useStorage<Record<string, number>>('read-star-manual-score', {})
const route = useRoute()
const message = useMessage()
@ -20,20 +23,39 @@ 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 currentQuestionId = ref<number | string>('') // 当前选中的题目大纲 ID
const currentQuestionType = ref<string>('') // 当前选中的题目类型
const groups = ref<GroupItem[]>([])
const questionList = ref<(GroupItem & { type: string })[]>([])
const currentQuestion = ref<Api.Competition.GetCurrentQuestionInfo>() // 当前正在参赛的题目
const questionInfo = ref<Api.Competition.QuestionListDetailRound>() // 题目详情
const currentQuestionDetailID = ref<number | string>('') // 当前选中的题目详情 ID
// 判断当前activityId是正在比赛的活动
const isActiveActivityId = computed(() => {
if (!currentQuestion.value)
return false
const qActivityId = (currentQuestion.value as any).MainID || (currentQuestion.value as any).ActivityID
return Number(activityId.value) === Number(qActivityId)
})
// 计算当前题目类型对应的布局
const currentLayout = computed(() => {
// 只有 汉字加一加 (CharacterRadicalAddition) 需要人工评分 (列表布局 - 分屏视图)
// 其他所有题型 (汉字听写、诗词、词语听写、成语) 都使用长框展示 (网格布局 - 带切换的大图)
if (currentQuestionType.value === QuestionCategoryEnum.CharacterRadicalAddition) {
// 修正需求scoringTypes 包含 CharacterRadicalAddition, IdiomWriting1, ChineseCharacterDictation2
// 这些题型使用 'list' 布局,其他使用 'grid'
// 补充:当 scoringTypes 为 grid 时候,只有对错和正确,提交给接口分数规则是:如果评委觉得是正确的,就取 currentQuestion 里面的 Point 字段,反之错的就是 0
const scoringTypes = [
QuestionCategoryEnum.CharacterRadicalAddition,
QuestionCategoryEnum.IdiomWriting1,
QuestionCategoryEnum.ChineseCharacterDictation2,
]
if (scoringTypes.includes(currentQuestionType.value as any)) {
return 'list'
}
// 其他题型 (汉字听写、诗词、词语听写、成语看图等) 使用 'grid' 布局 (网格布局 - 带切换的大图)
return 'grid'
})
@ -60,11 +82,37 @@ async function setStatus(team: TeamResult, item: any, status: number) {
}
}
/** 根据 QuestionDetailID 查询题目详情 */
async function fetchQuestionDetail(QuestionDetailID: number) {
try {
const { data } = await fetchGetQuestionDetailByID(QuestionDetailID)
if (data?.data) {
questionInfo.value = data.data
console.log(questionInfo.value, 'questionInfo')
}
}
catch (error: any) {
message.error(error.message || '查询题目详情失败')
}
}
async function handleManualScoreUpdate(item: any, newScore: number) {
item.Score = newScore
item._isManual = true
// 保存分数到本地存储
if (roomId.value && currentQuestionId.value && item.Guid && item.TeamId) {
const key = `${roomId.value}-${currentQuestionId.value}-${item.TeamId}-${item.Guid}`
manualScoreStorage.value[key] = newScore
}
try {
await updateScore(item.TeamQuestionUseID, newScore)
await updateScore([{
Id: item.TeamQuestionUseID,
ResultPotins: newScore,
QuestionDetailID: item.QuestionDetailID || 0,
QuestionId: item.QuestionId || 0,
}])
message.success('分数已更新')
}
catch (e: any) {
@ -86,22 +134,46 @@ function handleNext() {
/**
* 根据房间当前题目,自动同步选中题目和当前正在比赛的组
* 逻辑:
* - 如果后端返回了当前题目的 QuestionID并且题目列表已加载则将 currentQuestionId 设为该题
* - 同时同步 currentQuestionType便于切换布局
* - 若找不到匹配项,则维持现状(由首次加载保持为第一题)
*/
function syncCurrentQuestionSelection() {
const qid = currentQuestion.value?.QuestionID
const gid = currentQuestion.value?.TeamGroupID
if (!qid || questionList.value.length === 0)
// 1. 如果不是当前正在进行的活动,默认选中第一组第一题
if (!isActiveActivityId.value) {
console.log('当前活动不是正在进行的活动,默认选中第一组第一题')
if (groups.value.length > 0) {
currentGroupId.value = groups.value[0].id
}
if (questionList.value.length > 0) {
currentQuestionId.value = questionList.value[0].id
currentQuestionType.value = questionList.value[0].type
}
return
const hit = questionList.value.find(q => Number(q.id) === Number(qid))
if (gid)
}
// 2. 如果是当前正在进行的活动,同步选中当前正在进行的题目和组
const currentQ = currentQuestion.value
const qid = currentQ?.QuestionID
const gid = currentQ?.TeamGroupID
// 尝试匹配题目
if (qid) {
const hit = questionList.value.find(q => Number(q.id) === Number(qid))
if (hit) {
currentQuestionId.value = hit.id
currentQuestionType.value = (hit as any).type || ''
}
}
// 尝试匹配组
if (gid) {
currentGroupId.value = gid
if (hit) {
currentQuestionId.value = hit.id
currentQuestionType.value = (hit as any).type || ''
}
// 兜底:如果同步失败(例如当前题目不在列表中),默认选中第一个
if (!currentQuestionId.value && questionList.value.length > 0) {
currentQuestionId.value = questionList.value[0].id
currentQuestionType.value = questionList.value[0].type
}
if (!currentGroupId.value && groups.value.length > 0) {
currentGroupId.value = groups.value[0].id
}
}
@ -194,7 +266,7 @@ async function fetchCurrentQuestion() {
* 获取答题卡数据
*/
async function fetchAnswerData() {
if (!currentGroupId.value || !currentQuestionId.value || !currentQuestion.value?.QuestionDetailID)
if (!currentGroupId.value || !currentQuestionId.value)
return
try {
const params = {
@ -212,6 +284,20 @@ async function fetchAnswerData() {
const result = data?.data || []
console.log('接口返回的游戏统计数据:', result)
// 获取题目详情 ID
let detailId = 0
// 1. 尝试从答题卡数据中获取当前比赛的题目详情 ID (优先)
if (result.length > 0) {
const item = result[0]
if (item) {
detailId = item?.QuestionDetaiID || 0
}
}
// 如果获取到了新的 detailId更新 currentQuestionDetailID (会触发 watch 调用 fetchQuestionDetail)
if (detailId && detailId !== Number(currentQuestionDetailID.value)) {
currentQuestionDetailID.value = detailId
}
result.forEach((item) => {
const UserAnswerFont = JSON.parse(item.UserAnswerFont)
console.log('解析后的 UserAnswerFont:', UserAnswerFont)
@ -225,7 +311,8 @@ async function fetchAnswerData() {
if (t.Answers) {
t.Answers.forEach((a: any) => {
if (a.Guid) {
oldMap.set(a.Guid, a)
// 使用 TeamId + Guid 作为 key确保唯一性
oldMap.set(`${t.TeamId}-${a.Guid}`, a)
}
})
}
@ -238,52 +325,119 @@ async function fetchAnswerData() {
if (answerData.UserAnswerFont) {
const parsedFonts = JSON.parse(answerData.UserAnswerFont)
parsedAnswers = Array.isArray(parsedFonts) ? parsedFonts : []
console.warn('parsed fonts', parsedFonts)
// console.warn('parsed fonts', parsedFonts)
}
}
catch (e) {
console.error('解析 UserAnswerFont 失败', e)
}
// 如果 UserAnswerFont 为空,尝试使用根对象的数据构建答案(适用于选择题等没有切图的题型)
if (parsedAnswers.length === 0 && (answerData.UserAnswerPicture || answerData.AnswerValuePicture)) {
parsedAnswers = [{
Guid: answerData.Guid || `${answerData.QuestionId}-${answerData.TeamId}`,
TeamQuestionUseID: answerData.Id, // 确保有 TeamQuestionUseID 用于后续更新分数
AnswerText: '查看原图', // 默认提示文案
AnswerValuePicture: answerData.AnswerValuePicture || answerData.UserAnswerPicture,
Status: answerData.Status,
Score: answerData.Points, // 使用根节点的 Points
}]
}
// 将新答案与旧的手动编辑合并
const mergedAnswers = parsedAnswers.map((ans: any) => {
const oldAns = oldMap.get(ans.Guid)
// 使用 TeamId + Guid 获取旧状态,确保不同队伍之间不冲突
const oldAns = oldMap.get(`${answerData.TeamId}-${ans.Guid}`)
// 检查本地存储是否有手动修改记录
const storageKey = `${roomId.value}-${currentQuestionId.value}-${answerData.TeamId}-${ans.Guid}`
const storedStatus = manualStatusStorage.value[storageKey]
const storedScore = manualScoreStorage.value[storageKey]
const hasStoredStatus = storedStatus !== undefined
const hasStoredScore = storedScore !== undefined
// 记录 AI 的原始推荐值(如果后端返回了)
const aiStatus = ans.Status
const aiScore = ans.Score
// _aiScore 专门用于存储 AI 的推荐分数(优先取外层 Points如果没有则尝试内层
const aiScore = answerData.Points ?? ans.Score ?? ans.Point
if ((oldAns && oldAns._isManual) || hasStoredStatus) {
// 强制更新图片 URL防止缓存
// 优先使用 ans 中的图片,如果没有则回退到 answerData 中的图片
const imageUrl = ans.AnswerValuePicture || (parsedAnswers.length === 1 && parsedAnswers[0] === ans ? answerData.UserAnswerPicture : '')
let finalImageUrl = imageUrl
let cleanBaseUrl = ''
let timestamp = new Date().getTime()
if (imageUrl) {
// 清理 URL 中的反引号、引号和空格
cleanBaseUrl = imageUrl.replace(/[`'"]/g, '').trim()
// 尝试复用旧的时间戳以防止闪烁
// 只有当 URL 发生变化,或者 AI 状态/分数发生变化时才更新时间戳
// 注意:如果 ans.AnswerValuePicture 发生了变化即使只是参数变化cleanBaseUrl 会变
if (oldAns && oldAns._baseImgUrl === cleanBaseUrl && oldAns._aiStatus === aiStatus && oldAns._aiScore === aiScore && oldAns._t) {
timestamp = oldAns._t
}
// 强制刷新:每次都添加新的时间戳
const separator = cleanBaseUrl.includes('?') ? '&' : '?'
finalImageUrl = `${cleanBaseUrl}${separator}_t=${timestamp}`
}
// 判断是否手动干预:
// 1. 本地存储有状态 (hasStoredStatus) 或有分数 (hasStoredScore)
// 2. 内存中有手动标记 (oldAns._isManual)
const isManual = hasStoredStatus || hasStoredScore || (oldAns && oldAns._isManual)
if (isManual) {
// 优先使用本地存储的状态,其次是内存中的手动编辑状态
const finalStatus = hasStoredStatus ? storedStatus : (oldAns ? oldAns.Status : ans.Status)
const finalScore = oldAns ? oldAns.Score : ans.Score // 分数目前只在内存保留,也可以加 localStorage
return {
// 分数逻辑:
// 1. 优先使用本地存储的分数 (hasStoredScore)
// 2. 其次使用内存中的手动分数 (oldAns.Score)
// 3. 如果都没有 (可能是只改了状态没改分数),则默认 null
let finalScore: number | null = null
if (hasStoredScore) {
finalScore = storedScore
}
else if (oldAns && oldAns.Score !== undefined && oldAns.Score !== null) {
finalScore = oldAns.Score
}
// 修正:在返回的对象中注入 TeamId以便 handleManualScoreUpdate 使用
const resultItem = {
...ans,
TeamId: answerData.TeamId, // [新增] 注入 TeamId
QuestionId: answerData.QuestionId, // [新增]
QuestionDetailID: answerData.QuestionDetaiID, // [新增] 注意后端字段拼写
AnswerText: ans.AnswerText,
AnswerValuePicture: finalImageUrl,
_baseImgUrl: cleanBaseUrl,
_t: timestamp, // 保存时间戳
Score: finalScore,
Status: finalStatus,
_isManual: true,
_aiStatus: aiStatus, // 保存 AI 的最新状态供参考
_aiStatus: aiStatus,
_aiScore: aiScore,
}
return resultItem
}
// 如果没有手动编辑,不要自动应用 AI 的状态,保持“未评”状态
// 用 _isManual = false 来表示 "未人工介入",此时 Status 仍然跟随 AI (ans.Status)
// 但 UI 上会显示 "AI 推荐: Correct/Wrong",而不是 "已人工校准"。
// 只有当用户点击后_isManual = true才显示 "已人工校准"。
// 并且 Status = ans.Status (AI 的值)。
// 如果没有手动编辑
return {
...ans,
Status: ans.Status, // 默认跟随 AI但 UI 会标记为 "AI 推荐"
TeamId: answerData.TeamId, // [新增] 注入 TeamId
QuestionId: answerData.QuestionId, // [新增]
QuestionDetailID: answerData.QuestionDetaiID, // [新增] 注意后端字段拼写
AnswerText: ans.AnswerText,
AnswerValuePicture: finalImageUrl,
_baseImgUrl: cleanBaseUrl,
_t: timestamp, // 保存时间戳
Status: ans.Status,
_aiStatus: aiStatus,
_aiScore: aiScore,
_isManual: false, // 明确标记为非手动
Score: null, // 默认 null
_isManual: false,
}
})
@ -311,6 +465,13 @@ async function fetchAnswerData() {
*/
async function handleNexCurrentQuestion() {
await fetchCurrentQuestion()
// 校验 ActivityID 是否一致
if (!isActiveActivityId.value) {
window.$message?.warning('当前房间正在进行的不是本场活动,无法跳转到当前题目')
return
}
if (currentQuestionId.value && currentGroupId.value) {
currentQuestionId.value = currentQuestion.value?.QuestionID || currentQuestionId.value
currentGroupId.value = currentQuestion.value?.TeamGroupID || currentGroupId.value
@ -343,28 +504,64 @@ function handlePublish() {
negativeText: '再次检查',
onPositiveClick: async () => {
// 在发布前,将所有手动修改的分数/状态提交一次
const updatePromises: Promise<any>[] = []
// 注意:现在 updateScore 接口支持批量提交,我们需要收集所有需要提交的数据
const scoreList: { Id: number, ResultPotins: number, QuestionDetailID: number, QuestionId: number }[] = []
let isValid = true
teamResults.value.forEach((team) => {
if (!isValid)
return
if (team.Answers) {
team.Answers.forEach((item: any) => {
if (item._isManual) {
// 调用 updateScore 接口提交分数
// 对于客观题,如果 Status=1 (Correct)Score=1Status=0 (Wrong)Score=0
// 假设客观题满分是 1 分 (或者根据业务逻辑调整)
// 无论是手动还是 AI 推荐,最终都需要提交分数
// 如果是 grid 布局客观题Status=1 -> 1分Status=0 -> 0分
// 如果是 list 布局(主观题),直接用 Score 分数
// 必须有 TeamQuestionUseID (Id) 才能提交
if (item.TeamQuestionUseID) {
let scoreToSubmit = item.Score
if (currentLayout.value === 'grid') {
scoreToSubmit = item.Status === 1 ? 1 : 0
// 校验:对于 list 布局(主观题),分数不能为 null
if (currentLayout.value === 'list' && (scoreToSubmit === null || scoreToSubmit === undefined)) {
window.$message?.error(`队伍 "${team.TeamName}" 存在未打分的题目,请检查`)
isValid = false
return
}
updatePromises.push(updateScore(item.TeamQuestionUseID, scoreToSubmit))
if (currentLayout.value === 'grid') {
// Grid 布局(客观题):只有对错
// 规则:如果评委认为是正确 (Status=1),则使用当前题目的总分 (currentQuestion.Point)
// 否则(错误),分数为 0
// 注意currentQuestion 可能没有 Point 字段,需要确认类型,如果没有则尝试从 item._aiScore 获取或者默认 1
// 根据需求:"就取currentQuestion里面的Point字段"
// 查看 typingscurrentQuestion 是 GetCurrentQuestionInfo 类型,可能没有 Point。
// 假设 GetCurrentQuestionInfo 有 Point 字段 (或者 Points / Score)
// 如果 currentQuestion 没有,回退到 item._aiScore (AI 认为正确的那个分数) 还是默认 1
// 通常客观题分数是固定的。这里假设 currentQuestion 有 Point。
// 修正currentQuestion.value 可能是 undefined。
const maxPoint = (currentQuestion.value as any)?.Point || (currentQuestion.value as any)?.Score || 1
scoreToSubmit = item.Status === 1 ? maxPoint : 0
}
// 确保分数为数字
scoreList.push({
Id: item.TeamQuestionUseID,
ResultPotins: Number(scoreToSubmit || 0),
QuestionDetailID: item.QuestionDetailID || 0,
QuestionId: item.QuestionId || 0,
})
}
})
}
})
if (!isValid)
return
try {
await Promise.all(updatePromises)
if (scoreList.length > 0) {
await updateScore(scoreList)
}
await fetchUpdateCurrentQuestionUse(currentQuestion.value?.TeamGroupQuestionID || 0)
// 发布成功后,清理本地存储
const prefix = `${roomId.value}-${currentQuestionId.value}-`
@ -372,6 +569,10 @@ function handlePublish() {
if (k.startsWith(prefix))
delete manualStatusStorage.value[k]
})
Object.keys(manualScoreStorage.value).forEach((k) => {
if (k.startsWith(prefix))
delete manualScoreStorage.value[k]
})
}
catch (error: any) {
window.$message?.error(error.message || '提交分数或发布失败,请重试')
@ -387,10 +588,41 @@ function handlePublish() {
const pollingTimer = ref<NodeJS.Timeout | null>(null)
function startPolling() {
if (pollingTimer.value)
stopPolling()
// 检查是否已经结束
const endTime = (currentQuestion.value as any)?.EndTime
if (endTime) {
// 增加 8s 缓冲时间
const end = new Date(endTime).getTime() + 12000
const now = new Date().getTime()
if (now > end) {
console.log('当前题目已结束含12s缓冲仅获取一次最终结果不启动轮询', endTime)
fetchAnswerData()
return
}
}
// 如果不是当前正在进行的活动,仅获取一次数据,不启动轮询
if (!isActiveActivityId.value) {
console.log('当前活动 ID 不是正在比赛的活动,仅获取一次数据', activityId.value, currentQuestion.value?.ActivityID)
fetchAnswerData()
return
}
fetchAnswerData() // 立即获取
pollingTimer.value = setInterval(() => {
// 每次轮询前再次检查是否过期
const currentEndTime = (currentQuestion.value as any)?.EndTime
if (currentEndTime) {
// 增加 8s 缓冲时间
const endTs = new Date(currentEndTime).getTime() + 12000
if (Date.now() > endTs) {
console.log('题目时间已到含12s缓冲停止轮询', currentEndTime)
stopPolling()
return
}
}
fetchAnswerData()
}, 3000)
}
@ -437,6 +669,14 @@ watch([currentGroupId, currentQuestionId], () => {
}
})
watch(() => currentQuestionDetailID.value, (newVal) => {
console.log(newVal)
if (newVal) {
fetchQuestionDetail(Number(newVal))
}
}, { immediate: true })
onMounted(async () => {
// 并行拉取分组、题目与当前题目,完成后做一次同步
await Promise.all([fetchGroups(), fetchQuestions(), fetchCurrentQuestion()])
@ -498,12 +738,12 @@ onUnmounted(() => {
</div>
<!-- 参考答案展示如果有 -->
<div v-if="currentQuestion?.Answer" class="border border-green-200 rounded-lg bg-green-50 p-4">
<div v-if="questionInfo?.Answer" class="border border-green-200 rounded-lg bg-green-50 p-4">
<div class="mb-2 text-sm text-green-800 font-bold tracking-wider uppercase">
参考答案
</div>
<div class="text-base text-green-900 font-medium leading-relaxed">
{{ currentQuestion.Answer }}
{{ questionInfo.Answer }}
</div>
</div>
</div>
@ -536,26 +776,28 @@ onUnmounted(() => {
!item._isManual ? 'border-gray-300 bg-gray-50/30' : (item.Status === 1 ? 'border-green-500 bg-green-50/30' : 'border-red-500 bg-red-50/30'),
]"
>
<!-- 图片上方的标签 -->
<div class="mb-2 text-center text-lg text-gray-700 font-bold">
{{ item.AnswerText || '答案' }}
<div v-if="item.AnswerText" class="text-lg text-gray-800 font-bold">
<span> ai识别出的答题文本</span>
<Typewriter :text="item.AnswerText" />
</div>
<!-- AI Suggestion Indicator -->
<div
v-if="!item._isManual"
class="mb-2 flex items-center justify-center gap-1 text-xs text-orange-500 font-bold"
>
<div class="i-carbon-ai-status" />
AI 推荐: {{ item.Status === 1 ? '正确' : '错误' }}
</div>
<!-- Manual Calibration Indicator -->
<div
v-else
class="mb-2 flex items-center justify-center gap-1 text-xs text-green-600 font-bold"
>
<div class="i-carbon-user-avatar-filled-alt" />
已人工校准: {{ item.Status === 1 ? '正确' : '错误' }}
<div class="mb-2 flex items-center justify-center gap-2 text-xs font-bold">
<!-- AI 推荐 -->
<div class="flex items-center gap-1 text-orange-500">
<div class="i-carbon-ai-status" />
<span>AI: {{ item._aiScore }} ({{ item._aiStatus === 1 ? '对' : '错' }})</span>
</div>
<!-- 分隔符 -->
<div class="h-3 w-[1px] bg-gray-300" />
<!-- 当前状态 -->
<div v-if="!item._isManual" class="text-gray-400">
跟随 AI
</div>
<div v-else class="flex items-center gap-1 text-green-600">
<div class="i-carbon-user-avatar-filled-alt" />
<span>人工: {{ item.Status === 1 ? '对' : '错' }}</span>
</div>
</div>
<!-- 图片展示 -->
<div class="flex items-center justify-center">
@ -571,10 +813,16 @@ onUnmounted(() => {
>
<!-- 如果未校准显示两个按钮 -->
<template v-if="!item._isManual">
<NButton strong secondary round size="medium" type="success" class="shadow-xl" @click="setStatus(team, item, 1)">
<NButton
strong secondary round size="medium" type="success" class="shadow-xl"
@click="setStatus(team, item, 1)"
>
确认正确 (绿)
</NButton>
<NButton strong secondary round size="medium" type="error" class="shadow-xl" @click="setStatus(team, item, 0)">
<NButton
strong secondary round size="medium" type="error" class="shadow-xl"
@click="setStatus(team, item, 0)"
>
确认错误 ()
</NButton>
</template>
@ -603,8 +851,8 @@ onUnmounted(() => {
<div
class="flex items-center gap-1 border border-green-200 rounded bg-green-50 px-2 py-1 text-xs text-green-600"
>
<div class="i-carbon-checkmark" />
已人工校准
<icon-ic:twotone-rule class="text-icon" />
<span> 已人工校准</span>
</div>
</div>
</div>
@ -613,12 +861,13 @@ onUnmounted(() => {
<!-- List Layout: 主观题 -> Split View (Image Left, Scoring Right) -->
<div v-else class="w-full flex flex-col gap-6">
<div
v-for="(item, idx) in team.Answers" :key="item.Guid"
v-for="item in team.Answers" :key="item.Guid"
class="flex flex-col gap-6 border border-gray-100 rounded-xl bg-white p-6 shadow-sm"
>
<!-- Title -->
<div class="text-lg text-gray-800 font-bold">
{{ idx + 1 }}. {{ item.AnswerText || '题目' }} (汉字加一加)
<span> ai识别出的答题文本</span>
<Typewriter :text="item.AnswerText || '题目'" />
</div>
<div class="flex flex-col gap-6 lg:flex-row">
@ -630,6 +879,7 @@ onUnmounted(() => {
<div class="i-carbon-image" />
主观题原图展示区
</div>
<div class="h-full flex items-center justify-center">
<NImage
v-if="item.AnswerValuePicture || item.imageUrl"
@ -651,25 +901,56 @@ onUnmounted(() => {
<div class="mt-1 text-sm text-gray-500">
请根据对的字数进行打分
</div>
<div v-if="!item._isManual" class="mt-2 flex items-center gap-2 rounded bg-orange-50 px-2 py-1 text-xs text-orange-600">
<div class="i-carbon-ai-status" />
AI 推荐: {{ item.Score }}
</div>
<div v-else class="mt-2 flex items-center gap-2 rounded bg-green-50 px-2 py-1 text-xs text-green-600">
<div class="i-carbon-user-avatar-filled-alt" />
已人工校准: {{ item.Score }}
<!-- 评分状态展示区域明确区分 AI 推荐和人工干预状态 -->
<div class="mt-2 flex flex-col gap-2">
<!-- AI 推荐信息 (始终显示作为参考) -->
<div
class="flex items-center justify-between border border-gray-100 rounded bg-gray-50 px-3 py-2"
>
<div class="flex items-center gap-1.5">
<div class="i-carbon-ai-status text-orange-500" />
<span class="text-xs text-gray-600 font-medium">AI 推荐</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-800 font-bold">{{ item._aiScore }} </span>
<span
class="rounded px-1.5 py-0.5 text-[10px]"
:class="item._aiStatus === 1 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
>
{{ item._aiStatus === 1 ? '判对' : '判错' }}
</span>
</div>
</div>
<!-- 当前生效状态提示 -->
<div
v-if="item._isManual"
class="flex items-center gap-1 border border-green-200 rounded bg-green-50 px-2 py-1 text-xs text-green-600"
>
<!-- <div class="i-carbon-user-avatar-filled-alt" /> -->
<icon-ic:twotone-rule class="text-icon" />
<span> 已人工校准</span>
</div>
<div v-else class="flex items-center gap-1.5 px-1 text-xs text-gray-400">
<div class="i-carbon-arrows-horizontal" />
<span>当前跟随 AI 推荐结果</span>
</div>
</div>
</div>
<!-- Score Input -->
<div class="flex flex-col gap-2">
<div class="text-sm text-gray-600 font-medium">
最终得分
最终得分 (人工)
</div>
<div class="flex items-center border border-blue-100 rounded-xl bg-white px-4 py-3 shadow-sm">
<!-- <NInputNumber v-model:value="item.Score" :min="0" :max="100" :show-button="false"
class="flex-1 text-center text-3xl font-bold !border-none" placeholder="0" /> -->
<NInputNumber v-model:value="item.Score" button-placement="both" :min="0" :max="100" @update:value="(val) => handleManualScoreUpdate(item, val || 0)">
<NInputNumber
v-model:value="item.Score" button-placement="both" :min="0" :max="100"
@update:value="(val) => handleManualScoreUpdate(item, val || 0)"
>
<template #suffix>
</template>
@ -682,13 +963,13 @@ onUnmounted(() => {
<div class="text-sm text-gray-600 font-medium">
快捷打分
</div>
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-4 gap-2">
<NButton
v-for="score in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]" :key="score" secondary strong
:type="item.Score === score ? 'primary' : 'default'" class="h-10"
:type="item.Score === score ? 'primary' : 'default'" class="h-10 w-full"
@click="() => handleManualScoreUpdate(item, score)"
>
{{ score }}
{{ score }}
</NButton>
</div>
</div>

View File

@ -0,0 +1,48 @@
<script lang="ts" setup>
import type { Options } from 'typeit'
import TypeIt from 'typeit'
import { onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps<{
text?: string
}>()
const textRef = ref<HTMLElement>()
let typeItInstance: any = null
function init() {
if (!textRef.value)
return
if (typeItInstance) {
typeItInstance.destroy()
}
const options: Options = {
strings: props.text || '',
lifeLike: true,
speed: 100,
loop: false,
cursor: false,
}
typeItInstance = new TypeIt(textRef.value, options).go()
}
onMounted(() => {
init()
})
watch(() => props.text, () => {
init()
})
onUnmounted(() => {
if (typeItInstance) {
typeItInstance.destroy()
}
})
</script>
<template>
<span ref="textRef" class="text-lg text-gray-800 font-bold" />
</template>

View File

@ -20,7 +20,7 @@ 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 QuestionDetailID = computed(() => currentQuestionDetail.value?.Id || 0)
const zoomedImage = ref<string | null>(null)
const isCompleted = ref(false)
@ -123,7 +123,7 @@ async function fetchGetGameStatisticsData() {
const _params = {
TeamGroupID: Number(groupsId.value),
QuestionID: Number(QuestionID.value),
QuestionDetaiID: Number(QuestionDetaiID.value),
QuestionDetaiID: Number(QuestionDetailID.value),
}
const { data } = await fetchGetGameStatistics(_params)
teams.value = (data?.data as any[]) || []

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
@ -28,10 +28,12 @@ const activityName = computed(() => activityInfo.value?.ActivityTitle || '星·
const flippedCards = ref<Set<number>>(new Set())
// 计算是否所有卡片都已翻转 (当前显示4张卡片)
// eslint-disable-next-line unused-imports/no-unused-vars
const isAllFlipped = computed(() => flippedCards.value.size === questions.value.length)
// 控制下一步按钮的显示(带延迟)
const showNextButton = ref(false)
const showNextButton = ref(true)
// eslint-disable-next-line prefer-const, unused-imports/no-unused-vars
let timer: ReturnType<typeof setTimeout> | null = null
// 获取路由参数
@ -70,22 +72,22 @@ async function getQuestions() {
loading.value = false
}
}
// 测试先注释掉
// watch(isAllFlipped, (val) => {
// if (timer) {
// clearTimeout(timer)
// timer = null
// }
watch(isAllFlipped, (val) => {
if (timer) {
clearTimeout(timer)
timer = null
}
if (val) {
timer = setTimeout(() => {
showNextButton.value = true
}, 1500)
}
else {
showNextButton.value = false
}
})
// if (val) {
// timer = setTimeout(() => {
// showNextButton.value = true
// }, 1500)
// }
// else {
// showNextButton.value = false
// }
// })
function handleBack() {
routerBack()

View File

@ -130,7 +130,15 @@ async function handleNext() {
// 点击开始答题
if (!isStarted.value) {
isStarted.value = true
store.startTimer(store.currentQuestionInfo?.QuestionTime || 10)
// 获取题目时间,优先从 currentQuestionInfo 获取,兼容大小写
// 并在获取失败时尝试从 detail 获取(如果后端在详情接口也返回了时间)
const infoTime = store.currentQuestionInfo?.QuestionTime || (store.currentQuestionInfo as any)?.questionTime
const detailTime = (store.currentQuestionDetail as any)?.QuestionTime || (store.currentQuestionDetail as any)?.questionTime
const duration = Number(infoTime || detailTime || 120) // 默认值改为 120s 或者保持 10s用户说是 120s
console.log('开始倒计时,时长:', duration, 'InfoTime:', infoTime, 'DetailTime:', detailTime)
store.startTimer(duration)
// 提交题目使用记录
try {
@ -207,10 +215,34 @@ async function fetchGetGameStatisticsData(_params: Api.Competition.QuestionAddPa
// 使用 Map 去重,以 TeamId 为准,保留最新的状态
const teamMap = new Map()
list.forEach((item) => {
let answerCount = 0
try {
if (item.UserAnswerFont) {
const parsed = JSON.parse(item.UserAnswerFont)
if (Array.isArray(parsed) && parsed.length > 0) {
// 如果 AnswerText 存在,取其长度;否则(如选择题)如果对象存在则算 1 个
// 逻辑:如果是填空/听写类AnswerText 是字符串,取长度?
// 根据需求:答题数量用 UserAnswerFont 里面 AnswerText 字段的 length 长度
// 注意AnswerText 可能是 "B" (选择题) 或 "汉字" (听写)
// 如果是数组,是否需要累加所有项的 AnswerText 长度?
// 假设 UserAnswerFont 是一个数组 [{"AnswerText": "..."}]
// 这里我们遍历数组累加长度
parsed.forEach((p: any) => {
if (p.AnswerText) {
answerCount += String(p.AnswerText).length
}
})
}
}
}
catch (e) {
console.error('解析 UserAnswerFont 失败', e)
}
teamMap.set(item.TeamName, {
group: item.TeamName || '未知队伍',
total: item.Points || 0,
correct: item.ResultPotins || 0,
total: answerCount, // 答题数量
correct: item.Points || 0, // 预估得分 (使用外层 Points)
})
})
chartData.value = Array.from(teamMap.values())

View File

@ -30,7 +30,7 @@ function buildOption(list: BarDatum[]): ECOption {
axisPointer: { type: 'shadow' },
},
legend: {
data: ['答题数量', '正确数量'],
data: ['答题数量', '预估得分'],
top: 0,
right: 0,
icon: 'circle',