const { request, BASE_URL } = require('../../utils/request.js'); // 树冠挂载点(基于正方形背景图的图片坐标,百分比) // x 需落在 22~78 之间,避免竖屏 aspectFill 时被左右裁掉 const CANOPY_SLOTS = [ { x: 30, y: 30 }, { x: 36, y: 20 }, { x: 43, y: 14 }, { x: 50, y: 12 }, { x: 57, y: 15 }, { x: 64, y: 22 }, { x: 70, y: 32 }, { x: 27, y: 42 }, { x: 34, y: 38 }, { x: 42, y: 34 }, { x: 58, y: 34 }, { x: 66, y: 40 }, { x: 73, y: 48 }, { x: 27, y: 55 }, { x: 47, y: 26 }, { x: 53, y: 40 }, { x: 39, y: 48 }, { x: 61, y: 50 }, { x: 45, y: 56 }, { x: 31, y: 18 }, { x: 60, y: 26 }, { x: 68, y: 44 }, { x: 40, y: 24 }, { x: 55, y: 20 }, { x: 48, y: 44 }, { x: 35, y: 52 }, { x: 63, y: 56 }, { x: 29, y: 34 }, { x: 71, y: 40 }, { x: 52, y: 30 }, { x: 44, y: 50 }, ]; // 便签配色 const TAG_COLORS = [ { color: '#ffd700', bgColor: 'rgba(255, 224, 130, 0.92)', borderColor: 'rgba(255, 215, 0, 0.5)', shadowColor: 'rgba(255, 215, 0, 0.35)', }, { color: '#ff6b6b', bgColor: 'rgba(255, 150, 150, 0.92)', borderColor: 'rgba(255, 107, 107, 0.5)', shadowColor: 'rgba(255, 107, 107, 0.35)', }, { color: '#51cf66', bgColor: 'rgba(140, 226, 155, 0.92)', borderColor: 'rgba(81, 207, 102, 0.5)', shadowColor: 'rgba(81, 207, 102, 0.35)', }, ]; // 挂树数量上限,超出的以弹幕展示 const MAX_HANG = 30; // 伪随机(保证每次渲染一致) function seeded(i, salt) { const v = Math.sin((i + 1) * 12.9898 + salt * 78.233) * 43758.5453; return v - Math.floor(v); } Page({ data: { trees: [], currentTreeIndex: 0, currentDanmaku: [], topWishes: [], detailWish: null, fireflies: [], showCreateModal: false, wishContent: '', wishType: 'free', maxFreeLength: 20, maxPaidLength: 100, products: [], selectedProduct: null, }, onLoad() { // 屏幕尺寸,用于图片坐标到视口坐标的换算 const sys = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); this.vw = sys.windowWidth; this.vh = sys.windowHeight; // 每棵树的许愿缓存:{ treeId: { hangWishes, danmaku, top } } this.wishCache = {}; this.initAmbiance(); this.loadTrees(); this.loadProducts(); }, onUnload() { this.stopDanmakuScheduler(); }, onHide() { this.stopDanmakuScheduler(); }, onShow() { // 从后台/下一页返回时,若已有弹幕数据则恢复调度 if (this.danmakuRows && this.danmakuRows.length && !(this.danmakuTimers && this.danmakuTimers.length)) { this.startDanmakuScheduler(this.danmakuRows); } }, onPullDownRefresh() { const { currentTreeIndex, trees } = this.data; const tree = trees[currentTreeIndex]; if (tree) { this.loadTreeWishes(currentTreeIndex, true).then(() => { wx.stopPullDownRefresh(); }); } else { wx.stopPullDownRefresh(); } }, // 初始化萤火虫氛围 initAmbiance() { const fireflies = []; for (let i = 0; i < 22; i++) { fireflies.push({ left: seeded(i, 1) * 100, top: seeded(i, 2) * 100, size: 2 + seeded(i, 3) * 4, duration: 2.5 + seeded(i, 4) * 3.5, delay: seeded(i, 5) * 4, }); } this.setData({ fireflies }); }, // 图片坐标(正方形图)→ 视口坐标 // aspectFill 下竖屏会左右对称裁剪,横向需要按比例展开 transformSlot(slot) { const r = this.vh / this.vw; const x = slot.x * r - (r - 1) * 50; return { x: Math.min(96, Math.max(4, x)), y: slot.y, }; }, // 加载许愿树列表(后端提供几个就显示几个分屏) async loadTrees() { try { const res = await request({ url: '/api/wish/trees', method: 'GET' }); if (res.code === 0 && res.data.list && res.data.list.length > 0) { const trees = res.data.list.map((t) => ({ ...t, imageUrl: this.resolveImageUrl(t.image), hangWishes: [], })); this.setData({ trees }); this.loadTreeWishes(0); return; } } catch (err) { console.error('加载许愿树失败:', err); } // 兜底:单棵本地树 this.setData({ trees: [{ id: 1, name: '祈福树', imageUrl: '/images/wish-tree.jpg', hangWishes: [], }], }); this.loadTreeWishes(0); }, // 解析树背景图地址(相对路径拼接服务器域名) resolveImageUrl(image) { if (!image) return '/images/wish-tree.jpg'; if (/^https?:\/\//.test(image)) return image; return BASE_URL + image; }, // 分屏切换 onSwiperChange(e) { const index = e.detail.current; this.setData({ currentTreeIndex: index }); const tree = this.data.trees[index]; if (!tree) return; const cache = this.wishCache[tree.id]; if (cache) { // 已加载过,直接展示缓存 this.applyTreeWishes(index, cache); } else { this.loadTreeWishes(index); } }, // 加载指定树的许愿(默认100条) async loadTreeWishes(index, force) { const trees = this.data.trees; const tree = trees[index]; if (!tree) return; if (!force && this.wishCache[tree.id]) { this.applyTreeWishes(index, this.wishCache[tree.id]); return; } let list = []; try { const res = await request({ url: `/api/wish/tree/${tree.id}/wishes`, method: 'GET', }); if (res.code === 0) { list = res.data.list || []; } } catch (err) { console.error('加载许愿失败:', err); list = this.getMockWishes(); } // 按时间倒序(新数据在前) list.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)); // 顶级位置轮播 const top = list.filter((w) => w.isTop).map((w) => this.formatWish(w, 0)); // 最新30条挂树 const hangWishes = list.slice(0, MAX_HANG).map((w, i) => { const wish = this.formatWish(w, i); const slot = this.transformSlot(this.pickSlot(i)); return { ...wish, x: slot.x, y: slot.y, swayDuration: 3.4 + (i % 5) * 0.6, swayDelay: (i % 7) * 0.35, isNew: Date.now() - new Date(w.createdAt || 0).getTime() < 60000, }; }); // 其余以弹幕飘过:树桩处3行 + 置顶轮播下方1行,慢速均匀间隔不重叠 const danmaku = this.buildDanmaku(list.slice(MAX_HANG)); const cache = { hangWishes, danmaku, top }; this.wishCache[tree.id] = cache; this.applyTreeWishes(index, cache); }, // 应用某棵树的许愿数据到视图 applyTreeWishes(index, cache) { this.setData({ [`trees[${index}].hangWishes`]: cache.hangWishes, topWishes: cache.top, }); // 启动弹幕调度:每行一条完毕后再出下一条 this.startDanmakuScheduler(cache.danmaku); }, // 启动弹幕调度:每行一个发射定时器,前一条完全进入屏幕后就出下一条, // 同一行屏上保持 2~3 条,首尾相接不压着;各行速度不同、同行弹幕速度也错落 startDanmakuScheduler(rows) { this.stopDanmakuScheduler(); this.danmakuRows = rows || []; this.danmakuTimers = []; this.danmakuSeq = 0; // 全局自增序号,保证每条 key 唯一 if (!this.danmakuRows.length) { this.setData({ currentDanmaku: [] }); return; } // 屏上目标条数:同一行同时约 2.5 条 const ON_SCREEN = 2.5; // 弹幕完全进入屏幕约需飘过总路程的 30%(不压着前一条的最小间隔) const ENTER_RATIO = 0.3; const MIN_INTERVAL = 3; // 同一行相邻两条最小间隔(秒) // 往某行发射一条弹幕,返回该条的 duration(供调度下一条用) const emit = (row, idx) => { const rowData = this.danmakuRows[row]; if (!rowData) return 0; const item = rowData.list[idx % rowData.list.length]; const seq = ++this.danmakuSeq; const danmaku = { key: `d${seq}`, id: item.id, content: item.content, author: item.author, type: item.type, top: rowData.top, duration: item.duration, }; // 追加到屏上弹幕列表(构造新数组,避免索引冲突) this.setData({ currentDanmaku: this.data.currentDanmaku.concat(danmaku) }); // 动画播完(飘到屏外)后,把这条从列表移除 const removeTimer = setTimeout(() => { const list = this.data.currentDanmaku.filter((d) => d.key !== danmaku.key); this.setData({ currentDanmaku: list }); }, item.duration * 1000); this.danmakuTimers.push(removeTimer); return item.duration; }; // 每行独立调度:按刚发射这条的速度决定下一条间隔,循环 this.danmakuRows.forEach((rowData, row) => { let idx = 0; const loop = () => { const duration = emit(row, idx); idx += 1; // 下一条间隔:屏上保持约 2.5 条(duration/2.5), // 但不小于“完全进入”时间(duration*0.3,不压着)和最小间隔 const interval = Math.max(duration / ON_SCREEN, duration * ENTER_RATIO, MIN_INTERVAL); const timer = setTimeout(loop, interval * 1000); this.danmakuTimers.push(timer); }; // 各行错开启动,避免 4 行同时从右侧涌出 const startTimer = setTimeout(loop, row * 1500); this.danmakuTimers.push(startTimer); }); }, // 停止弹幕调度 stopDanmakuScheduler() { if (this.danmakuTimers) { this.danmakuTimers.forEach((t) => clearTimeout(t)); this.danmakuTimers = []; } // 清空屏上弹幕,避免恢复时残留未播完的项 if (this.data.currentDanmaku && this.data.currentDanmaku.length) { this.setData({ currentDanmaku: [] }); } }, // 构建弹幕:共 4 行(行 0-2 树桩处三行,行 3 顶部置顶轮播下方) // 每行速度不同、同行弹幕速度也带随机错落,避免太整齐;前一条完全进入后出下一条 buildDanmaku(list) { // 行位置(屏幕百分比): // - 行3 顶部行紧贴置顶轮播下方(稍有空隙) // - 行0-2 树桩处三行,第一条70%,三行紧凑(间隔3%) const ROW_TOPS = [70, 73, 76, 12]; // 每行基准速度(秒/次):各行不同,避免整齐划一 const ROW_BASE_DURATION = [26, 33, 40, 22]; // 轮流分配到 4 行 const rows = [[], [], [], []]; list.forEach((w, i) => { rows[i % 4].push(w); }); // 每行存该行的所有弹幕(带位置/时长),供定时器轮流播放 return rows.map((rowList, row) => ({ top: ROW_TOPS[row], list: rowList.map((w, i) => ({ id: w.id, content: w.content, author: w.author || '匿名', type: w.type, // 同行弹幕速度也带 ±20% 错落(seeded 保证每次渲染一致) duration: Math.round(ROW_BASE_DURATION[row] * (0.8 + seeded(w.id != null ? w.id : i, row + 7) * 0.4)), })), })).filter((r) => r.list.length > 0); }, // 格式化许愿 formatWish(w, i) { const colors = TAG_COLORS[i % TAG_COLORS.length]; return { id: w.id, content: w.content, author: w.author || '匿名', type: w.type, isTop: !!w.isTop, dateStr: this.formatDate(w.createdAt), ...colors, }; }, // 选择挂载点(循环复用,复用时加抖动) pickSlot(index) { const base = CANOPY_SLOTS[index % CANOPY_SLOTS.length]; const wrap = Math.floor(index / CANOPY_SLOTS.length); if (wrap === 0) return base; const jitterX = ((wrap * 37) % 7) - 3; const jitterY = ((wrap * 53) % 7) - 3; return { x: Math.min(75, Math.max(25, base.x + jitterX)), y: Math.min(60, Math.max(10, base.y + jitterY)), }; }, // 日期格式化 formatDate(t) { if (!t) return ''; const d = new Date(t); const pad = (n) => (n < 10 ? '0' + n : '' + n); return `${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`; }, // 模拟数据(开发调试用,60条以验证弹幕效果) getMockWishes() { const texts = [ '愿家人平安喜乐,身体健康', '希望今年考研上岸,一战成硕', '愿世界温柔以待每一个努力的人', '早日遇见那个对的人', '祝爸妈身体硬朗,笑口常开', '愿所求皆如愿,所行皆坦途', '希望新工作顺顺利利', '愿此生尽兴,赤诚善良', '愿代码零bug,一次通过', '希望减肥成功,越来越好看', '愿所有付出都有回报', '希望今年能去一次海边', ]; const authors = ['小满', '阿远', '林深', '拾光', '念念', '白露', '子夜', '青禾', '小测', '桃桃', '余涵', '奕']; const list = []; for (let i = 0; i < 60; i++) { list.push({ id: i + 1, content: texts[i % texts.length], author: authors[i % authors.length], type: i % 5 === 0 ? 'paid' : 'free', position: i === 0 ? 100 : 0, isTop: i === 0, createdAt: new Date(Date.now() - i * 3600 * 1000).toISOString(), }); } return list; }, // 查看便签详情 openDetail(e) { const wish = e.currentTarget.dataset.wish; if (!wish) return; this.setData({ detailWish: wish }); }, closeDetail() { this.setData({ detailWish: null }); }, // 加载许愿商品 async loadProducts() { try { const res = await request({ url: '/api/wish/products', method: 'GET' }); if (res.code === 0) { this.setData({ products: res.data.list || [] }); return; } } catch (err) { console.error('加载商品失败:', err); } this.setData({ products: [ { id: 1, name: '普通许愿条', price: 100, duration: 7 }, { id: 2, name: '精品许愿条', price: 500, duration: 30 }, { id: 3, name: '至尊许愿条', price: 2000, duration: 90 }, ], }); }, // 打开/关闭许愿弹窗 openCreateModal() { this.setData({ showCreateModal: true }); }, closeCreateModal() { this.setData({ showCreateModal: false, wishContent: '', wishType: 'free', selectedProduct: null, }); }, onInputContent(e) { this.setData({ wishContent: e.detail.value }); }, selectType(e) { this.setData({ wishType: e.currentTarget.dataset.type }); }, selectProduct(e) { const id = e.currentTarget.dataset.id; const product = this.data.products.find((p) => p.id === id); this.setData({ selectedProduct: product }); }, // 当前树ID currentTreeId() { const { trees, currentTreeIndex } = this.data; const tree = trees[currentTreeIndex]; return tree ? tree.id : 1; }, // 提交许愿 async submitWish() { const { wishContent, wishType, selectedProduct, maxFreeLength, maxPaidLength } = this.data; if (!wishContent.trim()) { wx.showToast({ title: '请输入许愿内容', icon: 'none' }); return; } const maxLength = wishType === 'free' ? maxFreeLength : maxPaidLength; if (wishContent.length > maxLength) { wx.showToast({ title: `最多输入${maxLength}个字`, icon: 'none' }); return; } if (wishType === 'paid' && !selectedProduct) { wx.showToast({ title: '请选择许愿商品', icon: 'none' }); return; } try { if (wishType === 'paid') { const orderRes = await request({ url: '/api/pay/create', method: 'POST', data: { type: 'wish', productId: selectedProduct.id, content: wishContent, treeId: this.currentTreeId(), }, }); if (orderRes.code === 0) { await this.wxPay(orderRes.data); } } else { const res = await request({ url: '/api/wish/create', method: 'POST', data: { content: wishContent, type: 'free', treeId: this.currentTreeId(), }, }); if (res.code === 0) { wx.showToast({ title: '许愿成功', icon: 'success' }); this.closeCreateModal(); this.loadTreeWishes(this.data.currentTreeIndex, true); } } } catch (err) { console.error('许愿失败:', err); wx.showToast({ title: '许愿失败,请重试', icon: 'none' }); } }, // 微信支付 async wxPay(orderData) { return new Promise((resolve, reject) => { wx.requestPayment({ timeStamp: orderData.timeStamp, nonceStr: orderData.nonceStr, package: orderData.package, signType: orderData.signType, paySign: orderData.paySign, success: () => { wx.showToast({ title: '支付成功', icon: 'success' }); this.closeCreateModal(); this.loadTreeWishes(this.data.currentTreeIndex, true); resolve(); }, fail: (err) => { console.error('支付失败:', err); wx.showToast({ title: '支付失败', icon: 'none' }); reject(err); }, }); }); }, // 分享 onShareAppMessage() { return { title: '快来许愿树许下你的愿望吧!', path: '/pages/wish-tree/wish-tree', }; }, onShareTimeline() { return { title: '快来许愿树许下你的愿望吧!' }; } });