- 实现 AI 起名功能(Kimi API 接入) - 添加用户收藏功能(MySQL 数据库) - 实现海报生成与分享 - 添加音效和触觉反馈 - 配置生产环境部署(WAR 包 + Nginx) - 支持多种起名模式(经典、诗词、自然、现代) - 实现分批加载优化体验
501 lines
13 KiB
JavaScript
501 lines
13 KiB
JavaScript
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,
|
||
rotate: 0,
|
||
opacity: 1,
|
||
transition: 'none',
|
||
|
||
cardKey: 0,
|
||
collectedNames: [],
|
||
showCollection: false,
|
||
|
||
// 海报相关
|
||
showPoster: false,
|
||
posterNameChars: []
|
||
},
|
||
|
||
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 = 'test_openid'; // 实际应从登录获取
|
||
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);
|
||
},
|
||
|
||
// 第一波:快速获取前2个名字
|
||
fetchFirstBatch(keyword, mode, surname, count) {
|
||
const apiBaseUrl = getApp().globalData.apiBaseUrl;
|
||
const requestData = {
|
||
keyword,
|
||
count: count,
|
||
batch: 'first'
|
||
};
|
||
if (mode) requestData.mode = mode;
|
||
if (surname) requestData.surname = surname;
|
||
|
||
wx.request({
|
||
url: `${apiBaseUrl}/api/names/generate`,
|
||
data: requestData,
|
||
success: (res) => {
|
||
if (res.statusCode === 200 && res.data && res.data.length > 0) {
|
||
// 清除定时器
|
||
if (this.loadingQuoteTimer) {
|
||
clearInterval(this.loadingQuoteTimer);
|
||
this.loadingQuoteTimer = null;
|
||
}
|
||
|
||
// 立即展示第一波结果
|
||
this.setData({
|
||
nameList: res.data,
|
||
currentIndex: 0,
|
||
isLoading: false,
|
||
cardKey: this.data.cardKey + 1
|
||
});
|
||
|
||
// 静默加载剩余3个名字
|
||
this.fetchSecondBatch(keyword, mode, surname, 3);
|
||
} else {
|
||
this.handleFetchError('意境未达,请重试');
|
||
}
|
||
},
|
||
fail: () => {
|
||
this.handleFetchError('网络疏离,请检查后端');
|
||
}
|
||
});
|
||
},
|
||
|
||
// 第二波:静默加载剩余名字
|
||
fetchSecondBatch(keyword, mode, surname, count) {
|
||
const apiBaseUrl = getApp().globalData.apiBaseUrl;
|
||
const requestData = {
|
||
keyword,
|
||
count: count,
|
||
batch: 'second'
|
||
};
|
||
if (mode) requestData.mode = mode;
|
||
if (surname) requestData.surname = surname;
|
||
|
||
wx.request({
|
||
url: `${apiBaseUrl}/api/names/generate`,
|
||
data: requestData,
|
||
success: (res) => {
|
||
if (res.statusCode === 200 && res.data && res.data.length > 0) {
|
||
// 平滑合并数据
|
||
const currentList = this.data.nameList;
|
||
const newList = [...currentList, ...res.data];
|
||
this.setData({ nameList: newList });
|
||
console.log('第二波数据已静默加载,当前共', newList.length, '个名字');
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
console.log('第二波加载失败(不影响已展示内容):', err);
|
||
}
|
||
});
|
||
},
|
||
|
||
// 处理加载错误
|
||
handleFetchError(message) {
|
||
if (this.loadingQuoteTimer) {
|
||
clearInterval(this.loadingQuoteTimer);
|
||
this.loadingQuoteTimer = null;
|
||
}
|
||
this.setData({ isLoading: false });
|
||
wx.showToast({ title: message, icon: 'none' });
|
||
},
|
||
|
||
// 返回首页
|
||
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;
|
||
// 只有移动超过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;
|
||
}
|
||
|
||
// 以下是拖拽结束后的逻辑
|
||
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 = 'test_openid'; // 实际应从登录获取
|
||
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 {
|
||
wx.showModal({
|
||
title: '见素时刻',
|
||
content: '这一波灵感已尽,是否再求几名?',
|
||
confirmText: '再求', cancelText: '返回',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
this.setData({ isLoading: true });
|
||
this.startLoadingQuoteRotation();
|
||
this.fetchNamesBatch(this.data.keyword, this.data.mode, this.data.surname);
|
||
} else {
|
||
this.onBack();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
this.isExiting = false;
|
||
});
|
||
});
|
||
},
|
||
|
||
toggleCollectionView() {
|
||
// 跳转到 profile 页面
|
||
wx.navigateTo({
|
||
url: '/pages/profile/profile'
|
||
});
|
||
},
|
||
|
||
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'
|
||
});
|
||
},
|
||
|
||
// 阻止事件冒泡
|
||
preventBubble() {
|
||
// 什么都不做,只是阻止事件冒泡
|
||
}
|
||
});
|