add avatar
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
8
miniprogram/pages/avatar_crop/avatar_crop.json
Normal file
8
miniprogram/pages/avatar_crop/avatar_crop.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "移动和缩放",
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarBackgroundColor": "#000000",
|
||||
"backgroundColor": "#000000",
|
||||
"disableScroll": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
342
miniprogram/pages/avatar_crop/avatar_crop.ts
Normal file
342
miniprogram/pages/avatar_crop/avatar_crop.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
})
|
||||
35
miniprogram/pages/avatar_crop/avatar_crop.wxml
Normal file
35
miniprogram/pages/avatar_crop/avatar_crop.wxml
Normal 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>
|
||||
96
miniprogram/pages/avatar_crop/avatar_crop.wxss
Normal file
96
miniprogram/pages/avatar_crop/avatar_crop.wxss
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
// 如果没有 thumbnailId,fetchQaExercises 可能会加载图片,这里暂时不做额外处理,
|
||||
// 除非 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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user