add confetti
This commit is contained in:
448
miniprogram/components/vx-confetti/vx-confetti.js
Normal file
448
miniprogram/components/vx-confetti/vx-confetti.js
Normal file
@@ -0,0 +1,448 @@
|
||||
// canvas-confetti.js - 微信小程序兼容版本
|
||||
// 基于 https://github.com/catdad/canvas-confetti
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
width: {
|
||||
type: Number,
|
||||
value: 300
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
value: 300
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
value: 'confettiCanvas'
|
||||
}
|
||||
},
|
||||
|
||||
data: {
|
||||
canvasId: '',
|
||||
isCanvasReady: false,
|
||||
canvasInitPromise: null,
|
||||
animationFrameId: null,
|
||||
animatingFettis: [],
|
||||
defaults: {
|
||||
particleCount: 50,
|
||||
angle: 90,
|
||||
spread: 45,
|
||||
startVelocity: 45,
|
||||
decay: 0.9,
|
||||
gravity: 1,
|
||||
drift: 0,
|
||||
ticks: 200,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
shapes: ['square', 'circle'],
|
||||
colors: [
|
||||
'#26ccff',
|
||||
'#a25afd',
|
||||
'#ff5e7e',
|
||||
'#88ff5a',
|
||||
'#fcff42',
|
||||
'#ffa62d',
|
||||
'#ff36ff'
|
||||
],
|
||||
scalar: 1,
|
||||
flat: false
|
||||
}
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
this.setData({
|
||||
canvasId: this.properties.id
|
||||
});
|
||||
// 创建初始化Promise
|
||||
this.data.canvasInitPromise = new Promise((resolve) => {
|
||||
this._canvasReadyResolver = resolve;
|
||||
});
|
||||
},
|
||||
ready() {
|
||||
// 在组件准备好后初始化canvas
|
||||
this.initCanvas();
|
||||
},
|
||||
detached() {
|
||||
this.reset();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
initCanvas() {
|
||||
const query = this.createSelectorQuery();
|
||||
query.select(`#${this.data.canvasId}`)
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res) => {
|
||||
if (res && res[0]) {
|
||||
const canvas = res[0].node;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 设置canvas大小
|
||||
canvas.width = this.properties.width;
|
||||
canvas.height = this.properties.height;
|
||||
|
||||
// 优化真机显示 - 考虑设备像素比
|
||||
try {
|
||||
const deviceInfo = wx.getDeviceInfo();
|
||||
const dpr = deviceInfo.pixelRatio || 1;
|
||||
canvas.width = this.properties.width * dpr;
|
||||
canvas.height = this.properties.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
} catch (e) {
|
||||
console.error('设置DPR失败', e);
|
||||
}
|
||||
|
||||
this.canvas = canvas;
|
||||
this.ctx = ctx;
|
||||
|
||||
// 标记canvas已准备好
|
||||
this.setData({ isCanvasReady: true });
|
||||
|
||||
// 解析初始化Promise
|
||||
if (this._canvasReadyResolver) {
|
||||
this._canvasReadyResolver();
|
||||
}
|
||||
|
||||
console.log('Canvas 已初始化完成', this.canvas.width, this.canvas.height);
|
||||
} else {
|
||||
console.error('Canvas 节点未找到', this.data.canvasId);
|
||||
// 如果找不到canvas,200ms后重试
|
||||
setTimeout(() => {
|
||||
this.initCanvas();
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 等待canvas初始化完成
|
||||
async waitForCanvasReady() {
|
||||
if (this.data.isCanvasReady && this.canvas && this.ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.data.canvasInitPromise;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Canvas 初始化失败', err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 核心方法,触发五彩纸屑效果
|
||||
async fire(options = {}) {
|
||||
// 等待canvas初始化完成
|
||||
const isReady = await this.waitForCanvasReady();
|
||||
|
||||
if (!isReady || !this.canvas || !this.ctx) {
|
||||
console.error('Canvas 未初始化');
|
||||
return Promise.reject('Canvas 未初始化');
|
||||
}
|
||||
|
||||
return this.fireConfetti(options);
|
||||
},
|
||||
|
||||
// 重置,停止当前动画
|
||||
reset() {
|
||||
this.cancelAnimation();
|
||||
if (this.ctx && this.canvas) {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
this.setData({
|
||||
animatingFettis: []
|
||||
});
|
||||
},
|
||||
|
||||
// 取消动画帧
|
||||
cancelAnimation() {
|
||||
if (this.data.animationFrameId) {
|
||||
if (wx.canIUse('cancelAnimationFrame')) {
|
||||
cancelAnimationFrame(this.data.animationFrameId);
|
||||
} else {
|
||||
clearTimeout(this.data.animationFrameId);
|
||||
}
|
||||
this.setData({
|
||||
animationFrameId: null
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 请求动画帧
|
||||
requestFrame(callback) {
|
||||
if (wx.canIUse('requestAnimationFrame')) {
|
||||
return requestAnimationFrame(callback);
|
||||
} else {
|
||||
return setTimeout(callback, 1000 / 60);
|
||||
}
|
||||
},
|
||||
|
||||
// 转换属性
|
||||
convert(val, transform) {
|
||||
return transform ? transform(val) : val;
|
||||
},
|
||||
|
||||
// 检查值是否有效
|
||||
isOk(val) {
|
||||
return !(val === null || val === undefined);
|
||||
},
|
||||
|
||||
// 获取配置属性
|
||||
prop(options, name, transform) {
|
||||
return this.convert(
|
||||
options && this.isOk(options[name]) ? options[name] : this.data.defaults[name],
|
||||
transform
|
||||
);
|
||||
},
|
||||
|
||||
// 将十六进制颜色转为RGB
|
||||
hexToRgb(str) {
|
||||
const val = String(str).replace(/[^0-9a-f]/gi, '');
|
||||
const hex = val.length < 6
|
||||
? val[0] + val[0] + val[1] + val[1] + val[2] + val[2]
|
||||
: val;
|
||||
|
||||
return {
|
||||
r: parseInt(hex.substring(0, 2), 16),
|
||||
g: parseInt(hex.substring(2, 4), 16),
|
||||
b: parseInt(hex.substring(4, 6), 16)
|
||||
};
|
||||
},
|
||||
|
||||
// 颜色数组转RGB
|
||||
colorsToRgb(colors) {
|
||||
return colors.map(color => this.hexToRgb(color));
|
||||
},
|
||||
|
||||
// 只接受正整数
|
||||
onlyPositiveInt(number) {
|
||||
return number < 0 ? 0 : Math.floor(number);
|
||||
},
|
||||
|
||||
// 生成随机整数
|
||||
randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min)) + min;
|
||||
},
|
||||
|
||||
// 获取原点配置
|
||||
getOrigin(options) {
|
||||
const origin = this.prop(options, 'origin', Object) || {};
|
||||
origin.x = this.isOk(origin.x) ? origin.x : this.data.defaults.x;
|
||||
origin.y = this.isOk(origin.y) ? origin.y : this.data.defaults.y;
|
||||
return origin;
|
||||
},
|
||||
|
||||
// 创建随机物理属性
|
||||
randomPhysics(opts) {
|
||||
const radAngle = opts.angle * (Math.PI / 180);
|
||||
const radSpread = opts.spread * (Math.PI / 180);
|
||||
|
||||
return {
|
||||
x: opts.x,
|
||||
y: opts.y,
|
||||
wobble: Math.random() * 10,
|
||||
wobbleSpeed: Math.min(0.11, Math.random() * 0.1 + 0.05),
|
||||
velocity: (opts.startVelocity * 0.5) + (Math.random() * opts.startVelocity),
|
||||
angle2D: -radAngle + ((0.5 * radSpread) - (Math.random() * radSpread)),
|
||||
tiltAngle: (Math.random() * (0.75 - 0.25) + 0.25) * Math.PI,
|
||||
color: opts.color,
|
||||
shape: opts.shape,
|
||||
tick: 0,
|
||||
totalTicks: opts.ticks,
|
||||
decay: opts.decay,
|
||||
drift: opts.drift,
|
||||
random: Math.random() + 2,
|
||||
tiltSin: 0,
|
||||
tiltCos: 0,
|
||||
wobbleX: 0,
|
||||
wobbleY: 0,
|
||||
gravity: opts.gravity * 3,
|
||||
ovalScalar: 0.6,
|
||||
scalar: opts.scalar,
|
||||
flat: opts.flat
|
||||
};
|
||||
},
|
||||
|
||||
// 微信小程序不支持Path2D和标准的ellipse,创建一个ellipse方法
|
||||
ellipse(context, x, y, radiusX, radiusY, rotation, startAngle, endAngle) {
|
||||
context.save();
|
||||
context.translate(x, y);
|
||||
context.rotate(rotation);
|
||||
context.scale(radiusX, radiusY);
|
||||
context.arc(0, 0, 1, startAngle, endAngle, false);
|
||||
context.restore();
|
||||
},
|
||||
|
||||
// 更新单个五彩纸屑
|
||||
updateFetti(context, fetti) {
|
||||
fetti.x += Math.cos(fetti.angle2D) * fetti.velocity + fetti.drift;
|
||||
fetti.y += Math.sin(fetti.angle2D) * fetti.velocity + fetti.gravity;
|
||||
fetti.velocity *= fetti.decay;
|
||||
|
||||
if (fetti.flat) {
|
||||
fetti.wobble = 0;
|
||||
fetti.wobbleX = fetti.x + (10 * fetti.scalar);
|
||||
fetti.wobbleY = fetti.y + (10 * fetti.scalar);
|
||||
|
||||
fetti.tiltSin = 0;
|
||||
fetti.tiltCos = 0;
|
||||
fetti.random = 1;
|
||||
} else {
|
||||
fetti.wobble += fetti.wobbleSpeed;
|
||||
fetti.wobbleX = fetti.x + ((10 * fetti.scalar) * Math.cos(fetti.wobble));
|
||||
fetti.wobbleY = fetti.y + ((10 * fetti.scalar) * Math.sin(fetti.wobble));
|
||||
|
||||
fetti.tiltAngle += 0.1;
|
||||
fetti.tiltSin = Math.sin(fetti.tiltAngle);
|
||||
fetti.tiltCos = Math.cos(fetti.tiltAngle);
|
||||
fetti.random = Math.random() + 2;
|
||||
}
|
||||
|
||||
const progress = (fetti.tick++) / fetti.totalTicks;
|
||||
|
||||
const x1 = fetti.x + (fetti.random * fetti.tiltCos);
|
||||
const y1 = fetti.y + (fetti.random * fetti.tiltSin);
|
||||
const x2 = fetti.wobbleX + (fetti.random * fetti.tiltCos);
|
||||
const y2 = fetti.wobbleY + (fetti.random * fetti.tiltSin);
|
||||
|
||||
context.fillStyle = `rgba(${fetti.color.r}, ${fetti.color.g}, ${fetti.color.b}, ${1 - progress})`;
|
||||
context.beginPath();
|
||||
|
||||
if (fetti.shape === 'circle') {
|
||||
this.ellipse(
|
||||
context,
|
||||
fetti.x,
|
||||
fetti.y,
|
||||
Math.abs(x2 - x1) * fetti.ovalScalar,
|
||||
Math.abs(y2 - y1) * fetti.ovalScalar,
|
||||
Math.PI / 10 * fetti.wobble,
|
||||
0,
|
||||
2 * Math.PI
|
||||
);
|
||||
} else if (fetti.shape === 'star') {
|
||||
let rot = Math.PI / 2 * 3;
|
||||
const innerRadius = 4 * fetti.scalar;
|
||||
const outerRadius = 8 * fetti.scalar;
|
||||
const x = fetti.x;
|
||||
const y = fetti.y;
|
||||
let spikes = 5;
|
||||
const step = Math.PI / spikes;
|
||||
|
||||
while (spikes--) {
|
||||
let xTemp = x + Math.cos(rot) * outerRadius;
|
||||
let yTemp = y + Math.sin(rot) * outerRadius;
|
||||
context.lineTo(xTemp, yTemp);
|
||||
rot += step;
|
||||
|
||||
xTemp = x + Math.cos(rot) * innerRadius;
|
||||
yTemp = y + Math.sin(rot) * innerRadius;
|
||||
context.lineTo(xTemp, yTemp);
|
||||
rot += step;
|
||||
}
|
||||
} else {
|
||||
// square (default)
|
||||
context.moveTo(Math.floor(fetti.x), Math.floor(fetti.y));
|
||||
context.lineTo(Math.floor(fetti.wobbleX), Math.floor(y1));
|
||||
context.lineTo(Math.floor(x2), Math.floor(y2));
|
||||
context.lineTo(Math.floor(x1), Math.floor(fetti.wobbleY));
|
||||
}
|
||||
|
||||
context.closePath();
|
||||
context.fill();
|
||||
|
||||
return fetti.tick < fetti.totalTicks;
|
||||
},
|
||||
|
||||
// 动画函数
|
||||
animate(fettis) {
|
||||
const animatingFettis = [...fettis];
|
||||
const context = this.ctx;
|
||||
const canvas = this.canvas;
|
||||
|
||||
const update = () => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const stillAlive = [];
|
||||
for (let i = 0; i < animatingFettis.length; i++) {
|
||||
if (this.updateFetti(context, animatingFettis[i])) {
|
||||
stillAlive.push(animatingFettis[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (stillAlive.length) {
|
||||
this.setData({
|
||||
animatingFettis: stillAlive,
|
||||
animationFrameId: this.requestFrame(() => update())
|
||||
});
|
||||
} else {
|
||||
this.setData({
|
||||
animatingFettis: [],
|
||||
animationFrameId: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.setData({
|
||||
animationFrameId: this.requestFrame(() => update())
|
||||
});
|
||||
},
|
||||
|
||||
// 发射五彩纸屑
|
||||
fireConfetti(options) {
|
||||
return new Promise((resolve) => {
|
||||
const particleCount = this.prop(options, 'particleCount', this.onlyPositiveInt.bind(this));
|
||||
const angle = this.prop(options, 'angle', Number);
|
||||
const spread = this.prop(options, 'spread', Number);
|
||||
const startVelocity = this.prop(options, 'startVelocity', Number);
|
||||
const decay = this.prop(options, 'decay', Number);
|
||||
const gravity = this.prop(options, 'gravity', Number);
|
||||
const drift = this.prop(options, 'drift', Number);
|
||||
const colors = this.prop(options, 'colors', this.colorsToRgb.bind(this));
|
||||
const ticks = this.prop(options, 'ticks', Number);
|
||||
const shapes = this.prop(options, 'shapes');
|
||||
const scalar = this.prop(options, 'scalar');
|
||||
const flat = !!this.prop(options, 'flat');
|
||||
const origin = this.getOrigin(options);
|
||||
|
||||
let temp = particleCount;
|
||||
const fettis = [];
|
||||
|
||||
const startX = this.canvas.width * origin.x;
|
||||
const startY = this.canvas.height * origin.y;
|
||||
|
||||
while (temp--) {
|
||||
fettis.push(
|
||||
this.randomPhysics({
|
||||
x: startX,
|
||||
y: startY,
|
||||
angle: angle,
|
||||
spread: spread,
|
||||
startVelocity: startVelocity,
|
||||
color: colors[temp % colors.length],
|
||||
shape: shapes[this.randomInt(0, shapes.length)],
|
||||
ticks: ticks,
|
||||
decay: decay,
|
||||
gravity: gravity,
|
||||
drift: drift,
|
||||
scalar: scalar,
|
||||
flat: flat
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 合并已有和新的五彩纸屑
|
||||
const allFettis = [...this.data.animatingFettis, ...fettis];
|
||||
|
||||
this.setData({
|
||||
animatingFettis: allFettis
|
||||
}, () => {
|
||||
// 如果已经有动画在运行,不需要再次启动
|
||||
if (!this.data.animationFrameId) {
|
||||
this.animate(allFettis);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/vx-confetti/vx-confetti.json
Normal file
4
miniprogram/components/vx-confetti/vx-confetti.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
1
miniprogram/components/vx-confetti/vx-confetti.wxml
Normal file
1
miniprogram/components/vx-confetti/vx-confetti.wxml
Normal file
@@ -0,0 +1 @@
|
||||
<canvas type="2d" id="{{canvasId}}" style="width: {{width}}px; height: {{height}}px;"></canvas>
|
||||
@@ -178,7 +178,7 @@ type IPageMethods = {
|
||||
ensureRecordPermission: () => void
|
||||
onMoreTap: () => void
|
||||
onSceneSentenceTap: () => void
|
||||
onImageQaExerciseTap: () => void
|
||||
onImageQaExerciseTap: (e: any) => void
|
||||
onSentenceTouchStart: (e: any) => void
|
||||
onSentenceTouchMove: (e: any) => void
|
||||
onSentenceTouchEnd: () => void
|
||||
@@ -1412,15 +1412,16 @@ Page<IPageData, IPageInstance>({
|
||||
})
|
||||
},
|
||||
|
||||
onImageQaExerciseTap() {
|
||||
onImageQaExerciseTap(e: any) {
|
||||
const imageId = this.data.imageId || ''
|
||||
const type = e?.currentTarget?.dataset?.type;
|
||||
if (!imageId) {
|
||||
wx.showToast({ title: '缺少图片ID', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ isMoreMenuOpen: false, isMoreMenuClosing: false })
|
||||
wx.navigateTo({
|
||||
url: `/pages/qa_exercise/qa_exercise?image_id=${encodeURIComponent(imageId)}`
|
||||
url: `/pages/qa_exercise/qa_exercise?image_id=${encodeURIComponent(imageId)}${type ? ('&type=' + type) : ''}`
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
<view class="bottom-more-area {{isMoreMenuOpen ? 'open' : (isMoreMenuClosing ? 'close' : '')}}" wx:if="{{isMoreMenuOpen || isMoreMenuClosing}}">
|
||||
<view class="more-items">
|
||||
<view class="more-item" bindtap="onSceneSentenceTap">场景句型</view>
|
||||
<view class="more-item" bindtap="onImageQaExerciseTap">场景练习</view>
|
||||
<view class="more-item" bindtap="onImageQaExerciseTap" data-type="choice">问答练习</view>
|
||||
<view class="more-item" bindtap="onImageQaExerciseTap" data-type="cloze">完形填空</view>
|
||||
<!-- <view class="more-item">功能3</view> -->
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton",
|
||||
"word-dictionary": "../../components/word-dictionary/word-dictionary"
|
||||
"word-dictionary": "../../components/word-dictionary/word-dictionary",
|
||||
"vx-confetti": "/components/vx-confetti/vx-confetti"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@ interface IData {
|
||||
processDotClasses?: string[]
|
||||
showCompletionPopup?: boolean
|
||||
isTrialMode?: boolean
|
||||
fixedMode?: QuestionMode,
|
||||
canvasWidth?: number,
|
||||
canvasHeight?: number,
|
||||
confetti?: any
|
||||
nextButtonIcon?: string
|
||||
isAutoSwitching?: boolean
|
||||
}
|
||||
|
||||
interface IPageInstance {
|
||||
@@ -125,6 +131,8 @@ interface IPageInstance {
|
||||
checkAllQuestionsAttempted: () => void
|
||||
handleCompletionPopupClose: () => void
|
||||
handleShareAchievement: () => void
|
||||
fireConfetti: () => void
|
||||
resetConfetti: () => void
|
||||
}
|
||||
|
||||
Page<IData, IPageInstance>({
|
||||
@@ -182,7 +190,13 @@ Page<IData, IPageInstance>({
|
||||
retryDisabled: true,
|
||||
audioUrlMap: {},
|
||||
audioLocalMap: {},
|
||||
isPlaying: false
|
||||
isPlaying: false,
|
||||
fixedMode: undefined,
|
||||
canvasWidth: 0,
|
||||
canvasHeight: 0,
|
||||
confetti: null,
|
||||
nextButtonIcon: 'chevron-right',
|
||||
isAutoSwitching: false
|
||||
},
|
||||
updateProcessDots() {
|
||||
const list = this.data.qaList || []
|
||||
@@ -321,6 +335,7 @@ Page<IData, IPageInstance>({
|
||||
this.switchQuestion(-1)
|
||||
},
|
||||
onNextTap() {
|
||||
this.setData({ nextButtonIcon: 'chevron-right', isAutoSwitching: false })
|
||||
this.switchQuestion(1)
|
||||
},
|
||||
toggleMode() {
|
||||
@@ -330,10 +345,37 @@ Page<IData, IPageInstance>({
|
||||
const next = order[(i + 1) % order.length]
|
||||
this.switchMode(next)
|
||||
},
|
||||
onReady() {
|
||||
// 获取组件实例
|
||||
(this as any).confetti = this.selectComponent('#confetti');
|
||||
},
|
||||
fireConfetti() {
|
||||
// 触发五彩纸屑效果 - 注意这是异步方法,返回Promise
|
||||
(this as any).confetti.fire({
|
||||
particleCount: 100,
|
||||
spread: 70,
|
||||
origin: { x: 0.5, y: 0.5 }
|
||||
}).then(() => {
|
||||
logger.log('五彩纸屑效果已启动');
|
||||
}).catch((err: any) => {
|
||||
logger.error('启动失败', err);
|
||||
});
|
||||
},
|
||||
|
||||
resetConfetti() {
|
||||
// 重置画布,清除五彩纸屑
|
||||
(this as any).confetti?.reset?.();
|
||||
},
|
||||
|
||||
async onLoad(options: Record<string, string>) {
|
||||
try {
|
||||
const app = getApp<IAppOption>()
|
||||
|
||||
const type = options.type as QuestionMode
|
||||
if (type && ENABLED_QUESTION_MODES.includes(type)) {
|
||||
this.setData({ fixedMode: type })
|
||||
}
|
||||
|
||||
// 处理推荐人ID
|
||||
const referrerId = options.referrer || options.referrerId || options.referrer_id
|
||||
if (referrerId) {
|
||||
@@ -351,6 +393,19 @@ Page<IData, IPageInstance>({
|
||||
// duration: 3000
|
||||
// })
|
||||
// }
|
||||
try {
|
||||
const windowInfo = (wx as any).getWindowInfo ? (wx as any).getWindowInfo() : wx.getSystemInfoSync()
|
||||
this.setData({
|
||||
canvasWidth: windowInfo.windowWidth,
|
||||
canvasHeight: windowInfo.windowHeight
|
||||
});
|
||||
} catch (e) {
|
||||
this.setData({
|
||||
canvasWidth: 375,
|
||||
canvasHeight: 667
|
||||
});
|
||||
logger.error('获取窗口信息失败:', e)
|
||||
}
|
||||
|
||||
const imageId = options?.id || options?.image_id || ''
|
||||
const thumbnailId = options?.thumbnail_id || '' // 兼容旧逻辑,如果是分享进来的可能需要通过 API 获取图片链接
|
||||
@@ -400,8 +455,9 @@ Page<IData, IPageInstance>({
|
||||
// 检查是否是从成就弹窗分享
|
||||
const isAchievement = options?.from === 'button' && options?.target?.dataset?.type === 'achievement'
|
||||
|
||||
const type = this.data.fixedMode || this.data.questionMode
|
||||
// 构建分享路径
|
||||
const path = `/pages/qa_exercise/qa_exercise?id=${imageId}&referrer=${myUserId}&mode=trial`
|
||||
const path = `/pages/qa_exercise/qa_exercise?id=${imageId}&referrer=${myUserId}&mode=trial&type=${type}`
|
||||
|
||||
let shareTitle = `一起解锁拍照学英语的快乐!👇`
|
||||
let imageUrl = this.data.imageLocalUrl || undefined
|
||||
@@ -525,7 +581,12 @@ Page<IData, IPageInstance>({
|
||||
const hasOptions = (Array.isArray(q?.correct_options) && q.correct_options.length > 0) || (Array.isArray(q?.incorrect_options) && q.incorrect_options.length > 0)
|
||||
const hasCloze = !!q?.cloze && !!q.cloze.sentence_with_blank
|
||||
const preferredMode: QuestionMode = hasOptions ? QUESTION_MODES.CHOICE : (hasCloze ? QUESTION_MODES.CLOZE : QUESTION_MODES.FREE_TEXT)
|
||||
const mode: QuestionMode = ENABLED_QUESTION_MODES.includes(preferredMode) ? preferredMode : ENABLED_QUESTION_MODES[0]
|
||||
let mode: QuestionMode = ENABLED_QUESTION_MODES.includes(preferredMode) ? preferredMode : ENABLED_QUESTION_MODES[0]
|
||||
|
||||
if (this.data.fixedMode) {
|
||||
mode = this.data.fixedMode
|
||||
}
|
||||
|
||||
let choiceOptions: Array<{ content: string; correct: boolean; type?: string }> = []
|
||||
if (hasOptions) {
|
||||
const correct = (q.correct_options || []).map((o: any) => ({ content: o?.content || '', correct: true, type: o?.type }))
|
||||
@@ -639,6 +700,8 @@ Page<IData, IPageInstance>({
|
||||
this.updateProcessDots()
|
||||
},
|
||||
switchMode(mode: QuestionMode) {
|
||||
if (this.data.fixedMode) return
|
||||
|
||||
let target: QuestionMode = mode
|
||||
if (!ENABLED_QUESTION_MODES.includes(target)) {
|
||||
target = ENABLED_QUESTION_MODES[0]
|
||||
@@ -931,6 +994,7 @@ Page<IData, IPageInstance>({
|
||||
} catch (e) {}
|
||||
},
|
||||
selectOption(e: any) {
|
||||
if (this.data.resultDisplayed) return
|
||||
const i = Number(e?.currentTarget?.dataset?.index) || 0
|
||||
const flags = (this.data.selectedFlags || []).slice()
|
||||
const required = Number(this.data.choiceRequiredCount || 0)
|
||||
@@ -1002,6 +1066,7 @@ Page<IData, IPageInstance>({
|
||||
this.handleWordClick(event as any)
|
||||
},
|
||||
selectClozeOption(e: any) {
|
||||
if (this.data.resultDisplayed) return
|
||||
const i = Number(e?.currentTarget?.dataset?.index) || 0
|
||||
this.setData({ selectedClozeIndex: i })
|
||||
},
|
||||
@@ -1020,14 +1085,44 @@ Page<IData, IPageInstance>({
|
||||
}
|
||||
},
|
||||
triggerAutoNextIfCorrect(evaluation: any, qid: string) {
|
||||
const resStr = String(evaluation?.result || '').toLowerCase()
|
||||
if (['correct', 'exact', 'exact match', '完全正确', '完全匹配'].includes(resStr)) {
|
||||
const resStr = String(evaluation?.detail || '').toLowerCase()
|
||||
if (['correct'].includes(resStr)) {
|
||||
this.fireConfetti()
|
||||
|
||||
// Check if there is a next question
|
||||
const currentIndex = this.data.currentIndex
|
||||
const total = (this.data.qaList || []).length
|
||||
if (currentIndex >= total - 1) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({ isAutoSwitching: true, nextButtonIcon: 'numbers-3' })
|
||||
|
||||
setTimeout(() => {
|
||||
const currentQ = this.data.qaList[this.data.currentIndex] || {}
|
||||
if (String(currentQ.id) !== qid) {
|
||||
this.setData({ isAutoSwitching: false, nextButtonIcon: 'chevron-right' })
|
||||
return
|
||||
}
|
||||
this.setData({ nextButtonIcon: 'numbers-2' })
|
||||
}, 1000)
|
||||
|
||||
setTimeout(() => {
|
||||
const currentQ = this.data.qaList[this.data.currentIndex] || {}
|
||||
if (String(currentQ.id) !== qid) {
|
||||
this.setData({ isAutoSwitching: false, nextButtonIcon: 'chevron-right' })
|
||||
return
|
||||
}
|
||||
this.setData({ nextButtonIcon: 'numbers-1' })
|
||||
}, 2000)
|
||||
|
||||
setTimeout(() => {
|
||||
const currentQ = this.data.qaList[this.data.currentIndex] || {}
|
||||
if (String(currentQ.id) === qid) {
|
||||
this.onNextTap()
|
||||
}
|
||||
}, 800)
|
||||
this.setData({ isAutoSwitching: false, nextButtonIcon: 'chevron-right' })
|
||||
}, 3000)
|
||||
}
|
||||
},
|
||||
async submitAttempt() {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<view class="question-content {{modeAnim}}" wx:if="{{questionMode === 'choice'}}">
|
||||
<view class="choice-title">Select the correct answer ({{selectedCount}}/{{choiceRequiredCount}})</view>
|
||||
<view class="option-list">
|
||||
<view wx:for="{{choiceOptions}}" wx:key="index" class="option-item {{evalClasses[index]}} {{(!choiceSubmitted && !selectedFlags[index] && selectedCount >= choiceRequiredCount) ? 'disabled' : ''}}" data-index="{{index}}" data-word="{{item.content}}" bindtap="selectOption" bindlongpress="onOptionLongPress">
|
||||
<view wx:for="{{choiceOptions}}" wx:key="index" class="option-item {{evalClasses[index]}} {{(!choiceSubmitted && !selectedFlags[index] && selectedCount >= choiceRequiredCount) ? 'disabled' : ''}} {{resultDisplayed ? 'disabled' : ''}}" data-index="{{index}}" data-word="{{item.content}}" bindtap="selectOption" bindlongpress="onOptionLongPress">
|
||||
<view class="option-radio">
|
||||
<view class="radio-dot {{selectedFlags[index] ? 'on' : ''}} {{(evalClasses[index] === 'opt-incorrect' && selectedFlags[index]) ? 'red' : ''}}"></view>
|
||||
</view>
|
||||
@@ -47,7 +47,7 @@
|
||||
<text class="cloze-text">{{clozeParts[1]}}</text>
|
||||
</view>
|
||||
<view class="option-list">
|
||||
<view wx:for="{{clozeOptions}}" wx:key="index" class="option-item {{evalClasses[index]}}" data-index="{{index}}" data-word="{{item}}" bindtap="selectClozeOption" bindlongpress="onOptionLongPress">
|
||||
<view wx:for="{{clozeOptions}}" wx:key="index" class="option-item {{evalClasses[index]}} {{resultDisplayed ? 'disabled' : ''}}" data-index="{{index}}" data-word="{{item}}" bindtap="selectClozeOption" bindlongpress="onOptionLongPress">
|
||||
<view class="option-radio">
|
||||
<view class="radio-dot {{selectedClozeIndex === index ? 'on' : ''}} {{(evalClasses[index] === 'opt-incorrect' && selectedClozeIndex === index) ? 'red' : ''}}"></view>
|
||||
</view>
|
||||
@@ -80,9 +80,9 @@
|
||||
<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" />
|
||||
<t-icon name="{{nextButtonIcon || 'chevron-right'}}" class="bottom-btn {{(qaList && (currentIndex >= qaList.length - 1)) ? 'disabled' : ''}}" size="48rpx" bind:tap="onNextTap" />
|
||||
</view>
|
||||
|
||||
<word-dictionary
|
||||
@@ -196,4 +196,5 @@
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<vx-confetti id="confetti" class="confetti-canvas" width="{{canvasWidth}}" height="{{canvasHeight}}"></vx-confetti>
|
||||
</view>
|
||||
|
||||
@@ -60,11 +60,11 @@
|
||||
.option-item.opt-incorrect { border-color: #e74c3c; background: #fdecea; }
|
||||
.option-item.opt-missing { border-color: #21cc80; background: #eafaf2; }
|
||||
.option-item.disabled { opacity: 0.6; }
|
||||
.option-radio { width: 36rpx; height: 36rpx; border-radius: 50%; border: 2rpx solid #cfd8e3; display: flex; align-items: center; justify-content: center; }
|
||||
.option-radio { width: 36rpx; height: 36rpx; min-width: 36rpx; min-height: 36rpx; max-width: 36rpx; max-height: 36rpx; flex: 0 0 36rpx; flex-shrink: 0; align-self: center; box-sizing: border-box; border-radius: 50%; border: 2rpx solid #cfd8e3; display: flex; align-items: center; justify-content: center; }
|
||||
.radio-dot { width: 16rpx; height: 16rpx; border-radius: 50%; background: transparent; }
|
||||
.radio-dot.on { background: #21cc80; }
|
||||
.radio-dot.red { background: #e74c3c; }
|
||||
.option-text { font-size: 30rpx; color: #001858; line-height: 44rpx; }
|
||||
.option-text { flex: 1; min-width: 0; font-size: 30rpx; color: #001858; line-height: 44rpx; }
|
||||
.cloze-sentence { display: flex; flex-wrap: wrap; gap: 8rpx; align-items: baseline; }
|
||||
.cloze-text { font-size: 40rpx; font-weight: 700; color: #001858; line-height: 56rpx; }
|
||||
.cloze-fill { font-size: 40rpx; font-weight: 700; color: #001858; line-height: 56rpx; }
|
||||
@@ -397,3 +397,13 @@
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.confetti-canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
z-index: 9999;
|
||||
pointer-events: none; /* 确保canvas不会阻挡点击事件 */
|
||||
}
|
||||
|
||||
@@ -1194,6 +1194,7 @@ Page({
|
||||
try {
|
||||
const imageId = e?.currentTarget?.dataset?.imageId;
|
||||
const thumbnailId = e?.currentTarget?.dataset?.thumbnailId;
|
||||
const type = e?.currentTarget?.dataset?.type;
|
||||
if (!imageId) return;
|
||||
const list = (this.data.selectedDateImages || []).map((img: any) => ({ ...img, more_open: false, menu_closing: false }));
|
||||
this.setData({ selectedDateImages: list }, () => {
|
||||
@@ -1202,7 +1203,7 @@ Page({
|
||||
}
|
||||
});
|
||||
wx.navigateTo({
|
||||
url: `/pages/qa_exercise/qa_exercise?image_id=${encodeURIComponent(imageId)}${thumbnailId ? ('&thumbnail_id=' + encodeURIComponent(thumbnailId)) : ''}`
|
||||
url: `/pages/qa_exercise/qa_exercise?image_id=${encodeURIComponent(imageId)}${thumbnailId ? ('&thumbnail_id=' + encodeURIComponent(thumbnailId)) : ''}${type ? ('&type=' + type) : ''}`
|
||||
});
|
||||
} catch (err) {}
|
||||
},
|
||||
|
||||
@@ -92,7 +92,8 @@
|
||||
</view>
|
||||
<view wx:if="{{image.more_open || image.menu_closing}}" class="image-more-menu {{image.menu_closing ? 'closing' : 'opening'}}">
|
||||
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{image.image_id}}">场景句型</view>
|
||||
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{image.image_id}}" data-thumbnail-id="{{image.thumbnail_file_id}}">场景练习</view>
|
||||
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{image.image_id}}" data-thumbnail-id="{{image.thumbnail_file_id}}" data-type="choice">问答练习</view>
|
||||
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{image.image_id}}" data-thumbnail-id="{{image.thumbnail_file_id}}" data-type="cloze">完形填空</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -114,7 +115,8 @@
|
||||
</view>
|
||||
<view wx:if="{{item.more_open || item.menu_closing}}" class="image-more-menu {{item.menu_closing ? 'closing' : 'opening'}}">
|
||||
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{item.image_id}}">场景句型</view>
|
||||
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}">场景练习</view>
|
||||
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="choice">问答练习</view>
|
||||
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="cloze">完形填空</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -133,7 +135,8 @@
|
||||
</view>
|
||||
<view wx:if="{{item.more_open || item.menu_closing}}" class="image-more-menu {{item.menu_closing ? 'closing' : 'opening'}}">
|
||||
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{item.image_id}}">场景句型</view>
|
||||
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}">场景练习</view>
|
||||
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="choice">问答练习</view>
|
||||
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="cloze">完形填空</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
Reference in New Issue
Block a user