Files
miniprogram-1/miniprogram/pages/qa_exercise/qa_exercise.ts
2026-01-21 13:29:15 +08:00

2431 lines
92 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, IQaConversationSettingPayload } 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?: IQaConversationSettingPayload
conversationDifficulty?: string
selectedRole?: { roleIndex: number, roleSide: 1 | 2 } | null
conversationSelectedScenes?: string[]
conversationExtraNote?: string
conversationSceneLang?: 'zh' | 'en'
conversationSelectedScenesMap?: Record<string, boolean>
conversationSelectedEvents?: string[]
conversationSelectedEventsMap?: Record<string, boolean>
conversationCustomSceneText?: string
conversationCustomSceneKey?: string
conversationCustomSceneEditing?: boolean
conversationCustomScenes?: Array<{ key: string; text: string }>
conversationCustomSceneOverLimit?: boolean
conversationCustomEventText?: string
conversationCustomEventEditing?: boolean
conversationCustomEvents?: Array<{ key: string; text: string }>
conversationCustomEventOverLimit?: boolean
conversationSuggestedRoles?: Array<{ key: string; role1_en: string; role1_zh: string; role2_en: string; role2_zh: string }>
difficultyOptions: Array<{ value: string; label_zh: string; label_en: string }>
conversationViewMode: 'setup' | 'chat'
conversationLatestSession: any
conversationDetail: any
conversationMessages: any[]
replyLoading: boolean
chatInputValue: string
isChatInputVisible: boolean
scrollIntoView: string
renderPresets: Array<{ name: string; type: string }>
sidebar?: 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
startConversationPolling: (taskId: string, sessionId: string, showLoadingMask?: boolean, append?: boolean) => 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
selectConversationDifficulty: (e: any) => void
toggleConversationScene: (e: any) => void
onConversationNoteInput: (e: any) => void
onStartConversationTap: () => void
toggleConversationSceneLang: () => void
toggleConversationEvent: (e: any) => void
onConversationCustomSceneAdd: () => void
onConversationCustomSceneInput: (e: any) => void
onConversationCustomSceneConfirm: () => void
onConversationCustomSceneCancel: () => void
onConversationCustomSceneBlur: () => void
onConversationCustomSceneDelete: (e: any) => void
onConversationCustomEventAdd: () => void
onConversationCustomEventInput: (e: any) => void
onConversationCustomEventConfirm: () => void
onConversationCustomEventBlur: () => void
onConversationCustomEventDelete: (e: any) => void
onRoleSelect: (e: any) => void
normalizeConversationTagLabel: (raw: string) => string
toggleConversationView: () => void
onSendMessage: (e: any) => void
onChatInput: (e: any) => void
onChatBlur: (e: any) => void
onChatCloseTap: (e: any) => void
updateConversationMessages: (detail: any, lang: string, append?: boolean) => void
showChatInput: () => void
onHistoryTap: () => void
chatItemClick: (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: '',
isChatInputVisible: false,
scrollIntoView: '',
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,
conversationDifficulty: 'easy',
sidebar: [],
conversationSceneLang: 'zh',
conversationSelectedScenes: [],
conversationSelectedScenesMap: {},
conversationSelectedEvents: [],
conversationSelectedEventsMap: {},
conversationCustomSceneText: '',
conversationCustomSceneKey: '',
conversationCustomSceneEditing: false,
conversationCustomScenes: [],
conversationCustomSceneOverLimit: false,
conversationCustomEventText: '',
conversationCustomEventEditing: false,
conversationCustomEvents: [],
conversationCustomEventOverLimit: false,
conversationSuggestedRoles: [],
conversationExtraNote: '',
difficultyOptions: [
{ value: 'easy', label_zh: '初级', label_en: 'Easy' },
{ value: 'medium', label_zh: '中级', label_en: 'Medium' },
{ value: 'hard', label_zh: '高级', label_en: 'Hard' }
],
conversationViewMode: 'setup', // 'setup' | 'chat'
conversationLatestSession: null as any, // 存储 latest_session 信息
conversationDetail: null as any, // 存储会话详情
conversationMessages: [],
replyLoading: false,
chatInputValue: '',
renderPresets: [ { name: 'send', type: 'icon'} ]
},
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 res = await apiManager.getQaConversationSetting(imageId)
logger.log('Conversation setting:', res)
let viewMode = 'setup'
let detail = null
if (res && res.latest_session && res.latest_session.status === 'ongoing') {
const sessionId = res.latest_session.id || (res.latest_session as any).session_id
if (sessionId) {
try {
detail = await apiManager.getQaConversationDetail(sessionId)
logger.info('Recovered conversation detail:', detail)
viewMode = 'chat'
} catch (err) {
logger.error('Failed to recover conversation detail:', err)
}
}
}
if (!res) {
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: res.setting,
conversationLatestSession: res.latest_session,
conversationDetail: detail,
conversationViewMode: viewMode as any,
loadingMaskVisible: false,
statusText: '加载完成'
})
this.updateConversationMessages(detail, this.data.conversationSceneLang || 'zh', false)
} catch (e) {
logger.error('获取自由对话设置失败', e)
const msg = (e as any)?.message || ''
wx.showToast({ title: msg || '加载失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '加载失败' })
}
},
toggleConversationView() {
const { conversationViewMode, conversationLatestSession } = this.data
// 如果没有 latest_session无法切换到 chat 模式,也不应该启用按钮
if (!conversationLatestSession) {
return
}
const nextMode = conversationViewMode === 'setup' ? 'chat' : 'setup'
this.setData({ conversationViewMode: nextMode })
},
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
},
startConversationPolling(taskId: string, sessionId: string, showLoadingMask: boolean = true, append?: boolean) {
if (this.conversationPollTimer) {
clearInterval(this.conversationPollTimer)
this.conversationPollTimer = undefined
}
if (showLoadingMask) {
this.setData({ loadingMaskVisible: true, statusText: '对话生成中...' })
}
this.conversationPollTimer = setInterval(async () => {
try {
const res = await apiManager.getQaExerciseTaskStatus(taskId)
if (res.status === 'completed') {
clearInterval(this.conversationPollTimer!)
this.conversationPollTimer = undefined
try {
const detail = await apiManager.getQaConversationLatest(sessionId)
logger.info('Started conversation detail:', detail)
this.setData({
conversationDetail: detail,
conversationViewMode: 'chat',
loadingMaskVisible: false,
statusText: '加载完成',
conversationLatestSession: { id: sessionId, status: 'ongoing' },
replyLoading: false
})
this.updateConversationMessages(detail, this.data.conversationSceneLang || 'zh', append)
} catch (err) {
logger.error('Failed to get latest conversation detail:', err)
wx.showToast({ title: '获取对话详情失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '获取详情失败', replyLoading: false })
}
} else if (res.status === 'failed') {
clearInterval(this.conversationPollTimer!)
this.conversationPollTimer = undefined
wx.showToast({ title: '任务失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '任务失败', replyLoading: false })
}
} 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()
},
selectConversationDifficulty(e: any) {
const level = String(e?.currentTarget?.dataset?.level || '')
if (!level) return
this.setData({ conversationDifficulty: level })
},
toggleConversationScene(e: any) {
const scene = String(e?.currentTarget?.dataset?.scene || '')
if (!scene) return
const list = (this.data.conversationSelectedScenes || []).slice()
const map = { ...(this.data.conversationSelectedScenesMap || {}) }
const idx = list.indexOf(scene)
if (idx >= 0) {
list.splice(idx, 1)
delete map[scene]
} else {
list.push(scene)
map[scene] = true
}
this.setData({ conversationSelectedScenes: list, conversationSelectedScenesMap: map })
},
toggleConversationEvent(e: any) {
const ev = String(e?.currentTarget?.dataset?.event || '')
if (!ev) return
const list = (this.data.conversationSelectedEvents || []).slice()
const map = { ...(this.data.conversationSelectedEventsMap || {}) }
const idx = list.indexOf(ev)
if (idx >= 0) {
list.splice(idx, 1)
delete map[ev]
} else {
list.push(ev)
map[ev] = true
}
const setting = this.data.conversationSetting as any
const allEvents = setting && Array.isArray((setting as any).all_possible_events) ? (setting as any).all_possible_events : []
const selectedSet = new Set(list)
const roles: Array<{ key: string; role1_en: string; role1_zh: string; role2_en: string; role2_zh: string }> = []
const seen = new Set<string>()
if (allEvents && allEvents.length && selectedSet.size) {
allEvents.forEach((item: any) => {
const evKey = String(item?.event_en || '')
if (!evKey || !selectedSet.has(evKey)) return
const sr = Array.isArray(item?.suggested_roles) ? item.suggested_roles : []
sr.forEach((r: any) => {
const r1en = String(r?.role1_en || '')
const r2en = String(r?.role2_en || '')
const r1zh = String(r?.role1_zh || '')
const r2zh = String(r?.role2_zh || '')
const dedupeKey = `${r1en}||${r2en}||${r1zh}||${r2zh}`
if (!dedupeKey.trim() || seen.has(dedupeKey)) return
seen.add(dedupeKey)
roles.push({
key: dedupeKey,
role1_en: r1en,
role1_zh: r1zh,
role2_en: r2en,
role2_zh: r2zh
})
})
})
}
this.setData({ conversationSelectedEvents: list, conversationSelectedEventsMap: map, conversationSuggestedRoles: roles })
},
onRoleSelect(e: WechatMiniprogram.TouchEvent) {
const { index, side } = e.currentTarget.dataset
const { selectedRole } = this.data
if (selectedRole && selectedRole.roleIndex === index && selectedRole.roleSide === side) {
this.setData({ selectedRole: null })
} else {
this.setData({ selectedRole: { roleIndex: index, roleSide: side } })
}
},
onConversationNoteInput(e: any) {
const v = String(e?.detail?.value || '')
this.setData({ conversationExtraNote: v.slice(0, 200) })
},
toggleConversationSceneLang() {
const cur = this.data.conversationSceneLang || 'zh'
const next = cur === 'zh' ? 'en' : 'zh'
this.setData({ conversationSceneLang: next })
this.updateConversationMessages(this.data.conversationDetail, next, false)
},
onConversationCustomSceneAdd() {
this.setData({ conversationCustomSceneEditing: true, conversationCustomSceneText: '' })
},
onConversationCustomSceneInput(e: any) {
const v = String(e?.detail?.value || '')
const text = v.trim()
let over = false
if (text) {
const parts = text.split(/\s+/)
if (parts.length > 1) {
if (parts.length > 5) {
over = true
}
} else {
const chars = Array.from(text)
if (chars.length > 12) {
over = true
}
}
}
this.setData({ conversationCustomSceneText: v, conversationCustomSceneOverLimit: over })
},
onConversationCustomSceneConfirm() {
const raw = this.data.conversationCustomSceneText || ''
const name = this.normalizeConversationTagLabel(raw)
if (!name) {
this.setData({ conversationCustomSceneEditing: false, conversationCustomSceneText: '' })
return
}
const key = `custom:${Date.now()}:${Math.random().toString(36).slice(2, 6)}`
const scenes = (this.data.conversationCustomScenes || []).slice()
scenes.push({ key, text: name })
const list = (this.data.conversationSelectedScenes || []).slice()
const map = { ...(this.data.conversationSelectedScenesMap || {}) }
if (list.indexOf(key) < 0) {
list.push(key)
}
map[key] = true
this.setData({
conversationCustomScenes: scenes,
conversationCustomSceneEditing: false,
conversationCustomSceneText: '',
conversationSelectedScenes: list,
conversationSelectedScenesMap: map
})
},
onConversationCustomSceneCancel() {
this.setData({ conversationCustomSceneEditing: false, conversationCustomSceneText: '' })
},
onConversationCustomSceneBlur() {
this.onConversationCustomSceneConfirm()
},
onConversationCustomSceneDelete(e: any) {
const key = String(e?.currentTarget?.dataset?.key || '')
if (!key) return
const scenes = (this.data.conversationCustomScenes || []).filter((s: any) => s.key !== key)
const list = (this.data.conversationSelectedScenes || []).slice()
const idx = list.indexOf(key)
if (idx >= 0) {
list.splice(idx, 1)
}
const map = { ...(this.data.conversationSelectedScenesMap || {}) }
delete map[key]
this.setData({
conversationCustomScenes: scenes,
conversationSelectedScenes: list,
conversationSelectedScenesMap: map
})
},
onConversationCustomEventAdd() {
this.setData({
conversationCustomEventEditing: true,
conversationCustomEventText: ''
})
},
async onSendMessage(e: any) {
const content = e.detail?.value || this.data.chatInputValue
if (!content || !content.trim()) return
const sessionId = this.data.conversationLatestSession?.session_id || this.data.conversationLatestSession?.id
if (!sessionId) {
wx.showToast({ title: '会话无效', icon: 'none' })
return
}
// Optimistic update
const newMsg = {
role: 'user',
content: [{ type: 'text', data: content }]
}
const messages = [...(this.data.conversationMessages || []), newMsg]
this.setData({
conversationMessages: messages,
chatInputValue: '',
replyLoading: true
})
try {
const res = await apiManager.replyQaConversation(sessionId, content)
if (res && res.task_id) {
this.startConversationPolling(res.task_id, sessionId, false, true)
} else {
wx.showToast({ title: '回复失败', icon: 'none' })
this.setData({ replyLoading: false })
}
} catch (err) {
logger.error('Reply failed:', err)
wx.showToast({ title: '发送失败', icon: 'none' })
this.setData({ replyLoading: false })
}
},
onChatInput(e: any) {
this.setData({ chatInputValue: e.detail?.value || '' })
},
onChatCloseTap(e: any) {
this.setData({ isChatInputVisible: false })
},
onChatBlur(e: any) {
this.setData({ isChatInputVisible: false, scrollIntoView: '' })
setTimeout(() => {
this.setData({ scrollIntoView: 'bottom-anchor' })
}, 300)
},
showChatInput() {
if (!this.data.conversationLatestSession) {
return
}
this.setData({ isChatInputVisible: true, scrollIntoView: '' })
setTimeout(() => {
this.setData({ scrollIntoView: 'bottom-anchor' })
logger.info('Scroll to bottom anchor')
}, 2000)
},
updateConversationMessages(detail: any, lang: string, append: boolean = false) {
if (!detail || !detail.messages) {
if (!append) {
this.setData({ conversationMessages: [] })
}
return
}
const messages = detail.messages.map((item: any) => {
const contentObj = item.content || {}
let text = ''
if (item.role === 'user') {
text = contentObj.text || ''
} else {
text = contentObj.response_en || ''
// if (lang === 'zh') {
// text = contentObj.response_zh || ''
// } else {
// text = contentObj.response_en || ''
// }
}
return {
role: item.role,
content: [{ type: 'text', data: text }]
}
})
if (append) {
const current = this.data.conversationMessages || []
this.setData({ conversationMessages: [...current, ...messages] })
} else {
this.setData({ conversationMessages: messages })
}
},
onConversationCustomEventInput(e: any) {
const v = String(e?.detail?.value || '')
const text = v.trim()
let over = false
if (text) {
const parts = text.split(/\s+/)
if (parts.length > 1) {
if (parts.length > 5) {
over = true
}
} else {
const chars = Array.from(text)
if (chars.length > 12) {
over = true
}
}
}
this.setData({ conversationCustomEventText: v, conversationCustomEventOverLimit: over })
},
onConversationCustomEventConfirm() {
const raw = this.data.conversationCustomEventText || ''
const name = this.normalizeConversationTagLabel(raw)
if (!name) {
this.setData({ conversationCustomEventEditing: false, conversationCustomEventText: '' })
return
}
const key = `custom:${Date.now()}:${Math.random().toString(36).slice(2, 6)}`
const events = (this.data.conversationCustomEvents || []).slice()
events.push({ key, text: name })
const list = (this.data.conversationSelectedEvents || []).slice()
const map = { ...(this.data.conversationSelectedEventsMap || {}) }
if (list.indexOf(key) < 0) {
list.push(key)
}
map[key] = true
this.setData({
conversationCustomEvents: events,
conversationCustomEventEditing: false,
conversationCustomEventText: '',
conversationSelectedEvents: list,
conversationSelectedEventsMap: map
})
},
normalizeConversationTagLabel(raw: string) {
const text = String(raw || '').trim()
if (!text) return ''
const parts = text.split(/\s+/)
if (parts.length > 1) {
const limited = parts.slice(0, 5)
return limited.join(' ')
}
const chars = Array.from(text)
if (chars.length > 12) {
return chars.slice(0, 12).join('')
}
return text
},
onConversationCustomEventBlur() {
this.onConversationCustomEventConfirm()
},
onConversationCustomEventDelete(e: any) {
const key = String(e?.currentTarget?.dataset?.key || '')
if (!key) return
const events = (this.data.conversationCustomEvents || []).filter((s: any) => s.key !== key)
const list = (this.data.conversationSelectedEvents || []).slice()
const idx = list.indexOf(key)
if (idx >= 0) {
list.splice(idx, 1)
}
const map = { ...(this.data.conversationSelectedEventsMap || {}) }
delete map[key]
this.setData({
conversationCustomEvents: events,
conversationSelectedEvents: list,
conversationSelectedEventsMap: map
})
},
onStartConversationTap() {
const run = async () => {
try {
const imageId = this.data.imageId
if (!imageId) {
wx.showToast({ title: '缺少图片信息', icon: 'none' })
return
}
const setting = this.data.conversationSetting as any
const scenesSelected = (this.data.conversationSelectedScenes || []).slice()
const eventsSelected = (this.data.conversationSelectedEvents || []).slice()
const customScenes = this.data.conversationCustomScenes || []
const customEvents = this.data.conversationCustomEvents || []
const selectedRole = this.data.selectedRole
const rolesList = this.data.conversationSuggestedRoles || []
const noteRaw = this.data.conversationExtraNote || ''
const info = noteRaw.trim()
let sceneList: any[] = []
let eventList: any[] = []
let userRole = {}
let assistantRole = {}
let style = {}
const hasScene = scenesSelected.length > 0
const hasEvent = eventsSelected.length > 0
const hasRole = !!selectedRole && !!rolesList[selectedRole.roleIndex]
const hasInfo = !!info
if (!hasScene && !hasEvent && !hasRole && !hasInfo) {
if (!setting) {
wx.showToast({ title: '请先加载对话设置', icon: 'none' })
return
}
const allScenes = Array.isArray(setting.all_possible_scenes) ? setting.all_possible_scenes : []
const allEvents = Array.isArray(setting.all_possible_events) ? setting.all_possible_events : []
if (!allEvents.length) {
wx.showToast({ title: '暂无可用事件', icon: 'none' })
return
}
const eventsWithRoles = allEvents.filter((ev: any) => Array.isArray(ev.suggested_roles) && ev.suggested_roles.length)
const baseEventList = eventsWithRoles.length ? eventsWithRoles : allEvents
const randEv = baseEventList[Math.floor(Math.random() * baseEventList.length)]
if (randEv && randEv.event_en) {
eventList = [String(randEv.event_en)]
}
if (randEv && randEv.style_en) {
style = String(randEv.style_en)
}
if (randEv && typeof randEv.scene_en === 'string' && randEv.scene_en) {
sceneList = [String(randEv.scene_en)]
} else if (allScenes.length) {
const randScene = allScenes[Math.floor(Math.random() * allScenes.length)]
if (randScene && randScene.scene_en) {
sceneList = [String(randScene.scene_en)]
}
}
const sr = Array.isArray((randEv as any).suggested_roles) ? (randEv as any).suggested_roles : []
if (sr.length) {
const pair = sr[Math.floor(Math.random() * sr.length)] || {}
const r1 = String(pair.role1_en || '')
const r2 = String(pair.role2_en || '')
if (r1 && r2) {
userRole = r1
assistantRole = r2
}
}
} else {
sceneList = scenesSelected.map((s) => {
if (s.startsWith('custom:')) {
const found = customScenes.find((cs: any) => cs.key === s)
if (found && found.text) {
return { en: String(found.text), zh: String(found.text) }
}
} else {
const allScenes = Array.isArray(setting.all_possible_scenes) ? setting.all_possible_scenes : []
const found = allScenes.find((item: any) => item.scene_en === s)
if (found) {
return { en: String(found.scene_en), zh: String(found.scene_zh || found.scene_en) }
}
}
return { en: String(s), zh: String(s) }
}).filter((e) => !!e.en)
const allEvents = Array.isArray(setting.all_possible_events) ? setting.all_possible_events : []
eventList = eventsSelected.map((evKey) => {
if (evKey.startsWith('custom:')) {
const found = customEvents.find((ce: any) => ce.key === evKey)
if (found && found.text) {
return { en: String(found.text), zh: String(found.text) }
}
} else {
const matchedEv = allEvents.find((e: any) => e.event_en === evKey)
if (matchedEv) {
// 如果找到了事件对象,也提取 style
if (matchedEv.style_en) {
style = { en: String(matchedEv.style_en), zh: String(matchedEv.style_zh || matchedEv.style_en) }
}
return { en: String(matchedEv.event_en), zh: String(matchedEv.event_zh || matchedEv.event_en) }
}
}
return { en: String(evKey), zh: String(evKey) }
}).filter((e) => !!e.en)
// 如果没有选中场景,但选中了事件,且事件有默认场景,则使用默认场景
if (sceneList.length === 0 && eventList.length > 0) {
// 尝试从第一个事件中获取关联场景
const firstEventKey = eventsSelected[0]
if (!firstEventKey.startsWith('custom:')) {
const foundEvent = allEvents.find((item: any) => item.event_en === firstEventKey)
if (foundEvent && foundEvent.scene_en) {
sceneList = [{ en: String(foundEvent.scene_en), zh: String(foundEvent.scene_zh || foundEvent.scene_en) }]
}
}
}
if (eventList.length > 0) {
const firstEventKey = eventsSelected[0]
if (!firstEventKey.startsWith('custom:')) {
const foundEvent = allEvents.find((item: any) => item.event_en === firstEventKey)
if (foundEvent && foundEvent.style_en) {
style = { en: String(foundEvent.style_en), zh: String(foundEvent.style_zh || foundEvent.style_en) }
}
}
}
if (hasRole && selectedRole) {
const idx = selectedRole.roleIndex
const side = selectedRole.roleSide
const pair = rolesList[idx]
if (pair) {
if (side === 1) {
userRole = { en: String(pair.role1_en), zh: String(pair.role1_zh || pair.role1_en) }
assistantRole = { en: String(pair.role2_en), zh: String(pair.role2_zh || pair.role2_en) }
} else {
userRole = { en: String(pair.role2_en), zh: String(pair.role2_zh || pair.role2_en) }
assistantRole = { en: String(pair.role1_en), zh: String(pair.role1_zh || pair.role1_en) }
}
}
}
}
const levelKey = this.data.conversationDifficulty || 'easy'
let level = levelKey
const payload: any = {
image_id: imageId,
level,
scene: sceneList,
event: eventList,
info: hasInfo ? info : undefined
}
if (userRole) payload.user_role = userRole
if (assistantRole) payload.assistant_role = assistantRole
if (style) payload.style = style
// wx.showLoading({ title: '启动对话...', mask: true })
if (info) {
payload.info = info
}
logger.info('开始对话 payload:', payload)
this.setData({ loadingMaskVisible: true, statusText: '正在创建对话...' })
const res = await apiManager.startQaConversation(payload)
if (res && res.task_id && res.session_id) {
this.startConversationPolling(res.task_id, res.session_id, false)
} else {
this.setData({ loadingMaskVisible: false })
// wx.showToast({ title: '已发起对话', icon: 'success' })
}
} catch (err) {
this.setData({ loadingMaskVisible: false })
logger.error('开始对话失败', err)
const msg = (err as any)?.message || '开始对话失败'
wx.showToast({ title: msg, icon: 'none' })
}
}
run()
},
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' })
})
}
},
onHistoryTap() {
const run = async () => {
try {
const { imageId } = this.data
if (!imageId) {
wx.showToast({ title: '缺少图片信息', icon: 'none' })
return
}
this.setData({ visible: true })
const res = await apiManager.listQaConversations(imageId)
logger.info('获取到的历史聊天对话记录:', res)
// Assuming res is { list: [...], total: ... } or just array
const list = Array.isArray(res) ? res : (res.list || [])
const sidebar = list.map((item: any) => ({
title: item.title || item.summary || (item.created_time ? `对话 ${item.created_time}` : `对话 ${item.id}`),
...item
}))
this.setData({ sidebar })
} catch (err) {
logger.error('获取历史聊天对话记录失败:', err)
wx.showToast({ title: '获取历史记录失败', icon: 'none' })
}
}
run()
},
chatItemClick(e: any) {
const { item } = e.detail
if (!item || !item.id) return
const sessionId = item.id
this.setData({ visible: false })
const run = async () => {
try {
this.setData({ loadingMaskVisible: true, statusText: '加载对话...' })
const detail = await apiManager.getQaConversationLatest(sessionId)
logger.info('Loaded conversation detail:', detail)
this.setData({
conversationDetail: detail,
conversationViewMode: 'chat',
loadingMaskVisible: false,
statusText: '加载完成',
conversationLatestSession: { id: sessionId, status: 'ongoing' },
replyLoading: false
})
this.updateConversationMessages(detail, this.data.conversationSceneLang || 'zh', false)
} catch (err) {
logger.error('Failed to load conversation:', err)
wx.showToast({ title: '加载对话失败', icon: 'none' })
this.setData({ loadingMaskVisible: false })
}
}
run()
},
onUnload() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = undefined
}
if (this.audioCtx) {
this.audioCtx.destroy()
this.audioCtx = undefined
}
}
})