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 qaResultCache?: Record qaResultFetched?: Record 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 audioLocalMap?: Record 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 variationImagesLoading?: Record variationImageLoaded: Record 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 conversationSelectedEvents?: string[] conversationSelectedEventsMap?: Record 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 fetchVariationExercises: (imageId: string) => Promise fetchConversationSetting: (imageId: string) => Promise 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: (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 applyCachedResultForQuestion: (questionId: string) => void previewImage: () => void submitAttempt: () => Promise 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 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 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({ 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(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) { try { const app = getApp() 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 { // 如果没有 thumbnailId,fetchQaExercises 可能会加载图片,这里暂时不做额外处理, // 除非 fetchQaExercises 获取的 exercise 数据里有 image_url } } catch (error) { logger.error('QaExercise onLoad Error:', error) this.setData({ loadingMaskVisible: false, statusText: '加载失败' }) } }, onShareAppMessage(options: any) { const app = getApp() 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 = {} 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() 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 } } })