feat(admin): 支持模板页码和题目类型细分,优化评分界面
- 在模板详情页添加页码(PageNo)字段,支持模板按页管理 - 将汉字听写和成语题型细分为多个子类型,提升题目分类精度 - 重构结果详情页的评分界面,区分客观题(网格布局)和主观题(分屏布局) - 添加AI评分推荐和人工校准功能,支持分数手动调整和快捷打分 - 实现活动复制功能,优化题目排序逻辑和界面间距 - 新增评委打分和状态更新接口,完善评分数据持久化机制
This commit is contained in:
@ -16,9 +16,11 @@ export enum QuestionType {
|
||||
}
|
||||
|
||||
export enum QuestionCategoryEnum {
|
||||
ChineseCharacterDictation = 'ChineseCharacterDictation', // 汉字听写-提示 (jiu)
|
||||
ChineseCharacterDictation1 = 'ChineseCharacterDictation1', // 汉字听写-提示 (jiu表示小鸟的叫声)
|
||||
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 根据拼音写汉字-提示 (tang)
|
||||
CharacterRadicalAddition = 'CharacterRadicalAddition', // 汉字加一加 (车)
|
||||
WordDictation = 'WordDictation', // 词语听写 (zhi re)
|
||||
IdiomWriting = 'IdiomWriting', // 成语-文字要求 (反义字)
|
||||
IdiomWriting1 = 'IdiomWriting1', // 成语-文字要求 (反义字)
|
||||
IdiomWriting2 = 'IdiomWriting2', // 成语-文字要求 (看图写成语)
|
||||
PoetryComprehension = 'PoetryComprehension', // 诗词理解-选择题
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ export function fetchCreateQuestion(data: Api.Competition.CreateQuestionRequest[
|
||||
}
|
||||
|
||||
/** 根据活动ID获取活动题目列表 */
|
||||
export function fetchGetQuestionList(id: string) {
|
||||
export function fetchGetQuestionList(id: number) {
|
||||
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
|
||||
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}`,
|
||||
method: 'get',
|
||||
@ -140,3 +140,11 @@ export function fetchUpdatePublishStatus(id: number, status: number) {
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
/** 复制活动 */
|
||||
export function fetchCopyActivity(ActivityID: number) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: `/Base/ActivityMain/CopyActivity?ActivityID=${ActivityID}`,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
@ -8,3 +8,21 @@ export function updateCurrentQuestionUse(TeamGroupID: number) {
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
6
apps/admin/src/typings/api/Competition.d.ts
vendored
6
apps/admin/src/typings/api/Competition.d.ts
vendored
@ -39,10 +39,12 @@ declare namespace Api {
|
||||
|
||||
/** question template id */
|
||||
type QuestionCategoryName =
|
||||
| 'ChineseCharacterDictation' // 汉字听写-提示 (jiu)
|
||||
| 'ChineseCharacterDictation1' // 汉字听写-提示 (jiu表示小鸟的叫声)
|
||||
| 'ChineseCharacterDictation2' // 根据拼音写汉字-提示 (tang)
|
||||
| 'CharacterRadicalAddition' // 汉字加一加 (车)
|
||||
| 'WordDictation' // 词语听写 (zhi re)
|
||||
| 'IdiomWriting' // 成语-文字要求 (反义字)
|
||||
| 'IdiomWriting1' // 成语-文字要求 (反义字)
|
||||
| 'IdiomWriting2' // 成语-文字要求 (看图写成语)
|
||||
// | 'TEMPLATE_IDIOM_IMAGE' // 成语-看图
|
||||
| 'PoetryComprehension' // 诗词理解-选择题
|
||||
|
||||
|
||||
2
apps/admin/src/typings/api/template.d.ts
vendored
2
apps/admin/src/typings/api/template.d.ts
vendored
@ -46,6 +46,8 @@ declare namespace Api {
|
||||
BackGroundUrl: string
|
||||
/** template content */
|
||||
TempContent: string
|
||||
/** page number */
|
||||
PageNo: string
|
||||
/** competition id */
|
||||
CompetitionId?: number
|
||||
}
|
||||
|
||||
@ -163,7 +163,7 @@ async function submitForm() {
|
||||
})
|
||||
|
||||
// 根据 questionID 降序排序 (例如: 54455 -> 55544)
|
||||
questionParams.sort((a, b) => Number(b.questionID) - Number(a.questionID))
|
||||
// questionParams.sort((a, b) => Number(b.questionID) - Number(a.questionID))
|
||||
|
||||
// 重新计算 questionIndex
|
||||
questionParams.forEach((item, index) => {
|
||||
|
||||
@ -186,7 +186,7 @@ defineExpose({ validate, reset })
|
||||
</div>
|
||||
|
||||
<!-- 列表内容 -->
|
||||
<div v-else class="flex flex-col gap-6 pb-10">
|
||||
<div v-else class="flex flex-col gap-6 pb-40">
|
||||
<div
|
||||
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"
|
||||
|
||||
@ -114,8 +114,8 @@ async function getQuestionList(id: number) {
|
||||
// 点击编辑模块
|
||||
/** 请求编辑模块 */
|
||||
function handleRequestEdit(moduleName: string) {
|
||||
if (basicInfo.value.PublishStatus === PublishStatus.Processing || basicInfo.value.PublishStatus === PublishStatus.Finished) {
|
||||
window.$message?.warning('进行中或已结束的赛事无法编辑')
|
||||
if (basicInfo.value.PublishStatus === PublishStatus.Processing || basicInfo.value.PublishStatus === PublishStatus.Finished || basicInfo.value.PublishStatus === PublishStatus.Published) {
|
||||
window.$message?.warning('已发布、进行中或已结束的赛事无法编辑')
|
||||
return
|
||||
}
|
||||
if (activeEditModule.value && activeEditModule.value !== moduleName) {
|
||||
@ -220,7 +220,7 @@ async function handleSaveQuestion() {
|
||||
})
|
||||
|
||||
// 根据 questionID 降序排序 (例如: 54455 -> 55544)
|
||||
flatQuestions.sort((a, b) => Number(b.QuestionID) - Number(a.QuestionID))
|
||||
// flatQuestions.sort((a, b) => Number(b.QuestionID) - Number(a.QuestionID))
|
||||
|
||||
// 重新计算 questionIndex
|
||||
flatQuestions.forEach((item, index) => {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { NButton, NGi, NGrid, NSpace } from 'naive-ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
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 CompetitionAddModal from '../competition-add/index.vue'
|
||||
|
||||
@ -21,9 +21,23 @@ function handleAdd() {
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function handleCopy(item: Api.Competition.CompetitionItem) {
|
||||
async function handleCopy(item: Api.Competition.CompetitionItem) {
|
||||
// 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}`)
|
||||
// 刷新列表
|
||||
fetchActivityList()
|
||||
}
|
||||
|
||||
function handleDelete(item: Api.Competition.CompetitionItem) {
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
<!-- eslint-disable no-console -->
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { NButton, NCard, NImage, useMessage } from 'naive-ui'
|
||||
import { computed, onMounted, ref, watch } from '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 } 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'
|
||||
|
||||
// 本地持久化存储手动修改的状态 (key: roomId-questionId-guid)
|
||||
const manualStatusStorage = useStorage<Record<string, number>>('read-star-manual-status', {})
|
||||
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const activityId = computed(() => route.query?.activityId as string)
|
||||
@ -24,17 +29,12 @@ const currentQuestion = ref<Api.Competition.GetCurrentQuestionInfo>() // 当前
|
||||
|
||||
// 计算当前题目类型对应的布局
|
||||
const currentLayout = computed(() => {
|
||||
if (!currentQuestionType.value)
|
||||
return 'list' // 默认列表
|
||||
|
||||
// 汉字听写、汉字加一加、选择题 -> Grid (多个小方块)
|
||||
const gridTypes = [
|
||||
QuestionCategoryEnum.ChineseCharacterDictation,
|
||||
QuestionCategoryEnum.CharacterRadicalAddition,
|
||||
QuestionCategoryEnum.PoetryComprehension,
|
||||
]
|
||||
|
||||
return gridTypes.includes(currentQuestionType.value as QuestionCategoryEnum) ? 'grid' : 'list'
|
||||
// 只有 汉字加一加 (CharacterRadicalAddition) 需要人工评分 (列表布局 - 分屏视图)
|
||||
// 其他所有题型 (汉字听写、诗词、词语听写、成语) 都使用长框展示 (网格布局 - 带切换的大图)
|
||||
if (currentQuestionType.value === QuestionCategoryEnum.CharacterRadicalAddition) {
|
||||
return 'list'
|
||||
}
|
||||
return 'grid'
|
||||
})
|
||||
|
||||
interface TeamResult {
|
||||
@ -48,9 +48,28 @@ interface TeamResult {
|
||||
// 当前所有队伍的解析结果列表
|
||||
const teamResults = ref<TeamResult[]>([])
|
||||
|
||||
// 切换状态逻辑:点击后 Correct <-> Wrong
|
||||
function toggleStatus(team: TeamResult, item: any) {
|
||||
item.Status = item.Status === 1 ? 0 : 1
|
||||
// 手动设置状态(确认或修改)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -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)
|
||||
return
|
||||
try {
|
||||
const { data, error } = await fetchGetQuestionList(activityId.value)
|
||||
const { data, error } = await fetchGetQuestionList(Number(activityId.value))
|
||||
if (error) {
|
||||
message.error(error.message)
|
||||
return
|
||||
@ -205,6 +158,7 @@ async function fetchQuestions() {
|
||||
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()
|
||||
@ -217,7 +171,6 @@ async function fetchQuestions() {
|
||||
/**
|
||||
* 获取当前正在比赛的题目
|
||||
*/
|
||||
|
||||
async function fetchCurrentQuestion() {
|
||||
if (!roomId.value)
|
||||
return
|
||||
@ -243,47 +196,10 @@ async function fetchCurrentQuestion() {
|
||||
async function fetchAnswerData() {
|
||||
if (!currentGroupId.value || !currentQuestionId.value || !currentQuestion.value?.QuestionDetailID)
|
||||
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 {
|
||||
// 这里假设 fetchGetGameStatistics 需要的参数是 TeamGroupID, QuestionID, QuestionDetaiID
|
||||
// 由于接口定义中 QuestionDetaiID 是必须的,但根据需求描述可能只知道 QuestionId
|
||||
// 这里暂时传 0 或者从 questionList 中获取更多信息如果可用
|
||||
const params = {
|
||||
TeamGroupID: Number(currentGroupId.value),
|
||||
QuestionID: Number(currentQuestionId.value),
|
||||
// QuestionDetaiID: currentQuestion.value?.QuestionDetailID || 0,
|
||||
}
|
||||
|
||||
const { data, error } = await fetchGetGameStatistics(params)
|
||||
@ -294,26 +210,89 @@ async function fetchAnswerData() {
|
||||
|
||||
// 假设返回的数据结构是数组,取第一条或者根据逻辑取特定的一条
|
||||
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) {
|
||||
// 创建现有项目的映射以保留手动编辑
|
||||
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) => {
|
||||
let parsedAnswers: any[] = []
|
||||
try {
|
||||
if (answerData.UserAnswerFont) {
|
||||
const parsedFonts = JSON.parse(answerData.UserAnswerFont)
|
||||
parsedAnswers = parsedFonts
|
||||
parsedAnswers = Array.isArray(parsedFonts) ? parsedFonts : []
|
||||
console.warn('parsed fonts', parsedFonts)
|
||||
}
|
||||
}
|
||||
catch (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 {
|
||||
TeamId: answerData.TeamId,
|
||||
TeamName: answerData.TeamName,
|
||||
UserAnswerPicture: answerData.UserAnswerPicture,
|
||||
Points: answerData.Points,
|
||||
Answers: parsedAnswers,
|
||||
Answers: mergedAnswers,
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -340,18 +319,89 @@ async function handleNexCurrentQuestion() {
|
||||
|
||||
function handlePublish() {
|
||||
if (currentQuestion.value?.TeamGroupQuestionID) {
|
||||
window.$dialog?.info({
|
||||
title: '发布确认',
|
||||
content: '确定要发布当前题目吗?',
|
||||
positiveText: '发布',
|
||||
negativeText: '取消',
|
||||
// 检查是否有未评分的题目
|
||||
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 () => {
|
||||
// 在发布前,将所有手动修改的分数/状态提交一次
|
||||
const updatePromises: Promise<any>[] = []
|
||||
|
||||
teamResults.value.forEach((team) => {
|
||||
if (team.Answers) {
|
||||
team.Answers.forEach((item: any) => {
|
||||
if (item._isManual) {
|
||||
// 调用 updateScore 接口提交分数
|
||||
// 对于客观题,如果 Status=1 (Correct),Score=1;Status=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)
|
||||
if (!error) {
|
||||
window?.$message?.success('结果已发布')
|
||||
stopPolling() // 发布后停止轮询
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
@ -375,22 +426,32 @@ watch([currentGroupId, currentQuestionId], () => {
|
||||
if (question) {
|
||||
currentQuestionType.value = question.type
|
||||
}
|
||||
fetchAnswerData()
|
||||
// 重新启动新问题的轮询(通过获取重新开始隐式清除旧的手动编辑?不,等等。)
|
||||
// 我们可能需要重置 teamResults 或根据需要处理手动编辑清除。
|
||||
// 目前,fetchAnswerData 将根据新问题数据重新初始化。
|
||||
// 但是,如果我们保持轮询,我们需要确保如果 ID 冲突(如果 Guid 是唯一的,则不太可能),我们不会保留上一个问题的手动编辑。
|
||||
// 让我们停止并重新启动以保持清洁。
|
||||
stopPolling()
|
||||
teamResults.value = [] // 清除旧结果以避免闪烁或陈旧数据
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchGroups()
|
||||
// 并行拉取题目与当前题目,完成后做一次同步
|
||||
await Promise.all([fetchQuestions(), fetchCurrentQuestion()])
|
||||
// 并行拉取分组、题目与当前题目,完成后做一次同步
|
||||
await Promise.all([fetchGroups(), fetchQuestions(), fetchCurrentQuestion()])
|
||||
syncCurrentQuestionSelection()
|
||||
// generateMockData()
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 gap-8">
|
||||
<h1 class="m-0 flex items-center gap-2 text-xl text-gray-800 font-bold">
|
||||
@ -419,20 +480,38 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<!-- 主要内容 -->
|
||||
<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" :title="team.TeamName ? `${team.TeamName}` : '队伍信息'"
|
||||
hoverable
|
||||
>
|
||||
<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="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="UserAnswerPicture flex-shrink-0">
|
||||
<!-- <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"
|
||||
@ -443,65 +522,175 @@ onMounted(async () => {
|
||||
>
|
||||
暂无原图
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 解析答题里面的题目 -->
|
||||
<div class="UserAnswerFont flex-1">
|
||||
<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
|
||||
v-for="(item) in team.Answers" :key="item.Guid"
|
||||
class="relative cursor-pointer transition-all duration-200" @click="toggleStatus(team, item)"
|
||||
>
|
||||
<!-- Image Container with Status Border -->
|
||||
<div
|
||||
class="relative overflow-hidden border-2 rounded-md border-dashed bg-white p-1" :class="[
|
||||
item.Status === 1 ? 'border-green-500' : 'border-red-500',
|
||||
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 class="mb-2 text-center text-lg text-gray-700 font-bold">
|
||||
{{ item.AnswerText || '答案' }}
|
||||
</div>
|
||||
|
||||
<!-- AI Suggestion Indicator -->
|
||||
<div
|
||||
v-if="!item._isManual"
|
||||
class="mb-2 flex items-center justify-center gap-1 text-xs text-orange-500 font-bold"
|
||||
>
|
||||
<div class="i-carbon-ai-status" />
|
||||
AI 推荐: {{ item.Status === 1 ? '正确' : '错误' }}
|
||||
</div>
|
||||
<!-- Manual Calibration Indicator -->
|
||||
<div
|
||||
v-else
|
||||
class="mb-2 flex items-center justify-center gap-1 text-xs text-green-600 font-bold"
|
||||
>
|
||||
<div class="i-carbon-user-avatar-filled-alt" />
|
||||
已人工校准: {{ item.Status === 1 ? '正确' : '错误' }}
|
||||
</div>
|
||||
<!-- 图片展示 -->
|
||||
<div class="flex items-center justify-center">
|
||||
<NImage
|
||||
:src="item.AnswerValuePicture || item.imageUrl" preview-disabled object-fit="contain"
|
||||
:src="item.AnswerValuePicture || item.imageUrl" object-fit="contain"
|
||||
class="max-h-[300px] w-full"
|
||||
/>
|
||||
<!-- {{ item.AnswerText }} -->
|
||||
</div>
|
||||
<!-- Status Icon Overlay -->
|
||||
<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" />
|
||||
<Icon v-else icon="mdi:close-circle" class="text-2xl text-red-500" />
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Label if exists -->
|
||||
<div class="mt-1 text-center text-sm text-gray-500">
|
||||
{{ item.AnswerText }}
|
||||
<!-- 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"
|
||||
>
|
||||
<div class="i-carbon-checkmark" />
|
||||
已人工校准
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List Layout (Long Rectangle for Words/Idioms) -->
|
||||
<div v-else class="flex flex-wrap gap-x-8 gap-y-4">
|
||||
<!-- List Layout: 主观题 -> Split View (Image Left, Scoring Right) -->
|
||||
<div v-else class="w-full flex flex-col gap-6">
|
||||
<div
|
||||
v-for="(item, idx) in team.Answers" :key="item.Guid"
|
||||
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">
|
||||
<span class="w-6 text-gray-600 font-medium">{{ idx + 1 }}.</span>
|
||||
<!-- Title -->
|
||||
<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
|
||||
class="relative overflow-hidden border-2 rounded-md border-dashed bg-white px-2 py-1" :class="[
|
||||
item.Status === 1 ? 'border-green-500' : 'border-red-500',
|
||||
]"
|
||||
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
|
||||
:src="item.AnswerValuePicture || item.imageUrl" preview-disabled object-fit="contain"
|
||||
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 -->
|
||||
<div class="absolute z-10 rounded-full bg-white shadow-sm -right-3 -top-3">
|
||||
<Icon v-if="item.Status === 1" icon="mdi:check-circle" class="text-2xl text-green-500" />
|
||||
<Icon v-else icon="mdi:close-circle" class="text-2xl text-red-500" />
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
@ -131,7 +131,7 @@ async function handleSave() {
|
||||
|
||||
const params: Api.Template.AddTemplateParams = {
|
||||
id,
|
||||
pageNo: '1761.172.8.24',
|
||||
pageNo: templateInfo.value.pageNo || '1761.172.8.25',
|
||||
name: templateInfo.value.name,
|
||||
backGroundUrl: templateInfo.value.backGroundUrl || '',
|
||||
width: Math.round(templateInfo.value.width),
|
||||
@ -145,6 +145,10 @@ async function handleSave() {
|
||||
message.error('请输入模板名称')
|
||||
return
|
||||
}
|
||||
if (!params.pageNo) {
|
||||
message.error('请输入页码')
|
||||
return
|
||||
}
|
||||
|
||||
const { error } = await fetchAddTemplate(params)
|
||||
|
||||
@ -169,11 +173,12 @@ async function initData() {
|
||||
if (!error && response) {
|
||||
const { data, success } = response
|
||||
if (success) {
|
||||
const { Name, Width, Height, BackGroundUrl, TempContent } = data || {}
|
||||
const { Name, Width, Height, BackGroundUrl, TempContent, PageNo } = data || {}
|
||||
templateInfo.value = {
|
||||
name: Name,
|
||||
width: Width,
|
||||
height: Height,
|
||||
pageNo: PageNo,
|
||||
backGroundUrl: BackGroundUrl,
|
||||
}
|
||||
imgSrc.value = BackGroundUrl || ''
|
||||
|
||||
@ -59,6 +59,9 @@ function handleSelect(region: Region, e: MouseEvent) {
|
||||
<NFormItem label="模版名称">
|
||||
<NInput v-model:value="localTemplateInfo.name" />
|
||||
</NFormItem>
|
||||
<NFormItem label="页码(PageNo)">
|
||||
<NInput v-model:value="localTemplateInfo.pageNo" placeholder="请输入页码" />
|
||||
</NFormItem>
|
||||
<NGrid :cols="2" :x-gap="12">
|
||||
<NGridItem>
|
||||
<NFormItem label="画布宽 (px)">
|
||||
|
||||
@ -14,6 +14,7 @@ export interface TemplateInfo {
|
||||
name: string
|
||||
width: number
|
||||
height: number
|
||||
pageNo?: string
|
||||
backGroundUrl?: string
|
||||
}
|
||||
|
||||
|
||||
@ -79,16 +79,17 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="w-full flex p-6">
|
||||
<!-- template:{{ template }} -->
|
||||
<!-- 模板A: 汉字听写-提示 (拼音+提示) -->
|
||||
<div v-if="template === QuestionCategoryEnum.ChineseCharacterDictation" class="text-center">
|
||||
<!-- <p>template:{{ template }}</p> -->
|
||||
<!-- 模板A: 汉字听写1-提示 (拼音+提示) -->
|
||||
<div v-if="template === QuestionCategoryEnum.ChineseCharacterDictation1" class="text-center">
|
||||
<div class="mb-6 inline-block text-5xl font-bold leading-none">
|
||||
<div class="ptions-container1 mb-2 text-center text-2xl">
|
||||
{{ title }}
|
||||
</div>
|
||||
<!-- <div class="rich-content ml-3 flex gap-1">
|
||||
<TianZiGe v-for="(char, index) in pinyinChars" :key="index" :char="char" />
|
||||
</div> -->
|
||||
<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" /> -->
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -126,7 +127,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<!-- 模板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">
|
||||
<!-- {{ content }} -->
|
||||
<div class="rich-content" v-html="content" />
|
||||
|
||||
Reference in New Issue
Block a user