feat: 添加排行榜分数编辑和题目详情展示功能
- 在排行榜详情页中,添加队伍题目分数编辑功能,支持批量保存 - 新增打字机组件用于题目答案的动态展示 - 优化游戏页面的答题数量统计逻辑,使用 UserAnswerFont 解析 - 更新题目导入工具,支持更多 JSON 格式 - 调整游戏倒计时逻辑,优先使用题目详情中的时间 - 移除排行榜中的部分冗余列,优化界面显示
This commit is contained in:
45
apps/admin/src/components/custom/typeit/index.vue
Normal file
45
apps/admin/src/components/custom/typeit/index.vue
Normal 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>
|
||||
@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
8
apps/admin/src/typings/api/Competition.d.ts
vendored
8
apps/admin/src/typings/api/Competition.d.ts
vendored
@ -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 {
|
||||
|
||||
6
apps/admin/src/typings/components.d.ts
vendored
6
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: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']
|
||||
|
||||
@ -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: '操作',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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=1;Status=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字段"
|
||||
// 查看 typings,currentQuestion 是 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>
|
||||
|
||||
@ -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>
|
||||
@ -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[]) || []
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -30,7 +30,7 @@ function buildOption(list: BarDatum[]): ECOption {
|
||||
axisPointer: { type: 'shadow' },
|
||||
},
|
||||
legend: {
|
||||
data: ['答题数量', '正确数量'],
|
||||
data: ['答题数量', '预估得分'],
|
||||
top: 0,
|
||||
right: 0,
|
||||
icon: 'circle',
|
||||
|
||||
Reference in New Issue
Block a user