Initial commit

This commit is contained in:
王鹏
2026-04-16 11:25:29 +08:00
commit c4db35b183
39 changed files with 1725 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
const EXIT_THRESHOLD = 80;
const FAST_SWIPE_VELOCITY = 0.5;
Page({
isDragging: false,
isExiting: false,
startTime: 0,
startX: 0,
startY: 0,
lastX: 0,
data: {
nameList: [],
currentIndex: 0,
isLoading: true,
isFlipped: false,
keyword: '清冷',
// 动画控制
translateX: 0,
rotate: 0,
opacity: 1,
transition: 'none',
cardKey: 0,
collectedNames: [],
showCollection: false
},
onLoad(options) {
const keyword = options.keyword || this.data.keyword;
this.setData({ keyword });
this.fetchNames(keyword);
},
fetchNames(keyword) {
this.setData({ isLoading: true });
wx.showLoading({ title: '见素正在感悟...', mask: true });
wx.request({
url: 'http://localhost:8080/api/names/generate',
data: { keyword },
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.length > 0) {
this.setData({ nameList: res.data, currentIndex: 0, isLoading: false, cardKey: this.data.cardKey + 1 });
} else {
wx.showToast({ title: '意境未达,请重试', icon: 'none' });
}
},
fail: () => wx.showToast({ title: '网络疏离,请检查后端', icon: 'none' }),
complete: () => { this.setData({ isLoading: false }); wx.hideLoading(); }
});
},
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;
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();
} else {
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.handleCardExit('like');
},
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.fetchNames(this.data.keyword); }
});
}
this.isExiting = false;
});
});
},
toggleCollectionView() {
this.setData({ showCollection: !this.data.showCollection });
},
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];
wx.showLoading({ title: '绘笔收录中...', mask: true });
const query = wx.createSelectorQuery().in(this);
query.select('#posterCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
const dpr = wx.getSystemInfoSync().pixelRatio;
canvas.width = 750 * dpr;
canvas.height = 1334 * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, 750, 1334);
ctx.fillStyle = '#2D2D2D';
ctx.font = '300 120px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const name = card.name;
const charArray = name.split('');
const charHeight = 140;
const startY = 667 - (charArray.length * charHeight) / 2 + charHeight / 2;
charArray.forEach((char, index) => ctx.fillText(char, 375, startY + index * charHeight));
const sealChar = this.data.keyword.substring(0, 1) || '素';
ctx.fillStyle = '#B22222';
ctx.fillRect(100, 100, 80, 80);
ctx.fillStyle = '#FFFFFF';
ctx.font = '40px serif';
ctx.fillText(sealChar, 140, 140);
const randomNum = Math.floor(Math.random() * 9000) + 1000;
ctx.fillStyle = '#D0D0D0';
ctx.font = '24px sans-serif';
ctx.fillText(`见素第 ${randomNum} 号灵感`, 375, 1200);
setTimeout(() => {
wx.canvasToTempFilePath({
canvas: canvas,
success: (res) => {
wx.hideLoading();
wx.showShareImageMenu({ path: res.tempFilePath });
},
fail: () => {
wx.hideLoading();
wx.showToast({ title: '绘笔受阻', icon: 'none' });
}
});
}, 300);
});
}
});

View File

@@ -0,0 +1,74 @@
<view class="container">
<view class="card-stack" wx:if="{{!isLoading && nameList.length > 0}}">
<view
class="card-container"
style="transform: translateX({{translateX}}px) rotate({{rotate}}deg); transition: {{transition}};"
bindtouchstart="onTouchStart"
bindtouchmove="onTouchMove"
bindtouchend="onTouchEnd"
bindtransitionend="onTransitionEnd"
>
<view class="card {{isFlipped ? 'flipped' : ''}}">
<!-- 正面:名字与诗词 -->
<view class="card-face front" bindtap="onFlip">
<text class="name">{{nameList[currentIndex].name}}</text>
<view class="poem-container">
<text class="poem">{{nameList[currentIndex].origin}}</text>
</view>
</view>
<!-- 反面:故事化解读 -->
<view class="card-face back">
<view class="desc-container">
<text class="desc">{{nameList[currentIndex].description}}</text>
</view>
<view class="analysis-container">
<view class="tone-tag">声韵:{{nameList[currentIndex].tone}}</view>
<view class="score-tag">见素评分:{{nameList[currentIndex].score}}</view>
<view class="save-btn" catchtap="onSavePoster">
<text class="save-icon"></text>
<text>存为海报</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 海报生成画布 (隐藏在屏幕外) -->
<canvas type="2d" id="posterCanvas" style="width: 750px; height: 1334px; position: absolute; left: -9999px;"></canvas>
<!-- 底部操作提示 -->
<view class="footer" wx:if="{{!isLoading}}">
<view class="hint">左滑无感 · 右滑收藏</view>
</view>
<!-- 空状态 -->
<view class="empty-state" wx:if="{{!isLoading && nameList.length === 0}}">
<text>暂无灵感,请重试</text>
<button bindtap="onLoad" style="margin-top: 20px; font-weight: 300;">重新感悟</button>
</view>
<!-- 收藏锦囊 -->
<view class="collection-bag" bindtap="toggleCollectionView">
<text class="bag-icon">囊</text>
<view class="collection-count">{{collectedNames.length}}</view>
</view>
<!-- 收藏列表浮层 -->
<view class="collection-overlay {{showCollection ? 'visible' : ''}}" bindtap="toggleCollectionView">
<view class="collection-content" catchtap>
<view class="collection-title">见素锦囊</view>
<scroll-view scroll-y class="collection-scroll">
<view class="collection-item" wx:for="{{collectedNames}}" wx:key="name">
<view>
<view class="item-name">{{item.name}}</view>
<view class="item-origin">{{item.origin}}</view>
</view>
<view class="delete-btn" data-name="{{item.name}}" catchtap="onDeleteCollected">×</view>
</view>
<view wx:if="{{collectedNames.length === 0}}" class="collection-empty">
锦囊空空,静待灵感。
</view>
</scroll-view>
</view>
</view>
</view>

View File

@@ -0,0 +1,311 @@
page {
background-color: #FFFFFF;
height: 100%;
}
.container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.card-stack {
position: relative;
width: 70vw;
height: 100vw;
z-index: 1;
}
.card-container {
position: absolute;
width: 100%;
height: 100%;
}
.card {
width: 100%;
height: 100%;
position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.04);
border: 1px solid #F0F0F0;
border-radius: 8rpx;
background: white;
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx;
box-sizing: border-box;
border-radius: 8rpx;
}
.front {
transform: rotateY(0deg);
}
.back {
transform: rotateY(180deg);
}
.card.flipped .front {
transform: rotateY(-180deg);
}
.card.flipped .back {
transform: rotateY(0deg);
}
.front .name {
font-family: "Noto Serif SC", "Source Han Serif SC", "PingFang SC", serif;
font-size: 140rpx;
color: #2D2D2D;
margin-bottom: 60rpx;
font-weight: 300;
letter-spacing: 10rpx;
text-align: center;
}
.poem-container {
height: 350rpx;
display: flex;
align-items: center;
justify-content: center;
}
.front .poem {
font-size: 26rpx;
color: #A0A0A0;
letter-spacing: 12rpx;
writing-mode: vertical-rl;
line-height: 1.5;
text-align: center;
}
.back {
transform: rotateY(180deg);
display: flex;
flex-direction: column;
justify-content: space-between !important;
padding: 80rpx 60rpx !important;
}
.desc-container {
flex: 1;
display: flex;
align-items: center;
}
.back .desc {
font-size: 28rpx;
color: #4A4A4A;
line-height: 2.2;
text-align: justify;
letter-spacing: 2rpx;
}
.analysis-container {
margin-top: 40rpx;
display: flex;
flex-direction: column;
align-items: flex-end;
border-top: 1rpx solid #F0F0F0;
padding-top: 30rpx;
}
.tone-tag, .score-tag {
font-size: 18rpx;
color: #D0D0D0;
letter-spacing: 2rpx;
margin-bottom: 10rpx;
}
.save-btn {
margin-top: 20rpx;
display: flex;
align-items: center;
font-size: 16rpx;
color: #A0A0A0;
border: 1rpx solid #F0F0F0;
padding: 8rpx 20rpx;
border-radius: 40rpx;
letter-spacing: 2rpx;
transition: all 0.2s;
}
.save-btn:active {
background: #FAFAFA;
transform: scale(0.95);
}
.save-icon {
margin-right: 8rpx;
font-size: 14rpx;
}
/* 退出动画 */
.card-exit-left {
transform: translate3d(-150%, 0, 0) rotate(-20deg) !important;
opacity: 0 !important;
transition: all 0.5s ease-in !important;
}
.card-exit-right {
transform: translate3d(150%, 0, 0) rotate(20deg) !important;
opacity: 0 !important;
transition: all 0.5s ease-in !important;
}
.footer {
margin-top: 100rpx;
}
.hint {
font-size: 22rpx;
color: #D0D0D0;
letter-spacing: 4rpx;
}
/* 收藏锦囊 */
.collection-bag {
position: fixed;
right: 60rpx;
bottom: 120rpx;
width: 100rpx;
height: 100rpx;
background-color: #FFFFFF;
border-radius: 50%;
box-shadow: 0 10rpx 40rpx rgba(0,0,0,0.08);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
z-index: 10;
transition: transform 0.2s;
}
.collection-bag:active {
transform: scale(0.9);
}
.bag-icon {
font-family: "Noto Serif SC", serif;
font-size: 40rpx;
color: #4A4A4A;
}
.collection-count {
position: absolute;
top: 0;
right: 0;
background-color: #B22222;
color: white;
font-size: 18rpx;
border-radius: 50%;
padding: 4rpx 10rpx;
min-width: 18rpx;
text-align: center;
}
/* 收藏列表 */
.collection-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(20px);
z-index: 20;
opacity: 0;
visibility: hidden;
transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
}
.collection-overlay.visible {
opacity: 1;
visibility: visible;
}
.collection-content {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 60vh;
background-color: rgba(255, 255, 255, 0.8);
border-top-left-radius: 40rpx;
border-top-right-radius: 40rpx;
box-shadow: 0 -10rpx 60rpx rgba(0,0,0,0.05);
transform: translateY(100%);
transition: transform 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
}
.collection-overlay.visible .collection-content {
transform: translateY(0);
}
.collection-title {
text-align: center;
padding: 40rpx 0;
font-size: 24rpx;
color: #A0A0A0;
letter-spacing: 4rpx;
border-bottom: 1rpx solid #F0F0F0;
}
.collection-scroll {
height: calc(100% - 110rpx);
}
.collection-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx 60rpx;
border-bottom: 1rpx solid #F0F0F0;
}
.item-name {
font-family: "Noto Serif SC", serif;
font-size: 32rpx;
color: #2D2D2D;
margin-bottom: 4rpx;
}
.item-origin {
font-size: 20rpx;
color: #A0A0A0;
font-weight: 200;
}
.delete-btn {
font-size: 40rpx;
color: #E0E0E0;
font-weight: 200;
padding: 10rpx 20rpx;
border-radius: 50%;
transition: all 0.2s;
}
.delete-btn:active {
background-color: #F0F0F0;
color: #B22222;
}
.collection-empty {
text-align: center;
padding: 100rpx;
font-size: 24rpx;
color: #E0E0E0;
}