Files
reader-star/apps/admin/src/views/results/results-detail/index.vue
rjl 2fad025496 feat(admin): 新增品牌logo资源并优化用户界面组件
- 新增重庆、树人、文字logo资源(PNG和WebP格式)
- 新增用户界面背景图片资源(内容背景、对框、主页背景等)
- 优化ActionButton组件样式(自适应宽度、禁用状态样式)
- 更新CompetitionLayout组件header布局,集成新logo资源
- 优化TianZiGe组件功能和交互体验
- 更新竞赛相关页面组件(QuestionConfig、draw、QuestionRenderer等)
- 更新排名列表页面样式和功能
- 更新诗词常识题库数据
- 完善Competition类型定义
2026-03-18 16:58:13 +08:00

1002 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- 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字段"
// 查看 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 {
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>