add variation

This commit is contained in:
Felix
2026-01-13 20:50:02 +08:00
parent 78b7964860
commit 90057c8ddb
12 changed files with 653 additions and 47 deletions

View File

@@ -0,0 +1,7 @@
{
"component": true,
"usingComponents": {
"t-image": "tdesign-miniprogram/image/image",
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton"
}
}

View File

@@ -0,0 +1,98 @@
import apiManager from '../../utils/api';
// Global memory cache for image URLs
const urlCache = new Map<string, string>();
// Track pending requests to avoid duplicate fetches for the same fileId
const pendingRequests = new Map<string, Promise<string>>();
Component({
virtualHost: true,
properties: {
fileId: {
type: String,
observer: 'loadUrl'
},
width: {
type: String,
value: '100%'
},
height: {
type: String,
value: '100%'
},
mode: {
type: String,
value: 'widthFix'
},
radius: {
type: String,
value: '0'
}
},
data: {
imageUrl: '',
isLoading: true,
isError: false
},
methods: {
async loadUrl(this: any, fileId: string) {
if (!fileId) {
this.setData({ imageUrl: '', isLoading: false });
return;
}
// Check cache first
if (urlCache.has(fileId)) {
this.setData({
imageUrl: urlCache.get(fileId),
isLoading: false,
isError: false
});
return;
}
this.setData({ isLoading: true, isError: false });
try {
let promise = pendingRequests.get(fileId);
if (!promise) {
promise = apiManager.getFileDisplayUrl(fileId);
pendingRequests.set(fileId, promise);
}
const url = await promise;
urlCache.set(fileId, url);
pendingRequests.delete(fileId);
// Ensure the fileId hasn't changed while we were fetching
if (this.data.fileId === fileId) {
console.log('[CloudImage] Loaded url for', fileId, url)
this.setData({
imageUrl: url,
isLoading: false
});
}
} catch (e) {
console.error('Failed to load image url for fileId:', fileId, e);
if (this.data.fileId === fileId) {
this.setData({
isLoading: false,
isError: true
});
}
pendingRequests.delete(fileId);
}
},
onLoad(this: any, e: any) {
this.triggerEvent('load', e);
},
onError(this: any, e: any) {
this.setData({ isError: true, isLoading: false });
this.triggerEvent('error', e);
}
}
});

View File

@@ -0,0 +1,24 @@
<view class="cloud-image" style="width: {{width}}; height: {{height}}; border-radius: {{radius}}; overflow: hidden; position: relative;">
<t-skeleton
wx:if="{{isLoading}}"
row-col="{{[{ width: '100%', height: '100%', borderRadius: radius }]}}"
loading
class="skeleton"
style="width: 100%; height: 100%; display: block;"
></t-skeleton>
<t-image
wx:if="{{!isLoading && imageUrl}}"
src="{{imageUrl}}"
mode="{{mode}}"
class="image"
t-class="image"
style="width: 100%; height: {{mode === 'widthFix' ? 'auto' : '100%'}}; display: block;"
bind:error="onError"
bind:load="onLoad"
/>
<view wx:if="{{isError}}" class="error-placeholder" style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #f5f5f5; color: #999;">
<text style="font-size: 24rpx;">加载失败</text>
</view>
</view>

View File

@@ -0,0 +1,34 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.cloud-image {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
}
.skeleton {
width: 100%;
height: 100%;
}
.image {
width: 100%;
height: 100%;
display: block;
}
.error-placeholder {
width: 100%;
height: 100%;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
color: #ccc;
font-size: 24rpx;
}

View File

@@ -21,7 +21,14 @@ Component({
const raw = String(word || '')
const cleaned = raw.replace(/[.,?!*;:'"()]/g, '').trim()
if (!cleaned) return
self.setData({ visible: true, loading: true })
// Clear previous word data before showing loading state
self.setData({
visible: true,
loading: true,
wordDict: {},
prototypeWord: '',
isWordEmptyResult: false
})
try {
const detail: any = await apiManager.getWordDetail(cleaned)
const collins = detail['collins']

View File

@@ -71,8 +71,7 @@
<view class="bottom-more-area {{isMoreMenuOpen ? 'open' : (isMoreMenuClosing ? 'close' : '')}}" wx:if="{{isMoreMenuOpen || isMoreMenuClosing}}">
<view class="more-items">
<view class="more-item" bindtap="onSceneSentenceTap">场景句型</view>
<view class="more-item" bindtap="onImageQaExerciseTap" data-type="choice">问答练习</view>
<view class="more-item" bindtap="onImageQaExerciseTap" data-type="cloze">完形填空</view>
<view class="more-item" bindtap="onImageQaExerciseTap" data-type="cloze">场景练习</view>
<!-- <view class="more-item">功能3</view> -->
</view>
</view>

View File

@@ -9,6 +9,7 @@
"t-icon": "tdesign-miniprogram/icon/icon",
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton",
"word-dictionary": "../../components/word-dictionary/word-dictionary",
"vx-confetti": "/components/vx-confetti/vx-confetti"
"vx-confetti": "/components/vx-confetti/vx-confetti",
"cloud-image": "../../components/cloud-image/cloud-image"
}
}

View File

@@ -3,14 +3,15 @@ import logger from '../../utils/logger'
import { IQaExerciseItem, IQaExerciseSession, IAppOption } from '../../types/app'
export const QUESTION_MODES = {
CHOICE: 'choice',
CLOZE: 'cloze',
FREE_TEXT: 'free_text'
CHOICE: 'choice',
FREE_TEXT: 'free_text',
VARIATION: 'variation'
} as const
export type QuestionMode = typeof QUESTION_MODES[keyof typeof QUESTION_MODES]
export const ENABLED_QUESTION_MODES: QuestionMode[] = [QUESTION_MODES.CHOICE, QUESTION_MODES.CLOZE]
export const ENABLED_QUESTION_MODES: QuestionMode[] = [QUESTION_MODES.CHOICE, QUESTION_MODES.CLOZE, QUESTION_MODES.VARIATION]
interface IData {
loadingMaskVisible: boolean
@@ -40,6 +41,7 @@ interface IData {
clozeOptions?: string[]
selectedClozeIndex?: number
clozeParts?: string[]
clozeSentenceTokens?: Array<{ text: string; word?: string; isBlank?: boolean }>
freeTextInput: string
imageLocalUrl?: string
imageLoaded?: boolean
@@ -85,20 +87,35 @@ interface IData {
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
}
interface IPageInstance {
pollTimer?: number
variationPollTimer?: number
audioCtx?: WechatMiniprogram.InnerAudioContext
fetchQaExercises: (imageId: string, referrerId?: string) => Promise<void>
fetchVariationExercises: (imageId: string) => Promise<void>
startPolling: (taskId: string, imageId: string) => void
startVariationPolling: (taskId: string, imageId: string) => void
initExerciseContent: (exercise: any, session?: IQaExerciseSession) => void
updateActionButtonsState: () => void
shuffleArray: <T>(arr: T[]) => T[]
buildClozeSentence: (sentenceRaw: any, correctWordRaw: any) => { blanked: string; parts: string[]; tokens: Array<{ text: string; word?: string; isBlank?: boolean }> }
updateContentReady: () => void
switchQuestion: (delta: number) => void
switchMode: (mode: QuestionMode) => void
switchMode: (arg: any) => void
selectOption: (e: any) => void
selectVariationOption: (e: any) => void
onRetryTap: () => void
ensureQuestionResultFetched: (questionId: string) => Promise<void>
applyCachedResultForQuestion: (questionId: string) => void
@@ -133,6 +150,8 @@ interface IPageInstance {
handleShareAchievement: () => void
fireConfetti: () => void
resetConfetti: () => void
loadVariationImages: (list: any[]) => void
onVariationImageLoad: (e: any) => void
}
Page<IData, IPageInstance>({
@@ -156,6 +175,7 @@ Page<IData, IPageInstance>({
imageLoaded: false,
choiceSubmitted: false,
evalClasses: [],
clozeSentenceTokens: [],
freeTextInput: '',
modeAnim: '',
isModeSwitching: false,
@@ -196,7 +216,14 @@ Page<IData, IPageInstance>({
canvasHeight: 0,
confetti: null,
nextButtonIcon: 'chevron-right',
isAutoSwitching: false
isAutoSwitching: false,
variationQaList: [],
variationTaskId: '',
variationImages: {},
variationImagesLoading: {},
variationImageLoaded: {},
variationSelectedIndex: -1,
variationSubmitted: false
},
updateProcessDots() {
const list = this.data.qaList || []
@@ -292,6 +319,10 @@ Page<IData, IPageInstance>({
} else if (mode === QUESTION_MODES.FREE_TEXT) {
submitDisabled = false
retryDisabled = true
} 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 })
},
@@ -305,6 +336,29 @@ Page<IData, IPageInstance>({
}
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 })
@@ -410,7 +464,11 @@ Page<IData, IPageInstance>({
const imageId = options?.id || options?.image_id || ''
const thumbnailId = options?.thumbnail_id || '' // 兼容旧逻辑,如果是分享进来的可能需要通过 API 获取图片链接
this.setData({ imageId, loadingMaskVisible: true, statusText: '加载中...' })
await this.fetchQaExercises(imageId, referrerId)
if (type === QUESTION_MODES.VARIATION) {
await this.fetchVariationExercises(imageId)
} else {
await this.fetchQaExercises(imageId, referrerId)
}
// 如果没有 thumbnailId尝试通过 imageId 获取(或者 fetchQaExercises 内部处理了?)
// 假设 fetchQaExercises 会处理内容,这里主要处理图片加载
@@ -488,7 +546,7 @@ Page<IData, IPageInstance>({
},
async fetchQaExercises(imageId: string, referrerId?: string) {
try {
const res = await apiManager.listQaExercisesByImage(imageId)
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) => ({
@@ -508,7 +566,10 @@ Page<IData, IPageInstance>({
completed = (session.progress as any).answered
}
const progressText = `已完成 ${completed} / ${total}`
this.setData({ session, progressText })
// Save to mainQaList as well
this.setData({ session, progressText, mainQaList: qaList })
this.initExerciseContent(exerciseWithList, session)
// 尝试获取图片链接(如果是通过 image_id 进入且没有 thumbnail_id
@@ -526,7 +587,7 @@ Page<IData, IPageInstance>({
return
}
const { task_id } = await apiManager.createQaExerciseTask(imageId)
const { task_id } = await apiManager.createQaExerciseTask(imageId, 'scene_basic')
this.setData({ taskId: task_id, statusText: '解析中...' })
this.startPolling(task_id, imageId)
} catch (e) {
@@ -536,6 +597,118 @@ Page<IData, IPageInstance>({
this.setData({ loadingMaskVisible: false, statusText: '加载失败' })
}
},
async loadVariationImages(list: any[]) {
if (!list || list.length === 0) return
const loadingMap: Record<string, boolean> = {}
list.forEach(item => {
const fileId = (item.ext || {}).file_id
if (fileId) {
// If not already in variationImages, fetch url
if (!this.data.variationImages || !this.data.variationImages[fileId]) {
loadingMap[fileId] = true
}
}
})
if (Object.keys(loadingMap).length === 0) return
// Update loading state for new items
this.setData({
variationImagesLoading: {
...this.data.variationImagesLoading,
...loadingMap
}
})
// Fetch URLs
Object.keys(loadingMap).forEach(async (fileId) => {
try {
const url = await apiManager.getFileDisplayUrl(fileId)
this.setData({
variationImages: { ...this.data.variationImages, [fileId]: url }
})
} 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 qaList = (res.questions || []).map((q: any) => ({
id: q.id,
exercise_id: q.exercise_id,
image_id: q.image_id,
question: q.question,
...(q.ext || {})
}))
logger.log('Variation exercises loaded:', qaList.length)
this.setData({
variationQaList: qaList,
variationExerciseId,
loadingMaskVisible: false,
statusText: '加载完成',
qaList: qaList,
currentIndex: 0,
questionMode: 'variation' as QuestionMode
})
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: '加载失败' })
}
},
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
},
startPolling(taskId: string, imageId: string) {
if (this.pollTimer) {
clearInterval(this.pollTimer)
@@ -579,12 +752,14 @@ Page<IData, IPageInstance>({
}
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_with_blank
const hasCloze = !!q?.cloze && !!q.cloze.sentence
const preferredMode: QuestionMode = hasOptions ? QUESTION_MODES.CHOICE : (hasCloze ? QUESTION_MODES.CLOZE : QUESTION_MODES.FREE_TEXT)
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 }> = []
@@ -594,11 +769,11 @@ Page<IData, IPageInstance>({
choiceOptions = [...correct, ...incorrect]
}
const choiceRequiredCount = Array.isArray(q?.correct_options) ? q.correct_options.length : 1
const clozeSentenceWithBlank = q?.cloze?.sentence_with_blank || ''
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 clozeParts = typeof clozeSentenceWithBlank === 'string' ? clozeSentenceWithBlank.split('___') : [clozeSentenceWithBlank || '', '']
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({
@@ -621,6 +796,7 @@ Page<IData, IPageInstance>({
clozeOptions,
selectedClozeIndex: -1,
clozeParts,
clozeSentenceTokens,
answers: q.answers || {},
freeTextInput: '',
questionWords,
@@ -648,7 +824,6 @@ Page<IData, IPageInstance>({
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 hasCloze = !!q?.cloze && !!q.cloze.sentence_with_blank
const mode: QuestionMode = this.data.questionMode
let choiceOptions: Array<{ content: string; correct: boolean; type?: string }> = []
if (hasOptions) {
@@ -657,11 +832,11 @@ Page<IData, IPageInstance>({
choiceOptions = [...correct, ...incorrect]
}
const choiceRequiredCount = Array.isArray(q?.correct_options) ? q.correct_options.length : 1
const clozeSentenceWithBlank = q?.cloze?.sentence_with_blank || ''
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 clozeParts = typeof clozeSentenceWithBlank === 'string' ? clozeSentenceWithBlank.split('___') : [clozeSentenceWithBlank || '', '']
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({
@@ -681,6 +856,7 @@ Page<IData, IPageInstance>({
clozeOptions,
selectedClozeIndex: -1,
clozeParts,
clozeSentenceTokens,
answers: q.answers || {},
freeTextInput: '',
questionWords
@@ -699,8 +875,17 @@ Page<IData, IPageInstance>({
}
this.updateProcessDots()
},
switchMode(mode: QuestionMode) {
if (this.data.fixedMode) return
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)) {
@@ -709,10 +894,66 @@ Page<IData, IPageInstance>({
if (this.data.isModeSwitching || target === this.data.questionMode) {
return
}
// Immediately update questionMode to reflect user selection visually
// Save previous mode in case we need to revert
const previousMode = this.data.questionMode
this.setData({ questionMode: target })
// Special handling for Variation mode switching
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) {
this.setData({ isModeSwitching: true, modeAnim: 'fade-out' })
setTimeout(() => {
this.setData({
qaList: this.data.variationQaList,
currentIndex: 0,
modeAnim: 'fade-in',
isModeSwitching: false
})
this.switchQuestion(0)
}, 300)
} else {
// Should not happen if fetch succeeded, but as safeguard
this.setData({ questionMode: previousMode })
}
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 = {
questionMode: target,
selectedOptionIndexes: [],
selectedIndex: -1,
freeTextInput: '',
@@ -744,6 +985,7 @@ Page<IData, IPageInstance>({
}
this.updateActionButtonsState()
}
setTimeout(() => {
this.setData({ modeAnim: '', isModeSwitching: false })
}, 300)
@@ -755,6 +997,7 @@ Page<IData, IPageInstance>({
if (mode === QUESTION_MODES.CHOICE) return (resultObj.choice || {}).evaluation || null
if (mode === QUESTION_MODES.CLOZE) return (resultObj.cloze || {}).evaluation || null
if (mode === QUESTION_MODES.FREE_TEXT) return (resultObj.free_text || {}).evaluation || null
if (mode === QUESTION_MODES.VARIATION) return (resultObj.variation || {}).evaluation || null
return null
} catch (e) {
return null
@@ -828,6 +1071,28 @@ Page<IData, IPageInstance>({
if (text) {
this.setData({ freeTextInput: text })
}
} 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() {
@@ -971,6 +1236,11 @@ Page<IData, IPageInstance>({
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
}
},
async onHintTap() {
if (this.data.questionMode !== QUESTION_MODES.FREE_TEXT) {
@@ -1133,8 +1403,12 @@ Page<IData, IPageInstance>({
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: String(this.data.exercise?.id || ''),
exercise_id: exerciseId,
mode: this.data.questionMode
}
if (this.data.isTrialMode) {
@@ -1154,6 +1428,16 @@ Page<IData, IPageInstance>({
}
} else if (this.data.questionMode === QUESTION_MODES.FREE_TEXT) {
payload.input_text = this.data.freeTextInput
} 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: '提交中' })
@@ -1178,11 +1462,12 @@ Page<IData, IPageInstance>({
this.setData({ qaResultFetched: fetched })
await this.ensureQuestionResultFetched(String(qid))
wx.hideLoading()
this.setData({ choiceSubmitted: true, submitDisabled: true, retryDisabled: false })
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
@@ -1190,18 +1475,30 @@ Page<IData, IPageInstance>({
if (mode === QUESTION_MODES.CHOICE) evaluation = res.choice?.evaluation
else if (mode === QUESTION_MODES.CLOZE) evaluation = res.cloze?.evaluation
else if (mode === QUESTION_MODES.FREE_TEXT) evaluation = res.free_text?.evaluation
else if (mode === QUESTION_MODES.VARIATION) evaluation = res.variation?.evaluation
if (evaluation) {
this.triggerAutoNextIfCorrect(evaluation, String(qid))
this.triggerAutoNextIfCorrect(evaluation, String(qid))
}
this.checkAllQuestionsAttempted()
} catch (e) {
wx.hideLoading()
logger.error('提交失败', e)
wx.showToast({ title: '提交失败', icon: 'none' })
}
},
selectVariationOption(e: any) {
if (this.data.resultDisplayed) return
let i = -1
if (typeof e === 'number') {
i = e
} else {
i = Number(e?.currentTarget?.dataset?.index)
if (isNaN(i)) i = -1
}
this.setData({ variationSelectedIndex: i, variationResultStatus: null as any })
this.updateActionButtonsState()
},
async getQuestionAudio(questionId: string) {
try {
const map = this.data.audioUrlMap || {}

View File

@@ -1,5 +1,6 @@
<view class="qa-exercise-container">
<view wx:if="{{!contentReady}}" class="page-loading-mask">
<view wx:if="{{!contentReady || loadingMaskVisible}}" class="page-loading-mask">
<view class="loading-center">
<view class="scanner scanner-visible">
<view class="star star1"></view>
@@ -10,13 +11,19 @@
</view>
</view>
<view class="container {{contentVisible ? 'fade-in' : 'fade-out'}}" wx:if="{{contentReady}}">
<view class="type-container" wx:if="{{fixedMode}}">
<view class="type-item {{questionMode === 'cloze' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="cloze">填空</view>
<view class="type-item {{questionMode === 'choice' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="choice">问答</view>
<view class="type-item {{questionMode === 'variation' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="variation">扩展训练</view>
</view>
<view class="container {{contentVisible ? 'fade-in' : 'fade-out'}}" wx:if="{{contentReady && !loadingMaskVisible}}">
<view class="process-container" wx:if="{{qaList && qaList.length > 0}}">
<block wx:for="{{qaList}}" wx:key="index">
<view class="process-dot {{processDotClasses[index]}} {{index === currentIndex ? 'current' : ''}}"></view>
</block>
</view>
<view class="image-card">
<view class="image-card" wx:if="{{questionMode !== 'variation'}}">
<image wx:if="{{imageLocalUrl}}" class="image" src="{{imageLocalUrl}}" mode="aspectFill" bindtap="previewImage" bindload="onImageLoad" binderror="onImageError"></image>
<view class="view-full" wx:if="{{imageLocalUrl}}" bindtap="previewImage">
<t-icon name="zoom-in" size="32rpx" />
@@ -40,11 +47,9 @@
</view>
</view>
<view class="question-content {{modeAnim}}" wx:if="{{questionMode === 'cloze'}}">
<view class="choice-title">Select the correct word to complete the sentence:</view>
<!-- <view class="choice-title">Select the correct word to complete the sentence:</view> -->
<view class="cloze-sentence">
<text class="cloze-text">{{clozeParts[0]}}</text>
<text class="cloze-fill">_____</text>
<text class="cloze-text">{{clozeParts[1]}}</text>
<text wx:for="{{clozeSentenceTokens}}" wx:key="index" class="{{item.isBlank ? 'cloze-fill' : 'cloze-text'}}" data-word="{{item.word}}" bindtap="handleWordClick">{{item.text}}</text>
</view>
<view class="option-list">
<view wx:for="{{clozeOptions}}" wx:key="index" class="option-item {{evalClasses[index]}} {{resultDisplayed ? 'disabled' : ''}}" data-index="{{index}}" data-word="{{item}}" bindtap="selectClozeOption" bindlongpress="onOptionLongPress">
@@ -69,6 +74,29 @@
<text class="input-type">English input</text>
</view>
</view>
<view class="question-content {{modeAnim}}" wx:if="{{questionMode === 'variation'}}">
<view class="variation-container">
<view class="variation-grid">
<view class="variation-item" wx:for="{{variationQaList}}" wx:key="index">
<view class="variation-image-wrapper {{index === variationSelectedIndex ? 'selected' : ''}}" data-index="{{index}}" bindtap="selectVariationOption">
<cloud-image
file-id="{{item.file_id}}"
mode="widthFix"
height="auto"
radius="24rpx"
bind:load="onVariationImageLoad"
data-fileid="{{item.file_id}}"
/>
<view wx:if="{{variationImageLoaded[item.file_id]}}" class="selection-badge {{variationResultStatus === 'incorrect' && index === variationSelectedIndex ? 'incorrect' : (index === variationSelectedIndex ? 'selected' : 'unselected')}}">
<t-icon name="{{variationResultStatus === 'incorrect' && index === variationSelectedIndex ? 'close-circle' : 'check-circle'}}" size="36rpx" color="#fff" />
</view>
<view wx:if="{{variationResultStatus && index === variationSelectedIndex}}" class="variation-border {{variationResultStatus}}"></view>
</view>
<!-- <view class="variation-text">{{item.question}}</view> -->
</view>
</view>
</view>
</view>
<view class="submit-row">
<button class="submit-btn" bindtap="onSubmitTap" disabled="{{submitDisabled}}" wx:if="{{retryDisabled}}">提交</button>

View File

@@ -348,6 +348,83 @@
border-radius: 4rpx;
}
/* Variation Mode Styles */
.variation-container {
width: 100%;
}
.variation-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24rpx;
width: 100%;
}
.variation-item {
width: 100%;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.variation-image-wrapper {
position: relative;
width: 100%;
border-radius: 24rpx;
overflow: hidden;
background-color: #f5f5f5;
min-height: 200rpx;
}
.variation-image-wrapper.selected {
}
.variation-border {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 24rpx;
border-width: 4rpx;
border-style: solid;
box-sizing: border-box;
pointer-events: none;
}
.variation-border.correct {
border-color: #21cc80;
}
.variation-border.incorrect {
border-color: #e34d59;
}
.selection-badge {
position: absolute;
top: 12rpx;
right: 12rpx;
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.selection-badge.unselected {
background-color: rgba(0,0,0,0.25);
}
.selection-badge.selected {
background-color: #21cc80;
box-shadow: 0 4rpx 12rpx rgba(33, 204, 128, 0.4);
}
.selection-badge.incorrect {
background-color: #e34d59;
box-shadow: 0 4rpx 12rpx rgba(227, 77, 89, 0.3);
}
.variation-text {
font-size: 28rpx;
color: #333;
line-height: 1.4;
}
.confetti.c1 { background: #FFD700; top: -10rpx; left: 10rpx; transform: rotate(-15deg); }
.confetti.c2 { background: #4facfe; top: 20rpx; right: -10rpx; transform: rotate(25deg); }
.confetti.c3 { background: #a18cd1; bottom: 0; left: -20rpx; transform: rotate(-45deg); }
@@ -407,3 +484,37 @@
z-index: 9999;
pointer-events: none; /* 确保canvas不会阻挡点击事件 */
}
.type-container {
display: flex;
justify-content: center;
background: #f5f5f5;
border-radius: 40rpx;
padding: 8rpx;
width: fit-content;
position: relative;
left: 50%;
transform: translateX(-50%);
z-index: 100;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.type-item {
padding: 12rpx 48rpx;
border-radius: 32rpx;
font-size: 28rpx;
color: #666;
transition: all 0.3s;
}
.type-item.active {
background: #fff;
color: #333;
font-weight: bold;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.1);
}
.type-item-hover {
opacity: 0.7;
background-color: rgba(0,0,0,0.05);
}

View File

@@ -92,8 +92,7 @@
</view>
<view wx:if="{{image.more_open || image.menu_closing}}" class="image-more-menu {{image.menu_closing ? 'closing' : 'opening'}}">
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{image.image_id}}">场景句型</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{image.image_id}}" data-thumbnail-id="{{image.thumbnail_file_id}}" data-type="choice">问答练习</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{image.image_id}}" data-thumbnail-id="{{image.thumbnail_file_id}}" data-type="cloze">完形填空</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{image.image_id}}" data-thumbnail-id="{{image.thumbnail_file_id}}" data-type="cloze">场景练习</view>
</view>
</view>
</view>
@@ -115,8 +114,7 @@
</view>
<view wx:if="{{item.more_open || item.menu_closing}}" class="image-more-menu {{item.menu_closing ? 'closing' : 'opening'}}">
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{item.image_id}}">场景句型</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="choice">问答练习</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="cloze">完形填空</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="cloze">场景练习</view>
</view>
</view>
</view>
@@ -135,8 +133,7 @@
</view>
<view wx:if="{{item.more_open || item.menu_closing}}" class="image-more-menu {{item.menu_closing ? 'closing' : 'opening'}}">
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{item.image_id}}">场景句型</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="choice">问答练习</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="cloze">完形填空</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="cloze">场景练习</view>
</view>
</view>
</view>

View File

@@ -2090,10 +2090,9 @@ class ApiManager {
return resp.data
}
async createQaExerciseTask(imageId: number | string, title?: string, description?: string): Promise<IQaExerciseCreateAccepted> {
async createQaExerciseTask(imageId: number | string, type?: string): Promise<IQaExerciseCreateAccepted> {
const payload: Record<string, any> = { image_id: imageId }
if (title !== undefined) payload.title = title
if (description !== undefined) payload.description = description
if (type !== undefined) payload.type = type
const resp = await this.request<IQaExerciseCreateAccepted>(`/api/v1/qa/exercises/tasks`, 'POST', payload)
return resp.data
}
@@ -2103,8 +2102,12 @@ class ApiManager {
return resp.data
}
async listQaExercisesByImage(imageId: string): Promise<IQaExerciseQueryResponse | null> {
const resp = await this.request<IQaExerciseQueryResponse | null>(`/api/v1/qa/${imageId}/exercises`, 'GET')
async listQaExercisesByImage(imageId: string, type?: string): Promise<IQaExerciseQueryResponse | null> {
let url = `/api/v1/qa/${imageId}/exercises`
if (type) {
url += `?type=${type}`
}
const resp = await this.request<IQaExerciseQueryResponse | null>(url, 'GET')
return resp.data
}