2431 lines
92 KiB
TypeScript
2431 lines
92 KiB
TypeScript
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 {
|
||
// 如果没有 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<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
|
||
}
|
||
}
|
||
})
|