add confetti

This commit is contained in:
Felix
2026-01-09 12:29:07 +08:00
parent bdf0112046
commit 78b7964860
11 changed files with 587 additions and 21 deletions

View 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);
// 如果找不到canvas200ms后重试
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();
});
});
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1 @@
<canvas type="2d" id="{{canvasId}}" style="width: {{width}}px; height: {{height}}px;"></canvas>

View File

@@ -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) : ''}`
})
},

View File

@@ -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>

View File

@@ -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"
}
}

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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不会阻挡点击事件 */
}

View File

@@ -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) {}
},

View File

@@ -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>