feat(admin): 支持模板页码和题目类型细分,优化评分界面

- 在模板详情页添加页码(PageNo)字段,支持模板按页管理
- 将汉字听写和成语题型细分为多个子类型,提升题目分类精度
- 重构结果详情页的评分界面,区分客观题(网格布局)和主观题(分屏布局)
- 添加AI评分推荐和人工校准功能,支持分数手动调整和快捷打分
- 实现活动复制功能,优化题目排序逻辑和界面间距
- 新增评委打分和状态更新接口,完善评分数据持久化机制
This commit is contained in:
2026-03-03 18:10:44 +08:00
parent 94af0bec6a
commit ed3c815cd7
14 changed files with 452 additions and 207 deletions

View File

@ -16,9 +16,11 @@ export enum QuestionType {
} }
export enum QuestionCategoryEnum { export enum QuestionCategoryEnum {
ChineseCharacterDictation = 'ChineseCharacterDictation', // 汉字听写-提示 (jiu) ChineseCharacterDictation1 = 'ChineseCharacterDictation1', // 汉字听写-提示 (jiu表示小鸟的叫声)
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 根据拼音写汉字-提示 (tang)
CharacterRadicalAddition = 'CharacterRadicalAddition', // 汉字加一加 (车) CharacterRadicalAddition = 'CharacterRadicalAddition', // 汉字加一加 (车)
WordDictation = 'WordDictation', // 词语听写 (zhi re) WordDictation = 'WordDictation', // 词语听写 (zhi re)
IdiomWriting = 'IdiomWriting', // 成语-文字要求 (反义字) IdiomWriting1 = 'IdiomWriting1', // 成语-文字要求 (反义字)
IdiomWriting2 = 'IdiomWriting2', // 成语-文字要求 (看图写成语)
PoetryComprehension = 'PoetryComprehension', // 诗词理解-选择题 PoetryComprehension = 'PoetryComprehension', // 诗词理解-选择题
} }

View File

@ -58,7 +58,7 @@ export function fetchCreateQuestion(data: Api.Competition.CreateQuestionRequest[
} }
/** 根据活动ID获取活动题目列表 */ /** 根据活动ID获取活动题目列表 */
export function fetchGetQuestionList(id: string) { export function fetchGetQuestionList(id: number) {
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({ return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}`, url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}`,
method: 'get', method: 'get',
@ -140,3 +140,11 @@ export function fetchUpdatePublishStatus(id: number, status: number) {
method: 'post', method: 'post',
}) })
} }
/** 复制活动 */
export function fetchCopyActivity(ActivityID: number) {
return request<App.Service.Response<Api.Common.CommonResponse>>({
url: `/Base/ActivityMain/CopyActivity?ActivityID=${ActivityID}`,
method: 'post',
})
}

View File

@ -8,3 +8,21 @@ export function updateCurrentQuestionUse(TeamGroupID: number) {
data: { TeamGroupID }, data: { TeamGroupID },
}) })
} }
/** 评委对其进行打分 */
export function updateScore(Id: number, ResultPotins: number) {
return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateTeamQuestionUseOne?Id=${Id}&ResultPotins=${ResultPotins}`,
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 },
})
}

View File

@ -38,13 +38,15 @@ declare namespace Api {
type QuestionTimeType = 5 | 10 | 15 | 20 | 30 | 45 | 60 | 90 /** 5s | 10s | 15s | 20s | 30s | 45s | 60s | 90s */ type QuestionTimeType = 5 | 10 | 15 | 20 | 30 | 45 | 60 | 90 /** 5s | 10s | 15s | 20s | 30s | 45s | 60s | 90s */
/** question template id */ /** question template id */
type QuestionCategoryName = type QuestionCategoryName =
| 'ChineseCharacterDictation' // 汉字听写-提示 (jiu) | 'ChineseCharacterDictation1' // 汉字听写-提示 (jiu表示小鸟的叫声)
| 'CharacterRadicalAddition' // 汉字加一加 (车) | 'ChineseCharacterDictation2' // 根据拼音写汉字-提示 (tang)
| 'WordDictation' // 词语听写 (zhi re) | 'CharacterRadicalAddition' // 汉字加一加 (车)
| 'IdiomWriting' // 成语-文字要求 (反义字) | 'WordDictation' // 词语听写 (zhi re)
// | 'TEMPLATE_IDIOM_IMAGE' // 成语-看图 | 'IdiomWriting1' // 成语-文字要求 (反义字)
| 'PoetryComprehension' // 诗词理解-选择题 | 'IdiomWriting2' // 成语-文字要求 (看图写成语)
// | 'TEMPLATE_IDIOM_IMAGE' // 成语-看图
| 'PoetryComprehension' // 诗词理解-选择题
/** question score type */ /** question score type */
type QuestionScoreType = 0 | 1 /** 固定分数 | 答题个数 */ type QuestionScoreType = 0 | 1 /** 固定分数 | 答题个数 */

View File

@ -46,6 +46,8 @@ declare namespace Api {
BackGroundUrl: string BackGroundUrl: string
/** template content */ /** template content */
TempContent: string TempContent: string
/** page number */
PageNo: string
/** competition id */ /** competition id */
CompetitionId?: number CompetitionId?: number
} }

View File

@ -163,7 +163,7 @@ async function submitForm() {
}) })
// 根据 questionID 降序排序 (例如: 54455 -> 55544) // 根据 questionID 降序排序 (例如: 54455 -> 55544)
questionParams.sort((a, b) => Number(b.questionID) - Number(a.questionID)) // questionParams.sort((a, b) => Number(b.questionID) - Number(a.questionID))
// 重新计算 questionIndex // 重新计算 questionIndex
questionParams.forEach((item, index) => { questionParams.forEach((item, index) => {

View File

@ -186,7 +186,7 @@ defineExpose({ validate, reset })
</div> </div>
<!-- 列表内容 --> <!-- 列表内容 -->
<div v-else class="flex flex-col gap-6 pb-10"> <div v-else class="flex flex-col gap-6 pb-40">
<div <div
v-for="(round, roundIndex) in props.modelValue?.rounds" :key="round.id" v-for="(round, roundIndex) in props.modelValue?.rounds" :key="round.id"
class="group relative overflow-hidden border border-gray-100 rounded-2xl bg-white p-1 shadow-sm transition-all hover:shadow-md" class="group relative overflow-hidden border border-gray-100 rounded-2xl bg-white p-1 shadow-sm transition-all hover:shadow-md"

View File

@ -114,8 +114,8 @@ async function getQuestionList(id: number) {
// 点击编辑模块 // 点击编辑模块
/** 请求编辑模块 */ /** 请求编辑模块 */
function handleRequestEdit(moduleName: string) { function handleRequestEdit(moduleName: string) {
if (basicInfo.value.PublishStatus === PublishStatus.Processing || basicInfo.value.PublishStatus === PublishStatus.Finished) { if (basicInfo.value.PublishStatus === PublishStatus.Processing || basicInfo.value.PublishStatus === PublishStatus.Finished || basicInfo.value.PublishStatus === PublishStatus.Published) {
window.$message?.warning('进行中或已结束的赛事无法编辑') window.$message?.warning('已发布、进行中或已结束的赛事无法编辑')
return return
} }
if (activeEditModule.value && activeEditModule.value !== moduleName) { if (activeEditModule.value && activeEditModule.value !== moduleName) {
@ -220,7 +220,7 @@ async function handleSaveQuestion() {
}) })
// 根据 questionID 降序排序 (例如: 54455 -> 55544) // 根据 questionID 降序排序 (例如: 54455 -> 55544)
flatQuestions.sort((a, b) => Number(b.QuestionID) - Number(a.QuestionID)) // flatQuestions.sort((a, b) => Number(b.QuestionID) - Number(a.QuestionID))
// 重新计算 questionIndex // 重新计算 questionIndex
flatQuestions.forEach((item, index) => { flatQuestions.forEach((item, index) => {

View File

@ -2,7 +2,7 @@
import { NButton, NGi, NGrid, NSpace } from 'naive-ui' import { NButton, NGi, NGrid, NSpace } from 'naive-ui'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouterPush } from '@/hooks/common/router' import { useRouterPush } from '@/hooks/common/router'
import { fetchDeleteActivity, fetchGetActivityList } from '@/service/api/competition' import { fetchCopyActivity, fetchDeleteActivity, fetchGetActivityList } from '@/service/api/competition'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import CompetitionAddModal from '../competition-add/index.vue' import CompetitionAddModal from '../competition-add/index.vue'
@ -21,9 +21,23 @@ function handleAdd() {
showAddModal.value = true showAddModal.value = true
} }
function handleCopy(item: Api.Competition.CompetitionItem) { async function handleCopy(item: Api.Competition.CompetitionItem) {
// TODO: 实现复制逻辑 // TODO: 实现复制逻辑
try {
const { error } = await fetchCopyActivity(item.Id)
if (error) {
window.$message?.error(error.message || '复制失败')
return
}
}
catch (e) {
console.error(e)
window.$message?.error('复制失败')
return
}
window.$message?.success(`已复制活动:${item.Name}`) window.$message?.success(`已复制活动:${item.Name}`)
// 刷新列表
fetchActivityList()
} }
function handleDelete(item: Api.Competition.CompetitionItem) { function handleDelete(item: Api.Competition.CompetitionItem) {

View File

@ -1,14 +1,19 @@
<!-- eslint-disable no-console -->
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { NButton, NCard, NImage, useMessage } from 'naive-ui' import { useStorage } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue' import { NButton, NCard, NImage, NInputNumber, NTag, useMessage } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { QuestionCategoryEnum } from '@/enum/business' import { QuestionCategoryEnum } from '@/enum/business'
import { fetchGetQuestionList } from '@/service/api/competition' import { fetchGetQuestionList } from '@/service/api/competition'
import { fetchGetCurrentQuestionByRoomID, fetchGetGameStatistics, fetchGetGroupListByActivityID } from '@/service/api/game' import { fetchGetCurrentQuestionByRoomID, fetchGetGameStatistics, fetchGetGroupListByActivityID } from '@/service/api/game'
import { updateCurrentQuestionUse } from '@/service/api/result' import { updateCurrentQuestionUse, updateScore } from '@/service/api/result'
import GroupTabs, { type GroupItem } from './modules/GroupTabs.vue' import GroupTabs, { type GroupItem } from './modules/GroupTabs.vue'
// 本地持久化存储手动修改的状态 (key: roomId-questionId-guid)
const manualStatusStorage = useStorage<Record<string, number>>('read-star-manual-status', {})
const route = useRoute() const route = useRoute()
const message = useMessage() const message = useMessage()
const activityId = computed(() => route.query?.activityId as string) const activityId = computed(() => route.query?.activityId as string)
@ -24,17 +29,12 @@ const currentQuestion = ref<Api.Competition.GetCurrentQuestionInfo>() // 当前
// 计算当前题目类型对应的布局 // 计算当前题目类型对应的布局
const currentLayout = computed(() => { const currentLayout = computed(() => {
if (!currentQuestionType.value) // 只有 汉字加一加 (CharacterRadicalAddition) 需要人工评分 (列表布局 - 分屏视图)
return 'list' // 默认列表 // 其他所有题型 (汉字听写、诗词、词语听写、成语) 都使用长框展示 (网格布局 - 带切换的大图)
if (currentQuestionType.value === QuestionCategoryEnum.CharacterRadicalAddition) {
// 汉字听写、汉字加一加、选择题 -> Grid (多个小方块) return 'list'
const gridTypes = [ }
QuestionCategoryEnum.ChineseCharacterDictation, return 'grid'
QuestionCategoryEnum.CharacterRadicalAddition,
QuestionCategoryEnum.PoetryComprehension,
]
return gridTypes.includes(currentQuestionType.value as QuestionCategoryEnum) ? 'grid' : 'list'
}) })
interface TeamResult { interface TeamResult {
@ -48,9 +48,28 @@ interface TeamResult {
// 当前所有队伍的解析结果列表 // 当前所有队伍的解析结果列表
const teamResults = ref<TeamResult[]>([]) const teamResults = ref<TeamResult[]>([])
// 切换状态逻辑:点击后 Correct <-> Wrong // 手动设置状态(确认或修改)
function toggleStatus(team: TeamResult, item: any) { async function setStatus(team: TeamResult, item: any, status: number) {
item.Status = item.Status === 1 ? 0 : 1 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
}
}
async function handleManualScoreUpdate(item: any, newScore: number) {
item.Score = newScore
item._isManual = true
try {
await updateScore(item.TeamQuestionUseID, newScore)
message.success('分数已更新')
}
catch (e: any) {
message.error(e.message || '保存分数失败')
}
} }
function handleNext() { function handleNext() {
@ -86,72 +105,6 @@ function syncCurrentQuestionSelection() {
} }
} }
// Mock 数据生成器
// eslint-disable-next-line unused-imports/no-unused-vars
function generateMockData() {
// Mock 分组
groups.value = Array.from({ length: 5 }).map((_, i) => ({
id: i + 1,
name: `${i + 1}`,
}))
if (groups.value.length > 0) {
currentGroupId.value = groups.value[0].id
}
// Mock 题目
// 1. 汉字听写 (Grid)
// 2. 汉字加一加 (Grid)
// 3. 诗词理解/选择题 (Grid)
// 4. 词语听写 (Long)
// 5. 成语写一写 (Long)
const mockTypes = [
// QuestionCategoryEnum.ChineseCharacterDictation,
// QuestionCategoryEnum.CharacterRadicalAddition,
// QuestionCategoryEnum.PoetryComprehension,
QuestionCategoryEnum.WordDictation,
QuestionCategoryEnum.IdiomWriting,
]
// 生成 10 个题目,覆盖所有类型
questionList.value = Array.from({ length: 10 }).map((_, i) => ({
id: i + 1,
name: `题目 ${i + 1}`,
type: mockTypes[i % mockTypes.length],
}))
if (questionList.value.length > 0) {
currentQuestionId.value = questionList.value[0].id
currentQuestionType.value = questionList.value[0].type
}
// Mock 答题卡数据
teamResults.value = Array.from({ length: 4 }).map((_, i) => {
// 根据当前题目类型决定答案数量
// 词语听写(WordDictation)、成语(IdiomWriting) 通常是单个答案块(包含多个字),整体判分
// 汉字听写(ChineseCharacterDictation)、汉字加一加(CharacterRadicalAddition)、诗词(PoetryComprehension) 通常是多个答案块,单独判分
let answerCount = 5
if ([QuestionCategoryEnum.WordDictation, QuestionCategoryEnum.IdiomWriting].includes(currentQuestionType.value as QuestionCategoryEnum)) {
answerCount = 1
}
return {
TeamId: i + 1,
TeamName: `Mock 战队 ${i + 1}`,
UserAnswerPicture: 'https://placehold.co/800x400?text=Original+Answer+Sheet',
Points: 0,
Answers: Array.from({ length: answerCount }).map((__, j) => ({
AnswerValuePicture: answerCount > 1
? `https://placehold.co/100x100?text=Answer+${j + 1}`
: `https://placehold.co/400x100?text=Long+Answer`,
Status: Math.random() > 0.5 ? 1 : 0,
TeamQuestionUseID: 123,
Guid: `mock-guid-${i}-${j}`,
})),
}
})
}
/** /**
* 获取分组列表 * 获取分组列表
*/ */
@ -188,7 +141,7 @@ async function fetchQuestions() {
if (!activityId.value) if (!activityId.value)
return return
try { try {
const { data, error } = await fetchGetQuestionList(activityId.value) const { data, error } = await fetchGetQuestionList(Number(activityId.value))
if (error) { if (error) {
message.error(error.message) message.error(error.message)
return return
@ -205,6 +158,7 @@ async function fetchQuestions() {
if (questionList.value.length > 0 && !currentQuestionId.value) { if (questionList.value.length > 0 && !currentQuestionId.value) {
currentQuestionId.value = questionList.value[0].id currentQuestionId.value = questionList.value[0].id
currentQuestionType.value = questionList.value[0].type currentQuestionType.value = questionList.value[0].type
console.warn(questionList.value, 'questionList')
} }
// 题目加载完成后尝试同步到“当前进行中的题目” // 题目加载完成后尝试同步到“当前进行中的题目”
syncCurrentQuestionSelection() syncCurrentQuestionSelection()
@ -217,7 +171,6 @@ async function fetchQuestions() {
/** /**
* 获取当前正在比赛的题目 * 获取当前正在比赛的题目
*/ */
async function fetchCurrentQuestion() { async function fetchCurrentQuestion() {
if (!roomId.value) if (!roomId.value)
return return
@ -243,47 +196,10 @@ async function fetchCurrentQuestion() {
async function fetchAnswerData() { async function fetchAnswerData() {
if (!currentGroupId.value || !currentQuestionId.value || !currentQuestion.value?.QuestionDetailID) if (!currentGroupId.value || !currentQuestionId.value || !currentQuestion.value?.QuestionDetailID)
return return
// 如果是 Mock 环境,重新生成一下随机答案
if (!activityId.value) {
// 复用 generateMockData 中的逻辑来更新 teamResults
// 但这里只需要更新 results 部分,不需要重置 group 和 questionList
teamResults.value = Array.from({ length: 4 }).map((_, i) => {
let answerCount = 5
// Grid 类型 (ChineseCharacterDictation, CharacterRadicalAddition, PoetryComprehension) -> 多图
// List 类型 (WordDictation, IdiomWriting) -> 单图
const isListType = [QuestionCategoryEnum.WordDictation, QuestionCategoryEnum.IdiomWriting].includes(currentQuestionType.value as QuestionCategoryEnum)
if (isListType) {
answerCount = 1
}
return {
TeamId: i + 1,
TeamName: `Mock 战队 ${i + 1}`,
UserAnswerPicture: 'https://placehold.co/800x400?text=Original+Answer+Sheet',
Points: 0,
Answers: Array.from({ length: answerCount }).map((__, j) => ({
AnswerValuePicture: answerCount > 1
? `https://placehold.co/100x100?text=Answer+${j + 1}`
: `https://placehold.co/400x100?text=Long+Answer`,
Status: Math.random() > 0.5 ? 1 : 0,
TeamQuestionUseID: 123,
Guid: `mock-guid-${i}-${j}`,
})),
}
})
return
}
try { try {
// 这里假设 fetchGetGameStatistics 需要的参数是 TeamGroupID, QuestionID, QuestionDetaiID
// 由于接口定义中 QuestionDetaiID 是必须的,但根据需求描述可能只知道 QuestionId
// 这里暂时传 0 或者从 questionList 中获取更多信息如果可用
const params = { const params = {
TeamGroupID: Number(currentGroupId.value), TeamGroupID: Number(currentGroupId.value),
QuestionID: Number(currentQuestionId.value), QuestionID: Number(currentQuestionId.value),
// QuestionDetaiID: currentQuestion.value?.QuestionDetailID || 0,
} }
const { data, error } = await fetchGetGameStatistics(params) const { data, error } = await fetchGetGameStatistics(params)
@ -294,26 +210,89 @@ async function fetchAnswerData() {
// 假设返回的数据结构是数组,取第一条或者根据逻辑取特定的一条 // 假设返回的数据结构是数组,取第一条或者根据逻辑取特定的一条
const result = data?.data || [] const result = data?.data || []
console.warn('game statistics result', result) console.log('接口返回的游戏统计数据:', result)
result.forEach((item) => {
const UserAnswerFont = JSON.parse(item.UserAnswerFont)
console.log('解析后的 UserAnswerFont:', UserAnswerFont)
})
if (result && result.length > 0) { 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) {
oldMap.set(a.Guid, a)
}
})
}
})
}
teamResults.value = result.map((answerData) => { teamResults.value = result.map((answerData) => {
let parsedAnswers: any[] = [] let parsedAnswers: any[] = []
try { try {
const parsedFonts = JSON.parse(answerData.UserAnswerFont) if (answerData.UserAnswerFont) {
parsedAnswers = parsedFonts const parsedFonts = JSON.parse(answerData.UserAnswerFont)
console.warn('parsed fonts', parsedFonts) parsedAnswers = Array.isArray(parsedFonts) ? parsedFonts : []
console.warn('parsed fonts', parsedFonts)
}
} }
catch (e) { catch (e) {
console.error('解析 UserAnswerFont 失败', e) console.error('解析 UserAnswerFont 失败', e)
} }
// 将新答案与旧的手动编辑合并
const mergedAnswers = parsedAnswers.map((ans: any) => {
const oldAns = oldMap.get(ans.Guid)
// 检查本地存储是否有手动修改记录
const storageKey = `${roomId.value}-${currentQuestionId.value}-${answerData.TeamId}-${ans.Guid}`
const storedStatus = manualStatusStorage.value[storageKey]
const hasStoredStatus = storedStatus !== undefined
// 记录 AI 的原始推荐值(如果后端返回了)
const aiStatus = ans.Status
const aiScore = ans.Score
if ((oldAns && oldAns._isManual) || hasStoredStatus) {
// 优先使用本地存储的状态,其次是内存中的手动编辑状态
const finalStatus = hasStoredStatus ? storedStatus : (oldAns ? oldAns.Status : ans.Status)
const finalScore = oldAns ? oldAns.Score : ans.Score // 分数目前只在内存保留,也可以加 localStorage
return {
...ans,
Score: finalScore,
Status: finalStatus,
_isManual: true,
_aiStatus: aiStatus, // 保存 AI 的最新状态供参考
_aiScore: aiScore,
}
}
// 如果没有手动编辑,不要自动应用 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 推荐"
_aiStatus: aiStatus,
_aiScore: aiScore,
_isManual: false, // 明确标记为非手动
}
})
return { return {
TeamId: answerData.TeamId, TeamId: answerData.TeamId,
TeamName: answerData.TeamName, TeamName: answerData.TeamName,
UserAnswerPicture: answerData.UserAnswerPicture, UserAnswerPicture: answerData.UserAnswerPicture,
Points: answerData.Points, Points: answerData.Points,
Answers: parsedAnswers, Answers: mergedAnswers,
} }
}) })
} }
@ -340,18 +319,89 @@ async function handleNexCurrentQuestion() {
function handlePublish() { function handlePublish() {
if (currentQuestion.value?.TeamGroupQuestionID) { if (currentQuestion.value?.TeamGroupQuestionID) {
window.$dialog?.info({ // 检查是否有未评分的题目
title: '发布确认', let hasUnrated = false
content: '确定要发布当前题目吗?', teamResults.value.forEach((team) => {
positiveText: '发布', if (team.Answers) {
negativeText: '取消', 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 () => { onPositiveClick: async () => {
await fetchUpdateCurrentQuestionUse(currentQuestion.value?.TeamGroupQuestionID || 0) // 在发布前,将所有手动修改的分数/状态提交一次
const updatePromises: Promise<any>[] = []
teamResults.value.forEach((team) => {
if (team.Answers) {
team.Answers.forEach((item: any) => {
if (item._isManual) {
// 调用 updateScore 接口提交分数
// 对于客观题,如果 Status=1 (Correct)Score=1Status=0 (Wrong)Score=0
// 假设客观题满分是 1 分 (或者根据业务逻辑调整)
let scoreToSubmit = item.Score
if (currentLayout.value === 'grid') {
scoreToSubmit = item.Status === 1 ? 1 : 0
}
updatePromises.push(updateScore(item.TeamQuestionUseID, scoreToSubmit))
}
})
}
})
try {
await Promise.all(updatePromises)
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]
})
}
catch (error: any) {
window.$message?.error(error.message || '提交分数或发布失败,请重试')
}
}, },
}) })
} }
} }
/**
* 消费并发布当前题目
*/
const pollingTimer = ref<NodeJS.Timeout | null>(null)
function startPolling() {
if (pollingTimer.value)
return
fetchAnswerData() // 立即获取
pollingTimer.value = setInterval(() => {
fetchAnswerData()
}, 3000)
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value)
pollingTimer.value = null
}
}
/** /**
* 消费并发布当前题目 * 消费并发布当前题目
*/ */
@ -361,6 +411,7 @@ async function fetchUpdateCurrentQuestionUse(TeamGroupQuestionID: number) {
console.warn('updateCurrentQuestionUse result', data) console.warn('updateCurrentQuestionUse result', data)
if (!error) { if (!error) {
window?.$message?.success('结果已发布') window?.$message?.success('结果已发布')
stopPolling() // 发布后停止轮询
} }
} }
catch (error: any) { catch (error: any) {
@ -375,22 +426,32 @@ watch([currentGroupId, currentQuestionId], () => {
if (question) { if (question) {
currentQuestionType.value = question.type currentQuestionType.value = question.type
} }
fetchAnswerData() // 重新启动新问题的轮询(通过获取重新开始隐式清除旧的手动编辑?不,等等。)
// 我们可能需要重置 teamResults 或根据需要处理手动编辑清除。
// 目前fetchAnswerData 将根据新问题数据重新初始化。
// 但是,如果我们保持轮询,我们需要确保如果 ID 冲突(如果 Guid 是唯一的,则不太可能),我们不会保留上一个问题的手动编辑。
// 让我们停止并重新启动以保持清洁。
stopPolling()
teamResults.value = [] // 清除旧结果以避免闪烁或陈旧数据
startPolling()
} }
}) })
onMounted(async () => { onMounted(async () => {
await fetchGroups() // 并行拉取分组、题目与当前题目,完成后做一次同步
// 并行拉取题目与当前题目,完成后做一次同步 await Promise.all([fetchGroups(), fetchQuestions(), fetchCurrentQuestion()])
await Promise.all([fetchQuestions(), fetchCurrentQuestion()])
syncCurrentQuestionSelection() syncCurrentQuestionSelection()
// generateMockData() startPolling()
})
onUnmounted(() => {
stopPolling()
}) })
</script> </script>
<template> <template>
<div class="h-full flex flex-col gap-2 overflow-hidden bg-[#F9FAFB] p-6"> <div class="h-full flex flex-col gap-2 overflow-hidden bg-[#F9FAFB] p-6">
<!-- Top Bar --> <!-- 顶部栏 -->
<div class="flex items-center justify-between rounded-2xl bg-white px-6 py-4 shadow-sm"> <div class="flex items-center justify-between rounded-2xl bg-white px-6 py-4 shadow-sm">
<div class="flex items-center gap-8"> <div class="flex items-center gap-8">
<h1 class="m-0 flex items-center gap-2 text-xl text-gray-800 font-bold"> <h1 class="m-0 flex items-center gap-2 text-xl text-gray-800 font-bold">
@ -419,20 +480,38 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- Main Content --> <!-- 主要内容 -->
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-auto">
<div v-if="teamResults.length === 0" class="h-full flex items-center justify-center text-gray-400"> <div v-if="teamResults.length === 0" class="h-full flex items-center justify-center text-gray-400">
暂无队伍数据 暂无队伍数据
</div> </div>
<div v-else class="flex flex-col gap-6 pb-6"> <div v-else class="flex flex-col gap-6 pb-6">
<NCard <NCard v-for="team in teamResults" :key="team.TeamId" hoverable>
v-for="team in teamResults" :key="team.TeamId" :title="team.TeamName ? `${team.TeamName}` : '队伍信息'" <template #header>
hoverable <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="currentQuestion?.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 }}
</div>
</div>
</div>
</template>
<div class="flex flex-row items-start gap-6 px-4 py-2"> <div class="flex flex-row items-start gap-6 px-4 py-2">
<!-- 学生答题原图 --> <!-- 学生答题原图 -->
<div class="UserAnswerPicture flex-shrink-0"> <!-- <div class="UserAnswerPicture flex-shrink-0">
<NImage <NImage
v-if="team.UserAnswerPicture" :src="team.UserAnswerPicture" width="550" object-fit="contain" v-if="team.UserAnswerPicture" :src="team.UserAnswerPicture" width="550" object-fit="contain"
class="block border border-gray-200 rounded-lg" class="block border border-gray-200 rounded-lg"
@ -443,65 +522,175 @@ onMounted(async () => {
> >
暂无原图 暂无原图
</div> </div>
</div> </div> -->
<!-- 解析答题里面的题目 --> <!-- 解析答题里面的题目 -->
<div class="UserAnswerFont flex-1"> <div class="UserAnswerFont flex-1">
<div class="flex flex-wrap gap-x-8 gap-y-4"> <div class="flex flex-wrap gap-x-8 gap-y-4">
<!-- Grid Layout --> <!-- 网格布局汉字听写汉字加一加选择题 -> 带切换的大图 -->
<div v-if="currentLayout === 'grid'" class="flex flex-wrap gap-4"> <div v-if="currentLayout === 'grid'" class="w-full flex flex-col gap-6">
<div <div
v-for="(item) in team.Answers" :key="item.Guid" v-for="(item) in team.Answers" :key="item.Guid"
class="relative cursor-pointer transition-all duration-200" @click="toggleStatus(team, item)" 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'),
]"
> >
<!-- Image Container with Status Border --> <!-- 图片上方的标签 -->
<div class="mb-2 text-center text-lg text-gray-700 font-bold">
{{ item.AnswerText || '答案' }}
</div>
<!-- AI Suggestion Indicator -->
<div <div
class="relative overflow-hidden border-2 rounded-md border-dashed bg-white p-1" :class="[ v-if="!item._isManual"
item.Status === 1 ? 'border-green-500' : 'border-red-500', class="mb-2 flex items-center justify-center gap-1 text-xs text-orange-500 font-bold"
]"
> >
<div class="flex items-center justify-center"> <div class="i-carbon-ai-status" />
<NImage AI 推荐: {{ item.Status === 1 ? '正确' : '错误' }}
:src="item.AnswerValuePicture || item.imageUrl" preview-disabled object-fit="contain" </div>
/> <!-- Manual Calibration Indicator -->
<!-- {{ item.AnswerText }} --> <div
</div> v-else
<!-- Status Icon Overlay --> class="mb-2 flex items-center justify-center gap-1 text-xs text-green-600 font-bold"
<div class="absolute z-10 rounded-full bg-white -right-3px -top-3px"> >
<Icon v-if="item.Status === 1" icon="mdi:check-circle" class="text-2xl text-green-500" /> <div class="i-carbon-user-avatar-filled-alt" />
<Icon v-else icon="mdi:close-circle" class="text-2xl text-red-500" /> 已人工校准: {{ item.Status === 1 ? '正确' : '错误' }}
</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>
</div> </div>
<!-- Label if exists --> <!-- Manual Calibration Badge (Bottom Right) -->
<div class="mt-1 text-center text-sm text-gray-500"> <div class="absolute bottom-4 right-4 z-10">
{{ item.AnswerText }} <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" />
已人工校准
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- List Layout (Long Rectangle for Words/Idioms) --> <!-- List Layout: 主观题 -> Split View (Image Left, Scoring Right) -->
<div v-else class="flex flex-wrap gap-x-8 gap-y-4"> <div v-else class="w-full flex flex-col gap-6">
<div <div
v-for="(item, idx) in team.Answers" :key="item.Guid" v-for="(item, idx) in team.Answers" :key="item.Guid"
class="relative cursor-pointer transition-all duration-200" @click="toggleStatus(team, item)" class="flex flex-col gap-6 border border-gray-100 rounded-xl bg-white p-6 shadow-sm"
> >
<div class="flex items-center gap-2"> <!-- Title -->
<span class="w-6 text-gray-600 font-medium">{{ idx + 1 }}.</span> <div class="text-lg text-gray-800 font-bold">
{{ idx + 1 }}. {{ item.AnswerText || '题目' }} (汉字加一加)
</div>
<div class="flex flex-col gap-6 lg:flex-row">
<!-- Left: Original Image Display Area -->
<div <div
class="relative overflow-hidden border-2 rounded-md border-dashed bg-white px-2 py-1" :class="[ class="relative min-h-[800px] flex-1 overflow-hidden border-2 border-gray-200 rounded-xl border-dashed bg-gray-50 p-4"
item.Status === 1 ? 'border-green-500' : 'border-red-500',
]"
> >
<NImage <div class="absolute left-4 top-4 flex items-center gap-1 text-sm text-gray-400">
:src="item.AnswerValuePicture || item.imageUrl" preview-disabled object-fit="contain" <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>
<!-- Status Icon Overlay --> <!-- Right: Scoring Panel -->
<div class="absolute z-10 rounded-full bg-white shadow-sm -right-3 -top-3"> <div class="w-full flex flex-col gap-6 rounded-xl bg-blue-50/30 p-6 lg:w-[320px]">
<Icon v-if="item.Status === 1" icon="mdi:check-circle" class="text-2xl text-green-500" /> <div>
<Icon v-else icon="mdi:close-circle" class="text-2xl text-red-500" /> <div class="text-lg text-gray-800 font-bold">
人工评分
</div>
<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 }}
</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-2 gap-3">
<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"
@click="() => handleManualScoreUpdate(item, score)"
>
{{ score }}
</NButton>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -131,7 +131,7 @@ async function handleSave() {
const params: Api.Template.AddTemplateParams = { const params: Api.Template.AddTemplateParams = {
id, id,
pageNo: '1761.172.8.24', pageNo: templateInfo.value.pageNo || '1761.172.8.25',
name: templateInfo.value.name, name: templateInfo.value.name,
backGroundUrl: templateInfo.value.backGroundUrl || '', backGroundUrl: templateInfo.value.backGroundUrl || '',
width: Math.round(templateInfo.value.width), width: Math.round(templateInfo.value.width),
@ -145,6 +145,10 @@ async function handleSave() {
message.error('请输入模板名称') message.error('请输入模板名称')
return return
} }
if (!params.pageNo) {
message.error('请输入页码')
return
}
const { error } = await fetchAddTemplate(params) const { error } = await fetchAddTemplate(params)
@ -169,11 +173,12 @@ async function initData() {
if (!error && response) { if (!error && response) {
const { data, success } = response const { data, success } = response
if (success) { if (success) {
const { Name, Width, Height, BackGroundUrl, TempContent } = data || {} const { Name, Width, Height, BackGroundUrl, TempContent, PageNo } = data || {}
templateInfo.value = { templateInfo.value = {
name: Name, name: Name,
width: Width, width: Width,
height: Height, height: Height,
pageNo: PageNo,
backGroundUrl: BackGroundUrl, backGroundUrl: BackGroundUrl,
} }
imgSrc.value = BackGroundUrl || '' imgSrc.value = BackGroundUrl || ''

View File

@ -59,6 +59,9 @@ function handleSelect(region: Region, e: MouseEvent) {
<NFormItem label="模版名称"> <NFormItem label="模版名称">
<NInput v-model:value="localTemplateInfo.name" /> <NInput v-model:value="localTemplateInfo.name" />
</NFormItem> </NFormItem>
<NFormItem label="页码(PageNo)">
<NInput v-model:value="localTemplateInfo.pageNo" placeholder="请输入页码" />
</NFormItem>
<NGrid :cols="2" :x-gap="12"> <NGrid :cols="2" :x-gap="12">
<NGridItem> <NGridItem>
<NFormItem label="画布宽 (px)"> <NFormItem label="画布宽 (px)">

View File

@ -14,6 +14,7 @@ export interface TemplateInfo {
name: string name: string
width: number width: number
height: number height: number
pageNo?: string
backGroundUrl?: string backGroundUrl?: string
} }

View File

@ -79,16 +79,17 @@ watch(
<template> <template>
<div class="w-full flex p-6"> <div class="w-full flex p-6">
<!-- template:{{ template }} --> <!-- <p>template:{{ template }}</p> -->
<!-- 模板A: 汉字听写-提示 (拼音+提示) --> <!-- 模板A: 汉字听写1-提示 (拼音+提示) -->
<div v-if="template === QuestionCategoryEnum.ChineseCharacterDictation" class="text-center"> <div v-if="template === QuestionCategoryEnum.ChineseCharacterDictation1" class="text-center">
<div class="mb-6 inline-block text-5xl font-bold leading-none"> <div class="mb-6 inline-block text-5xl font-bold leading-none">
<div class="ptions-container1 mb-2 text-center text-2xl"> <div class="ptions-container1 mb-2 text-center text-2xl">
{{ title }} {{ title }}
</div> </div>
<!-- <div class="rich-content ml-3 flex gap-1"> <div class="rich-content ml-3 mt-4 flex gap-1 color-red-600">
<TianZiGe v-for="(char, index) in pinyinChars" :key="index" :char="char" /> <!-- <TianZiGe v-for="(char, index) in pinyinChars" :key="index" :char="char" /> -->
</div> --> {{ content }}
</div>
</div> </div>
</div> </div>
@ -126,7 +127,7 @@ watch(
</div> </div>
<!-- 模板E: 成语-文字要求 --> <!-- 模板E: 成语-文字要求 -->
<div v-else-if="template === QuestionCategoryEnum.IdiomWriting" class="text-center"> <div v-else-if="template === QuestionCategoryEnum.IdiomWriting1" class="text-center">
<div class="mb-8 text-4xl text-gray-800 font-bold"> <div class="mb-8 text-4xl text-gray-800 font-bold">
<!-- {{ content }} --> <!-- {{ content }} -->
<div class="rich-content" v-html="content" /> <div class="rich-content" v-html="content" />