init: initial commit

This commit is contained in:
2026-10-07 09:28:43 +08:00
commit a68a4ef411
251 changed files with 38279 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
// app.js
const { api } = require('./utils/request');
App({
globalData: {
authState: 'loading',
authMessage: '',
userId: null,
token: null,
isConnected: true,
networkType: 'wifi'
},
onLaunch: function() {
this.removeLegacyDataCaches();
this._authListeners = [];
// 冷启动始终换取新 token,所有业务请求共享这一次登录。
this.silentLogin().catch(function() {});
this.checkNetwork();
wx.onNetworkStatusChange((res) => {
this.globalData.isConnected = res.isConnected;
this.globalData.networkType = res.networkType;
});
},
setAuthState: function(state, message) {
this.globalData.authState = state;
this.globalData.authMessage = message || '';
(this._authListeners || []).slice().forEach(function(listener) { listener(state, message || ''); });
},
observeAuth: function(listener) {
if (!this._authListeners) this._authListeners = [];
this._authListeners.push(listener);
listener(this.globalData.authState, this.globalData.authMessage);
return () => { this._authListeners = this._authListeners.filter(function(item) { return item !== listener; }); };
},
ensureAuth: function() {
if (this._authPromise) return this._authPromise;
if (this.globalData.authState === 'error') return Promise.reject({ error: this.globalData.authMessage, authFailed: true });
if (this.globalData.authState === 'ready' && wx.getStorageSync('token')) return Promise.resolve();
return this.silentLogin();
},
// 多个旧 token 请求同时返回 401 时,只刷新一次;较晚返回的旧请求复用新 token。
refreshAuth: function(failedToken) {
if (this._authPromise) return this._authPromise;
if (this.globalData.authState === 'ready' && wx.getStorageSync('token') !== failedToken) return Promise.resolve();
return this.silentLogin();
},
removeLegacyDataCaches: function() {
try {
var keys = wx.getStorageInfoSync().keys || [];
var exact = {
indexCache: true, profileCache: true, discoverStatsCache: true,
songsCache: true, albumsCache: true, diaryCache: true,
timelineCache: true, timelineBgCache: true, checkinsCache: true,
coupleInfoCache: true, petCache: true, petAchievementCache: true,
period_cache_data: true, bg_cache: true, subscribe_status_cache: true,
_albums_inited: true, myLocation: true
};
keys.forEach(function(key) {
if (exact[key] || /^(offline_|albumCache_|diaryDetailCache_|timelineDetailCache_|checkinDetailCache_|historyCache_|period_cache_data:|timelineBg_)/.test(key)) {
wx.removeStorageSync(key);
}
});
} catch (e) { console.warn('清理旧数据缓存失败', e); }
},
// ★ 获取用户位置(使用 wx.getFuzzyLocation 模糊定位)
getLocationRegion: function() {
return new Promise(function(resolve) {
wx.getFuzzyLocation({
type: 'wgs84',
success: function(loc) {
// 直接存储经纬度字符串,不依赖地图 API
var region = loc.latitude.toFixed(4) + ',' + loc.longitude.toFixed(4);
resolve(region);
},
fail: function(err) {
console.log('获取模糊位置失败:', err);
resolve('');
}
});
});
},
silentLogin: function() {
if (this._authPromise) return this._authPromise;
this.setAuthState('loading');
this._authPromise = this.performLogin().then((res) => {
this.setAuthState('ready');
return res;
}).catch((error) => {
wx.removeStorageSync('token');
this.globalData.token = null;
var failure = { error: (error && error.error) || '登录失败,请检查网络后重试', authFailed: true };
this.setAuthState('error', failure.error);
throw failure;
}).finally(() => { this._authPromise = null; });
return this._authPromise;
},
performLogin: async function() {
var loginRes = await new Promise(function(resolve, reject) {
var done = false;
var timer = setTimeout(function() {
if (!done) { done = true; reject({ error: '登录超时,请重试' }); }
}, 10000);
wx.login({
success: function(res) { if (!done) { done = true; clearTimeout(timer); resolve(res); } },
fail: function(err) { if (!done) { done = true; clearTimeout(timer); reject(err); } }
});
});
if (!loginRes.code) throw { error: '未能获取微信登录凭证,请重试' };
// 定位授权不阻塞登录,城市信息由页面后续的定位接口更新。
var res = await api.wechatLogin(loginRes.code, '');
if (!res || !res.token || !res.user_id) throw { error: '登录响应不完整,请重试' };
wx.setStorageSync('token', res.token);
wx.setStorageSync('userId', res.user_id);
wx.setStorageSync('coupleId', res.couple_id || '');
wx.setStorageSync('displayId', String(res.display_id || ''));
wx.setStorageSync('nickname', res.nickname || '');
wx.setStorageSync('region', res.region || '');
wx.setStorageSync('hasLoggedIn', true);
this.globalData.token = res.token;
this.globalData.userId = res.user_id;
return res;
},
// 检查当前网络状态
checkNetwork: function() {
wx.getNetworkType({
success: (res) => {
this.globalData.isConnected = res.networkType !== 'none';
this.globalData.networkType = res.networkType;
}
});
}
});
+47
View File
@@ -0,0 +1,47 @@
{
"pages": [
"pages/index/index",
"pages/discover/discover",
"pages/timeline/timeline",
"pages/punch/punch",
"pages/diary/diary",
"pages/albums/albums",
"pages/album/album",
"pages/lovewords/lovewords",
"pages/settings/settings",
"pages/history/history",
"pages/profile/profile",
"pages/publish/publish",
"pages/checkin-detail/checkin-detail",
"pages/timeline-detail/timeline-detail",
"pages/period/period",
"pages/unbind/unbind",
"pages/diary_detail/diary_detail",
"pages/diary_edit/diary_edit",
"pages/pet/pet",
"pages/pet/achievements"
],
"window": {
"backgroundTextStyle": "light",
"navigationStyle": "custom",
"navigationBarTextStyle": "black",
"backgroundColor": "#FAF5EE"
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"lazyCodeLoading": "requiredComponents",
"permission": {
"scope.userFuzzyLocation": {
"desc": "你的位置信息将用于在个人主页展示所在城市"
},
"scope.userLocation": {
"desc": "你的位置信息将用于在发布日记或照片时标记地理位置"
}
},
"requiredPrivateInfos": [
"getFuzzyLocation"
],
"usingComponents": {
"launch-transition": "/components/launch-transition/launch-transition"
}
}
+297
View File
@@ -0,0 +1,297 @@
/* ========================================
全局设计系统 (Design Tokens)
情侣小程序统一视觉规范
======================================== */
page {
/* 主色板 */
--primary: #B74768; /* 主色:玫瑰粉 */
--primary-deep: #943C58; /* 深玫红 */
--primary-light: #F4E2E3; /* 浅粉底 */
--primary-gradient: linear-gradient(135deg, #B74768 0%, #D5A6AE 100%);
--primary-gradient-deep: linear-gradient(135deg, #943C58 0%, #B74768 100%);
/* 辅助色 */
--gold: #FFB84D; /* 金色:成就/倒计时 */
--gold-light: #FFF3DF;
--success: #2ECC71; /* 成功 */
--success-light: #E8F9EF;
/* 中性色 */
--text-main: #333333;
--text-sub: #888888;
--text-light: #BBBBBB;
--bg: #FAF5EE;
--bg-soft: #F3EBE1;
--white: #FFFFFF;
/* 圆角 */
--radius-sm: 12rpx;
--radius-md: 20rpx;
--radius-lg: 32rpx;
--radius-full: 999rpx;
/* 阴影 */
--shadow-sm: 0 4rpx 16rpx rgba(0,0,0,0.05);
--shadow-md: 0 8rpx 30rpx rgba(255,107,129,0.10);
--shadow-lg: 0 16rpx 48rpx rgba(255,107,129,0.18);
/* 间距 */
--gap-sm: 16rpx;
--gap-md: 24rpx;
--gap-lg: 32rpx;
/* 过渡 */
--transition: all 0.3s ease;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 28rpx;
color: var(--text-main);
background-color: var(--bg);
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
/* 隐藏所有滚动条 */
::-webkit-scrollbar { display: none; }
scroll-view ::-webkit-scrollbar { display: none; }
/* 通用容器 */
.container {
padding: 30rpx;
}
/* 卡片样式 */
.card {
background: var(--white);
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
}
/* 按钮样式 */
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: var(--white);
border: none;
border-radius: 50rpx;
padding: 24rpx 60rpx;
font-size: 28rpx;
}
.btn-outline {
background: transparent;
color: var(--primary-color);
border: 2rpx solid var(--primary-color);
border-radius: 50rpx;
padding: 22rpx 60rpx;
font-size: 28rpx;
}
/* 输入框 */
.input-group {
margin-bottom: 30rpx;
}
.input-group label {
display: block;
font-size: 26rpx;
color: var(--text-light);
margin-bottom: 10rpx;
}
.input-group input {
width: 100%;
padding: 24rpx;
border: 2rpx solid #eee;
border-radius: 16rpx;
font-size: 28rpx;
}
/* Flex布局 */
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-col {
display: flex;
flex-direction: column;
}
/* 文字 */
.text-center {
text-align: center;
}
.text-primary {
color: var(--primary-color);
}
.text-light {
color: var(--text-light);
}
/* 间距 */
.mt-20 {
margin-top: 20rpx;
}
.mt-40 {
margin-top: 40rpx;
}
.mb-20 {
margin-bottom: 20rpx;
}
.mb-40 {
margin-bottom: 40rpx;
}
/* ========================================
公共卡片类(设计系统)
======================================== */
.card {
background: var(--white);
border-radius: var(--radius-lg);
padding: var(--gap-lg);
box-shadow: var(--shadow-sm);
}
.card-primary {
background: var(--primary-gradient);
border-radius: var(--radius-lg);
padding: var(--gap-lg);
box-shadow: var(--shadow-md);
color: var(--white);
}
/* 渐变文字 */
.text-gradient {
background: var(--primary-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* 胶囊标签 */
.pill {
display: inline-flex;
align-items: center;
padding: 6rpx 20rpx;
border-radius: var(--radius-full);
font-size: 22rpx;
background: var(--primary-light);
color: var(--primary);
}
/* 图标圆底 */
.icon-bubble {
width: 96rpx;
height: 96rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 44rpx;
}
/* ========================================
动效类(设计系统)
======================================== */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(30rpx); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
@keyframes floaty {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12rpx); }
}
.anim-fade-up { animation: fadeUp 0.5s ease both; }
.anim-fade-in { animation: fadeIn 0.6s ease both; }
.anim-pulse { animation: pulse 2s ease-in-out infinite; }
.anim-floaty { animation: floaty 3s ease-in-out infinite; }
/* 错峰入场:配合 .anim-fade-up 使用 */
.delay-1 { animation-delay: 0.1s; }
.delay-2 { animation-delay: 0.2s; }
.delay-3 { animation-delay: 0.3s; }
.delay-4 { animation-delay: 0.4s; }
.delay-5 { animation-delay: 0.5s; }
.delay-6 { animation-delay: 0.6s; }
/* 按压反馈 */
.press {
transition: transform 0.15s ease, opacity 0.15s ease;
}
.press:active {
transform: scale(0.96);
opacity: 0.85;
}
/* ========================================
空状态(设计系统)
======================================== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 40rpx;
}
.empty-illustration {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: var(--primary-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 72rpx;
margin-bottom: var(--gap-md);
}
.empty-title {
font-size: 30rpx;
font-weight: 600;
color: var(--text-main);
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: var(--text-sub);
margin-bottom: var(--gap-lg);
text-align: center;
}
.empty-btn {
background: var(--primary-gradient);
color: var(--white);
font-size: 28rpx;
border-radius: var(--radius-full);
padding: 20rpx 64rpx;
border: none;
}
.empty-btn::after { border: none; }
+12
View File
@@ -0,0 +1,12 @@
const { getLayout } = require('../../utils/layout');
Component({
properties: { title: String, back: Boolean },
data: { statusBarHeight: 20, barHeight: 44, navSide: 100 },
lifetimes: { attached: function() { this.setData(getLayout()); } },
pageLifetimes: { resize: function() { this.setData(getLayout()); } },
methods: { goBack: function() {
this.triggerEvent('back');
if (getCurrentPages().length > 1) wx.navigateBack();
else wx.reLaunch({ url: '/pages/index/index' });
} }
});
+1
View File
@@ -0,0 +1 @@
{"component":true}
+6
View File
@@ -0,0 +1,6 @@
<view class="nav" style="padding-top:{{statusBarHeight}}px;">
<view class="bar" style="height:{{barHeight}}px;padding:0 {{navSide}}px;">
<view wx:if="{{back}}" class="back" bindtap="goBack" aria-label="返回"><image src="/images/back.svg" /></view>
<text class="title">{{title}}</text>
</view>
</view>
+1
View File
@@ -0,0 +1 @@
.nav{position:fixed;top:0;left:0;right:0;z-index:900;background:#FAF5EE;box-sizing:border-box}.bar{position:relative;display:flex;align-items:center;justify-content:center;box-sizing:border-box}.title{font-size:32rpx;font-weight:600;color:#352E2A;white-space:nowrap}.back{position:absolute;left:20rpx;top:0;bottom:0;width:80rpx;display:flex;align-items:center;justify-content:center}.back image{width:36rpx;height:36rpx}
+20
View File
@@ -0,0 +1,20 @@
const { tabForPage, paths } = require('../../utils/navigation');
Component({
data: { active: '' },
lifetimes: { attached: function() { this.syncActive(); } },
pageLifetimes: { show: function() { this.syncActive(); } },
methods: {
syncActive: function() {
var pages = getCurrentPages();
var current = pages[pages.length - 1];
this.setData({ active: tabForPage(current && current.route) });
},
navigate: function(e) {
var tab = e.currentTarget.dataset.tab;
if (!paths[tab]) return;
var pages = getCurrentPages();
// 当前主页面重复点击不重新加载。
if (pages.length && pages[pages.length - 1].route === paths[tab]) return;
wx.reLaunch({ url: '/' + paths[tab] });
}
} });
@@ -0,0 +1 @@
{"component":true}
@@ -0,0 +1,11 @@
<view class="tabs" wx:if="{{active}}">
<view class="tab {{active === 'home' ? 'active' : ''}}" data-tab="home" bindtap="navigate" hover-class="tab-pressed" aria-label="我们" aria-role="button">
<view class="tab-icon"><image src="{{active === 'home' ? '/components/app-tabbar/icons/us-active.svg' : '/components/app-tabbar/icons/us.svg'}}" mode="aspectFit" /></view><text class="tab-label">我们</text>
</view>
<view class="tab {{active === 'records' ? 'active' : ''}}" data-tab="records" bindtap="navigate" hover-class="tab-pressed" aria-label="记录" aria-role="button">
<view class="tab-icon"><image src="{{active === 'records' ? '/components/app-tabbar/icons/journal-active.svg' : '/components/app-tabbar/icons/journal.svg'}}" mode="aspectFit" /></view><text class="tab-label">记录</text>
</view>
<view class="tab {{active === 'profile' ? 'active' : ''}}" data-tab="profile" bindtap="navigate" hover-class="tab-pressed" aria-label="我的" aria-role="button">
<view class="tab-icon"><image src="{{active === 'profile' ? '/components/app-tabbar/icons/me-active.svg' : '/components/app-tabbar/icons/me.svg'}}" mode="aspectFit" /></view><text class="tab-label">我的</text>
</view>
</view>
@@ -0,0 +1,41 @@
.tabs {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 900;
display: flex;
align-items: center;
background: #FFFDF8;
border-top: 1rpx solid #E9DDD0;
padding: 10rpx 20rpx calc(12rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.tab {
flex: 1;
min-width: 0;
min-height: 92rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
padding: 4rpx 0;
color: #8B827B;
}
.tab-icon {
display: flex;
align-items: center;
justify-content: center;
width: 88rpx;
height: 54rpx;
border-radius: 32rpx;
background: transparent;
transition: background-color .18s ease;
}
.tab-icon image { width: 48rpx; height: 48rpx; }
.tab-label { font-size: 23rpx; line-height: 1.4; letter-spacing: 1rpx; }
.active { color: #B74768; }
.active .tab-icon { background: #F4E2E3; }
.active .tab-label { font-weight: 600; }
.tab-pressed .tab-icon { background: #F3EBE1; }
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="#F4E2E3" stroke="#B74768" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 7.5c-3.4-2-7.4-2-11-.7v16c3.6-1.3 7.6-1.3 11 .7 3.4-2 7.4-2 11-.7v-16c-3.6-1.3-7.6-1.3-11 .7Z"/><path d="M14 7.5v16M7 11h3.5M7 15h3.5M18 11h3M18 15h3"/></svg>

After

Width:  |  Height:  |  Size: 329 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="none" stroke="#8B827B" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 7.5c-3.4-2-7.4-2-11-.7v16c3.6-1.3 7.6-1.3 11 .7 3.4-2 7.4-2 11-.7v-16c-3.6-1.3-7.6-1.3-11 .7Z"/><path d="M14 7.5v16M7 11h3.5M7 15h3.5M18 11h3M18 15h3"/></svg>

After

Width:  |  Height:  |  Size: 326 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="#F4E2E3" stroke="#B74768" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="14" cy="10" r="4.5"/><path d="M5 23v-1.4a6 6 0 0 1 6-6h6a6 6 0 0 1 6 6V23Z"/></svg>

After

Width:  |  Height:  |  Size: 253 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="none" stroke="#8B827B" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="14" cy="10" r="4.5"/><path d="M5 23v-1.4a6 6 0 0 1 6-6h6a6 6 0 0 1 6 6V23Z"/></svg>

After

Width:  |  Height:  |  Size: 250 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="#F4E2E3" stroke="#B74768" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 23S4 17.2 4 10.6A5.3 5.3 0 0 1 14 8a5.3 5.3 0 0 1 10 2.6C24 17.2 14 23 14 23Z"/><path d="M9 11.5c0-1.3.8-2.3 2-2.6"/></svg>

After

Width:  |  Height:  |  Size: 294 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="none" stroke="#8B827B" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 23S4 17.2 4 10.6A5.3 5.3 0 0 1 14 8a5.3 5.3 0 0 1 10 2.6C24 17.2 14 23 14 23Z"/><path d="M9 11.5c0-1.3.8-2.3 2-2.6"/></svg>

After

Width:  |  Height:  |  Size: 291 B

@@ -0,0 +1,41 @@
Component({
data: { visible: true, state: 'loading', message: '' },
lifetimes: {
attached: function() {
this._startedAt = Date.now();
this._unobserve = getApp().observeAuth((state, message) => {
clearTimeout(this._exitTimer);
if (state === 'ready' && !this._waiting) {
this.setData({ visible: false });
} else if (state === 'ready') {
this._exitTimer = setTimeout(() => { this.setData({ visible: false }); }, Math.max(0, 350 - (Date.now() - this._startedAt)));
} else {
this._waiting = true;
this.setData({ visible: true, state: state, message: message });
}
});
},
detached: function() {
clearTimeout(this._exitTimer);
if (this._unobserve) this._unobserve();
}
},
methods: {
preventMove: function() {},
retry: async function() {
try {
await getApp().silentLogin();
// 重新加载失败的页面,保留分享链接、相册 id 等原始入口参数。
var pages = getCurrentPages();
var page = pages[pages.length - 1];
if (!page) return;
var query = Object.keys(page.options || {}).map(function(key) {
return encodeURIComponent(key) + '=' + encodeURIComponent(page.options[key]);
}).join('&');
wx.reLaunch({ url: '/' + page.route + (query ? '?' + query : '') });
} catch (error) {
// App 已发布失败状态,保留重试画面。
}
}
}
});
@@ -0,0 +1 @@
{"component":true}
@@ -0,0 +1,13 @@
<view wx:if="{{visible}}" class="launch-screen" catchtouchmove="preventMove">
<view class="launch-content">
<text class="launch-mark {{state === 'loading' ? 'launch-animated' : ''}}">💝</text>
<text class="launch-title">我们</text>
<text class="launch-caption">把日常,写成我们的故事</text>
<view wx:if="{{state === 'loading'}}" class="launch-progress"><view></view><view></view><view></view></view>
<block wx:else>
<text class="launch-error">{{message}}</text>
<button class="launch-retry" bindtap="retry">重新连接</button>
</block>
</view>
<text class="launch-footer">PAGES OF US</text>
</view>
@@ -0,0 +1,14 @@
.launch-screen{position:fixed;top:0;right:0;bottom:0;left:0;z-index:99999;background:#FAF5EE;display:flex;align-items:center;justify-content:center;color:#352E2A;}
.launch-content{display:flex;align-items:center;flex-direction:column;width:100%;padding:0 60rpx;box-sizing:border-box;transform:translateY(-40rpx);}
.launch-mark{display:block;width:152rpx;height:152rpx;line-height:152rpx;font-size:120rpx;text-align:center;}
.launch-animated{animation:book-breathe 1.8s ease-in-out infinite;}
.launch-title{font-size:44rpx;font-weight:600;letter-spacing:8rpx;margin:48rpx 0 18rpx;}
.launch-caption{font-size:26rpx;color:#85776D;}
.launch-progress{display:flex;gap:12rpx;margin-top:54rpx;}
.launch-progress view{width:8rpx;height:8rpx;border-radius:50%;background:#B74768;animation:dot-breathe 1.2s ease-in-out infinite;}
.launch-progress view:nth-child(2){animation-delay:.15s;}.launch-progress view:nth-child(3){animation-delay:.3s;}
.launch-error{margin-top:42rpx;font-size:26rpx;line-height:1.6;text-align:center;color:#85776D;}
.launch-retry{margin:28rpx 0 0;width:300rpx;background:#B74768;color:#FFFDF8;border-radius:18rpx;font-size:28rpx;line-height:84rpx;}.launch-retry::after{border:0;}
.launch-footer{position:absolute;bottom:calc(56rpx + env(safe-area-inset-bottom));font-size:20rpx;letter-spacing:6rpx;color:#A39386;}
@keyframes book-breathe{0%,100%{transform:translateY(0) rotate(-3deg);}50%{transform:translateY(-10rpx) rotate(1deg);}}
@keyframes dot-breathe{0%,100%{opacity:.25;}50%{opacity:1;}}
@@ -0,0 +1 @@
Component({ properties: { kind: { type: String, value: 'empty' }, title: String, description: String, action: String }, methods: { act: function() { this.triggerEvent('action'); } } });
@@ -0,0 +1 @@
{"component":true}
@@ -0,0 +1 @@
<view class="state state-{{kind}}"><view class="mark {{kind}}">{{kind === 'loading' ? '…' : kind === 'error' ? '!' : '♡'}}</view><text class="title">{{title}}</text><text wx:if="{{description}}" class="desc">{{description}}</text><button wx:if="{{action}}" bindtap="act">{{action}}</button></view>
@@ -0,0 +1,4 @@
.state{padding:64rpx 32rpx;display:flex;flex-direction:column;align-items:center;text-align:center;gap:16rpx}.mark{width:88rpx;height:88rpx;line-height:88rpx;border-radius:28rpx;background:#F4E2E3;color:#B74768;font-size:44rpx}.title{color:#352E2A;font-size:30rpx;font-weight:600}.desc{color:#85776D;font-size:26rpx;line-height:1.6}.state button{margin-top:12rpx;background:#B74768;color:#FFFDF8;font-size:26rpx;border-radius:20rpx;padding:0 32rpx}.state button:after{border:0}
.state button{min-height:80rpx;line-height:1.4;display:flex;align-items:center;justify-content:center;padding:20rpx 40rpx;box-sizing:border-box;flex-shrink:0;min-width:220rpx}
.state-empty { background:#FFFDF8; border:1rpx dashed #E9DDD0; border-radius:12rpx 50rpx 12rpx 50rpx; margin:20rpx 0; }.mark { background:transparent; border-radius:0; }.state button { border-radius:26rpx; }.state .desc { font-size:24rpx; line-height:1.8; }
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="18" rx="3"/>
<circle cx="8" cy="10" r="2"/>
<path d="M22 16l-4-4-3 3-3-3-4 4"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>

After

Width:  |  Height:  |  Size: 201 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5V4a1 1 0 0 1 1-1h9l6 6v10.5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/>
<polyline points="14 3 14 9 20 9"/>
</svg>

After

Width:  |  Height:  |  Size: 279 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 11 12 14 22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18" stroke="#FF6B81" stroke-width="2.5" stroke-linecap="round"/>
<path d="M6 6L18 18" stroke="#FF6B81" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80"><rect width="80" height="80" rx="40" fill="#F8EDF0"/><circle cx="40" cy="29" r="13" fill="#D5A8B6"/><path d="M16 68c0-17 10-26 24-26s24 9 24 26" fill="#D5A8B6"/></svg>

After

Width:  |  Height:  |  Size: 250 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff4d4f">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>

After

Width:  |  Height:  |  Size: 176 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#666">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>

After

Width:  |  Height:  |  Size: 245 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id="heartGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#B74768"/>
<stop offset="1" stop-color="#B74768"/>
</linearGradient>
</defs>
<!-- 爱心主体:粉色渐变,圆润饱满 -->
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
fill="url(#heartGrad)"/>
<!-- 高光:左上角小亮点,更可爱 -->
<ellipse cx="8.2" cy="7.6" rx="1.5" ry="1.1" fill="#B74768" opacity="0.55"/>
<!-- 底部小高光 -->
<ellipse cx="12" cy="13.2" rx="3.2" ry="1.4" fill="#B74768" opacity="0.25"/>
</svg>

After

Width:  |  Height:  |  Size: 787 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9.5z"/>
<path d="M9 21V12h6v9"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h5l3-8 4 16 3-8h5"/></svg>

After

Width:  |  Height:  |  Size: 195 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2c-3 5-7 8-7 13a7 7 0 0 0 14 0c0-5-4-8-7-13Z"/></svg>

After

Width:  |  Height:  |  Size: 221 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2.5"/><path d="M12 9.5C7 4 9 2 12 2s5 2 0 7.5M14.5 12C20 7 22 9 22 12s-2 5-7.5 0M12 14.5C17 20 15 22 12 22s-5-2 0-7.5M9.5 12C4 17 2 15 2 12s2-5 7.5 0"/></svg>

After

Width:  |  Height:  |  Size: 340 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18M5 21h14M4 7l8-3 8 3M4 7 1 14h6L4 7ZM20 7l-3 7h6l-3-7Z"/></svg>

After

Width:  |  Height:  |  Size: 235 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8 14a4 4 0 0 0 8 0M8.5 8.5h.01M15.5 8.5h.01"/></svg>

After

Width:  |  Height:  |  Size: 248 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14V5a3 3 0 0 1 6 0v9a5 5 0 1 1-6 0Z"/><path d="M12 7v10"/><circle cx="12" cy="18" r="1"/></svg>

After

Width:  |  Height:  |  Size: 262 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#85776D"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>

After

Width:  |  Height:  |  Size: 180 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none"><ellipse cx="13" cy="15" rx="4" ry="6" fill="#B74768" transform="rotate(-25 13 15)"/><ellipse cx="24" cy="10" rx="4" ry="6" fill="#B74768"/><ellipse cx="35" cy="15" rx="4" ry="6" fill="#B74768" transform="rotate(25 35 15)"/><path d="M12 32c0-7 7-14 12-14s12 7 12 14c0 9-8 4-12 4s-12 5-12-4" fill="#B74768"/></svg>

After

Width:  |  Height:  |  Size: 408 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="6" x2="12" y2="18"/>
<line x1="6" y1="12" x2="18" y2="12"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="4"/>
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>

After

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B74768" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

+469
View File
@@ -0,0 +1,469 @@
const { getLayout } = require('../../utils/layout');
// pages/album/album.js - 日记式分组相册
const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request');
const { requestSubscribeAfterAction } = require('../../utils/subscribe');
Page({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
batches: [],
albumId: null,
showUploadModal: false,
uploadFilePaths: [],
photoFormList: [], // 每张照片独立的表单数据
selectedPhotoIdx: 0, // 当前选中编辑的照片索引
uploadForm: { taken_at: '', location: '', activity: '' },
uploading: false,
showPreview: false,
previewImages: [],
previewIndex: 0,
},
onLoad: function(options) {
this.setData(getLayout());var id = options && options.album_id;
if (id) { this.setData({ albumId: id }); }
this._pendingPhotoPicker = !!(id && options.upload === '1');
this.loadData();
},
onShow: function() { this._visible = true; this.loadData(); },
onHide: function() { this._visible = false; },
// 左滑返回相册列表
touchStartX: 0, touchStartY: 0,
onTouchStart: function(e) {
if (this.data.showPreview) return;
this.touchStartX = e.touches[0].clientX;
this.touchStartY = e.touches[0].clientY;
},
onTouchEnd: function(e) {
if (this.data.showPreview) return;
var dx = this.touchStartX - e.changedTouches[0].clientX;
var dy = Math.abs(this.touchStartY - e.changedTouches[0].clientY);
if (dx < 20 && dy < 20) return;
if (Math.abs(dx) > dy && dx > 80) this.goToAlbums();
},
onPullDownRefresh: async function() {
await this.loadData();
wx.stopPullDownRefresh();
},
// 分享到好友
onShareAppMessage: function() {
return {
title: '让所有的爱有迹可循',
path: '/pages/album/album?album_id=' + (this.data.albumId || ''),
imageUrl: '/images/share.png'
};
},
// 分享到朋友圈
onShareTimeline: function() {
return {
title: '让所有的爱有迹可循',
imageUrl: '/images/share.png'
};
},
loadData: async function() {
if (this._pageLoading) return;
this._pageLoading = true;
this.setData({loading:true, loadError:false});
try {
var self = this;
var albumId = this.data.albumId;
if (!albumId) {
var albums = await api.albumList();
if (albums && albums.length > 0) {
albumId = albums[0].id;
this.setData({ albumId: albumId });
}
}
if (albumId) {
// 使用 noCache 禁用缓存,确保获取最新数据
var photos = await api.photos(albumId, null, 'GET', true);
// 根据 batch_id 分组
var batchMap = {};
var myUid = wx.getStorageSync('userId');
var info = await api.coupleInfo();
var p1 = info.partner1 || {}, p2 = info.partner2 || {};
var nameMap = {};
var avatarMap = {};
nameMap[p1.id] = p1.nickname || p1.username || 'TA';
nameMap[p2.id] = p2.nickname || p2.username || 'TA';
avatarMap[p1.id] = getImageUrl(p1.avatar);
avatarMap[p2.id] = getImageUrl(p2.avatar);
var defaultName = nameMap[p1.id] || nameMap[p2.id] || '情侣';
var defaultAvatar = avatarMap[p1.id] || avatarMap[p2.id] || '';
(photos || []).forEach(function(p) {
var batchId = p.batch_id || p.created_at || 'default';
if (!batchMap[batchId]) {
var cid = parseInt(p.created_by) || 0;
batchMap[batchId] = {
batch_id: batchId,
creator_name: nameMap[cid] || defaultName,
creator_avatar: avatarMap[cid] || defaultAvatar,
created_at: p.created_at || '',
created_time: p.created_at ? p.created_at.substring(0, 16).replace('T', ' ') : '',
photos: []
};
}
batchMap[batchId].photos.push({
...p,
url: getImageUrl(p.path)
});
});
// 按时间排序(最新的在最前)
var batches = Object.values(batchMap);
batches.sort(function(a, b) { return b.created_at > a.created_at ? 1 : -1; });
this.setData({ batches: batches });
} else { this.setData({batches:[]}); }
} catch (e) {
this.setData({loadError:true}); console.error(e); }
finally {
this._pageLoading = false;
this.setData({loading:false});
if (this._visible && this._pendingPhotoPicker && !this.data.loadError && this.data.albumId) {
this._pendingPhotoPicker = false;
this.chooseImage();
}
}
},
chooseImage: function() {
var self = this;
wx.chooseImage({
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: function(res) {
// 确保文件路径是字符串数组
var paths = (res.tempFilePaths || []).map(function(p) { return String(p); });
if (paths.length === 0) {
wx.showToast({ title: '请选择照片', icon: 'none' });
return;
}
var formList = paths.map(function() { return { taken_at: '', location: '', activity: '' }; });
self.setData({
showUploadModal: true,
uploadFilePaths: paths,
photoFormList: formList,
selectedPhotoIdx: 0,
uploadForm: { taken_at: '', location: '', activity: '' }
});
},
fail: function(err) {
console.error('Choose image failed:', err);
wx.showToast({ title: '选择照片失败', icon: 'none' });
}
});
},
onUploadInput: function(e) {
var field = e.currentTarget.dataset.field;
var v = e.detail.value;
// 同步更新当前显示的表单和 photoFormList
var formList = this.data.photoFormList.slice();
formList[this.data.selectedPhotoIdx] = Object.assign({}, formList[this.data.selectedPhotoIdx], { [field]: v });
this.setData({
['uploadForm.' + field]: v,
photoFormList: formList
});
},
// ===== 选中照片编辑 =====
selectPhoto: function(e) {
var idx = e.currentTarget.dataset.index;
var form = this.data.photoFormList[idx] || { taken_at: '', location: '', activity: '' };
this.setData({
selectedPhotoIdx: idx,
uploadForm: { taken_at: form.taken_at || '', location: form.location || '', activity: form.activity || '' }
});
},
removePhoto: function(e) {
var idx = e.currentTarget.dataset.index;
var paths = this.data.uploadFilePaths.filter(function(_, i) { return i !== idx; });
var formList = this.data.photoFormList.filter(function(_, i) { return i !== idx; });
if (paths.length === 0) {
this.cancelUpload();
} else {
var newIdx = Math.min(this.data.selectedPhotoIdx, paths.length - 1);
var f = formList[newIdx] || { taken_at: '', location: '', activity: '' };
this.setData({
uploadFilePaths: paths,
photoFormList: formList,
selectedPhotoIdx: newIdx,
uploadForm: { taken_at: f.taken_at || '', location: f.location || '', activity: f.activity || '' }
});
}
},
addMorePhotos: function() {
var self = this;
var current = this.data.uploadFilePaths.length;
wx.chooseImage({
count: 9 - current,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: function(res) {
var newPaths = (res.tempFilePaths || []).map(function(p) { return String(p); });
var newForms = newPaths.map(function() { return { taken_at: '', location: '', activity: '' }; });
self.setData({
uploadFilePaths: self.data.uploadFilePaths.concat(newPaths),
photoFormList: self.data.photoFormList.concat(newForms)
});
}
});
},
// ===== 批量上传全部照片(每张使用各自的表单)=====
uploadAll: async function() {
if (this.data.uploading) return;
var paths = this.data.uploadFilePaths;
if (paths.length === 0) { wx.showToast({ title: '没有照片', icon: 'none' }); return; }
// 先保存当前编辑中的表单到 photoFormList
var formList = this.data.photoFormList.slice();
var curIdx = this.data.selectedPhotoIdx;
formList[curIdx] = {
taken_at: this.data.uploadForm.taken_at || '',
location: this.data.uploadForm.location || '',
activity: this.data.uploadForm.activity || ''
};
this.setData({ uploading: true });
var self = this;
var successCount = 0;
var failCount = 0;
var failReasons = [];
// ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
requestSubscribeAfterAction('photo');
try {
// 上传前先预热 session,确保认证状态有效
// 解决 Android 端首次上传时 session 未初始化的问题
try {
wx.showLoading({ title: '准备中...', mask: true });
await api.checkAuth();
wx.hideLoading();
} catch (e) {
wx.hideLoading();
console.warn('Session 预热失败(不影响后续上传)', e);
}
var albumId = this.data.albumId;
if (!albumId) {
wx.showLoading({ title: '创建相册...' });
var album = await api.albumAdd({ title: '默认相册', description: '我们的甜蜜照片' });
albumId = album.id;
this.setData({ albumId: albumId });
wx.hideLoading();
}
var batchId = Date.now().toString();
// 自动定位:为未填写位置的照片自动获取地理位置(发布后自动显示)
var autoLoc = null;
var needLoc = formList.some(function(x) { return !(x.location && x.location.trim()); });
if (needLoc) {
try {
autoLoc = await new Promise(function(resolve) {
wx.getFuzzyLocation({
success: function(res) { resolve({ lat: res.latitude, lng: res.longitude }); },
fail: function() { resolve(null); }
});
});
} catch (e) { autoLoc = null; }
}
for (var i = 0; i < paths.length; i++) {
wx.showLoading({ title: '上传中 ' + (i + 1) + '/' + paths.length, mask: true });
var f = formList[i] || { taken_at: '', location: '', activity: '' };
try {
// 按照日记上传逻辑:先上传到通用 upload 接口获取路径,再调用 API 保存到相册
var uploadRes = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: paths[i],
name: 'file',
formData: { sub_dir: 'photos' }
});
// 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'}
var uploadedPath = uploadRes && (uploadRes.path || uploadRes.url || '');
if (uploadedPath) {
await api.photoAdd({
album_id: String(albumId),
path: uploadedPath,
taken_at: f.taken_at || '',
location: f.location || '',
activity: f.activity || '',
batch_id: batchId,
// 未手动填写位置时,自动定位并记录经纬度(发布后自动显示地址)
lat: (f.location && f.location.trim()) ? 0 : (autoLoc ? autoLoc.lat : 0),
lng: (f.location && f.location.trim()) ? 0 : (autoLoc ? autoLoc.lng : 0)
});
successCount++;
} else {
failCount++;
failReasons.push('第' + (i+1) + '张: 上传返回无路径 - ' + JSON.stringify(uploadRes));
}
} catch (e) {
failCount++;
failReasons.push('第' + (i+1) + '张: ' + (e.error || e.errMsg || e.message || JSON.stringify(e)));
// 内容安全违规:明确提示用户
if (e && e.error) {
wx.showToast({ title: e.error, icon: 'none', duration: 2000 });
}
}
wx.hideLoading();
}
} catch (e) {
wx.hideLoading();
failReasons.push('整体异常: ' + (e.error || e.errMsg || e.message || JSON.stringify(e)));
}
this.setData({ uploading: false });
var msg = '上传完成 ' + successCount + '/' + paths.length;
if (failCount > 0) msg += '(' + failCount + '张失败)';
wx.showToast({ title: msg, icon: successCount > 0 ? 'success' : 'none', duration: 2000 });
// 如果有失败,输出详细原因到控制台
if (failCount > 0 && failReasons.length > 0) {
console.error('相册上传失败详情:', failReasons.join(' | '));
}
if (successCount > 0) {
this.setData({ showUploadModal: false, uploadFilePaths: [], photoFormList: [], uploadForm: { taken_at: '', location: '', activity: '' } });
this.loadData();
}
},
cancelUpload: function() {
if (this.data.uploading) return;
this.setData({ showUploadModal: false, uploadFilePaths: [], photoFormList: [], uploadForm: { taken_at: '', location: '', activity: '' } });
},
// 上传页左滑返回
_upTouchX: 0, _upTouchY: 0,
onUploadTouchStart: function(e) {
this._upTouchX = e.touches[0].clientX;
this._upTouchY = e.touches[0].clientY;
},
onUploadTouchEnd: function(e) {
var dx = this._upTouchX - e.changedTouches[0].clientX;
var dy = Math.abs(this._upTouchY - e.changedTouches[0].clientY);
if (dx < 20 && dy < 20) return;
if (Math.abs(dx) > dy && dx > 80) {
this.cancelUpload();
}
},
closePreview: function() { this.setData({ showPreview: false }); },
onSwiperChange: function(e) { this.setData({ previewIndex: e.detail.current }); },
// 长按明信片 - 保存原图到相册
onPostcardLongPress: function(e) {
var self = this;
var url = e.currentTarget.dataset.url;
if (!url) return;
wx.showActionSheet({
itemList: ['保存原图到相册'],
success: function(res) {
if (res.tapIndex === 0) self.savePreviewImage(url);
}
});
},
// 保存原图:先通过 getImageInfo 获取本地临时路径再保存
savePreviewImage: function(url) {
var self = this;
wx.showLoading({ title: '保存中...' });
wx.getImageInfo({
src: url,
success: function(info) {
wx.hideLoading();
self._saveToAlbum(info.path);
},
fail: function() {
wx.hideLoading();
wx.showToast({ title: '获取图片失败', icon: 'none' });
}
});
},
_saveToAlbum: function(filePath) {
wx.saveImageToPhotosAlbum({
filePath: filePath,
success: function() {
wx.showToast({ title: '已保存到相册', icon: 'success' });
},
fail: function(err) {
var msg = (err && err.errMsg) || '';
if (msg.indexOf('auth') >= 0 || msg.indexOf('authorize') >= 0) {
wx.showModal({
title: '需要相册权限',
content: '请在设置中开启「保存到相册」权限,才能保存原图哦',
confirmText: '去设置',
success: function(res) {
if (res.confirm) wx.openSetting();
}
});
} else {
wx.showToast({ title: '保存失败', icon: 'none' });
}
}
});
},
// 预览时仅支持点击遮罩关闭
onPreviewTouchStart: function() {},
onPreviewTouchEnd: function() {},
// 点击单张照片 - 进入全屏明信片预览
onPhotoTap: function(e) {
var batchIdx = e.currentTarget.dataset.batch;
var imgIdx = e.currentTarget.dataset.index;
var batch = this.data.batches[batchIdx];
if (!batch || !batch.photos || !batch.photos[imgIdx]) return;
var allImages = [];
batch.photos.forEach(function(p) {
// 经纬度为0(定位失败)时不显示位置信息
var hasCoord = p.lat && p.lng && parseFloat(p.lat) !== 0 && parseFloat(p.lng) !== 0;
var loc = p.location;
if (!hasCoord) loc = '';
allImages.push({ url: p.url, taken_at: p.taken_at, location: loc, activity: p.activity });
});
this.setData({ showPreview: true, previewImages: allImages, previewIndex: imgIdx });
},
// 长按照片 - 删除照片
onPhotoLongPress: function(e) {
var self = this;
var batchIdx = e.currentTarget.dataset.batch;
var imgIdx = e.currentTarget.dataset.index;
var batch = this.data.batches[batchIdx];
if (!batch || !batch.photos || !batch.photos[imgIdx]) return;
var photo = batch.photos[imgIdx];
wx.showModal({
title: '删除照片',
content: '确定要删除这张照片吗?',
success: function(res) {
if (res.confirm) {
wx.showLoading({ title: '删除中...' });
api.photoDelete({ id: photo.id }).then(function() {
wx.hideLoading();
wx.showToast({ title: '已删除', icon: 'success' });
self.loadData();
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '删除失败', icon: 'none' });
});
}
}
});
},
goToAlbums: function() { wx.navigateBack(); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); },
goProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); }
});
+8
View File
@@ -0,0 +1,8 @@
{
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}
+140
View File
@@ -0,0 +1,140 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/album/album.wxml -->
<app-nav title="相册详情" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd" wx:if="{{!showUploadModal}}">
<view class="container"><view class="j-row"><view><text class="j-eyebrow">MOMENTS WE KEEP</text><text class="j-page-title">相册里的片段</text></view><button class="j-button j-button-small" bindtap="chooseImage">+ 照片</button></view>
<page-state wx:if="{{loading}}" kind="loading" title="正在加载" /><page-state wx:elif="{{loadError}}" kind="error" title="加载失败" description="请检查网络后重新加载" action="重试" bind:action="loadData" />
<view class="moments-list" wx:if="{{!loading && !loadError && batches.length > 0}}">
<view class="moments-item" wx:for="{{batches}}" wx:key="batch_id">
<view class="moments-avatar">
<image wx:if="{{item.creator_avatar}}" src="{{item.creator_avatar}}" mode="aspectFill" class="moments-avatar-img"></image>
<view class="avatar-circle" wx:else>{{item.creator_name}}</view>
</view>
<view class="moments-body">
<view class="moments-name-row">
<text class="moments-name">{{item.creator_name || '情侣'}}</text>
<text class="moments-count">{{item.photos.length}}张</text>
<text class="moments-time">{{item.created_time}}</text>
</view>
<view class="moments-images">
<view class="album-img-wrap" wx:for="{{item.photos}}" wx:key="id" wx:for-index="imgIdx" data-batch="{{index}}" data-index="{{imgIdx}}" bindtap="onPhotoTap" bindlongpress="onPhotoLongPress">
<image src="{{item.url}}" mode="aspectFill" class="album-img" />
</view>
</view>
</view>
</view>
</view>
<view class="empty-state" wx:if="{{!loading && !loadError && !batches.length}}">
<text>📷</text>
<text>还没有照片</text>
<text>点击上方添加照片</text>
</view>
</view>
</scroll-view>
<view class="preview-overlay" wx:if="{{showPreview}}" bindtap="closePreview">
<swiper class="preview-swiper" current="{{previewIndex}}" bindchange="onSwiperChange" circular bindtouchstart="onPreviewTouchStart" bindtouchend="onPreviewTouchEnd">
<swiper-item wx:for="{{previewImages}}" wx:key="*this">
<view class="preview-postcard" bindtap="closePreview" bindlongpress="onPostcardLongPress" data-url="{{item.url}}">
<view class="postcard-photo-frame">
<image src="{{item.url}}" mode="aspectFit" class="postcard-photo" />
<view class="postcard-seal">♥</view>
<view class="postcard-stamp" catchtap="closePreview">
<text class="stamp-heart">❤</text>
<text class="stamp-text">LOVE</text>
</view>
</view>
<view class="postcard-body">
<view class="postcard-rule" wx:if="{{item.taken_at}}">
<text class="rule-icon">📅</text>
<text class="rule-text">{{item.taken_at}}</text>
</view>
<view class="postcard-rule" wx:if="{{item.location}}">
<text class="rule-icon">📍</text>
<text class="rule-text">{{item.location}}</text>
</view>
<view class="postcard-rule" wx:if="{{item.activity}}">
<text class="rule-icon">💌</text>
<text class="rule-text">{{item.activity}}</text>
</view>
<view class="postcard-rule" wx:if="{{!item.taken_at && !item.location && !item.activity}}">
<text class="rule-text postcard-empty">— 我们past —</text>
</view>
<view class="postcard-footer">
<text class="footer-slogan">✦ 让所有的爱都有迹可循 ✦</text>
<text class="footer-hint">长按可保存原图</text>
</view>
</view>
</view>
</swiper-item>
</swiper>
<view class="preview-counter">{{previewIndex + 1}} / {{previewImages.length}}</view>
</view>
<!-- 全屏上传页 -->
<view wx:if="{{showUploadModal}}" class="upload-root" bindtouchstart="onUploadTouchStart" bindtouchend="onUploadTouchEnd">
<view class="upload-nav">
<view class="upload-back" catchtap="cancelUpload">
<image class="back-icon" src="/images/back.svg" mode="aspectFit" />
</view>
</view>
<scroll-view class="upload-body" scroll-y show-scrollbar="{{false}}">
<!-- 照片网格 -->
<view class="photo-grid">
<view class="photo-grid-item {{selectedPhotoIdx == index ? 'selected' : ''}}" wx:for="{{uploadFilePaths}}" wx:key="*this" bindtap="selectPhoto" data-index="{{index}}">
<image src="{{item}}" mode="aspectFill" class="photo-grid-img" />
<view class="photo-has-form" wx:if="{{photoFormList[index].taken_at || photoFormList[index].location || photoFormList[index].activity}}">✓</view>
<view class="photo-del-btn" catchtap="removePhoto" data-index="{{index}}">
<text class="photo-del-x">✕</text>
</view>
</view>
<!-- 添加更多 -->
<view class="photo-grid-item photo-grid-add" catchtap="addMorePhotos" wx:if="{{uploadFilePaths.length < 9}}">
<text class="add-plus">+</text>
<text class="add-label">添加</text>
</view>
</view>
<!-- 选中照片的表单 -->
<view class="upload-form-card" wx:if="{{uploadFilePaths.length > 0}}">
<view class="form-card-head">
<image src="{{uploadFilePaths[selectedPhotoIdx]}}" mode="aspectFill" class="form-preview" />
<text class="form-card-desc">为第 {{selectedPhotoIdx + 1}} 张照片填写信息</text>
</view>
<view class="input-group">
<label>拍摄时间</label>
<picker mode="date" value="{{uploadForm.taken_at}}" bindchange="onUploadInput" data-field="taken_at">
<view class="input">{{uploadForm.taken_at || '选择日期'}}</view>
</picker>
</view>
<view class="input-group">
<label>地点</label>
<input class="input" placeholder="例如:北京颐和园" value="{{uploadForm.location}}" bindinput="onUploadInput" data-field="location" />
</view>
<view class="input-group">
<label>在做什么</label>
<input class="input" placeholder="例如:第一次一起看日落" value="{{uploadForm.activity}}" bindinput="onUploadInput" data-field="activity" />
</view>
</view>
<!-- 空状态提示(无照片时) -->
<view class="upload-empty" wx:if="{{uploadFilePaths.length == 0}}">
<text class="empty-icon">📷</text>
<text class="empty-desc">点击 + 添加照片</text>
</view>
</scroll-view>
<view class="upload-footer">
<button class="btn btn-primary" bindtap="uploadAll" hover-class="none" loading="{{uploading}}" wx:if="{{uploadFilePaths.length > 0}}">上传全部 ({{uploadFilePaths.length}}张)</button>
</view>
</view>
</view>
+331
View File
@@ -0,0 +1,331 @@
/* pages/album/album.wxss - 日记式分组相册 */
@import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
.upload-root {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
z-index: 1000;
background: #FAFAFA;
display: flex; flex-direction: column;
}
.upload-nav {
display: flex; align-items: center;
padding: 96rpx 32rpx 16rpx;
}
.upload-back {
width: 56rpx; height: 56rpx;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(10rpx);
}
.upload-back .back-icon { width: 28rpx; height: 28rpx; }
.upload-body {
flex: 1; padding: 0 16rpx; overflow-y: auto;
}
/* 三宫格照片 - 精确三等分 */
.photo-grid {
display: flex; flex-wrap: wrap;
gap: 10rpx;
padding: 8rpx 0 20rpx;
}
.photo-grid-item {
width: calc((100% - 20rpx) / 3);
aspect-ratio: 1;
border-radius: 14rpx;
overflow: hidden;
position: relative;
background: #f0f0f0;
box-sizing: border-box;
}
.photo-grid-item.selected {
border: 4rpx solid #B74768;
box-shadow: 0 0 0 2rpx rgba(255,107,157,0.3);
}
.photo-grid-img {
width: 100%;
height: 100%;
display: block;
}
.photo-has-form {
position: absolute;
bottom: 6rpx;
left: 6rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: rgba(255,107,157,0.85);
color: white;
font-size: 22rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.photo-del-btn {
position: absolute;
top: 4rpx;
right: 4rpx;
width: 38rpx;
height: 38rpx;
border-radius: 50%;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.photo-del-x {
color: white;
font-size: 22rpx;
font-weight: 700;
line-height: 1;
}
/* 添加更多 */
.photo-grid-add {
background: #fff;
border: 2rpx dashed #ddd;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
}
.add-plus { font-size: 44rpx; color: #ccc; font-weight: 300; }
.add-label { font-size: 20rpx; color: #bbb; }
/* 表单卡片 */
.upload-form-card {
background: white;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.04);
}
.form-card-head {
display: flex; align-items: center; gap: 16rpx;
margin-bottom: 20rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #F4E2E3;
}
.form-preview {
width: 80rpx; height: 80rpx;
border-radius: 12rpx;
flex-shrink: 0;
}
.form-card-desc {
font-size: 24rpx; color: #999;
}
.upload-form-card .input-group { margin-bottom: 16rpx; }
.upload-form-card .input-group:last-child { margin-bottom: 0; }
.upload-form-card .input-group label { font-size: 26rpx; font-weight: 500; color: #555; display: block; margin-bottom: 8rpx; }
.upload-form-card .input { padding: 18rpx 22rpx; font-size: 28rpx; background: #FFF5F8; border-radius: 14rpx; border: 1rpx solid #F4E2E3; }
/* 空状态 */
.upload-empty {
text-align: center; padding: 200rpx 40rpx;
}
.upload-empty .empty-icon { font-size: 80rpx; display: block; margin-bottom: 16rpx; }
.upload-empty .empty-desc { font-size: 28rpx; color: #bbb; display: block; }
/* 底部上传按钮 */
.upload-footer {
padding: 12rpx 24rpx;
padding-bottom: calc(12rpx + env(safe-area-inset-bottom));
}
.container { padding-top: 16rpx; padding-bottom: 300rpx; }
/* ===== 留念卡片(复用日记样式) ===== */
.moments-list { display: flex; flex-direction: column; gap: 24rpx; }
.moments-item {
display: flex; gap: 24rpx; background: white; border-radius: 24rpx;
padding: 32rpx; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.04);
}
.moments-avatar { flex-shrink: 0; }
.moments-avatar-img { width: 72rpx; height: 72rpx; border-radius: 50%; }
.avatar-circle {
width: 72rpx; height: 72rpx; border-radius: 50%;
background: linear-gradient(135deg, #B74768, #B388FF);
display: flex; align-items: center; justify-content: center;
font-size: 28rpx; font-weight: 700; color: white;
}
.moments-body { flex: 1; min-width: 0; }
.moments-name-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24rpx;
margin-bottom: 8rpx;
}
.moments-name { font-size: 28rpx; font-weight: 600; color: #B74768; }
.moments-count { font-size: 22rpx; color: #999; }
.moments-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 12rpx; }
.moments-time { font-size: 22rpx; color: #999; font-weight: 500; flex-shrink: 0; margin-left: auto; }
/* ===== 朋友圈9宫格 ===== */
.moments-images {
display: flex;
flex-wrap: wrap;
margin-top: 40rpx;
}
.album-img-wrap {
width: 33.333%;
height: 0;
padding-bottom: 33.333%;
position: relative;
overflow: hidden;
background: #f0f0f0;
box-sizing: border-box;
}
.album-img-wrap:not(:nth-child(3n)) { padding-right: 4rpx; }
.album-img-wrap:nth-child(n+4):nth-child(-n+6) { padding-top: 4rpx; }
.album-img-wrap:nth-child(n+7) { padding-top: 4rpx; }
.album-img {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
}
/* ===== 全屏明信片预览(导航栏下方整屏) ===== */
.preview-overlay {
position: fixed; top: var(--nav-height,64px); left: 0; right: 0;
height: calc(100vh - var(--nav-height,64px));
background: rgba(0,0,0,0.93); z-index: 9999;
}
.preview-swiper { width: 100%; height: 100%; }
.preview-postcard {
position: relative;
width: 100%;
height: 100%;
min-height: 640rpx;
background: #fffdf7;
background-image:
radial-gradient(circle at 12% 88%, rgba(255,107,157,0.05) 0, rgba(255,107,157,0.05) 60rpx, transparent 61rpx),
radial-gradient(circle at 90% 8%, rgba(255,186,210,0.08) 0, rgba(255,186,210,0.08) 46rpx, transparent 47rpx);
border: 1rpx solid #f5e3d0;
display: flex;
flex-direction: column;
padding: 20rpx 24rpx 18rpx;
box-sizing: border-box;
overflow-y: auto;
box-shadow: inset 0 0 0 6rpx rgba(255,255,255,0.6), 0 20rpx 60rpx rgba(0,0,0,0.35);
}
/* 左上角爱心印章(叠加在照片左上角) */
.postcard-seal {
position: absolute;
top: 20rpx; left: 20rpx;
width: 64rpx; height: 64rpx;
border-radius: 50%;
background: rgba(255,255,255,0.8);
border: 3rpx double rgba(255,107,157,0.6);
color: rgba(255,107,157,0.8);
font-size: 28rpx;
display: flex; align-items: center; justify-content: center;
transform: rotate(-12deg);
z-index: 3;
}
/* 右上角爱心邮票(叠加在照片右上角) */
.postcard-stamp {
position: absolute;
top: 14rpx; right: 14rpx;
width: 84rpx; height: 98rpx;
background: #fff;
border: 3rpx dashed #B74768;
border-radius: 8rpx;
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 3;
transform: rotate(4deg);
box-shadow: 0 4rpx 14rpx rgba(255,107,157,0.25);
}
.stamp-heart {
font-size: 36rpx;
line-height: 1.1;
color: #B74768;
}
.stamp-text {
font-size: 15rpx;
letter-spacing: 2rpx;
color: #B74768;
font-weight: 700;
margin-top: 2rpx;
}
/* 照片白边相框 */
.postcard-photo-frame {
position: relative;
flex: 1;
min-height: 220rpx;
padding: 10rpx;
background: #fff;
border-radius: 10rpx;
box-shadow: 0 10rpx 30rpx rgba(180,120,90,0.18);
box-sizing: border-box;
overflow: hidden;
}
.postcard-photo {
width: 100%;
height: 100%;
border-radius: 6rpx;
background: #f2e8d5;
display: block;
}
/* 底部书写区 */
.postcard-body {
flex-shrink: 0;
margin-top: 14rpx;
padding: 12rpx 4rpx 6rpx;
border-top: 1rpx dashed #e8c9a8;
}
.postcard-rule {
display: flex;
align-items: flex-start;
gap: 10rpx;
padding: 6rpx 0;
line-height: 1.45;
font-size: 24rpx;
color: #6b4a2f;
}
.rule-icon {
font-size: 22rpx;
flex-shrink: 0;
line-height: 1.45;
}
.rule-text {
flex: 1;
word-break: break-all;
line-height: 1.45;
}
.postcard-empty {
color: #c9b294;
font-size: 24rpx;
}
.postcard-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8rpx;
padding-top: 8rpx;
border-top: 1rpx dashed rgba(232,201,168,0.6);
}
.footer-slogan {
font-size: 20rpx;
color: #B74768;
letter-spacing: 2rpx;
}
.footer-hint {
font-size: 18rpx;
color: #c9a98a;
}
.preview-counter {
position: absolute; bottom: 24rpx; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.55); color: white; padding: 8rpx 28rpx;
border-radius: 40rpx; font-size: 26rpx;
z-index: 20;
}
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
+176
View File
@@ -0,0 +1,176 @@
const { getLayout } = require('../../utils/layout');
// pages/albums/albums.js
const { api, getImageUrl } = require('../../utils/request');
Page({
onResize: function() { this.setData(getLayout()); },
data: {
loading: true, loadError: false,
albums: [], uploadMode:false,
showMoreModal: false,
showEditModal: false,
currentAlbum: null,
editForm: { title: '', desc: '' }
},
onLoad: function(options) {
this.setData(getLayout());
this.setData({uploadMode: !!(options && options.upload === '1')});
},
onShow: function() {
this.loadAlbums();
},
onPullDownRefresh: async function() {
await this.loadAlbums();
wx.stopPullDownRefresh();
},
// 分享到好友
onShareAppMessage: function() {
return {
title: '让所有的爱有迹可循',
path: '/pages/albums/albums',
imageUrl: '/images/share.png'
};
},
// 分享到朋友圈
onShareTimeline: function() {
return {
title: '让所有的爱有迹可循',
imageUrl: '/images/share.png'
};
},
noop: function() {},
loadAlbums: async function() {
if (this._loading) return;
this._loading = true;
this.setData({ loading: true, loadError: false });
try {
var list = await api.albumList(null, 'GET', true);
list = (list || []).map(function(a) {
a.cover = getImageUrl(a.cover);
a.photo_count = a.photo_count || 0;
// 地理位置:优先显示地址名称,其次显示经纬度
a.location_text = a.location || ((a.lat && a.lng) ? (a.lat + ', ' + a.lng) : '');
return a;
});
this.setData({ albums: list });
} catch(e) { console.error(e); this.setData({ loadError: true }); } finally { this._loading = false; this.setData({ loading: false }); }
},
openAlbum: function(e) {
var id = e.currentTarget.dataset.id;
wx.navigateTo({ url: '/pages/album/album?album_id=' + id + (this.data.uploadMode ? '&upload=1' : '') });
},
// 显示更多菜单
showMoreMenu: function(e) {
var id = e.currentTarget.dataset.id;
var title = e.currentTarget.dataset.title || '默认相册';
var desc = e.currentTarget.dataset.desc || '';
var cover = e.currentTarget.dataset.cover || '';
this.setData({
showMoreModal: true,
currentAlbum: { id: id, title: title, description: desc, cover: cover }
});
},
hideMoreMenu: function() {
this.setData({ showMoreModal: false });
},
// 编辑相册
editAlbum: function() {
var album = this.data.currentAlbum;
if (!album) return;
this.setData({
showMoreModal: false,
showEditModal: true,
editForm: { title: album.title, desc: album.description || '' }
});
},
hideEditModal: function() {
this.setData({ showEditModal: false });
},
onEditTitleInput: function(e) { this.setData({ 'editForm.title': e.detail.value }); },
onEditDescInput: function(e) { this.setData({ 'editForm.desc': e.detail.value }); },
saveAlbumEdit: async function() {
var self = this;
var album = this.data.currentAlbum;
if (!album) return;
var title = this.data.editForm.title;
if (!title) { wx.showToast({ title: '请输入相册标题', icon: 'none' }); return; }
try {
wx.showLoading({ title: '保存中...' });
await api.albumUpdate({
id: album.id,
title: title,
description: this.data.editForm.desc || '',
// 保留原有地理位置
lat: album.lat || 0,
lng: album.lng || 0,
location: album.location || ''
});
wx.hideLoading();
wx.showToast({ title: '已保存 ✨', icon: 'success' });
this.hideEditModal();
this.loadAlbums();
} catch(e) {
wx.hideLoading();
wx.showToast({ title: e.error || '保存失败', icon: 'none' });
}
},
// 删除相册
deleteAlbum: function() {
var self = this;
var album = this.data.currentAlbum;
if (!album) return;
// 从相册列表中查找照片数量
var albums = this.data.albums;
var targetAlbum = albums.find(function(a) { return String(a.id) === String(album.id); });
var count = targetAlbum ? (targetAlbum.photo_count || 0) : 0;
var title = album.title || '该相册';
this.setData({ showMoreModal: false });
if (count > 0) {
wx.showModal({
title: '无法删除',
content: '「' + title + '」中还有 ' + count + ' 张照片,请先删除里面的全部照片后再删除相册',
showCancel: false,
confirmText: '知道了'
});
return;
}
wx.showModal({
title: '确认删除',
content: '确定要删除「' + title + '」吗?',
success: function(res) {
if (res.confirm) {
wx.showLoading({ title: '删除中...' });
api.albumDelete({ id: album.id }).then(function() {
wx.hideLoading();
wx.showToast({ title: '已删除', icon: 'success' });
self.loadAlbums();
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '删除失败', icon: 'none' });
});
}
}
});
}, goToPublish: function() { wx.navigateTo({ url: '/pages/publish/publish?type=album' }); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); },
goBack: function() { wx.navigateBack(); },
goToProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); }
});
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
},
"enablePullDownRefresh": true
}
+48
View File
@@ -0,0 +1,48 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/albums/albums.wxml - 相册列表 -->
<app-nav title="{{uploadMode ? '选择相册' : '相册'}}" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="j-body"><view class="j-row"><view><text class="j-eyebrow">MOMENTS WE KEEP</text><text class="j-page-title">{{uploadMode ? '选择上传照片的相册' : '照片里的日常'}}</text></view><button class="j-button j-button-small" bindtap="goToPublish">+ 建相册</button></view>
<page-state wx:if="{{loading}}" kind="loading" title="正在打开相册" /><page-state wx:elif="{{loadError}}" kind="error" title="加载失败" action="重试" bind:action="loadAlbums" />
<view class="j-album-list" wx:else><view class="j-album-print" wx:for="{{albums}}" wx:key="id" bindtap="openAlbum" data-id="{{item.id}}"><image wx:if="{{item.cover}}" src="{{item.cover}}" mode="aspectFill" /><view wx:else class="j-album-placeholder"><image src="/images/album.svg" /><text>还没有照片</text></view><view class="j-row"><view><text class="j-story-title">{{item.title || '我们的相册'}}</text><text class="j-caption">{{item.photo_count || 0}} 张照片</text></view><view class="j-link j-album-menu" catchtap="showMoreMenu" data-id="{{item.id}}" data-title="{{item.title}}" data-desc="{{item.description}}" data-cover="{{item.cover}}">•••</view></view><text wx:if="{{item.description}}" class="j-caption">{{item.description}}</text></view></view>
<page-state wx:if="{{!loading && !loadError && !albums.length}}" title="相册还空着" description="为旅行、周末或日常,留一本相册" action="创建相册" bind:action="goToPublish" />
</view>
<!-- 操作菜单弹窗 -->
<view class="modal-overlay" wx:if="{{showMoreModal}}" bindtap="hideMoreMenu">
<view class="action-sheet" catchtap="noop">
<view class="action-item" bindtap="editAlbum">
<image src="/images/edit.svg" mode="aspectFit" class="action-icon" />
<text>编辑相册信息</text>
</view>
<view class="action-item danger" bindtap="deleteAlbum">
<image src="/images/delete.svg" mode="aspectFit" class="action-icon" />
<text>删除相册</text>
</view>
<view class="action-cancel" bindtap="hideMoreMenu">取消</view>
</view>
</view>
<!-- 编辑相册弹窗 -->
<view class="modal-overlay" wx:if="{{showEditModal}}" bindtap="hideEditModal">
<view class="modal" catchtap="noop" style="width:90%;max-width:560rpx;">
<button class="modal-close" bindtap="hideEditModal"><image src="/images/close.svg" mode="aspectFit" /></button>
<text class="title">编辑相册</text>
<view class="input-group">
<label>相册标题</label>
<input class="input" placeholder="例如:旅行记录" value="{{editForm.title}}" bindinput="onEditTitleInput" />
</view>
<view class="input-group">
<label>描述(选填)</label>
<input class="input" placeholder="简单描述这个相册" value="{{editForm.desc}}" bindinput="onEditDescInput" />
</view>
<button class="btn btn-primary" bindtap="saveAlbumEdit" hover-class="none">保存</button>
</view>
</view>
</scroll-view>
</view>
+128
View File
@@ -0,0 +1,128 @@
/* pages/albums/albums.wxss */
@import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
.album-grid {
display: flex;
flex-wrap: wrap;
padding: 16rpx 4rpx 300rpx;
}
.album-cell {
width: calc(50% - 8rpx);
margin: 0 4rpx 16rpx 4rpx;
background: white;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
position: relative;
box-sizing: border-box;
}
.album-cover {
width: 100%;
height: 260rpx;
background: #f0f0f0;
}
.album-info {
padding: 20rpx 16rpx;
padding-right: 60rpx;
}
.album-name {
font-size: 28rpx;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.album-desc {
font-size: 22rpx;
color: var(--text-muted);
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.album-count {
font-size: 22rpx;
color: var(--pink);
font-weight: 500;
margin-top: 6rpx;
}
/* 三个点菜单按钮 */
.album-more-btn {
position: absolute;
right: 12rpx;
bottom: 12rpx;
width: 56rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.85);
border-radius: 24rpx;
z-index: 10;
transition: background 0.2s;
}
.album-more-btn:active {
background: rgba(255,255,255,0.6);
}
.more-dots {
font-size: 28rpx;
font-weight: 700;
color: #999;
letter-spacing: 2rpx;
line-height: 1;
}
/* 操作菜单弹窗 */
.action-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-radius: 32rpx 32rpx 0 0;
padding: 16rpx 0;
padding-bottom: calc(48rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
z-index: 1000;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.action-item {
display: flex;
align-items: center;
padding: 28rpx 40rpx;
font-size: 30rpx;
color: #333;
}
.action-item .action-icon {
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
opacity: 0.7;
}
.action-item.danger {
color: #ff4d4f;
}
.action-item.danger .action-icon {
opacity: 1;
}
.action-cancel {
margin: 12rpx 30rpx 0;
padding: 24rpx;
text-align: center;
font-size: 30rpx;
color: #999;
background: #f5f5f5;
border-radius: 20rpx;
}
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
@@ -0,0 +1,193 @@
const { getLayout } = require('../../utils/layout');
// pages/checkin-detail/checkin-detail.js
const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request');
const { createSwipeBack } = require('../../utils/util');
const { requestSubscribeAfterAction } = require('../../utils/subscribe');
const COOLDOWN = 5000;
Page({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false, item: null, logs: [], freqLabel: '', typeLabel: '', myStreak: 0, partnerStreak: 0, showCheckModal: false, checkPhoto: '', checkNote: '', lastActionTime: 0, detailId: 0, myAvatar: '', partnerAvatar: '', myName: '我', partnerName: 'TA' },
onLoad: function(options) {
this.setData(getLayout());
var id = options.id;
if (id) {
this.setData({ detailId: parseInt(id) });
this.loadCoupleInfo();
this.loadDetail(parseInt(id));
}
var self = this;
var swipe = createSwipeBack(function() { wx.navigateBack(); });
this.onTouchStart = swipe.onTouchStart;
this.onTouchEnd = swipe.onTouchEnd;
},
noop: function() {},
retryDetail: function() { this.loadDetail(this.data.detailId); },
onShow: function() {
if (this.data.detailId) {
this.loadCoupleInfo();
this.loadDetail(this.data.detailId);
}
},
// 从接口加载情侣头像和名称
loadCoupleInfo: function() {
var self = this;
api.coupleInfo().then(function(info) {
self._applyCoupleInfo(info);
}).catch(function(e) { console.error(e); });
},
_applyCoupleInfo: function(info) {
if (!info) return;
var myUid = wx.getStorageSync('userId');
var p1 = info.partner1 || {}, p2 = info.partner2 || {};
var me = (p1.id == myUid) ? p1 : p2;
var partner = (p1.id == myUid) ? p2 : p1;
this.setData({
myAvatar: getImageUrl(me.avatar || ''),
partnerAvatar: getImageUrl(partner.avatar || ''),
myName: me.nickname || '我',
partnerName: partner.nickname || 'TA'
});
},
loadDetail: async function(id) {
if (this._pageLoading) return;
this._pageLoading = true;
this.setData({loading:true, loadError:false});
try {
var data = await api.checkinDetail(id);
var item = data.item || {};
var myUid = wx.getStorageSync('userId');
var freqLabels = { daily: '每天', weekly: '每周', monthly: '每月', once: '一次', unlimited: '不限' };
var typeLabels = { both: '双方', first: '第一视角', her: '她打卡' };
var freqLabel = freqLabels[item.frequency] || '每天';
var typeLabel = typeLabels[item.type] || '双方';
// 创建人头像URL
item.creator_avatar_url = item.creator_avatar ? getImageUrl(item.creator_avatar) : '';
// 使用本次页面加载的情侣信息
var myAvatar = this.data.myAvatar;
var partnerAvatar = this.data.partnerAvatar;
var myName = this.data.myName;
var partnerName = this.data.partnerName;
var logs = (data.logs || []).map(function(l) {
l.time = l.created_at ? l.created_at.substring(0, 16).replace('T', ' ') : '';
l.photoUrl = l.photo ? getImageUrl(l.photo) : '';
return l;
});
// 根据打卡权限类型映射统计数据
var isCreator = (myUid == item.created_by);
var showMyStats = (item.type == 'both') || (item.type == 'first');
var showPartnerStats = (item.type == 'both') || (item.type == 'her');
var checkinerIsMe = (item.type == 'first' && isCreator) || (item.type == 'her' && !isCreator);
var displayAvatar = (item.type == 'both' || checkinerIsMe) ? myAvatar : partnerAvatar;
var displayName = (item.type == 'both' || checkinerIsMe) ? myName : partnerName;
var checkinToday = checkinerIsMe ? (data.my_today || 0) : (data.partner_today || 0);
var checkinWeek = checkinerIsMe ? (data.my_week || 0) : (data.partner_week || 0);
var checkinMonth = checkinerIsMe ? (data.my_month || 0) : (data.partner_month || 0);
var checkinYear = checkinerIsMe ? (data.my_year || 0) : (data.partner_year || 0);
var checkinStreak = checkinerIsMe ? (item.my_streak || 0) : (item.partner_streak || 0);
var pageData = {
item: item,
logs: logs,
freqLabel: freqLabel,
typeLabel: typeLabel,
myStreak: (item.type == 'both') ? (item.my_streak || 0) : checkinStreak,
partnerStreak: (item.type == 'both') ? (item.partner_streak || 0) : 0,
showMyStats: showMyStats,
showPartnerStats: showPartnerStats,
myToday: (item.type == 'both') ? (data.my_today || 0) : checkinToday,
myWeek: (item.type == 'both') ? (data.my_week || 0) : checkinWeek,
myMonth: (item.type == 'both') ? (data.my_month || 0) : checkinMonth,
myYear: (item.type == 'both') ? (data.my_year || 0) : checkinYear,
partnerToday: (item.type == 'both') ? (data.partner_today || 0) : 0,
partnerWeek: (item.type == 'both') ? (data.partner_week || 0) : 0,
partnerMonth: (item.type == 'both') ? (data.partner_month || 0) : 0,
partnerYear: (item.type == 'both') ? (data.partner_year || 0) : 0,
myAvatar: displayAvatar,
myName: displayName,
partnerAvatar: partnerAvatar,
partnerName: partnerName
};
this.setData(pageData);
} catch(e) {
this.setData({loadError:true}); console.error(e); }
finally { this._pageLoading = false; this.setData({loading:false}); }
},
// ===== 打卡操作 =====
showCheckinModal: function() {
var now = Date.now();
if (now - this.data.lastActionTime < COOLDOWN) {
wx.showToast({ title: '操作太快,请稍后', icon: 'none' }); return;
}
this.data.lastActionTime = now;
this.setData({ showCheckModal: true, checkPhoto: '', checkNote: '' });
},
onCheckNoteInput: function(e) { this.setData({ checkNote: e.detail.value }); },
chooseCheckPhoto: function() {
var self = this;
wx.chooseImage({
count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'],
success: function(res) { self.setData({ checkPhoto: res.tempFilePaths[0] }); }
});
},
submitCheck: async function() {
var item = this.data.item;
if (!item) return;
// ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
requestSubscribeAfterAction('checkin');
try {
var photoPath = '';
if (this.data.checkPhoto) {
wx.showLoading({ title: '上传中...' });
try {
var upResult = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: this.data.checkPhoto,
name: 'file',
formData: { sub_dir: 'checkins' }
});
photoPath = upResult.path || '';
} catch(e) {
// 内容安全违规:抛出错误中断打卡并提示用户
if (e && e.error) {
throw e;
}
photoPath = '';
}
wx.hideLoading();
}
var res = await api.checkinDo({ id: item.id, photo: photoPath, note: this.data.checkNote });
this.setData({ showCheckModal: false });
var toast = '打卡成功 ✅';
if (item.type === 'both' && item.partner_done_today !== true && !res.both_done) {
toast = '已打卡,等待对方';
}
wx.showToast({ title: toast, icon: 'success' });
// 刷新详情数据
this.loadDetail(item.id);
} catch(e) { wx.showToast({ title: e.error || '打卡失败', icon: 'none' }); }
},
hideCheckModal: function() { this.setData({ showCheckModal: false }); },
// ===== 图片预览 =====
previewPhoto: function(e) {
var url = e.currentTarget.dataset.url;
if (url) {
wx.previewImage({ urls: [url], current: url });
}
},
goBack: function() { wx.navigateBack(); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); },
goProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); }
});
@@ -0,0 +1,7 @@
{
"navigationStyle": "custom",
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}
@@ -0,0 +1,145 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/checkin-detail/checkin-detail.wxml - 打卡详情 -->
<app-nav title="打卡详情" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}" style="height:100vh;" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<view class="c-body">
<page-state wx:if="{{loading}}" kind="loading" title="正在加载详情" /><page-state wx:elif="{{loadError}}" kind="error" title="详情加载失败" action="重试" bind:action="retryDetail" />
<block wx:else>
<!-- 打卡信息卡片(与列表卡片一致) -->
<view class="detail-card" wx:if="{{item}}">
<!-- 主题 -->
<view class="card-row card-theme">
<text class="card-icon" wx:if="{{item.icon && item.icon !== '✅'}}">{{item.icon}}</text><image wx:else class="card-icon" src="/images/check.svg" mode="aspectFit" />
<text class="card-title">{{item.title}}</text>
</view>
<!-- 打卡频率 -->
<view class="card-row">
<text class="card-label">打卡频率</text>
<text class="card-value">{{freqLabel}}</text>
</view>
<!-- 参与人(双人:双方昵称+头像 / 单人:对应昵称+头像) -->
<view class="card-row" wx:if="{{item.type == 'both'}}">
<text class="card-label">参与人</text>
<view class="card-users">
<view class="user-chip">
<image wx:if="{{myAvatar}}" class="chip-avatar" src="{{myAvatar}}" mode="aspectFill" />
<text wx:else class="chip-avatar-fallback">{{myName ? myName.charAt(0) : '我'}}</text>
<text class="chip-name">{{myName}}</text>
</view>
<text class="chip-sep">·</text>
<view class="user-chip">
<image wx:if="{{partnerAvatar}}" class="chip-avatar" src="{{partnerAvatar}}" mode="aspectFill" />
<text wx:else class="chip-avatar-fallback">{{partnerName ? partnerName.charAt(0) : 'TA'}}</text>
<text class="chip-name">{{partnerName}}</text>
</view>
</view>
</view>
<view class="card-row" wx:elif="{{item.type == 'first' || item.type == 'her'}}">
<text class="card-label">参与人</text>
<view class="card-users">
<view class="user-chip">
<image wx:if="{{item.type == 'first' && myAvatar}}" class="chip-avatar" src="{{myAvatar}}" mode="aspectFill" />
<image wx:elif="{{item.type == 'her' && partnerAvatar}}" class="chip-avatar" src="{{partnerAvatar}}" mode="aspectFill" />
<text wx:else class="chip-avatar-fallback">{{item.type == 'first' ? (myName ? myName.charAt(0) : '我') : (partnerName ? partnerName.charAt(0) : 'TA')}}</text>
<text class="chip-name">{{item.type == 'first' ? myName : partnerName}}</text>
</view>
</view>
</view>
<!-- 创建人 -->
<view class="card-row">
<text class="card-label">创建人</text>
<view class="card-creator-info">
<image wx:if="{{item.creator_avatar_url}}" class="creator-avatar" src="{{item.creator_avatar_url}}" mode="aspectFill" />
<text wx:else class="creator-avatar-fallback">{{item.creator_name ? item.creator_name.charAt(0) : '?'}}</text>
<text class="creator-name">{{item.creator_name || '未知'}}</text>
</view>
</view>
<!-- 连续统计卡片 -->
<view class="streak-section">
<!-- 我的连续(根据权限控制显示) -->
<view class="streak-card" wx:if="{{showMyStats}}">
<view class="streak-header">
<image wx:if="{{myAvatar}}" class="streak-avatar" src="{{myAvatar}}" mode="aspectFill" />
<text wx:else class="streak-avatar-fallback">{{myName ? myName.charAt(0) : '我'}}</text>
<text class="streak-user">{{myName}}</text>
</view>
<view class="streak-grid">
<view class="streak-item"><text class="streak-num">{{myToday}}</text><text class="streak-label">当天</text></view>
<view class="streak-item"><text class="streak-num">{{myWeek}}</text><text class="streak-label">本周</text></view>
<view class="streak-item"><text class="streak-num">{{myMonth}}</text><text class="streak-label">本月</text></view>
<view class="streak-item"><text class="streak-num">{{myYear}}</text><text class="streak-label">今年</text></view>
</view>
</view>
<!-- TA连续(根据权限控制显示) -->
<view class="streak-card" wx:if="{{showPartnerStats}}">
<view class="streak-header">
<image wx:if="{{partnerAvatar}}" class="streak-avatar" src="{{partnerAvatar}}" mode="aspectFill" />
<text wx:else class="streak-avatar-fallback">{{partnerName ? partnerName.charAt(0) : 'TA'}}</text>
<text class="streak-user">{{partnerName}}</text>
</view>
<view class="streak-grid">
<view class="streak-item"><text class="streak-num">{{partnerToday}}</text><text class="streak-label">当天</text></view>
<view class="streak-item"><text class="streak-num">{{partnerWeek}}</text><text class="streak-label">本周</text></view>
<view class="streak-item"><text class="streak-num">{{partnerMonth}}</text><text class="streak-label">本月</text></view>
<view class="streak-item"><text class="streak-num">{{partnerYear}}</text><text class="streak-label">今年</text></view>
</view>
</view>
</view>
</view>
<!-- 打卡按钮(独立居中,匹配全局UI) -->
<button class="detail-checkin-btn" wx:if="{{item.can_checkin}}" bindtap="showCheckinModal" hover-class="none">记录这一次</button>
<!-- 打卡记录列表 -->
<text class="section-title">打卡记录</text>
<view class="log-list" wx:if="{{logs.length > 0}}">
<view class="log-item" wx:for="{{logs}}" wx:key="id">
<view class="log-dot" style="background:{{item.avatar ? '#B74768' : '#9C27B0'}};"></view>
<view class="log-card">
<view class="log-row">
<text class="log-user">{{item.nickname || '我们'}}</text>
<text class="log-time">{{item.time}}</text>
</view>
<text wx:if="{{item.note}}" class="log-note">{{item.note}}</text>
<view wx:if="{{item.photo && item.photoUrl}}" class="log-photo-wrap" bindtap="previewPhoto" data-url="{{item.photoUrl}}">
<image src="{{item.photoUrl}}" mode="aspectFill" class="log-photo" />
<view class="log-photo-polaroid">
<text class="polaroid-date">{{item.time}}</text>
<text class="polaroid-icon">📸</text>
</view>
</view>
</view>
</view>
</view>
<view class="empty-state" wx:else>
<text>📭</text>
<text>暂无打卡记录</text>
</view>
</block>
</view>
</scroll-view>
<!-- 打卡弹窗 -->
<view class="modal-overlay" wx:if="{{showCheckModal}}" bindtap="hideCheckModal">
<view class="modal" catchtap="noop" style="max-width:600rpx;">
<text class="title">{{item.icon || '✅'}} {{item.title}}</text>
<text class="modal-subtitle">记录这一刻</text>
<view class="input-group"><label>备注</label><textarea class="input" style="min-height:100rpx;" placeholder="一句话..." value="{{checkNote}}" bindinput="onCheckNoteInput" maxlength="60" /></view>
<view class="input-group"><label>照片</label>
<view class="photo-upload-box" bindtap="chooseCheckPhoto">
<image wx:if="{{checkPhoto}}" src="{{checkPhoto}}" mode="aspectFill" class="photo-upload-img" />
<view wx:else class="photo-upload-placeholder"><text>+</text><text>添加照片</text></view>
</view>
</view>
<view class="modal-actions">
<button class="btn btn-outline" bindtap="hideCheckModal" hover-class="none">取消</button>
<button class="btn btn-primary" bindtap="submitCheck" hover-class="none">确认打卡</button>
</view>
</view>
</view>
</view>
@@ -0,0 +1,20 @@
@import '../../styles/common.wxss';
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
@import '../../styles/companion.wxss';
.streak-section { margin-top:28rpx; display:flex; flex-direction:column; gap:20rpx; }
.streak-card { background:#FAF5EE; border-top:1rpx solid #E9DDD0; padding:24rpx 20rpx; border-radius:12rpx; }
.streak-header { display:flex; align-items:center; gap:12rpx; margin-bottom:20rpx; }
.streak-avatar,.streak-avatar-fallback { width:38rpx; height:38rpx; border-radius:50%; }.streak-avatar-fallback { background:#F4E2E3; color:#943C58; display:flex; align-items:center; justify-content:center; font-size:21rpx; }
.streak-user { font-size:25rpx; font-weight:500; color:#352E2A; }.streak-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:12rpx; }.streak-item { display:flex; flex-direction:column; align-items:center; gap:8rpx; }
.streak-num { font-size:36rpx; font-weight:500; color:#943C58; }.streak-label { font-size:21rpx; color:#85776D; }
.detail-checkin-btn { width:100%; margin:24rpx 0 40rpx; padding:0; height:84rpx; line-height:84rpx; border-radius:18rpx; background:#B74768; color:#FFFDF8; font-size:28rpx; font-weight:500; box-shadow:none; }.detail-checkin-btn::after { border:0; }
.section-title { display:block; font-size:29rpx; font-weight:600; color:#352E2A; margin:36rpx 0 24rpx; }
.log-list { border-left:1rpx solid #DFD2C3; margin-left:8rpx; padding-left:28rpx; }.log-item { position:relative; margin-bottom:24rpx; }
.log-dot { position:absolute; left:-36rpx; top:24rpx; width:14rpx; height:14rpx; border:4rpx solid #FAF5EE; border-radius:50%; }
.log-card { background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:12rpx; padding:24rpx; }.log-row { display:flex; align-items:center; justify-content:space-between; gap:14rpx; flex-wrap:wrap; }.log-user { color:#352E2A; font-size:25rpx; font-weight:500; }.log-time { color:#A39386; font-size:20rpx; }
.log-note { display:block; font-size:26rpx; line-height:1.7; color:#65594F; margin-top:14rpx; }
.log-photo-wrap { padding:10rpx; margin-top:20rpx; background:#FAF5EE; border:1rpx solid #E9DDD0; }.log-photo { width:100%; height:280rpx; display:block; }.log-photo-polaroid { display:none; }
.empty-state { padding:64rpx 20rpx; font-size:25rpx; color:#85776D; text-align:center; }
.modal-overlay { z-index:1200; }.modal .title { display:block; color:#352E2A; text-align:left; }.modal-subtitle { text-align:left; }.modal-actions { display:flex; gap:20rpx; margin-top:28rpx; }.modal-actions .btn { flex:1; min-width:0; padding:22rpx 12rpx; }
.photo-upload-box { width:160rpx; height:160rpx; border:1rpx dashed #D5C5B3; border-radius:16rpx; overflow:hidden; background:#FAF5EE; }.photo-upload-img { width:100%; height:100%; }.photo-upload-placeholder { height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8rpx; color:#85776D; font-size:22rpx; }.photo-upload-placeholder text:first-child { font-size:44rpx; }
+201
View File
@@ -0,0 +1,201 @@
const { getLayout } = require('../../utils/layout');
// Diary journal with server pagination.
const { api, getImageUrl } = require('../../utils/request');
const { diaryEntry } = require('../../utils/journal');
const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos');
Page({
onResize: function() { this.setData(getLayout()); },
data: {
loading: true, loadError: false,
list: [],
page: 1,
pageSize: 20,
total: 0,
loadingMore: false,
hasMore: true,
scrollTop: 0,
tabType: 'all' // 'all' 全部 | 'favorite' 收藏
},
onLoad: function(options) {
this.setData(getLayout());
this.setData({ scrollTop: getScrollPosition('diary') });
},
onShow: function() {
var self = this;
var savedTop = getScrollPosition('diary');
this.setData({ scrollTop: savedTop });
setTimeout(function() {
self.setData({ scrollTop: savedTop });
}, 100);
this.loadData();
},
// 收藏 tab 切换已移除
onScroll: function(e) {
saveScrollPosition('diary', e.detail.scrollTop);
},
onPullDownRefresh: async function() {
this.setData({ page: 1, list: [], hasMore: true });
await this.loadData();
wx.stopPullDownRefresh();
},
onScrollToLower: function() {
if (this.data.loadingMore || !this.data.hasMore) return;
this.loadMore();
},
onShareAppMessage: function() {
return { title: '让所有的爱有迹可循', path: '/pages/diary/diary', imageUrl: '/images/share.png' };
},
onShareTimeline: function() {
return { title: '让所有的爱有迹可循', imageUrl: '/images/share.png' };
},
loadData: async function() {
if (this._loading) return;
this._loading = true;
this.setData({ loading: true, loadError: false });
try {
var data = await api.diaryList({ page: 1, page_size: this.data.pageSize });
var items = this._formatItems(data.items || []);
this.setData({
page: 1,
list: items,
total: data.total || 0,
hasMore: (data.total || 0) > this.data.pageSize
});
this._loadPartnerInfo();
} catch (e) {
this.setData({ loadError: true });
console.error(e);
wx.showToast({ title: (e && (e.error || e.errMsg || e.message)) || '加载失败', icon: 'none' });
} finally { this._loading = false; this.setData({ loading: false }); }
},
loadMore: async function() {
if (this.data.loadingMore || !this.data.hasMore) return;
this.setData({ loadingMore: true });
try {
var nextPage = this.data.page + 1;
var data = await api.diaryList({ page: nextPage, page_size: this.data.pageSize });
var newItems = this._formatItems(data.items || []);
var merged = this.data.list.concat(newItems);
this.setData({
list: merged,
page: nextPage,
hasMore: merged.length < (data.total || 0),
loadingMore: false
});
this._loadPartnerInfo();
} catch (e) {
this.setData({ loadingMore: false });
}
},
_formatItems: function(items) {
var myUid = wx.getStorageSync('userId');
var partnerNames = {};
var partnerAvatars = {};
return items.map(function(item) {
item.creator_name = partnerNames[item.created_by] || '我';
item.creator_avatar = partnerAvatars[item.created_by] || '';
item.created_time = item.created_at ? item.created_at.substring(0, 16).replace('T', ' ') : '';
var imgs = item.images || [];
if (typeof imgs === 'string') {
try { imgs = JSON.parse(imgs); } catch(e) { imgs = []; }
}
if (!Array.isArray(imgs)) imgs = [];
item.imageUrls = imgs.map(function(p) { return getImageUrl(p); });
// 生成摘要文本(截断内容,用于无图卡片展示)
var summaryText = item.content || item.title || '';
summaryText = summaryText.replace(/\s+/g, ' ');
if (summaryText.length > 80) summaryText = summaryText.substring(0, 80) + '...';
item.summary = summaryText;
// 地理位置:优先显示地址名称,其次显示经纬度
item.location_text = item.location || ((item.lat && item.lng) ? (item.lat + ', ' + item.lng) : '');
return Object.assign(item, diaryEntry(item));
});
},
_loadPartnerInfo: async function() {
try {
var info = await api.coupleInfo();
var myUid = wx.getStorageSync('userId');
var p1 = info.partner1 || {}, p2 = info.partner2 || {};
var names = {}, avatars = {};
names[p1.id] = p1.nickname || p1.username || 'TA';
names[p2.id] = p2.nickname || p2.username || 'TA';
avatars[p1.id] = getImageUrl(p1.avatar);
avatars[p2.id] = getImageUrl(p2.avatar);
var oldList = this.data.list;
var changed = false;
var updates = {};
for (var i = 0; i < oldList.length; i++) {
var item = oldList[i];
var newName = names[item.created_by] || '我';
var newAvatar = avatars[item.created_by] || '';
if (item.creator_name !== newName || item.creator_avatar !== newAvatar) {
updates['list[' + i + '].creator_name'] = newName;
updates['list[' + i + '].author'] = newName;
updates['list[' + i + '].creator_avatar'] = newAvatar;
changed = true;
}
}
if (changed) {
this.setData(updates);
}
} catch (e) {}
},
_refreshPartnerInfo: function() {
var self = this;
api.coupleInfo().then(function(info) {
var myUid = wx.getStorageSync('userId');
var p1 = info.partner1 || {}, p2 = info.partner2 || {};
var names = {}, avatars = {};
names[p1.id] = p1.nickname || p1.username || 'TA';
names[p2.id] = p2.nickname || p2.username || 'TA';
avatars[p1.id] = getImageUrl(p1.avatar);
avatars[p2.id] = getImageUrl(p2.avatar);
var oldList = self.data.list;
var changed = false;
var updates = {};
for (var i = 0; i < oldList.length; i++) {
var item = oldList[i];
var newName = names[item.created_by] || '我';
var newAvatar = avatars[item.created_by] || '';
if (item.creator_name !== newName || item.creator_avatar !== newAvatar) {
updates['list[' + i + '].creator_name'] = newName;
updates['list[' + i + '].author'] = newName;
updates['list[' + i + '].creator_avatar'] = newAvatar;
changed = true;
}
}
if (changed) {
self.setData(updates);
}
}).catch(function() {});
},
// 点击进入日记详情
goToDetail: function(e) {
var id = e.currentTarget.dataset.id;
if (!id) return;
saveScrollPosition('diary', this.data.scrollTop);
wx.navigateTo({ url: '/pages/diary_detail/diary_detail?id=' + id });
},
_savePosBeforeLeave: function() {
saveScrollPosition('diary', this.data.scrollTop);
},
openJournalEntry: function(e) { this.goToDetail(e); },
goToPublish: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/publish/publish?type=diary' }); },
goHome: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/index/index' }); },
goBack: function() { wx.navigateBack(); },
goProfile: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/profile/profile' }); }
});
+8
View File
@@ -0,0 +1,8 @@
{
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}
+8
View File
@@ -0,0 +1,8 @@
<import src="../../templates/journal.wxml" />
<launch-transition />
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}"><app-nav title="日记" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bindscrolltolower="onScrollToLower" lower-threshold="100" bindscroll="onScroll" scroll-top="{{scrollTop}}">
<view class="j-body"><view class="j-row"><view><text class="j-eyebrow">LIFE IN WORDS</text><text class="j-page-title">生活里的每一页</text></view><button class="j-button j-button-small" bindtap="goToPublish">+ 写日记</button></view>
<page-state wx:if="{{loading}}" kind="loading" title="正在翻开日记" /><page-state wx:elif="{{loadError}}" kind="error" title="加载失败" action="重试" bind:action="loadData" />
<block wx:else><block wx:for="{{list}}" wx:key="id"><template is="journal-entry" data="{{entry:item}}" /></block><page-state wx:if="{{!list.length}}" title="故事,从今天开始" description="写下第一篇,记录一个平常又特别的瞬间" action="写日记" bind:action="goToPublish" /></block>
<view class="j-caption j-center" wx:if="{{loadingMore}}">正在加载更多…</view></view></scroll-view></view>
+3
View File
@@ -0,0 +1,3 @@
@import '../../styles/common.wxss';
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
+230
View File
@@ -0,0 +1,230 @@
const { getLayout } = require('../../utils/layout');
const { api, getImageUrl } = require('../../utils/request');
Page({
noop: function() {},
onResize: function() { this.setData(getLayout()); },
data: { loadError: false,
diary: null,
loading: true,
comments: [],
commentPage: 1,
hasMoreComments: true,
showCommentInput: false,
commentText: '',
replyTo: null,
coverSwiperIndex: 0,
previewImages: [],
previewIndex: 0,
showPreview: false,
myUid: 0
},
onLoad: function(options) {
this.setData(getLayout());
var diaryId = options.id;
if (!diaryId) { wx.showToast({ title: '参数错误', icon: 'none' }); return; }
this.setData({ diaryId: parseInt(diaryId), myUid: wx.getStorageSync('userId') || 0 });
this.loadDiary();
this.loadComments();
},
loadDiary: async function() {
if (this._pageLoading) return;
this._pageLoading = true;
this.setData({loading:true, loadError:false});
try {
var data = await api.diaryDetail(this.data.diaryId);
data = this._formatDiary(data);
this.setData({ diary: data, myUid: wx.getStorageSync('userId') || 0, loading: false });
} catch (e) {
this.setData({loadError:true});
wx.showToast({ title: '加载失败', icon: 'none' });
this.setData({ loading: false });
}
finally { this._pageLoading = false; this.setData({loading:false}); }
},
_formatDiary: function(data) {
var myUid = wx.getStorageSync('userId') || 0;
data.creator_name = data.creator_name || (data.created_by == myUid ? '我' : 'TA');
data.creator_avatar = getImageUrl(data.creator_avatar);
data.created_time = data.created_at ? data.created_at.substring(0, 16).replace('T', ' ') : '';
var imgs = data.images || [];
if (typeof imgs === 'string') {
try { imgs = JSON.parse(imgs); } catch(e) { imgs = []; }
}
if (!Array.isArray(imgs)) imgs = [];
data.imageUrls = imgs.map(function(p) { return getImageUrl(p); });
data.comment_count = data.comment_count || 0;
// 地理位置:优先显示地址名称,其次显示经纬度(经纬度为0的不显示)
data.location_text = '';
var hasCoord = data.lat && data.lng && parseFloat(data.lat) !== 0 && parseFloat(data.lng) !== 0;
if (data.location) {
data.location_text = data.location;
} else if (hasCoord) {
data.location_text = data.lat + ', ' + data.lng;
}
return data;
},
loadComments: async function() {
try {
var data = await api.diaryCommentList({ diary_id: this.data.diaryId });
if (data && data.length > 0) {
data.forEach(function(c) {
if (c.created_at) {
c._time = c.created_at.substring(0, 16).replace('T', ' ');
} else if (c.create_time) {
c._time = c.create_time.substring(0, 16).replace('T', ' ');
} else {
c._time = '';
}
});
}
this.setData({ comments: data || [] });
} catch (e) { console.error(e); }
},
onShareTap: function() {
wx.showActionSheet({
itemList: ['分享给微信好友', '分享到朋友圈', '生成分享海报'],
success: function(res) {
if (res.tapIndex === 0) {
wx.shareAppMessage ? wx.shareAppMessage() : wx.showToast({ title: '请点击右上角分享', icon: 'none' });
} else if (res.tapIndex === 1) {
wx.showToast({ title: '请点击右上角分享到朋友圈', icon: 'none' });
} else {
wx.showToast({ title: '功能开发中', icon: 'none' });
}
}
});
},
onCoverSwiperChange: function(e) {
this.setData({ coverSwiperIndex: e.detail.current });
},
onImageTap: function(e) {
var idx = this.data.coverSwiperIndex;
var imgs = this.data.diary.imageUrls || [];
this.setData({ showPreview: true, previewIndex: idx, previewImages: imgs });
},
closePreview: function() { this.setData({ showPreview: false }); },
onPreviewSwiperChange: function(e) { this.setData({ previewIndex: e.detail.current }); },
onCommentTap: function() {
this.setData({ showCommentInput: true, replyTo: null, commentText: '' });
},
onReplyTap: function(e) {
var item = e.currentTarget.dataset.item;
this.setData({
showCommentInput: true,
replyTo: { id: item.id, user_id: item.user_id, nickname: item.nickname },
commentText: ''
});
},
onCommentInput: function(e) {
this.setData({ commentText: e.detail.value });
},
cancelComment: function() {
this.setData({ showCommentInput: false, replyTo: null, commentText: '' });
},
submitComment: async function() {
var text = this.data.commentText.trim();
if (!text) { wx.showToast({ title: '请输入评论内容', icon: 'none' }); return; }
try {
var params = { diary_id: this.data.diaryId, content: text };
if (this.data.replyTo) {
params.parent_id = this.data.replyTo.id;
params.reply_to_user_id = this.data.replyTo.user_id;
params.reply_to_username = this.data.replyTo.nickname;
}
var res = await api.diaryCommentAdd(params);
if (res.success) {
var newComment = res.comment || {};
if (newComment.created_at) {
newComment._time = newComment.created_at.substring(0, 16).replace('T', ' ');
} else if (newComment.create_time) {
newComment._time = newComment.create_time.substring(0, 16).replace('T', ' ');
} else {
var now = new Date();
newComment._time = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0') + ' ' + String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0');
}
var newComments = this.data.comments.slice();
newComments.push(newComment);
this.setData({
comments: newComments,
showCommentInput: false,
replyTo: null,
commentText: '',
'diary.comment_count': (this.data.diary.comment_count || 0) + 1
});
wx.showToast({ title: '评论成功', icon: 'success' });
}
} catch (e) {
wx.showToast({ title: e.error || '评论失败', icon: 'none' });
}
},
onCommentLongPress: function(e) {
var item = e.currentTarget.dataset.item;
if (item.user_id != this.data.myUid) return;
var self = this;
wx.showModal({
title: '删除评论',
content: '确定要删除这条评论吗?',
success: async function(res) {
if (res.confirm) {
try {
await api.diaryCommentDelete({ id: item.id });
var list = self.data.comments.filter(function(c) { return c.id != item.id; });
self.setData({
comments: list,
'diary.comment_count': Math.max(0, (self.data.diary.comment_count || 0) - 1)
});
wx.showToast({ title: '已删除', icon: 'success' });
} catch (e) { wx.showToast({ title: '删除失败', icon: 'none' }); }
}
}
});
},
onMoreTap: function() {
var self = this;
wx.showActionSheet({
itemList: ['编辑', '删除'],
success: function(res) {
if (res.tapIndex === 0) {
wx.navigateTo({ url: '/pages/diary_edit/diary_edit?id=' + self.data.diaryId });
} else if (res.tapIndex === 1) {
self._deleteDiary();
}
}
});
},
_deleteDiary: function() {
var self = this;
wx.showModal({
title: '删除日记',
content: '确定要删除这篇日记吗?',
success: async function(res) {
if (res.confirm) {
try {
await api.diaryDelete({ id: self.data.diaryId });
wx.showToast({ title: '已删除', icon: 'success' });
setTimeout(function() { wx.navigateBack(); }, 1500);
} catch (e) {
wx.showToast({ title: e.error || '删除失败', icon: 'none' });
}
}
}
});
},
goBack: function() { wx.navigateBack(); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); }
});
@@ -0,0 +1,8 @@
{
"navigationStyle": "custom",
"enablePullDownRefresh": false,
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}
+101
View File
@@ -0,0 +1,101 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<app-nav title="日记详情" back="{{true}}" /><view class="page-bg">
<view class="read-state" wx:if="{{loading || loadError}}"><page-state wx:if="{{loading}}" kind="loading" title="正在加载日记" /><page-state wx:else kind="error" title="日记加载失败" action="重试" bind:action="loadDiary" /></view>
<scroll-view scroll-y class="read-scroll" wx:if="{{!loading && !loadError && diary}}" show-scrollbar="{{false}}" enhanced>
<view class="read-page">
<view class="read-author"><image class="read-avatar" src="{{diary.creator_avatar || '/images/default-avatar.svg'}}" mode="aspectFill" /><view class="read-author-info"><text class="read-name">{{diary.creator_name || 'TA'}}</text><text class="read-date">{{diary.created_time}}</text><text class="read-location" wx:if="{{diary.location_text}}">{{diary.location_text}}</text></view><view class="read-menu" catchtap="onMoreTap" aria-label="日记操作"><image src="/images/more.svg" mode="aspectFit" /></view></view>
<view class="read-paper">
<!-- ===== 图片区:有图轮播,无图用背景替代 ===== -->
<view wx:if="{{diary.imageUrls && diary.imageUrls.length > 0}}">
<view class="img-box">
<swiper class="img-swiper" circular="{{false}}" indicator-dots="{{false}}" bindchange="onCoverSwiperChange" current="{{coverSwiperIndex}}">
<swiper-item wx:for="{{diary.imageUrls}}" wx:key="*this" class="img-slide">
<image src="{{item}}" mode="aspectFill" class="img-item" catchtap="onImageTap" />
</swiper-item>
</swiper>
<view class="img-count" wx:if="{{diary.imageUrls.length > 1}}">
<text>{{coverSwiperIndex + 1}}/{{diary.imageUrls.length}}</text>
</view>
</view>
</view>
<!-- ===== 内容区:白色卡片 ===== -->
<view class="content-card">
<!-- 标题 -->
<text class="title-text" wx:if="{{diary.title}}">{{diary.title}}</text>
<!-- 正文 -->
<text class="content-text" wx:if="{{diary.content}}">{{diary.content}}</text>
</view>
</view>
<!-- ===== 评论区 ===== -->
<view class="comment-box">
<view class="comment-head">
<text class="comment-head-text">留言 {{diary.comment_count || 0}}</text>
</view>
<view class="comment-empty" wx:if="{{comments.length === 0}}">
<text>还没有留言,写下你读到这里的心情吧</text>
</view>
<view class="comment-item" wx:for="{{comments}}" wx:key="id" wx:for-item="c"
data-item="{{c}}" catchlongpress="onCommentLongPress" catchtap="onReplyTap">
<image class="comment-avatar" src="{{c.avatar_url || '/images/default-avatar.svg'}}" mode="aspectFill" />
<view class="comment-body">
<view class="comment-top">
<text class="comment-name">{{c.nickname || '匿名'}}</text>
<text class="comment-date">{{c._time || ''}}</text>
</view>
<text class="comment-content" wx:if="{{!c.reply_to_username}}">{{c.content}}</text>
<text class="comment-content" wx:else>
<text>回复 </text>
<text class="comment-at">@{{c.reply_to_username}}</text>
<text>:{{c.content}}</text>
</text>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部输入栏 -->
<view class="bottom-bar" wx:if="{{!loading && !loadError && diary}}">
<view class="bottom-input" catchtap="onCommentTap">
<image class="bottom-input-icon" src="/images/comment.svg" mode="aspectFit" />
<text class="bottom-input-text">说点什么...</text>
</view>
</view>
<!-- 评论输入弹窗 -->
<view class="modal-mask" wx:if="{{showCommentInput}}" catchtap="cancelComment">
<view class="modal-box" catchtap="noop">
<view class="modal-top">
<text class="modal-cancel" catchtap="cancelComment">取消</text>
<text class="modal-title">{{replyTo ? '回复 @' + replyTo.nickname : '发表评论'}}</text>
<text class="modal-send" catchtap="submitComment">发送</text>
</view>
<textarea class="modal-input" placeholder="说点什么..." value="{{commentText}}" bindinput="onCommentInput" auto-focus maxlength="500" />
</view>
</view>
<!-- 图片预览 -->
<view class="preview-mask" wx:if="{{showPreview}}" catchtap="closePreview">
<swiper class="preview-swiper" current="{{previewIndex}}" bindchange="onPreviewSwiperChange" circular>
<swiper-item wx:for="{{previewImages}}" wx:key="*this" class="preview-slide">
<image src="{{item}}" mode="widthFix" class="preview-img" />
</swiper-item>
</swiper>
<view class="preview-num">{{previewIndex + 1}} / {{previewImages.length}}</view>
<view class="preview-close" catchtap="closePreview">
<image class="preview-close-icon" src="/images/close.svg" mode="aspectFit" />
</view>
</view>
</view>
</view>
@@ -0,0 +1,17 @@
@import '../../styles/common.wxss';
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
.read-scroll { position:fixed; top:var(--nav-height,64px); left:0; right:0; height:calc(100vh - var(--nav-height,64px) - 100rpx - env(safe-area-inset-bottom)); }
.read-state { padding:28rpx 44rpx; }.read-page { padding:32rpx 44rpx 40rpx; }
.read-author { display:flex; align-items:center; gap:16rpx; margin-bottom:28rpx; }.read-avatar { width:64rpx; height:64rpx; border-radius:50%; flex-shrink:0; }.read-author-info { flex:1; min-width:0; }.read-name { display:block; font-size:26rpx; font-weight:500; color:#352E2A; }.read-date { display:block; margin-top:6rpx; font-size:21rpx; color:#85776D; }.read-location { display:block; font-size:21rpx; color:#85776D; margin-top:6rpx; line-height:1.5; }
.read-menu { width:64rpx; height:64rpx; display:flex; align-items:center; justify-content:center; flex-shrink:0; margin-right:-12rpx; }.read-menu image { width:36rpx; height:36rpx; }
.read-paper { padding:12rpx; border:1rpx solid #E9DDD0; border-radius:6rpx 28rpx 6rpx 6rpx; background:#FFFDF8; }
.img-box { width:100%; height:500rpx; position:relative; background:#F1EBE2; overflow:hidden; border-radius:3rpx 16rpx 3rpx 3rpx; }.img-swiper { width:100%; height:100%; }.img-slide { display:flex; align-items:center; justify-content:center; }.img-item { width:100%; height:100%; display:block; }
.img-count { position:absolute; right:16rpx; bottom:16rpx; background:rgba(53,46,42,.6); color:#FFFDF8; padding:6rpx 14rpx; border-radius:10rpx; font-size:21rpx; }
.content-card { padding:32rpx 24rpx 28rpx; }.title-text { display:block; font-size:34rpx; line-height:1.5; font-weight:600; color:#352E2A; margin-bottom:20rpx; }.content-text { display:block; font-size:29rpx; line-height:1.9; color:#4D423A; white-space:pre-wrap; overflow-wrap:break-word; }
.comment-box { margin-top:38rpx; }.comment-head { padding-bottom:20rpx; border-bottom:1rpx solid #E9DDD0; }.comment-head-text { color:#352E2A; font-size:28rpx; font-weight:600; }.comment-empty { padding:44rpx 8rpx; text-align:center; font-size:24rpx; line-height:1.7; color:#85776D; }
.comment-item { display:flex; align-items:flex-start; gap:14rpx; padding:24rpx 0; border-bottom:1rpx solid #E9DDD0; }.comment-item:last-child { border:0; }.comment-avatar { width:48rpx; height:48rpx; border-radius:50%; flex-shrink:0; }.comment-body { flex:1; min-width:0; }.comment-top { display:flex; justify-content:space-between; gap:12rpx; flex-wrap:wrap; margin-bottom:12rpx; }.comment-name { font-size:24rpx; color:#352E2A; font-weight:500; }.comment-date { font-size:20rpx; color:#A39386; }.comment-content { display:block; font-size:26rpx; line-height:1.65; color:#65594F; overflow-wrap:break-word; }.comment-at { color:#B74768; }
.bottom-bar { position:fixed; left:0; right:0; bottom:0; z-index:20; background:#FFFDF8; border-top:1rpx solid #E9DDD0; padding:16rpx 44rpx calc(16rpx + env(safe-area-inset-bottom)); height:calc(100rpx + env(safe-area-inset-bottom)); box-sizing:border-box; }.bottom-input { display:flex; align-items:center; gap:14rpx; height:66rpx; border:1rpx solid #E9DDD0; background:#FAF5EE; padding:0 20rpx; border-radius:16rpx; }.bottom-input-icon { width:30rpx; height:30rpx; }.bottom-input-text { color:#85776D; font-size:25rpx; }
.modal-mask { position:fixed; top:0; left:0; right:0; bottom:0; z-index:1200; display:flex; align-items:flex-end; }.modal-box { width:100%; border-radius:24rpx 24rpx 0 0!important; padding:28rpx 32rpx calc(28rpx + env(safe-area-inset-bottom)); }.modal-top { display:flex; align-items:center; justify-content:space-between; gap:16rpx; margin-bottom:24rpx; }.modal-cancel,.modal-send { padding:12rpx 0; font-size:26rpx; color:#85776D; }.modal-send { color:#B74768; }.modal-title { font-size:28rpx; font-weight:500; color:#352E2A; }.modal-input { width:100%; height:200rpx; background:#FAF5EE; border:1rpx solid #E9DDD0; border-radius:16rpx; padding:20rpx; font-size:27rpx; box-sizing:border-box; }
.preview-mask { position:fixed; top:0; left:0; right:0; bottom:0; z-index:9999; background:rgba(0,0,0,.95); }.preview-swiper { width:100%; height:100%; }.preview-slide { position:relative; }.preview-img { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; display:block; }.preview-num { position:fixed; bottom:calc(44rpx + env(safe-area-inset-bottom)); left:0; right:0; text-align:center; color:#FFFDF8; font-size:25rpx; }.preview-close { position:fixed; top:var(--nav-height,64px); right:24rpx; width:72rpx; height:72rpx; display:flex; align-items:center; justify-content:center; }.preview-close-icon { width:34rpx; height:34rpx; filter:brightness(0) invert(1); }
@media (max-width:350px) { .read-page { padding-left:32rpx; padding-right:32rpx; }.bottom-bar { padding-left:32rpx; padding-right:32rpx; } }
+183
View File
@@ -0,0 +1,183 @@
const { getLayout } = require('../../utils/layout');
const { api, API_BASE, getImageUrl, uploadFileCompat } = require('../../utils/request');
var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '😎', '🤔', '😭', '😋', '😌'];
var WEATHER_OPTIONS = ['☀️', '⛅', '☁️', '🌧️', '⛈️', '❄️', '🌤️', '🌥️', '🌈', '🌪️', '🌫️', '🌙'];
Page({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
diaryId: 0,
form: { title: '', content: '' },
tempImages: [],
existingImages: [],
submitting: false,
contentLength: 0
},
onLoad: function(options) {
this.setData(getLayout());
var id = options.id;
if (!id) {
wx.showToast({ title: '参数错误', icon: 'none' });
return;
}
this.setData({ diaryId: parseInt(id) });
this.loadDiary();
},
loadDiary: async function() {
if (this._pageLoading) return;
this._pageLoading = true;
this.setData({loading:true, loadError:false});
try {
var data = await api.diaryDetail(this.data.diaryId);
var title = data.title || '';
var content = data.content || '';
var imgs = data.images || [];
if (typeof imgs === 'string') {
try { imgs = JSON.parse(imgs); } catch(e) { imgs = []; }
}
if (!Array.isArray(imgs)) imgs = [];
var tempImages = imgs.map(function(p) { return getImageUrl(p); });
this.setData({
'form.title': title,
'form.content': content,
'form.lat': data.lat || 0,
'form.lng': data.lng || 0,
'form.location': data.location || '',
tempImages: tempImages,
existingImages: imgs,
contentLength: content.length
});
} catch (e) {
this.setData({loadError:true});
wx.showToast({ title: '加载日记失败', icon: 'none' });
}
finally { this._pageLoading = false; this.setData({loading:false}); }
},
onTitleInput: function(e) {
this.setData({ 'form.title': e.detail.value });
},
onContentInput: function(e) {
var val = e.detail.value || '';
this.setData({ 'form.content': val, contentLength: val.length });
},
chooseImages: function() {
var self = this;
wx.chooseImage({
count: 9 - this.data.tempImages.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: function(res) {
self.setData({ tempImages: self.data.tempImages.concat(res.tempFilePaths) });
}
});
},
// ===== 删除单张图片 =====
removeImage: function(e) {
var index = parseInt(e.currentTarget.dataset.index);
var images = this.data.tempImages;
var existing = this.data.existingImages;
images.splice(index, 1);
existing.splice(index, 1);
this.setData({ tempImages: images, existingImages: existing });
},
submit: async function() {
if (this.data.loading || this.data.loadError) return;
if (this.data.submitting) return;
var content = this.data.form.content;
if (!content || !content.trim()) {
wx.showToast({ title: '请填写日记内容', icon: 'none' });
return;
}
this.setData({ submitting: true });
try {
var uploadPaths = [];
var existing = this.data.existingImages || [];
var temps = this.data.tempImages || [];
// 判断哪些是新上传的图片(tempFilePaths 以 http 开头的是已有图片,否则是新选的)
for (var i = 0; i < temps.length; i++) {
var p = temps[i];
if (p.indexOf('http') === 0) {
// 已有图片,用原始路径
uploadPaths.push(existing[i] || '');
} else {
// 新图片,需要上传
wx.showLoading({ title: '上传图片中...' });
var res = await this._uploadImage(p);
wx.hideLoading();
if (res) uploadPaths.push(res);
}
}
await api.diaryUpdate({
id: this.data.diaryId,
title: this.data.form.title || '',
content: content.trim(),
images: uploadPaths,
// 保留原有地理位置(编辑时位置不变)
lat: this.data.form.lat || 0,
lng: this.data.form.lng || 0,
location: this.data.form.location || ''
});
// 清除详情页缓存
wx.showToast({ title: '修改成功 ✨', icon: 'success' });
setTimeout(function() { wx.navigateBack(); }, 1500);
} catch (e) {
wx.hideLoading();
wx.showToast({ title: (e && (e.error || e.message || e.errMsg)) || '修改失败', icon: 'none' });
} finally {
this.setData({ submitting: false });
}
},
_uploadImage: async function(filePath) {
try {
var data = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'diaries' }
});
var path = data && (data.path || data.url || '');
if (!path) {
// 内容安全检测失败:抛出错误提示用户
if (data && data.error) {
throw data;
}
try {
var retryData = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'diaries' }
});
path = retryData && (retryData.path || retryData.url || '');
} catch(retryErr) {
console.error('图片上传重试失败', retryErr);
}
}
return path;
} catch(e) {
// 内容安全违规错误:直接抛出,中断发布并提示用户
if (e && e.error) {
throw e;
}
console.error('图片上传失败', e);
return '';
}
},
goBack: function() {
wx.navigateBack();
}
});
+8
View File
@@ -0,0 +1,8 @@
{
"disableScroll": true,
"navigationStyle": "custom",
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}
+46
View File
@@ -0,0 +1,46 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<app-nav title="编辑日记" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" style="height:100vh;">
<view class="container"><page-state wx:if="{{loading}}" kind="loading" title="正在加载日记" /><page-state wx:elif="{{loadError}}" kind="error" title="日记加载失败" action="重试" bind:action="loadDiary" /><block wx:else>
<!-- 标题 -->
<view class="input-group">
<label>标题(选填)</label>
<input class="input diary-title-input" placeholder="给日记起个标题..." value="{{form.title}}" bindinput="onTitleInput" maxlength="100" />
</view>
<!-- 内容编辑器(支持换行) -->
<view class="input-group editor-group">
<view class="editor-header">
<label>内容</label>
<text class="char-count {{contentLength > 5000 ? 'char-warn' : ''}}">{{contentLength}}/5000</text>
</view>
<textarea class="input textarea diary-editor" placeholder="想写些什么..." value="{{form.content}}" bindinput="onContentInput" maxlength="5000" auto-height />
</view>
<!-- 图片(可删除) -->
<view class="input-group">
<view class="editor-header">
<label>图片</label>
<text class="img-count">{{tempImages.length}}/9</text>
</view>
<view class="img-picker-row">
<view wx:for="{{tempImages}}" wx:key="*this" class="img-picker-wrap">
<image class="img-picker-thumb" src="{{item}}" mode="aspectFill" />
<view class="img-picker-del" bindtap="removeImage" data-index="{{index}}" hover-class="del-hover">
<text>✕</text>
</view>
</view>
<view class="img-picker-add" bindtap="chooseImages" wx:if="{{tempImages.length < 9}}">
<text class="add-icon">+</text>
<text class="add-text">添加图片</text>
</view>
</view>
</view>
<button class="btn btn-primary submit-btn" bindtap="submit" hover-class="none" loading="{{submitting}}">保存修改</button>
</block>
</view>
</scroll-view>
</view>
+142
View File
@@ -0,0 +1,142 @@
@import '../../styles/common.wxss';
.container { padding: 84rpx 32rpx 300rpx; }
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; }
/* ===== 输入框美化 ===== */
.input-group { margin-bottom: 36rpx; }
.input-group label {
display: block;
font-size: 26rpx;
font-weight: 500;
color: #888;
margin-bottom: 12rpx;
}
.input {
width: 100%;
padding: 28rpx 32rpx;
background: #fff;
border-radius: 20rpx;
font-size: 28rpx;
box-sizing: border-box;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
border: 2rpx solid transparent;
transition: all 0.3s;
}
.input:focus {
border-color: #FF6B81;
box-shadow: 0 0 0 6rpx rgba(255,107,129,0.08);
}
.textarea {
min-height: 200rpx;
line-height: 1.6;
}
/* ===== 日记标题输入框 ===== */
.diary-title-input {
font-size: 32rpx;
font-weight: 600;
padding: 28rpx 32rpx;
}
/* ===== 编辑器头部(标签+计数) ===== */
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.editor-header label {
margin-bottom: 0;
}
.char-count {
font-size: 22rpx;
color: #bbb;
}
.char-warn {
color: #ff6b6b;
font-weight: 600;
}
.img-count {
font-size: 22rpx;
color: #bbb;
}
/* ===== 日记编辑器 ===== */
.diary-editor {
min-height: 300rpx;
font-size: 30rpx;
line-height: 1.7;
padding: 28rpx 32rpx;
}
/* ===== 图片选择(可删除) ===== */
.img-picker-row {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.img-picker-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
overflow: visible;
}
.img-picker-thumb {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
background: #eee;
}
.img-picker-del {
position: absolute;
top: -12rpx;
right: -12rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: rgba(255,71,87,0.9);
color: #fff;
font-size: 22rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(255,71,87,0.3);
z-index: 2;
}
.del-hover {
transform: scale(1.2);
background: #ff4757;
}
.img-picker-add {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
background: #fff;
border: 2rpx dashed #ddd;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
}
.img-picker-add .add-icon {
font-size: 48rpx;
color: #ccc;
line-height: 1;
}
.img-picker-add .add-text {
font-size: 20rpx;
color: #ccc;
}
/* ===== 提交按钮 ===== */
.submit-btn {
margin-top: 40rpx;
margin-bottom: 80rpx;
}
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
+102
View File
@@ -0,0 +1,102 @@
const { getLayout } = require('../../utils/layout');
// pages/discover/discover.js
const { api } = require('../../utils/request');
const { diaryEntry, albumEntry, memoryEntry } = require('../../utils/journal');
const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos');
Page({
onResize: function() { this.setData(getLayout()); },
data: {
stats: null, loading: true, loadError: false, refreshing: false, publishOpen: false,
activeFilter: 'diary', filters: [{value:'diary',label:'日记'}, {value:'album',label:'相册'}, {value:'memory',label:'记忆日'}],
entries: [], albums: [], memories: [],
scrollTop: 0
},
onLoad: function() {
this.setData(getLayout());
this.setData({ scrollTop: getScrollPosition('discover') });
},
onShow: function() {
// 延迟设置 scrollTop,确保 scroll-view 在页面显示后重新应用滚动位置
// 解决从子页面 navigateBack 返回后滚动位置丢失的问题
var self = this;
var savedTop = getScrollPosition('discover');
this.setData({ scrollTop: savedTop });
setTimeout(function() {
self.setData({ scrollTop: savedTop });
}, 100);
this.loadStats();
},
// 保存滚动位置
onScroll: function(e) {
saveScrollPosition('discover', e.detail.scrollTop);
},
onPullDownRefresh: async function() {
await this.loadStats(true);
wx.stopPullDownRefresh();
},
loadStats: async function() {
if (this._loading) return;
this._loading = true;
this.setData({loading:true, loadError:false});
try {
const [timeline, album, diary] = await Promise.all([
api.timelineList(), api.albumList(), api.diaryList()
]);
var photoCount = 0;
if (Array.isArray(album)) {
for (var i = 0; i < album.length; i++) {
photoCount += parseInt(album[i].photo_count || 0);
}
}
var stats = {
timeline: Array.isArray(timeline) ? timeline.length : 0,
album: photoCount,
diary: (diary && diary.total) ? diary.total : 0,
};
this.setData({ stats: stats, entries: ((diary && diary.items) || []).map(diaryEntry), albums: (Array.isArray(album) ? album : []).map(albumEntry), memories: (Array.isArray(timeline) ? timeline : []).map(memoryEntry) });
} catch (e) {
console.error(e);
this.setData({loadError:true});
} finally { this._loading = false; this.setData({loading:false}); }
},
onRefresh: async function() { this.setData({refreshing:true}); try { await this.loadStats(); } finally { this.setData({refreshing:false}); } },
selectFilter: function(e) { this.setData({activeFilter:e.currentTarget.dataset.filter}); },
showPublish: function() { this.setData({publishOpen:true}); },
hidePublish: function() { this.setData({publishOpen:false}); },
noop: function() {}, preventMove: function() {},
_publish: function(type) { this.hidePublish(); this._savePosBeforeLeave(); wx.navigateTo({url:'/pages/publish/publish?type=' + type}); },
writeDiary: function() { this._publish('diary'); },
uploadPhotos: function() { this.hidePublish(); this._savePosBeforeLeave(); wx.navigateTo({url:'/pages/albums/albums?upload=1'}); },
createAlbum: function() { this._publish('album'); },
createMemory: function() { this._publish('timeline'); },
openJournalEntry: function(e) { this._savePosBeforeLeave(); wx.navigateTo({url:'/pages/diary_detail/diary_detail?id=' + e.currentTarget.dataset.id}); },
openAlbum: function(e) { this._savePosBeforeLeave(); wx.navigateTo({url:'/pages/album/album?album_id=' + e.currentTarget.dataset.id}); },
// 跳转前保存当前滚动位置
_savePosBeforeLeave: function() {
saveScrollPosition('discover', this.data.scrollTop);
},
// Tab 导航(替换,不留栈)
goHome: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/index/index' }); },
goToDiaryFromDiscover: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/diary/diary?from=discover' }); },
goToProfile: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/profile/profile' }); },
// 功能入口(推入栈,支持左滑返回)
goToTimeline: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/timeline/timeline' }); },
goToAlbum: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/albums/albums' }); },
goToSongs: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/lovewords/lovewords' }); },
goToCheckin: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/punch/punch' }); },
goToPet: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/pet/pet' }); },
goToPeriod: function() {
this.hidePublish();
this._savePosBeforeLeave();
wx.navigateTo({ url: '/pages/period/period' });
}
});
+8
View File
@@ -0,0 +1,8 @@
{
"navigationStyle": "custom",
"usingComponents": {
"app-tabbar": "/components/app-tabbar/app-tabbar",
"page-state": "/components/page-state/page-state"
},
"enablePullDownRefresh": true
}
+17
View File
@@ -0,0 +1,17 @@
<import src="../../templates/journal.wxml" />
<launch-transition />
<view class="app-shell" style="{{layoutStyle}}"><view class="j-fixed-header"><view class="j-fixed-heading" style="height:{{barHeight}}px;padding-right:{{navSide}}px;"><text class="j-eyebrow">PAGES OF US</text><text class="j-page-title">我们的生活手账</text></view></view>
<scroll-view class="page-bg j-main-scroll" scroll-y show-scrollbar="{{false}}" bindscroll="onScroll" scroll-top="{{scrollTop}}" refresher-enabled="{{true}}" refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh">
<view class="j-body"><view class="records-action-row"><button class="j-button j-button-small" bindtap="showPublish">+ 记录</button></view>
<view class="j-tools records-tools"><text class="j-small-title">一起的日常</text><view class="j-tool-grid"><view bindtap="goToSongs"><image src="/images/music.svg" /><text>歌曲</text></view><view bindtap="goToCheckin"><image src="/images/check.svg" /><text>打卡</text></view><view bindtap="goToPet"><image src="/images/pet.svg" /><text>宠物</text></view><view bindtap="goToPeriod"><image src="/images/j-flower.svg" /><text>姨姨</text></view></view></view>
<view class="j-filters"><view wx:for="{{filters}}" wx:key="value" class="j-filter {{activeFilter === item.value ? 'is-active' : ''}}" data-filter="{{item.value}}" bindtap="selectFilter">{{item.label}}</view></view>
<page-state wx:if="{{loading}}" kind="loading" title="正在翻开手账" /><page-state wx:elif="{{loadError}}" kind="error" title="手账暂时没有加载成功" action="重新加载" bind:action="loadStats" />
<block wx:else>
<block wx:if="{{activeFilter === 'diary'}}"><block wx:for="{{entries}}" wx:key="id"><template is="journal-entry" data="{{entry:item}}" /></block><page-state wx:if="{{!entries.length}}" title="故事,从今天开始" description="慢慢积攒属于你们的生活片段" action="写日记" bind:action="writeDiary" /><view class="j-link j-center" wx:else bindtap="goToDiaryFromDiscover">查看全部日记 →</view></block>
<block wx:elif="{{activeFilter === 'album'}}"><view class="j-album-list"><view class="j-album-print" wx:for="{{albums}}" wx:key="id" bindtap="openAlbum" data-id="{{item.id}}"><image wx:if="{{item.cover}}" src="{{item.cover}}" mode="aspectFill" /><view wx:else class="j-album-placeholder"><image src="/images/album.svg" /><text>还没有照片</text></view><text class="j-story-title">{{item.title}}</text><text class="j-caption">{{item.photo_count || 0}} 张照片</text></view></view><page-state wx:if="{{!albums.length}}" title="相册还空着" description="为旅行、周末或日常,留一本相册" action="创建相册" bind:action="createAlbum" /><view class="j-link j-center" wx:else bindtap="goToAlbum">管理全部相册 →</view></block>
<block wx:else><view class="j-memory j-memory-list" wx:for="{{memories}}" wx:key="id" bindtap="goToTimeline"><view class="j-stamp"><text>{{item.dateMonth}}月</text><text>{{item.dateDay}}</text></view><view class="j-memory-info"><text class="j-small-title">{{item.title}}</text><text class="j-caption">{{item.date}}</text></view><text class="j-link">›</text></view><page-state wx:if="{{!memories.length}}" title="记住一个特别的日子" description="第一次见面、旅行,还有每个值得期待的日子" action="添加记忆日" bind:action="createMemory" /><view class="j-link j-center" wx:else bindtap="goToTimeline">查看全部记忆日 →</view></block>
</block>
</view></scroll-view><app-tabbar />
<view wx:if="{{publishOpen}}" class="j-sheet-mask" bindtap="hidePublish" catchtouchmove="preventMove"><view class="j-sheet" catchtap="noop"><view class="j-sheet-grip" /><view class="j-section-head"><text>留下一页日常</text><view class="j-link" bindtap="hidePublish">关闭</view></view><view class="j-publish-grid"><view bindtap="writeDiary"><image src="/images/book.svg" /><text>写日记</text></view><view bindtap="uploadPhotos"><image src="/images/album.svg" /><text>上传照片</text></view><view bindtap="createMemory"><image src="/images/timeline.svg" /><text>记忆日</text></view></view></view></view>
</view>
+11
View File
@@ -0,0 +1,11 @@
@import '../../styles/common.wxss';
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
.records-tools { padding-top:0; margin:0 0 32rpx; border-top:0; }
.records-tools .j-tool-grid { margin-top:12rpx; border-bottom:1rpx solid #E9DDD0; padding-bottom:12rpx; gap:12rpx; }
.records-tools .j-tool-grid > view { flex-direction:row; gap:8rpx; background:transparent; border:0; border-radius:0; padding:20rpx 0; color:#352E2A; min-height:80rpx; }
.records-tools .j-tool-grid image { width:32rpx; height:32rpx; flex-shrink:0; }
.records-action-row { display:flex; justify-content:flex-end; margin-bottom:24rpx; }
.records-action-row .j-button { margin:0; }
+106
View File
@@ -0,0 +1,106 @@
const { getLayout } = require('../../utils/layout');
// pages/history/history.js
const { api } = require('../../utils/request');
// 操作类型对应的图标和颜色
var ACTION_CONFIG = {
login: { icon: '🔓', color: '#82B1FF', label: '登录' },
timeline_create: { icon: '📅', color: '#FF8A65', label: '记忆日' },
timeline_update: { icon: '✏️', color: '#FF8A65', label: '记忆日' },
album_create: { icon: '📷', color: '#66BB6A', label: '相册' },
photo_upload: { icon: '🖼️', color: '#66BB6A', label: '照片' },
diary_create: { icon: '📝', color: '#FF6B9D', label: '日记' },
diary_update: { icon: '✏️', color: '#FF6B9D', label: '日记' },
song_add: { icon: '🎵', color: '#AB47BC', label: '歌曲' },
checkin_do: { icon: '✅', color: '#42A5F5', label: '打卡' },
profile_edit: { icon: '👤', color: '#FFA726', label: '资料' },
partner_edit: { icon: '💑', color: '#FFA726', label: '资料' },
couple_edit: { icon: '💕', color: '#EC407A', label: '纪念日' },
unbind_request: { icon: '💔', color: '#EF5350', label: '解绑' },
unbind_agreed: { icon: '💔', color: '#EF5350', label: '解绑' },
unbind_rejected: { icon: '💖', color: '#EF5350', label: '解绑' },
unbind_cancelled: { icon: '↩️', color: '#EF5350', label: '解绑' },
};
Page({
onResize: function() { this.setData(getLayout()); },
data: { loadError: false, list: [], page: 1, totalPages: 0, pages: [], loading: true },
onLoad: function() {
this.setData(getLayout()); this.loadLogs(); },
onPullDownRefresh: async function() {
this.setData({ page: 1, list: [] });
await this.loadLogs();
wx.stopPullDownRefresh();
},
loadLogs: async function() {
if (this._pageLoading) return;
this._pageLoading = true;
this.setData({loading:true, loadError:false});
var cp = this.data.page;
this.setData({ loading: true });
try {
const res = await api.activityLogList({ page: cp, page_size: 20 });
var items = (res.items || []).map(function(item) {
item.created_at = item.created_at ? item.created_at.substring(0, 16).replace('T', ' ') : '';
var cfg = ACTION_CONFIG[item.action_type] || { icon: '📌', color: '#999', label: '' };
item.action_icon = cfg.icon;
item.action_color = cfg.color;
item.action_label = cfg.label;
return item;
});
var tp = res.total_pages || 1;
var pages = [];
if (tp <= 7) {
for (var i = 1; i <= tp; i++) { pages.push({ num: i, dot: false }); }
} else {
pages.push({ num: 1, dot: false });
pages.push({ num: 2, dot: false });
pages.push({ num: 3, dot: false });
if (cp > 4) pages.push({ num: 0, dot: true });
pages.push({ num: tp - 2, dot: false });
pages.push({ num: tp - 1, dot: false });
pages.push({ num: tp, dot: false });
}
var pageData = {
list: items,
page: cp,
totalPages: tp,
pages: pages,
loading: false
};
this.setData(pageData);
} catch (e) {
this.setData({loadError:true});
this.setData({ loading: false });
wx.showToast({ title: '加载失败', icon: 'none' });
}
finally { this._pageLoading = false; this.setData({loading:false}); }
},
goPage: function(e) {
var p = parseInt(e.currentTarget.dataset.page);
if (p === this.data.page) return;
this.setData({ page: p });
this.loadLogs();
},
goPrev: function() {
if (this.data.page <= 1) return;
this.setData({ page: this.data.page - 1 });
this.loadLogs();
},
goNext: function() {
if (this.data.page >= this.data.totalPages) return;
this.setData({ page: this.data.page + 1 });
this.loadLogs();
},
goToSettings: function() { wx.navigateTo({ url: '/pages/settings/settings' }); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); },
goToProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); }
});
+7
View File
@@ -0,0 +1,7 @@
{
"disableScroll": true,
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}
+51
View File
@@ -0,0 +1,51 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/history/history.wxml - 情侣操作记录(优化版) -->
<app-nav title="操作记录" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}" style="height:100vh;">
<view class="container">
<!-- 加载状态 -->
<page-state wx:if="{{loading}}" kind="loading" title="正在加载操作记录" /><page-state wx:elif="{{loadError}}" kind="error" title="操作记录加载失败" action="重试" bind:action="loadLogs" />
<block wx:elif="{{list.length > 0}}">
<view class="log-list">
<view class="log-item" wx:for="{{list}}" wx:key="id">
<view class="log-icon-wrap" style="background:{{item.action_color}}20;">
<text class="log-icon">{{item.action_icon}}</text>
</view>
<view class="log-card">
<view class="log-header">
<view class="log-user-row">
<text class="log-label" style="color:{{item.action_color}};">{{item.action_label}}</text>
<text class="log-user" style="color:{{item.gender == 'male' ? '#82B1FF' : '#FF8A80'}};">{{item.nickname}}</text>
</view>
<text class="log-time">{{item.created_at}}</text>
</view>
<view class="log-body">{{item.description}}</view>
</view>
</view>
</view>
<view class="pagination" wx:if="{{totalPages > 1}}">
<text class="page-btn {{page <= 1 ? 'disabled' : ''}}" bindtap="goPrev">‹</text>
<block wx:for="{{pages}}" wx:key="num">
<text class="page-dot" wx:if="{{item.dot}}">…</text>
<text class="page-num {{page == item.num ? 'active' : ''}}" wx:else bindtap="goPage" data-page="{{item.num}}">{{item.num}}</text>
</block>
<text class="page-btn {{page >= totalPages ? 'disabled' : ''}}" bindtap="goNext">›</text>
</view>
</block>
<block wx:else>
<view class="empty-state">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无操作记录</text>
<text class="empty-desc">双方的操作都会记录在这里 ✨</text>
</view>
</block>
</view>
</scroll-view>
</view>
+77
View File
@@ -0,0 +1,77 @@
/* pages/history/history.wxss - 操作记录优化版 */
@import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; }
.container { padding-top: 16rpx; padding-bottom: 300rpx; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
/* ===== 加载状态 ===== */
.loading-state { text-align:center; padding:160rpx 40rpx; }
.loading-icon { font-size:60rpx; display:block; margin-bottom:16rpx; }
.loading-text { font-size:26rpx; color:#bbb; }
/* ===== 操作列表(卡片式) ===== */
.log-list { padding: 0 8rpx; }
.log-item {
display: flex;
align-items: flex-start;
gap: 20rpx;
margin-bottom: 24rpx;
}
.log-icon-wrap {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 4rpx;
}
.log-icon { font-size: 28rpx; }
.log-card {
flex: 1;
background: #fff;
border-radius: 16rpx;
padding: 18rpx 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6rpx;
}
.log-user-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.log-label {
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-weight: 500;
}
.log-user { font-size: 24rpx; font-weight: 600; }
.log-time { font-size: 20rpx; color: #bbb; flex-shrink: 0; }
.log-body { font-size: 26rpx; color: #555; line-height: 1.6; }
/* ===== 分页 ===== */
.pagination { display:flex; justify-content:center; align-items:center; gap:8rpx; padding:32rpx 0; flex-wrap:wrap; }
.page-btn { width:56rpx; height:56rpx; line-height:56rpx; text-align:center; border-radius:50%; background:#fff; color:#FF6B81; font-size:28rpx; font-weight:700; }
.page-btn.disabled { color:#ddd; }
.page-num { width:56rpx; height:56rpx; line-height:56rpx; text-align:center; border-radius:50%; background:#fff; color:#888; font-size:24rpx; }
.page-num.active { background:linear-gradient(135deg,#B74768,#B388FF); color:#fff; font-weight:700; }
.page-dot { width:56rpx; height:56rpx; line-height:56rpx; text-align:center; color:#ccc; font-size:24rpx; }
/* ===== 空状态 ===== */
.empty-state { text-align:center; padding:120rpx 40rpx; }
.empty-icon { font-size:80rpx; display:block; margin-bottom:24rpx; }
.empty-text { font-size:30rpx; color:#888; display:block; margin-bottom:8rpx; }
.empty-desc { font-size:24rpx; color:#bbb; }
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
+342
View File
@@ -0,0 +1,342 @@
const { getLayout } = require('../../utils/layout');
// pages/index/index.js
const { api, getImageUrl } = require('../../utils/request');
const { diaryEntry } = require('../../utils/journal');
const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos');
Page({
onResize: function() { this.setData(getLayout()); },
data: {
userId: null,
coupleId: null,
partnerName: '和TA',
loveStartDate: '',
daysCount: '?',
daysLabel: '已注册',
datePrefix: '注册于',
partner1AvatarUrl: '',
partner2AvatarUrl: '',
partner1Initial: '',
partner2Initial: '',
partner1Birthday: '',
partner2Birthday: '',
partner1Name: '?',
partner2Name: '?',
partner1Location: '',
partner2Location: '',
coupleDistance: '',
stats: {
timeline: 0, album: 0, diary: 0,
songs: 0, checkin: 0, pet: 0
},
bgImage: '',
scrollTop: 0,
refreshing: false,
relationshipKnown: false, homeError: false, statsLoading: true, statsError: false, recentDiary: [], recentAlbums: [], upcomingMemory: null
},
onLoad: function() {
this.setData(getLayout());
this.setData({
userId: wx.getStorageSync('userId'),
coupleId: wx.getStorageSync('coupleId'),
scrollTop: getScrollPosition('index')
});
this.loadBg();
this.loadData();
},
onShow: function() {
var self = this;
var savedTop = getScrollPosition('index');
var prevCoupleId = this.data.coupleId;
var curCoupleId = wx.getStorageSync('coupleId');
this.setData({
userId: wx.getStorageSync('userId'),
coupleId: curCoupleId,
scrollTop: savedTop
});
// 延迟重新设置 scrollTop,解决从子页面 navigateBack 返回后滚动位置丢失的问题
setTimeout(function() {
self.setData({ scrollTop: savedTop });
}, 100);
// 检查是否已被解绑(之前有 coupleId 但被清除了)
if (prevCoupleId && !curCoupleId) {
wx.reLaunch({ url: '/pages/index/index' });
return;
}
this.loadBg();
this.loadData();
this.fetchIpLocation();
},
// 保存滚动位置
onScroll: function(e) {
saveScrollPosition('index', e.detail.scrollTop);
},
_loadingData: false,
_daysSince: function(dateStr) {
if (!dateStr) return '?';
var parts = dateStr.substring(0, 10).split('-').map(Number);
if (parts.length !== 3 || parts.some(isNaN) || parts[0] < 1970 ||
parts[1] < 1 || parts[1] > 12 || parts[2] < 1 || parts[2] > 31) return '?';
var start = new Date(parts[0], parts[1] - 1, parts[2]);
if (start.getFullYear() !== parts[0] || start.getMonth() !== parts[1] - 1 ||
start.getDate() !== parts[2]) return '?';
var now = new Date();
var today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return Math.max(0, Math.round((today - start) / 86400000));
},
loadData: async function() {
var self = this;
// 防止并发重复加载
if (this._loadingData) return;
this._loadingData = true;
this.setData({ homeError: false });
try {
const info = await api.coupleInfo();
this.setData({userId: wx.getStorageSync('userId')});
this.setData({ relationshipKnown: true });
// 未绑定情侣时显示用户注册时间和默认信息
if (info.not_bound) {
wx.removeStorageSync('coupleId');
var p1 = info.partner1 || {};
let daysCount = '?';
let loveStartDate = '';
if (info.love_start_date) {
daysCount = this._daysSince(info.love_start_date);
var parts = info.love_start_date.split('-');
loveStartDate = parseInt(parts[0]) + '年' + parseInt(parts[1]) + '月' + parseInt(parts[2]) + '日';
}
this.setData({
coupleId: null,
partner1AvatarUrl: getImageUrl(p1.avatar),
partner1Name: p1.nickname || '未命名',
partner1Initial: (p1.nickname || '未命名')[0],
partner1Birthday: '',
partner1Location: p1.location || '',
partner2AvatarUrl: '',
partner2Name: '未绑定',
partner2Initial: '?',
partner2Birthday: '',
partner2Location: '',
daysCount: daysCount,
daysLabel: '已注册',
datePrefix: '注册于',
loveStartDate: loveStartDate,
coupleDistance: '',
stats: { timeline: 0, album: 0, diary: 0, songs: 0, checkin: 0, pet: 0 }
});
await this.loadStats();
return;
}
// 同步情侣ID:红包/邀请创建后分享人刷新即可识别
if (info.id) {
var prevCid = wx.getStorageSync('coupleId');
if (prevCid != info.id) {
wx.setStorageSync('coupleId', info.id);
}
}
const myUid = this.data.userId;
var p1 = info.partner1 || {};
var p2 = info.partner2 || {};
if (p2.id == myUid) {
var tmp = p1; p1 = p2; p2 = tmp;
}
let daysCount = '?';
let loveStartDate = '';
var togetherStart = info.love_start_date || (info.created_at || '').substring(0, 10);
if (togetherStart) {
daysCount = this._daysSince(togetherStart);
var parts = togetherStart.split('-');
loveStartDate = parseInt(parts[0]) + '年' + parseInt(parts[1]) + '月' + parseInt(parts[2]) + '日';
}
function getBirthdayText(birthday) {
if (!birthday) return '';
var t = new Date(); t.setHours(0,0,0,0);
var p = birthday.split('-');
if (p.length !== 3) return '';
var nb = new Date(t.getFullYear(), parseInt(p[1])-1, parseInt(p[2]));
if (nb < t) nb.setFullYear(nb.getFullYear()+1);
var c = Math.ceil((nb - t) / 86400000);
if (c === 0) return '今天!';
if (c === 1) return '明天';
return c + '天';
}
// 双方相距距离(后端基于双方经纬度用 Haversine 公式计算,单位:公里)
var coupleDistance = '';
if (info.distance !== null && info.distance !== undefined && info.distance !== '') {
var d = parseFloat(info.distance);
if (!isNaN(d)) {
coupleDistance = d < 1
? '相距 ' + Math.round(d * 1000) + ' 米'
: '相距 ' + d.toFixed(1) + ' 公里';
}
}
var dn = {
daysCount: daysCount,
daysLabel: '我们在一起',
datePrefix: '始于',
loveStartDate: loveStartDate,
partner1AvatarUrl: getImageUrl(p1.avatar),
partner2AvatarUrl: getImageUrl(p2.avatar),
partner1Name: p1.nickname || p1.username || '?',
partner2Name: p2.nickname || p2.username || '?',
partner1Initial: (p1.nickname || p1.username || '?')[0],
partner2Initial: (p2.nickname || p2.username || '?')[0],
partner1Birthday: getBirthdayText(p1.birthday),
partner2Birthday: getBirthdayText(p2.birthday),
partner1Location: (p1.location && !/^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(p1.location)) ? p1.location : '',
partner2Location: (p2.location && !/^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(p2.location)) ? p2.location : '',
coupleDistance: coupleDistance
};
this.setData(dn);
await this.loadStats();
} catch (e) {
this.setData({ homeError: true, relationshipKnown: false, statsLoading: false, statsError: true, recentDiary: [], recentAlbums: [], upcomingMemory: null });
console.error(e);
} finally {
this._loadingData = false;
}
},
loadStats: async function() {
this.setData({ statsLoading: true, statsError: false });
try {
const [timeline, album, diary, songs, checkin, pet] = await Promise.all([
api.timelineList(), api.albumList(), api.diaryList(),
api.songsList(), api.checkinList(), api.petInfo()
]);
var photoCount = 0;
if (Array.isArray(album)) {
for (var i = 0; i < album.length; i++) {
photoCount += parseInt(album[i].photo_count || 0);
}
}
var petLevel = 0;
if (pet && pet.has_pet && pet.pet) {
petLevel = parseInt(pet.pet.level || 0);
}
var recentDiary = ((diary && diary.items) || []).slice(0, 1).map(diaryEntry);
var today = new Date(); today.setHours(0,0,0,0);
var future = (Array.isArray(timeline) ? timeline : []).filter(function(item) { return item.event_date && new Date(item.event_date.replace(/-/g, '/')) >= today; }).sort(function(a,b) { return a.event_date.localeCompare(b.event_date); });
var next = future[0];
this.setData({
recentDiary: recentDiary,
recentAlbums: (Array.isArray(album) ? album : []).filter(function(item) { return !!item.cover; }).slice(0,3).map(function(item) { return { id:item.id, title:item.title || '我们的相册', cover:getImageUrl(item.cover) }; }),
upcomingMemory: next ? { title: next.title || '下一个记忆日', date: next.event_date, dateMonth: next.event_date.slice(5,7), dateDay: next.event_date.slice(8,10), days: Math.ceil((new Date(next.event_date.replace(/-/g, '/')) - today) / 86400000) } : null,
stats: {
timeline: Array.isArray(timeline) ? timeline.length : 0,
album: photoCount,
diary: (diary && diary.total) ? diary.total : 0,
songs: Array.isArray(songs) ? songs.length : 0,
checkin: (checkin && checkin.checkin_log_total !== undefined) ? checkin.checkin_log_total : ((checkin && checkin.items) ? checkin.items.length : (Array.isArray(checkin) ? checkin.length : 0)),
pet: petLevel
}
});
} catch (e) { console.error(e); this.setData({ statsError: true, recentDiary: [], recentAlbums: [], upcomingMemory: null }); } finally { this.setData({ statsLoading: false }); }
},
// 获取本机经纬度并上报后端解析地址(后端按距离判断是否重新解析,超过5km才更新),并刷新首页双方地址显示
fetchIpLocation: function() {
var self = this;
var report = function(lat, lng) {
var data = {};
if (lat && lng) { data.lat = lat; data.lng = lng; }
api.updateIpLocation(data).then(function(resp) {
if (resp && resp.location) {
self.loadData();
}
}).catch(function() {});
};
// 优先用模糊定位获取经纬度(getFuzzyLocation 与 getLocation 互斥,统一用模糊定位)
if (wx.getFuzzyLocation) {
wx.getFuzzyLocation({
type: 'gcj02',
success: function(res) {
report(res.latitude, res.longitude);
},
fail: function() {
report(0, 0);
}
});
} else {
report(0, 0);
}
},
// 背景图以接口返回为准
loadBg: function() {
var self = this;
api.getBgConfig().then(function(cfg) {
self.setData({ bgImage: cfg && cfg.image ? getImageUrl(cfg.image) : '' });
}).catch(function(e){ console.warn('loadBg error:', e && (e.error || e._statusCode || e)); });
},
// 跳转前保存当前滚动位置
_savePosBeforeLeave: function() {
saveScrollPosition('index', this.data.scrollTop);
},
// 页面跳转
goToDiary: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/diary/diary' }); },
goToProfile: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/profile/profile' }); },
goToTimeline: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/timeline/timeline' }); },
goToAlbum: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/albums/albums' }); },
goToSongs: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/lovewords/lovewords' }); },
goToCheckin: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/punch/punch' }); },
goToPet: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/pet/pet' }); },
goToPeriod: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/period/period' }); },
bindPartner: function() { wx.reLaunch({ url: '/pages/profile/profile?bind=1' }); },
openJournalEntry: function(e) { wx.navigateTo({ url: '/pages/diary_detail/diary_detail?id=' + e.currentTarget.dataset.id }); },
writeDiary: function() { wx.navigateTo({ url: '/pages/publish/publish?type=diary' }); },
openAlbumPreview: function(e) { wx.navigateTo({ url: '/pages/album/album?album_id=' + e.currentTarget.dataset.id }); },
openRecentDiary: function(e) { wx.navigateTo({ url: '/pages/diary_detail/diary_detail?id=' + e.currentTarget.dataset.id }); },
showMore: function() {
wx.reLaunch({ url: '/pages/discover/discover' });
},
onPullDownRefresh: async function() {
await this.loadData();
this.loadBg();
wx.stopPullDownRefresh();
},
onRefresh: async function() {
this.setData({ refreshing: true });
try {
await this.loadData();
this.loadBg();
} catch (e) {
console.error('下拉刷新失败', e);
} finally {
this.setData({ refreshing: false });
}
},
// 分享到好友
onShareAppMessage: function() {
return {
title: '让所有的爱有迹可循',
path: '/pages/index/index',
imageUrl: '/images/share.png'
};
},
// 分享到朋友圈
onShareTimeline: function() {
return {
title: '让所有的爱有迹可循',
query: '',
imageUrl: '/images/share.png'
};
}
});
+8
View File
@@ -0,0 +1,8 @@
{
"usingComponents": {
"app-tabbar": "/components/app-tabbar/app-tabbar",
"page-state": "/components/page-state/page-state"
},
"navigationStyle": "custom",
"enablePullDownRefresh": false
}
+25
View File
@@ -0,0 +1,25 @@
<import src="../../templates/journal.wxml" />
<launch-transition />
<view class="app-shell" style="{{layoutStyle}}">
<view wx:if="{{bgImage}}" class="j-background"><image src="{{bgImage}}" mode="aspectFill" /><view class="j-background-wash" /></view>
<view class="home-fixed-header"><view class="home-fixed-heading" style="height:{{barHeight}}px;padding-right:{{navSide}}px;"><text class="j-eyebrow">OUR LITTLE WORLD</text><text class="j-page-title">让所有的爱有迹可循</text></view></view>
<scroll-view class="page-bg home-scroll" scroll-y show-scrollbar="{{false}}" refresher-enabled="{{true}}" refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh" bindscroll="onScroll" scroll-top="{{scrollTop}}">
<view class="j-body home-body">
<page-state wx:if="{{homeError}}" kind="error" title="首页加载失败" description="请检查网络后重新加载" action="重试" bind:action="loadData" />
<page-state wx:elif="{{!relationshipKnown}}" kind="loading" title="正在打开你的空间" />
<view wx:else class="j-cover">
<view class="j-tape" /><view class="j-row"><text class="j-eyebrow">{{daysLabel}}</text><image class="j-cover-heart" src="/images/heart.svg" /></view>
<view class="j-days"><text class="j-days-number">{{daysCount}}</text><text class="j-days-unit">天</text></view><text class="j-cover-date" wx:if="{{loveStartDate}}">{{datePrefix}} {{loveStartDate}}</text>
<view class="j-couple-line"><view class="j-avatar"><image wx:if="{{partner1AvatarUrl}}" src="{{partner1AvatarUrl}}" mode="aspectFill" /><text wx:else>{{partner1Initial}}</text></view><view class="j-avatar j-avatar-partner" wx:if="{{coupleId}}"><image wx:if="{{partner2AvatarUrl}}" src="{{partner2AvatarUrl}}" mode="aspectFill" /><text wx:else>{{partner2Initial}}</text></view><view class="j-couple-names"><text>{{partner1Name}}{{coupleId ? ' & ' + partner2Name : ''}}</text><text class="j-caption" wx:if="{{partner1Location || partner2Location}}">{{partner1Location}}{{partner2Location ? ' / ' + partner2Location : ''}}</text></view></view>
<view class="j-cover-meta" wx:if="{{coupleDistance || partner1Birthday || partner2Birthday}}"><text wx:if="{{coupleDistance}}">{{coupleDistance}}</text><text wx:if="{{partner1Birthday}}">你的生日:{{partner1Birthday}}</text><text wx:if="{{partner2Birthday}}">TA的生日:{{partner2Birthday}}</text></view>
</view>
<view class="j-invite" wx:if="{{relationshipKnown && !coupleId}}" bindtap="bindPartner"><view><text class="j-small-title">邀请另一半,共写这本手账</text><text class="j-caption">绑定后,一起收藏日常</text></view><text class="j-outline-label">邀请</text></view>
<view class="j-memory" wx:if="{{upcomingMemory && !homeError}}" bindtap="goToTimeline"><view class="j-stamp"><text>{{upcomingMemory.dateMonth}}月</text><text>{{upcomingMemory.dateDay}}</text></view><view class="j-memory-info"><text class="j-small-title">下一次值得期待</text><text class="j-caption">{{upcomingMemory.title}}</text></view><view class="j-countdown"><text>{{upcomingMemory.days}}</text><text>{{upcomingMemory.days === 0 ? '今天' : '天后'}}</text></view></view>
<view class="j-section"><view class="j-section-head"><text>最近的一页</text><view class="j-link" bindtap="showMore">翻翻手账 →</view></view>
<page-state wx:if="{{statsLoading}}" kind="loading" title="正在加载记录" /><page-state wx:elif="{{statsError}}" kind="error" title="记录暂时没有加载成功" action="重新加载" bind:action="loadStats" />
<block wx:elif="{{recentDiary.length}}"><block wx:for="{{recentDiary}}" wx:key="id"><template is="journal-entry" data="{{entry:item}}" /></block></block>
<page-state wx:else title="故事,从今天开始" description="一句心情、一张照片,都可以成为第一页" action="留下第一篇" bind:action="writeDiary" />
</view>
<view class="j-section" wx:if="{{recentAlbums.length && !statsError}}"><view class="j-section-head"><text>相册里的片段</text><view class="j-link" bindtap="goToAlbum">全部相册 →</view></view><view class="j-photo-strip"><view class="j-photo-print" wx:for="{{recentAlbums}}" wx:key="id" data-id="{{item.id}}" bindtap="openAlbumPreview"><image src="{{item.cover}}" mode="aspectFill" /><text>{{item.title}}</text></view></view></view>
</view></scroll-view><app-tabbar />
</view>
+13
View File
@@ -0,0 +1,13 @@
@import '../../styles/common.wxss';
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
/* 首页标题与系统胶囊位于同一行,状态栏和右侧空间按设备计算。 */
.home-fixed-header { position:fixed; top:0; left:0; right:0; z-index:900; padding:var(--status-height,20px) 0 0; margin:0; text-align:left; box-sizing:border-box; }
.home-scroll { position:fixed; top:var(--nav-height,64px); left:0; right:0; height:calc(100vh - var(--nav-height,64px)); padding-top:0!important; }
.home-body { padding-top:48rpx; }
.home-fixed-heading { display:flex; flex-direction:column; align-items:flex-start; justify-content:center; box-sizing:border-box; padding-left:44rpx; }
.home-fixed-heading .j-eyebrow { font-size:20rpx; line-height:1.2; letter-spacing:2rpx; white-space:nowrap; }
.home-fixed-heading .j-page-title { font-size:34rpx; line-height:1.35; margin:4rpx 0 0; white-space:nowrap; }
@media (max-width:350px) { .home-fixed-heading { padding-left:32rpx; } }
+126
View File
@@ -0,0 +1,126 @@
const { getLayout } = require('../../utils/layout');
// pages/lovewords/lovewords.js - 一起听过的歌
const { api } = require('../../utils/request');
Page({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
list: [],
showModal: false,
isEdit: false,
editId: '',
form: { song_name: '', artist: '', note: '' }
},
onLoad: function() {
this.setData(getLayout());
this.loadData();
},
onShow: function() { this.loadData(); },
onPullDownRefresh: async function() {
await this.loadData();
wx.stopPullDownRefresh();
},
onShareAppMessage: function() {
return { title: '让所有的爱有迹可循', path: '/pages/lovewords/lovewords', imageUrl: '/images/share.png' };
},
onShareTimeline: function() {
return { title: '让所有的爱有迹可循', imageUrl: '/images/share.png' };
},
loadData: async function() {
if (this._pageLoading) return;
this._pageLoading = true;
this.setData({loading:true, loadError:false});
try {
const list = await api.songsList();
const items = (list || []).map(function(item) {
if (item.created_at) {
var d = new Date(item.created_at);
item.date_month = String(d.getMonth() + 1).padStart(2, '0');
item.date_day = String(d.getDate()).padStart(2, '0');
}
return item;
});
this.setData({ list: items });
} catch (e) {
this.setData({loadError:true});
console.error(e);
wx.showToast({ title: '加载失败', icon: 'none' });
}
finally { this._pageLoading = false; this.setData({loading:false}); }
},
noop: function() {},
onSongMenu: function(e) {
wx.showActionSheet({ itemList:['编辑歌曲', '删除歌曲'], success:(res) => {
if (res.tapIndex === 0) this.editSong(e);
else if (res.tapIndex === 1) this.deleteSong(e);
}});
},
deleteSong: function(e) {
var id = e.currentTarget.dataset.id;
wx.showModal({
title: '确认删除',
content: '确定要删除这首歌吗?',
success: async (res) => {
if (res.confirm) {
try {
await api.songsDelete({ id: id });
wx.showToast({ title: '已删除', icon: 'success' });
this.loadData();
} catch (err) { wx.showToast({ title: err.error || '删除失败', icon: 'none' }); }
}
}
});
},
// 编辑歌曲:打开弹窗并带入原数据
editSong: function(e) {
var ds = e.currentTarget.dataset;
this.setData({
showModal: true,
isEdit: true,
editId: ds.id,
form: { song_name: ds.name || '', artist: ds.artist || '', note: ds.note || '' }
});
},
// ===== 添加/编辑弹窗 =====
showModal: function() {
this.setData({ showModal: true, isEdit: false, editId: '', form: { song_name: '', artist: '', note: '' } });
},
hideModal: function() { this.setData({ showModal: false, isEdit: false, editId: '' }); },
onSongNameInput: function(e) { this.setData({ 'form.song_name': e.detail.value }); },
onArtistInput: function(e) { this.setData({ 'form.artist': e.detail.value }); },
onNoteInput: function(e) { this.setData({ 'form.note': e.detail.value }); },
submit: async function() {
var form = this.data.form;
if (!form.song_name || !form.artist) {
wx.showToast({ title: '请填写歌曲名和歌手名', icon: 'none' }); return;
}
try {
if (this.data.isEdit && this.data.editId) {
await api.songsUpdate({ id: this.data.editId, song_name: form.song_name, artist: form.artist, note: form.note || '' });
wx.showToast({ title: '已修改 ✏️', icon: 'success' });
} else {
await api.songsAdd(form);
wx.showToast({ title: '收藏成功 🎵', icon: 'success' });
}
this.hideModal();
this.loadData();
} catch (e) {
wx.showToast({ title: e.error || '保存失败', icon: 'none' });
}
},
goToPublish: function() {
this.showModal();
},
goBack: function() { wx.navigateBack(); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); },
goProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); }
});
+8
View File
@@ -0,0 +1,8 @@
{
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}
+52
View File
@@ -0,0 +1,52 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/lovewords/lovewords.wxml - 一起听过的歌 -->
<app-nav title="一起听的歌" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="c-body"><view class="c-heading j-row"><view><text class="j-eyebrow">OUR SOUNDTRACK</text><text class="j-page-title">一起听过的旋律</text></view><button class="j-button j-button-small" bindtap="goToPublish">+ 添加</button></view>
<page-state wx:if="{{loading}}" kind="loading" title="正在加载" /><page-state wx:elif="{{loadError}}" kind="error" title="加载失败" description="请检查网络后重新加载" action="重试" bind:action="loadData" />
<view class="song-list" wx:if="{{!loading && !loadError && list.length}}">
<view class="song-item" wx:for="{{list}}" wx:key="id">
<view class="song-number">{{index < 9 ? '0' + (index + 1) : index + 1}}</view>
<view class="song-content">
<text class="song-name">{{item.song_name}}</text><text class="song-artist">{{item.artist}}</text>
<text class="song-note" wx:if="{{item.note}}">{{item.note}}</text>
<view class="song-footer"><text>{{item.nickname || item.author || '我们'}}</text><text wx:if="{{item.date_month}}">{{item.date_month}}/{{item.date_day}}</text></view>
</view>
<view class="song-menu" bindtap="onSongMenu" data-id="{{item.id}}" data-name="{{item.song_name}}" data-artist="{{item.artist}}" data-note="{{item.note}}" aria-label="歌曲操作"><image src="/images/more.svg" mode="aspectFit" /></view>
</view>
</view>
<page-state wx:if="{{!loading && !loadError && !list.length}}" title="收藏我们的第一首歌" description="让某一段旋律,成为两个人的记忆" action="添加歌曲" bind:action="goToPublish" />
</view>
</scroll-view>
<!-- 添加弹窗 -->
<view class="modal-overlay" wx:if="{{showModal}}" bindtap="hideModal">
<view class="modal" catchtap="noop">
<button class="modal-close" bindtap="hideModal"><image src="/images/close.svg" mode="aspectFit" /></button>
<text class="title">{{isEdit ? '编辑歌曲' : '收藏歌曲'}}</text>
<text class="modal-subtitle">记录我们一起听过的歌</text>
<view class="input-group">
<label>歌曲名</label>
<input class="input" placeholder="输入歌曲名" value="{{form.song_name}}" bindinput="onSongNameInput" />
</view>
<view class="input-group">
<label>歌手</label>
<input class="input" placeholder="输入歌手名" value="{{form.artist}}" bindinput="onArtistInput" />
</view>
<view class="input-group">
<label>备注(选填)</label>
<input class="input" placeholder="一句话,限60字" maxlength="60" value="{{form.note}}" bindinput="onNoteInput" />
</view>
<button class="btn btn-primary" bindtap="submit" hover-class="none">{{isEdit ? '保存修改' : '保存'}}</button>
</view>
</view>
</view>
+13
View File
@@ -0,0 +1,13 @@
@import '../../styles/common.wxss';
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
@import '../../styles/companion.wxss';
.song-list { background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:12rpx 32rpx 12rpx 12rpx; padding:0 26rpx; }
.song-item { display:flex; align-items:flex-start; gap:18rpx; padding:30rpx 0; border-bottom:1rpx solid #E9DDD0; }.song-item:last-child { border-bottom:0; }
.song-number { flex-shrink:0; width:32rpx; padding-top:5rpx; font-size:22rpx; color:#A39386; font-variant-numeric:tabular-nums; }
.song-content { min-width:0; flex:1; }.song-name { display:block; font-size:29rpx; line-height:1.5; font-weight:600; color:#352E2A; overflow-wrap:break-word; }
.song-artist { display:block; font-size:24rpx; color:#85776D; margin-top:4rpx; line-height:1.5; }
.song-note { display:block; margin-top:14rpx; font-size:24rpx; line-height:1.6; color:#65594F; }
.song-footer { display:flex; gap:16rpx; flex-wrap:wrap; margin-top:16rpx; font-size:21rpx; color:#A39386; }
.song-menu { width:64rpx; height:64rpx; margin-right:-12rpx; display:flex; align-items:center; justify-content:center; flex-shrink:0; }.song-menu image { width:36rpx; height:36rpx; }
.modal-overlay { z-index:1200; }.modal-close { background:transparent; }.modal .title { display:block; text-align:left; font-size:32rpx; color:#352E2A; }.modal-subtitle { text-align:left; }
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}
+447
View File
@@ -0,0 +1,447 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/period/period.wxml - 姨姨(美柚风格日历模式) -->
<app-nav title="姨姨" back="{{true}}" /><view class="period-loading" wx:if="{{accessLoading}}"><page-state kind="loading" title="正在加载姨姨记录" /></view>
<view class="j-denied" wx:if="{{!accessLoading && accessDenied}}"><view class="j-denied-card"><image style="width:56rpx;height:56rpx" src="/images/j-flower.svg" /><text class="j-page-title">对方暂时关闭了可见</text><text class="j-caption">这份生理记录目前不可查看,需要对方重新开启后才能访问。</text><button class="j-button" bindtap="loadData">刷新权限</button></view><view class="j-permission" wx:if="{{hasCouple}}"><view><text class="j-small-title">对方可见</text><text class="j-caption">你的开关只决定对方能否查看</text></view><switch checked="{{periodVisible}}" disabled="{{visibilitySaving}}" bindchange="onPeriodVisibleChange" color="#B74768" /></view></view>
<view class="j-denied" wx:if="{{!accessLoading && selectionRequired}}"><view class="j-denied-card"><text class="j-page-title">选择保留的生理记录</text><text class="j-caption">绑定前双方都有记录,请选择一份作为当前记录。另一份将删除。</text><button class="j-button" bindtap="choosePeriodSource">选择当前记录</button></view></view>
<scroll-view class="page-bg" wx:if="{{!accessLoading && !accessDenied && !selectionRequired}}" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="j-body">
<view class="j-period-summary"><view class="j-row"><text class="j-eyebrow">BODY JOURNAL</text><view class="j-link" bindtap="showPeriodSettings">周期设置</view></view><text class="j-period-title">{{cycles.length ? '周期第 ' + cycleDay + ' 天' : '慢慢了解自己的节奏'}}</text><text class="j-period-description">{{cycles.length ? '周期长度 ' + avgCycle + ' 天 · 距下次预计 ' + predictedDays + ' 天' : '从记录一次生理期开始'}}</text><text wx:if="{{cycles.length}}" class="j-caption">{{periodStatusText}}</text></view>
<view wx:if="{{!cycles.length}}" class="j-denied-card" style="margin-bottom:36rpx"><text class="j-small-title">还没有生理期记录</text><text class="j-caption">填写最近一次生理期,开始记录身体状态。</text><button class="j-button" bindtap="showPeriodSettings">设置生理期</button></view>
<!-- ===== 日历卡片 ===== -->
<view class="calendar-card">
<!-- 日历导航 -->
<view class="calendar-nav">
<text class="nav-btn" bindtap="prevMonth">‹</text>
<text class="nav-title">{{currentYear}}年{{currentMonth}}月</text>
<text class="nav-btn" bindtap="nextMonth">›</text>
</view>
<!-- 日历星期头 -->
<view class="calendar-weekdays">
<text wx:for="{{weekdays}}" wx:key="index" class="weekday {{item === '日' || item === '六' ? 'weekend' : ''}}">{{item}}</text>
</view>
<!-- 日历格子 -->
<view class="calendar-grid">
<view wx:for="{{calendarDays}}" wx:key="index" class="calendar-cell {{item.className}}" catchtap="{{(item.isFuture || !item.dateStr) ? '' : 'onDayTap'}}" bindlongpress="{{(item.isFuture || !item.dateStr) ? '' : 'onDayLongPress'}}" data-date="{{item.dateStr}}" data-index="{{index}}">
<!-- 爱爱图标(日期数字上方) -->
<view wx:if="{{item.hasSex}}" class="sex-icon-top">
<text class="sex-icon-top-text">💕</text>
<text wx:if="{{item.sexCount > 1}}" class="sex-icon-top-count">{{item.sexCount}}</text>
</view>
<text class="cell-day">{{item.isToday ? '今' : item.day}}</text>
<view class="cell-dots-row">
<!-- 经期指示 -->
<view wx:if="{{item.periodLevel > 0}}" class="cell-dot period-dot level-{{item.periodLevel}}"></view>
<!-- 有记录指示(无经期时显示) -->
<view wx:if="{{item.hasRecord && item.periodLevel === 0}}" class="cell-dot record-dot"></view>
</view>
</view>
</view>
<!-- 日历图例(卡片内最下方) -->
<view class="calendar-legend">
<view class="legend-item"><view class="legend-dot legend-period"></view><text>经期</text></view>
<view class="legend-item"><view class="legend-dot legend-ovulation-day"></view><text>排卵日</text></view>
<view class="legend-item"><view class="legend-dot legend-ovulation"></view><text>排卵期</text></view>
<view class="legend-item"><view class="legend-dot legend-predicted"></view><text>预测经期</text></view>
<view class="legend-item"><view class="legend-dot legend-safe"></view><text>安全期</text></view>
</view>
</view>
<!-- ===== 快捷记录卡片(8个按钮,日历下方) ===== -->
<page-state wx:if="{{!selectedDate}}" title="选择一天,留意身体的感受" description="点选上方日历中的日期,查看或填写当天记录" />
<view class="quick-events-card" wx:if="{{selectedDate}}"><view class="section-heading"><text>{{selectedDateLabel}}</text><text class="section-link">{{selectedWeekday}}</text></view>
<view class="quick-events-grid">
<view class="quick-event-btn {{dailyRecord.period > 0 ? 'active period' : ''}}" catchtap="onPeriodTap">
<image class="qe-icon" src="/images/j-flower.svg" />
<text class="qe-label">月经</text>
<text wx:if="{{dailyRecord.period > 0}}" class="qe-check">✓</text>
</view>
<view class="quick-event-btn {{sexRecords.length > 0 ? 'active sex' : ''}}" catchtap="showSexRecords">
<image class="qe-icon" src="/images/heart.svg" />
<text class="qe-label">爱爱</text>
<text wx:if="{{sexRecords.length > 0}}" class="sex-count-badge">{{sexRecords.length}}</text>
</view>
<view class="quick-event-btn {{dailyRecord.symptoms ? 'active' : ''}}" catchtap="showSymptomPicker">
<image class="qe-icon" src="/images/j-activity.svg" />
<text class="qe-label">症状</text>
<text wx:if="{{dailyRecord.symptoms}}" class="qe-check">✓</text>
</view>
<view class="quick-event-btn {{dailyRecord.mood ? 'active' : ''}}" catchtap="showMoodPicker">
<image class="qe-icon" src="/images/j-smile.svg" />
<text class="qe-label">心情</text>
<text wx:if="{{dailyRecord.mood}}" class="qe-check">✓</text>
</view>
<view class="quick-event-btn {{dailyRecord.discharge ? 'active' : ''}}" catchtap="onDischargeTap">
<image class="qe-icon" src="/images/j-droplet.svg" />
<text class="qe-label">白带</text>
<text wx:if="{{dailyRecord.discharge}}" class="qe-check">✓</text>
</view>
<view class="quick-event-btn {{dailyRecord.temperature ? 'active' : ''}}" catchtap="onTempTap">
<image class="qe-icon" src="/images/j-thermometer.svg" />
<text class="qe-label">体温</text>
<text wx:if="{{dailyRecord.temperature}}" class="qe-check">✓</text>
</view>
<view class="quick-event-btn {{dailyRecord.weight ? 'active' : ''}}" catchtap="onWeightTap">
<image class="qe-icon" src="/images/j-scale.svg" />
<text class="qe-label">体重</text>
<text wx:if="{{dailyRecord.weight}}" class="qe-check">✓</text>
</view>
<view class="quick-event-btn {{dailyRecord.note ? 'active' : ''}}" catchtap="showNoteInput">
<image class="qe-icon" src="/images/book.svg" />
<text class="qe-label">备注</text>
<text wx:if="{{dailyRecord.note}}" class="qe-check">✓</text>
</view>
</view>
</view>
<view class="j-permission" wx:if="{{hasCouple}}"><view><text class="j-small-title">对方可见</text><text class="j-caption">关闭后,对方无法查看这份生理记录</text></view><switch checked="{{periodVisible}}" disabled="{{visibilitySaving}}" bindchange="onPeriodVisibleChange" color="#B74768" /></view>
<!-- ===== 历史周期列表 ===== -->
<view class="section-title">历史周期<text class="section-hint">长按可删除</text></view>
<view class="cycle-list" wx:if="{{cycles.length > 0}}">
<view class="cycle-card" wx:for="{{cycles}}" wx:key="start_date" bindlongpress="onCycleLongPress" data-id="{{item.id}}" data-start="{{item.start_date}}" data-end="{{item.end_date}}">
<view class="cycle-header">
<image class="cycle-icon" src="/images/j-flower.svg" style="width:28rpx;height:28rpx" />
<text class="cycle-date">{{item.start_date}} ~ {{item.end_date}}</text>
</view>
<view class="cycle-details">
<text class="cycle-detail">周期 {{item.cycle_length}}天</text>
<text class="cycle-detail">持续 {{item.period_length}}天</text>
<text wx:if="{{item.symptoms}}" class="cycle-detail">{{item.symptoms}}</text>
</view>
<view wx:if="{{item.note}}" class="cycle-note">{{item.note}}</view>
</view>
</view>
<view class="empty-state" wx:else>
<text class="empty-icon">🌸</text>
<text class="empty-title">还没有周期记录</text>
<text class="empty-desc">点击上方周期设置,记录最近一次生理期</text>
</view>
</view>
</scroll-view>
<!-- 浮动按钮已移至日历卡片内 -->
<!-- ===== 姨姨设置弹窗 ===== -->
<view class="modal-overlay" wx:if="{{showCycleModal}}" catchtouchmove="preventMove">
<scroll-view scroll-y class="modal period-modal settings-modal" show-scrollbar="{{false}}">
<text class="modal-title">姨姨设置</text>
<text class="modal-subtitle">填写上次姨妈信息,帮你推算下次</text>
<view class="modal-form">
<!-- 持续天数 -->
<view class="form-row">
<text class="form-label">持续天数</text>
<view class="form-stepper">
<text class="stepper-btn" catchtap="adjustPeriod" data-dir="-1">−</text>
<text class="stepper-value">{{cycleForm.period_length}}天</text>
<text class="stepper-btn" catchtap="adjustPeriod" data-dir="1">+</text>
</view>
</view>
<!-- 上一次来时 -->
<view class="form-row">
<text class="form-label">上一次来时</text>
<picker mode="date" value="{{cycleForm.start_date}}" bindchange="onCycleStartChange">
<view class="form-picker">{{cycleForm.start_date}}</view>
</picker>
</view>
<!-- 结束时 -->
<view class="form-row">
<text class="form-label">结束时</text>
<picker mode="date" value="{{cycleForm.end_date}}" bindchange="onCycleEndChange">
<view class="form-picker">{{cycleForm.end_date}}</view>
</picker>
</view>
<!-- 周期长度(用于推算) -->
<view class="form-row">
<text class="form-label">周期长度</text>
<view class="form-stepper">
<text class="stepper-btn" catchtap="adjustCycle" data-dir="-1">−</text>
<text class="stepper-value">{{cycleForm.cycle_length}}天</text>
<text class="stepper-btn" catchtap="adjustCycle" data-dir="1">+</text>
</view>
</view>
</view>
<!-- 推算结果 -->
<view class="prediction-box">
<text class="prediction-title">📅 推算结果</text>
<view class="prediction-row">
<text class="prediction-label">下次姨妈</text>
<text class="prediction-value">{{cycleForm.predicted_start}} ~ {{cycleForm.predicted_end}}</text>
</view>
<view class="prediction-row">
<text class="prediction-label">预计排卵日</text>
<text class="prediction-value ovulation">{{cycleForm.predicted_ovulation_day}}</text>
</view>
<view class="prediction-row">
<text class="prediction-label">排卵期</text>
<text class="prediction-value ovulation-range">{{cycleForm.predicted_ovulation_start}} ~ {{cycleForm.predicted_ovulation_end}}</text>
</view>
<!-- 推算公式说明 -->
<view class="formula-toggle" bindtap="toggleFormula">
<text class="formula-toggle-text">{{showFormula ? '收起' : '查看'}}推算公式</text>
<text class="formula-toggle-arrow">{{showFormula ? '▲' : '▼'}}</text>
</view>
<view class="formula-detail" wx:if="{{showFormula}}">
<view class="formula-item">
<text class="formula-label">下次姨妈:</text>
<text class="formula-text">上次来时 + 周期长度({{cycleForm.cycle_length}}天)</text>
</view>
<view class="formula-item">
<text class="formula-label">下次结束:</text>
<text class="formula-text">下次来时 + 持续天数({{cycleForm.period_length}}天) − 1</text>
</view>
<view class="formula-item">
<text class="formula-label">排卵日:</text>
<text class="formula-text">下次姨妈开始日 − 14天</text>
</view>
<view class="formula-item">
<text class="formula-label">排卵期:</text>
<text class="formula-text">排卵日 ± 3天(共7天)</text>
</view>
<view class="formula-item">
<text class="formula-label">安全期:</text>
<text class="formula-text">经期 & 排卵期之外的日期</text>
</view>
</view>
<!-- 免责声明 -->
<view class="disclaimer-box">
<text class="disclaimer-icon">⚠️</text>
<text class="disclaimer-text">以上推算基于标准周期公式,<text class="disclaimer-bold">仅供参考</text>。实际周期受个人身体状态、作息规律、情绪压力、饮食运动等多种因素影响,可能存在偏差。如有疑问请咨询专业医生。</text>
</view>
</view>
<view class="modal-actions">
<view class="btn btn-secondary" bindtap="closeCycleModal">取消</view>
<view class="btn btn-primary" bindtap="saveCycle">保存设置</view>
</view>
</scroll-view>
</view>
<!-- ===== 经期详情弹窗(量、颜色、疼痛) ===== -->
<view class="modal-overlay" wx:if="{{showPeriodModal}}" catchtouchmove="preventMove">
<view class="modal period-modal">
<view class="modal-heart">🌸</view>
<text class="modal-title">月经详情</text>
<text class="modal-subtitle">{{selectedDateLabel}} {{selectedWeekday}}</text>
<!-- 量 -->
<view class="period-section">
<text class="period-section-label">量</text>
<view class="period-chip-group">
<view wx:for="{{periodAmountOptions}}" wx:key="value" class="period-chip {{periodForm.amount === item.value ? 'active' : ''}}" catchtap="onPeriodAmount" data-value="{{item.value}}">{{item.label}}</view>
</view>
</view>
<!-- 颜色 -->
<view class="period-section">
<text class="period-section-label">颜色</text>
<view class="period-chip-group">
<view wx:for="{{periodColorOptions}}" wx:key="value" class="period-chip {{periodForm.color === item.value ? 'active' : ''}}" catchtap="onPeriodColor" data-value="{{item.value}}">
<view class="color-dot" style="background:{{item.dot}};"></view>
<text>{{item.label}}</text>
</view>
</view>
</view>
<!-- 疼痛 -->
<view class="period-section">
<text class="period-section-label">疼痛</text>
<view class="period-chip-group">
<view wx:for="{{periodPainOptions}}" wx:key="value" class="period-chip {{periodForm.pain === item.value ? 'active' : ''}}" catchtap="onPeriodPain" data-value="{{item.value}}">{{item.label}}</view>
</view>
</view>
<view class="modal-actions">
<view class="btn btn-secondary" bindtap="closePeriodModal">取消</view>
<view class="btn btn-primary" bindtap="confirmPeriodDetail">确定</view>
</view>
</view>
</view>
<!-- ===== 症状选择弹窗 ===== -->
<view class="modal-overlay" wx:if="{{showSymptomModal}}" catchtouchmove="preventMove">
<view class="modal period-modal">
<view class="modal-heart">🤒</view>
<text class="modal-title">选择症状</text>
<view class="symptom-grid">
<text wx:for="{{symptomOptions}}" wx:key="*this" class="symptom-tag-lg {{symptomMap[item] ? 'active' : ''}}" catchtap="toggleSymptom" data-symptom="{{item}}">{{item}}</text>
</view>
<view class="modal-actions">
<view class="btn btn-secondary" bindtap="closeSymptomModal">取消</view>
<view class="btn btn-primary" bindtap="confirmSymptoms">确定</view>
</view>
</view>
</view>
<!-- ===== 心情选择弹窗 ===== -->
<view class="modal-overlay" wx:if="{{showMoodModal}}" catchtouchmove="preventMove">
<view class="modal period-modal">
<view class="modal-heart">😊</view>
<text class="modal-title">选择心情</text>
<view class="mood-grid">
<view wx:for="{{moodOptions}}" wx:key="*this" class="mood-item {{dailyRecord.mood === item.label ? 'active' : ''}}" catchtap="selectMood" data-mood="{{item.label}}">
<text class="mood-emoji">{{item.emoji}}</text>
<text class="mood-label">{{item.label}}</text>
</view>
</view>
<view class="modal-actions">
<view class="btn btn-secondary" bindtap="closeMoodModal">取消</view>
</view>
</view>
</view>
<!-- ===== 白带详情弹窗(状态、流量、异常) ===== -->
<view class="modal-overlay" wx:if="{{showDischargeModal}}" catchtouchmove="preventMove">
<view class="modal period-modal">
<view class="modal-heart">💧</view>
<text class="modal-title">白带详情</text>
<text class="modal-subtitle">{{selectedDateLabel}} {{selectedWeekday}}</text>
<!-- 状态 -->
<view class="period-section">
<text class="period-section-label">状态</text>
<view class="period-chip-group">
<view wx:for="{{dischargeStatusOptions}}" wx:key="value" class="period-chip {{dischargeForm.status === item.value ? 'active' : ''}}" catchtap="onDischargeStatus" data-value="{{item.value}}">{{item.label}}</view>
</view>
</view>
<!-- 流量 -->
<view class="period-section">
<text class="period-section-label">流量</text>
<view class="period-chip-group">
<view wx:for="{{dischargeFlowOptions}}" wx:key="value" class="period-chip {{dischargeForm.flow === item.value ? 'active' : ''}}" catchtap="onDischargeFlow" data-value="{{item.value}}">{{item.label}}</view>
</view>
</view>
<!-- 异常(多选) -->
<view class="period-section">
<text class="period-section-label">异常(可多选)</text>
<view class="period-chip-group">
<view wx:for="{{dischargeAbnormalOptions}}" wx:key="value" class="period-chip {{dischargeAbnormalMap[item.value] ? 'active' : ''}}" catchtap="onDischargeAbnormal" data-value="{{item.value}}">{{item.label}}</view>
</view>
</view>
<view class="modal-actions">
<view class="btn btn-secondary" bindtap="closeDischargeModal">取消</view>
<view class="btn btn-primary" bindtap="confirmDischargeDetail">确定</view>
</view>
</view>
</view>
<!-- ===== 体温输入弹窗 ===== -->
<view class="modal-overlay" wx:if="{{showTempModal}}" catchtouchmove="preventMove">
<view class="modal period-modal">
<view class="modal-heart">🌡️</view>
<text class="modal-title">记录体温</text>
<view class="temp-input-row">
<input class="temp-input" type="digit" placeholder="36.5" value="{{tempValue}}" bindinput="onTempInput" />
<text class="temp-unit">°C</text>
</view>
<view class="modal-actions">
<view class="btn btn-secondary" bindtap="closeTempModal">取消</view>
<view class="btn btn-primary" bindtap="confirmTemp">保存</view>
</view>
</view>
</view>
<!-- ===== 体重输入弹窗 ===== -->
<view class="modal-overlay" wx:if="{{showWeightModal}}" catchtouchmove="preventMove">
<view class="modal period-modal">
<view class="modal-heart">⚖️</view>
<text class="modal-title">记录体重</text>
<view class="temp-input-row">
<input class="temp-input" type="digit" placeholder="50.0" value="{{weightValue}}" bindinput="onWeightInput" />
<text class="temp-unit">kg</text>
</view>
<view class="modal-actions">
<view class="btn btn-secondary" bindtap="closeWeightModal">取消</view>
<view class="btn btn-primary" bindtap="confirmWeight">保存</view>
</view>
</view>
</view>
<!-- ===== 备注输入弹窗 ===== -->
<view class="modal-overlay" wx:if="{{showNoteModal}}" catchtouchmove="preventMove">
<view class="modal period-modal">
<view class="modal-heart">📝</view>
<text class="modal-title">添加备注</text>
<textarea class="note-textarea" placeholder="记录一下今天的状态..." value="{{noteValue}}" bindinput="onNoteInput" maxlength="500" />
<view class="modal-actions">
<view class="btn btn-secondary" bindtap="closeNoteModal">取消</view>
<view class="btn btn-primary" bindtap="confirmNote">确定</view>
</view>
</view>
</view>
<!-- ===== 爱爱记录弹窗 ===== -->
<view class="modal-overlay" wx:if="{{showSexModal}}" catchtouchmove="preventMove">
<view class="modal period-modal sex-modal">
<view class="modal-heart">💕</view>
<text class="modal-title">爱爱记录</text>
<text class="modal-subtitle">{{selectedDateLabel}} {{selectedWeekday}}</text>
<!-- 已记录列表 -->
<scroll-view class="sex-record-list" wx:if="{{sexRecords.length > 0}}" scroll-y show-scrollbar="{{false}}">
<view class="sex-record-item" wx:for="{{sexRecords}}" wx:key="id">
<view class="sex-record-info">
<text class="sex-time">{{item.record_time ? item.record_time.substring(0, 5) : '--:--'}}</text>
<text class="sex-measure">{{item.measure || '未知'}}</text>
<text wx:if="{{item.remark}}" class="sex-remark">{{item.remark}}</text>
</view>
<view class="sex-record-time">{{item.record_time || '--:--'}}</view>
<view class="sex-record-del" catchtap="deleteSexRecord" data-id="{{item.id}}">
<text class="sex-del-icon">✕</text>
</view>
</view>
</scroll-view>
<!-- 添加新记录 -->
<view class="sex-add-section">
<text class="sex-add-title">添加记录</text>
<!-- 时间选择 -->
<view class="sex-form-row">
<text class="sex-form-label">时间</text>
<picker mode="time" value="{{sexForm.record_time}}" bindchange="onSexTimeChange">
<view class="sex-form-picker">{{sexForm.record_time || '选择时间'}}</view>
</picker>
</view>
<!-- 措施选择 -->
<text class="sex-form-label" style="margin-top:16rpx;display:block;">措施</text>
<view class="sex-measure-grid">
<view wx:for="{{measureOptions}}" wx:key="value" class="sex-measure-item {{sexForm.measure === item.value ? 'active' : ''}}" catchtap="onSexMeasureSelect" data-measure="{{item.value}}">
<text class="sex-measure-icon">{{item.icon}}</text>
<text class="sex-measure-label">{{item.label}}</text>
</view>
</view>
<!-- 备注 -->
<view class="sex-form-row">
<text class="sex-form-label">备注</text>
<input class="sex-form-input" placeholder="选填" value="{{sexForm.remark}}" bindinput="onSexRemarkInput" />
</view>
</view>
<view class="modal-actions">
<view class="btn btn-secondary" bindtap="closeSexModal">关闭</view>
<view class="btn btn-primary" bindtap="confirmSexAdd">添加记录</view>
</view>
</view>
</view>
</view>
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
const { getLayout } = require('../../utils/layout');
// pages/pet/achievements.js
const { api } = require('../../utils/request');
Page({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
achievements: [],
unlockedCount: 0,
totalCount: 0,
progressPercent: 0
},
onLoad: function() {
this.setData(getLayout());
this.loadAchievements();
},
onShow: function() {
this.loadAchievements();
},
loadAchievements: async function() {
if (this._pageLoading) return;
this._pageLoading = true;
this.setData({loading:true, loadError:false});
try {
const res = await api.petAchievements();
let list = res.achievements || [];
// 排序:已解锁的按解锁时间倒序(最新获得的排最前),未解锁的排后面
list = list.sort(function(a, b) {
var aUnlocked = a.unlocked ? 1 : 0;
var bUnlocked = b.unlocked ? 1 : 0;
if (aUnlocked !== bUnlocked) {
return bUnlocked - aUnlocked;
}
if (aUnlocked && bUnlocked) {
var ta = (a.unlocked_at || '').replace(/-/g, '').replace(/[: ]/g, '');
var tb = (b.unlocked_at || '').replace(/-/g, '').replace(/[: ]/g, '');
return tb.localeCompare(ta);
}
return 0;
});
const unlocked = list.filter(a => a.unlocked);
const total = list.length;
var pageData = {
achievements: list,
unlockedCount: unlocked.length,
totalCount: total,
progressPercent: total > 0 ? Math.round(unlocked.length / total * 100) : 0
};
this.setData(pageData);
} catch (e) {
this.setData({loadError:true});
console.error(e);
}
finally { this._pageLoading = false; this.setData({loading:false}); }
},
goBack: function() {
wx.navigateBack();
}
});
+7
View File
@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "宠物成就",
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}
+43
View File
@@ -0,0 +1,43 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/pet/achievements.wxml -->
<app-nav title="宠物成就" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="container">
<page-state wx:if="{{loading}}" kind="loading" title="正在加载" /><page-state wx:elif="{{loadError}}" kind="error" title="加载失败" action="重试" bind:action="loadAchievements" /><block wx:else>
<!-- 成就概览 -->
<view class="ach-summary">
<view class="ach-deco ach-deco-1">🌟</view>
<view class="ach-deco ach-deco-2">🏅</view>
<text class="ach-count">{{unlockedCount}}<text class="ach-total">/{{totalCount}}</text></text>
<text class="ach-progress-text">已解锁成就</text>
<view class="ach-progress-track">
<view class="ach-progress-fill" style="width:{{progressPercent}}%;"></view>
</view>
<text class="ach-percent">{{progressPercent}}%</text>
</view>
<!-- 成就列表 -->
<view class="ach-list">
<view wx:for="{{achievements}}" wx:key="key" class="ach-card {{item.unlocked?'':'locked'}}">
<view class="ach-card-icon">
<text>{{item.unlocked ? item.icon : '🔒'}}</text>
</view>
<view class="ach-card-info">
<view class="ach-card-name-row">
<text class="ach-card-name">{{item.name}}</text>
<text class="ach-card-status {{item.unlocked?'unlocked':'locked-badge'}}">{{item.unlocked ? '已解锁' : '未解锁'}}</text>
</view>
<text class="ach-card-desc">{{item.desc}}</text>
<text wx:if="{{item.unlocked && item.unlocked_at}}" class="ach-card-time">🏅 {{item.unlocked_at}}</text>
</view>
</view>
</view>
</block>
</view>
</scroll-view>
</view>
+183
View File
@@ -0,0 +1,183 @@
/* pages/pet/achievements.wxss */
@import '../../styles/common.wxss';
page {
background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%);
height: 100vh;
overflow: hidden;
}
scroll-view.page-bg {
height: 100vh;
padding-top: 196rpx;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%);
}
/* ===== 顶部导航(与其他页面一致,fixed 定位与微信胶囊对齐) ===== */
.top-nav-card {
background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%);
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: #C96A85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 右侧占位,保持标题真正居中 */
.nav-spacer {
width: 56rpx;
flex-shrink: 0;
}
.container {
padding: 24rpx 32rpx;
}
/* ===== 成就概览 ===== */
.ach-summary {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #FFD3E0, #FFB6C6 55%, #FF9AB0);
border-radius: 32rpx;
padding: 40rpx 40rpx;
text-align: center;
margin-bottom: 24rpx;
color: #fff;
box-shadow: 0 12rpx 30rpx rgba(255, 140, 170, 0.22);
}
.ach-deco {
position: absolute;
font-size: 60rpx;
opacity: 0.3;
}
.ach-deco-1 {
top: 24rpx;
left: 32rpx;
}
.ach-deco-2 {
bottom: 24rpx;
right: 32rpx;
}
.ach-count {
font-size: 88rpx;
font-weight: 800;
display: block;
line-height: 1;
}
.ach-total {
font-size: 40rpx;
font-weight: 400;
opacity: 0.7;
}
.ach-progress-text {
font-size: 26rpx;
opacity: 0.85;
display: block;
margin-top: 12rpx;
margin-bottom: 24rpx;
}
.ach-progress-track {
height: 14rpx;
background: rgba(255,255,255,0.3);
border-radius: 999rpx;
overflow: hidden;
}
.ach-progress-fill {
height: 100%;
background: linear-gradient(90deg, #FFE066, #FFD700);
border-radius: 999rpx;
transition: width 0.5s ease;
}
.ach-percent {
display: block;
margin-top: 16rpx;
font-size: 24rpx;
font-weight: 600;
opacity: 0.9;
}
/* ===== 成就列表 ===== */
.ach-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.ach-card {
display: flex;
align-items: center;
gap: 24rpx;
padding: 28rpx;
background: linear-gradient(160deg, #FFF8FA 0%, #FFF0F4 100%);
border: 2rpx solid rgba(255,190,210,0.3);
border-radius: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(255, 140, 170, 0.08);
transition: all 0.3s;
}
.ach-card.locked {
opacity: 0.6;
}
.ach-card-icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #FFD3E0, #FFB6C6);
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
flex-shrink: 0;
box-shadow: inset 0 0 0 2rpx rgba(255,107,129,0.15);
}
.ach-card.locked .ach-card-icon {
background: #F5F5F5;
box-shadow: inset 0 0 0 2rpx rgba(0,0,0,0.04);
}
.ach-card-info {
flex: 1;
}
.ach-card-name-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6rpx;
}
.ach-card-name {
font-size: 30rpx;
font-weight: 600;
color: #5A4A4E;
}
.ach-card-desc {
font-size: 24rpx;
color: #B08A8A;
display: block;
}
.ach-card-time {
font-size: 20rpx;
color: #C9A8A8;
display: block;
margin-top: 8rpx;
}
.ach-card-status {
padding: 6rpx 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 500;
flex-shrink: 0;
}
.ach-card-status.unlocked {
background: #E8F5E9;
color: #66BB6A;
}
.ach-card-status.locked-badge {
background: #F5F5F5;
color: #C9A8A8;
}
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
.adopt-card,.stat-card,.action-card,.dynamics-card,.ach-card { background:#FFFDF8; border:1rpx solid #E9DDD0; box-shadow:none; }.adopt-title,.ach-card-name { color:#352E2A; }.adopt-desc,.ach-card-desc { color:#85776D; }.ach-summary { background:#F4E2E3; color:#943C58; box-shadow:none; }.ach-deco { display:none; }
+309
View File
@@ -0,0 +1,309 @@
const { getLayout } = require('../../utils/layout');
// pages/pet/pet.js
const { api } = require('../../utils/request');
const BREED_NAMES = {
'golden': '金毛',
'husky': '哈士奇',
'corgi': '柯基',
'shiba': '柴犬'
};
const BREED_EMOJIS = {
'golden': '🐕',
'husky': '🐺',
'corgi': '🦊',
'shiba': '🐶'
};
const STAGE_NAMES = ['', '幼犬期', '少年期', '青年期', '成年期', '终极期'];
Page({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
hasPet: false,
pet: null,
daily: null,
achievements: [],
dynamics: [],
displayDynamics: [],
equipped: [],
// 领养表单
breedSelect: 'golden',
genderSelect: 1,
petName: '',
adopting: false,
// 宠物状态
dailyStatus: {},
partnerDaily: {},
allDone: false,
expProgress: 0,
expForNextLevel: 0,
breedName: '',
petEmoji: '',
stageName: '',
equippedCollar: false,
// 最近成就
recentAchievements: [],
achievementCount: 0,
// 改名
showRename: false,
renameValue: '',
renaming: false,
// 动态折叠(默认收起,收起时显示一条)
showAllDynamics: false
},
onLoad: function() {
this.setData(getLayout());
this.loadPetInfo();
},
onShow: function() {
this.loadPetInfo();
},
loadPetInfo: async function() {
if (this._pageLoading) return;
this._pageLoading = true;
this.setData({loading:true, loadError:false});
try {
const res = await api.petInfo();
if (res.has_pet && res.pet) {
const pet = res.pet;
const dailyStatus = {};
const partnerDaily = {};
if (res.daily) {
const d = res.daily;
const suffix = res.is_user1 ? 'u1' : 'u2';
const partnerSuffix = res.is_user1 ? 'u2' : 'u1';
['feed', 'water', 'walk', 'bath', 'sleep', 'play'].forEach(a => {
dailyStatus[a] = d[a + '_' + suffix] == 1;
partnerDaily[a] = d[a + '_' + partnerSuffix] == 1;
});
}
// 获取已装备物品
const equipped = res.equipped || [];
const equippedCollar = equipped.some(e => e.item_type === 'collar');
// 计算最近成就
const allAch = this.getAllAchievementDefs();
const recentAch = allAch.filter(a => res.achievements.indexOf(a.key) >= 0).slice(0, 5);
// 计算领养天数
var adoptDays = 0;
if (pet.adopted_at) {
var adoptDate = new Date(pet.adopted_at.replace(/-/g, '/'));
if (!isNaN(adoptDate.getTime())) {
adoptDays = Math.floor((Date.now() - adoptDate.getTime()) / 86400000);
}
}
var pageData = {
hasPet: true,
pet: pet,
daily: res.daily,
achievements: res.achievements,
dynamics: this._formatDynamics(res.dynamics || []),
showAllDynamics: false,
equipped: equipped,
dailyStatus: dailyStatus,
partnerDaily: partnerDaily,
allDone: res.daily ? res.daily.all_done == 1 : false,
expProgress: res.exp_progress || 0,
expForNextLevel: res.exp_for_next_level || 0,
breedName: BREED_NAMES[pet.breed] || '金毛',
petEmoji: BREED_EMOJIS[pet.breed] || '🐕',
stageName: STAGE_NAMES[pet.stage] || '幼犬期',
equippedCollar: equippedCollar,
recentAchievements: recentAch,
achievementCount: (res.achievements || []).length,
adoptDays: adoptDays
};
this.setData(pageData);
this._buildDisplayDynamics();
} else {
this.setData({ hasPet: false, pet:null, daily:null, achievements:[], dynamics:[], displayDynamics:[] });
}
} catch (e) {
this.setData({loadError:true});
console.error(e);
}
finally { this._pageLoading = false; this.setData({loading:false}); }
},
// 格式化动态数据:时间转为友好显示,昵称取首字
_formatDynamics: function(list) {
var now = Date.now();
return list.map(function(d) {
var timeText = '';
if (d.created_at) {
var t = new Date(String(d.created_at).replace(/-/g, '/').replace('T', ' '));
if (!isNaN(t.getTime())) {
var diff = Math.floor((now - t.getTime()) / 1000);
if (diff < 60) timeText = '刚刚';
else if (diff < 3600) timeText = Math.floor(diff / 60) + '分钟前';
else if (diff < 86400) timeText = Math.floor(diff / 3600) + '小时前';
else if (diff < 172800) timeText = '昨天';
else timeText = (t.getMonth() + 1) + '月' + t.getDate() + '日';
}
}
var initial = d.user_id == 0 ? '🐾' : (d.nickname ? String(d.nickname).trim()[0] : '?');
return {
user_id: d.user_id,
nickname: d.nickname,
avatar: d.avatar || '',
action: d.action,
message: d.message,
time_text: timeText,
initial: initial
};
});
},
getAllAchievementDefs: function() {
return [
{ key: 'first_adopt', name: '初次相遇', icon: '🐣' },
{ key: 'level_5', name: '成长之路', icon: '⭐' },
{ key: 'level_10', name: '小有成就', icon: '🌟' },
{ key: 'level_20', name: '资深铲屎官', icon: '👑' },
{ key: 'level_30', name: '终极陪伴', icon: '💎' },
{ key: 'stage_2', name: '少年初长成', icon: '🐕' },
{ key: 'stage_3', name: '青春活力', icon: '🦮' },
{ key: 'stage_4', name: '成熟稳重', icon: '🐩' },
{ key: 'stage_5', name: '终极形态', icon: '🦁' },
{ key: 'all_done_7', name: '一周全勤', icon: '📅' },
{ key: 'all_done_30', name: '月度之星', icon: '🏆' },
{ key: 'feed_100', name: '大胃王', icon: '🍖' },
{ key: 'walk_50', name: '散步达人', icon: '🚶' },
{ key: 'bath_30', name: '爱干净', icon: '🛁' },
{ key: 'play_50', name: '玩伴', icon: '🎾' },
{ key: 'happiness_100', name: '快乐满分', icon: '😊' },
{ key: 'rename_first', name: '取名大师', icon: '✏️' },
{ key: 'adopt_7days', name: '一周陪伴', icon: '📆' },
{ key: 'adopt_30days', name: '一月相伴', icon: '🗓️' },
{ key: 'adopt_100days', name: '百日纪念', icon: '💯' }
];
},
// 选择品种
selectBreed: function(e) {
this.setData({ breedSelect: e.currentTarget.dataset.breed });
},
// 选择性别
selectGender: function(e) {
this.setData({ genderSelect: parseInt(e.currentTarget.dataset.gender) });
},
// 输入名字
onNameInput: function(e) {
this.setData({ petName: e.detail.value });
},
// 领养
doAdopt: async function() {
var name = this.data.petName.trim();
if (!name) {
wx.showToast({ title: '请给宠物取个名字', icon: 'none' });
return;
}
this.setData({ adopting: true });
try {
const res = await api.petAdopt({
name: name,
breed: this.data.breedSelect,
gender: this.data.genderSelect
});
wx.showToast({ title: '🎉 领养成功!', icon: 'success' });
this.loadPetInfo();
} catch (e) {
wx.showToast({ title: e.error || '领养失败', icon: 'none' });
} finally {
this.setData({ adopting: false });
}
},
// 执行日常任务
doDailyAction: async function(e) {
var action = e.currentTarget.dataset.action;
if (this.data.dailyStatus[action]) {
wx.showToast({ title: '已完成', icon: 'none' });
return;
}
try {
const res = await api.petDailyAction({ action: action });
wx.showToast({ title: '✅ +' + (res.exp_gain || 0) + '经验', icon: 'success', duration: 1500 });
this.loadPetInfo();
} catch (e) {
if (e.already_done) {
wx.showToast({ title: '今天已完成啦', icon: 'none' });
this.loadPetInfo();
} else {
wx.showToast({ title: e.error || '操作失败', icon: 'none' });
}
}
},
// 改名
showRenameModal: function() {
this.setData({
showRename: true,
renameValue: this.data.pet ? this.data.pet.name : ''
});
},
hideRenameModal: function() {
this.setData({ showRename: false });
},
onRenameInput: function(e) {
this.setData({ renameValue: e.detail.value });
},
doRename: async function() {
var name = this.data.renameValue.trim();
if (!name) {
wx.showToast({ title: '请输入名字', icon: 'none' });
return;
}
this.setData({ renaming: true });
try {
await api.petRename({ name: name });
wx.showToast({ title: '改名成功 ✏️', icon: 'success' });
this.setData({ showRename: false });
this.loadPetInfo();
} catch (e) {
wx.showToast({ title: e.error || '改名失败', icon: 'none' });
} finally {
this.setData({ renaming: false });
}
},
// 展开/收起动态
toggleDynamics: function() {
this.setData({ showAllDynamics: !this.data.showAllDynamics }, () => {
this._buildDisplayDynamics();
});
},
// 根据折叠状态计算要展示的动态列表(收起时只显示一条)
_buildDisplayDynamics: function() {
var list = this.data.dynamics || [];
var show = this.data.showAllDynamics;
this.setData({ displayDynamics: show ? list : list.slice(0, 1) });
},
// 跳转到成就页
goToAchievements: function() {
wx.navigateTo({ url: '/pages/pet/achievements' });
},
// 返回
goBack: function() {
wx.navigateBack();
},
stopPropagation: function() {}
});
+7
View File
@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "宠物养成",
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}

Some files were not shown because too many files have changed in this diff Show More