add subscribe

This commit is contained in:
Felix
2026-01-18 11:48:03 +08:00
parent 90057c8ddb
commit 751b2ae087
14 changed files with 383 additions and 151 deletions

View File

@@ -82,7 +82,7 @@ Component({
const hasPastExam = !!(detail['individual'] && detail['individual'].pastExamSents && detail['individual'].pastExamSents.length > 0)
const defaultTab = hasCollins ? '0' : (hasPhrs ? '1' : (hasPastExam ? '2' : '3'))
const protoTemp = detail.ee?.word?.['return-phrase']?.['l']?.['i'] || ''
const proto = protoTemp != '' && protoTemp != word ? protoTemp : (detail?.ec?.word?.[0]?.prototype || '')
const proto = protoTemp != '' && protoTemp.toLowerCase() != word.toLowerCase() ? protoTemp : (detail?.ec?.word?.[0]?.prototype || '')
const hideProto = !!self.data.forceHidePrototype
self.setData({
wordDict: {

View File

@@ -1387,7 +1387,9 @@ Page<IPageData, IPageInstance>({
})
recorderManager.onError((res) => {
wx.showToast({ title: '录音失败', icon: 'none' })
logger.error('录音失败', res)
const msg = (res as any)?.errMsg || '录音失败'
wx.showToast({ title: msg, icon: 'none' })
// 录音出错时淡出高亮层
this.setData({ overlayVisible: false, isRecording: false })
const timer = setTimeout(() => {

View File

@@ -4,6 +4,7 @@ import logger from '../../utils/logger'
Page({
data: {
products: [] as Array<any>,
plans: [] as Array<any>,
userPoints: 0,
displayUserPoints: '0',
vipLevel: 0
@@ -11,11 +12,20 @@ Page({
async onLoad(options: Record<string, string>) {
logger.info('Coupon page loaded')
try {
const list = await apiManager.getProductList()
const products = (list || []).map((p: any) => ({
const [productList, planList] = await Promise.all([
apiManager.getProductList(),
apiManager.getSubscriptionPlans()
])
const products = (productList || []).map((p: any) => ({
...p,
amountYuan: ((p.amount_cents || 0) / 100).toFixed(2)
}))
const plans = (planList || []).map((p: any) => ({
...p,
priceYuan: ((p.price || 0) / 100).toFixed(2)
}))
const ptsStr = options?.points || '0'
const lvlStr = options?.vipLevel || '0'
const ptsNum = Number(ptsStr) || 0
@@ -26,6 +36,7 @@ Page({
}
this.setData({
products,
plans,
userPoints: ptsNum,
displayUserPoints: fmt(ptsNum),
vipLevel: lvlNum
@@ -35,6 +46,59 @@ Page({
wx.showToast({ title: '加载失败', icon: 'none' })
}
},
async handlePlanTap(e: any) {
try {
const productId = e?.currentTarget?.dataset?.id
if (!productId) {
wx.showToast({ title: '订阅商品信息错误', icon: 'none' })
return
}
const order = await apiManager.createSubscriptionOrder(String(productId))
const prepayId = order?.prepay_id
const outTradeNo = order?.out_trade_no
if (!prepayId) {
wx.showToast({ title: '下单失败', icon: 'none' })
return
}
const pkg = `prepay_id=${prepayId}`
const timeStamp = order?.timeStamp
const nonceStr = order?.nonceStr
const paySign = order?.paySign
const signType = (order?.signType || 'RSA') as any
if (timeStamp && nonceStr && paySign) {
wx.requestPayment({
timeStamp,
nonceStr,
package: pkg,
signType,
paySign,
success: async () => {
if (outTradeNo) {
await this.queryPaymentWithRetry(outTradeNo)
} else {
wx.showToast({ title: '订阅成功', icon: 'success' })
}
},
fail: async (err) => {
logger.error('订阅支付失败', err)
if (outTradeNo) {
await this.queryPaymentWithRetry(outTradeNo)
} else {
wx.showToast({ title: '订阅支付失败', icon: 'none' })
}
}
})
} else {
wx.showToast({ title: '缺少支付签名参数', icon: 'none' })
}
} catch (error) {
logger.error('发起订阅支付失败', error)
wx.showToast({ title: '发起订阅支付失败', icon: 'none' })
}
},
async handleCouponTap(e: any) {
try {
const productId = e?.currentTarget?.dataset?.id
@@ -55,7 +119,7 @@ Page({
const timeStamp = order?.timeStamp
const nonceStr = order?.nonceStr
const paySign = order?.paySign
const signType = order?.signType || 'RSA'
const signType = (order?.signType || 'RSA') as any
if (timeStamp && nonceStr && paySign) {
wx.requestPayment({

View File

@@ -1,16 +1,19 @@
<!--coupon.wxml-->
<view class="coupon-container">
<view class="coupon_title">
订阅计划
</view>
<view class="card-box">
<view class="card" wx:for="{{plans}}" wx:key="id" wx:for-item="plan" bindtap="handlePlanTap" data-id="{{plan.id}}">
<view class="card-title">{{plan.name}}</view>
<view class="card-points">{{plan.features && plan.features.label ? plan.features.label : ''}}</view>
<view class="card-credits">{{plan.features && plan.features.extra ? plan.features.extra : ''}}</view>
<view class="card-price">¥{{plan.priceYuan}}</view>
</view>
</view>
<view class="coupon_title">
获取更多积分
</view>
<!-- <view class='coupon_box {{item.one_time ? "one_time" : ""}}' wx:for="{{products}}" wx:key="id" wx:for-item="item">
<view class='content' bindtap="handleCouponTap" data-id="{{item.id}}" data-points="{{item.points}}">
<view class='title'>{{item.title}}</view>
<view class='how_much'>{{item.points}}</view>
</view>
<view class='btn'> ¥{{item.amountYuan}}</view>
</view> -->
<view class="card-box">
<view class="card" wx:for="{{products}}" wx:key="id" wx:for-item="item" bindtap="handleCouponTap" data-id="{{item.id}}" data-points="{{item.points}}">
<view class="card-title">{{item.title}}</view>
@@ -26,6 +29,7 @@
<view class="tips-title">积分说明</view>
</view>
<view class="tips-list">
<view class="tips-item">订阅期间不消耗积分。</view>
<view class="tips-item">积分购买后永久有效。</view>
<!-- <view class="tips-item"></view> -->
</view>

View File

@@ -23,7 +23,9 @@ Page({
frozen_balance: 0,
total_purchased: 0,
total_refunded: 0,
expired_time: ''
expired_time: '',
is_subscribed: false,
subscription_expires_at: null as string | null
},
// 兑换码弹窗显示控制
@@ -316,7 +318,7 @@ Page({
const pointsData = await apiManager.getPointsData()
// 如果返回的数据为null则设置默认值
const finalPointsData = pointsData || { balance: 0, available_balance: 0, frozen_balance: 0, total_purchased: 0, total_refunded: 0, expired_time: '' }
const finalPointsData = pointsData || { balance: 0, available_balance: 0, frozen_balance: 0, total_purchased: 0, total_refunded: 0, expired_time: '', is_subscribed: false, subscription_expires_at: null }
const endTime = Date.now()
logger.info('积分数据加载完成,耗时:', endTime - startTime, 'ms', finalPointsData)
@@ -331,12 +333,31 @@ Page({
frozen_balance: 0,
total_purchased: 0,
total_refunded: 0,
expired_time: ''
expired_time: '',
is_subscribed: false,
subscription_expires_at: null as string | null
}
})
}
},
formatDate(dateStr: string | null) {
if (!dateStr) {
return ''
}
const date = new Date(dateStr)
const time = date.getTime()
if (isNaN(time)) {
return dateStr || ''
}
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const mm = month < 10 ? '0' + month : '' + month
const dd = day < 10 ? '0' + day : '' + day
return `${year}-${mm}-${dd}`
},
// 加载缓存统计信息
loadCacheStats() {
return new Promise<void>((resolve) => {

View File

@@ -69,11 +69,11 @@
maxcharacter="{{6}}"
/>
</t-dialog>
<navigator url="/pages/coupon/coupon?points={{points.available_balance}}" class="cell-navigator">
<t-cell title="积分" hover note="{{points.available_balance}}">
<t-icon slot="left-icon" name="star" size="44rpx"></t-icon>
</t-cell>
</navigator>
<navigator url="/pages/coupon/coupon?points={{points.available_balance}}" class="cell-navigator">
<t-cell title="{{points.is_subscribed ? '订阅到期时间' : '积分'}}" hover note="{{points.is_subscribed ? points.subscription_expires_at : points.available_balance}}">
<t-icon slot="left-icon" name="star" size="44rpx"></t-icon>
</t-cell>
</navigator>
<navigator url="/pages/order/order" class="cell-navigator">
<t-cell title="订单记录" hover arrow>
<t-icon slot="left-icon" name="shop" size="44rpx"></t-icon>

View File

@@ -1,5 +1,5 @@
{
"navigationBarTitleText": "问答练习",
"navigationBarTitleText": "场景练习",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#ffffff",

View File

@@ -5,13 +5,13 @@ import { IQaExerciseItem, IQaExerciseSession, IAppOption } from '../../types/app
export const QUESTION_MODES = {
CLOZE: 'cloze',
CHOICE: 'choice',
FREE_TEXT: 'free_text',
VARIATION: 'variation'
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]
export const ENABLED_QUESTION_MODES: QuestionMode[] = [QUESTION_MODES.CHOICE, QUESTION_MODES.CLOZE, QUESTION_MODES.VARIATION, QUESTION_MODES.CONVERSATION]
interface IData {
loadingMaskVisible: boolean
@@ -42,7 +42,6 @@ interface IData {
selectedClozeIndex?: number
clozeParts?: string[]
clozeSentenceTokens?: Array<{ text: string; word?: string; isBlank?: boolean }>
freeTextInput: string
imageLocalUrl?: string
imageLoaded?: boolean
modeAnim?: '' | 'fade-in' | 'fade-out'
@@ -97,16 +96,20 @@ interface IData {
variationSubmitted?: boolean
variationResultStatus?: 'correct' | 'incorrect'
variationExerciseId?: string
}
conversationSetting?: 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
initExerciseContent: (exercise: any, session?: IQaExerciseSession) => void
updateActionButtonsState: () => void
shuffleArray: <T>(arr: T[]) => T[]
@@ -119,7 +122,6 @@ interface IPageInstance {
onRetryTap: () => void
ensureQuestionResultFetched: (questionId: string) => Promise<void>
applyCachedResultForQuestion: (questionId: string) => void
inputChange: (e: any) => void
previewImage: () => void
submitAttempt: () => Promise<void>
onPrevTap: () => void
@@ -138,7 +140,6 @@ interface IPageInstance {
onTabsChange: (e: any) => void
onTabsClick: (e: any) => void
handleBackToPreviousWord: () => void
onHintTap: () => Promise<void>
onOptionLongPress: (e: any) => void
onImageLoad: () => void
onImageError: () => void
@@ -152,13 +153,14 @@ interface IPageInstance {
resetConfetti: () => void
loadVariationImages: (list: any[]) => void
onVariationImageLoad: (e: any) => void
previewVariationImage: (e: any) => void
}
Page<IData, IPageInstance>({
data: {
loadingMaskVisible: false,
imageId: '',
statusText: '加载中...',
statusText: '生成练习中...',
exercises: [],
contentVisible: false,
contentReady: false,
@@ -176,7 +178,6 @@ Page<IData, IPageInstance>({
choiceSubmitted: false,
evalClasses: [],
clozeSentenceTokens: [],
freeTextInput: '',
modeAnim: '',
isModeSwitching: false,
progressText: '',
@@ -241,8 +242,8 @@ Page<IData, IPageInstance>({
const r = cache[qid] || {}
const c1 = !!((r.choice || {}).evaluation)
const c2 = !!((r.cloze || {}).evaluation)
const c3 = !!((r.free_text || {}).evaluation)
let depth = Number(c1) + Number(c2) + Number(c3)
const c4 = !!((r.variation || {}).evaluation)
let depth = Number(c1) + Number(c2) + Number(c4)
if (depth === 0 && attemptMap.has(qid)) {
const att = attemptMap.get(qid)
@@ -263,11 +264,11 @@ Page<IData, IPageInstance>({
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.free_text || {}).evaluation)
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 })
@@ -285,7 +286,7 @@ Page<IData, IPageInstance>({
const qid = String(q.id)
// Check local cache (latest submission)
const r = cache[qid] || {}
const hasLocalResult = !!((r.choice || {}).evaluation) || !!((r.cloze || {}).evaluation) || !!((r.free_text || {}).evaluation)
const hasLocalResult = !!((r.choice || {}).evaluation) || !!((r.cloze || {}).evaluation) || !!((r.variation || {}).evaluation)
// Check session history
const hasSessionAttempt = attemptSet.has(qid)
return hasLocalResult || hasSessionAttempt
@@ -316,9 +317,6 @@ Page<IData, IPageInstance>({
} else if (mode === QUESTION_MODES.CLOZE) {
submitDisabled = hasResult
retryDisabled = !hasResult
} 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
@@ -371,6 +369,16 @@ Page<IData, IPageInstance>({
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()
@@ -393,7 +401,6 @@ Page<IData, IPageInstance>({
this.switchQuestion(1)
},
toggleMode() {
// const order: Array<'choice' | 'cloze' | 'free_text'> = ['choice', 'cloze', 'free_text']
const order: QuestionMode[] = ENABLED_QUESTION_MODES
const i = order.indexOf(this.data.questionMode)
const next = order[(i + 1) % order.length]
@@ -427,7 +434,7 @@ Page<IData, IPageInstance>({
const type = options.type as QuestionMode
if (type && ENABLED_QUESTION_MODES.includes(type)) {
this.setData({ fixedMode: type })
this.setData({ fixedMode: type, questionMode: type })
}
// 处理推荐人ID
@@ -463,7 +470,7 @@ Page<IData, IPageInstance>({
const imageId = options?.id || options?.image_id || ''
const thumbnailId = options?.thumbnail_id || '' // 兼容旧逻辑,如果是分享进来的可能需要通过 API 获取图片链接
this.setData({ imageId, loadingMaskVisible: true, statusText: '加载中...' })
this.setData({ imageId, loadingMaskVisible: true, statusText: '生成练习中...' })
if (type === QUESTION_MODES.VARIATION) {
await this.fetchVariationExercises(imageId)
} else {
@@ -588,7 +595,7 @@ Page<IData, IPageInstance>({
return
}
const { task_id } = await apiManager.createQaExerciseTask(imageId, 'scene_basic')
this.setData({ taskId: task_id, statusText: '解析中...' })
this.setData({ taskId: task_id, statusText: '生成练习中...' })
this.startPolling(task_id, imageId)
} catch (e) {
logger.error('获取练习失败', e)
@@ -601,10 +608,9 @@ Page<IData, IPageInstance>({
if (!list || list.length === 0) return
const loadingMap: Record<string, boolean> = {}
list.forEach(item => {
const fileId = (item.ext || {}).file_id
list.forEach((item: any) => {
const fileId = String(item.file_id || (item.ext || {}).file_id || '')
if (fileId) {
// If not already in variationImages, fetch url
if (!this.data.variationImages || !this.data.variationImages[fileId]) {
loadingMap[fileId] = true
}
@@ -613,25 +619,24 @@ Page<IData, IPageInstance>({
if (Object.keys(loadingMap).length === 0) return
// Update loading state for new items
this.setData({
variationImagesLoading: {
...this.data.variationImagesLoading,
...loadingMap
}
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 }
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 }
variationImagesLoading: { ...this.data.variationImagesLoading, [fileId]: false }
})
}
})
@@ -653,6 +658,7 @@ Page<IData, IPageInstance>({
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,
@@ -660,31 +666,72 @@ Page<IData, IPageInstance>({
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: 0,
questionMode: 'variation' as QuestionMode
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.setData({ variationTaskId: task_id, statusText: '生成练习中...' })
this.startVariationPolling(task_id, imageId)
} catch (e) {
logger.error('获取变化练习失败', 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 setting = await apiManager.getQaConversationSetting(imageId)
logger.log('Conversation setting:', setting)
if (!setting) {
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: setting,
loadingMaskVisible: false,
statusText: '加载完成'
})
} catch (e) {
logger.error('获取自由对话设置失败', e)
const msg = (e as any)?.message || ''
wx.showToast({ title: msg || '加载失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '加载失败' })
}
},
startVariationPolling(taskId: string, imageId: string) {
if (this.variationPollTimer) {
clearInterval(this.variationPollTimer)
@@ -709,6 +756,30 @@ Page<IData, IPageInstance>({
}
}, 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
},
startPolling(taskId: string, imageId: string) {
if (this.pollTimer) {
clearInterval(this.pollTimer)
@@ -753,7 +824,7 @@ 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
const preferredMode: QuestionMode = hasOptions ? QUESTION_MODES.CHOICE : (hasCloze ? QUESTION_MODES.CLOZE : QUESTION_MODES.FREE_TEXT)
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) {
@@ -798,7 +869,6 @@ Page<IData, IPageInstance>({
clozeParts,
clozeSentenceTokens,
answers: q.answers || {},
freeTextInput: '',
questionWords,
loadingMaskVisible: false,
statusText: '已获取数据',
@@ -858,7 +928,6 @@ Page<IData, IPageInstance>({
clozeParts,
clozeSentenceTokens,
answers: q.answers || {},
freeTextInput: '',
questionWords
})
const cur = this.data.qaList[idx] || {}
@@ -866,6 +935,9 @@ Page<IData, IPageInstance>({
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()
@@ -895,12 +967,9 @@ Page<IData, IPageInstance>({
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 })
this.setData({ questionMode: target, fixedMode: 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) {
@@ -914,11 +983,23 @@ Page<IData, IPageInstance>({
}
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: 0,
currentIndex: nextIndex,
modeAnim: 'fade-in',
isModeSwitching: false
})
@@ -931,6 +1012,16 @@ Page<IData, IPageInstance>({
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) {
@@ -956,7 +1047,6 @@ Page<IData, IPageInstance>({
const resetData: any = {
selectedOptionIndexes: [],
selectedIndex: -1,
freeTextInput: '',
modeAnim: 'fade-in',
selectedClozeIndex: -1,
choiceSubmitted: false
@@ -996,7 +1086,6 @@ Page<IData, IPageInstance>({
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.FREE_TEXT) return (resultObj.free_text || {}).evaluation || null
if (mode === QUESTION_MODES.VARIATION) return (resultObj.variation || {}).evaluation || null
return null
} catch (e) {
@@ -1065,12 +1154,6 @@ Page<IData, IPageInstance>({
})
const idx = optionContents.findIndex((w) => w === input)
this.setData({ selectedClozeIndex: idx, evalClasses })
} else if (this.data.questionMode === QUESTION_MODES.FREE_TEXT) {
const base = (r as any).free_text || {}
const text = String(base.text || '')
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 || '')
@@ -1166,20 +1249,6 @@ Page<IData, IPageInstance>({
}
}
} else if (type === QUESTION_MODES.FREE_TEXT) {
const text = String((base && base.text) || '')
const info1: string[] = []
if (text) info1.push(text)
if (info1.length) blocks.push({ title: '你的回答', variant: 'info', items: info1 })
const info2: string[] = []
if (result === 'correct' || result === 'exact') info2.push('完全匹配')
else if (result === 'partial') info2.push('部分匹配')
else info2.push('不匹配')
if (detail) info2.push(detail)
if (info2.length) blocks.push({ title: '结果说明', variant: 'info', items: info2 })
const info3: string[] = []
if (feedback) info3.push(feedback)
if (info3.length) blocks.push({ title: '反馈', variant: 'info', items: info3 })
} else {
const infoItems: string[] = []
if (detail) infoItems.push(detail)
@@ -1200,7 +1269,14 @@ Page<IData, IPageInstance>({
if (!evaluation) return
const type = String(evaluation?.type || '')
const qText = String(cur?.question || '')
const base = (modeNow === QUESTION_MODES.CHOICE) ? (r as any).choice : (modeNow === QUESTION_MODES.CLOZE) ? (r as any).cloze : (r as any).free_text
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,
@@ -1231,38 +1307,12 @@ Page<IData, IPageInstance>({
this.updateActionButtonsState()
return
}
if (this.data.questionMode === QUESTION_MODES.FREE_TEXT) {
this.setData({ freeTextInput: '', resultDisplayed: false })
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) {
wx.showToast({ title: '当前非输入题目', icon: 'none' })
return
}
const a = this.data.answers || {}
const keys = ['lively', 'spoken', 'written', 'friendly']
const items = keys.map((k) => k)
try {
const res = await wx.showActionSheet({ itemList: items })
const idx = res?.tapIndex ?? -1
if (idx >= 0) {
const key = keys[idx]
const v = a[key] || ''
if (v) {
this.setData({ freeTextInput: v })
this.updateActionButtonsState()
}
}
} catch (e) {}
},
selectOption(e: any) {
if (this.data.resultDisplayed) return
const i = Number(e?.currentTarget?.dataset?.index) || 0
@@ -1340,14 +1390,6 @@ Page<IData, IPageInstance>({
const i = Number(e?.currentTarget?.dataset?.index) || 0
this.setData({ selectedClozeIndex: i })
},
inputChange(e: any) {
const v = String(e?.detail?.value || '')
const english = v.replace(/[^A-Za-z0-9 ,.'!?-]/g, '')
if (english !== v) {
wx.showToast({ title: '仅限英文输入', icon: 'none' })
}
this.setData({ freeTextInput: english })
},
previewImage() {
const url = this.data.imageLocalUrl
if (url) {
@@ -1359,10 +1401,40 @@ Page<IData, IPageInstance>({
if (['correct'].includes(resStr)) {
this.fireConfetti()
// Check if there is a next question
const currentIndex = this.data.currentIndex
const total = (this.data.qaList || []).length
if (currentIndex >= total - 1) {
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
}
@@ -1389,7 +1461,8 @@ Page<IData, IPageInstance>({
setTimeout(() => {
const currentQ = this.data.qaList[this.data.currentIndex] || {}
if (String(currentQ.id) === qid) {
this.onNextTap()
const delta = targetIndex - this.data.currentIndex
this.switchQuestion(delta)
}
this.setData({ isAutoSwitching: false, nextButtonIcon: 'chevron-right' })
}, 3000)
@@ -1426,8 +1499,6 @@ Page<IData, IPageInstance>({
if (typeof sel === 'number' && sel >= 0 && sel < opt.length) {
payload.input_text = opt[sel]
}
} 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) {
@@ -1474,7 +1545,6 @@ Page<IData, IPageInstance>({
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.FREE_TEXT) evaluation = res.free_text?.evaluation
else if (mode === QUESTION_MODES.VARIATION) evaluation = res.variation?.evaluation
if (evaluation) {

View File

@@ -12,13 +12,14 @@
</view>
<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 class="type-item {{fixedMode === 'cloze' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="cloze">填空</view>
<view class="type-item {{fixedMode === 'choice' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="choice">问答</view>
<view class="type-item {{fixedMode === 'variation' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="variation">识图</view>
<view class="type-item {{fixedMode === 'conversation' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="conversation">对话</view>
</view>
<view class="container {{contentVisible ? 'fade-in' : 'fade-out'}}" wx:if="{{contentReady && !loadingMaskVisible}}">
<view class="process-container" wx:if="{{qaList && qaList.length > 0}}">
<view class="process-container" wx:if="{{qaList && qaList.length > 0 && questionMode !== 'conversation'}}">
<block wx:for="{{qaList}}" wx:key="index">
<view class="process-dot {{processDotClasses[index]}} {{index === currentIndex ? 'current' : ''}}"></view>
</block>
@@ -30,7 +31,7 @@
<!-- <text>View Full</text> -->
</view>
</view>
<view class="question-title">
<view class="question-title" wx:if="{{questionMode !== 'conversation'}}">
<text wx:for="{{questionWords}}" wx:key="index" class="word-item" data-word="{{item}}" bindtap="handleWordClick">{{item}}</text>
</view>
<!-- <view class="progress-text">{{progressText}}</view> -->
@@ -78,7 +79,7 @@
<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">
<view class="variation-image-wrapper {{index === variationSelectedIndex ? 'selected' : ''}}" data-index="{{index}}" bindtap="selectVariationOption">
<cloud-image
file-id="{{item.file_id}}"
mode="widthFix"
@@ -87,6 +88,9 @@
bind:load="onVariationImageLoad"
data-fileid="{{item.file_id}}"
/>
<view class="view-full" wx:if="{{variationImageLoaded[item.file_id]}}" data-fileid="{{item.file_id}}" catchtap="previewVariationImage">
<t-icon name="zoom-in" size="32rpx" />
</view>
<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 File

@@ -48,7 +48,7 @@
.image-card { position: relative; border-radius: 24rpx; overflow: hidden; height: 360rpx; }
.image { width: 100%; height: 360rpx; border-radius: 24rpx; background: #f5f5f5; }
.view-full { position: absolute; right: 16rpx; bottom: 16rpx; display: flex; align-items: center; gap: 8rpx; padding: 10rpx 16rpx; border-radius: 24rpx; background: rgba(0,0,0,0.4); color: #fff; }
.view-full { position: absolute; right: 16rpx; bottom: 16rpx; display: flex; align-items: center; gap: 8rpx; padding: 8rpx; border-radius: 24rpx; background: rgba(0,0,0,0.4); color: #fff; }
.question-title { font-size: 40rpx; font-weight: 700; color: #001858; line-height: 56rpx; width: 100%; display: flex; flex-wrap: wrap; justify-content: flex-start;}
.progress-text { font-size: 26rpx; color: #666; }
.word-item { display: inline; font-size: 40rpx; font-weight: 700; color: #001858; line-height: 56rpx; margin-right: 12rpx; }
@@ -491,6 +491,7 @@
background: #f5f5f5;
border-radius: 40rpx;
padding: 8rpx;
margin-top: 12rpx;
width: fit-content;
position: relative;
left: 50%;

View File

@@ -642,6 +642,9 @@ Page({
try {
this.setData({ isProcessing: true })
wx.showLoading({ title: '准备拍照...' })
try {
wx.pageScrollTo({ scrollTop: 0, duration: 300 })
} catch (e) {}
// const imagePath = await imageManager.takePhoto({
// quality: 80,
@@ -679,6 +682,9 @@ Page({
try {
this.setData({ isProcessing: true })
wx.showLoading({ title: '准备选择图片...' })
try {
wx.pageScrollTo({ scrollTop: 0, duration: 300 })
} catch (e) {}
// const imagePath = await imageManager.chooseFromAlbum({
// quality: 80,
@@ -767,7 +773,6 @@ Page({
}
} catch {}
},
async ensurePhotoPersistent() {
try {
const p = String(this.data.photoPath || '').trim()

View File

@@ -40,7 +40,7 @@
</view>
</view>
</view>
<view wx:if="{{takePhoto && showExpandLayer }}" class="photo-expand-layer" style="{{photoExpandTransform}} {{photoExpandTransition}}">
<view wx:if="{{takePhoto && showExpandLayer }}" class="photo-expand-layer" style="{{photoExpandTransform}} {{photoExpandTransition}}" catchtouchmove="noop">
<!-- <view class="photo-expand-inner" style="{{photoExpandCurrentWidth ? ('width: ' + photoExpandCurrentWidth + 'px;') : ''}} {{photoExpandCurrentHeight ? ('height: ' + photoExpandCurrentHeight + 'px;') : ''}}"> -->
<view class="photo-expand-inner" style="{{photoExpandCurrentWidth ? ('width: ' + photoExpandCurrentWidth + 'px;') : ''}} {{photoExpandCurrentHeight ? ('height: ' + photoExpandCurrentHeight + 'px;') : ''}}">
<image class="photo-expand-image {{photoExpandLoaded ? 'visible' : 'hidden'}}" src="{{takePhoto ? photoExpandSrc : photoSvgData}}" mode="widthFix" bindload="onPhotoExpandLoaded" binderror="onPhotoExpandError"></image>

View File

@@ -168,6 +168,8 @@ export interface IPointsData {
total_purchased: number
total_refunded: number
expired_time: string
is_subscribed: boolean
subscription_expires_at: string | null
}
export interface IQaExerciseSession {

View File

@@ -1961,7 +1961,7 @@ class ApiManager {
// 如果返回的数据为null则返回默认值
if (!response.data) {
return { balance: 0, available_balance: 0, frozen_balance: 0, total_purchased: 0, total_refunded: 0, expired_time: '' };
return { balance: 0, available_balance: 0, frozen_balance: 0, total_purchased: 0, total_refunded: 0, expired_time: '', is_subscribed: false, subscription_expires_at: null as any };
}
const d: any = response.data as any
const normalized = {
@@ -1970,7 +1970,9 @@ class ApiManager {
frozen_balance: Number(d.frozen_balance || 0),
total_purchased: Number(d.total_purchased || 0),
total_refunded: Number(d.total_refunded || 0),
expired_time: String(d.expired_time || '')
expired_time: String(d.expired_time || ''),
is_subscribed: !!d.is_subscribed,
subscription_expires_at: d.subscription_expires_at || null
}
return normalized as import("../types/app").IPointsData;
} catch (error) {
@@ -2017,6 +2019,29 @@ class ApiManager {
return response.data
}
async createSubscriptionOrder(product_id: string): Promise<{
out_trade_no: string
prepay_id: string
trade_state: string
timeStamp?: string
nonceStr?: string
paySign?: string
signType?: string
}> {
const response = await this.request<{
out_trade_no: string
prepay_id: string
trade_state: string
timeStamp?: string
nonceStr?: string
paySign?: string
signType?: string
}>('/api/v1/wxpay/order/jsapi/subscription', 'POST', {
product_id
})
return response.data
}
async getProductList(): Promise<Array<{
id: number
title: string
@@ -2036,6 +2061,33 @@ class ApiManager {
return response.data
}
async getSubscriptionPlans(): Promise<Array<{
id: string
name: string
price: number
cycle_type: string
cycle_length: number
max_cycle_usage: number
features?: {
label?: string
extra?: string
}
}>> {
const response = await this.request<Array<{
id: string
name: string
price: number
cycle_type: string
cycle_length: number
max_cycle_usage: number
features?: {
label?: string
extra?: string
}
}>>('/api/v1/product/plan', 'GET')
return response.data
}
async getWxpayOrder(out_trade_no: string): Promise<{
out_trade_no: string
trade_state: string
@@ -2111,6 +2163,13 @@ class ApiManager {
return resp.data
}
async getQaConversationSetting(imageId: string): Promise<any> {
const resp = await this.request<any>('/api/v1/qa/conversations/setting', 'POST', {
image_id: imageId
})
return resp.data
}
async createQaQuestionAttempt(
questionId: string,
payload: {