const appContext = require('../../utils/app-context.js') const getReviewSettings = () => { const app = appContext.withApp() const settings = app.globalData.userSettings || {} return { autoNextQuestion: Number(settings.auto_next_question || 0) === 1, showAnswerAfterSubmit: Number(settings.show_answer_after_submit || 0) === 1, blankCount: Number(settings.blank_count || 2), blankDifficulty: settings.blank_difficulty || 'normal', } } Page({ data: { progress: 0, current: 1, total: 0, word: '', meaning: '', answer: '', answers: [], clozeAnswers: [], feedback: '', revealAnswer: '', feedbackType: '', showFeedback: false, completed: false, masterHint: false, questions: [], loading: false, error: '', settings: getReviewSettings(), questionBadge: '', entryTypeHint: '', isCloze: false, currentClozeQuestion: null, currentClozeResults: [], showClozeFeedback: false, clozeSegments: [], clozeFeedbackSummary: '', activeClozeBlankIndex: 0, }, submitting: false, judged: false, onLoad() { this.loadSettingsAndQuestions() }, getReviewSessionState() { const app = appContext.withApp() return app.globalData.reviewSession }, loadSettingsAndQuestions() { const app = appContext.withApp() if (!app.globalData.authToken) { this.setData({ error: '请先登录' }) return } wx.request({ url: `${app.globalData.baseUrl}/api/v1/settings`, header: { 'X-Auth-Token': app.globalData.authToken }, success: (res) => { if (appContext.isUnauthorizedResponse(res)) { appContext.handleUnauthorized() return } if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.code === 0) { app.globalData.userSettings = res.data?.data || {} this.setData({ settings: getReviewSettings() }) } this.loadQuestions() }, fail: () => { this.setData({ error: '设置加载失败' }) this.loadQuestions() }, }) }, buildClozeSegments(clozeQuestion) { if (!clozeQuestion || !Array.isArray(clozeQuestion.blanks)) return [] const prompt = clozeQuestion.prompt_text || '' const blanks = clozeQuestion.blanks || [] const segments = [] const useUnderscorePrompt = prompt.includes('____') const workingPrompt = useUnderscorePrompt ? prompt : (clozeQuestion.original_text || prompt) const pushWordBlank = (blankIndex, blank) => { const answerText = String(blank?.answer || '') const answerLength = Math.max(1, answerText.length || Number(blank?.hint?.length || 1)) const blankWidth = Math.max(88, Math.min(320, 32 + answerLength * 28)) segments.push({ type: 'word-blank', blankIndex, index: `blank-${blankIndex}`, width: `${blankWidth}rpx`, minWidth: `${blankWidth}rpx`, maxLength: Math.max(1, answerLength), }) } if (useUnderscorePrompt) { let blankIndex = 0 workingPrompt.split('____').forEach((text, index, parts) => { if (text) segments.push({ type: 'text', text, index: `text-${index}` }) if (index < parts.length - 1) { pushWordBlank(blankIndex, blanks[blankIndex]) blankIndex += 1 } }) return segments } let cursor = 0 blanks.forEach((blank, idx) => { const start = Math.max(0, Number(blank.start || 0)) const end = Math.max(start, Number(blank.end || start + 1)) if (start > cursor) { segments.push({ type: 'text', text: workingPrompt.slice(cursor, start), index: `text-${idx}` }) } pushWordBlank(idx, blank) cursor = end }) if (cursor < workingPrompt.length) { segments.push({ type: 'text', text: workingPrompt.slice(cursor), index: 'text-tail' }) } return segments }, loadQuestions() { const app = appContext.withApp() const session = app.globalData.reviewSessionMeta || wx.getStorageSync('reviewSessionMeta') if (!session?.id) { this.setData({ error: '暂无复习会话' }) return } app.globalData.reviewSessionMeta = session this.setData({ loading: true, error: '' }) wx.request({ url: `${app.globalData.baseUrl}/api/v1/review/questions?session_id=${session.id}`, header: { 'X-Auth-Token': app.globalData.authToken || '' }, success: (res) => { if (appContext.isUnauthorizedResponse(res)) { appContext.handleUnauthorized() return } if (res.statusCode < 200 || res.statusCode >= 300 || res.data?.code !== 0) { const message = res.data?.detail || res.data?.message || '题目加载失败' this.setData({ error: message, questions: [] }) return } const questions = (res.data?.data || []).map((item) => { if (item.question_mode === 'C') { return { ...item, blank_prompt: item.blank_prompt || item.cloze_question?.prompt_text || item.prompt, cloze_question: item.cloze_question || null, } } return { ...item, entry_type: item.entry_type || 'word', } }) if (!questions.length) { this.setData({ error: '当前复习会话没有题目,请返回重新选择范围', questions: [] }) return } app.globalData.reviewSession = { deck: questions, currentIndex: 0, correctCount: 0, wrongCount: 0, completed: false, currentAnswer: '', currentAnswers: [], lastClozeResults: [], startedAt: Date.now(), } this.setData({ questions }) this.syncSession() }, fail: () => this.setData({ error: '题目加载失败' }), complete: () => this.setData({ loading: false }), }) }, syncSession() { const session = this.getReviewSessionState() const current = session.deck[session.currentIndex] if (!current) return const total = session.deck.length || 1 const isCloze = current?.question_mode === 'C' const clozeQuestion = isCloze ? (current?.cloze_question || null) : null this.setData({ progress: Math.round((session.currentIndex / total) * 100), current: session.currentIndex + 1, total, word: isCloze ? (current?.blank_prompt || clozeQuestion?.prompt_text || current?.prompt || '') : (current?.prompt || ''), meaning: isCloze ? (current?.answer_hint || '') : '', answer: session.currentAnswer || '', clozeAnswers: isCloze ? (Array.isArray(session.currentAnswers) ? session.currentAnswers : Array.from({ length: clozeQuestion?.blanks?.length || 0 }, () => '')) : [], activeClozeBlankIndex: 0, feedback: '', revealAnswer: '', feedbackType: '', showFeedback: false, completed: session.completed, masterHint: false, questionBadge: isCloze ? `填空 · ${current?.entry_type === 'sentence' ? '句子' : '单词'}` : `${current?.entry_type === 'sentence' ? '句子' : '单词'} · ${current?.question_mode || 'A'}`, entryTypeHint: isCloze ? '请逐空作答,提交后会显示每个空的结果。' : (current?.entry_type === 'sentence' ? '当前题目来自句子库,请注意上下文表达。' : ''), isCloze, currentClozeQuestion: clozeQuestion, currentClozeResults: session.lastClozeResults || [], showClozeFeedback: false, clozeSegments: isCloze ? this.buildClozeSegments(clozeQuestion) : [], clozeFeedbackSummary: '', }) }, onAnswerInput(e) { const session = this.getReviewSessionState() const answer = e.detail.value session.currentAnswer = answer this.setData({ answer, showFeedback: false, revealAnswer: '', feedback: '', feedbackType: '', judged: false, showClozeFeedback: false }) }, onClozeLetterInput(e) { const session = this.getReviewSessionState() const { index, letterIndex } = e.currentTarget.dataset const rawValue = e.detail.value || '' const cleaned = rawValue.slice(-1) const answers = [...(session.currentAnswers || [])] const current = answers[index] || '' const chars = current.split('') chars[letterIndex] = cleaned answers[index] = chars.join('').slice(0, Math.max(chars.length, letterIndex + 1)) session.currentAnswers = answers this.setData({ [`clozeAnswers[${index}]`]: answers[index], showFeedback: false, revealAnswer: '', feedback: '', feedbackType: '', judged: false, showClozeFeedback: false, }, () => { if (cleaned) this.focusNextLetterCell(Number(index), Number(letterIndex) + 1) }) }, focusNextLetterCell(blankIndex, letterIndex) { const selector = `#cloze-input-${blankIndex}-${letterIndex}` wx.nextTick(() => { const query = wx.createSelectorQuery().in(this) query.select(selector).fields({ node: true }).exec((res) => { if (!res || !res[0]) return const node = res[0].node if (node && typeof node.focus === 'function') node.focus() }) }) }, handleLetterKeydown(e) { const session = this.getReviewSessionState() const key = e?.detail?.key || e?.detail?.value || '' const { index, letterIndex } = e.currentTarget.dataset if (key === 'Backspace' || key === 'Delete') { const answers = [...(session.currentAnswers || [])] const current = answers[index] || '' const chars = current.split('') const pos = Number(letterIndex) if (chars[pos]) { chars[pos] = '' answers[index] = chars.join('') session.currentAnswers = answers this.setData({ [`clozeAnswers[${index}]`]: answers[index] }) return } const prevIndex = Number(letterIndex) - 1 if (prevIndex >= 0) this.focusNextLetterCell(Number(index), prevIndex) return } if (key === 'ArrowLeft') { const prevIndex = Number(letterIndex) - 1 if (prevIndex >= 0) this.focusNextLetterCell(Number(index), prevIndex) return } if (key === 'ArrowRight' || key === ' ' || key === 'Space') { this.focusNextLetterCell(Number(index), Number(letterIndex) + 1) } }, onClozeAnswerInput(e) { const session = this.getReviewSessionState() const { index } = e.currentTarget.dataset const rawValue = e.detail.value || '' const clozeQuestion = this.data.currentClozeQuestion const blank = clozeQuestion?.blanks?.[index] const maxLength = Math.max(1, Number(blank?.answer?.length || 1)) const normalizedValue = rawValue.slice(-maxLength).slice(0, maxLength) const answers = [...(session.currentAnswers || [])] answers[index] = normalizedValue session.currentAnswers = answers const nextIndex = Number(index) + (normalizedValue.length >= maxLength ? 1 : 0) const prevIndex = Number(index) - 1 this.setData({ [`clozeAnswers[${index}]`]: normalizedValue, activeClozeBlankIndex: nextIndex, showFeedback: false, revealAnswer: '', feedback: '', feedbackType: '', judged: false, showClozeFeedback: false, }, () => { if (!normalizedValue && this.data.isCloze && rawValue === '') { this.focusNextClozeInput(Math.max(0, prevIndex)) return } if (normalizedValue.length >= maxLength && this.data.isCloze) this.focusNextClozeInput(nextIndex) }) }, focusNextClozeInput(nextIndex) { const selector = `#cloze-input-${nextIndex}` wx.nextTick(() => { const query = wx.createSelectorQuery().in(this) query.select(selector).fields({ node: true, size: true }).exec((res) => { if (!res || !res[0]) return const node = res[0].node if (node && typeof node.focus === 'function') node.focus() }) }) }, submitAnswer(showExplanation = false, advanceAfterSubmit = false) { const app = appContext.withApp() const session = this.getReviewSessionState() const current = session.deck[session.currentIndex] if (!current || this.submitting || this.judged) return const answer = (this.data.answer || '').trim() const clozeQuestion = current.cloze_question const payload = { session_id: app.globalData.reviewSessionMeta.id, record_id: current.record_id, user_answer: answer, } if (current.question_mode === 'C' && clozeQuestion?.blanks?.length) { const answers = (this.data.clozeAnswers || []).map((item) => (item || '').trim()) while (answers.length < clozeQuestion.blanks.length) answers.push('') if (!answers.some((item) => item)) { wx.showToast({ title: '请先输入答案', icon: 'none' }) return } payload.user_answers = answers } else if (!answer) { wx.showToast({ title: '请先输入答案', icon: 'none' }) return } this.submitting = true wx.request({ url: `${app.globalData.baseUrl}/api/v1/review/submit`, method: 'POST', header: { 'Content-Type': 'application/json', 'X-Auth-Token': app.globalData.authToken || '' }, data: payload, success: (res) => { if (appContext.isUnauthorizedResponse(res)) { appContext.handleUnauthorized() return } const ok = res.statusCode >= 200 && res.statusCode < 300 && res.data?.code === 0 if (!ok) { wx.showToast({ title: res.data?.detail || res.data?.message || '提交答案失败', icon: 'none' }) return } const result = res.data?.data || {} session.correctCount += result.is_correct ? 1 : 0 session.wrongCount += result.is_correct ? 0 : 1 this.judged = true const shouldShowAnswer = showExplanation || this.data.settings.showAnswerAfterSubmit const correctAnswerText = result.correct_answer_text || '' const clozeResults = Array.isArray(result.cloze_results) ? result.cloze_results : [] const fallbackClozeResults = clozeResults.length ? clozeResults : (this.data.currentClozeQuestion?.blanks || []).map((blank, idx) => ({ index: typeof blank?.index === 'number' ? blank.index : idx, user_answer: '', correct_answers: [blank?.answer, ...(Array.isArray(blank?.accepted_answers) ? blank.accepted_answers : [])].filter(Boolean), correct_answer_text: blank?.answer || '', is_correct: false, })) const normalizedClozeResults = fallbackClozeResults.map((item, idx) => { const correctAnswers = Array.isArray(item.correct_answers) ? item.correct_answers.filter(Boolean) : [item.correct_answer_text, ...(Array.isArray(item.accepted_answers) ? item.accepted_answers : [])].filter(Boolean) const standardAnswerText = correctAnswers.join(' / ') || item.correct_answer_text || '' return { index: item.index ?? idx, user_answer: item.user_answer ?? '', correct_answers: correctAnswers, correct_answer_text: item.correct_answer_text || standardAnswerText, standard_answer_text: standardAnswerText, is_correct: Boolean(item.is_correct), } }) const derivedClozeResults = normalizedClozeResults.length ? normalizedClozeResults : (correctAnswerText ? [{ index: 0, user_answer: '', correct_answers: [correctAnswerText], correct_answer_text: correctAnswerText, standard_answer_text: correctAnswerText, is_correct: false }] : []) if (derivedClozeResults.length) { session.lastClozeResults = derivedClozeResults } const clozeFeedbackSummary = result.is_correct ? '回答正确' : '回答错误' this.setData({ feedback: clozeFeedbackSummary, feedbackType: result.is_correct ? 'correct' : 'wrong', revealAnswer: '', showFeedback: true, showClozeFeedback: derivedClozeResults.length > 0, currentClozeResults: derivedClozeResults, clozeFeedbackSummary, }) if (this.data.isCloze) { session.currentAnswers = [] this.setData({ clozeAnswers: [] }) } if (this.data.settings.autoNextQuestion || advanceAfterSubmit) { const delay = shouldShowAnswer ? 2500 : 0 setTimeout(() => this.nextQuestion(), delay) } }, complete: () => { this.submitting = false }, }) }, checkAnswer() { this.submitAnswer(true) }, nextQuestion() { const app = appContext.withApp() const session = this.getReviewSessionState() if (!session.deck.length) return if (!this.judged) { this.submitAnswer(true, true) return } this.judged = false if (session.currentIndex >= session.deck.length - 1) { session.completed = true session.endedAt = Date.now() wx.request({ url: `${app.globalData.baseUrl}/api/v1/review/finish?session_id=${app.globalData.reviewSessionMeta.id}`, method: 'POST', header: { 'X-Auth-Token': app.globalData.authToken || '' }, success: (res) => { if (appContext.isUnauthorizedResponse(res)) { appContext.handleUnauthorized() return } wx.navigateTo({ url: '/pages/review-result/review-result' }) }, fail: () => { wx.showToast({ title: '提交结果失败,已按本地完成处理', icon: 'none' }) wx.navigateTo({ url: '/pages/review-result/review-result' }) }, }) return } session.currentIndex += 1 session.currentAnswer = '' session.currentAnswers = [] this.syncSession() }, markMastered() { this.setData({ masterHint: true }) wx.showToast({ title: '已标记', icon: 'success' }) }, })