Files
王鹏 2c47fb8f65 feat: 完善见素起名小程序功能
- 添加收藏锦囊功能,支持查看和删除收藏
- 实现积分系统,每日赠送5次灵感次数
- 添加静心阅读功能,阅读15秒可获得额外次数
- 实现灵感广场,展示用户分享的名字
- 添加字源溯源组件,长按汉字查看详情
- 优化空状态和结语卡片样式统一
- 添加音频控制(静音/风铃/雨落/古琴/白噪音/森林/溪流)
- 优化名字生成逻辑,确保每次返回5个不重复名字
- 修复卡片翻转样式问题
- 移除首页动态提醒气泡
2026-04-18 16:56:31 +08:00

895 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const EXIT_THRESHOLD = 80;
const FAST_SWIPE_VELOCITY = 0.5;
// 加载文案库
const LOADING_QUOTES = [
"正在翻阅《古今集成》...",
"于诗书中寻觅意境...",
"聆听平仄的韵律...",
"在字里行间游走...",
"采撷一缕清风入名...",
"品味楚辞的芬芳...",
"捕捉诗经的灵光...",
"与古人隔空对话..."
];
Page({
isDragging: false,
isExiting: false,
startTime: 0,
startX: 0,
startY: 0,
lastX: 0,
loadingQuoteTimer: null,
data: {
nameList: [],
currentIndex: 0,
isLoading: true,
isFlipped: false,
keyword: '清冷',
mode: 'classic',
modeName: '拾遗',
surname: '',
loadingQuote: '正在翻阅《古今集成》...',
// 动画控制
translateX: 0,
translateY: 0,
rotate: 0,
opacity: 1,
transition: 'none',
cardKey: 0,
collectedNames: [],
showCollection: false,
// 海报相关
showPoster: false,
posterNameChars: [],
// 音频相关
showMusicMenu: false,
currentAmbience: 'silent',
// 字源弹窗
showCharDetail: false,
selectedChar: '',
charDetailData: null,
// AI 解析弹窗
showAIModal: false,
aiContext: '',
aiExplanation: '',
aiLoading: false,
// 结语卡片
showEndingCard: false
},
onLoad(options) {
// 解码 URL 参数
const keyword = options.keyword ? decodeURIComponent(options.keyword) : this.data.keyword;
const mode = options.mode || 'classic';
const surname = options.surname ? decodeURIComponent(options.surname) : '';
// 模式名称映射
const modeNameMap = {
'baby': '宝宝',
'persona': '人设',
'classic': '拾遗'
};
this.setData({
keyword,
mode,
surname,
modeName: modeNameMap[mode] || '拾遗',
isLoading: true
});
// 启动文案轮换
this.startLoadingQuoteRotation();
// 分批加载先请求2个再静默加载剩余
this.fetchNamesBatch(keyword, mode, surname);
},
onUnload() {
// 清理定时器
if (this.loadingQuoteTimer) {
clearInterval(this.loadingQuoteTimer);
this.loadingQuoteTimer = null;
}
},
onShow() {
// 每次显示页面时加载收藏列表
this.loadCollectedNames();
},
// 从后端加载收藏列表
loadCollectedNames() {
const openid = getApp().getOpenid();
if (!openid) {
console.log('openid 未获取到,跳过加载收藏');
return;
}
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/favorites/list`,
data: { openid },
success: (res) => {
if (res.data && res.data.success) {
this.setData({ collectedNames: res.data.data || [] });
}
}
});
},
// 启动加载文案轮换
startLoadingQuoteRotation() {
let index = 0;
this.loadingQuoteTimer = setInterval(() => {
index = (index + 1) % LOADING_QUOTES.length;
this.setData({ loadingQuote: LOADING_QUOTES[index] });
}, 2000);
},
// 分批加载名字
fetchNamesBatch(keyword, mode, surname) {
// 第一波请求2个名字快速展示
this.fetchFirstBatch(keyword, mode, surname, 2);
},
// 第一波快速获取前5个名字
fetchFirstBatch(keyword, mode, surname, count) {
const apiBaseUrl = getApp().globalData.apiBaseUrl;
const openid = getApp().getOpenid();
// 获取已生成的名字列表(用于去重)
const existingNames = this.data.nameList.map(card => card.name).join(',');
const requestData = {
keyword,
count: 5,
batch: 'first'
};
if (mode) requestData.mode = mode;
if (surname) requestData.surname = surname;
if (openid) requestData.openid = openid;
if (existingNames) requestData.excludeNames = existingNames;
wx.request({
url: `${apiBaseUrl}/api/names/generate`,
data: requestData,
success: (res) => {
// 检查是否积分不足
if (res.data && !res.data.success) {
wx.showToast({ title: res.data.message || '次数已用完', icon: 'none' });
this.setData({ isLoading: false });
return;
}
const nameList = res.data && res.data.data ? res.data.data : res.data;
if (res.statusCode === 200 && nameList && nameList.length > 0) {
// 清除定时器
if (this.loadingQuoteTimer) {
clearInterval(this.loadingQuoteTimer);
this.loadingQuoteTimer = null;
}
// 立即展示结果5个名字
this.setData({
nameList: nameList,
currentIndex: 0,
isLoading: false,
cardKey: this.data.cardKey + 1
});
// 呼吸震动效果 - 名字逐字显现时的节奏震动
this.breatheVibrate(nameList[0].name);
console.log('已加载 5 个不重复名字:', nameList.map(n => n.name).join(', '));
} else {
this.handleFetchError('意境未达,请重试');
}
},
fail: () => {
this.handleFetchError('网络疏离,请检查后端');
}
});
},
// 处理加载错误 - 显示结语卡片
handleFetchError(message) {
if (this.loadingQuoteTimer) {
clearInterval(this.loadingQuoteTimer);
this.loadingQuoteTimer = null;
}
this.setData({
isLoading: false,
showEndingCard: true
});
},
// 返回首页
onBack() {
wx.navigateBack({
success: () => {
// 返回成功后,首页的 onShow 会重置状态
}
});
},
// 点击不喜欢按钮
onDislike() {
if (this.isExiting) return;
this.isExiting = true;
getApp().playAudio('swipe');
this.setData({
translateX: -500,
opacity: 0,
transition: 'all 0.3s cubic-bezier(0.6, -0.28, 0.735, 0.045)'
});
},
// 点击喜欢按钮
onLike() {
if (this.isExiting) return;
this.handleLike();
this.isExiting = true;
this.setData({
translateX: 500,
opacity: 0,
transition: 'all 0.3s cubic-bezier(0.6, -0.28, 0.735, 0.045)'
});
},
onFlip() {
// 守卫如果这是一次拖拽isDragging 会为 true则不执行翻转
if (this.isDragging || this.isExiting) return;
getApp().playAudio('flip');
this.setData({ isFlipped: !this.data.isFlipped });
},
onTouchStart(e) {
if (this.isExiting) return;
this.isDragging = false; // 每次开始触摸时,都假定为点击,而非拖拽
this.startX = e.touches[0].clientX;
this.lastX = this.startX;
this.startY = e.touches[0].clientY;
this.startTime = Date.now();
this.setData({ transition: 'none' });
},
onTouchMove(e) {
if (this.isExiting) return;
const deltaX = e.touches[0].clientX - this.startX;
const deltaY = e.touches[0].clientY - this.startY;
// 检测上划进入广场在Y轴移动超过X轴且向上滑动
if (Math.abs(deltaY) > Math.abs(deltaX) && deltaY < -50 && !this.isDragging) {
this.isDragging = true;
this.isSwipingUp = true;
this.setData({
translateY: deltaY,
opacity: 1 - Math.abs(deltaY) / 400,
transition: 'none'
});
return;
}
// 只有移动超过5px才真正判定为"拖拽"
if (Math.abs(deltaX) > 5) {
this.isDragging = true;
}
// 如果不是拖拽,则不进行任何移动
if (!this.isDragging) return;
this.lastX = e.touches[0].clientX;
const rotate = deltaX * 0.05;
const opacity = 1 - Math.abs(deltaX) / 200;
this.setData({
translateX: deltaX,
rotate: rotate,
opacity: opacity
});
},
onTouchEnd() {
if (this.isExiting) return;
// 如果不是拖拽(即这是一次纯点击),则 onTouchEnd 不执行任何操作,交由 onFlip 处理
if (!this.isDragging) {
return;
}
// 处理上划进入广场
if (this.isSwipingUp) {
const deltaY = this.data.translateY;
if (Math.abs(deltaY) > 150) {
// 上划距离足够,进入广场
this.isExiting = true;
this.setData({
translateY: -800,
opacity: 0,
transition: 'all 0.4s cubic-bezier(0.6, -0.28, 0.735, 0.045)'
});
// 延迟后跳转
setTimeout(() => {
this.onGoToSquare();
// 重置状态
this.setData({
translateY: 0,
opacity: 1,
transition: 'none'
});
this.isExiting = false;
this.isDragging = false;
this.isSwipingUp = false;
}, 400);
} else {
// 上划距离不够,回弹
this.setData({
translateY: 0,
opacity: 1,
transition: 'all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
});
setTimeout(() => {
this.isDragging = false;
this.isSwipingUp = false;
}, 100);
}
return;
}
// 以下是拖拽结束后的逻辑(左右滑动)
const deltaX = this.lastX - this.startX;
const deltaTime = Date.now() - this.startTime;
const velocity = deltaX / deltaTime;
const shouldExit = Math.abs(deltaX) > EXIT_THRESHOLD || Math.abs(velocity) > FAST_SWIPE_VELOCITY;
if (shouldExit) {
this.isExiting = true;
const direction = deltaX > 0 ? 1 : -1;
const targetX = direction * 500;
// 播放滑动音效
getApp().playAudio('swipe');
// 触觉反馈
wx.vibrateShort({ type: 'light' });
this.setData({
translateX: targetX,
opacity: 0,
transition: 'all 0.3s cubic-bezier(0.6, -0.28, 0.735, 0.045)'
});
} else {
this.setData({
translateX: 0,
rotate: 0,
opacity: 1,
transition: 'all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
});
}
// 延迟重置 isDragging 标志位。
// 这是为了确保在系统触发 tap 事件时isDragging 标志仍然为 true
// 从而让 onFlip 方法可以正确地忽略掉这次由拖拽产生的 tap。
setTimeout(() => {
this.isDragging = false;
}, 100);
},
onTransitionEnd() {
if (!this.isExiting) return;
const direction = this.data.translateX > 0 ? 'like' : 'dislike';
if (direction === 'like') {
this.handleLike();
}
this.handleCardExit(direction);
},
handleLike() {
// 触觉反馈 - 中等震动
wx.vibrateShort({ type: 'medium' });
// 音效反馈
getApp().playAudio('success');
const card = this.data.nameList[this.data.currentIndex];
if (!this.data.collectedNames.some(item => item.name === card.name)) {
this.setData({ collectedNames: [...this.data.collectedNames, card] });
// 同步到后端
this.syncFavoriteToBackend(card);
}
},
// 同步收藏到后端
syncFavoriteToBackend(card) {
const openid = getApp().getOpenid();
if (!openid) {
console.log('openid 未获取到,无法同步收藏');
return;
}
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/favorites/add?openid=${openid}&mode=${this.data.mode}&keyword=${encodeURIComponent(this.data.keyword)}`,
method: 'POST',
header: {
'Content-Type': 'application/json'
},
data: {
name: card.name,
origin: card.origin,
description: card.description,
tone: card.tone,
score: card.score
},
success: (res) => {
if (res.data && res.data.success) {
console.log('收藏同步成功');
} else {
console.log('收藏同步失败:', res.data);
}
},
fail: (err) => {
console.log('收藏同步失败:', err);
}
});
},
handleCardExit() {
const nextIndex = this.data.currentIndex + 1;
wx.nextTick(() => {
this.setData({
transition: 'none',
translateX: 0,
rotate: 0,
opacity: 1,
isFlipped: false
}, () => {
if (nextIndex < this.data.nameList.length) {
this.setData({ currentIndex: nextIndex, cardKey: this.data.cardKey + 1 });
} else {
// 检查是否还有剩余次数
this.checkAndLoadMore();
}
this.isExiting = false;
});
});
},
// 检查是否还有次数,有则自动加载,无则显示结语卡片
checkAndLoadMore() {
const openid = getApp().getOpenid();
if (!openid) {
this.setData({ showEndingCard: true });
return;
}
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/credits/info`,
data: { openid },
success: (res) => {
if (res.data && res.data.success) {
const creditsInfo = res.data.data;
if (creditsInfo.dailyCredits > 0) {
// 还有次数,自动加载新的一批
console.log('还有次数,自动加载新的一批');
this.setData({ isLoading: true });
this.startLoadingQuoteRotation();
this.fetchFirstBatch(this.data.keyword, this.data.mode, this.data.surname, 5);
} else {
// 次数用完,显示结语卡片
this.setData({ showEndingCard: true });
}
} else {
this.setData({ showEndingCard: true });
}
},
fail: () => {
this.setData({ showEndingCard: true });
}
});
},
toggleCollectionView() {
// 跳转到 profile 页面
wx.navigateTo({
url: '/pages/profile/profile'
});
},
// 跳转到灵感广场
onGoToSquare() {
wx.navigateTo({
url: '/pages/square/square'
});
},
onDeleteCollected(e) {
const nameToDelete = e.currentTarget.dataset.name;
this.setData({
collectedNames: this.data.collectedNames.filter(item => item.name !== nameToDelete)
});
wx.vibrateShort({ type: 'light' });
},
// 显示海报弹窗
onSavePoster() {
const card = this.data.nameList[this.data.currentIndex];
const nameChars = card.name.split('');
this.setData({
showPoster: true,
posterNameChars: nameChars
});
// 触觉反馈
wx.vibrateShort({ type: 'light' });
},
// 关闭海报弹窗
closePoster() {
this.setData({ showPoster: false });
},
// 保存海报到相册
savePoster() {
wx.showLoading({ title: '绘笔收录中...', mask: true });
// 使用 snapshot 组件截图
wx.nextTick(() => {
const query = wx.createSelectorQuery().in(this);
query.select('#posterArea')
.node()
.exec((res) => {
const node = res[0].node;
// 使用 snapshot 截图
wx.canvasToTempFilePath({
x: 0,
y: 0,
width: node.width,
height: node.height,
destWidth: node.width * 2,
destHeight: node.height * 2,
canvasId: 'posterCanvas',
success: (res) => {
wx.hideLoading();
wx.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
wx.showToast({ title: '已保存到相册', icon: 'success' });
},
fail: () => {
wx.showToast({ title: '保存失败', icon: 'none' });
}
});
},
fail: () => {
wx.hideLoading();
wx.showToast({ title: '绘笔受阻', icon: 'none' });
}
});
});
});
},
// 分享海报
sharePoster() {
wx.showShareImageMenu({
path: '/images/share.png'
});
},
// 分享到灵感广场
shareToSquare() {
const card = this.data.nameList[this.data.currentIndex];
const openid = getApp().getOpenid();
if (!openid) {
wx.showToast({ title: '请先登录', icon: 'none' });
return;
}
wx.showLoading({ title: '分享中...', mask: true });
const apiBaseUrl = getApp().globalData.apiBaseUrl;
// 直接提交到灵感广场(不上传图片,使用默认图片)
// 将数据转换为 URL 编码格式
const formData = `name=${encodeURIComponent(card.name)}&origin=${encodeURIComponent(card.origin)}&description=${encodeURIComponent(card.description)}&keyword=${encodeURIComponent(this.data.keyword)}&mode=${encodeURIComponent(this.data.mode)}&openid=${encodeURIComponent(openid)}&imageUrl=`;
wx.request({
url: `${apiBaseUrl}/api/square/posts`,
method: 'POST',
header: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: formData,
success: (res) => {
wx.hideLoading();
if (res.data && res.data.success) {
wx.showToast({ title: '分享成功', icon: 'success' });
this.setData({ showPoster: false });
} else {
wx.showToast({ title: res.data.message || '分享失败', icon: 'none' });
}
},
fail: () => {
wx.hideLoading();
wx.showToast({ title: '分享失败', icon: 'none' });
}
});
},
// 阻止事件冒泡
preventBubble() {
// 什么都不做,只是阻止事件冒泡
},
// 点击音符按钮
onMusicTap() {
this.setData({
showMusicMenu: !this.data.showMusicMenu
});
},
// 阻止菜单冒泡
onMenuTap() {
// 什么都不做,只是阻止事件冒泡
},
// 切换环境音
onAmbienceChange(e) {
const type = e.currentTarget.dataset.type;
const { currentAmbience } = this.data;
// 如果点击当前正在播放的类型,则切换到静音
const newType = (type === currentAmbience) ? 'silent' : type;
this.setData({
currentAmbience: newType,
showMusicMenu: false
});
// 播放或停止环境音
const { playAmbience, fadeOutAndStop } = require('../../utils/audio.js');
if (newType === 'silent') {
fadeOutAndStop();
wx.showToast({ title: '已静音', icon: 'none' });
} else {
playAmbience(newType, true);
const typeName = { wind: '风铃', rain: '雨落', guqin: '古琴' }[newType];
wx.showToast({ title: `正在播放:${typeName}`, icon: 'none' });
}
},
// 长按名字显示字源弹窗
onNameLongPress() {
const currentName = this.data.nameList[this.data.currentIndex];
if (!currentName || !currentName.name) return;
// 默认显示名字的第一个字
const firstChar = currentName.name.charAt(0);
this.showCharDetail(firstChar);
// 触觉反馈
wx.vibrateLong();
},
// 显示字源详情
showCharDetail(char) {
// 播放水滴声(入墨效果)
const { playSoundEffect } = require('../../utils/audio.js');
playSoundEffect('inkDrop', { volume: 0.5 });
this.setData({
showCharDetail: true,
selectedChar: char,
charDetailData: null
});
// 加载字源数据
this.loadCharDetail(char);
},
// 加载字源数据
loadCharDetail(char) {
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/char/detail`,
data: { char },
success: (res) => {
if (res.data && res.data.success) {
this.setData({ charDetailData: res.data.data });
}
},
fail: () => {
// 使用本地静态数据作为备选
this.setData({ charDetailData: this.getLocalCharData(char) });
}
});
},
// 本地静态字源数据(备选)
getLocalCharData(char) {
const charDB = {
'清': {
char: '清',
pinyin: 'qīng',
radical: '氵',
strokes: 11,
wuxing: '水',
meaning: '水清澈透明,引申为纯净、高洁、明白',
imagery: '清澈如泉,心境澄明。寓意纯净通透、高洁自持,如清水出芙蓉,天然去雕饰。',
poetry: '「清水出芙蓉,天然去雕饰」——李白'
},
'雅': {
char: '雅',
pinyin: 'yǎ',
radical: '隹',
strokes: 12,
wuxing: '木',
meaning: '正也,规范、美好、高尚',
imagery: '温文尔雅,气度不凡。寓意举止得体、品味高雅,如芝兰玉树,生于庭阶。',
poetry: '「雅步擢纤腰,巧笑发皓齿」——曹植'
},
'若': {
char: '若',
pinyin: 'ruò',
radical: '艹',
strokes: 8,
wuxing: '木',
meaning: '如、像,引申为顺从、选择',
imagery: '虚怀若谷,温润如玉。寓意谦逊包容、从容自在,如兰若生春阳。',
poetry: '「若有人兮山之阿,被薜荔兮带女萝」——屈原'
},
'溪': {
char: '溪',
pinyin: 'xī',
radical: '氵',
strokes: 13,
wuxing: '水',
meaning: '山间小河,水流清澈',
imagery: '溪水潺潺,源远流长。寓意灵动清澈、生生不息,如溪水绕石,柔中带刚。',
poetry: '「旧时茅店社林边,路转溪桥忽见」——辛弃疾'
},
'云': {
char: '云',
pinyin: 'yún',
radical: '二',
strokes: 4,
wuxing: '水',
meaning: '水气上升凝结,引申为说、众多',
imagery: '云卷云舒,自在飘逸。寓意超然物外、洒脱不羁,如行云流水,任意西东。',
poetry: '「行到水穷处,坐看云起时」——王维'
},
'月': {
char: '月',
pinyin: 'yuè',
radical: '月',
strokes: 4,
wuxing: '木',
meaning: '月亮,引申为月份、光明',
imagery: '明月清风,皎洁无瑕。寓意光明磊落、清雅高洁,如月出东山,照彻千里。',
poetry: '「举杯邀明月,对影成三人」——李白'
}
};
return charDB[char] || {
char: char,
pinyin: '',
radical: '',
strokes: '',
wuxing: '',
meaning: '暂无详细解析',
imagery: '暂无意象分析',
poetry: ''
};
},
// 关闭字源弹窗
closeCharDetail() {
this.setData({
showCharDetail: false,
selectedChar: '',
charDetailData: null
});
},
// 呼吸震动 - 名字逐字显现时的节奏震动
breatheVibrate(name) {
if (!name || name.length === 0) return;
const chars = name.split('');
let index = 0;
// 每 300ms 震动一次,模拟呼吸节奏
const vibrateInterval = setInterval(() => {
if (index >= chars.length) {
clearInterval(vibrateInterval);
return;
}
// 极微弱的震动,营造意境
wx.vibrateShort({ type: 'light' });
// 播放字显现音效
const { playSoundEffect } = require('../../utils/audio.js');
playSoundEffect('char', { volume: 0.3 });
index++;
}, 300);
},
// 打开 AI 解析弹窗
onAskAI(e) {
e.stopPropagation();
this.setData({
showAIModal: true,
aiContext: '',
aiExplanation: ''
});
},
// 关闭 AI 解析弹窗
closeAIModal() {
this.setData({
showAIModal: false,
aiContext: '',
aiExplanation: ''
});
},
// AI 上下文输入
onAIContextInput(e) {
this.setData({ aiContext: e.detail.value });
},
// 请求 AI 解析
requestAIExplanation() {
const currentName = this.data.nameList[this.data.currentIndex];
if (!currentName || !currentName.name) return;
this.setData({ aiLoading: true });
const apiBaseUrl = getApp().globalData.apiBaseUrl;
wx.request({
url: `${apiBaseUrl}/api/names/explain`,
method: 'POST',
header: { 'Content-Type': 'application/json' },
data: {
name: currentName.name,
context: this.data.aiContext || '一般场景'
},
success: (res) => {
if (res.data && res.data.success) {
this.setData({
aiExplanation: res.data.data,
aiLoading: false
});
} else {
wx.showToast({ title: '解析失败', icon: 'none' });
this.setData({ aiLoading: false });
}
},
fail: () => {
wx.showToast({ title: '网络错误', icon: 'none' });
this.setData({ aiLoading: false });
}
});
}
});