add avatar

This commit is contained in:
Felix
2026-01-03 15:47:49 +08:00
parent 0aab0e2172
commit 827b939299
12 changed files with 1041 additions and 148 deletions

View File

@@ -12,7 +12,8 @@
"pages/coupon/coupon",
"pages/order/order",
"pages/scene_sentence/scene_sentence",
"pages/qa_exercise/qa_exercise"
"pages/qa_exercise/qa_exercise",
"pages/avatar_crop/avatar_crop"
],
"window": {
"navigationBarTextStyle": "black",

View File

@@ -8,7 +8,8 @@ App<IAppOption>({
isLoggedIn: false,
userInfo: undefined,
token: undefined,
dictLevel: undefined // 新增字段,用户词典等级配置
dictLevel: undefined, // 新增字段,用户词典等级配置
pendingReferrerId: undefined
},
onLaunch() {
@@ -83,10 +84,18 @@ App<IAppOption>({
} else {
console.log('Token 已过期,清理本地数据')
this.clearLoginData()
// 尝试重新登录(携带 referrerId
if (this.globalData.pendingReferrerId) {
apiManager.login(0, this.globalData.pendingReferrerId)
}
}
} else {
console.log('未找到登录信息')
this.globalData.isLoggedIn = false
// 尝试登录(携带 referrerId
if (this.globalData.pendingReferrerId) {
apiManager.login(0, this.globalData.pendingReferrerId)
}
}
} catch (error) {
console.error('初始化登录状态失败:', error)

View File

@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "移动和缩放",
"navigationBarTextStyle": "white",
"navigationBarBackgroundColor": "#000000",
"backgroundColor": "#000000",
"disableScroll": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,342 @@
Page({
data: {
src: '',
width: 0,
height: 0,
x: 0,
y: 0,
scale: 1,
minScale: 0.1, // 初始设小一点,防止默认值限制
maxScale: 10,
// 图片原始尺寸
imgWidth: 0,
imgHeight: 0,
// 屏幕尺寸
windowWidth: 0,
windowHeight: 0,
// 裁剪框尺寸(正方形)
cropSize: 0,
cropTop: 0,
cropLeft: 0
},
onLoad(options: any) {
const src = options.src
if (src) {
this.setData({ src: decodeURIComponent(src) })
this.initLayout()
} else {
wx.showToast({ title: '未找到图片', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
}
},
initLayout() {
const sys = wx.getSystemInfoSync()
const windowWidth = sys.windowWidth
const windowHeight = sys.windowHeight
// 设置裁剪框大小(屏幕宽度 - 40px留出边距
const cropSize = windowWidth - 40
const cropLeft = (windowWidth - cropSize) / 2
const cropTop = (windowHeight - cropSize) / 2
this.setData({
windowWidth,
windowHeight,
cropSize,
cropLeft,
cropTop,
width: windowWidth // 初始宽度设为屏幕宽
})
},
onReady() {
// 布局已由 JS 统一控制,无需再探测 DOM
// 如果图片已经加载,确保位置正确
if (this.data.imgWidth && this.data.imgHeight) {
// 延迟一点确保 setData 生效
setTimeout(() => {
this.resetImagePosition(this.data.imgWidth, this.data.imgHeight)
}, 50)
}
},
// 内部状态,避免频繁 setData
_x: 0,
_y: 0,
_scale: 1,
onImageLoad(e: any) {
const { width, height } = e.detail
this.setData({
imgWidth: width,
imgHeight: height
})
this.resetImagePosition(width, height)
},
resetImagePosition(imgWidth: number, imgHeight: number) {
// 初始化图片位置:居中显示
// movable-view 的初始 x, y 是相对于 movable-area 的左上角
// 我们希望图片中心对齐裁剪框中心
// 计算图片在屏幕上的显示尺寸widthFix 模式下,宽度等于 movable-view 宽度)
// movable-view 宽度初始设为 windowWidth
const displayWidth = this.data.windowWidth
const displayHeight = (imgHeight / imgWidth) * displayWidth
// 计算最小缩放比例,确保图片覆盖裁剪框
const { cropSize, cropTop, cropLeft, windowWidth } = this.data
// 宽或高必须至少填满裁剪框
// 增加一点点冗余 (1.05倍) 防止浮点数精度问题导致的白边,确保完全覆盖
const minScale = Math.max(cropSize / displayWidth, cropSize / displayHeight) * 1.05
// 动态计算最大缩放比例,确保有足够的缩放空间
const maxScale = Math.max(minScale * 5, 10)
// 初始位置:
// x: 让图片水平中心与裁剪框中心重合
// y: 与裁剪框顶部对齐响应用户反馈movable-view y 应与 crop-box top 一致)
// 裁剪框中心坐标
const cropCenterX = cropLeft + cropSize / 2
// 图片(未缩放)中心应该是 (x + displayWidth/2)
const x = cropCenterX - displayWidth / 2
// 垂直方向直接对齐顶部
const y = cropTop
// 初始缩放比例:直接设为最小缩放比例,确保刚好覆盖
const scale = minScale
// 更新内部状态
this._x = x
this._y = y
this._scale = scale
// 1. 先设置尺寸和计算出的约束参数minScale/maxScale 仅用于 JS 逻辑控制,不绑定到 WXML
this.setData({
width: displayWidth,
height: displayHeight,
minScale,
maxScale
}, () => {
// 2. 延迟设置位置和缩放
// 分离设置是为了防止 movable-view 在尺寸未更新前应用位置导致错位
setTimeout(() => {
this.setData({
x,
y,
scale
})
// 立即执行一次 checkBounds 以确保位置合法
// 注意:这里 checkBounds 会读取 this.data.scale (可能是 1),与传入的 scale (例如 1.42) 不同
// 从而触发一次校正 setData这是预期的
this.checkBounds(x, y, scale)
}, 50)
})
},
onChange(e: any) {
// 始终更新内部状态,保持同步
this._x = e.detail.x
this._y = e.detail.y
if (e.detail.scale) {
this._scale = e.detail.scale
}
if (e.detail.source === 'touch' || e.detail.source === 'touch-out-of-bounds') {
this.checkBounds(this._x, this._y, this._scale)
}
},
onScale(e: any) {
// 始终更新内部状态,保持同步
this._scale = e.detail.scale
this._x = e.detail.x
this._y = e.detail.y
if (e.detail.source === 'touch' || e.detail.source === 'touch-out-of-bounds') {
this.checkBounds(this._x, this._y, this._scale)
}
},
checkBounds(x: number, y: number, scale: number) {
const { width, height, cropLeft, cropTop, cropSize, minScale } = this.data
// 0. 强制缩放限制
if (scale < minScale) {
scale = minScale
}
// 假设 movable-view 的 x,y 是视觉左上角坐标(或系统自动处理了 transform-origin
// 之前基于中心缩放的 offset 计算导致了边界判断错误(偏大),从而强制图片下移
// 计算合法的移动范围
// 图片左边缘 x <= cropLeft
const max_x = cropLeft
// 图片右边缘 x + width * scale >= cropLeft + cropSize
// x >= cropLeft + cropSize - width * scale
const min_x = cropLeft + cropSize - width * scale
// y轴同理
// 图片上边缘 y <= cropTop
const max_y = cropTop
// 图片下边缘 y + height * scale >= cropTop + cropSize
const min_y = cropTop + cropSize - height * scale
let new_x = x
let new_y = y
// 修正 x
if (new_x > max_x) new_x = max_x
if (new_x < min_x) new_x = min_x
// 修正 y
if (new_y > max_y) new_y = max_y
if (new_y < min_y) new_y = min_y
// 只有当位置需要修正时,或者 scale 发生变化时才 setData
if (Math.abs(new_x - x) > 1 || Math.abs(new_y - y) > 1 || Math.abs(scale - this.data.scale) > 0.001) {
this._x = new_x
this._y = new_y
this.setData({
x: new_x,
y: new_y,
scale: scale
})
}
},
onCancel() {
wx.navigateBack()
},
async onConfirm() {
wx.showLoading({ title: '裁剪中...' })
try {
// 获取 Canvas 节点
const query = wx.createSelectorQuery()
query.select('#cropCanvas')
.fields({ node: true, size: true })
.exec(async (res) => {
const canvas = res[0].node
const ctx = canvas.getContext('2d')
// 设置导出尺寸(例如 512x512
const exportSize = 512
canvas.width = exportSize
canvas.height = exportSize
// 计算绘制参数
// 目标:将裁剪框内的图片区域绘制到 canvas 上
// 1. 获取当前图片的状态
// 使用内部状态获取最新的位置和缩放(比 setData 更新的快)
const { width: displayWidth, height: displayHeight, cropSize, cropLeft, cropTop, src } = this.data
const x = this._x
const y = this._y
const scale = this._scale
// 2. 创建图片对象
const img = canvas.createImage()
img.src = src
img.onload = () => {
// 计算裁剪区域在原图上的坐标
// movable-view 的缩放是基于中心的
// 缩放产生的偏移
const scaleOffsetX = displayWidth * (scale - 1) / 2
const scaleOffsetY = displayHeight * (scale - 1) / 2
// 视觉左上角坐标 vx
const vx = x - scaleOffsetX
const vy = y - scaleOffsetY
// 图片相对于裁剪框的偏移 (visual_offset)
// 通常是负值,因为图片左上角在裁剪框左上角的左上方
const offsetInCropX = vx - cropLeft
const offsetInCropY = vy - cropTop
// 此时图片显示的实际宽及高
const scaledWidth = displayWidth * scale
const scaledHeight = displayHeight * scale
// 绘制
// drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
// 这里我们用另一种方式:先清空,然后平移旋转缩放,再绘制图片
ctx.clearRect(0, 0, exportSize, exportSize)
// 映射关系:
// 裁剪框的 cropSize 对应 canvas 的 exportSize
const ratio = exportSize / cropSize
ctx.save()
// 移动 canvas 原点到图片在 canvas 中的起始位置
ctx.translate(offsetInCropX * ratio, offsetInCropY * ratio)
ctx.scale(ratio * scale, ratio * scale) // 缩放
// 绘制完整图片(因为 movable-view 的 x,y 是图片左上角)
// 注意displayWidth 是图片在 scale=1 时的显示宽度
// 我们需要绘制的是原始图片,但大小要匹配 displayWidth
// 实际上 drawImage(img, 0, 0, displayWidth, displayHeight)
ctx.drawImage(img, 0, 0, displayWidth, displayHeight)
ctx.restore()
// 导出
wx.canvasToTempFilePath({
canvas,
width: exportSize,
height: exportSize,
destWidth: exportSize,
destHeight: exportSize,
fileType: 'jpg',
quality: 0.9,
success: (res) => {
wx.hideLoading()
// 返回上一页并传递结果
const pages = getCurrentPages()
const prevPage = pages[pages.length - 2]
if (prevPage) {
// 通过事件通道或直接调用方法传递
// 这里简单点,直接调用上一页的方法(如果存在)
if ((prevPage as any).onAvatarCropped) {
(prevPage as any).onAvatarCropped(res.tempFilePath)
}
}
wx.navigateBack()
},
fail: (err) => {
console.error(err)
wx.hideLoading()
wx.showToast({ title: '导出失败', icon: 'none' })
}
})
}
img.onerror = (err: any) => {
console.error(err)
wx.hideLoading()
wx.showToast({ title: '加载图片失败', icon: 'none' })
}
})
} catch (e) {
console.error(e)
wx.hideLoading()
wx.showToast({ title: '裁剪出错', icon: 'none' })
}
}
})

View File

@@ -0,0 +1,35 @@
<view class="container">
<movable-area class="crop-area" scale-area>
<movable-view
class="image-holder"
direction="all"
scale
scale-min="0.5"
scale-max="10"
scale-value="{{scale}}"
x="{{x}}"
y="{{y}}"
out-of-bounds="{{true}}"
style="width: {{width}}px; height: {{height}}px;"
bindchange="onChange"
bindscale="onScale"
animation="{{false}}"
>
<image src="{{src}}" class="target-image" bindload="onImageLoad" />
</movable-view>
</movable-area>
<!-- 遮罩层 -->
<view class="mask">
<view class="crop-box" style="width: {{cropSize}}px; height: {{cropSize}}px; left: {{cropLeft}}px; top: {{cropTop}}px;">
<view class="crop-circle"></view>
</view>
</view>
<view class="footer">
<view class="btn cancel" bindtap="onCancel">取消</view>
<view class="btn confirm" bindtap="onConfirm">确定</view>
</view>
<canvas type="2d" id="cropCanvas" class="crop-canvas"></canvas>
</view>

View File

@@ -0,0 +1,96 @@
page {
background-color: #000;
height: 100%;
overflow: hidden;
}
.container {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
}
.crop-area {
width: 100%;
height: 100%;
background-color: #000;
overflow: hidden;
}
.image-holder {
/* 尺寸由行内样式控制 */
display: flex;
justify-content: center;
align-items: center;
}
.target-image {
width: 100%;
height: 100%;
display: block;
}
/* 遮罩层 */
.mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* 让事件穿透到下层的 movable-area */
z-index: 10;
overflow: hidden; /* 防止 box-shadow 溢出导致滚动条(虽然 page hidden 了) */
}
.crop-box {
/* 尺寸和位置由行内样式动态控制 */
position: absolute;
display: flex;
box-sizing: border-box;
}
.crop-circle {
flex: 1;
background-color: transparent;
border-radius: 50%;
border: 2px solid #fff;
/* 使用超大阴影作为遮罩,足够覆盖大部分屏幕 */
box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.6);
}
.footer {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 100rpx; /* safe area handled by padding */
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 40rpx 40rpx;
box-sizing: border-box;
z-index: 20;
}
.btn {
color: #fff;
font-size: 32rpx;
padding: 20rpx 40rpx;
}
.confirm {
color: #07c160; /* 微信绿 */
font-weight: 500;
}
.crop-canvas {
position: absolute;
top: -9999px;
left: -9999px;
width: 512px;
height: 512px;
}

View File

@@ -96,6 +96,9 @@ Page({
// 更新用户信息(快速操作)
this.updateUserInfo()
// 获取最新头像
this.fetchUserAvatar()
// 异步加载用户数据,不阻塞页面显示
this.loadAllDataAsync()
@@ -104,6 +107,25 @@ Page({
logger.info('个人中心页面显示完成,耗时:', endTime - startTime, 'ms')
},
// 获取用户头像
async fetchUserAvatar() {
if (!this.data.isLoggedIn) return;
try {
const { url } = await apiManager.getUserAvatar();
if (url && url !== this.data.userInfo?.avatar_url) {
const userInfo = this.data.userInfo || {} as IUserInfo;
const updatedUser = { ...userInfo, avatar_url: url };
this.setData({ userInfo: updatedUser });
app.globalData.userInfo = updatedUser;
wx.setStorageSync('userInfo', updatedUser);
logger.info('User avatar updated from server', { url });
}
} catch (error) {
logger.error('Failed to fetch user avatar', error);
}
},
// 初始化页面
async initPage() {
try {
@@ -582,14 +604,74 @@ Page({
}
},
// 头像裁剪完成回调
async onAvatarCropped(filePath: string) {
try {
wx.showLoading({ title: '上传中...' });
// 1. 上传图片
const fileId = await apiManager.uploadImageFile(filePath);
// 2. 更新用户信息
const { url } = await apiManager.updateUserAvatar(fileId);
// 3. 更新本地数据
const userInfo = this.data.userInfo || {} as IUserInfo;
const updatedUser = { ...userInfo, avatar_url: url };
this.setData({
userInfo: updatedUser
});
// 更新全局数据
app.globalData.userInfo = updatedUser;
wx.setStorageSync('userInfo', updatedUser);
wx.hideLoading();
wx.showToast({ title: '更换成功', icon: 'success' });
logger.info('Avatar updated successfully', { fileId });
} catch (error: any) {
wx.hideLoading();
logger.error('Failed to change avatar', error);
wx.showToast({
title: error.message || '更换失败',
icon: 'none'
});
}
},
/**
* 更换头像方法
* 点击头像区域触发此方法
*/
changeAvatar() {
// TODO: 实现更换头像的逻辑
logger.info('更换头像方法被触发');
// 这里可以添加选择图片、上传图片等逻辑
// 检查登录状态
if (!this.data.isLoggedIn) {
wx.showToast({ title: '请先登录', icon: 'none' });
return;
}
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
const tempFilePath = res.tempFiles[0].tempFilePath;
// 跳转到裁剪页面
wx.navigateTo({
url: `/pages/avatar_crop/avatar_crop?src=${encodeURIComponent(tempFilePath)}`
});
},
fail: (err) => {
if (err.errMsg.indexOf('cancel') === -1) {
logger.error('Choose media failed', err);
wx.showToast({ title: '选择图片失败', icon: 'none' });
}
}
});
},
// Generate SVG data URL for avatar

View File

@@ -1,6 +1,6 @@
import apiManager from '../../utils/api'
import logger from '../../utils/logger'
import { IQaExerciseItem, IQaExerciseSession } from '../../types/app'
import { IQaExerciseItem, IQaExerciseSession, IAppOption } from '../../types/app'
export const QUESTION_MODES = {
CHOICE: 'choice',
@@ -77,14 +77,16 @@ interface IData {
audioLocalMap?: Record<string, string>
isPlaying?: boolean
processDotClasses?: string[]
showCompletionPopup?: boolean
isTrialMode?: boolean
}
interface IPageInstance {
pollTimer?: number
audioCtx?: WechatMiniprogram.InnerAudioContext
fetchQaExercises: (imageId: string) => Promise<void>
fetchQaExercises: (imageId: string, referrerId?: string) => Promise<void>
startPolling: (taskId: string, imageId: string) => void
initExerciseContent: (exercise: any) => void
initExerciseContent: (exercise: any, session?: IQaExerciseSession) => void
updateActionButtonsState: () => void
shuffleArray: <T>(arr: T[]) => T[]
updateContentReady: () => void
@@ -98,6 +100,7 @@ interface IPageInstance {
previewImage: () => void
submitAttempt: () => Promise<void>
onPrevTap: () => void
triggerAutoNextIfCorrect: (evaluation: any, qid: string) => void
onNextTap: () => void
toggleMode: () => void
selectClozeOption: (e: any) => void
@@ -119,6 +122,9 @@ interface IPageInstance {
getQuestionAudio: (questionId: string) => Promise<string | undefined>
playStandardVoice: () => void
updateProcessDots: () => void
checkAllQuestionsAttempted: () => void
handleCompletionPopupClose: () => void
handleShareAchievement: () => void
}
Page<IData, IPageInstance>({
@@ -181,14 +187,28 @@ Page<IData, IPageInstance>({
updateProcessDots() {
const list = this.data.qaList || []
const cache = this.data.qaResultCache || {}
const flags = this.data.submittedFlags || []
const session = this.data.session
let attempts: any[] = []
if (session && typeof session.progress === 'object' && session.progress !== null && 'attempts' in session.progress) {
attempts = (session.progress as any).attempts
}
const attemptMap = new Map()
attempts.forEach((a: any) => attemptMap.set(String(a.question_id), a))
const items = list.map((q, idx) => {
const qid = String((q as any)?.id || '')
const r = cache[qid] || {}
const c1 = !!((r.choice || {}).evaluation)
const c2 = !!((r.cloze || {}).evaluation)
const c3 = !!((r.free_text || {}).evaluation)
const depth = Number(c1) + Number(c2) + Number(c3)
let depth = Number(c1) + Number(c2) + Number(c3)
if (depth === 0 && attemptMap.has(qid)) {
const att = attemptMap.get(qid)
const isCorrect = ['correct'].includes(String(att.is_correct || '').toLowerCase())
depth = isCorrect ? 3 : 1
}
if (depth <= 0) return 'dot-0'
if (depth === 1) return 'dot-1'
if (depth === 2) return 'dot-2'
@@ -196,6 +216,52 @@ Page<IData, IPageInstance>({
})
this.setData({ processDotClasses: items })
},
checkAllQuestionsAttempted() {
const list = this.data.qaList || []
const cache = this.data.qaResultCache || {}
const session = this.data.session
// 如果是试玩模式,仅检查本地缓存是否有结果
if (this.data.isTrialMode) {
const allAttempted = list.every((q: any) => {
const qid = String(q.id)
const r = cache[qid] || {}
return !!((r.choice || {}).evaluation) || !!((r.cloze || {}).evaluation) || !!((r.free_text || {}).evaluation)
})
if (allAttempted) {
this.setData({ showCompletionPopup: true })
}
return
}
let attempts: any[] = []
if (session && typeof session.progress === 'object' && session.progress !== null && 'attempts' in session.progress) {
attempts = (session.progress as any).attempts
}
const attemptSet = new Set(attempts.map((a: any) => String(a.question_id)))
const allAttempted = list.every((q: any) => {
const qid = String(q.id)
// Check local cache (latest submission)
const r = cache[qid] || {}
const hasLocalResult = !!((r.choice || {}).evaluation) || !!((r.cloze || {}).evaluation) || !!((r.free_text || {}).evaluation)
// Check session history
const hasSessionAttempt = attemptSet.has(qid)
return hasLocalResult || hasSessionAttempt
})
if (allAttempted) {
this.setData({ showCompletionPopup: true })
}
},
handleCompletionPopupClose() {
this.setData({ showCompletionPopup: false })
},
handleShareAchievement() {
// 已经通过 open-type="share" 触发 onShareAppMessage
// 这里可以添加埋点或其他逻辑
logger.log('Share achievement clicked')
},
updateActionButtonsState() {
const mode = this.data.questionMode
const hasResult = !!this.data.resultDisplayed
@@ -266,35 +332,105 @@ Page<IData, IPageInstance>({
},
async onLoad(options: Record<string, string>) {
try {
const imageId = options?.image_id || ''
const thumbnailId = options?.thumbnail_id || ''
const app = getApp<IAppOption>()
// 处理推荐人ID
const referrerId = options.referrer || options.referrerId || options.referrer_id
if (referrerId) {
app.globalData.pendingReferrerId = referrerId
console.log('检测到推荐人ID:', referrerId)
}
// 处理试玩模式
// const isTrialMode = options.mode === 'trial'
// if (isTrialMode) {
// this.setData({ isTrialMode: true })
// wx.showToast({
// title: '当前为练习模式,进度不保存',
// icon: 'none',
// duration: 3000
// })
// }
const imageId = options?.id || options?.image_id || ''
const thumbnailId = options?.thumbnail_id || '' // 兼容旧逻辑,如果是分享进来的可能需要通过 API 获取图片链接
this.setData({ imageId, loadingMaskVisible: true, statusText: '加载中...' })
await this.fetchQaExercises(imageId)
try {
const url = await apiManager.getFileDisplayUrl(String(thumbnailId))
this.setData({ imageLocalUrl: url, imageLoaded: false })
await this.fetchQaExercises(imageId, referrerId)
// 如果没有 thumbnailId尝试通过 imageId 获取(或者 fetchQaExercises 内部处理了?)
// 假设 fetchQaExercises 会处理内容,这里主要处理图片加载
if (thumbnailId) {
try {
wx.getImageInfo({
src: url,
success: () => {
this.setData({ imageLoaded: true })
this.updateContentReady()
},
fail: () => {
this.setData({ imageLoaded: false })
this.updateContentReady()
}
})
} catch (e) {}
this.updateContentReady()
} catch (e) {}
} catch (e) {
logger.error('页面加载失败', e)
wx.showToast({ title: '加载失败', icon: 'none' })
const url = await apiManager.getFileDisplayUrl(String(thumbnailId))
this.setData({ imageLocalUrl: url, imageLoaded: false })
try {
wx.getImageInfo({
src: url,
success: () => {
this.setData({ imageLoaded: true })
this.updateContentReady()
},
fail: () => {
this.setData({ imageLoaded: false })
this.updateContentReady()
}
})
} catch(e) {
console.error(e)
}
} catch (e) {
console.error('获取图片URL失败:', e)
}
} else {
// 如果没有 thumbnailIdfetchQaExercises 可能会加载图片,这里暂时不做额外处理,
// 除非 fetchQaExercises 获取的 exercise 数据里有 image_url
}
} catch (error) {
logger.error('QaExercise onLoad Error:', error)
this.setData({ loadingMaskVisible: false, statusText: '加载失败' })
}
},
async fetchQaExercises(imageId: string) {
onShareAppMessage(options: any) {
const app = getApp<IAppOption>()
const myUserId = app.globalData.userInfo?.id || ''
const imageId = this.data.imageId
const title = this.data.exercise?.title || '英语口语练习'
// 检查是否是从成就弹窗分享
const isAchievement = options?.from === 'button' && options?.target?.dataset?.type === 'achievement'
// 构建分享路径
const path = `/pages/qa_exercise/qa_exercise?id=${imageId}&referrer=${myUserId}&mode=trial`
let shareTitle = `一起解锁拍照学英语的快乐!👇`
let imageUrl = this.data.imageLocalUrl || undefined
if (isAchievement) {
shareTitle = `图片里面是什么?你也来试试吧!`
// 如果有特定的成就图片,可以在这里设置
// imageUrl = '...'
}
logger.log('分享路径:', path)
// 记录分享触发日志
// 注意:微信不再支持 success/fail 回调,无法精确判断分享是否成功,此处仅记录触发行为
logger.log('User triggered share', {
from: options.from,
path,
imageId
})
// TODO: 未来如果需要对接后端分享统计接口,可在此处调用,例如:
// apiManager.reportShare({ imageId, from: options.from })
return {
title: shareTitle,
path: path,
imageUrl: imageUrl // 使用当前题目图片作为分享图
}
},
async fetchQaExercises(imageId: string, referrerId?: string) {
try {
const res = await apiManager.listQaExercisesByImage(imageId)
if (res && res.exercise && Array.isArray(res.questions) && res.questions.length > 0) {
@@ -309,10 +445,29 @@ Page<IData, IPageInstance>({
const exerciseWithList = { ...exercise, qa_list: qaList }
const session = res.session as IQaExerciseSession
const total = Number(exercise?.question_count || qaList.length || 0)
const completed = Number(session?.progress || 0)
let completed = 0
if (typeof session?.progress === 'number') {
completed = session.progress
} else if (session?.progress && typeof (session.progress as any).answered === 'number') {
completed = (session.progress as any).answered
}
const progressText = `已完成 ${completed} / ${total}`
this.setData({ session, progressText })
this.initExerciseContent(exerciseWithList)
this.initExerciseContent(exerciseWithList, session)
// 尝试获取图片链接(如果是通过 image_id 进入且没有 thumbnail_id
if (!this.data.imageLocalUrl && imageId) {
try {
logger.log('Fetching image file_id for imageId:', imageId)
const fileId = await apiManager.getImageFileId(imageId)
logger.log('Fetching image url for fileId:', fileId)
const url = await apiManager.getFileDisplayUrl(fileId, referrerId)
this.setData({ imageLocalUrl: url })
} catch (e) {
logger.error('Failed to fetch image url from exercise data', e)
}
}
return
}
const { task_id } = await apiManager.createQaExerciseTask(imageId)
@@ -349,9 +504,23 @@ Page<IData, IPageInstance>({
}
}, 3000) as any
},
initExerciseContent(exercise: any) {
initExerciseContent(exercise: any, session?: any) {
const qaList = Array.isArray(exercise?.qa_list) ? exercise.qa_list : []
const idx = 0
let idx = 0
if (this.data.isTrialMode) {
this.setData({ session: undefined, qaResultCache: {}, qaResultFetched: {} })
} else if (session?.progress?.attempts && Array.isArray(session.progress.attempts)) {
this.setData({ session: session })
const attempts = session.progress.attempts
const attemptedIds = new Set(attempts.map((a: any) => String(a.question_id)))
const firstUnansweredIndex = qaList.findIndex((q: any) => !attemptedIds.has(String(q.id)))
if (firstUnansweredIndex !== -1) {
idx = firstUnansweredIndex
}
} else {
this.setData({ session: session })
}
const q = qaList[idx] || {}
const hasOptions = (Array.isArray(q?.correct_options) && q.correct_options.length > 0) || (Array.isArray(q?.incorrect_options) && q.incorrect_options.length > 0)
const hasCloze = !!q?.cloze && !!q.cloze.sentence_with_blank
@@ -582,7 +751,7 @@ Page<IData, IPageInstance>({
if (c === input) return 'opt-incorrect'
if (missingCorrect.includes(c)) return 'opt-correct'
return ''
} else if (result === 'correct' || result === 'exact' || result === '完全正确') {
} else if (result === 'correct' || result === 'exact' || result === '完全正确' || result === '完全匹配') {
if (c === input) return 'opt-correct'
return ''
}
@@ -609,8 +778,8 @@ Page<IData, IPageInstance>({
let overviewText = ''
let status: 'correct' | 'partial' | 'incorrect' = 'partial'
let iconName = 'data-search'
if (result === 'correct' || result === 'exact' || result === 'exact match' || result === '完全正确') {
overviewText = result === '完全正确' ? '完全正确' : '完全匹配'
if (result === 'correct' || result === 'exact' || result === 'exact match' || result === '完全正确' || result === '完全匹配') {
overviewText = (result === '完全正确' || result === '完全匹配') ? result : '完全匹配'
status = 'correct'
iconName = 'data-checked'
} else if (result === 'partial' || result === 'partial match') {
@@ -850,6 +1019,17 @@ Page<IData, IPageInstance>({
wx.previewImage({ urls: [url] })
}
},
triggerAutoNextIfCorrect(evaluation: any, qid: string) {
const resStr = String(evaluation?.result || '').toLowerCase()
if (['correct', 'exact', 'exact match', '完全正确', '完全匹配'].includes(resStr)) {
setTimeout(() => {
const currentQ = this.data.qaList[this.data.currentIndex] || {}
if (String(currentQ.id) === qid) {
this.onNextTap()
}
}, 800)
}
},
async submitAttempt() {
const q = this.data.qaList[this.data.currentIndex] || {}
const qid = q?.id
@@ -857,104 +1037,74 @@ Page<IData, IPageInstance>({
wx.showToast({ title: '题目暂不可提交', icon: 'none' })
return
}
const payload: any = {
exercise_id: String(this.data.exercise?.id || ''),
mode: this.data.questionMode
}
if (this.data.isTrialMode) {
payload.is_trial = true
}
if (this.data.questionMode === QUESTION_MODES.CHOICE) {
const selected_options = (this.data.selectedOptionIndexes || [])
.map((i) => this.data.choiceOptions?.[i]?.content)
.filter((v) => typeof v === 'string' && v.length > 0)
const resp = await apiManager.createQaQuestionAttempt(qid, { exercise_id: this.data.exercise?.id, mode: QUESTION_MODES.CHOICE, selected_options })
wx.showToast({ title: resp.status || 'accepted', icon: 'none' })
this.setData({ resultDisplayed: true })
try {
const evaluation = (resp as any)?.evaluation
if (evaluation) {
const selected = evaluation?.selected || {}
const selectedCorrect: string[] = Array.isArray(selected?.correct) ? selected.correct.map((x: any) => (typeof x === 'string' ? x : String(x?.content || ''))) : []
const selectedIncorrect: string[] = Array.isArray(selected?.incorrect) ? selected.incorrect.map((x: any) => (typeof x === 'string' ? x : String(x?.content || ''))) : []
const missingCorrect: string[] = Array.isArray(evaluation?.missing_correct) ? evaluation.missing_correct : []
const optionContents = (this.data.choiceOptions || []).map((o) => o?.content || '')
const evalClasses = optionContents.map((c) => {
if (selectedCorrect.includes(c)) return 'opt-correct'
if (selectedIncorrect.includes(c)) return 'opt-incorrect'
if (missingCorrect.includes(c)) return 'opt-missing'
return ''
})
const selectedAll = [...selectedCorrect, ...selectedIncorrect]
const flags = optionContents.map((c) => selectedAll.includes(c))
const indexes = flags.map((v, idx) => (v ? idx : -1)).filter((v) => v >= 0)
const count = indexes.length
if (evalClasses.length) {
this.setData({ evalClasses, selectedFlags: flags, selectedOptionIndexes: indexes, selectedCount: count, choiceSubmitted: true })
}
const cache = { ...(this.data.qaResultCache || {}) }
const fetched = { ...(this.data.qaResultFetched || {}) }
const prev = cache[String(qid)] || {}
cache[String(qid)] = { ...prev, choice: { ...(prev.choice || {}), evaluation } }
fetched[String(qid)] = true
this.setData({ qaResultCache: cache, qaResultFetched: fetched })
}
const latest = await apiManager.getQaResult(String(qid))
const cache2 = { ...(this.data.qaResultCache || {}) }
const fetched2 = { ...(this.data.qaResultFetched || {}) }
cache2[String(qid)] = latest
fetched2[String(qid)] = true
this.setData({ qaResultCache: cache2, qaResultFetched: fetched2 })
this.applyCachedResultForQuestion(String(qid))
} catch (e) {}
const flags = (this.data.submittedFlags || []).slice()
flags[this.data.currentIndex] = true
this.setData({ submittedFlags: flags })
this.updateActionButtonsState()
this.updateProcessDots()
return
payload.selected_options = selected_options
} else if (this.data.questionMode === QUESTION_MODES.CLOZE) {
const opt = this.data.clozeOptions || []
const sel = this.data.selectedClozeIndex
if (typeof sel === 'number' && sel >= 0 && sel < opt.length) {
payload.input_text = opt[sel]
}
} else if (this.data.questionMode === QUESTION_MODES.FREE_TEXT) {
payload.input_text = this.data.freeTextInput
}
if (this.data.questionMode === QUESTION_MODES.CLOZE) {
const idx = Number(this.data.selectedClozeIndex ?? -1)
const selected = idx >= 0 ? String((this.data.clozeOptions || [])[idx] || '') : String(this.data.clozeCorrectWord || '')
const cloze_options = selected ? [selected] : []
const payload: any = { exercise_id: this.data.exercise?.id, mode: QUESTION_MODES.CLOZE, cloze_options }
const resp = await apiManager.createQaQuestionAttempt(qid, payload)
wx.showToast({ title: resp.status || 'accepted', icon: 'none' })
this.setData({ resultDisplayed: true })
try {
const latest = await apiManager.getQaResult(String(qid))
const cache = { ...(this.data.qaResultCache || {}) }
const fetched = { ...(this.data.qaResultFetched || {}) }
cache[String(qid)] = latest
fetched[String(qid)] = true
this.setData({ qaResultCache: cache, qaResultFetched: fetched })
this.applyCachedResultForQuestion(String(qid))
} catch (e) {}
const flags = (this.data.submittedFlags || []).slice()
flags[this.data.currentIndex] = true
this.setData({ submittedFlags: flags })
this.updateActionButtonsState()
this.updateProcessDots()
return
}
if (this.data.questionMode === QUESTION_MODES.FREE_TEXT) {
const text = this.data.freeTextInput || ''
const resp = await apiManager.createQaQuestionAttempt(qid, { exercise_id: this.data.exercise?.id, mode: QUESTION_MODES.FREE_TEXT, input_text: text })
wx.showToast({ title: resp.status || 'accepted', icon: 'none' })
this.setData({ resultDisplayed: true })
try {
const latest = await apiManager.getQaResult(String(qid))
const cache = { ...(this.data.qaResultCache || {}) }
const fetched = { ...(this.data.qaResultFetched || {}) }
cache[String(qid)] = latest
fetched[String(qid)] = true
this.setData({ qaResultCache: cache, qaResultFetched: fetched })
} catch (e) {}
const flags = (this.data.submittedFlags || []).slice()
flags[this.data.currentIndex] = true
this.setData({ submittedFlags: flags })
this.updateActionButtonsState()
this.updateProcessDots()
return
wx.showLoading({ title: '提交中' })
try {
const resp = await apiManager.createQaQuestionAttempt(String(qid), payload)
// 提交成功后重新获取结果,使用新的 ensureQuestionResultFetched 逻辑
// 注意ensureQuestionResultFetched 内部会更新缓存和界面状态
// 由于服务器处理可能需要一点时间,这里可以短暂延迟或依靠轮询
// 但对于同步返回结果的场景(如本接口可能优化为直接返回结果),这里假设需要重新获取详情
// 如果 createQaQuestionAttempt 直接返回了 evaluation则可以直接使用
// 暂时通过 ensureQuestionResultFetched 刷新
// 为了防止后端异步处理延迟,这里可以尝试立即获取,或者等待推送
// 实际项目中createAttempt 可能直接返回结果,或者需要轮询
// 假设 ensureQuestionResultFetched 会处理
// 强制刷新当前题目的结果
// 先清除本地标记,强制拉取
const k = String(qid)
const fetched = this.data.qaResultFetched || {}
fetched[k] = false
this.setData({ qaResultFetched: fetched })
await this.ensureQuestionResultFetched(String(qid))
wx.hideLoading()
this.setData({ choiceSubmitted: true, submitDisabled: true, retryDisabled: false })
// 检查自动跳转
const cache = this.data.qaResultCache || {}
const res = cache[k]
const mode = this.data.questionMode
let evaluation: any = null
if (mode === QUESTION_MODES.CHOICE) evaluation = res.choice?.evaluation
else if (mode === QUESTION_MODES.CLOZE) evaluation = res.cloze?.evaluation
else if (mode === QUESTION_MODES.FREE_TEXT) evaluation = res.free_text?.evaluation
if (evaluation) {
this.triggerAutoNextIfCorrect(evaluation, String(qid))
}
this.checkAllQuestionsAttempted()
} catch (e) {
wx.hideLoading()
logger.error('提交失败', e)
wx.showToast({ title: '提交失败', icon: 'none' })
}
},
async getQuestionAudio(questionId: string) {

View File

@@ -80,7 +80,7 @@
<view class="bottom-bar {{contentVisible ? 'show' : ''}}">
<t-icon name="chevron-left" class="bottom-btn {{currentIndex <= 0 ? 'disabled' : ''}}" size="48rpx" bind:tap="onPrevTap" />
<t-icon name="{{isPlaying ? 'pause' : 'play'}}" class="bottom-btn" size="48rpx" bind:tap="playStandardVoice" />
<!-- <t-icon name="swap" class="bottom-btn" size="48rpx" bind:tap="toggleMode" /> -->
<t-icon name="swap" class="bottom-btn" size="48rpx" bind:tap="toggleMode" />
<t-icon name="fact-check" class="bottom-btn {{resultDisplayed ? '' : 'disabled'}}" size="48rpx" bind:tap="onScoreTap" />
<t-icon name="chevron-right" class="bottom-btn {{(qaList && (currentIndex >= qaList.length - 1)) ? 'disabled' : ''}}" size="48rpx" bind:tap="onNextTap" />
</view>
@@ -174,4 +174,26 @@
</block>
</scroll-view>
</view>
<view class="completion-popup-mask" wx:if="{{showCompletionPopup}}">
<view class="completion-card">
<view class="completion-close" bindtap="handleCompletionPopupClose">
<t-icon name="close" size="48rpx" color="#ccc" />
</view>
<view class="completion-trophy">
<view class="trophy-circle">
<t-icon name="thumb-up-2" size="80rpx" color="#001858" />
</view>
<view class="confetti c1"></view>
<view class="confetti c2"></view>
<view class="confetti c3"></view>
</view>
<view class="completion-title">Excellent Job!</view>
<!-- <view class="completion-subtitle">You've mastered the vocabulary for this photo scene.</view> -->
<button class="completion-share-btn" open-type="share" data-type="achievement">
<t-icon name="share" size="36rpx" color="#fff" style="margin-right: 12rpx;" />
Share Achievement
</button>
</view>
</view>
</view>

View File

@@ -290,3 +290,110 @@
.detail-row.incorrect .detail-item { color: #e74c3c; }
.detail-row.missing .detail-item { color: #2f80ed; }
.detail-row.your-choice.partial .detail-item { color: #0b9e62; }
.completion-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease;
}
.completion-card {
width: 600rpx;
background: #ffffff;
border-radius: 32rpx;
padding: 48rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
animation: scaleIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.completion-close {
position: absolute;
top: 24rpx;
right: 24rpx;
padding: 12rpx;
}
.completion-trophy {
position: relative;
margin-bottom: 32rpx;
margin-top: 16rpx;
}
.trophy-circle {
width: 160rpx;
height: 160rpx;
background: #21cc80;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 12rpx 36rpx rgba(33, 204, 128, 0.6);
}
.confetti {
position: absolute;
width: 16rpx;
height: 16rpx;
border-radius: 4rpx;
}
.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); }
.completion-title {
font-size: 48rpx;
font-weight: 800;
color: #001858;
margin-bottom: 16rpx;
text-align: center;
}
.completion-subtitle {
font-size: 30rpx;
color: #666;
text-align: center;
margin-bottom: 48rpx;
line-height: 1.5;
padding: 0 32rpx;
}
.completion-share-btn {
width: 100%;
height: 96rpx;
background: #21cc80;
border-radius: 48rpx;
color: #fff;
font-size: 32rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
border: none;
box-shadow: 0 8rpx 24rpx rgba(33, 204, 128, 0.3);
}
.completion-share-btn:active {
transform: scale(0.98);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}

View File

@@ -6,6 +6,7 @@ export interface IUserInfo {
id: string
nickname?: string
avatar_url?: string
avatar_file_id?: string
gender?: number
country?: string
province?: string
@@ -170,13 +171,22 @@ export interface IPointsData {
}
export interface IQaExerciseSession {
id: number
id: string | number
exercise_id: number
starter_user_id: number
status: string
started_at?: string
completed_at?: string
progress: number
progress: number | {
answered: number
attempts: Array<{
attempt_id: string | number
created_at: string
is_correct: string
mode: string
question_id: string | number
}>
}
score?: number
ext?: any
}
@@ -218,6 +228,7 @@ export interface IGlobalData {
userInfo?: IUserInfo
token?: string
dictLevel?: string
pendingReferrerId?: string
apiManager: ApiManager
}

View File

@@ -274,9 +274,9 @@ class ApiManager {
data,
header: this.getHeaders(),
success: async (res) => {
if (showLoading) {
wx.hideLoading()
}
// if (showLoading) {
// wx.hideLoading()
// }
console.log('请求响应:', {
statusCode: res.statusCode,
@@ -573,7 +573,7 @@ class ApiManager {
}
// 用户登录(带重试机制)
async login(retryCount: number = 0): Promise<ILoginResponse> {
async login(retryCount: number = 0, referrerId?: string): Promise<ILoginResponse> {
const maxRetries = 5 // 最多重试5次首轮 + 5次重试共最多6次尝试
return new Promise((resolve, reject) => {
@@ -585,9 +585,11 @@ class ApiManager {
success: async (loginRes) => {
if (loginRes.code) {
try {
const response = await this.loginRequest<ILoginResponse>('/api/v1/wx/login', 'POST', {
code: loginRes.code
})
const data: any = { code: loginRes.code }
if (referrerId) {
data.referrer_id = referrerId
}
const response = await this.loginRequest<ILoginResponse>('/api/v1/wx/login', 'POST', data)
// 处理新的数据结构
const loginData = response.data
@@ -1500,6 +1502,20 @@ class ApiManager {
return response.data
}
// 更新用户头像
async updateUserAvatar(fileId: string): Promise<{ url: string }> {
const response = await this.request<{ url: string }>('/api/v1/wx/user/avatar', 'PUT', {
avatar_file_id: fileId
})
return response.data
}
// 获取用户头像
async getUserAvatar(): Promise<{ url: string }> {
const response = await this.request<{ url: string }>('/api/v1/wx/user/avatar', 'GET')
return response.data
}
// 检查登录状态
async checkLoginStatus(): Promise<boolean> {
try {
@@ -1665,10 +1681,14 @@ class ApiManager {
}
// 安全下载文件接口
async downloadFile(fileId: string): Promise<string> {
async downloadFile(fileId: string, referrerId?: string): Promise<string> {
if (USE_CLOUD) {
try {
const resp = await this.wx_request<{ url: string }>(`/api/v1/file/temp_url/${fileId}`, 'GET')
let url = `/api/v1/file/temp_url/${fileId}`
if (referrerId) {
url += `?r=${referrerId}`
}
const resp = await this.wx_request<{ url: string }>(url, 'GET')
const tempUrl = (resp as any)?.data?.url || (resp as any)?.url
if (!tempUrl) {
throw new Error('无法获取临时下载链接')
@@ -1706,7 +1726,10 @@ class ApiManager {
}
}
return new Promise((resolve, reject) => {
const fileUrl = `${BASE_URL}/api/v1/file/${fileId}`
let fileUrl = `${BASE_URL}/api/v1/file/${fileId}`
if (referrerId) {
fileUrl += `?ReferrerId=${referrerId}`
}
const headers = this.getHeaders()
wx.downloadFile({
url: fileUrl,
@@ -1741,8 +1764,14 @@ class ApiManager {
})
}
// 获取图片的 file_id
async getImageFileId(imageId: string): Promise<string> {
const resp = await this.request<{ file_id: string }>(`/api/v1/image/${imageId}/file_id`, 'GET')
return resp.data.file_id
}
// 将文件ID转换为可显示的图片URL
async getFileDisplayUrl(fileId: string): Promise<string> {
async getFileDisplayUrl(fileId: string, referrerId?: string): Promise<string> {
try {
// 首先检查图片缓存中是否已有该文件
if (imageCache.has(fileId)) {
@@ -1752,7 +1781,7 @@ class ApiManager {
}
// 下载文件
const filePath = await this.downloadFile(fileId);
const filePath = await this.downloadFile(fileId, referrerId);
// 将文件路径缓存起来
imageCache.set(fileId, filePath);
@@ -2087,6 +2116,7 @@ class ApiManager {
selected_options?: string[]
input_text?: string
file_id?: string
is_trial?: boolean
}
): Promise<IQaQuestionAttemptAccepted> {
const resp = await this.request<IQaQuestionAttemptAccepted>(`/api/v1/qa/questions/${questionId}/attempts`, 'POST', payload)