Files
miniprogram-1/miniprogram/pages/qa_exercise/qa_exercise.ts
2026-01-18 11:48:03 +08:00

1688 lines
64 KiB
TypeScript
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.
import apiManager from '../../utils/api'
import logger from '../../utils/logger'
import { IQaExerciseItem, IQaExerciseSession, IAppOption } from '../../types/app'
export const QUESTION_MODES = {
CLOZE: 'cloze',
CHOICE: 'choice',
VARIATION: 'variation',
CONVERSATION: 'conversation'
} as const
export type QuestionMode = typeof QUESTION_MODES[keyof typeof QUESTION_MODES]
export const ENABLED_QUESTION_MODES: QuestionMode[] = [QUESTION_MODES.CHOICE, QUESTION_MODES.CLOZE, QUESTION_MODES.VARIATION, QUESTION_MODES.CONVERSATION]
interface IData {
loadingMaskVisible: boolean
imageId: string
taskId?: string
statusText: string
exercises: IQaExerciseItem[]
contentVisible: boolean
contentReady?: boolean
exercise?: any
session?: IQaExerciseSession
qaList: any[]
currentIndex: number
questionMode: QuestionMode
choiceOptions: Array<{ content: string; correct: boolean; type?: string }>
selectedOptionIndexes: number[]
selectedIndex?: number
submittedFlags?: boolean[]
selectedFlags?: boolean[]
selectedCount?: number
choiceRequiredCount?: number
choiceSubmitted?: boolean
evalClasses?: string[]
clozeSentenceWithBlank?: string
clozeCorrectWord?: string
clozeDistractorWords?: string[]
clozeOptions?: string[]
selectedClozeIndex?: number
clozeParts?: string[]
clozeSentenceTokens?: Array<{ text: string; word?: string; isBlank?: boolean }>
imageLocalUrl?: string
imageLoaded?: boolean
modeAnim?: '' | 'fade-in' | 'fade-out'
isModeSwitching?: boolean
progressText?: string
questionWords?: string[]
showDictPopup?: boolean
showDictExtended?: boolean
dictLoading?: boolean
wordDict?: any
showBackIcon?: boolean
prototypeWord?: string
isWordEmptyResult?: boolean
dictDefaultTabValue?: string
activeWordAudioType?: string
wordAudioPlaying?: boolean
wordAudioIconName?: string
previousWord?: string
isReturningFromPrevious?: boolean
forceHidePrototype?: boolean
answers?: Record<string, string>
qaResultCache?: Record<string, any>
qaResultFetched?: Record<string, boolean>
resultDisplayed?: boolean
qaDetailVisible?: boolean
qaDetailQuestionText?: string
qaDetailOverviewText?: string
qaDetailResultStatus?: 'correct' | 'partial' | 'incorrect' | 'no correct'
qaDetailIconName?: string
qaDetailBlocks?: Array<{ title: string; variant: 'correct' | 'incorrect' | 'missing' | 'info'; items: any[]; iconName?: string }>
submitDisabled?: boolean
retryDisabled?: boolean
audioUrlMap?: Record<string, string>
audioLocalMap?: Record<string, string>
isPlaying?: boolean
processDotClasses?: string[]
showCompletionPopup?: boolean
isTrialMode?: boolean
fixedMode?: QuestionMode,
canvasWidth?: number,
canvasHeight?: number,
confetti?: any
nextButtonIcon?: string
isAutoSwitching?: boolean
variationQaList?: any[]
mainQaList?: any[]
variationTaskId?: string
variationImages?: Record<string, string>
variationImagesLoading?: Record<string, boolean>
variationImageLoaded: Record<string, boolean>
variationSelectedIndex?: number
variationSubmitted?: boolean
variationResultStatus?: 'correct' | 'incorrect'
variationExerciseId?: string
conversationSetting?: any
}
interface IPageInstance {
pollTimer?: number
variationPollTimer?: number
conversationPollTimer?: number
audioCtx?: WechatMiniprogram.InnerAudioContext
fetchQaExercises: (imageId: string, referrerId?: string) => Promise<void>
fetchVariationExercises: (imageId: string) => Promise<void>
fetchConversationSetting: (imageId: string) => Promise<void>
startPolling: (taskId: string, imageId: string) => void
startVariationPolling: (taskId: string, imageId: string) => void
startConversationInitPolling: (taskId: string, imageId: string) => void
initExerciseContent: (exercise: any, session?: IQaExerciseSession) => void
updateActionButtonsState: () => void
shuffleArray: <T>(arr: T[]) => T[]
buildClozeSentence: (sentenceRaw: any, correctWordRaw: any) => { blanked: string; parts: string[]; tokens: Array<{ text: string; word?: string; isBlank?: boolean }> }
updateContentReady: () => void
switchQuestion: (delta: number) => void
switchMode: (arg: any) => void
selectOption: (e: any) => void
selectVariationOption: (e: any) => void
onRetryTap: () => void
ensureQuestionResultFetched: (questionId: string) => Promise<void>
applyCachedResultForQuestion: (questionId: string) => void
previewImage: () => void
submitAttempt: () => Promise<void>
onPrevTap: () => void
triggerAutoNextIfCorrect: (evaluation: any, qid: string) => void
onNextTap: () => void
toggleMode: () => void
selectClozeOption: (e: any) => void
onSubmitTap: () => void
onScoreTap: () => void
onCloseDetailModal: () => void
buildDetailViewModel: (evaluation: any, questionText: string, base: any) => { overviewText: string; blocks: Array<{ title: string; variant: 'correct' | 'incorrect' | 'missing' | 'info'; items: any[] }>; status: 'correct' | 'partial' | 'incorrect'; iconName: string }
getEvaluationForMode: (resultObj: any, mode: QuestionMode) => any
handleWordClick: (e: any) => Promise<void>
handleDictClose: () => void
handleDictMore: () => void
onTabsChange: (e: any) => void
onTabsClick: (e: any) => void
handleBackToPreviousWord: () => void
onOptionLongPress: (e: any) => void
onImageLoad: () => void
onImageError: () => void
getQuestionAudio: (questionId: string) => Promise<string | undefined>
playStandardVoice: () => void
updateProcessDots: () => void
checkAllQuestionsAttempted: () => void
handleCompletionPopupClose: () => void
handleShareAchievement: () => void
fireConfetti: () => void
resetConfetti: () => void
loadVariationImages: (list: any[]) => void
onVariationImageLoad: (e: any) => void
previewVariationImage: (e: any) => void
}
Page<IData, IPageInstance>({
data: {
loadingMaskVisible: false,
imageId: '',
statusText: '生成练习中...',
exercises: [],
contentVisible: false,
contentReady: false,
qaList: [],
currentIndex: 0,
questionMode: QUESTION_MODES.CHOICE,
choiceOptions: [],
selectedOptionIndexes: [],
selectedIndex: -1,
submittedFlags: [],
selectedFlags: [],
selectedCount: 0,
choiceRequiredCount: 0,
imageLoaded: false,
choiceSubmitted: false,
evalClasses: [],
clozeSentenceTokens: [],
modeAnim: '',
isModeSwitching: false,
progressText: '',
questionWords: [],
showDictPopup: false,
showDictExtended: false,
dictLoading: false,
wordDict: {},
showBackIcon: false,
prototypeWord: '',
isWordEmptyResult: false,
dictDefaultTabValue: '0',
activeWordAudioType: '',
wordAudioPlaying: false,
wordAudioIconName: 'sound',
previousWord: '',
isReturningFromPrevious: false,
forceHidePrototype: false,
answers: {
lively: "",
spoken: "",
written: "",
friendly: ""
},
qaResultCache: {},
qaResultFetched: {},
qaDetailResultStatus: 'partial',
qaDetailIconName: '',
qaDetailBlocks: [],
submitDisabled: false,
retryDisabled: true,
audioUrlMap: {},
audioLocalMap: {},
isPlaying: false,
fixedMode: undefined,
canvasWidth: 0,
canvasHeight: 0,
confetti: null,
nextButtonIcon: 'chevron-right',
isAutoSwitching: false,
variationQaList: [],
variationTaskId: '',
variationImages: {},
variationImagesLoading: {},
variationImageLoaded: {},
variationSelectedIndex: -1,
variationSubmitted: false
},
updateProcessDots() {
const list = this.data.qaList || []
const cache = this.data.qaResultCache || {}
const session = this.data.session
let attempts: any[] = []
if (session && typeof session.progress === 'object' && session.progress !== null && 'attempts' in session.progress) {
attempts = (session.progress as any).attempts
}
const attemptMap = new Map()
attempts.forEach((a: any) => attemptMap.set(String(a.question_id), a))
const items = list.map((q, idx) => {
const qid = String((q as any)?.id || '')
const r = cache[qid] || {}
const c1 = !!((r.choice || {}).evaluation)
const c2 = !!((r.cloze || {}).evaluation)
const c4 = !!((r.variation || {}).evaluation)
let depth = Number(c1) + Number(c2) + Number(c4)
if (depth === 0 && attemptMap.has(qid)) {
const att = attemptMap.get(qid)
const isCorrect = ['correct'].includes(String(att.is_correct || '').toLowerCase())
depth = isCorrect ? 3 : 1
}
if (depth <= 0) return 'dot-0'
if (depth === 1) return 'dot-1'
if (depth === 2) return 'dot-2'
return 'dot-3'
})
this.setData({ processDotClasses: items })
},
checkAllQuestionsAttempted() {
const list = this.data.qaList || []
const cache = this.data.qaResultCache || {}
const session = this.data.session
// 如果是试玩模式,仅检查本地缓存是否有结果
if (this.data.isTrialMode) {
const allAttempted = list.every((q: any) => {
const qid = String(q.id)
const r = cache[qid] || {}
return !!((r.choice || {}).evaluation) || !!((r.cloze || {}).evaluation) || !!((r.variation || {}).evaluation)
})
if (allAttempted) {
this.setData({ showCompletionPopup: true })
}
return
}
let attempts: any[] = []
if (session && typeof session.progress === 'object' && session.progress !== null && 'attempts' in session.progress) {
attempts = (session.progress as any).attempts
}
const attemptSet = new Set(attempts.map((a: any) => String(a.question_id)))
const allAttempted = list.every((q: any) => {
const qid = String(q.id)
// Check local cache (latest submission)
const r = cache[qid] || {}
const hasLocalResult = !!((r.choice || {}).evaluation) || !!((r.cloze || {}).evaluation) || !!((r.variation || {}).evaluation)
// Check session history
const hasSessionAttempt = attemptSet.has(qid)
return hasLocalResult || hasSessionAttempt
})
if (allAttempted) {
this.setData({ showCompletionPopup: true })
}
},
handleCompletionPopupClose() {
this.setData({ showCompletionPopup: false })
},
handleShareAchievement() {
// 已经通过 open-type="share" 触发 onShareAppMessage
// 这里可以添加埋点或其他逻辑
logger.log('Share achievement clicked')
},
updateActionButtonsState() {
const mode = this.data.questionMode
const hasResult = !!this.data.resultDisplayed
let submitDisabled = false
let retryDisabled = true
if (mode === QUESTION_MODES.CHOICE) {
const count = Number(this.data.selectedCount || 0)
const required = Number(this.data.choiceRequiredCount || 0)
submitDisabled = hasResult || count < required
retryDisabled = !hasResult
} else if (mode === QUESTION_MODES.CLOZE) {
submitDisabled = hasResult
retryDisabled = !hasResult
} else if (mode === QUESTION_MODES.VARIATION) {
const hasSelection = typeof this.data.variationSelectedIndex === 'number' && this.data.variationSelectedIndex >= 0
submitDisabled = hasResult || !hasSelection
retryDisabled = !hasResult
}
this.setData({ submitDisabled, retryDisabled })
},
shuffleArray<T>(arr: T[]) {
const a = (arr || []).slice()
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const t = a[i]
a[i] = a[j]
a[j] = t
}
return a
},
buildClozeSentence(sentenceRaw: any, correctWordRaw: any) {
const sentence = typeof sentenceRaw === 'string' ? sentenceRaw : ''
const correctWord = typeof correctWordRaw === 'string' ? correctWordRaw : ''
const rawTokens = sentence.trim().length ? sentence.trim().split(/\s+/) : []
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z-']/g, '')
const target = normalize(correctWord)
const blankIndex = target ? rawTokens.findIndex((t) => normalize(t) === target) : -1
const blankedTokens = rawTokens.slice()
if (blankIndex >= 0) blankedTokens[blankIndex] = '___'
const blanked = blankedTokens.join(' ')
let parts = blanked.split('___')
if (parts.length < 2) parts = [blanked, '']
if (parts.length > 2) parts = [parts[0], parts.slice(1).join('___')]
const tokens = rawTokens.map((t, idx) => {
if (idx === blankIndex) return { text: '_____', word: '', isBlank: true }
return { text: t, word: t, isBlank: false }
})
return { blanked, parts, tokens }
},
updateContentReady() {
const hasList = Array.isArray(this.data.qaList) && this.data.qaList.length > 0
this.setData({ contentReady: hasList })
},
onImageLoad() {
this.setData({ imageLoaded: true })
this.updateContentReady()
},
onImageError() {
this.setData({ imageLoaded: true })
this.updateContentReady()
},
previewVariationImage(e: any) {
logger.log('previewVariationImage', e)
const ds = e?.currentTarget?.dataset || {}
const fileId = String(ds.fileid || '')
if (!fileId) return
const map = this.data.variationImages || {}
const url = map[fileId]
if (!url) return
wx.previewImage({ urls: [url] })
},
onOptionLongPress(e: any) {
const ds = e?.currentTarget?.dataset || {}
const raw = String(ds.word || '').trim()
// 只有独立的单词能够触发查询,如果包含空格则认为是短语或句子
if (!raw || /\s/.test(raw)) return
const word = raw.replace(/[^A-Za-z-]/g, '')
if (!word) return
this.setData({ showDictPopup: true, showDictExtended: false })
const comp = this.selectComponent('#wordDict') as any
if (comp && typeof comp.queryWord === 'function') {
comp.queryWord(word)
}
},
onPrevTap() {
this.switchQuestion(-1)
},
onNextTap() {
this.setData({ nextButtonIcon: 'chevron-right', isAutoSwitching: false })
this.switchQuestion(1)
},
toggleMode() {
const order: QuestionMode[] = ENABLED_QUESTION_MODES
const i = order.indexOf(this.data.questionMode)
const next = order[(i + 1) % order.length]
this.switchMode(next)
},
onReady() {
// 获取组件实例
(this as any).confetti = this.selectComponent('#confetti');
},
fireConfetti() {
// 触发五彩纸屑效果 - 注意这是异步方法返回Promise
(this as any).confetti.fire({
particleCount: 100,
spread: 70,
origin: { x: 0.5, y: 0.5 }
}).then(() => {
logger.log('五彩纸屑效果已启动');
}).catch((err: any) => {
logger.error('启动失败', err);
});
},
resetConfetti() {
// 重置画布,清除五彩纸屑
(this as any).confetti?.reset?.();
},
async onLoad(options: Record<string, string>) {
try {
const app = getApp<IAppOption>()
const type = options.type as QuestionMode
if (type && ENABLED_QUESTION_MODES.includes(type)) {
this.setData({ fixedMode: type, questionMode: type })
}
// 处理推荐人ID
const referrerId = options.referrer || options.referrerId || options.referrer_id
if (referrerId) {
app.globalData.pendingReferrerId = referrerId
console.log('检测到推荐人ID:', referrerId)
}
// 处理试玩模式
// const isTrialMode = options.mode === 'trial'
// if (isTrialMode) {
// this.setData({ isTrialMode: true })
// wx.showToast({
// title: '当前为练习模式,进度不保存',
// icon: 'none',
// duration: 3000
// })
// }
try {
const windowInfo = (wx as any).getWindowInfo ? (wx as any).getWindowInfo() : wx.getSystemInfoSync()
this.setData({
canvasWidth: windowInfo.windowWidth,
canvasHeight: windowInfo.windowHeight
});
} catch (e) {
this.setData({
canvasWidth: 375,
canvasHeight: 667
});
logger.error('获取窗口信息失败:', e)
}
const imageId = options?.id || options?.image_id || ''
const thumbnailId = options?.thumbnail_id || '' // 兼容旧逻辑,如果是分享进来的可能需要通过 API 获取图片链接
this.setData({ imageId, loadingMaskVisible: true, statusText: '生成练习中...' })
if (type === QUESTION_MODES.VARIATION) {
await this.fetchVariationExercises(imageId)
} else {
await this.fetchQaExercises(imageId, referrerId)
}
// 如果没有 thumbnailId尝试通过 imageId 获取(或者 fetchQaExercises 内部处理了?)
// 假设 fetchQaExercises 会处理内容,这里主要处理图片加载
if (thumbnailId) {
try {
const url = await apiManager.getFileDisplayUrl(String(thumbnailId))
this.setData({ imageLocalUrl: url, imageLoaded: false })
try {
wx.getImageInfo({
src: url,
success: () => {
this.setData({ imageLoaded: true })
this.updateContentReady()
},
fail: () => {
this.setData({ imageLoaded: false })
this.updateContentReady()
}
})
} catch(e) {
console.error(e)
}
} catch (e) {
console.error('获取图片URL失败:', e)
}
} else {
// 如果没有 thumbnailIdfetchQaExercises 可能会加载图片,这里暂时不做额外处理,
// 除非 fetchQaExercises 获取的 exercise 数据里有 image_url
}
} catch (error) {
logger.error('QaExercise onLoad Error:', error)
this.setData({ loadingMaskVisible: false, statusText: '加载失败' })
}
},
onShareAppMessage(options: any) {
const app = getApp<IAppOption>()
const myUserId = app.globalData.userInfo?.id || ''
const imageId = this.data.imageId
const title = this.data.exercise?.title || '英语口语练习'
// 检查是否是从成就弹窗分享
const isAchievement = options?.from === 'button' && options?.target?.dataset?.type === 'achievement'
const type = this.data.fixedMode || this.data.questionMode
// 构建分享路径
const path = `/pages/qa_exercise/qa_exercise?id=${imageId}&referrer=${myUserId}&mode=trial&type=${type}`
let shareTitle = `一起解锁拍照学英语的快乐!👇`
let imageUrl = this.data.imageLocalUrl || undefined
if (isAchievement) {
shareTitle = `图片里面是什么?你也来试试吧!`
// 如果有特定的成就图片,可以在这里设置
// imageUrl = '...'
}
logger.log('分享路径:', path)
// 记录分享触发日志
// 注意:微信不再支持 success/fail 回调,无法精确判断分享是否成功,此处仅记录触发行为
logger.log('User triggered share', {
from: options.from,
path,
imageId
})
// TODO: 未来如果需要对接后端分享统计接口,可在此处调用,例如:
// apiManager.reportShare({ imageId, from: options.from })
return {
title: shareTitle,
path: path,
imageUrl: imageUrl // 使用当前题目图片作为分享图
}
},
async fetchQaExercises(imageId: string, referrerId?: string) {
try {
const res = await apiManager.listQaExercisesByImage(imageId, 'scene_basic')
if (res && res.exercise && Array.isArray(res.questions) && res.questions.length > 0) {
const exercise = res.exercise as any
const qaList = (res.questions || []).map((q: any) => ({
id: q.id,
exercise_id: q.exercise_id,
image_id: q.image_id,
question: q.question,
...(q.ext || {})
}))
const exerciseWithList = { ...exercise, qa_list: qaList }
const session = res.session as IQaExerciseSession
const total = Number(exercise?.question_count || qaList.length || 0)
let completed = 0
if (typeof session?.progress === 'number') {
completed = session.progress
} else if (session?.progress && typeof (session.progress as any).answered === 'number') {
completed = (session.progress as any).answered
}
const progressText = `已完成 ${completed} / ${total}`
// Save to mainQaList as well
this.setData({ session, progressText, mainQaList: qaList })
this.initExerciseContent(exerciseWithList, session)
// 尝试获取图片链接(如果是通过 image_id 进入且没有 thumbnail_id
if (!this.data.imageLocalUrl && imageId) {
try {
logger.log('Fetching image file_id for imageId:', imageId)
const fileId = await apiManager.getImageFileId(imageId)
logger.log('Fetching image url for fileId:', fileId)
const url = await apiManager.getFileDisplayUrl(fileId, referrerId)
this.setData({ imageLocalUrl: url })
} catch (e) {
logger.error('Failed to fetch image url from exercise data', e)
}
}
return
}
const { task_id } = await apiManager.createQaExerciseTask(imageId, 'scene_basic')
this.setData({ taskId: task_id, statusText: '生成练习中...' })
this.startPolling(task_id, imageId)
} catch (e) {
logger.error('获取练习失败', e)
const msg = (e as any)?.message || ''
wx.showToast({ title: msg || '加载失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '加载失败' })
}
},
async loadVariationImages(list: any[]) {
if (!list || list.length === 0) return
const loadingMap: Record<string, boolean> = {}
list.forEach((item: any) => {
const fileId = String(item.file_id || (item.ext || {}).file_id || '')
if (fileId) {
if (!this.data.variationImages || !this.data.variationImages[fileId]) {
loadingMap[fileId] = true
}
}
})
if (Object.keys(loadingMap).length === 0) return
this.setData({
variationImagesLoading: {
...this.data.variationImagesLoading,
...loadingMap
}
})
Object.keys(loadingMap).forEach(async (fileId) => {
try {
const url = await apiManager.getFileDisplayUrl(fileId)
this.setData({
variationImages: { ...this.data.variationImages, [fileId]: url },
variationImagesLoading: { ...this.data.variationImagesLoading, [fileId]: false }
})
} catch (err) {
logger.error('Failed to get variation image url', err)
this.setData({
variationImagesLoading: { ...this.data.variationImagesLoading, [fileId]: false }
})
}
})
},
onVariationImageLoad(e: any) {
const fileId = e.currentTarget.dataset.fileid
if (fileId) {
this.setData({
variationImageLoaded: { ...this.data.variationImageLoaded, [fileId]: true },
variationImagesLoading: { ...this.data.variationImagesLoading, [fileId]: false }
})
}
},
async fetchVariationExercises(imageId: string) {
try {
this.setData({ loadingMaskVisible: true, statusText: '加载练习...' })
const res = await apiManager.listQaExercisesByImage(imageId, 'scene_variation')
if (res && Array.isArray(res.questions) && res.questions.length > 0) {
const exercise = res.exercise as any
const variationExerciseId = String(exercise?.id || '')
const session = res.session as IQaExerciseSession
const qaList = (res.questions || []).map((q: any) => ({
id: q.id,
exercise_id: q.exercise_id,
image_id: q.image_id,
question: q.question,
...(q.ext || {})
}))
let idx = 0
if (!this.data.isTrialMode && session?.progress && Array.isArray((session.progress as any).attempts)) {
const attempts = (session.progress as any).attempts
const attemptedIds = new Set(attempts.map((a: any) => String(a.question_id)))
const firstUnansweredIndex = qaList.findIndex((q: any) => !attemptedIds.has(String(q.id)))
if (firstUnansweredIndex !== -1) {
idx = firstUnansweredIndex
}
}
const total = Number((exercise as any)?.question_count || qaList.length || 0)
let completed = 0
if (typeof (session as any)?.progress === 'number') {
completed = (session as any).progress
} else if (session?.progress && typeof (session.progress as any).answered === 'number') {
completed = (session.progress as any).answered
}
const progressText = `已完成 ${completed} / ${total}`
this.setData({ session, progressText })
logger.log('Variation exercises loaded:', qaList.length)
this.setData({
variationQaList: qaList,
variationExerciseId,
loadingMaskVisible: false,
statusText: '加载完成',
qaList: qaList,
currentIndex: idx,
questionMode: 'variation' as QuestionMode
})
this.loadVariationImages(qaList)
this.switchQuestion(0)
return
}
const { task_id } = await apiManager.createQaExerciseTask(imageId, 'scene_variation')
this.setData({ variationTaskId: task_id, statusText: '生成练习中...' })
this.startVariationPolling(task_id, imageId)
} catch (e) {
logger.error('获取练习失败', e)
wx.showToast({ title: '加载失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '加载失败' })
}
},
async fetchConversationSetting(imageId: string) {
try {
this.setData({ loadingMaskVisible: true, statusText: '加载对话设置...' })
const setting = await apiManager.getQaConversationSetting(imageId)
logger.log('Conversation setting:', setting)
if (!setting) {
const { task_id } = await apiManager.createQaExerciseTask(imageId, 'init_conversion')
logger.log('Conversation init task created:', task_id)
this.startConversationInitPolling(task_id, imageId)
return
}
this.setData({
conversationSetting: setting,
loadingMaskVisible: false,
statusText: '加载完成'
})
} catch (e) {
logger.error('获取自由对话设置失败', e)
const msg = (e as any)?.message || ''
wx.showToast({ title: msg || '加载失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '加载失败' })
}
},
startVariationPolling(taskId: string, imageId: string) {
if (this.variationPollTimer) {
clearInterval(this.variationPollTimer)
this.variationPollTimer = undefined
}
this.setData({ loadingMaskVisible: true })
this.variationPollTimer = setInterval(async () => {
try {
const res = await apiManager.getQaExerciseTaskStatus(taskId)
if (res.status === 'completed') {
clearInterval(this.variationPollTimer!)
this.variationPollTimer = undefined
await this.fetchVariationExercises(imageId)
} else if (res.status === 'failed') {
clearInterval(this.variationPollTimer!)
this.variationPollTimer = undefined
wx.showToast({ title: '任务失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '任务失败' })
}
} catch (err) {
logger.error('轮询变化任务状态失败', err)
}
}, 3000) as any
},
startConversationInitPolling(taskId: string, imageId: string) {
if (this.conversationPollTimer) {
clearInterval(this.conversationPollTimer)
this.conversationPollTimer = undefined
}
this.setData({ loadingMaskVisible: true })
this.conversationPollTimer = setInterval(async () => {
try {
const res = await apiManager.getQaExerciseTaskStatus(taskId)
if (res.status === 'completed') {
clearInterval(this.conversationPollTimer!)
this.conversationPollTimer = undefined
await this.fetchConversationSetting(imageId)
} else if (res.status === 'failed') {
clearInterval(this.conversationPollTimer!)
this.conversationPollTimer = undefined
wx.showToast({ title: '任务失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '任务失败' })
}
} catch (err) {
logger.error('轮询自由对话任务状态失败', err)
}
}, 3000) as any
},
startPolling(taskId: string, imageId: string) {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = undefined
}
this.setData({ loadingMaskVisible: true })
this.pollTimer = setInterval(async () => {
try {
const res = await apiManager.getQaExerciseTaskStatus(taskId)
if (res.status === 'completed') {
clearInterval(this.pollTimer!)
this.pollTimer = undefined
await this.fetchQaExercises(imageId)
} else if (res.status === 'failed') {
clearInterval(this.pollTimer!)
this.pollTimer = undefined
wx.showToast({ title: '任务失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '任务失败' })
}
} catch (err) {
logger.error('轮询任务状态失败', err)
}
}, 3000) as any
},
initExerciseContent(exercise: any, session?: any) {
const qaList = Array.isArray(exercise?.qa_list) ? exercise.qa_list : []
let idx = 0
if (this.data.isTrialMode) {
this.setData({ session: undefined, qaResultCache: {}, qaResultFetched: {} })
} else if (session?.progress?.attempts && Array.isArray(session.progress.attempts)) {
this.setData({ session: session })
const attempts = session.progress.attempts
const attemptedIds = new Set(attempts.map((a: any) => String(a.question_id)))
const firstUnansweredIndex = qaList.findIndex((q: any) => !attemptedIds.has(String(q.id)))
if (firstUnansweredIndex !== -1) {
idx = firstUnansweredIndex
}
} else {
this.setData({ session: session })
}
const q = qaList[idx] || {}
const hasOptions = (Array.isArray(q?.correct_options) && q.correct_options.length > 0) || (Array.isArray(q?.incorrect_options) && q.incorrect_options.length > 0)
const hasCloze = !!q?.cloze && !!q.cloze.sentence
const preferredMode: QuestionMode = hasOptions ? QUESTION_MODES.CHOICE : QUESTION_MODES.CLOZE
let mode: QuestionMode = ENABLED_QUESTION_MODES.includes(preferredMode) ? preferredMode : ENABLED_QUESTION_MODES[0]
if (this.data.fixedMode) {
mode = this.data.fixedMode
} else {
mode = QUESTION_MODES.CHOICE
}
let choiceOptions: Array<{ content: string; correct: boolean; type?: string }> = []
if (hasOptions) {
const correct = (q.correct_options || []).map((o: any) => ({ content: o?.content || '', correct: true, type: o?.type }))
const incorrect = (q.incorrect_options || []).map((o: any) => ({ content: o?.content || '', correct: false, type: o?.error_type }))
choiceOptions = [...correct, ...incorrect]
}
const choiceRequiredCount = Array.isArray(q?.correct_options) ? q.correct_options.length : 1
const clozeCorrectWord = q?.cloze?.correct_word || ''
const clozeSentence = q?.cloze?.sentence || ''
const clozeDistractorWords = Array.isArray(q?.cloze?.distractor_words) ? q.cloze.distractor_words : []
const clozeOptions = this.shuffleArray([clozeCorrectWord, ...clozeDistractorWords].filter((w) => !!w))
const { blanked: clozeSentenceWithBlank, parts: clozeParts, tokens: clozeSentenceTokens } = this.buildClozeSentence(clozeSentence, clozeCorrectWord)
const questionText = String(q?.question || '')
const questionWords = questionText.split(/\s+/).filter((w) => !!w)
this.setData({
exercise,
qaList,
currentIndex: idx,
questionMode: mode,
choiceOptions,
selectedOptionIndexes: [],
selectedIndex: -1,
submittedFlags: new Array(qaList.length).fill(false),
selectedFlags: new Array(choiceOptions.length).fill(false),
selectedCount: 0,
choiceRequiredCount,
choiceSubmitted: false,
evalClasses: new Array(choiceOptions.length).fill(''),
clozeSentenceWithBlank,
clozeCorrectWord,
clozeDistractorWords,
clozeOptions,
selectedClozeIndex: -1,
clozeParts,
clozeSentenceTokens,
answers: q.answers || {},
questionWords,
loadingMaskVisible: false,
statusText: '已获取数据',
contentVisible: qaList.length > 0
})
this.updateContentReady()
this.updateProcessDots()
const cur = qaList[idx] || {}
const qid = String(cur?.id || '')
if (qid) {
const hasCached = !!(this.data.qaResultCache || {})[qid]
this.setData({ resultDisplayed: hasCached })
this.updateActionButtonsState()
this.applyCachedResultForQuestion(qid)
this.ensureQuestionResultFetched(qid)
}
},
switchQuestion(delta: number) {
const total = this.data.qaList.length
if (!total) return
let idx = this.data.currentIndex + delta
if (idx < 0) idx = 0
if (idx > total - 1) idx = total - 1
const q = this.data.qaList[idx] || {}
const hasOptions = (Array.isArray(q?.correct_options) && q.correct_options.length > 0) || (Array.isArray(q?.incorrect_options) && q.incorrect_options.length > 0)
const mode: QuestionMode = this.data.questionMode
let choiceOptions: Array<{ content: string; correct: boolean; type?: string }> = []
if (hasOptions) {
const correct = (q.correct_options || []).map((o: any) => ({ content: o?.content || '', correct: true, type: o?.type }))
const incorrect = (q.incorrect_options || []).map((o: any) => ({ content: o?.content || '', correct: false, type: o?.error_type }))
choiceOptions = [...correct, ...incorrect]
}
const choiceRequiredCount = Array.isArray(q?.correct_options) ? q.correct_options.length : 1
const clozeCorrectWord = q?.cloze?.correct_word || ''
const clozeSentence = q?.cloze?.sentence || ''
const clozeDistractorWords = Array.isArray(q?.cloze?.distractor_words) ? q.cloze.distractor_words : []
const clozeOptions = this.shuffleArray([clozeCorrectWord, ...clozeDistractorWords].filter((w) => !!w))
const { blanked: clozeSentenceWithBlank, parts: clozeParts, tokens: clozeSentenceTokens } = this.buildClozeSentence(clozeSentence, clozeCorrectWord)
const questionText = String(q?.question || '')
const questionWords = questionText.split(/\s+/).filter((w) => !!w)
this.setData({
currentIndex: idx,
questionMode: mode,
choiceOptions,
selectedOptionIndexes: [],
selectedIndex: -1,
selectedFlags: new Array(choiceOptions.length).fill(false),
selectedCount: 0,
choiceRequiredCount,
choiceSubmitted: false,
evalClasses: new Array(choiceOptions.length).fill(''),
clozeSentenceWithBlank,
clozeCorrectWord,
clozeDistractorWords,
clozeOptions,
selectedClozeIndex: -1,
clozeParts,
clozeSentenceTokens,
answers: q.answers || {},
questionWords
})
const cur = this.data.qaList[idx] || {}
const qid = String(cur?.id || '')
if (qid) {
const cache = (this.data.qaResultCache || {})[qid]
const modeNow = this.data.questionMode
if (modeNow === QUESTION_MODES.VARIATION) {
this.setData({ variationSelectedIndex: -1, variationResultStatus: null as any, resultDisplayed: false })
}
const evalForMode = this.getEvaluationForMode(cache, modeNow)
this.setData({ resultDisplayed: !!evalForMode })
this.updateActionButtonsState()
this.applyCachedResultForQuestion(qid)
const fetched = !!(this.data.qaResultFetched || {})[qid]
if (!fetched) this.ensureQuestionResultFetched(qid)
}
this.updateProcessDots()
},
async switchMode(arg: any) {
if (!this.data.contentReady || this.data.loadingMaskVisible) return
let mode: QuestionMode | undefined
if (arg && typeof arg === 'object' && arg.currentTarget && arg.currentTarget.dataset) {
mode = arg.currentTarget.dataset.mode
} else if (typeof arg === 'string') {
mode = arg as QuestionMode
}
if (!mode) return
let target: QuestionMode = mode
if (!ENABLED_QUESTION_MODES.includes(target)) {
target = ENABLED_QUESTION_MODES[0]
}
if (this.data.isModeSwitching || target === this.data.questionMode) {
return
}
const previousMode = this.data.questionMode
this.setData({ questionMode: target, fixedMode: target })
logger.log('Switching to mode:', target)
if (target === QUESTION_MODES.VARIATION) {
if (!this.data.variationQaList || this.data.variationQaList.length === 0) {
try {
await this.fetchVariationExercises(this.data.imageId)
} catch (e) {
// If fetch fails, revert mode
this.setData({ questionMode: previousMode })
return
}
}
logger.log('Variation mode data:', this.data.variationQaList)
if (this.data.variationQaList && this.data.variationQaList.length > 0) {
const list = this.data.variationQaList || []
const cache = this.data.qaResultCache || {}
let nextIndex = 0
for (let i = 0; i < list.length; i++) {
const qid = String((list[i] as any)?.id || '')
const r = cache[qid] || {}
const hasEval = !!((r.variation || {}).evaluation)
if (!hasEval) {
nextIndex = i
break
}
}
this.setData({ isModeSwitching: true, modeAnim: 'fade-out' })
setTimeout(() => {
this.setData({
qaList: this.data.variationQaList,
currentIndex: nextIndex,
modeAnim: 'fade-in',
isModeSwitching: false
})
this.switchQuestion(0)
}, 300)
} else {
// Should not happen if fetch succeeded, but as safeguard
this.setData({ questionMode: previousMode })
}
return
}
if (target === QUESTION_MODES.CONVERSATION) {
try {
await this.fetchConversationSetting(this.data.imageId)
} catch (e) {
this.setData({ questionMode: previousMode, fixedMode: previousMode })
return
}
return
}
// Switching FROM Variation TO others
if (previousMode === QUESTION_MODES.VARIATION) {
if (this.data.mainQaList && this.data.mainQaList.length > 0) {
this.setData({ isModeSwitching: true, modeAnim: 'fade-out' })
setTimeout(() => {
this.setData({
qaList: this.data.mainQaList,
currentIndex: 0,
modeAnim: 'fade-in',
isModeSwitching: false
})
this.switchQuestion(0)
}, 300)
} else {
// Fallback if mainQaList missing
this.fetchQaExercises(this.data.imageId)
}
return
}
this.setData({ isModeSwitching: true, modeAnim: 'fade-out' })
setTimeout(() => {
const resetData: any = {
selectedOptionIndexes: [],
selectedIndex: -1,
modeAnim: 'fade-in',
selectedClozeIndex: -1,
choiceSubmitted: false
}
if (target === QUESTION_MODES.CHOICE) {
const len = (this.data.choiceOptions || []).length
resetData.evalClasses = new Array(len).fill('')
resetData.selectedFlags = new Array(len).fill(false)
} else if (target === QUESTION_MODES.CLOZE) {
const len = (this.data.clozeOptions || []).length
resetData.evalClasses = new Array(len).fill('')
} else {
resetData.evalClasses = []
}
this.setData(resetData)
const cur = this.data.qaList[this.data.currentIndex] || {}
const qid = String(cur?.id || '')
if (qid) {
const cache = (this.data.qaResultCache || {})[qid]
const evalForMode = this.getEvaluationForMode(cache, target)
const hasResult = !!evalForMode
this.setData({ resultDisplayed: hasResult })
if (hasResult) {
this.applyCachedResultForQuestion(qid)
}
this.updateActionButtonsState()
}
setTimeout(() => {
this.setData({ modeAnim: '', isModeSwitching: false })
}, 300)
}, 200)
},
getEvaluationForMode(resultObj: any, mode: QuestionMode) {
if (!resultObj) return null
try {
if (mode === QUESTION_MODES.CHOICE) return (resultObj.choice || {}).evaluation || null
if (mode === QUESTION_MODES.CLOZE) return (resultObj.cloze || {}).evaluation || null
if (mode === QUESTION_MODES.VARIATION) return (resultObj.variation || {}).evaluation || null
return null
} catch (e) {
return null
}
},
async ensureQuestionResultFetched(questionId: string) {
const fetchedMap = { ...(this.data.qaResultFetched || {}) }
if (fetchedMap[questionId]) return
try {
const res = await apiManager.getQaResult(String(questionId))
const cache = { ...(this.data.qaResultCache || {}) }
cache[questionId] = res
fetchedMap[questionId] = true
const evalForMode = this.getEvaluationForMode(res, this.data.questionMode)
this.setData({ qaResultCache: cache, qaResultFetched: fetchedMap, resultDisplayed: !!evalForMode })
this.updateActionButtonsState()
this.applyCachedResultForQuestion(questionId)
this.updateProcessDots()
} catch (e) {}
},
applyCachedResultForQuestion(questionId: string) {
const cache = this.data.qaResultCache || {}
const r = cache[questionId]
if (!r) return
const evaluation = this.getEvaluationForMode(r, this.data.questionMode)
if (!evaluation) return
this.setData({ resultDisplayed: true })
this.updateActionButtonsState()
this.updateProcessDots()
if (this.data.questionMode === QUESTION_MODES.CHOICE) {
const selected = evaluation?.selected || {}
const selectedCorrect: string[] = Array.isArray(selected?.correct) ? selected.correct.map((x: any) => (typeof x === 'string' ? x : String(x?.content || ''))) : []
const selectedIncorrect: string[] = Array.isArray(selected?.incorrect) ? selected.incorrect.map((x: any) => (typeof x === 'string' ? x : String(x?.content || ''))) : []
const missingCorrect: string[] = Array.isArray(evaluation?.missing_correct) ? evaluation.missing_correct : []
const optionContents = (this.data.choiceOptions || []).map((o) => o?.content || '')
const evalClasses = optionContents.map((c) => {
if (selectedCorrect.includes(c)) return 'opt-correct'
if (selectedIncorrect.includes(c)) return 'opt-incorrect'
if (missingCorrect.includes(c)) return 'opt-missing'
return ''
})
const selectedAll = [...selectedCorrect, ...selectedIncorrect]
const flags = optionContents.map((c) => selectedAll.includes(c))
const indexes = flags.map((v, idx) => (v ? idx : -1)).filter((v) => v >= 0)
const count = indexes.length
if (evalClasses.length) {
this.setData({ evalClasses, selectedFlags: flags, selectedOptionIndexes: indexes, selectedCount: count, choiceSubmitted: true, resultDisplayed: true })
}
} else if (this.data.questionMode === QUESTION_MODES.CLOZE) {
const base = (r as any).cloze || {}
const input = String(base.input || '')
const result = String(evaluation?.result || '').toLowerCase()
const missingCorrect: string[] = Array.isArray(evaluation?.missing_correct) ? evaluation.missing_correct : []
const optionContents = (this.data.clozeOptions || [])
const evalClasses = optionContents.map((c) => {
if (result === 'incorrect' || result === '完全错误') {
if (c === input) return 'opt-incorrect'
if (missingCorrect.includes(c)) return 'opt-correct'
return ''
} else if (result === 'correct' || result === 'exact' || result === '完全正确' || result === '完全匹配') {
if (c === input) return 'opt-correct'
return ''
}
return ''
})
const idx = optionContents.findIndex((w) => w === input)
this.setData({ selectedClozeIndex: idx, evalClasses })
} else if (this.data.questionMode === QUESTION_MODES.VARIATION) {
const base = (r as any).variation || {}
const fileId = String(base.file_id || '')
const detail = String(evaluation?.detail || '').toLowerCase()
let status: 'correct' | 'incorrect' | undefined
if (detail === 'correct' || detail === 'exact' || detail === '完全正确') {
status = 'correct'
} else if (detail === 'incorrect' || detail === '错误') {
status = 'incorrect'
}
// Find index of the selected fileId
const list = this.data.variationQaList || []
const idx = list.findIndex(item => String(item.file_id || (item.ext || {}).file_id || '') === fileId)
this.setData({
variationSelectedIndex: idx >= 0 ? idx : -1,
variationResultStatus: status,
variationSubmitted: true,
resultDisplayed: true
})
}
},
onSubmitTap() {
this.submitAttempt()
},
buildDetailViewModel(evaluation: any, questionText: string, base: any) {
const type = String(evaluation?.type || '')
const result = String(evaluation?.result || '').toLowerCase()
const detail = String(evaluation?.detail || '')
const feedback = String(evaluation?.feedback || '')
let overviewText = ''
let status: 'correct' | 'partial' | 'incorrect' = 'partial'
let iconName = 'data-search'
if (result === 'correct' || result === 'exact' || result === 'exact match' || result === '完全正确' || result === '完全匹配') {
overviewText = (result === '完全正确' || result === '完全匹配') ? result : '完全匹配'
status = 'correct'
iconName = 'data-checked'
} else if (result === 'partial' || result === 'partial match') {
overviewText = '部分匹配'
status = 'partial'
iconName = 'data-search'
} else if (result === 'incorrect' || result === 'in correct' || result === '完全错误') {
overviewText = result === '完全错误' ? '完全错误' : '没有正确选项'
status = 'incorrect'
iconName = 'data-error'
} else {
overviewText = detail ? detail : '没有正确选项'
status = 'incorrect'
iconName = 'data-error'
}
const blocks: Array<{ title: string; variant: 'correct' | 'incorrect' | 'missing' | 'info'; items: any[]; iconName?: string }> = []
if (type === QUESTION_MODES.CHOICE) {
const selected = evaluation?.selected || {}
const correct: string[] = Array.isArray(selected?.correct)
? (selected.correct as any[]).map((x) => (typeof x === 'string' ? x : String(x?.content || '')))
: []
const incorrectItems: Array<{ content: string; error_type?: string; error_reason?: string }> = Array.isArray(selected?.incorrect)
? (selected.incorrect as any[]).map((x) => ({
content: typeof x === 'string' ? x : String(x?.content || ''),
error_type: typeof x === 'object' ? x?.error_type : '',
error_reason: typeof x === 'object' ? x?.error_reason : ''
}))
: []
const missing: string[] = Array.isArray(evaluation?.missing_correct) ? evaluation.missing_correct : []
if (correct.length === 0) {
if (missing.length) {
blocks.push({ title: '正确选项', variant: 'correct', items: missing, iconName: 'data-checked' })
}
} else {
blocks.push({ title: '你的选择', variant: 'info', items: correct, iconName: 'data-checked' })
if (missing.length) {
blocks.push({ title: '漏选选项', variant: 'missing', items: missing, iconName: 'data-search' })
}
}
if (incorrectItems.length) {
blocks.push({ title: '错误选项', variant: 'incorrect', items: incorrectItems, iconName: 'data-error' })
}
} else if (type === QUESTION_MODES.CLOZE) {
const input = String((base && base.input) || '')
const missingCorrect: string[] = Array.isArray(evaluation?.missing_correct) ? evaluation.missing_correct : []
if (status === 'correct') {
if (input) blocks.push({ title: '正确选项', variant: 'correct', items: [input], iconName: 'data-checked' })
} else {
const info1: string[] = []
if (input) info1.push(input)
if (info1.length) blocks.push({ title: '你的选择', variant: 'info', items: info1, iconName: status === 'incorrect' ? 'data-error' : 'data-checked' })
if (missingCorrect.length > 0) {
blocks.push({ title: '正确选项', variant: 'correct', items: [missingCorrect[0]], iconName: 'data-checked' })
}
}
} else {
const infoItems: string[] = []
if (detail) infoItems.push(detail)
if (feedback) infoItems.push(feedback)
if (infoItems.length) blocks.push({ title: '说明', variant: 'info', items: infoItems })
}
return { overviewText, blocks, status, iconName }
},
onScoreTap() {
const cur = this.data.qaList[this.data.currentIndex] || {}
const qid = String(cur?.id || '')
if (!qid) return
const cache = this.data.qaResultCache || {}
const r = cache[qid]
if (!r) return
const modeNow = this.data.questionMode
const evaluation = this.getEvaluationForMode(r, modeNow)
if (!evaluation) return
const type = String(evaluation?.type || '')
const qText = String(cur?.question || '')
let base: any = {}
if (modeNow === QUESTION_MODES.CHOICE) {
base = (r as any).choice
} else if (modeNow === QUESTION_MODES.CLOZE) {
base = (r as any).cloze
} else if (modeNow === QUESTION_MODES.VARIATION) {
base = (r as any).variation
}
const vm = this.buildDetailViewModel(evaluation, qText, base)
this.setData({
qaDetailQuestionText: qText,
qaDetailOverviewText: vm.overviewText,
qaDetailResultStatus: vm.status,
qaDetailBlocks: vm.blocks,
qaDetailIconName: vm.iconName,
qaDetailVisible: true
})
},
onCloseDetailModal() {
this.setData({ qaDetailVisible: false })
},
onRetryTap() {
if (this.data.questionMode === QUESTION_MODES.CHOICE) {
const count = 0
const flags = new Array((this.data.choiceOptions || []).length).fill(false)
const indexes: number[] = []
const evalClasses = new Array((this.data.choiceOptions || []).length).fill('')
this.setData({ selectedFlags: flags, selectedCount: count, selectedOptionIndexes: indexes, evalClasses, choiceSubmitted: false, resultDisplayed: false })
this.updateActionButtonsState()
return
}
if (this.data.questionMode === QUESTION_MODES.CLOZE) {
const len = (this.data.clozeOptions || []).length
const evalClasses = new Array(len).fill('')
this.setData({ selectedClozeIndex: -1, resultDisplayed: false, evalClasses })
this.updateActionButtonsState()
return
}
if (this.data.questionMode === QUESTION_MODES.VARIATION) {
this.setData({ variationSelectedIndex: -1, variationSubmitted: false, variationResultStatus: null as any, resultDisplayed: false })
this.updateActionButtonsState()
return
}
},
selectOption(e: any) {
if (this.data.resultDisplayed) return
const i = Number(e?.currentTarget?.dataset?.index) || 0
const flags = (this.data.selectedFlags || []).slice()
const required = Number(this.data.choiceRequiredCount || 0)
let count = Number(this.data.selectedCount || 0)
const wasSelected = !!flags[i]
if (wasSelected) {
flags[i] = false
count = Math.max(0, count - 1)
} else {
if (count >= required) {
return
}
flags[i] = true
count = count + 1
}
const indexes = flags.map((v, idx) => (v ? idx : -1)).filter((v) => v >= 0)
this.setData({ selectedFlags: flags, selectedCount: count, selectedOptionIndexes: indexes, selectedIndex: -1, choiceSubmitted: false })
this.updateActionButtonsState()
},
async handleWordClick(e: any) {
const ds = e?.currentTarget?.dataset || {}
const dt = e?.detail || {}
const w = String(ds.word || dt.word || '').replace(/[^A-Za-z-]/g, '')
if (!w) return
const isFromWordSource = !!dt.word
const { isReturningFromPrevious } = this.data
let showBackIcon = false
let previousWord = ''
if (isFromWordSource && !isReturningFromPrevious) {
previousWord = String(dt.previousWord || '')
showBackIcon = !!previousWord
} else if (isReturningFromPrevious) {
showBackIcon = false
previousWord = ''
}
this.setData({ prototypeWord: '', isReturningFromPrevious: false, forceHidePrototype: (isFromWordSource && !isReturningFromPrevious) })
this.setData({ showBackIcon, previousWord })
this.setData({ showDictPopup: true, showDictExtended: false })
const comp = this.selectComponent('#wordDict') as any
if (comp && typeof comp.queryWord === 'function') {
try {
wx.nextTick(() => {
comp.queryWord(w)
})
} catch (e) {
setTimeout(() => comp.queryWord(w), 0)
}
}
},
handleDictClose() {
this.setData({ showDictPopup: false, showDictExtended: false, activeWordAudioType: '', wordAudioPlaying: false })
},
handleDictMore() {
this.setData({ showDictExtended: true })
},
onTabsChange(e: any) {
const v = String(e?.detail?.value || '0')
this.setData({ dictDefaultTabValue: v })
},
onTabsClick(e: any) {
const v = String(e?.detail?.value || '0')
this.setData({ dictDefaultTabValue: v })
},
handleBackToPreviousWord() {
const w = String(this.data.previousWord || '')
if (!w) return
this.setData({ isReturningFromPrevious: true, forceHidePrototype: false })
const event = { currentTarget: { dataset: { word: w } } }
this.handleWordClick(event as any)
},
selectClozeOption(e: any) {
if (this.data.resultDisplayed) return
const i = Number(e?.currentTarget?.dataset?.index) || 0
this.setData({ selectedClozeIndex: i })
},
previewImage() {
const url = this.data.imageLocalUrl
if (url) {
wx.previewImage({ urls: [url] })
}
},
triggerAutoNextIfCorrect(evaluation: any, qid: string) {
const resStr = String(evaluation?.detail || '').toLowerCase()
if (['correct'].includes(resStr)) {
this.fireConfetti()
const list = this.data.qaList || []
if (!list.length) {
return
}
const cache = this.data.qaResultCache || {}
const session = this.data.session
let attempts: any[] = []
if (!this.data.isTrialMode && session && typeof session.progress === 'object' && session.progress !== null && 'attempts' in session.progress) {
attempts = (session.progress as any).attempts
}
const attemptSet = new Set(attempts.map((a: any) => String(a.question_id)))
const findFirstUnattemptedIndex = () => {
for (let i = 0; i < list.length; i++) {
const q = list[i] || {}
const id = String((q as any)?.id || '')
if (!id) continue
const r = cache[id] || {}
const hasLocalResult =
!!((r.choice || {}).evaluation) ||
!!((r.cloze || {}).evaluation) ||
!!((r.variation || {}).evaluation)
const hasSessionAttempt = this.data.isTrialMode ? false : attemptSet.has(id)
if (!(hasLocalResult || hasSessionAttempt)) {
return i
}
}
return -1
}
const targetIndex = findFirstUnattemptedIndex()
if (targetIndex === -1 || targetIndex === this.data.currentIndex) {
return
}
this.setData({ isAutoSwitching: true, nextButtonIcon: 'numbers-3' })
setTimeout(() => {
const currentQ = this.data.qaList[this.data.currentIndex] || {}
if (String(currentQ.id) !== qid) {
this.setData({ isAutoSwitching: false, nextButtonIcon: 'chevron-right' })
return
}
this.setData({ nextButtonIcon: 'numbers-2' })
}, 1000)
setTimeout(() => {
const currentQ = this.data.qaList[this.data.currentIndex] || {}
if (String(currentQ.id) !== qid) {
this.setData({ isAutoSwitching: false, nextButtonIcon: 'chevron-right' })
return
}
this.setData({ nextButtonIcon: 'numbers-1' })
}, 2000)
setTimeout(() => {
const currentQ = this.data.qaList[this.data.currentIndex] || {}
if (String(currentQ.id) === qid) {
const delta = targetIndex - this.data.currentIndex
this.switchQuestion(delta)
}
this.setData({ isAutoSwitching: false, nextButtonIcon: 'chevron-right' })
}, 3000)
}
},
async submitAttempt() {
const q = this.data.qaList[this.data.currentIndex] || {}
const qid = q?.id
if (!qid) {
wx.showToast({ title: '题目暂不可提交', icon: 'none' })
return
}
const exerciseId = this.data.questionMode === QUESTION_MODES.VARIATION
? (this.data.variationExerciseId || String(this.data.exercise?.id || ''))
: String(this.data.exercise?.id || '')
const payload: any = {
exercise_id: exerciseId,
mode: this.data.questionMode
}
if (this.data.isTrialMode) {
payload.is_trial = true
}
if (this.data.questionMode === QUESTION_MODES.CHOICE) {
const selected_options = (this.data.selectedOptionIndexes || [])
.map((i) => this.data.choiceOptions?.[i]?.content)
.filter((v) => typeof v === 'string' && v.length > 0)
payload.selected_options = selected_options
} else if (this.data.questionMode === QUESTION_MODES.CLOZE) {
const opt = this.data.clozeOptions || []
const sel = this.data.selectedClozeIndex
if (typeof sel === 'number' && sel >= 0 && sel < opt.length) {
payload.input_text = opt[sel]
}
} else if (this.data.questionMode === QUESTION_MODES.VARIATION) {
const idx = typeof this.data.variationSelectedIndex === 'number' ? this.data.variationSelectedIndex : -1
if (idx < 0) {
wx.showToast({ title: '请先选择图片', icon: 'none' })
return
}
const item = (this.data.variationQaList || [])[idx] || {}
const fileId = String(item?.file_id || (item?.ext || {}).file_id || '')
// 复用选择题的 selected_options 结构提交
payload.selected_options = fileId ? [fileId] : []
}
wx.showLoading({ title: '提交中' })
try {
const resp = await apiManager.createQaQuestionAttempt(String(qid), payload)
// 提交成功后重新获取结果,使用新的 ensureQuestionResultFetched 逻辑
// 注意ensureQuestionResultFetched 内部会更新缓存和界面状态
// 由于服务器处理可能需要一点时间,这里可以短暂延迟或依靠轮询
// 但对于同步返回结果的场景(如本接口可能优化为直接返回结果),这里假设需要重新获取详情
// 如果 createQaQuestionAttempt 直接返回了 evaluation则可以直接使用
// 暂时通过 ensureQuestionResultFetched 刷新
// 为了防止后端异步处理延迟,这里可以尝试立即获取,或者等待推送
// 实际项目中createAttempt 可能直接返回结果,或者需要轮询
// 假设 ensureQuestionResultFetched 会处理
// 强制刷新当前题目的结果
// 先清除本地标记,强制拉取
const k = String(qid)
const fetched = this.data.qaResultFetched || {}
fetched[k] = false
this.setData({ qaResultFetched: fetched })
await this.ensureQuestionResultFetched(String(qid))
wx.hideLoading()
if (this.data.questionMode !== QUESTION_MODES.VARIATION) {
this.setData({ choiceSubmitted: true, submitDisabled: true, retryDisabled: false })
}
const cache = this.data.qaResultCache || {}
const res = cache[k]
const mode = this.data.questionMode
let evaluation: any = null
if (mode === QUESTION_MODES.CHOICE) evaluation = res.choice?.evaluation
else if (mode === QUESTION_MODES.CLOZE) evaluation = res.cloze?.evaluation
else if (mode === QUESTION_MODES.VARIATION) evaluation = res.variation?.evaluation
if (evaluation) {
this.triggerAutoNextIfCorrect(evaluation, String(qid))
}
this.checkAllQuestionsAttempted()
} catch (e) {
wx.hideLoading()
logger.error('提交失败', e)
wx.showToast({ title: '提交失败', icon: 'none' })
}
},
selectVariationOption(e: any) {
if (this.data.resultDisplayed) return
let i = -1
if (typeof e === 'number') {
i = e
} else {
i = Number(e?.currentTarget?.dataset?.index)
if (isNaN(i)) i = -1
}
this.setData({ variationSelectedIndex: i, variationResultStatus: null as any })
this.updateActionButtonsState()
},
async getQuestionAudio(questionId: string) {
try {
const map = this.data.audioUrlMap || {}
if (map[questionId]) {
return map[questionId]
}
const { file_id } = await apiManager.getQaQuestionAudio(questionId)
if (file_id) {
const fileUrl = String(file_id)
this.setData({ audioUrlMap: { ...map, [questionId]: fileUrl } })
return fileUrl
}
} catch (err) {
logger.error('获取语音失败:', err)
wx.showToast({ title: '获取语音失败', icon: 'none' })
}
return undefined
},
async playStandardVoice() {
const q = this.data.qaList[this.data.currentIndex]
const sid = String(q?.id || '')
if (!sid) { return }
if (!this.audioCtx) {
this.audioCtx = wx.createInnerAudioContext()
try { (this.audioCtx as any).obeyMuteSwitch = false } catch (e) {}
try { (this.audioCtx as any).autoplay = false } catch (e) {}
this.audioCtx.onPlay(() => {
this.setData({ isPlaying: true })
})
this.audioCtx.onEnded(() => { this.setData({ isPlaying: false }) })
this.audioCtx.onStop(() => { this.setData({ isPlaying: false }) })
this.audioCtx.onError(() => { this.setData({ isPlaying: false }) })
}
let audioUrl = (this.data.audioUrlMap || {})[sid]
if (!audioUrl) {
wx.showLoading({ title: '正在获取...' })
try {
const result = await this.getQuestionAudio(sid)
if (result) {
audioUrl = result
}
} catch (e) {
logger.error('Play voice error:', e)
}
wx.hideLoading()
if (!audioUrl) {
return
}
}
const cachedLocal = (this.data.audioLocalMap || {})[sid]
const playWithPath = (filePath: string) => {
if (this.data.isPlaying && this.audioCtx?.src === filePath) {
try { this.audioCtx.pause() } catch (e) {}
try { this.audioCtx.seek(0) } catch (e) {}
this.setData({ isPlaying: false })
return
}
if (!this.audioCtx) {
this.audioCtx = wx.createInnerAudioContext()
try { (this.audioCtx as any).obeyMuteSwitch = false } catch (e) {}
try { (this.audioCtx as any).autoplay = false } catch (e) {}
this.audioCtx.onPlay(() => {
this.setData({ isPlaying: true })
})
this.audioCtx.onEnded(() => { this.setData({ isPlaying: false }) })
this.audioCtx.onStop(() => { this.setData({ isPlaying: false }) })
this.audioCtx.onError(() => { this.setData({ isPlaying: false }) })
}
if (this.audioCtx.src !== filePath) {
try { this.audioCtx.pause() } catch (e) {}
try { this.audioCtx.seek(0) } catch (e) {}
this.audioCtx.src = filePath
} else {
try { this.audioCtx.seek(0) } catch (e) {}
}
try {
this.audioCtx.play()
this.setData({ isPlaying: true })
} catch (error) {
wx.showToast({ title: '音频播放失败', icon: 'none' })
}
}
if (cachedLocal) {
playWithPath(cachedLocal)
} else {
apiManager.downloadFile(audioUrl).then((filePath) => {
const map = this.data.audioLocalMap || {}
this.setData({ audioLocalMap: { ...map, [sid]: filePath } })
playWithPath(filePath)
}).catch((error) => {
logger.error('下载音频失败:', error)
wx.showToast({ title: '音频下载失败', icon: 'none' })
})
}
},
onUnload() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = undefined
}
if (this.audioCtx) {
this.audioCtx.destroy()
this.audioCtx = undefined
}
}
})