Publish Mini Program Dev Version / publish (push) Successful in 1m3s
- 顶部行 12%→14%,树桩3行 68/73/78→70/73/76(间隔3%) - 核心:从'一行同一时刻只一条'改为'前一条完全进入屏幕后就出下一条' 同一时刻屏上可有多条,但首尾相接不重叠,更接近真实弹幕 - 每行发射定时器间隔=duration*0.35(完全进入屏幕约需飘总路程35%) - 弹幕播完后从列表移除,stop时清空屏上弹幕避免残留
576 lines
17 KiB
JavaScript
576 lines
17 KiB
JavaScript
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);
|
|
},
|
|
|
|
// 启动弹幕调度:每行一个发射定时器,前一条完全进入屏幕后就出下一条,
|
|
// 同一时刻屏上可有多条,但首尾相接不重叠(自然弹幕效果)
|
|
startDanmakuScheduler(rows) {
|
|
this.stopDanmakuScheduler();
|
|
this.danmakuRows = rows || [];
|
|
this.danmakuTimers = [];
|
|
this.danmakuSeq = 0; // 全局自增序号,保证每条 key 唯一
|
|
|
|
if (!this.danmakuRows.length) {
|
|
this.setData({ currentDanmaku: [] });
|
|
return;
|
|
}
|
|
|
|
// 弹幕完全进入屏幕大约需要飘过总路程的 35%(从右屏外到尾部越过右边缘)
|
|
// 此时下一条即可开始从右侧进入,与前一条首尾相接不重叠
|
|
const ENTER_RATIO = 0.35;
|
|
const MIN_INTERVAL = 4; // 同一行相邻两条最小间隔(秒),避免过密
|
|
|
|
// 往某行发射一条弹幕
|
|
const emit = (row, idx) => {
|
|
const rowData = this.danmakuRows[row];
|
|
if (!rowData) return;
|
|
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: rowData.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 });
|
|
}, rowData.duration * 1000);
|
|
this.danmakuTimers.push(removeTimer);
|
|
};
|
|
|
|
// 每行独立调度:每隔 interval 秒发一条,循环
|
|
this.danmakuRows.forEach((rowData, row) => {
|
|
const interval = Math.max(rowData.duration * ENTER_RATIO, MIN_INTERVAL);
|
|
let idx = 0;
|
|
const loop = () => {
|
|
emit(row, idx);
|
|
idx += 1;
|
|
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 顶部置顶轮播下方)
|
|
// 按行分组返回,由 JS 定时器控制:前一条完全进入屏幕后就出下一条,首尾相接不重叠
|
|
buildDanmaku(list) {
|
|
const DURATION_BOTTOM = 30; // 树桩处一条飘完全屏的秒数(慢速)
|
|
const DURATION_TOP = 24; // 顶部行稍快一点
|
|
// 行位置(屏幕百分比):
|
|
// - 行3 顶部行紧贴置顶轮播下方(稍有空隙)
|
|
// - 行0-2 树桩处三行,第一条70%,三行紧凑(间隔3%)
|
|
const ROW_TOPS = [70, 73, 76, 14];
|
|
const ROW_DURATIONS = [DURATION_BOTTOM, DURATION_BOTTOM, DURATION_BOTTOM, DURATION_TOP];
|
|
|
|
// 轮流分配到 4 行
|
|
const rows = [[], [], [], []];
|
|
list.forEach((w, i) => {
|
|
rows[i % 4].push(w);
|
|
});
|
|
|
|
// 每行存该行的所有弹幕(带位置/时长),供定时器轮流播放
|
|
return rows.map((rowList, row) => ({
|
|
top: ROW_TOPS[row],
|
|
duration: ROW_DURATIONS[row],
|
|
list: rowList.map((w) => ({
|
|
id: w.id,
|
|
content: w.content,
|
|
author: w.author || '匿名',
|
|
type: w.type,
|
|
})),
|
|
})).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: '快来许愿树许下你的愿望吧!'
|
|
};
|
|
}
|
|
});
|