- 新增重庆、树人、文字logo资源(PNG和WebP格式) - 新增用户界面背景图片资源(内容背景、对框、主页背景等) - 优化ActionButton组件样式(自适应宽度、禁用状态样式) - 更新CompetitionLayout组件header布局,集成新logo资源 - 优化TianZiGe组件功能和交互体验 - 更新竞赛相关页面组件(QuestionConfig、draw、QuestionRenderer等) - 更新排名列表页面样式和功能 - 更新诗词常识题库数据 - 完善Competition类型定义
1002 lines
40 KiB
Vue
1002 lines
40 KiB
Vue
<!-- eslint-disable no-console -->
|
||
<script lang="ts" setup>
|
||
import { Icon } from '@iconify/vue'
|
||
import { useStorage } from '@vueuse/core'
|
||
import { NButton, NCard, NImage, NInputNumber, NTag, useMessage } from 'naive-ui'
|
||
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, 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-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()
|
||
const activityId = computed(() => route.query?.activityId as string)
|
||
const roomId = computed(() => route.query?.roomId as string)
|
||
|
||
const currentGroupId = ref<number | string>('') // 当前选中的组别 ID
|
||
const currentQuestionId = ref<number | string>('') // 当前选中的题目大纲 ID
|
||
const currentQuestionType = ref<string>('') // 当前选中的题目类型
|
||
|
||
const groups = ref<GroupItem[]>([])
|
||
const questionList = ref<(GroupItem & { type: string })[]>([])
|
||
const currentQuestion = ref<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) 需要人工评分 (列表布局 - 分屏视图)
|
||
// 其他所有题型 (汉字听写、诗词、词语听写、成语) 都使用长框展示 (网格布局 - 带切换的大图)
|
||
// 修正需求: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'
|
||
})
|
||
|
||
interface TeamResult {
|
||
TeamName: string
|
||
UserAnswerPicture: string
|
||
Answers: any[]
|
||
Points: number
|
||
TeamId: number
|
||
}
|
||
|
||
// 当前所有队伍的解析结果列表
|
||
const teamResults = ref<TeamResult[]>([])
|
||
|
||
// 手动设置状态(确认或修改)
|
||
async function setStatus(team: TeamResult, item: any, status: number) {
|
||
item.Status = status
|
||
item._isManual = true
|
||
|
||
// 保存到本地存储
|
||
if (item.Guid && team.TeamId) {
|
||
const key = `${roomId.value}-${currentQuestionId.value}-${team.TeamId}-${item.Guid}`
|
||
manualStatusStorage.value[key] = status
|
||
}
|
||
}
|
||
|
||
/** 根据 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([{
|
||
Id: item.TeamQuestionUseID,
|
||
ResultPotins: newScore,
|
||
QuestionDetailID: item.QuestionDetailID || 0,
|
||
QuestionId: item.QuestionId || 0,
|
||
}])
|
||
message.success('分数已更新')
|
||
}
|
||
catch (e: any) {
|
||
message.error(e.message || '保存分数失败')
|
||
}
|
||
}
|
||
|
||
function handleNext() {
|
||
// “下一步”按钮的逻辑
|
||
// 简单示例:切换到下一题
|
||
const currentIndex = questionList.value.findIndex(q => q.id === currentQuestionId.value)
|
||
if (currentIndex !== -1 && currentIndex < questionList.value.length - 1) {
|
||
currentQuestionId.value = questionList.value[currentIndex + 1].id
|
||
}
|
||
else {
|
||
message.info('已经是最后一题了')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据房间当前题目,自动同步选中题目和当前正在比赛的组
|
||
*/
|
||
function syncCurrentQuestionSelection() {
|
||
// 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
|
||
}
|
||
|
||
// 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 (!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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取分组列表
|
||
*/
|
||
async function fetchGroups() {
|
||
if (!activityId.value) {
|
||
window.$message?.error('请先选择比赛')
|
||
return
|
||
}
|
||
try {
|
||
const { data, error } = await fetchGetGroupListByActivityID(activityId.value, 3)
|
||
if (error) {
|
||
window.$message?.error('获取题目失败')
|
||
return
|
||
}
|
||
const list = data?.data || []
|
||
groups.value = list.map((item: any) => ({
|
||
id: item.Id,
|
||
name: item.Id,
|
||
}))
|
||
// 默认选中第一个分组
|
||
if (groups.value.length > 0 && !currentGroupId.value) {
|
||
currentGroupId.value = groups.value[0].id
|
||
}
|
||
}
|
||
catch (error: any) {
|
||
window.$message?.error(error.message || '获取分组失败')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取题目列表
|
||
*/
|
||
async function fetchQuestions() {
|
||
if (!activityId.value)
|
||
return
|
||
try {
|
||
const { data, error } = await fetchGetQuestionList(Number(activityId.value), 3)
|
||
if (error) {
|
||
message.error(error.message)
|
||
return
|
||
}
|
||
const list = data?.data || []
|
||
console.warn('question list', list)
|
||
|
||
questionList.value = list.map((item, idx) => ({
|
||
id: item.ID,
|
||
name: item.ID || `题目 ${idx + 1}`,
|
||
type: item.UIType || '',
|
||
}))
|
||
// 默认选中第一个题目
|
||
if (questionList.value.length > 0 && !currentQuestionId.value) {
|
||
currentQuestionId.value = questionList.value[0].id
|
||
currentQuestionType.value = questionList.value[0].type
|
||
console.warn(questionList.value, 'questionList')
|
||
}
|
||
// 题目加载完成后尝试同步到“当前进行中的题目”
|
||
syncCurrentQuestionSelection()
|
||
}
|
||
catch (error: any) {
|
||
message.error(error.message || '获取题目失败')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取当前正在比赛的题目(不触发同步选中逻辑)
|
||
*/
|
||
async function fetchCurrentQuestion(shouldSync = true) {
|
||
if (!roomId.value)
|
||
return
|
||
try {
|
||
const { data, error } = await fetchGetCurrentQuestionByRoomID(Number(roomId.value))
|
||
if (error) {
|
||
message.error(error.message)
|
||
return
|
||
}
|
||
currentQuestion.value = data?.data || {}
|
||
console.warn('current question', currentQuestion.value)
|
||
// 只在初始化时同步选中题,轮询时不同步
|
||
if (shouldSync) {
|
||
syncCurrentQuestionSelection()
|
||
}
|
||
}
|
||
catch (error: any) {
|
||
message.error(error.message || '获取当前题目失败')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取答题卡数据
|
||
*/
|
||
async function fetchAnswerData() {
|
||
if (!currentGroupId.value || !currentQuestionId.value)
|
||
return
|
||
try {
|
||
const params = {
|
||
TeamGroupID: Number(currentGroupId.value),
|
||
QuestionID: Number(currentQuestionId.value),
|
||
}
|
||
|
||
const { data, error } = await fetchGetGameStatistics(params)
|
||
if (error) {
|
||
message.error(error.message)
|
||
return
|
||
}
|
||
|
||
// 假设返回的数据结构是数组,取第一条或者根据逻辑取特定的一条
|
||
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)
|
||
})
|
||
|
||
if (result && result.length > 0) {
|
||
// 创建现有项目的映射以保留手动编辑
|
||
const oldMap = new Map<string, any>()
|
||
if (teamResults.value && teamResults.value.length > 0) {
|
||
teamResults.value.forEach((t) => {
|
||
if (t.Answers) {
|
||
t.Answers.forEach((a: any) => {
|
||
if (a.Guid) {
|
||
// 使用 TeamId + Guid 作为 key,确保唯一性
|
||
oldMap.set(`${t.TeamId}-${a.Guid}`, a)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
teamResults.value = result.map((answerData) => {
|
||
let parsedAnswers: any[] = []
|
||
try {
|
||
if (answerData.UserAnswerFont) {
|
||
const parsedFonts = JSON.parse(answerData.UserAnswerFont)
|
||
parsedAnswers = Array.isArray(parsedFonts) ? 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) => {
|
||
// 使用 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
|
||
// _aiScore 专门用于存储 AI 的推荐分数(优先取外层 Points,如果没有则尝试内层)
|
||
const aiScore = answerData.Points ?? ans.Score ?? ans.Point
|
||
|
||
// 强制更新图片 URL,防止缓存
|
||
// 优先使用 ans 中的图片,如果没有则回退到 answerData 中的图片
|
||
const imageUrl = ans.AnswerValuePicture || (parsedAnswers.length === 1 && parsedAnswers[0] === ans ? answerData.UserAnswerPicture : '')
|
||
let finalImageUrl = imageUrl
|
||
const cleanBaseUrl = ''
|
||
const timestamp = new Date().getTime()
|
||
|
||
finalImageUrl = `${finalImageUrl}?_t=${timestamp}`
|
||
console.log(finalImageUrl, 'finalImageUrl')
|
||
|
||
// 判断是否手动干预:
|
||
// 1. 本地存储有状态 (hasStoredStatus) 或有分数 (hasStoredScore)
|
||
// 2. 内存中有手动标记 (oldAns._isManual)
|
||
const isManual = hasStoredStatus || hasStoredScore || (oldAns && oldAns._isManual)
|
||
|
||
if (isManual) {
|
||
// 优先使用本地存储的状态,其次是内存中的手动编辑状态
|
||
const finalStatus = hasStoredStatus ? storedStatus : (oldAns ? oldAns.Status : ans.Status)
|
||
|
||
// 分数逻辑:
|
||
// 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,
|
||
_aiScore: aiScore,
|
||
}
|
||
return resultItem
|
||
}
|
||
|
||
// 如果没有手动编辑
|
||
return {
|
||
...ans,
|
||
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,
|
||
Score: null, // 默认 null
|
||
_isManual: false,
|
||
}
|
||
})
|
||
|
||
return {
|
||
TeamId: answerData.TeamId,
|
||
TeamName: answerData.TeamName,
|
||
UserAnswerPicture: answerData.UserAnswerPicture,
|
||
Points: answerData.Points,
|
||
Answers: mergedAnswers,
|
||
}
|
||
})
|
||
}
|
||
else {
|
||
teamResults.value = []
|
||
message.info('暂无答题数据')
|
||
}
|
||
}
|
||
catch (error: any) {
|
||
message.error(error.message || '获取答题数据失败')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 调转到当前题目
|
||
*/
|
||
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
|
||
}
|
||
}
|
||
|
||
function handlePublish() {
|
||
const endTimeStr = (currentQuestion.value as any)?.EndTime
|
||
if (endTimeStr && Date.now() < new Date(endTimeStr).getTime() + 5000) {
|
||
window.$message?.warning('倒计时未结束(需等待5秒延迟),暂不能发布')
|
||
return
|
||
}
|
||
if (currentQuestion.value?.TeamGroupQuestionID) {
|
||
// 检查是否有未评分的题目
|
||
let hasUnrated = false
|
||
teamResults.value.forEach((team) => {
|
||
if (team.Answers) {
|
||
team.Answers.forEach((item: any) => {
|
||
if (!item._isManual) {
|
||
hasUnrated = true
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
if (hasUnrated) {
|
||
window.$message?.error('还有未人工校准的题目,请完成后再发布!')
|
||
return
|
||
}
|
||
|
||
window.$dialog?.warning({
|
||
title: '二次确认',
|
||
content: '请仔细检查所有人工打分和AI推荐结果是否已核对无误。提交后结果将不可更改,确定要发布吗?',
|
||
positiveText: '确认发布',
|
||
negativeText: '再次检查',
|
||
onPositiveClick: async () => {
|
||
// 在发布前,将所有手动修改的分数/状态提交一次
|
||
// 注意:现在 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) => {
|
||
// 无论是手动还是 AI 推荐,最终都需要提交分数
|
||
// 如果是 grid 布局(客观题),Status=1 -> 1分,Status=0 -> 0分
|
||
// 如果是 list 布局(主观题),直接用 Score 分数
|
||
// 必须有 TeamQuestionUseID (Id) 才能提交
|
||
if (item.TeamQuestionUseID) {
|
||
let scoreToSubmit = item.Score
|
||
|
||
// 校验:对于 list 布局(主观题),分数不能为 null
|
||
if (currentLayout.value === 'list' && (scoreToSubmit === null || scoreToSubmit === undefined)) {
|
||
window.$message?.error(`队伍 "${team.TeamName}" 存在未打分的题目,请检查`)
|
||
isValid = false
|
||
return
|
||
}
|
||
|
||
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 {
|
||
if (scoreList.length > 0) {
|
||
await updateScore(scoreList)
|
||
}
|
||
await fetchUpdateCurrentQuestionUse(currentQuestion.value?.TeamGroupQuestionID || 0)
|
||
// 发布成功后,清理本地存储
|
||
const prefix = `${roomId.value}-${currentQuestionId.value}-`
|
||
Object.keys(manualStatusStorage.value).forEach((k) => {
|
||
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 || '提交分数或发布失败,请重试')
|
||
}
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 消费并发布当前题目
|
||
*/
|
||
const pollingTimer = ref<NodeJS.Timeout | null>(null)
|
||
const nowTs = ref(Date.now())
|
||
const canPublish = computed(() => {
|
||
const endTime = (currentQuestion.value as any)?.EndTime
|
||
if (!endTime)
|
||
return true
|
||
// 结束时间 + 5秒延迟才能发布
|
||
const end = new Date(endTime).getTime() + 5000
|
||
return nowTs.value >= end
|
||
})
|
||
|
||
function startPolling() {
|
||
stopPolling()
|
||
|
||
// 检查是否已经结束
|
||
const endTime = (currentQuestion.value as any)?.EndTime
|
||
if (endTime) {
|
||
// 增加 12s 缓冲时间
|
||
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() // 立即获取
|
||
nowTs.value = Date.now()
|
||
pollingTimer.value = setInterval(() => {
|
||
// 每次轮询前再次检查是否过期
|
||
const currentEndTime = (currentQuestion.value as any)?.EndTime
|
||
if (currentEndTime) {
|
||
// 增加 12s 缓冲时间
|
||
const endTs = new Date(currentEndTime).getTime() + 12000
|
||
if (Date.now() > endTs) {
|
||
console.log('题目时间已到(含12s缓冲),停止轮询', currentEndTime)
|
||
stopPolling()
|
||
return
|
||
}
|
||
}
|
||
nowTs.value = Date.now()
|
||
|
||
// 轮询时同时更新当前题目信息(获取最新的 EndTime),但不触发同步
|
||
fetchCurrentQuestion(false)
|
||
fetchAnswerData()
|
||
}, 3000)
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollingTimer.value) {
|
||
clearInterval(pollingTimer.value)
|
||
pollingTimer.value = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 消费并发布当前题目
|
||
*/
|
||
async function fetchUpdateCurrentQuestionUse(TeamGroupQuestionID: number) {
|
||
try {
|
||
const { data, error } = await updateCurrentQuestionUse(TeamGroupQuestionID)
|
||
console.warn('updateCurrentQuestionUse result', data)
|
||
if (!error) {
|
||
window?.$message?.success('结果已发布')
|
||
stopPolling() // 发布后停止轮询
|
||
}
|
||
}
|
||
catch (error: any) {
|
||
window?.$message?.error(error.message)
|
||
}
|
||
}
|
||
|
||
// 监听分组和题目的变化,重新获取答题数据
|
||
watch([currentGroupId, currentQuestionId], () => {
|
||
if (currentGroupId.value && currentQuestionId.value) {
|
||
const question = questionList.value.find(q => q.id === currentQuestionId.value)
|
||
if (question) {
|
||
currentQuestionType.value = question.type
|
||
}
|
||
// 重新启动新问题的轮询(通过获取重新开始隐式清除旧的手动编辑?不,等等。)
|
||
// 我们可能需要重置 teamResults 或根据需要处理手动编辑清除。
|
||
// 目前,fetchAnswerData 将根据新问题数据重新初始化。
|
||
// 但是,如果我们保持轮询,我们需要确保如果 ID 冲突(如果 Guid 是唯一的,则不太可能),我们不会保留上一个问题的手动编辑。
|
||
// 让我们停止并重新启动以保持清洁。
|
||
stopPolling()
|
||
teamResults.value = [] // 清除旧结果以避免闪烁或陈旧数据
|
||
startPolling()
|
||
}
|
||
})
|
||
|
||
watch(() => currentQuestionDetailID.value, (newVal) => {
|
||
console.log(newVal)
|
||
|
||
if (newVal) {
|
||
fetchQuestionDetail(Number(newVal))
|
||
}
|
||
}, { immediate: true })
|
||
|
||
onMounted(async () => {
|
||
// 并行拉取分组、题目与当前题目,完成后做一次同步
|
||
await Promise.all([fetchGroups(), fetchQuestions(), fetchCurrentQuestion(true)])
|
||
syncCurrentQuestionSelection()
|
||
startPolling()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopPolling()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h-full flex flex-col gap-2 overflow-hidden bg-[#F9FAFB] p-6">
|
||
<!-- 顶部栏 -->
|
||
<div class="flex items-center justify-between rounded-2xl bg-white px-6 py-4 shadow-sm">
|
||
<div class="flex items-center gap-8">
|
||
<h1 class="m-0 flex items-center gap-2 text-xl text-gray-800 font-bold">
|
||
<div class="i-carbon-chart-line-data text-2xl text-blue-600" />
|
||
实时结果
|
||
</h1>
|
||
<GroupTabs
|
||
v-model:group-id="currentGroupId" v-model:question-id="currentQuestionId" :groups="groups"
|
||
:question-list="questionList"
|
||
/>
|
||
</div>
|
||
|
||
<div class="flex gap-4">
|
||
<div class="flex items-center rounded-full bg-blue-50 px-4 py-1.5 text-sm text-blue-600 font-bold">
|
||
当前进度 {{ questionList.findIndex(q => q.id === currentQuestionId) + 1 }}/{{ questionList.length }}
|
||
</div>
|
||
<NButton type="primary" strong class="px-6" @click="handleNexCurrentQuestion">
|
||
当前题目
|
||
</NButton>
|
||
<NButton secondary strong @click="handleNext">
|
||
下一题
|
||
</NButton>
|
||
<NButton type="tertiary" strong class="px-6" :disabled="!canPublish" @click="handlePublish">
|
||
发布结果
|
||
</NButton>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主要内容 -->
|
||
<div class="flex-1 overflow-auto">
|
||
<div v-if="teamResults.length === 0" class="h-full flex items-center justify-center text-gray-400">
|
||
暂无队伍数据
|
||
</div>
|
||
|
||
<div v-else class="flex flex-col gap-6 pb-6">
|
||
<NCard v-for="team in teamResults" :key="team.TeamId" hoverable>
|
||
<template #header>
|
||
<div class="flex flex-col gap-4">
|
||
<!-- 队伍信息 -->
|
||
<div class="flex items-center gap-2">
|
||
<NTag type="info" round size="large" class="text-base font-bold">
|
||
{{ team.TeamName || '队伍信息' }}
|
||
</NTag>
|
||
</div>
|
||
|
||
<!-- 参考答案展示(如果有) -->
|
||
<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">
|
||
{{ questionInfo.Answer }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="flex flex-row items-start gap-6 px-4 py-2">
|
||
<!-- 学生答题原图 -->
|
||
<!-- <div class="UserAnswerPicture flex-shrink-0">
|
||
<NImage
|
||
v-if="team.UserAnswerPicture" :src="team.UserAnswerPicture" width="550" object-fit="contain"
|
||
class="block border border-gray-200 rounded-lg"
|
||
/>
|
||
<div
|
||
v-else
|
||
class="h-[150px] w-[150px] flex items-center justify-center rounded-lg bg-gray-100 p-2 text-center text-xs text-gray-400"
|
||
>
|
||
暂无原图
|
||
</div>
|
||
</div> -->
|
||
|
||
<!-- 解析答题里面的题目 -->
|
||
<div class="UserAnswerFont flex-1">
|
||
<div class="flex flex-wrap gap-x-8 gap-y-4">
|
||
<!-- 网格布局:汉字听写、汉字加一加、选择题 -> 带切换的大图 -->
|
||
<div v-if="currentLayout === 'grid'" class="w-full flex flex-col gap-6">
|
||
<div
|
||
v-for="(item) in team.Answers" :key="item.Guid"
|
||
class="relative w-full overflow-hidden border-2 rounded-xl border-dashed p-4 transition-all duration-300"
|
||
:class="[
|
||
!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 v-if="item.AnswerText" class="border-b border-gray-200 pb-2 text-lg text-gray-800 font-bold">
|
||
<span> 由 ai识别出的答题文本:</span>
|
||
<Typewriter :text="item.AnswerText" />
|
||
</div>
|
||
|
||
<!-- AI Suggestion Indicator -->
|
||
<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">
|
||
<NImage
|
||
:src="item.AnswerValuePicture || item.imageUrl" object-fit="contain"
|
||
class="max-h-[300px] w-full"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Toggle Button Overlay (Center) -->
|
||
<div
|
||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 opacity-0 transition-opacity duration-200 hover:opacity-100"
|
||
>
|
||
<!-- 如果未校准,显示两个按钮 -->
|
||
<template v-if="!item._isManual">
|
||
<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>
|
||
</template>
|
||
|
||
<!-- 如果已校准,显示切换按钮 -->
|
||
<template v-else>
|
||
<NButton
|
||
strong secondary round size="large" :type="item.Status === 1 ? 'error' : 'success'"
|
||
class="shadow-xl" @click="setStatus(team, item, item.Status === 1 ? 0 : 1)"
|
||
>
|
||
切换为 {{ item.Status === 1 ? '错误 (红)' : '正确 (绿)' }}
|
||
</NButton>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Status Icon (Top Right) - Only show if manually calibrated -->
|
||
<div v-if="item._isManual" class="absolute right-4 top-4 z-10">
|
||
<div class="rounded-full bg-white shadow-md">
|
||
<Icon v-if="item.Status === 1" icon="mdi:check-circle" class="text-4xl text-green-500" />
|
||
<Icon v-else icon="mdi:close-circle" class="text-4xl text-red-500" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Manual Calibration Badge (Bottom Right) -->
|
||
<div class="absolute bottom-4 right-4 z-10">
|
||
<div
|
||
class="flex items-center gap-1 border border-green-200 rounded bg-green-50 px-2 py-1 text-xs text-green-600"
|
||
>
|
||
<icon-ic:twotone-rule class="text-icon" />
|
||
<span> 已人工校准</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- List Layout: 主观题 -> Split View (Image Left, Scoring Right) -->
|
||
<div v-else class="w-full flex flex-col gap-6">
|
||
<div
|
||
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">
|
||
<span> 由 ai识别出的答题文本:</span>
|
||
<Typewriter :text="item.AnswerText || '题目'" />
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-6 lg:flex-row">
|
||
<!-- Left: Original Image Display Area -->
|
||
<div
|
||
class="relative min-h-[800px] flex-1 overflow-hidden border-2 border-gray-200 rounded-xl border-dashed bg-gray-50 p-4"
|
||
>
|
||
<div class="absolute left-4 top-4 flex items-center gap-1 text-sm text-gray-400">
|
||
<div class="i-carbon-image" />
|
||
主观题原图展示区
|
||
</div>
|
||
|
||
<div class="h-full flex items-center justify-center">
|
||
<NImage
|
||
v-if="item.AnswerValuePicture || item.imageUrl"
|
||
:src="item.AnswerValuePicture || item.imageUrl" object-fit="contain"
|
||
class="max-h-[800px] w-full"
|
||
/>
|
||
<div v-else class="text-gray-300">
|
||
<div class="i-carbon-no-image text-6xl" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: Scoring Panel -->
|
||
<div class="w-full flex flex-col gap-6 rounded-xl bg-blue-50/30 p-6 lg:w-[320px]">
|
||
<div>
|
||
<div class="text-lg text-gray-800 font-bold">
|
||
人工评分
|
||
</div>
|
||
<div class="mt-1 text-sm text-gray-500">
|
||
请根据对的字数进行打分
|
||
</div>
|
||
<!-- 评分状态展示区域:明确区分 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)"
|
||
>
|
||
<template #suffix>
|
||
分
|
||
</template>
|
||
</NInputNumber>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick Score Buttons -->
|
||
<div class="flex flex-col gap-2">
|
||
<div class="text-sm text-gray-600 font-medium">
|
||
快捷打分
|
||
</div>
|
||
<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 w-full"
|
||
@click="() => handleManualScoreUpdate(item, score)"
|
||
>
|
||
{{ score }}
|
||
</NButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</NCard>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
:deep(.n-card__content) {
|
||
padding: 0;
|
||
}
|
||
</style>
|