修复小程序端的BUG

This commit is contained in:
taiyi 2026-05-13 15:42:18 +08:00
parent 6ec4b114da
commit c64a701b10
9 changed files with 319 additions and 119 deletions

View File

@ -1,4 +1,4 @@
const app = getApp()
const appContext = require('../../utils/app-context.js')
Page({
data: {
@ -10,18 +10,31 @@ Page({
this.loadStats()
},
async loadStats() {
const app = appContext.withApp()
this.setData({ loading: true, error: '' })
await app.ensureLogin()
await appContext.ensureLogin()
if (!app.globalData.authToken) {
this.setData({ error: '登录失败,请稍后重试', loading: false })
this.setData({ error: '请先登录后查看首页数据', loading: false })
return
}
wx.request({
url: `${app.globalData.baseUrl}/api/v1/home/stats`,
header: { 'X-Auth-Token': app.globalData.authToken },
success: (res) => {
if (appContext.isUnauthorizedResponse(res)) {
appContext.handleUnauthorized()
return
}
const payload = res.data?.data
if (payload) this.setData({ stats: payload })
const ok = res.statusCode >= 200 && res.statusCode < 300 && res.data?.code === 0 && payload
if (!ok) {
this.setData({ error: res.data?.detail || res.data?.message || '首页数据加载失败' })
return
}
this.setData({ stats: payload })
},
fail: () => this.setData({ error: '首页数据加载失败' }),
complete: () => this.setData({ loading: false }),

View File

@ -1,17 +1,22 @@
<view class="page">
<scroll-view scroll-y class="content">
<view class="greeting">
<text class="title">你好,同学</text>
<text class="subtitle">专注高频复习,持续积累词库</text>
<text class="title">你好,同学</text>
<text class="subtitle">专注高频复习,持续积累你的词库与掌握度</text>
</view>
<view wx:if="{{!stats}}" class="empty-state">
<view wx:if="{{loading}}" class="empty-state">
<text class="empty-title">加载中...</text>
<text class="empty-subtitle">正在同步你的学习数据</text>
</view>
<view wx:elif="{{error}}" class="empty-state">
<text class="empty-title">暂时无法加载首页</text>
<text class="empty-subtitle">{{error}}</text>
</view>
<view wx:elif="{{stats.review_count === 0 && stats.total_entries === 0}}" class="empty-state">
<text class="empty-title">还没有词库</text>
<text class="empty-title">还没有词库内容</text>
<text class="empty-subtitle">先去“我的”里上传一些单词或句子吧</text>
</view>
@ -23,7 +28,7 @@
<text class="hero-number">{{stats.review_count}}</text>
<text class="label">今日待复习</text>
</view>
<view class="hero-icon">🧠</view>
<view class="hero-icon">📘</view>
</view>
<view class="hero-grid">
<view class="metric-card"><text class="metric-value">{{stats.total_entries}}</text><text class="label small">词库总数</text></view>
@ -51,7 +56,7 @@
<view class="bottom-nav">
<view class="nav-item active" bindtap="goHome"><text class="nav-icon">⌂</text><text class="nav-label">首页</text></view>
<view class="nav-item" bindtap="goToReview"><text class="nav-icon">📖</text><text class="nav-label">复习</text></view>
<view class="nav-item" bindtap="goToReview"><text class="nav-icon"></text><text class="nav-label">复习</text></view>
<view class="nav-item" bindtap="goToMe"><text class="nav-icon">☺</text><text class="nav-label">我的</text></view>
</view>
</view>

141
me/me.js
View File

@ -1,4 +1,4 @@
const app = getApp()
const appContext = require('../../utils/app-context.js')
function normalizeAvatarUrl(avatarUrl, baseUrl) {
if (!avatarUrl) return ''
@ -6,13 +6,27 @@ function normalizeAvatarUrl(avatarUrl, baseUrl) {
}
function requestFailHint(errMsg) {
const m = String(errMsg || '').toLowerCase()
if (m.includes('domain')) return '域名未配置或未开启 HTTPS'
if (m.includes('ssl') || m.includes('certificate')) return 'HTTPS 证书异常,请检查服务端配置'
if (m.includes('timeout') || m.includes('timed out')) return '连接超时,请稍后重试'
const message = String(errMsg || '').toLowerCase()
if (message.includes('domain')) return '域名未配置或未开启 HTTPS'
if (message.includes('ssl') || message.includes('certificate')) return 'HTTPS 证书异常,请检查服务端配置'
if (message.includes('timeout') || message.includes('timed out')) return '连接超时,请稍后重试'
return '网络异常,请稍后重试'
}
function buildLoggedOutState() {
return {
loading: false,
name: '未登录',
nickname: '未登录',
username: '',
avatarUrl: '',
nicknameDraft: '',
badges: 0,
days: 0,
error: '',
}
}
const DEFAULT_AVATAR = 'data:image/svg+xml;utf8,' + encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
<defs>
@ -52,6 +66,7 @@ Page({
this.loadProfile()
},
isLoggedIn() {
const app = appContext.withApp()
return !!app.globalData.authToken
},
onAuthModeChange(e) {
@ -63,7 +78,8 @@ Page({
this.setData({ [`authForm.${field}`]: value })
},
submitAuth() {
const baseUrl = app.getApiBaseUrl ? app.getApiBaseUrl() : app.globalData.baseUrl
const app = appContext.withApp()
const baseUrl = appContext.getApiBaseUrl()
const mode = this.data.authMode
const username = (this.data.authForm.username || '').trim()
const password = (this.data.authForm.password || '').trim()
@ -87,6 +103,10 @@ Page({
data: mode === 'register' ? { username, password, nickname } : { username, password },
success: (res) => {
const payload = res.data?.data || null
if (appContext.isUnauthorizedResponse(res)) {
appContext.handleUnauthorized()
return
}
if (res.statusCode < 200 || res.statusCode >= 300 || !payload?.auth_token) {
const message = res.data?.detail || res.data?.message || `${mode === 'register' ? '注册' : '登录'}失败`
this.setData({ error: message })
@ -94,10 +114,11 @@ Page({
return
}
app.setLoginSession(payload)
appContext.setLoginSession(payload)
this.setData({
authForm: { username: '', password: '', nickname: '' },
username: payload.username || '',
error: '',
})
wx.showToast({ title: mode === 'register' ? '注册成功' : '登录成功', icon: 'success' })
this.loadProfile()
@ -111,30 +132,26 @@ Page({
})
},
loadProfile() {
const token = app.ensureLogin()
const app = appContext.withApp()
const token = appContext.ensureLogin()
Promise.resolve(token).then((authToken) => {
if (!authToken) {
this.setData({
loading: false,
name: '未登录',
nickname: '未登录',
username: '',
avatarUrl: '',
nicknameDraft: '',
badges: 0,
days: 0,
error: '',
})
this.setData(buildLoggedOutState())
return
}
this.setData({ loading: true, error: '' })
const baseUrl = app.getApiBaseUrl ? app.getApiBaseUrl() : app.globalData.baseUrl
const baseUrl = appContext.getApiBaseUrl()
wx.request({
url: `${baseUrl}/api/v1/auth/profile`,
method: 'GET',
header: app.getAuthHeader(),
header: appContext.getAuthHeader(),
success: (res) => {
if (appContext.isUnauthorizedResponse(res)) {
appContext.handleUnauthorized()
return
}
const body = res.data || {}
const profile = body.data
const ok = res.statusCode >= 200 && res.statusCode < 300 && body.code === 0 && profile
@ -189,19 +206,34 @@ Page({
this.setData({ nicknameDraft: e.detail.value })
},
saveNickname() {
const app = appContext.withApp()
if (!this.isLoggedIn()) return
const nickname = (this.data.nicknameDraft || '').trim()
if (!nickname) {
wx.showToast({ title: '请输入昵称', icon: 'none' })
return
}
wx.request({
url: `${app.getApiBaseUrl ? app.getApiBaseUrl() : app.globalData.baseUrl}/api/v1/auth/profile`,
url: `${appContext.getApiBaseUrl()}/api/v1/auth/profile`,
method: 'PUT',
header: app.getAuthHeader({ 'Content-Type': 'application/json' }),
header: appContext.getAuthHeader({ 'Content-Type': 'application/json' }),
data: { nickname },
success: (res) => {
const profile = res.data?.data || {}
if (appContext.isUnauthorizedResponse(res)) {
appContext.handleUnauthorized()
return
}
const body = res.data || {}
const profile = body.data || {}
const ok = res.statusCode >= 200 && res.statusCode < 300 && body.code === 0
if (!ok) {
wx.showToast({ title: body.detail || body.message || '保存失败', icon: 'none' })
return
}
const nextNickname = profile.nickname || nickname
const currentUser = { ...(app.globalData.currentUser || {}), nickname: nextNickname }
app.globalData.currentUser = currentUser
@ -209,10 +241,11 @@ Page({
this.setData({ nickname: nextNickname, name: nextNickname, nicknameDraft: nextNickname, showNicknameModal: false })
wx.showToast({ title: '保存成功', icon: 'success' })
},
fail: () => wx.showToast({ title: '保存失败', icon: 'none' }),
fail: (err) => wx.showToast({ title: requestFailHint(err.errMsg), icon: 'none' }),
})
},
onChooseAvatar(e) {
const app = appContext.withApp()
const tempPath = e.detail?.avatarUrl || ''
if (!this.isLoggedIn()) {
wx.showToast({ title: '请先登录', icon: 'none' })
@ -222,13 +255,19 @@ Page({
wx.showToast({ title: '未选择头像', icon: 'none' })
return
}
const baseUrl = app.getApiBaseUrl ? app.getApiBaseUrl() : app.globalData.baseUrl
const baseUrl = appContext.getApiBaseUrl()
wx.uploadFile({
url: `${baseUrl}/api/v1/auth/avatar`,
filePath: tempPath,
name: 'file',
header: app.getAuthHeader(),
header: appContext.getAuthHeader(),
success: (uploadRes) => {
if (Number(uploadRes.statusCode) === 401) {
appContext.handleUnauthorized()
return
}
try {
const payload = JSON.parse(uploadRes.data || '{}')
const ok = uploadRes.statusCode >= 200 && uploadRes.statusCode < 300 && payload.code === 0 && payload.data
@ -236,6 +275,7 @@ Page({
wx.showToast({ title: payload.message || '头像上传失败', icon: 'none' })
return
}
const rel = payload.data.avatar_url || ''
const full = normalizeAvatarUrl(rel, baseUrl)
const key = this.data.username || app.globalData.currentUser?.username || ''
@ -251,39 +291,50 @@ Page({
},
loadAchievements() {
if (!this.isLoggedIn()) return
const baseUrl = app.getApiBaseUrl ? app.getApiBaseUrl() : app.globalData.baseUrl
wx.request({
url: `${baseUrl}/api/v1/achievements`,
header: app.getAuthHeader(),
url: `${appContext.getApiBaseUrl()}/api/v1/achievements`,
header: appContext.getAuthHeader(),
success: (res) => {
if (appContext.isUnauthorizedResponse(res)) {
appContext.handleUnauthorized()
return
}
const body = res.data || {}
const ok = res.statusCode >= 200 && res.statusCode < 300 && body.code === 0 && Array.isArray(body.data)
if (!ok) return
const badges = body.data.filter((i) => i.is_unlocked).length
const badges = body.data.filter((item) => item.is_unlocked).length
this.setData({ badges })
},
})
},
logout() {
if (!this.isLoggedIn()) return
wx.request({
url: `${app.getApiBaseUrl ? app.getApiBaseUrl() : app.globalData.baseUrl}/api/v1/auth/logout`,
url: `${appContext.getApiBaseUrl()}/api/v1/auth/logout`,
method: 'POST',
header: app.getAuthHeader(),
complete: () => {
app.clearLoginSession()
this.setData({
name: '未登录',
nickname: '未登录',
username: '',
avatarUrl: '',
nicknameDraft: '',
badges: 0,
days: 0,
error: '',
})
header: appContext.getAuthHeader(),
success: (res) => {
if (appContext.isUnauthorizedResponse(res)) {
appContext.handleUnauthorized({ title: '登录状态已失效,请重新登录' })
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
}
appContext.clearLoginSession()
this.setData(buildLoggedOutState())
wx.showToast({ title: '已退出登录', icon: 'success' })
},
fail: (err) => {
wx.showToast({ title: requestFailHint(err.errMsg), icon: 'none' })
},
})
},
goTo(e) {

View File

@ -1,4 +1,4 @@
const app = getApp()
const appContext = require('../../utils/app-context.js')
Page({
data: {
@ -16,6 +16,7 @@ Page({
error: '',
},
onLoad() {
const app = appContext.withApp()
const plan = app.globalData.reviewPlan
this.setData({
activeTimeRange: plan.timeRangeIndex,
@ -28,6 +29,7 @@ Page({
})
},
persistPatch(patch) {
const app = appContext.withApp()
app.globalData.reviewPlan = { ...app.globalData.reviewPlan, ...patch }
},
setTimeRange(e) {
@ -61,6 +63,7 @@ Page({
this.persistPatch({ questionCount })
},
startReview() {
const app = appContext.withApp()
const scopeTypeMap = ['today', 'week', 'month']
const contentTypeMap = ['word', 'sentence']
const questionModeMap = ['A', 'B', 'C']
@ -68,12 +71,14 @@ Page({
const content_type = contentTypeMap[this.data.activeContentType]
const question_mode = questionModeMap[this.data.activeMode]
const scope_config = {}
this.setData({ loading: true, error: '' })
app.ensureLogin().then((authToken) => {
appContext.ensureLogin().then((authToken) => {
if (!authToken) {
this.setData({ loading: false, error: '请先登录后再开始复习' })
return
}
wx.request({
url: `${app.globalData.baseUrl}/api/v1/review/generate`,
method: 'POST',
@ -88,6 +93,11 @@ Page({
total_count: this.data.questionCount,
},
success: (res) => {
if (appContext.isUnauthorizedResponse(res)) {
appContext.handleUnauthorized()
return
}
const session = res.data?.data || null
if (res.statusCode < 200 || res.statusCode >= 300 || !session?.id) {
const message = res.data?.detail || res.data?.message || '生成复习会话失败'
@ -95,12 +105,14 @@ Page({
wx.showToast({ title: message, icon: 'none' })
return
}
if (!session.record_ids?.length) {
const message = '当前条件下没有可复习题目,请切换时间范围或关闭仅复习'
const message = '当前条件下没有可复习题目,请切换时间范围或关闭复习'
this.setData({ error: message })
wx.showToast({ title: message, icon: 'none' })
return
}
app.globalData.reviewSessionMeta = session
wx.setStorageSync('reviewSessionMeta', session)
wx.navigateTo({ url: '/pages/review-session/review-session' })

View File

@ -2,7 +2,7 @@
<scroll-view scroll-y class="content config-content">
<view class="heading">
<text class="title">配置复习计划</text>
<text class="subtitle">定制你的复习范围、题型和题量</text>
<text class="subtitle">定制你的复习范围、题型和题量</text>
</view>
<view class="config-card">
@ -20,7 +20,7 @@
</view>
<view class="config-card">
<text class="section-title">练模式</text>
<text class="section-title">练模式</text>
<view class="mode-list">
<view wx:for="{{modes}}" wx:key="index" data-index="{{index}}" bindtap="setMode" class="mode-item {{index===activeMode?'active':''}}">{{item}}</view>
</view>
@ -34,12 +34,16 @@
<slider min="5" max="100" step="5" value="{{questionCount}}" activeColor="#c19cff" backgroundColor="#23262c" bindchanging="onQuestionCountInput" bindchange="onQuestionCountInput" />
</view>
<button class="start-btn" bindtap="startReview">开始复习</button>
<view wx:if="{{error}}" class="config-card">
<text class="subtitle">{{error}}</text>
</view>
<button class="start-btn" loading="{{loading}}" bindtap="startReview">开始复习</button>
</scroll-view>
<view class="bottom-nav">
<view class="nav-item" bindtap="goHome"><text class="nav-icon">⌂</text><text class="nav-label">首页</text></view>
<view class="nav-item active"><text class="nav-icon">📖</text><text class="nav-label">复习</text></view>
<view class="nav-item active"><text class="nav-icon"></text><text class="nav-label">复习</text></view>
<view class="nav-item" bindtap="goToMe"><text class="nav-icon">☺</text><text class="nav-label">我的</text></view>
</view>
</view>

View File

@ -1,4 +1,4 @@
const app = getApp()
const appContext = require('../../utils/app-context.js')
Page({
data: {
@ -17,8 +17,10 @@ Page({
this.loadResult()
},
loadResult() {
const sessionId = app.globalData.reviewSessionMeta?.id
const app = appContext.withApp()
const sessionId = app.globalData.reviewSessionMeta?.id || wx.getStorageSync('reviewSessionMeta')?.id
const fallbackSession = app.globalData.reviewSession
if (!sessionId) {
if (fallbackSession?.completed) {
this.setData({ error: '本地复习已完成,但结果会话信息已丢失' })
@ -27,11 +29,23 @@ Page({
}
return
}
this.setData({ loading: true, error: '' })
wx.request({
url: `${app.globalData.baseUrl}/api/v1/review-result?session_id=${sessionId}`,
header: { 'X-Auth-Token': app.globalData.authToken || '' },
success: (res) => {
if (appContext.isUnauthorizedResponse(res)) {
appContext.handleUnauthorized()
return
}
const ok = res.statusCode >= 200 && res.statusCode < 300 && res.data?.code === 0
if (!ok) {
this.setData({ error: res.data?.detail || res.data?.message || '结果加载失败' })
return
}
const data = res.data?.data || {}
const total = Number(data.total || 0)
const correct = Number(data.correct || 0)
@ -52,9 +66,10 @@ Page({
is_correct: !!blank.is_correct,
}))
: [],
is_correct: !!item.blanks?.every?.((blank) => blank.is_correct),
is_correct: Array.isArray(item.blanks) ? item.blanks.every((blank) => blank.is_correct) : false,
}))
: []
this.setData({
total,
accuracy,
@ -71,7 +86,17 @@ Page({
})
},
restart() {
app.globalData.reviewSession = { deck: [], currentIndex: 0, correctCount: 0, wrongCount: 0, completed: false, currentAnswer: '' }
const app = appContext.withApp()
app.globalData.reviewSession = {
deck: [],
currentIndex: 0,
correctCount: 0,
wrongCount: 0,
completed: false,
currentAnswer: '',
currentAnswers: [],
lastClozeResults: [],
}
wx.reLaunch({ url: '/pages/review-config/review-config' })
},
goHome() {

View File

@ -1,6 +1,7 @@
const app = getApp()
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,
@ -45,17 +46,30 @@ Page({
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) => {
app.globalData.userSettings = res.data?.data || {}
this.setData({ settings: getReviewSettings() })
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: () => {
@ -98,11 +112,6 @@ Page({
return segments
}
if (blanks.length === 1 && workingPrompt === (clozeQuestion.original_text || prompt)) {
// Keep the original word context visible for single-word cloze questions.
// The general start/end slicing below will render visible letters around the blank.
}
let cursor = 0
blanks.forEach((blank, idx) => {
const start = Math.max(0, Number(blank.start || 0))
@ -114,27 +123,35 @@ Page({
cursor = end
})
if (cursor < workingPrompt.length) {
segments.push({ type: 'text', text: workingPrompt.slice(cursor), index: `text-tail` })
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 (res.statusCode < 200 || res.statusCode >= 300) {
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 {
@ -148,10 +165,12 @@ Page({
entry_type: item.entry_type || 'word',
}
})
if (!questions.length) {
this.setData({ error: '当前复习会话没有题目,请返回重新选择范围', questions: [] })
return
}
app.globalData.reviewSession = {
deck: questions,
currentIndex: 0,
@ -160,6 +179,7 @@ Page({
completed: false,
currentAnswer: '',
currentAnswers: [],
lastClozeResults: [],
startedAt: Date.now(),
}
this.setData({ questions })
@ -170,8 +190,10 @@ Page({
})
},
syncSession() {
const session = app.globalData.reviewSession
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
@ -191,7 +213,7 @@ Page({
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' ? '当前题目来自句子库,注意上下文表达。' : ''),
entryTypeHint: isCloze ? '请逐空作答,提交后会显示每个空的结果。' : (current?.entry_type === 'sentence' ? '当前题目来自句子库,注意上下文表达。' : ''),
isCloze,
currentClozeQuestion: clozeQuestion,
currentClozeResults: session.lastClozeResults || [],
@ -201,20 +223,22 @@ Page({
})
},
onAnswerInput(e) {
const session = this.getReviewSessionState()
const answer = e.detail.value
app.globalData.reviewSession.currentAnswer = answer
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 = [...(app.globalData.reviewSession.currentAnswers || [])]
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))
app.globalData.reviewSession.currentAnswers = answers
session.currentAnswers = answers
this.setData({
[`clozeAnswers[${index}]`]: answers[index],
showFeedback: false,
@ -239,17 +263,18 @@ Page({
})
},
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 = [...(app.globalData.reviewSession.currentAnswers || [])]
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('')
app.globalData.reviewSession.currentAnswers = answers
session.currentAnswers = answers
this.setData({ [`clozeAnswers[${index}]`]: answers[index] })
return
}
@ -267,15 +292,16 @@ Page({
}
},
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 = [...(app.globalData.reviewSession.currentAnswers || [])]
const answers = [...(session.currentAnswers || [])]
answers[index] = normalizedValue
app.globalData.reviewSession.currentAnswers = answers
session.currentAnswers = answers
const nextIndex = Number(index) + (normalizedValue.length >= maxLength ? 1 : 0)
const prevIndex = Number(index) - 1
this.setData({
@ -307,7 +333,8 @@ Page({
})
},
submitAnswer(showExplanation = false, advanceAfterSubmit = false) {
const session = app.globalData.reviewSession
const app = appContext.withApp()
const session = this.getReviewSessionState()
const current = session.deck[session.currentIndex]
if (!current || this.submitting || this.judged) return
@ -318,18 +345,15 @@ Page({
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('')
const isLetterCellMode = Array.isArray(this.data.clozeSegments) && this.data.clozeSegments.some((segment) => segment.type === 'letter-cell')
const normalizedAnswers = isLetterCellMode
? answers
: answers
if (!normalizedAnswers.some((item) => item)) {
if (!answers.some((item) => item)) {
wx.showToast({ title: '请先输入答案', icon: 'none' })
return
}
payload.user_answers = normalizedAnswers
payload.user_answers = answers
} else if (!answer) {
wx.showToast({ title: '请先输入答案', icon: 'none' })
return
@ -342,10 +366,22 @@ Page({
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 : []
@ -372,15 +408,17 @@ Page({
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
app.globalData.reviewSession.lastClozeResults = derivedClozeResults
}
const clozeFeedbackSummary = result.is_correct ? '回答正确' : '回答错误'
this.setData({
feedback: clozeFeedbackSummary,
@ -391,10 +429,12 @@ Page({
currentClozeResults: derivedClozeResults,
clozeFeedbackSummary,
})
if (this.data.isCloze) {
app.globalData.reviewSession.currentAnswers = []
session.currentAnswers = []
this.setData({ clozeAnswers: [] })
}
if (this.data.settings.autoNextQuestion || advanceAfterSubmit) {
const delay = shouldShowAnswer ? 2500 : 0
setTimeout(() => this.nextQuestion(), delay)
@ -409,12 +449,14 @@ Page({
this.submitAnswer(true)
},
nextQuestion() {
const session = app.globalData.reviewSession
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
@ -423,14 +465,21 @@ Page({
url: `${app.globalData.baseUrl}/api/v1/review/finish?session_id=${app.globalData.reviewSessionMeta.id}`,
method: 'POST',
header: { 'X-Auth-Token': app.globalData.authToken || '' },
success: () => wx.navigateTo({ url: '/pages/review-result/review-result' }),
success: (res) => {
if (appContext.isUnauthorizedResponse(res)) {
appContext.handleUnauthorized()
return
}
wx.navigateTo({ url: '/pages/review-result/review-result' })
},
fail: () => {
wx.showToast({ title: '提交结果失败,已本地完成', icon: 'none' })
wx.showToast({ title: '提交结果失败,已本地完成处理', icon: 'none' })
wx.navigateTo({ url: '/pages/review-result/review-result' })
},
})
return
}
session.currentIndex += 1
session.currentAnswer = ''
session.currentAnswers = []

View File

@ -1,4 +1,4 @@
const app = getApp()
const appContext = require('../../utils/app-context.js')
const difficultyOptions = [
{ label: '简单', value: 'easy' },
@ -11,6 +11,16 @@ const reviewOrderOptions = [
{ label: '随机复习', value: 'shuffle' },
]
function normalizeSettings(settings) {
return {
blank_count: Number(settings?.blank_count || 2),
blank_difficulty: settings?.blank_difficulty || 'normal',
review_order: settings?.review_order || 'sequential',
auto_next_question: Number(settings?.auto_next_question || 0),
show_answer_after_submit: Number(settings?.show_answer_after_submit || 0),
}
}
Page({
data: {
settings: null,
@ -27,26 +37,42 @@ Page({
this.loadSettings()
},
syncDerivedState(settings) {
const difficultyIndex = Math.max(0, difficultyOptions.findIndex((item) => item.value === settings.blank_difficulty))
const reviewOrderIndex = Math.max(0, reviewOrderOptions.findIndex((item) => item.value === settings.review_order))
const nextSettings = normalizeSettings(settings)
const difficultyIndex = difficultyOptions.findIndex((item) => item.value === nextSettings.blank_difficulty)
const reviewOrderIndex = reviewOrderOptions.findIndex((item) => item.value === nextSettings.review_order)
const safeDifficultyIndex = difficultyIndex >= 0 ? difficultyIndex : 1
const safeReviewOrderIndex = reviewOrderIndex >= 0 ? reviewOrderIndex : 0
this.setData({
settings,
difficultyIndex: difficultyIndex >= 0 ? difficultyIndex : 1,
difficultyLabel: difficultyOptions[difficultyIndex >= 0 ? difficultyIndex : 1].label,
reviewOrderIndex: reviewOrderIndex >= 0 ? reviewOrderIndex : 0,
reviewOrderLabel: reviewOrderOptions[reviewOrderIndex >= 0 ? reviewOrderIndex : 0].label,
settings: nextSettings,
difficultyIndex: safeDifficultyIndex,
difficultyLabel: difficultyOptions[safeDifficultyIndex].label,
reviewOrderIndex: safeReviewOrderIndex,
reviewOrderLabel: reviewOrderOptions[safeReviewOrderIndex].label,
})
},
loadSettings() {
const app = appContext.withApp()
if (!app.globalData.authToken) {
this.setData({ error: '请先登录' })
return
}
this.setData({ loading: true, error: '' })
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
}
const ok = res.statusCode >= 200 && res.statusCode < 300 && res.data?.code === 0
if (!ok) {
this.setData({ error: res.data?.detail || res.data?.message || '设置加载失败' })
return
}
this.syncDerivedState(res.data?.data || null)
},
fail: () => this.setData({ error: '设置加载失败' }),
@ -57,7 +83,7 @@ Page({
const value = Number(e.detail.value || 0)
this.setData({
settings: {
...this.data.settings,
...normalizeSettings(this.data.settings),
blank_count: Number.isFinite(value) ? value : 0,
},
})
@ -69,7 +95,7 @@ Page({
difficultyIndex: index,
difficultyLabel: option.label,
settings: {
...this.data.settings,
...normalizeSettings(this.data.settings),
blank_difficulty: option.value,
},
})
@ -81,29 +107,44 @@ Page({
reviewOrderIndex: index,
reviewOrderLabel: option.label,
settings: {
...this.data.settings,
...normalizeSettings(this.data.settings),
review_order: option.value,
},
})
},
toggleSetting(e) {
const key = e.currentTarget.dataset.key
const settings = { ...this.data.settings }
const settings = normalizeSettings(this.data.settings)
settings[key] = settings[key] ? 0 : 1
this.setData({ settings })
},
save() {
const app = appContext.withApp()
if (!app.globalData.authToken) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const settings = this.data.settings || {}
const settings = normalizeSettings(this.data.settings)
wx.request({
url: `${app.globalData.baseUrl}/api/v1/settings`,
method: 'PUT',
header: { 'Content-Type': 'application/json', 'X-Auth-Token': app.globalData.authToken },
data: settings,
success: () => wx.showToast({ title: '已保存', icon: 'success' }),
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
}
this.syncDerivedState(res.data?.data || settings)
wx.showToast({ title: '已保存', icon: 'success' })
},
fail: () => wx.showToast({ title: '保存失败', icon: 'none' }),
})
},

View File

@ -2,7 +2,7 @@
<scroll-view scroll-y class="content">
<view class="header">
<text class="title">设置</text>
<text class="subtitle">调整你的学习体验与交互偏好</text>
<text class="subtitle">调整你的复习偏好与交互方式</text>
</view>
<view wx:if="{{loading}}" class="empty-state">
@ -16,18 +16,18 @@
<view wx:if="{{settings}}" class="section-block">
<view class="section-head">
<view class="section-ico"></view>
<view class="section-ico"></view>
<text class="section-title">复习参数</text>
</view>
<view class="panel">
<view class="row">
<view><text class="label">挖空数量</text><text class="desc">题型 C 中每题挖空的词数</text></view>
<view><text class="label">挖空数量</text><text class="desc">题型 C 中每题需要挖空的词数</text></view>
<view class="value-group">
<input class="setting-input" type="number" value="{{settings.blank_count}}" bindinput="onBlankCountInput" />
</view>
</view>
<view class="row">
<view><text class="label">挖空难度</text><text class="desc">影响挖空比例与保留字</text></view>
<view><text class="label">挖空难度</text><text class="desc">影响挖空比例与保留字</text></view>
<view class="value-group">
<picker mode="selector" range="{{difficultyOptions}}" range-key="label" value="{{difficultyIndex}}" bindchange="onDifficultyChange">
<view class="picker-value accent">{{difficultyLabel}}</view>
@ -35,7 +35,7 @@
</view>
</view>
<view class="row">
<view><text class="label">复习顺序</text><text class="desc">控制复习题目按顺序还是随机展示</text></view>
<view><text class="label">复习顺序</text><text class="desc">控制题目按顺序还是随机展示</text></view>
<view class="value-group">
<picker mode="selector" range="{{reviewOrderOptions}}" range-key="label" value="{{reviewOrderIndex}}" bindchange="onReviewOrderChange">
<view class="picker-value accent">{{reviewOrderLabel}}</view>
@ -47,16 +47,16 @@
<view wx:if="{{settings}}" class="section-block">
<view class="section-head">
<view class="section-ico"></view>
<view class="section-ico"></view>
<text class="section-title">交互设置</text>
</view>
<view class="panel">
<view class="row" bindtap="toggleSetting" data-key="auto_next_question">
<view><text class="label">自动进入下一词</text><text class="desc">答题后自动跳转,保持心流状态</text></view>
<view><text class="label">自动进入下一题</text><text class="desc">答题后自动跳转,保持连续复习节奏</text></view>
<view class="toggle {{settings.auto_next_question ? 'on' : ''}}">{{settings.auto_next_question ? 'ON' : 'OFF'}}</view>
</view>
<view class="row" bindtap="toggleSetting" data-key="show_answer_after_submit">
<view><text class="label">提交后显示答案</text><text class="desc">答题后直接显示正确答案</text></view>
<view><text class="label">提交后显示答案</text><text class="desc">答题后立即展示标准答案</text></view>
<view class="toggle {{settings.show_answer_after_submit ? 'on' : ''}}">{{settings.show_answer_after_submit ? 'ON' : 'OFF'}}</view>
</view>
</view>