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
+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"
}
}
+225
View File
@@ -0,0 +1,225 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/pet/pet.wxml -->
<app-nav title="宠物养成" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="c-body">
<page-state wx:if="{{loading}}" kind="loading" title="正在加载" /><page-state wx:elif="{{loadError}}" kind="error" title="加载失败" action="重试" bind:action="loadPetInfo" /><block wx:else>
<!-- ===== 未领养:领养引导 ===== -->
<view wx:if="{{!hasPet}}" class="adopt-section">
<view class="adopt-hero">
<text class="adopt-hero-emoji">🐾</text>
</view>
<view class="adopt-card">
<text class="adopt-title">领养一只属于我们的宠物</text>
<text class="adopt-desc">一起照顾它,见证它成长</text>
<view class="breed-select">
<view class="breed-option {{breedSelect=='golden'?'active':''}}" bindtap="selectBreed" data-breed="golden">
<text class="breed-emoji">🐕</text>
<text class="breed-name">金毛</text>
</view>
<view class="breed-option {{breedSelect=='husky'?'active':''}}" bindtap="selectBreed" data-breed="husky">
<text class="breed-emoji">🐺</text>
<text class="breed-name">哈士奇</text>
</view>
<view class="breed-option {{breedSelect=='corgi'?'active':''}}" bindtap="selectBreed" data-breed="corgi">
<text class="breed-emoji">🦊</text>
<text class="breed-name">柯基</text>
</view>
<view class="breed-option {{breedSelect=='shiba'?'active':''}}" bindtap="selectBreed" data-breed="shiba">
<text class="breed-emoji">🐶</text>
<text class="breed-name">柴犬</text>
</view>
</view>
<view class="input-group" style="margin-top:32rpx;">
<input class="input" placeholder="给宠物取个名字吧" value="{{petName}}" bindinput="onNameInput" maxlength="20" />
</view>
<view class="gender-select">
<view class="gender-option {{genderSelect==1?'active':''}}" bindtap="selectGender" data-gender="1">
<text>♂ 男孩</text>
</view>
<view class="gender-option {{genderSelect==2?'active':''}}" bindtap="selectGender" data-gender="2">
<text>♀ 女孩</text>
</view>
</view>
<button class="btn btn-primary adopt-btn" bindtap="doAdopt" loading="{{adopting}}">领养我们的伙伴</button>
</view>
</view>
<!-- ===== 已领养:宠物主界面 ===== -->
<view wx:else class="pet-main">
<view class="pet-profile">
<view class="pet-profile-heading"><text class="j-eyebrow">OUR LITTLE COMPANION</text><view class="pet-profile-actions"><view bindtap="showRenameModal">改名</view><view bindtap="goToAchievements">成就</view></view></view>
<view class="pet-profile-main"><view class="pet-avatar-wrap"><view class="pet-avatar-ring"><text class="pet-avatar">{{petEmoji}}</text></view><view class="pet-equip" wx:if="{{equippedCollar}}"><text>🎀</text></view></view><view class="pet-profile-info"><view class="pet-name-row"><text class="pet-name">{{pet.name}}</text><text class="pet-gender-text">{{pet.gender==1?'♂':'♀'}}</text></view><text class="pet-description">{{breedName}} · {{stageName}}</text><text class="pet-adopt-days">一起照顾了 {{adoptDays || 0}} 天</text></view></view>
<view class="pet-level-bar"><text class="level-label">Lv.{{pet.level}}</text><view class="exp-track"><view class="exp-fill" style="width:{{expProgress}}%;" /></view><text class="exp-text">{{pet.exp}} / {{expForNextLevel}}</text></view>
</view>
<!-- 属性卡片 -->
<view class="stats-grid">
<view class="stat-card stat-happiness">
<view class="stat-head">
<text class="stat-icon">😊</text>
<text class="stat-label">心情</text>
<text class="stat-value">{{pet.happiness}}</text>
</view>
<view class="stat-bar"><view class="stat-fill" style="width:{{pet.happiness}}%;"></view></view>
</view>
<view class="stat-card stat-hunger">
<view class="stat-head">
<text class="stat-icon">🍖</text>
<text class="stat-label">饱腹</text>
<text class="stat-value">{{pet.hunger}}</text>
</view>
<view class="stat-bar"><view class="stat-fill" style="width:{{pet.hunger}}%;"></view></view>
</view>
<view class="stat-card stat-energy">
<view class="stat-head">
<text class="stat-icon">⚡</text>
<text class="stat-label">精力</text>
<text class="stat-value">{{pet.energy}}</text>
</view>
<view class="stat-bar"><view class="stat-fill" style="width:{{pet.energy}}%;"></view></view>
</view>
<view class="stat-card stat-cleanliness">
<view class="stat-head">
<text class="stat-icon">✨</text>
<text class="stat-label">清洁</text>
<text class="stat-value">{{pet.cleanliness}}</text>
</view>
<view class="stat-bar"><view class="stat-fill" style="width:{{pet.cleanliness}}%;"></view></view>
</view>
</view>
<!-- 今日任务 -->
<view class="section-title">
<text>今日照顾</text>
<text class="section-subtitle">一起完成</text>
</view>
<view class="daily-grid">
<view class="daily-item {{dailyStatus.feed?'done':''}}" bindtap="doDailyAction" data-action="feed">
<view class="daily-icon-wrap">
<text class="daily-icon">🍖</text>
<view class="daily-check {{dailyStatus.feed?'checked':''}}">
<text wx:if="{{dailyStatus.feed}}">✓</text>
</view>
</view>
<text class="daily-label">喂食</text>
<text class="daily-partner {{partnerDaily.feed?'partner-done':''}}">{{partnerDaily.feed ? 'TA已完成' : 'TA未完成'}}</text>
</view>
<view class="daily-item {{dailyStatus.water?'done':''}}" bindtap="doDailyAction" data-action="water">
<view class="daily-icon-wrap">
<text class="daily-icon">💧</text>
<view class="daily-check {{dailyStatus.water?'checked':''}}">
<text wx:if="{{dailyStatus.water}}">✓</text>
</view>
</view>
<text class="daily-label">喂水</text>
<text class="daily-partner {{partnerDaily.water?'partner-done':''}}">{{partnerDaily.water ? 'TA已完成' : 'TA未完成'}}</text>
</view>
<view class="daily-item {{dailyStatus.walk?'done':''}}" bindtap="doDailyAction" data-action="walk">
<view class="daily-icon-wrap">
<text class="daily-icon">🐾</text>
<view class="daily-check {{dailyStatus.walk?'checked':''}}">
<text wx:if="{{dailyStatus.walk}}">✓</text>
</view>
</view>
<text class="daily-label">遛狗</text>
<text class="daily-partner {{partnerDaily.walk?'partner-done':''}}">{{partnerDaily.walk ? 'TA已完成' : 'TA未完成'}}</text>
</view>
<view class="daily-item {{dailyStatus.bath?'done':''}}" bindtap="doDailyAction" data-action="bath">
<view class="daily-icon-wrap">
<text class="daily-icon">🛁</text>
<view class="daily-check {{dailyStatus.bath?'checked':''}}">
<text wx:if="{{dailyStatus.bath}}">✓</text>
</view>
</view>
<text class="daily-label">洗澡</text>
<text class="daily-partner {{partnerDaily.bath?'partner-done':''}}">{{partnerDaily.bath ? 'TA已完成' : 'TA未完成'}}</text>
</view>
<view class="daily-item {{dailyStatus.sleep?'done':''}}" bindtap="doDailyAction" data-action="sleep">
<view class="daily-icon-wrap">
<text class="daily-icon">😴</text>
<view class="daily-check {{dailyStatus.sleep?'checked':''}}">
<text wx:if="{{dailyStatus.sleep}}">✓</text>
</view>
</view>
<text class="daily-label">睡觉</text>
<text class="daily-partner {{partnerDaily.sleep?'partner-done':''}}">{{partnerDaily.sleep ? 'TA已完成' : 'TA未完成'}}</text>
</view>
<view class="daily-item {{dailyStatus.play?'done':''}}" bindtap="doDailyAction" data-action="play">
<view class="daily-icon-wrap">
<text class="daily-icon">🎾</text>
<view class="daily-check {{dailyStatus.play?'checked':''}}">
<text wx:if="{{dailyStatus.play}}">✓</text>
</view>
</view>
<text class="daily-label">玩耍</text>
<text class="daily-partner {{partnerDaily.play?'partner-done':''}}">{{partnerDaily.play ? 'TA已完成' : 'TA未完成'}}</text>
</view>
</view>
<!-- 全部完成庆祝 -->
<view wx:if="{{allDone}}" class="all-done-banner">
<text>今天的照顾已完成,辛苦啦</text>
</view>
<!-- 成就入口 -->
<view class="section-title">
<text>成长纪念</text>
<text class="section-subtitle" bindtap="goToAchievements">查看全部 →</text>
</view>
<view class="achievement-strip">
<view wx:for="{{recentAchievements}}" wx:key="key" class="ach-badge">
<text class="ach-icon">{{item.icon}}</text>
<text class="ach-name">{{item.name}}</text>
</view>
<view wx:if="{{achievementCount==0}}" class="ach-empty">
<text>完成日常任务解锁成就</text>
</view>
</view>
<!-- 动态 -->
<view class="section-title">
<text>最近的陪伴</text>
<text class="section-subtitle" wx:if="{{dynamics.length>1}}" bindtap="toggleDynamics">{{showAllDynamics?'收起':'查看全部'}} ({{dynamics.length}})</text>
</view>
<view class="dynamics-list">
<view wx:for="{{displayDynamics}}" wx:key="id" class="dynamic-item" wx:for-item="d">
<view class="dynamic-avatar">
<image wx:if="{{d.avatar}}" class="dynamic-avatar-img" src="{{d.avatar}}" mode="aspectFill" />
<text wx:else>{{d.initial}}</text>
</view>
<view class="dynamic-content">
<text class="dynamic-text">{{d.message}}</text>
<text class="dynamic-time">{{d.time_text}}</text>
</view>
</view>
<view wx:if="{{dynamics.length==0}}" class="empty-state">
<text>🐾</text>
<text>还没有动态,快去照顾宠物吧</text>
</view>
</view>
</view>
<view style="height:60rpx;"></view>
</block>
</view>
</scroll-view>
<!-- 改名弹窗 -->
<view wx:if="{{showRename}}" class="modal-overlay" bindtap="hideRenameModal">
<view class="rename-modal" catchtap="stopPropagation">
<text class="rename-modal-title">给宠物改名</text>
<input class="rename-input" placeholder="输入新名字" placeholder-class="rename-placeholder" value="{{renameValue}}" bindinput="onRenameInput" maxlength="20" />
<view class="rename-actions">
<button class="btn btn-secondary rename-btn" bindtap="hideRenameModal">取消</button>
<button class="btn btn-primary rename-btn" bindtap="doRename" loading="{{renaming}}">确定</button>
</view>
</view>
</view>
</view>
+19
View File
@@ -0,0 +1,19 @@
@import '../../styles/common.wxss';
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
@import '../../styles/companion.wxss';
.pet-profile { background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:12rpx 36rpx 12rpx 12rpx; padding:28rpx; }
.pet-profile-heading { display:flex; align-items:center; justify-content:space-between; gap:16rpx; }.pet-profile-heading .j-eyebrow { font-size:17rpx; letter-spacing:1rpx; }
.pet-profile-actions { display:flex; align-items:center; gap:18rpx; }.pet-profile-actions > view { font-size:22rpx; color:#B74768; min-height:52rpx; display:flex; align-items:center; }
.pet-profile-main { display:flex; align-items:center; gap:26rpx; margin:22rpx 0 30rpx; }.pet-avatar-wrap { position:relative; flex-shrink:0; }.pet-avatar-ring { width:134rpx; height:134rpx; background:#F1EBE2; border:1rpx solid #E9DDD0; border-radius:50%; display:flex; align-items:center; justify-content:center; }.pet-avatar { font-size:76rpx; }.pet-equip { position:absolute; right:-6rpx; top:0; font-size:28rpx; }
.pet-profile-info { flex:1; min-width:0; }.pet-name-row { display:flex; align-items:center; gap:12rpx; }.pet-name { font-size:36rpx; line-height:1.4; font-weight:600; color:#352E2A; overflow-wrap:break-word; }.pet-gender-text { font-size:24rpx; color:#85776D; }.pet-description { display:block; font-size:24rpx; color:#65594F; margin-top:10rpx; }.pet-adopt-days { display:block; font-size:23rpx; color:#85776D; margin-top:12rpx; }
.pet-level-bar { display:flex; align-items:center; gap:16rpx; }.level-label { font-size:22rpx; color:#943C58; flex-shrink:0; }.exp-track { height:10rpx; flex:1; background:#EDE3D7; border-radius:10rpx; overflow:hidden; }.exp-fill { height:100%; background:#B78F68; border-radius:10rpx; }.exp-text { font-size:20rpx; color:#85776D; flex-shrink:0; }
.stats-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:16rpx; margin-top:20rpx; padding:0!important; border:0!important; background:transparent!important; box-shadow:none!important; }
.stat-card { background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:14rpx; padding:20rpx; }.stat-head { display:flex; align-items:center; gap:10rpx; margin-bottom:16rpx; }.stat-icon { font-size:27rpx; }.stat-label { flex:1; font-size:23rpx; color:#85776D; }.stat-value { font-size:28rpx; font-weight:500; color:#352E2A; }.stat-bar { height:7rpx; background:#EDE3D7; border-radius:8rpx; overflow:hidden; }.stat-fill { height:100%; border-radius:8rpx; }.stat-happiness .stat-fill { background:#C6A06F; }.stat-hunger .stat-fill { background:#CA8E9E; }.stat-energy .stat-fill { background:#91A487; }.stat-cleanliness .stat-fill { background:#87A3B0; }
.section-title { display:flex; align-items:center; justify-content:space-between; gap:16rpx; margin:34rpx 0 20rpx; font-size:29rpx; font-weight:600; color:#352E2A; }.section-subtitle { font-size:22rpx; font-weight:400; color:#85776D; padding:10rpx 0; }
.daily-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:16rpx; }.daily-item { display:flex; flex-direction:column; align-items:center; gap:12rpx; padding:22rpx 8rpx; background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:14rpx; }.daily-item.done { background:#F0F3EB; border-color:#C7D0BA; }.daily-icon-wrap { position:relative; }.daily-icon { font-size:34rpx; }.daily-check { position:absolute; right:-16rpx; top:-4rpx; width:24rpx; height:24rpx; border:1rpx solid #CBBDAE; background:#FFFDF8; border-radius:50%; }.daily-check.checked { display:flex; align-items:center; justify-content:center; color:#FFFDF8; border-color:#8B9D7E; background:#8B9D7E; font-size:18rpx; }.daily-label { color:#352E2A; font-size:25rpx; }.daily-partner { font-size:18rpx; color:#A39386; }.daily-partner.partner-done { color:#677B59; }.all-done-banner { margin-top:20rpx; border-radius:14rpx; background:#F0F3EB; padding:22rpx; text-align:center; font-size:24rpx; color:#677B59; }
.achievement-strip { display:flex; gap:14rpx; overflow-x:auto; padding-bottom:8rpx; }.ach-badge { display:flex; flex-direction:column; align-items:center; gap:12rpx; min-width:116rpx; padding:18rpx 12rpx; background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:14rpx; flex-shrink:0; }.ach-icon { font-size:34rpx; }.ach-name { color:#65594F; font-size:20rpx; }.ach-empty { width:100%; text-align:center; font-size:23rpx; color:#85776D; padding:30rpx 0; }
.dynamics-list { border-top:1rpx solid #E9DDD0; }.dynamic-item { display:flex; align-items:flex-start; gap:16rpx; padding:24rpx 0; border-bottom:1rpx solid #E9DDD0; }.dynamic-avatar { width:50rpx; height:50rpx; border-radius:50%; background:#F4E2E3; color:#943C58; display:flex; align-items:center; justify-content:center; font-size:23rpx; flex-shrink:0; overflow:hidden; }.dynamic-avatar-img { width:100%; height:100%; }.dynamic-content { flex:1; min-width:0; }.dynamic-text { display:block; font-size:25rpx; color:#65594F; line-height:1.6; }.dynamic-time { display:block; font-size:21rpx; color:#A39386; margin-top:8rpx; }
.adopt-hero { text-align:center; margin:12rpx 0 28rpx; }.adopt-hero-emoji { font-size:64rpx; }.adopt-card { background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:16rpx; padding:28rpx; }.adopt-title { display:block; font-size:31rpx; font-weight:600; color:#352E2A; text-align:center; }.adopt-desc { display:block; font-size:24rpx; line-height:1.6; color:#85776D; margin-top:14rpx; text-align:center; }
.breed-select { display:grid; grid-template-columns:repeat(4,1fr); gap:12rpx; margin-top:28rpx; }.breed-option { display:flex; flex-direction:column; align-items:center; gap:10rpx; padding:18rpx 4rpx; border:1rpx solid #E9DDD0; border-radius:12rpx; }.breed-option.active { background:#F4E2E3; border-color:#D5A6AE; }.breed-emoji { font-size:35rpx; }.breed-name { font-size:22rpx; color:#65594F; }.gender-select { display:flex; gap:16rpx; margin:24rpx 0; }.gender-option { display:flex; flex:1; justify-content:center; padding:20rpx 8rpx; border:1rpx solid #E9DDD0; border-radius:12rpx; color:#85776D; font-size:25rpx; }.gender-option.active { background:#F4E2E3; border-color:#D5A6AE; color:#943C58; }.adopt-btn { width:100%; font-size:26rpx; border-radius:18rpx; padding:24rpx 12rpx; }
.modal-overlay { z-index:1200; }.rename-modal { width:100%; max-width:600rpx; padding:36rpx; background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:24rpx; box-sizing:border-box; }.rename-modal-title { display:block; color:#352E2A; font-weight:600; font-size:31rpx; margin-bottom:26rpx; }.rename-input { width:100%; height:84rpx; padding:0 20rpx; background:#FAF5EE; border:1rpx solid #E9DDD0; border-radius:14rpx; box-sizing:border-box; font-size:27rpx; }.rename-actions { display:flex; gap:20rpx; margin-top:28rpx; }.rename-btn { flex:1; min-width:0; padding:24rpx 10rpx; font-size:26rpx; }.rename-placeholder { color:#A39386; }
+932
View File
@@ -0,0 +1,932 @@
const { getLayout } = require('../../utils/layout');
// pages/profile/profile.js - 与原网站一致的个人中心
const { api, getImageUrl } = require('../../utils/request');
const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos');
Page({
onResize: function() { this.setData(getLayout()); },
data: {
scrollTop: 0,
// 我的信息
myNickname: '未登录',
myDisplayId: '',
myPhone: '-',
myGender: '-',
myAge: '-',
myBirthday: '-',
myRegion: '',
myLocation: '',
myStatus: '设置恋爱信息',
myAvatarUrl: '',
myInitial: '?',
// TA的信息
hasPartner: false,
bindingKnown: false, bindingError: false,
partnerName: 'TA',
partnerDisplayId: '',
partnerDetail: '',
partnerAvatarUrl: '',
partnerInitial: '?',
partnerBirthday: '',
partnerGenderRaw: '',
// 恋爱信息
anniversaryDateText: '点击设置你们的纪念日',
loveStoryText: '记录你们的故事',
loveStartDate: '',
// 编辑资料弹窗
showEditProfileModal: false,
editNickname: '',
editGender: 'male',
editGenderText: '男',
editAge: '',
editBirthday: '',
editRegion: '',
editAvatarUrl: '',
editAvatarPath: '',
editNicknameInitial: '?',
// 设置恋爱日期弹窗
showSetDateModal: false,
tempLoveDate: '',
tempLoveStory: '',
// 查看TA资料(只读)弹窗
showEditPartnerModal: false,
editPartnerNickname: '',
editPartnerGender: 'male',
editPartnerGenderText: '男',
editPartnerAge: '',
editPartnerBirthday: '',
editPartnerAvatarUrl: '',
editPartnerAvatarPath: '',
editPartnerNameInitial: '?',
genderOptions: ['男', '女'],
// 解除绑定验证
showUnbindVerifyModal: false,
unbindQuestion: '',
unbindAnswer: 0,
unbindUserInput: '',
// 解绑状态
unbindStatus: '',
unbindIsRequester: false,
unbindRequesterName: '',
unbindRemaining: 0,
// TA详情弹窗
showPartnerDetailModal: false,
partnerDetailNickname: '-',
partnerDetailGender: '-',
partnerDetailAge: '-',
partnerDetailBirthday: '-',
partnerDetailAvatarUrl: '',
partnerNameInitial: '?',
// ===== 邀请绑定相关 =====
showInvitePanel: false,
inviteTab: 'received',
receivedInvites: [],
sentInvites: [],
inviteTargetId: '',
inviteMessage: '',
// 分享绑定
showShareBindModal: false,
shareInviterId: '',
shareInviterName: '',
shareDisagree: false
},
// 左滑返回机制
touchStartX: 0, touchStartY: 0,
onTouchStart: function(e) {
this.touchStartX = e.touches[0].clientX;
this.touchStartY = e.touches[0].clientY;
},
onTouchEnd: function(e) {
var dx = this.touchStartX - e.changedTouches[0].clientX;
var dy = Math.abs(this.touchStartY - e.changedTouches[0].clientY);
// 短距离/垂直滑动不处理(交给按钮tap事件)
if (dx < 20 && dy < 20) return;
// 只有水平且左滑超过80px才处理
if (Math.abs(dx) > dy && dx > 80) {
if (this.data.showEditProfileModal) {
this.hideEditProfileModal();
} else if (this.data.showEditPartnerModal) {
this.hideEditPartnerModal();
} else {
wx.navigateBack();
}
}
},
_needRefresh: true,
onScroll: function(e) {
saveScrollPosition('profile', e.detail.scrollTop);
},
onLoad: function(options) {
this.setData(getLayout());
this._needRefresh = true;
this._openBindOnLoad = !!(options && options.bind);
this.setData({
userId: wx.getStorageSync('userId'),
coupleId: wx.getStorageSync('coupleId'),
scrollTop: getScrollPosition('profile')
});
this.loadData();
this.loadUnbindStatus();
this.fetchAndSaveLocation();
// 从首页点击头像跳转过来,自动打开编辑
if (options.edit === 'self') {
var self = this;
setTimeout(function() { self.showEditProfileModal(); }, 500);
} else if (options.edit === 'partner') {
var self = this;
setTimeout(function() { self.showEditPartnerModal(); }, 500);
}
// 从分享邀请链接进入
if (options && options.inviter) {
var self = this;
setTimeout(function() { self.handleShareInvite(options.inviter); }, 800);
}
},
onShow: function() {
var uid = wx.getStorageSync('userId');
var cid = wx.getStorageSync('coupleId');
var self = this;
var savedTop = getScrollPosition('profile');
this.setData({ scrollTop: savedTop, bindingKnown: false, myLocation: '' });
// 延迟重新设置 scrollTop,解决从子页面 navigateBack 返回后滚动位置丢失的问题
setTimeout(function() {
self.setData({ scrollTop: savedTop });
}, 100);
this.setData({ userId: uid, coupleId: cid });
this.loadData();
// 如果已绑定情侣,关闭邀请面板
if (cid && this.data.showInvitePanel) {
this.setData({ showInvitePanel: false });
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
}
},
onUnload: function() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
},
onPullDownRefresh: async function() {
await this.loadData();
wx.stopPullDownRefresh();
},
// 比较新旧值,只返回变化的字段(避免全量 setData 导致闪屏)
_diffUpdate: function(newData) {
var updates = {};
for (var key in newData) {
if (this.data[key] !== newData[key]) {
updates[key] = newData[key];
}
}
if (Object.keys(updates).length > 0) {
this.setData(updates);
}
},
loadData: async function() {
this.setData({bindingError:false});
var requestId = (this._coupleInfoRequestId || 0) + 1;
this._coupleInfoRequestId = requestId;
try {
// 获取情侣信息
const info = await api.coupleInfo();
this.setData({userId: wx.getStorageSync('userId')});
if (requestId !== this._coupleInfoRequestId) return;
// 未绑定情侣时从后端获取用户最新资料
if (info.not_bound) {
wx.removeStorageSync('coupleId');
var myInfo = info.partner1 || {};
var genderText = myInfo.gender === 'male' ? '男' : (myInfo.gender === 'female' ? '女' : '-');
var region = myInfo.region || '';
var location = myInfo.location || '';
if (/^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(location)) location = '';
wx.setStorageSync('region', region);
this._diffUpdate({
bindingKnown: true,
coupleId: null,
hasPartner: false,
partnerName: 'TA',
partnerDisplayId: '',
partnerDetail: '',
partnerAvatarUrl: '',
partnerInitial: '?',
partnerBirthday: '',
partnerGenderRaw: '',
myStatus: '设置恋爱信息',
anniversaryDateText: '点击设置你们的纪念日',
loveStoryText: '记录你们的故事',
loveStartDate: '',
tempLoveDate: '',
tempLoveStory: '',
myNickname: myInfo.nickname || myInfo.username || '未登录',
myDisplayId: myInfo.user_id || wx.getStorageSync('displayId') || '',
myPhone: myInfo.phone || myInfo.username || '-',
myGender: genderText,
myGenderRaw: myInfo.gender || '',
myAge: myInfo.age > 0 ? myInfo.age + '岁' : '-',
myAgeText: myInfo.age > 0 ? myInfo.age : '',
myBirthday: myInfo.birthday || '-',
myBirthdayText: myInfo.birthday || '',
myRegion: region,
myLocation: location,
myAvatarUrl: getImageUrl(myInfo.avatar),
myInitial: (myInfo.nickname || myInfo.username || '?')[0],
editNickname: myInfo.nickname || '',
editAge: myInfo.age > 0 ? myInfo.age : '',
editBirthday: myInfo.birthday || '',
editRegion: region,
editNicknameInitial: (myInfo.nickname || myInfo.username || '?')[0]
});
if (this._openBindOnLoad) {
this._openBindOnLoad = false;
this.showInvitePanel();
}
return;
}
// 同步情侣ID到本地存储
if (info.id) {
wx.setStorageSync('coupleId', info.id);
if (this.data.coupleId !== info.id) {
this.setData({ coupleId: info.id });
}
}
const myUid = this.data.userId;
const p1 = info.partner1 || {};
const p2 = info.partner2 || {};
const me = p1.id == myUid ? p1 : (p2.id == myUid ? p2 : null);
const partner = p1.id == myUid ? p2 : (p2.id == myUid ? p1 : null);
// 我的信息 — 只更新变化的字段
if (me) {
const genderText = me.gender === 'male' ? '男' : (me.gender === 'female' ? '女' : '-');
const storedDisplayId = wx.getStorageSync('displayId') || '';
var region = me.region || '';
var location = me.location || '';
if (/^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(location)) location = '';
wx.setStorageSync('region', region);
this._diffUpdate({
myNickname: me.nickname || me.username || '未登录',
myDisplayId: me.user_id || storedDisplayId,
myPhone: me.phone || me.username || '-',
myGender: genderText,
myGenderRaw: me.gender || '',
myAge: me.age > 0 ? me.age + '岁' : '-',
myAgeText: me.age > 0 ? me.age : '',
myBirthday: me.birthday || '-',
myBirthdayText: me.birthday || '',
myRegion: region,
myLocation: location,
myAvatarUrl: getImageUrl(me.avatar),
myInitial: (me.nickname || me.username || '?')[0],
editNickname: me.nickname || '',
editAge: me.age > 0 ? me.age : '',
editBirthday: me.birthday || '',
editRegion: region,
editNicknameInitial: (me.nickname || me.username || '?')[0]
});
}
// TA的信息 — 只更新变化的字段
if (partner) {
const genderText = partner.gender === 'male' ? '男' : (partner.gender === 'female' ? '女' : '-');
let details = [];
if (genderText !== '-') details.push(genderText);
if (partner.age > 0) details.push(partner.age + '岁');
if (partner.birthday) details.push('🎂 ' + partner.birthday);
this._diffUpdate({
bindingKnown: true,
hasPartner: true,
partnerName: partner.nickname || partner.username || 'TA',
partnerDisplayId: partner.user_id || '',
partnerDetail: details.join(' · ') || 'TA的信息',
partnerAvatarUrl: getImageUrl(partner.avatar),
partnerInitial: (partner.nickname || partner.username || '?')[0],
partnerBirthday: partner.birthday || '',
partnerGenderRaw: partner.gender || '',
partnerDetailNickname: partner.nickname || partner.username || '-',
partnerDetailGender: genderText,
partnerDetailAge: partner.age > 0 ? partner.age + '岁' : '-',
partnerDetailBirthday: partner.birthday || '-',
partnerDetailAvatarUrl: getImageUrl(partner.avatar),
partnerNameInitial: (partner.nickname || partner.username || '?')[0]
});
}
// 恋爱天数和状态 — 只更新变化的字段
let daysCount = 0;
if (info.love_start_date) {
const start = new Date(info.love_start_date);
daysCount = Math.floor((Date.now() - start) / 86400000);
const partnerName = partner ? (partner.nickname || partner.username || 'TA') : 'TA';
this._diffUpdate({
anniversaryDateText: info.love_start_date + ' (已设置)',
loveStoryText: info.love_story ? (info.love_story.length > 20 ? info.love_story.substring(0, 20) + '...' : info.love_story) : '记录你们的故事',
myStatus: '和' + partnerName + '在一起 ' + daysCount + ' 天 💕',
loveStartDate: info.love_start_date,
tempLoveDate: info.love_start_date || '',
tempLoveStory: info.love_story || ''
});
} else {
this._diffUpdate({
anniversaryDateText: '点击设置你们的纪念日',
loveStoryText: info.love_story ? (info.love_story.length > 20 ? info.love_story.substring(0, 20) + '...' : info.love_story) : '记录你们的故事',
myStatus: '设置恋爱信息',
loveStartDate: '',
tempLoveDate: '',
tempLoveStory: ''
});
}
} catch (e) {
if (requestId !== this._coupleInfoRequestId) return;
console.error(e);
this.setData({ bindingKnown: false, bindingError:true });
}
},
// 获取定位并保存到数据库(使用模糊定位,getLocation 与 getFuzzyLocation 互斥,统一用模糊定位)
fetchAndSaveLocation: function() {
var self = this;
self._fuzzyLocation();
},
// 模糊定位兜底
_fuzzyLocation: function() {
var self = this;
if (!wx.getFuzzyLocation) return;
wx.getFuzzyLocation({
type: 'gcj02',
success: function(res) {
self._reportLocation(res.latitude, res.longitude);
},
fail: function() {}
});
},
// 上报经纬度到后端并更新城市地址显示
_reportLocation: function(lat, lng) {
var self = this;
if (!lat || !lng) return;
// 保留更多小数位,减少坐标截断误差
var location = lat.toFixed(6) + ',' + lng.toFixed(6);
api.updateLocation(location).then(function(resp) {
// 后端返回的地址;若为空或经纬度(说明逆地理编码失败),一律不采用,绝不显示经纬度
var city = (resp && resp.location) ? resp.location : '';
if (city === '' || /^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(city)) {
return;
}
// 转换成功后展示接口返回的城市地址
if (self.data.myLocation !== city) {
self.setData({ myLocation: city });
}
}).catch(function() {});
},
// 编辑资料
showEditProfileModal: function() {
var d = this.data;
var rawGender = d.myGenderRaw || '';
var genderText = rawGender === 'female' ? '女' : '男';
this.setData({
showEditProfileModal: true,
editNickname: d.myNickname === '未设置' ? '' : d.myNickname,
editGender: rawGender || 'male',
editGenderText: genderText,
editAge: d.myAgeText ? String(parseInt(d.myAgeText) || '') : '',
editBirthday: d.myBirthdayText || '',
editRegion: d.myRegion || '',
editAvatarUrl: d.myAvatarUrl || '',
editNicknameInitial: d.myInitial || '?'
});
},
onEditGenderChange: function(e) {
var idx = e.detail.value;
var texts = ['男', '女'];
var vals = ['male', 'female'];
this.setData({ editGender: vals[idx], editGenderText: texts[idx] });
},
hideEditProfileModal: function() {
this.setData({ showEditProfileModal: false });
},
onEditNicknameInput: function(e) {
this.setData({ editNickname: e.detail.value });
},
onEditBirthdayInput: function(e) {
var birthday = e.detail.value;
var age = '';
if (birthday) {
var birthDate = new Date(birthday);
var today = new Date();
var calcAge = today.getFullYear() - birthDate.getFullYear();
var m = today.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
calcAge--;
}
age = calcAge > 0 ? calcAge : 1;
}
this.setData({ editBirthday: birthday, editAge: String(age) });
},
chooseAvatar: function() {
wx.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const filePath = res.tempFiles[0].path;
this.setData({ editAvatarUrl: filePath });
// 上传头像
try {
const data = await api.uploadAvatar(filePath);
this.setData({
editAvatarUrl: getImageUrl(data.path),
editAvatarPath: data.path
});
wx.showToast({ title: '头像上传成功 ✨', icon: 'success' });
} catch (e) {
wx.showToast({ title: e.error || '上传失败', icon: 'none' });
}
}
});
},
saveProfile: async function() {
const { editNickname, editGender, editAge, editBirthday, editRegion, editAvatarPath, tempLoveDate, tempLoveStory, coupleId } = this.data;
if (!editNickname) {
wx.showToast({ title: '请输入昵称', icon: 'none' }); return;
}
try {
await api.updateProfile({
nickname: editNickname,
gender: editGender,
age: parseInt(editAge) || 0,
birthday: editBirthday,
region: editRegion,
avatar: editAvatarPath
});
// 更新本地存储的昵称,确保未绑定情侣时也能正确显示
wx.setStorageSync('nickname', editNickname);
if (editAvatarPath) {
wx.setStorageSync('avatar', editAvatarPath);
}
// 如果有恋爱日期且已绑定情侣,也保存
if (tempLoveDate && coupleId) {
await api.updateCoupleInfo({ love_start_date: tempLoveDate, love_story: tempLoveStory || '' });
}
wx.showToast({ title: '资料更新成功 💕', icon: 'success' });
this.hideEditProfileModal();
this.loadData();
} catch (e) {
wx.showToast({ title: e.error || '保存失败', icon: 'none' });
}
},
// 设置恋爱日期
showSetDateModal: function() {
this.setData({ showSetDateModal: true });
},
hideSetDateModal: function() {
this.setData({ showSetDateModal: false });
},
onLoveDateInput: function(e) {
this.setData({ tempLoveDate: e.detail.value });
},
onLoveStoryInput: function(e) {
this.setData({ tempLoveStory: e.detail.value });
},
// 显示恋爱故事
showCoupleStory: function() {
const { tempLoveStory } = this.data;
if (!tempLoveStory) {
wx.showToast({ title: '还没有恋爱故事,先设置纪念日吧 ✨', icon: 'none' });
this.showSetDateModal();
} else {
wx.showModal({
title: '💕 恋爱故事',
content: tempLoveStory,
showCancel: false
});
}
},
saveLoveDate: async function() {
const { tempLoveDate, tempLoveStory } = this.data;
if (!tempLoveDate) {
wx.showToast({ title: '请选择日期', icon: 'none' }); return;
}
try {
await api.updateCoupleInfo({
love_start_date: tempLoveDate,
love_story: tempLoveStory
});
wx.showToast({ title: '纪念日设置成功 💕', icon: 'success' });
this.hideSetDateModal();
this.loadData();
} catch (e) {
wx.showToast({ title: e.error || '保存失败', icon: 'none' });
}
},
// TA详情
showPartnerDetail: function() {
this.setData({ showPartnerDetailModal: true });
},
hidePartnerDetailModal: function() {
this.setData({ showPartnerDetailModal: false });
},
// 查看TA资料(只读)
showEditPartnerModal: function() {
const d = this.data;
var rawGender = d.partnerGenderRaw || '';
var genderText = rawGender === 'male' ? '男' : (rawGender === 'female' ? '女' : '未设置');
// 从 partnerDetail 中解析年龄(格式:XX岁)
var ageMatch = d.partnerDetail.match(/(\d+)岁/);
var partnerAge = ageMatch ? ageMatch[1] : '';
this.setData({
showEditPartnerModal: true,
editPartnerNickname: d.partnerName === 'TA' ? '' : d.partnerName,
editPartnerGender: rawGender || 'male',
editPartnerGenderText: genderText,
editPartnerAge: partnerAge,
editPartnerBirthday: d.partnerBirthday || '',
editPartnerAvatarUrl: d.partnerAvatarUrl || '',
editPartnerAvatarPath: '',
editPartnerNameInitial: d.partnerInitial || '?'
});
},
hideEditPartnerModal: function() {
this.setData({ showEditPartnerModal: false });
},
// 加载解绑状态
loadUnbindStatus: function() {
var self = this;
api.unbindStatus().then(function(res) {
if (res && res.status && res.status !== 'none') {
self.setData({
unbindStatus: res.status,
unbindIsRequester: !!res.is_requester,
unbindRequesterName: res.requester_name || '对方',
unbindRemaining: res.remaining_seconds || 0
});
} else {
self.setData({ unbindStatus: '', unbindIsRequester: false, unbindRequesterName: '' });
}
}).catch(function() {});
},
// 解除绑定 — 根据状态显示不同操作
showUnbindVerify: function() {
var status = this.data.unbindStatus;
var isRequester = this.data.unbindIsRequester;
if (status === 'pending') {
if (isRequester) {
this.doCancelUnbind();
} else {
this.doRespondUnbind();
}
} else if (status === 'agreed') {
wx.showToast({ title: '双方已同意解绑,正在处理中', icon: 'none' });
wx.navigateTo({ url: '/pages/unbind/unbind' });
} else {
this.doRequestUnbind();
}
},
doRequestUnbind: function() {
var self = this;
wx.showModal({
title: '💔 解除绑定',
content: '发起解绑请求后,对方需要在72小时内确认同意。\n\n对方同意后,所有情侣记录将被永久删除(记忆日、日记、相册、纪念日、情话、歌曲、打卡),仅保留双方个人资料。\n\n你也可以在对方确认前撤销请求。',
cancelText: '取消',
confirmText: '发起解绑',
confirmColor: '#FF4757',
success: function(res) {
if (res.confirm) {
wx.showLoading({ title: '发送请求...' });
api.unbindRequest().then(function() {
wx.hideLoading();
wx.showToast({ title: '解绑请求已发送', icon: 'success' });
self.loadUnbindStatus();
wx.navigateTo({ url: '/pages/unbind/unbind' });
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
},
doCancelUnbind: function() {
var self = this;
wx.showModal({
title: '↩️ 撤销解绑',
content: '确定要撤销解绑请求吗?对方将不会收到任何通知。',
cancelText: '取消',
confirmText: '撤销请求',
confirmColor: '#FF6B9D',
success: function(res) {
if (res.confirm) {
wx.showLoading({ title: '撤销中...' });
api.unbindCancel().then(function() {
wx.hideLoading();
wx.showToast({ title: '已撤销解绑请求', icon: 'success' });
self.loadUnbindStatus();
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
},
doRespondUnbind: function() {
var self = this;
var name = this.data.unbindRequesterName;
wx.showActionSheet({
itemList: ['✅ 同意解绑', '❌ 拒绝解绑'],
success: function(res) {
if (res.tapIndex === 0) {
wx.showModal({
title: '⚠️ 确认同意',
content: name + ' 发起了解绑请求。\n\n同意后,所有情侣记录将被永久删除(记忆日、日记、相册、歌曲、打卡),仅保留双方个人资料。\n\n此操作不可恢复!',
cancelText: '取消',
confirmText: '同意解绑',
confirmColor: '#FF4757',
success: function(r) {
if (r.confirm) {
wx.showLoading({ title: '处理中...' });
api.unbindRespond({ agree: true }).then(function() {
wx.hideLoading();
wx.showToast({ title: '已同意解绑', icon: 'success' });
wx.removeStorageSync('coupleId');
wx.removeStorageSync('partnerId');
wx.removeStorageSync('partnerName');
wx.removeStorageSync('partnerAvatar');
wx.removeStorageSync('petHasPet');
setTimeout(function() {
wx.reLaunch({ url: '/pages/index/index' });
}, 1500);
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
} else if (res.tapIndex === 1) {
wx.showModal({
title: '拒绝解绑',
content: '确定要拒绝 ' + name + ' 的解绑请求吗?',
cancelText: '取消',
confirmText: '拒绝',
confirmColor: '#FF6B9D',
success: function(r) {
if (r.confirm) {
wx.showLoading({ title: '处理中...' });
api.unbindRespond({ agree: false }).then(function() {
wx.hideLoading();
wx.showToast({ title: '已拒绝解绑请求', icon: 'success' });
self.loadUnbindStatus();
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
}
}
});
},
// 空操作(用于 catchtap 阻止冒泡)
noop: function() {},
// 退出登录
logout: function() {
wx.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
api.logout().catch(() => {});
wx.clearStorageSync();
wx.reLaunch({ url: '/pages/index/index' });
}
}
});
},
// 跳转前保存当前滚动位置
_savePosBeforeLeave: function() {
saveScrollPosition('profile', this.data.scrollTop);
},
// ===== 邀请绑定 =====
showInvitePanel: function() {
var displayId = wx.getStorageSync('displayId') || '';
this.setData({
showInvitePanel: true,
inviteTab: 'received',
inviteTargetId: '',
inviteMessage: '',
myDisplayId: displayId
});
this.loadInvites();
this.startPollCoupleStatus();
},
hideInvitePanel: function() {
this.setData({ showInvitePanel: false });
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
},
onInviteTargetIdInput: function(e) { this.setData({ inviteTargetId: e.detail.value }); },
onInviteMessageInput: function(e) { this.setData({ inviteMessage: e.detail.value }); },
// 轮询检查情侣关系是否已恢复
startPollCoupleStatus: function() {
var self = this;
if (self._pollTimer) clearInterval(self._pollTimer);
self._pollTimer = setInterval(function() {
api.myInvitations().then(function(data) {
if (data && data.couple_id) {
clearInterval(self._pollTimer);
self._pollTimer = null;
wx.setStorageSync('coupleId', data.couple_id);
wx.showToast({ title: '情侣关系已恢复 💕', icon: 'success' });
setTimeout(function() {
self.setData({ showInvitePanel: false });
self.loadData();
}, 1500);
}
}).catch(function() {});
}, 10000);
},
sendInvite: async function() {
const { inviteTargetId, inviteMessage } = this.data;
if (!inviteTargetId) { wx.showToast({ title: '请输入对方ID', icon: 'none' }); return; }
if (inviteTargetId.length < 6) { wx.showToast({ title: 'ID为6位数字', icon: 'none' }); return; }
try {
wx.showLoading({ title: '发送中...' });
const res = await api.invitePartner({ target_user_id: inviteTargetId, message: inviteMessage });
wx.hideLoading();
wx.showToast({ title: res.msg || '邀请已发送,等待对方同意 💕', icon: 'success' });
this.setData({ inviteTargetId: '', inviteTab: 'sent' });
this.loadInvites();
} catch (err) {
wx.hideLoading();
wx.showToast({ title: err.error || '发送失败', icon: 'none' });
}
},
loadInvites: async function() {
try {
const data = await api.myInvitations();
const received = (data.received || []).map(function(item) {
item.from_initial = (item.from_nickname || item.from_username || '?')[0];
item.short_date = item.created_at ? item.created_at.substring(0, 16).replace('T', ' ') : '';
return item;
});
const sent = (data.sent || []).map(function(item) {
item.to_initial = (item.to_nickname || item.to_username || '?')[0];
item.short_date = item.created_at ? item.created_at.substring(0, 16).replace('T', ' ') : '';
return item;
});
this.setData({ receivedInvites: received, sentInvites: sent });
if (data.couple_id) {
wx.setStorageSync('coupleId', data.couple_id);
wx.showToast({ title: '情侣关系已恢复 💕', icon: 'success' });
setTimeout(() => {
this.setData({ showInvitePanel: false });
this.loadData();
}, 1500);
}
} catch (e) {
console.error(e);
}
},
switchInviteTab: function(e) {
const tab = e.currentTarget.dataset.tab;
this.setData({ inviteTab: tab });
},
acceptInvite: async function(e) {
const id = e.currentTarget.dataset.id;
try {
wx.showLoading({ title: '处理中...' });
const res = await api.respondInvitation({ invitation_id: id, accept: 1 });
wx.hideLoading();
if (res.couple_id) {
wx.setStorageSync('coupleId', res.couple_id);
wx.showToast({ title: '绑定成功 💕', icon: 'success' });
setTimeout(() => {
this.setData({ showInvitePanel: false });
this.loadData();
}, 1000);
}
} catch (err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
}
},
rejectInvite: async function(e) {
const id = e.currentTarget.dataset.id;
try {
wx.showLoading({ title: '处理中...' });
await api.respondInvitation({ invitation_id: id, accept: 0 });
wx.hideLoading();
wx.showToast({ title: '已拒绝', icon: 'success' });
this.loadInvites();
} catch (err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
}
},
// ===== 分享绑定 =====
onShareAppMessage: function() {
var displayId = wx.getStorageSync('displayId') || this.data.myDisplayId;
var nick = wx.getStorageSync('nickname') || '我';
if (!displayId) {
wx.showToast({ title: '请先完善资料', icon: 'none' });
return {};
}
return {
title: nick + ' 邀请你绑定情侣 💕',
path: '/pages/profile/profile?inviter=' + displayId,
imageUrl: '/images/share.png'
};
},
// 从分享链接进入时处理邀请绑定
handleShareInvite: function(inviterId) {
if (!inviterId) return;
this.setData({ shareInviterId: inviterId, showShareBindModal: true, shareDisagree: false });
},
acceptShareBind: async function() {
var self = this;
try {
wx.showLoading({ title: '处理中...' });
const res = await api.acceptShareInvite({ inviter_id: this.data.shareInviterId });
wx.hideLoading();
if (res.couple_id) {
wx.setStorageSync('coupleId', res.couple_id);
wx.showToast({ title: '绑定成功 💕', icon: 'success' });
this.setData({ showShareBindModal: false });
setTimeout(() => { this.loadData(); }, 1000);
}
} catch (err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
}
},
rejectShareBind: function() {
this.setData({ shareDisagree: true, shareInviterName: 'TA' });
},
hideShareBindModal: function() {
this.setData({ showShareBindModal: false });
},
// 页面跳转
manageRelationship: function() { wx.navigateTo({ url: '/pages/unbind/unbind' }); },
goToSettings: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/settings/settings' }); },
goHome: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/index/index' }); }
});
+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": true
}
+160
View File
@@ -0,0 +1,160 @@
<view class="app-shell" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/profile/profile.wxml -->
<view wx:if="{{!showEditProfileModal && !showEditPartnerModal}}" class="j-fixed-header"><view class="j-fixed-heading" style="height:{{barHeight}}px;padding-right:{{navSide}}px;"><text class="j-eyebrow">MY CORNER</text><text class="j-page-title">我的小角落</text></view></view>
<view wx:if="{{!showEditProfileModal && !showEditPartnerModal}}" class="page-root" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<scroll-view class="page-bg j-main-scroll" scroll-y show-scrollbar="{{false}}" bounces="{{true}}" bindscroll="onScroll" scroll-top="{{scrollTop}}">
<view class="j-body">
<view class="j-profile-summary" bindtap="showEditProfileModal"><view class="j-profile-info"><text class="j-profile-name">{{myNickname}}</text><text class="j-caption">ID {{myDisplayId}}</text><text class="j-caption">{{myGender}} {{myAge}} {{myBirthday}}</text><text class="j-link">编辑资料 →</text></view><view class="j-avatar"><image wx:if="{{myAvatarUrl}}" src="{{myAvatarUrl}}" mode="aspectFill" /><text wx:else>{{myInitial}}</text></view></view>
<view class="j-section-head"><text>我们的关系</text></view>
<view wx:if="{{bindingKnown && hasPartner}}" class="j-relation-card"><view class="j-row" bindtap="showEditPartnerModal"><view><text class="j-small-title">{{partnerName}}</text><text class="j-caption" wx:if="{{partnerDisplayId}}">ID {{partnerDisplayId}}</text><text class="j-caption">{{partnerDetail}}</text><text class="j-link">查看TA的资料 →</text></view><view class="j-avatar"><image wx:if="{{partnerAvatarUrl}}" src="{{partnerAvatarUrl}}" mode="aspectFill" /><text wx:else>{{partnerInitial}}</text></view></view><view class="j-profile-setting" bindtap="manageRelationship"><image src="/images/heart.svg" /><view><text class="j-small-title">关系管理</text><text class="j-caption">绑定信息与解绑</text></view><text class="j-link">›</text></view></view>
<view wx:elif="{{bindingKnown}}" class="j-invite" bindtap="showInvitePanel"><view><text class="j-small-title">另一半还没有加入</text><text class="j-caption">邀请TA,共写这本生活手账</text></view><text class="j-outline-label">绑定情侣</text></view>
<page-state wx:elif="{{bindingError}}" kind="error" title="绑定状态加载失败" action="重新加载" bind:action="loadData" /><page-state wx:else kind="loading" title="正在获取绑定状态" />
<view class="j-profile-setting" bindtap="goToSettings"><image src="/images/settings.svg" /><view><text class="j-small-title">外观与通知</text><text class="j-caption">背景、通知与账号设置</text></view><text class="j-link">›</text></view>
</view>
</scroll-view>
</view>
<app-tabbar />
<view wx:if="{{showEditProfileModal}}" class="edit-root" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<view class="edit-nav"><view class="back-btn" bindtap="hideEditProfileModal"><image class="back-icon" src="/images/back.svg" mode="aspectFit" /></view></view>
<view class="edit-body" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<view class="edit-avatar-wrap" bindtap="chooseAvatar"><image wx:if="{{editAvatarUrl}}" src="{{editAvatarUrl}}" mode="aspectFill" class="edit-avatar-img"></image><text wx:else class="edit-avatar-placeholder">{{editNicknameInitial || '?'}}</text><text class="edit-avatar-hint">点击更换头像</text></view>
<text class="edit-id">我的ID: {{myDisplayId}}</text>
<view class="input-group"><label>昵称</label><input class="input" placeholder="你的昵称" value="{{editNickname}}" bindinput="onEditNicknameInput" /></view>
<view class="input-group"><label>性别</label><picker mode="selector" range="{{genderOptions}}" bindchange="onEditGenderChange"><view class="input">{{editGenderText || '请选择'}}</view></picker></view>
<view class="input-group"><label>生日</label><picker mode="date" value="{{editBirthday}}" bindchange="onEditBirthdayInput"><view class="input">{{editBirthday || '请选择日期'}}</view></picker><text style="font-size:24rpx;color:#B74768;margin-top:8rpx;display:block;" wx:if="{{editAge}}">自动计算:{{editAge}}岁</text></view>
<view class="input-group" wx:if="{{hasPartner}}"><label>恋爱纪念日</label><picker mode="date" value="{{tempLoveDate}}" bindchange="onLoveDateInput"><view class="input">{{tempLoveDate || '设置你们在一起的日期'}}</view></picker></view>
<view class="input-group" wx:if="{{hasPartner}}"><label>恋爱故事</label><textarea class="input" style="min-height:120rpx;" placeholder="记录你们的故事..." value="{{tempLoveStory}}" bindinput="onLoveStoryInput"></textarea></view>
</view>
<view class="edit-footer"><button class="btn btn-primary" bindtap="saveProfile" hover-class="none">保存</button></view>
</view>
<view wx:if="{{showEditPartnerModal}}" class="edit-root partner-view" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<view class="edit-nav"><view class="back-btn" bindtap="hideEditPartnerModal"><image class="back-icon" src="/images/back.svg" mode="aspectFit" /></view><text class="partner-nav-title">TA的资料</text></view>
<view class="edit-body" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<view class="edit-avatar-wrap"><image wx:if="{{editPartnerAvatarUrl}}" src="{{editPartnerAvatarUrl}}" mode="aspectFill" class="edit-avatar-img"></image><text wx:else class="edit-avatar-placeholder">{{editPartnerNameInitial || '?'}}</text></view>
<text class="edit-id" wx:if="{{partnerDisplayId}}">TA的ID: {{partnerDisplayId}}</text>
<view class="input-group readonly"><label>昵称</label><view class="input readonly-text">{{editPartnerNickname || '未设置'}}</view></view>
<view class="input-group readonly"><label>性别</label><view class="input readonly-text">{{editPartnerGenderText || '-'}}</view></view>
<view class="input-group readonly"><label>生日</label><view class="input readonly-text">{{editPartnerBirthday || '-'}}</view></view>
<view class="input-group readonly"><label>恋爱纪念日</label><view class="input readonly-text">{{tempLoveDate || '未设置'}}</view></view>
<view class="input-group readonly"><label>恋爱故事</label><view class="input readonly-text" style="min-height:60rpx;">{{tempLoveStory || '未设置'}}</view></view>
</view>
</view>
<!-- ===== 邀请绑定面板 ===== -->
<view wx:if="{{showInvitePanel}}" class="invite-root">
<view class="invite-nav">
<view class="back-btn" bindtap="hideInvitePanel"><image class="back-icon" src="/images/back.svg" mode="aspectFit" /></view>
<text class="invite-nav-title">💕 绑定情侣</text>
<view style="width:56rpx;"></view>
</view>
<scroll-view class="invite-body" scroll-y show-scrollbar="{{false}}">
<view class="container" style="padding-top:200rpx;">
<view class="card" style="padding:32rpx;text-align:center;margin-bottom:32rpx;">
<text style="font-size:26rpx;color:#888;display:block;">你的ID</text>
<text style="font-size:64rpx;font-weight:700;color:#B74768;display:block;margin-top:8rpx;" wx:if="{{myDisplayId}}">{{myDisplayId}}</text>
<text style="font-size:64rpx;font-weight:700;color:#B74768;display:block;margin-top:8rpx;" wx:else>---</text>
<text style="font-size:22rpx;color:#888;display:block;margin-top:8rpx;">将此ID告诉TA</text>
</view>
<view class="input-group">
<label>TA的ID</label>
<input class="input" type="tel" placeholder="输入对方的6位ID" value="{{inviteTargetId}}" bindinput="onInviteTargetIdInput" maxlength="6" />
</view>
<button class="btn btn-primary" bindtap="sendInvite" hover-class="none">
<text>💕</text> 发送邀请
</button>
<view style="margin:32rpx 0;display:flex;align-items:center;gap:16rpx;">
<view style="flex:1;height:2rpx;background:#FFE4EC;"></view>
<text style="font-size:26rpx;color:#888;">或</text>
<view style="flex:1;height:2rpx;background:#FFE4EC;"></view>
</view>
<button class="btn btn-secondary" open-type="share" hover-class="none">
<text>💌</text> 分享给微信好友
</button>
<view class="invite-tabs" style="margin-top:32px;">
<view class="invite-tab {{inviteTab == 'received' ? 'active' : ''}}" bindtap="switchInviteTab" data-tab="received">收到的邀请</view>
<view class="invite-tab {{inviteTab == 'sent' ? 'active' : ''}}" bindtap="switchInviteTab" data-tab="sent">发出的邀请</view>
</view>
<view class="invite-list" wx:if="{{inviteTab == 'received'}}">
<block wx:for="{{receivedInvites}}" wx:key="id">
<view class="invite-card">
<view class="invite-card-header">
<view class="invite-avatar">{{item.from_initial}}</view>
<view class="invite-info">
<text class="invite-name">{{item.from_nickname || item.from_username}}</text>
<text class="invite-time">{{item.short_date}}</text>
</view>
<text class="invite-status {{item.status == 'accepted' ? 'accepted' : 'pending'}}">{{item.status == 'accepted' ? '已接受' : '待处理'}}</text>
</view>
<view class="invite-message" wx:if="{{item.message}}">💌 {{item.message}}</view>
<view class="invite-actions" wx:if="{{item.status == 'pending'}}">
<button class="btn btn-primary btn-sm" bindtap="acceptInvite" data-id="{{item.id}}" hover-class="none">✅ 接受</button>
<button class="btn btn-outline btn-sm" bindtap="rejectInvite" data-id="{{item.id}}" hover-class="none">❌ 拒绝</button>
</view>
</view>
</block>
<view class="empty-tip" wx:if="{{receivedInvites.length == 0}}">
<text>暂无收到的邀请 💭</text>
</view>
</view>
<view class="invite-list" wx:if="{{inviteTab == 'sent'}}">
<block wx:for="{{sentInvites}}" wx:key="id">
<view class="invite-card">
<view class="invite-card-header">
<view class="invite-avatar" style="background:linear-gradient(135deg,#82B1FF,#B388FF);">{{item.to_initial}}</view>
<view class="invite-info">
<text class="invite-name">{{item.to_nickname || item.to_username || item.to_phone}}</text>
<text class="invite-time">{{item.short_date}}</text>
</view>
<text class="invite-status {{item.status == 'accepted' ? 'accepted' : 'pending'}}">{{item.status == 'accepted' ? '已接受' : '待处理'}}</text>
</view>
</view>
</block>
<view class="empty-tip" wx:if="{{sentInvites.length == 0}}">
<text>暂无发出的邀请 💭</text>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- ===== 分享绑定弹窗 ===== -->
<view wx:if="{{showShareBindModal}}" class="modal-overlay" bindtap="hideShareBindModal">
<view class="modal" catchtap="noop" style="text-align:center;">
<view class="auth-header">
<text class="auth-icon" style="font-size:128rpx;">💌</text>
<text class="auth-title">情侣邀请</text>
<text class="auth-desc" wx:if="{{!shareDisagree}}">有人想和你绑定为情侣</text>
<text class="auth-desc" wx:if="{{shareDisagree}}" style="color:#FF4757;">需要对方同意绑定后才能使用</text>
</view>
<view wx:if="{{!shareDisagree}}">
<view class="card" style="padding:48rpx 32rpx;margin-bottom:40rpx;">
<text style="font-size:36rpx;font-weight:600;display:block;color:#4A4A4A;">ID: {{shareInviterId}}</text>
<text style="font-size:26rpx;color:#888;display:block;margin-top:8rpx;">想要和你绑定为情侣 💕</text>
</view>
<button class="btn btn-primary" bindtap="acceptShareBind" hover-class="none">
<text>💕</text> 同意绑定
</button>
<button class="btn btn-outline" style="margin-top:16rpx;" bindtap="rejectShareBind" hover-class="none">
不同意
</button>
</view>
<view wx:if="{{shareDisagree}}">
<view style="padding:80rpx 0;">
<text style="font-size:80rpx;display:block;">😅</text>
<text style="font-size:30rpx;color:#888;display:block;margin-top:32rpx;">需要对方同意绑定后才能使用本应用</text>
</view>
</view>
</view>
</view>
</view>
+308
View File
@@ -0,0 +1,308 @@
/* pages/profile/profile.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; }
.page-root { height: 100vh; overflow: hidden; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
/* 编辑卡全屏 */
.edit-root {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 1000;
background: linear-gradient(180deg, #FFF0F5 0%, #F4E2E3 40%, #FAFAFA 100%);
display: flex;
flex-direction: column;
}
.edit-nav {
display: flex;
align-items: center;
padding: 100rpx 32rpx 24rpx;
}
.edit-body {
flex: 1;
padding: 0 56rpx;
overflow-y: auto;
}
.edit-avatar-wrap {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24rpx;
}
.edit-avatar-img,
.edit-avatar-placeholder {
width: 120rpx; height: 120rpx;
border-radius: 50%;
border: 4rpx solid #FFB4C8;
}
.edit-avatar-placeholder {
background: linear-gradient(135deg, #FF6B81, #B388FF);
display: flex; align-items: center; justify-content: center;
font-size: 40rpx; font-weight: 700; color: white;
}
.edit-avatar-hint {
font-size: 22rpx; color: #B74768; margin-top: 8rpx;
}
.edit-id {
font-size: 24rpx; color: #bbb;
text-align: center; display: block;
margin-bottom: 24rpx;
}
.input-group { margin-bottom: 20rpx; }
.input-group label {
font-size: 28rpx; font-weight: 500; color: #555;
display: block; margin-bottom: 8rpx;
}
.input {
padding: 20rpx 28rpx; font-size: 30rpx;
background: white;
border-radius: 16rpx;
border: 2rpx solid #F0E6FF;
}
.input-group.readonly .input.readonly-text {
background: #F8F4FF;
color: #666;
border-color: #E8DCF5;
padding: 20rpx 28rpx;
font-size: 30rpx;
border-radius: 16rpx;
border: 2rpx solid #E8DCF5;
line-height: 1.5;
min-height: 44rpx;
display: flex;
align-items: center;
}
.edit-footer {
padding: 20rpx 56rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
/* ===== 邀请绑定面板 ===== */
.invite-root {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 1000;
background: #FFF5F5;
display: flex;
flex-direction: column;
}
.invite-nav {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 1001;
height: 176rpx;
display: flex;
align-items: flex-end;
justify-content: space-between;
padding-bottom: 16rpx;
background: linear-gradient(to bottom, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0) 100%);
}
.invite-nav-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.invite-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* 邀请页标签 */
.invite-tabs {
display: flex;
background: white;
border-radius: 100rpx;
padding: 8rpx;
margin-bottom: 48rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.05);
}
.invite-tab {
flex: 1;
padding: 24rpx;
text-align: center;
border-radius: 100rpx;
font-size: 28rpx;
font-weight: 600;
transition: all 0.3s;
color: #888;
}
.invite-tab.active {
background: linear-gradient(135deg, #B74768, #B388FF);
color: white;
}
.invite-list {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.invite-card {
background: white;
border-radius: 48rpx;
padding: 40rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.05);
}
.invite-card-header {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 24rpx;
}
.invite-avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #B388FF, #82B1FF);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.invite-info {
flex: 1;
}
.invite-name {
font-size: 30rpx;
font-weight: 600;
display: block;
}
.invite-time {
font-size: 26rpx;
color: #888;
display: block;
margin-top: 4rpx;
}
.invite-status {
padding: 8rpx 20rpx;
border-radius: 40rpx;
font-size: 24rpx;
flex-shrink: 0;
}
.invite-status.accepted {
background: #E8F5E9;
color: #4CAF50;
}
.invite-status.pending {
background: #FFF3E0;
color: #FF9800;
}
.invite-message {
font-size: 28rpx;
color: #4A4A4A;
padding: 24rpx;
background: #FFF8E7;
border-radius: 24rpx;
margin-bottom: 24rpx;
}
.invite-actions {
display: flex;
gap: 24rpx;
}
.invite-actions .btn {
flex: 1;
padding: 24rpx;
font-size: 28rpx;
}
.empty-tip {
text-align: center;
padding: 80rpx 0;
color: #888;
font-size: 26rpx;
}
/* 分享绑定弹窗 */
.auth-header {
text-align: center;
margin-bottom: 64rpx;
}
.auth-icon {
font-size: 112rpx;
margin-bottom: 32rpx;
display: block;
animation: bounce 2s ease-in-out infinite;
}
.auth-title {
font-size: 52rpx;
font-weight: 700;
color: #4A4A4A;
margin-bottom: 16rpx;
display: block;
background: linear-gradient(135deg, #B74768, #B388FF);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.auth-desc {
color: #888;
font-size: 28rpx;
display: block;
}
.btn-sm {
padding: 24rpx 32rpx;
font-size: 28rpx;
}
.btn-secondary {
background: white;
color: #B74768;
border: 4rpx solid #B74768;
}
.btn-secondary:active {
background: #F4E2E3;
}
.btn-outline {
background: transparent;
color: #B74768;
border: 4rpx solid #FFB4C8;
}
.btn-outline:active {
background: #F4E2E3;
}
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
/* TA 的资料仅供查看,不预留编辑操作区。 */
.partner-view .edit-body { min-height: 0; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); }
.partner-view .edit-nav { position: relative; }
.partner-nav-title { position: absolute; left: 120rpx; right: 120rpx; text-align: center; font-size: 32rpx; font-weight: 600; color: #342F2B; pointer-events: none; }
.partner-view .readonly-text { white-space: pre-wrap; overflow-wrap: anywhere; }
+530
View File
@@ -0,0 +1,530 @@
const { getLayout } = require('../../utils/layout');
// pages/publish/publish.js - 统一发布页(增强版编辑器)
const { api, API_BASE, uploadFileCompat } = require('../../utils/request');
const { createSwipeBack } = require('../../utils/util');
const { requestSubscribeAfterAction } = require('../../utils/subscribe');
var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '😎', '🤔', '😭', '😋', '😌'];
var WEATHER_OPTIONS = ['☀️', '⛅', '☁️', '🌧️', '⛈️', '❄️', '🌤️', '🌥️', '🌈', '🌪️', '🌫️', '🌙'];
var TYPE_CONFIG = {
timeline: { title: '添加记忆日', btn: '保存记忆日', api: 'timelineAdd' },
diary: { title: '写日记', btn: '发布日记', api: 'diaryAdd' },
album: { title: '创建相册', btn: '创建相册', api: 'albumAdd' },
song: { title: '收藏歌曲', btn: '保存歌曲', api: 'songsAdd' },
checkin: { title: '添加打卡', btn: '保存打卡', api: 'checkinAdd' }
};
Page({
onResize: function() { this.setData(getLayout()); },
data: {
type: '',
titleText: '',
btnText: '',
form: {},
tempImages: [],
coverPreview: '',
coverPath: '',
submitting: false, editLoading:false, editLoadError:false,
checkinTypes: ['双方打卡', '第一视角(我)', '她打卡'],
checkinTypeIdx: 0,
checkinTypeMap: ['both', 'first', 'her'],
checkinFreqs: ['每天打卡', '每周打卡', '每月打卡', '不限次数'],
checkinFreqIdx: 0,
checkinFreqMap: ['daily', 'weekly', 'monthly', 'unlimited'],
repeatOptions: ['无重复', '每周重复', '每月重复', '每年重复'],
repeatOptionIdx: 0,
repeatOptionMap: ['', 'week', 'month', 'year'],
editId: 0,
// 背景图
bgImageUrl: '',
bgImagePath: '',
// 心情/天气
moodOptions: MOOD_OPTIONS,
weatherOptions: WEATHER_OPTIONS,
contentLength: 0
},
onLoad: function(options) {
this.setData(getLayout());
var type = options.type || 'timeline';
var editId = options.edit ? parseInt(options.edit) : 0;
var cfg = TYPE_CONFIG[type] || TYPE_CONFIG['timeline'];
var titleText = editId ? '编辑事件' : cfg.title;
var btnText = editId ? '保存修改' : cfg.btn;
this.setData({ type: type, titleText: titleText, btnText: btnText, editId: editId });
var self = this;
var swipe = createSwipeBack(function() { self.goBack(); });
this.onTouchStart = swipe.onTouchStart;
this.onTouchEnd = swipe.onTouchEnd;
// 初始化表单
this.resetForm(type);
// 非编辑模式:优先恢复本地草稿(防误退出丢失内容)
if (!editId) {
this.loadDraft(type);
}
// 如果是编辑模式,加载已有数据
if (editId && type === 'timeline') {
this.loadEditData(editId);
}
// 如果是打卡页,加载情侣昵称
if (type === 'checkin') {
this.loadCoupleNames();
}
},
// ===== 草稿缓存 =====
// 读取本地草稿并填充表单
loadDraft: function(type) {
try {
var draftKey = 'publishDraft_' + type;
var draft = wx.getStorageSync(draftKey);
if (!draft) return;
var data = JSON.parse(draft);
var patch = {};
if (data.form) {
var form = this.data.form;
for (var k in data.form) {
if (data.form.hasOwnProperty(k) && form.hasOwnProperty(k)) {
form[k] = data.form[k];
}
}
patch.form = form;
patch.contentLength = (form.content || '').length;
}
if (typeof data.repeatOptionIdx === 'number') patch.repeatOptionIdx = data.repeatOptionIdx;
if (typeof data.checkinTypeIdx === 'number') patch.checkinTypeIdx = data.checkinTypeIdx;
if (typeof data.checkinFreqIdx === 'number') patch.checkinFreqIdx = data.checkinFreqIdx;
if (data.bgImageUrl) patch.bgImageUrl = data.bgImageUrl;
if (data.coverPreview) {
patch.coverPreview = data.coverPreview;
patch.coverPath = data.coverPath || data.coverPreview;
}
this.setData(patch);
} catch(e) {}
},
// 保存草稿到本地(输入时自动触发)
_saveDraft: function() {
try {
var type = this.data.type;
if (this.data.editId) return; // 编辑模式不保存草稿
var draftKey = 'publishDraft_' + type;
var data = {
form: this.data.form,
repeatOptionIdx: this.data.repeatOptionIdx,
checkinTypeIdx: this.data.checkinTypeIdx,
checkinFreqIdx: this.data.checkinFreqIdx,
bgImageUrl: this.data.bgImageUrl,
coverPreview: this.data.coverPreview,
coverPath: this.data.coverPath,
time: Date.now()
};
wx.setStorageSync(draftKey, JSON.stringify(data));
} catch(e) {}
},
// 发布成功后清除草稿
_clearDraft: function() {
try {
var type = this.data.type;
wx.removeStorageSync('publishDraft_' + type);
} catch(e) {}
},
retryEdit: function() { this.loadEditData(this.data.editId); },
loadEditData: async function(id) {
this.setData({editLoading:true,editLoadError:false});
try {
var item = await api.timelineDetail(id);
if (item) {
var form = {
title: item.title || '',
date: item.event_date || '',
location: item.location || '',
content: item.content || '',
repeat_interval: parseInt(item.repeat_interval) || 0,
repeat_unit: item.repeat_unit || ''
};
// 加载背景图
var bgPath = item.bg_image || '';
var bgUrl = bgPath ? require('../../utils/request').getImageUrl(bgPath) : '';
var repeatOptionIdx = 0;
if (form.repeat_interval > 0 && form.repeat_unit) {
var opts = this.data.repeatOptionMap;
for (var i = 0; i < opts.length; i++) {
if (opts[i] === form.repeat_unit) {
repeatOptionIdx = i;
break;
}
}
}
this.setData({
form: form,
repeatOptionIdx: repeatOptionIdx,
bgImageUrl: bgUrl,
bgImagePath: bgPath
});
}
} catch(e) {
console.error('加载编辑数据失败', e);
this.setData({editLoadError:true});
} finally { this.setData({editLoading:false}); }
},
loadCoupleNames: async function() {
try {
var info = await api.coupleInfo();
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;
var myName = me.nickname || '我';
var partnerName = partner.nickname || 'TA';
this.setData({
checkinTypes: ['双方打卡', myName, partnerName]
});
} catch(e) {
console.error('获取情侣信息失败', e);
}
},
resetForm: function(type) {
var form = {};
if (type === 'diary') {
form = { title: '', content: '', mood: '😊', weather: '☀️', lat: 0, lng: 0, location: '' };
} else if (type === 'song') {
form = { song_name: '', artist: '', note: '' };
} else if (type === 'album') {
form = { title: '', desc: '', lat: 0, lng: 0, location: '' };
this.setData({ coverPreview: '', coverPath: '' });
} else if (type === 'checkin') {
form = { title: '', type: 'both', frequency: 'daily' };
this.setData({ checkinTypeIdx: 0, checkinFreqIdx: 0 });
} else {
form = { title: '', date: '', location: '', content: '', repeat_interval: 0, repeat_unit: '' };
}
this.setData({ form: form, tempImages: [], repeatOptionIdx: 0, contentLength: (form.content || '').length });
},
// ===== 自动定位(日记 / 相册发布时自动获取经纬度)=====
_autoLocate: function() {
var self = this;
return new Promise(function(resolve) {
// 已有位置则直接使用,避免重复定位
if (self.data.form && self.data.form.lat && self.data.form.lng) {
resolve({ lat: self.data.form.lat, lng: self.data.form.lng, location: self.data.form.location || '' });
return;
}
wx.getFuzzyLocation({
success: function(res) {
resolve({ lat: res.latitude, lng: res.longitude, location: '' });
},
fail: function() {
// 定位失败不阻塞发布,经纬度置 0
resolve({ lat: 0, lng: 0, location: '' });
}
});
});
},
// ===== 通用输入(自动保存草稿)=====
onTitleInput: function(e) { this.setData({ 'form.title': e.detail.value }); this._saveDraft(); },
onDateInput: function(e) { this.setData({ 'form.date': e.detail.value }); this._saveDraft(); },
onContentInput: function(e){
var val = e.detail.value || '';
this.setData({ 'form.content': val, contentLength: val.length });
this._saveDraft();
},
onLocationInput: function(e){ this.setData({ 'form.location': e.detail.value }); this._saveDraft(); },
onDescInput: function(e) { this.setData({ 'form.desc': e.detail.value }); this._saveDraft(); },
onArtistInput: function(e) { this.setData({ 'form.artist': e.detail.value }); this._saveDraft(); },
onNoteInput: function(e) { this.setData({ 'form.note': e.detail.value }); this._saveDraft(); },
onCheckinTypeChange: function(e) {
var idx = parseInt(e.detail.value);
this.setData({ checkinTypeIdx: idx, 'form.type': this.data.checkinTypeMap[idx] });
this._saveDraft();
},
onCheckinFreqChange: function(e) {
var idx = parseInt(e.detail.value);
this.setData({ checkinFreqIdx: idx, 'form.frequency': this.data.checkinFreqMap[idx] });
this._saveDraft();
},
onRepeatChange: function(e) {
var idx = parseInt(e.detail.value);
var unit = this.data.repeatOptionMap[idx];
this.setData({
repeatOptionIdx: idx,
'form.repeat_interval': unit ? 1 : 0,
'form.repeat_unit': unit || ''
});
this._saveDraft();
},
// ===== 封面图片选择(相册)=====
chooseCover: function() {
var self = this;
wx.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: function(res) {
self.setData({ coverPreview: res.tempFilePaths[0], coverPath: res.tempFilePaths[0] });
self._saveDraft();
}
});
},
// ===== 图片选择(日记)=====
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) });
self._saveDraft();
}
});
},
// ===== 删除单张图片 =====
removeImage: function(e) {
var index = parseInt(e.currentTarget.dataset.index);
var images = this.data.tempImages;
images.splice(index, 1);
this.setData({ tempImages: images });
this._saveDraft();
},
// ===== 提交 =====
submit: async function() {
if (this.data.editLoading || this.data.editLoadError) return;
if (this.data.submitting) return;
this.setData({ submitting: true });
var editId = this.data.editId;
try {
var type = this.data.type;
var form = this.data.form;
// 先校验表单
if (type === 'timeline') {
if (!form.title) { throw new Error('请填写事件内容'); }
if (!form.date) { throw new Error('请填写事件日期'); }
} else if (type === 'diary') {
if (!form.content || !form.content.trim()) { throw new Error('请填写日记内容'); }
} else if (type === 'album') {
if (!form.title) { throw new Error('请填写相册名称'); }
} else if (type === 'song') {
if (!form.song_name || !form.artist) { throw new Error('请填写歌曲名和歌手名'); }
} else if (type === 'checkin') {
if (!form.title) { throw new Error('请填写打卡标题'); }
}
// ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
// 订阅弹窗不阻塞后续异步操作,用户拒绝也不影响发布
var sceneMap = {
diary: 'diary', album: 'photo', checkin: 'checkin',
timeline: 'timeline', song: 'song'
};
var scene = sceneMap[type];
if (scene) {
requestSubscribeAfterAction(scene);
}
// 再执行异步操作(上传/API调用)
if (type === 'timeline') {
var editId = this.data.editId;
// 上传背景图
var bgImagePath = this.data.bgImagePath;
if (this.data.bgImageUrl && !bgImagePath) {
wx.showLoading({ title: '上传背景图...' });
try {
var uploadRes = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: this.data.bgImageUrl,
name: 'file',
formData: { sub_dir: 'timeline_bg' }
});
bgImagePath = uploadRes && (uploadRes.path || uploadRes.url || '');
} catch(e) {}
wx.hideLoading();
}
var result;
if (editId) {
result = await api.timelineUpdate({ id: editId, title: form.title, event_date: form.date, location: form.location || '', content: form.content || '', repeat_interval: form.repeat_interval || 0, repeat_unit: form.repeat_unit || '', bg_image: bgImagePath || '' });
} else {
result = await api.timelineAdd({ title: form.title, event_date: form.date, location: form.location || '', content: form.content || '', repeat_interval: form.repeat_interval || 0, repeat_unit: form.repeat_unit || '', bg_image: bgImagePath || '' });
}
} else if (type === 'diary') {
var today = new Date();
var ds = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0');
var uploadPaths = [];
if (this.data.tempImages.length > 0) {
wx.showLoading({ title: '上传图片中...' });
for (var i = 0; i < this.data.tempImages.length; i++) {
var res = await this._uploadDiaryImage(this.data.tempImages[i]);
if (res) uploadPaths.push(res);
}
wx.hideLoading();
}
// 从缓存获取用户地区
var region = wx.getStorageSync('region') || '';
// 自动定位获取经纬度(发布后自动显示地理位置)
var loc = await this._autoLocate();
await api.diaryAdd({
title: form.title || '',
diary_date: ds,
content: form.content,
images: uploadPaths,
region: region,
lat: loc.lat,
lng: loc.lng,
location: loc.location
});
} else if (type === 'album') {
var coverUrl = '';
if (this.data.coverPath) {
wx.showLoading({ title: '上传封面...' });
coverUrl = await this._uploadAlbumCover(this.data.coverPath);
wx.hideLoading();
}
// 自动定位获取经纬度(发布后自动显示地理位置)
var albumLoc = await this._autoLocate();
await api.albumAdd({ title: form.title, description: form.desc || '', cover: coverUrl, lat: albumLoc.lat, lng: albumLoc.lng, location: albumLoc.location });
} else if (type === 'song') {
await api.songsAdd({ song_name: form.song_name, artist: form.artist, note: form.note || '' });
} else if (type === 'checkin') {
await api.checkinAdd({ title: form.title, type: form.type || 'dual', frequency: form.frequency || 'daily' });
}
// 发布/修改成功后清除草稿
this._clearDraft();
wx.showToast({ title: editId ? '修改成功 ✨' : '发布成功 ✨', icon: 'success' });
// 延迟返回页面,给订阅弹窗留出时间
setTimeout(function() { wx.navigateBack(); }, 1500);
} catch (e) {
wx.hideLoading();
wx.showToast({ title: (e && (e.error || e.message || e.errMsg)) || '发布失败', icon: 'none', duration: 2000 });
} finally {
this.setData({ submitting: false });
}
},
_uploadAlbumCover: async function(filePath) {
try {
var data = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'albums' }
});
// 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'}
var path = data && (data.path || data.url || '');
if (!path) {
// 内容安全检测失败:抛出错误提示用户
if (data && data.error) {
throw data;
}
console.error('封面上传返回无路径', JSON.stringify(data), '错误码:', data && data.code);
// 尝试重新上传一次
try {
var retryData = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'albums' }
});
var retryPath = retryData && (retryData.path || retryData.url || '');
if (retryPath) {
console.log('封面上传重试成功');
return retryPath;
}
} catch(retryErr) {
console.error('封面上传重试也失败', retryErr, '错误码:', retryErr && retryErr.code);
}
}
return path;
} catch(e) {
// 内容安全违规错误:直接抛出,中断发布并提示用户
if (e && e.error) {
throw e;
}
console.error('封面上传失败', e, '错误码:', e && e.code);
return '';
}
},
_uploadDiaryImage: async function(filePath) {
try {
var data = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'diaries' }
});
// 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'}
var path = data && (data.path || data.url || '');
if (!path) {
// 内容安全检测失败:抛出错误提示用户
if (data && data.error) {
throw data;
}
console.error('日记图片上传返回无路径', JSON.stringify(data), '错误码:', data && data.code);
// 尝试重新上传一次
try {
var retryData = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'diaries' }
});
var retryPath = retryData && (retryData.path || retryData.url || '');
if (retryPath) {
console.log('日记图片重试上传成功');
return retryPath;
}
} catch(retryErr) {
console.error('日记图片重试上传也失败', retryErr, '错误码:', retryErr && retryErr.code);
}
}
return path;
} catch(e) {
// 内容安全违规错误:直接抛出,中断发布并提示用户
if (e && e.error) {
throw e;
}
console.error('日记图片上传失败', e, '错误码:', e && e.code);
return '';
}
},
// ===== 背景图 =====
chooseBgImage: function() {
var self = this;
wx.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: function(res) {
var filePath = res.tempFilePaths[0];
self.setData({ bgImageUrl: filePath, bgImagePath: '' });
}
});
},
removeBgImage: function() {
this.setData({ bgImageUrl: '', bgImagePath: '' });
},
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"
}
}
+150
View File
@@ -0,0 +1,150 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/publish/publish.wxml - 统一发布页(增强版编辑器) -->
<app-nav title="{{titleText}}" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}" style="height:100vh;" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<view class="container"><page-state wx:if="{{editLoading}}" kind="loading" title="正在加载事件" /><page-state wx:elif="{{editLoadError}}" kind="error" title="事件加载失败" action="重试" bind:action="retryEdit" /><block wx:else>
<!-- ===== 时间线 / 打卡 / 相册 ===== -->
<block wx:if="{{type == 'timeline' || type == 'checkin' || type == 'album'}}">
<view class="input-group">
<label>{{type == 'album' ? '相册名称' : (type == 'timeline' ? '事件内容' : '标题')}}</label>
<input class="input" placeholder="{{type == 'album' ? '例如:我们的旅行' : (type == 'checkin' ? '例如:吃饭、睡觉、运动' : '例如:第一次约会')}}" value="{{form.title}}" bindinput="onTitleInput" />
</view>
</block>
<!-- ===== 打卡(类型 + 频率)===== -->
<block wx:if="{{type == 'checkin'}}">
<view class="input-group">
<label>打卡权限</label>
<picker mode="selector" range="{{checkinTypes}}" value="{{checkinTypeIdx}}" bindchange="onCheckinTypeChange">
<view class="input">{{checkinTypes[checkinTypeIdx] || '请选择'}}</view>
</picker>
</view>
<view class="input-group">
<label>打卡频率</label>
<picker mode="selector" range="{{checkinFreqs}}" value="{{checkinFreqIdx}}" bindchange="onCheckinFreqChange">
<view class="input">{{checkinFreqs[checkinFreqIdx] || '请选择'}}</view>
</picker>
<text style="font-size:22rpx;color:#999;margin-top:8rpx;display:block;">每天/每周/每月:周期内打卡一次即完成 | 不限:有记录即算完成</text>
</view>
</block>
<!-- ===== 时间线(日期)===== -->
<block wx:if="{{type == 'timeline'}}">
<view class="input-group">
<label>事件日期</label>
<picker mode="date" value="{{form.date}}" bindchange="onDateInput">
<view class="input">{{form.date || '请选择事件日期'}}</view>
</picker>
</view>
</block>
<!-- ===== 时间线(地点 + 备注 + 重复 + 背景图)===== -->
<block wx:if="{{type == 'timeline'}}">
<view class="input-group">
<label>事件地点(选填)</label>
<input class="input" placeholder="例如:西湖边" value="{{form.location}}" bindinput="onLocationInput" />
</view>
<view class="input-group">
<label>备注(选填)</label>
<textarea class="input textarea" placeholder="记录一些想说的话..." value="{{form.content}}" bindinput="onContentInput" auto-height maxlength="500" />
</view>
<view class="input-group">
<label>重复选项(选填)</label>
<picker mode="selector" range="{{repeatOptions}}" value="{{repeatOptionIdx}}" bindchange="onRepeatChange">
<view class="input">{{repeatOptions[repeatOptionIdx]}}</view>
</picker>
</view>
<view class="input-group">
<label>背景图片(选填)</label>
<view class="bg-pick-row">
<image wx:if="{{bgImageUrl}}" class="bg-preview" src="{{bgImageUrl}}" mode="aspectFill" />
<view class="bg-pick-btn" bindtap="chooseBgImage">
<text wx:if="{{!bgImageUrl}}">+ 添加背景</text>
<text wx:else>更换背景</text>
</view>
<view class="bg-remove-btn" wx:if="{{bgImageUrl}}" bindtap="removeBgImage">✕ 移除</view>
</view>
</view>
</block>
<!-- ===== 相册(描述 + 封面)===== -->
<view class="input-group" wx:if="{{type == 'album'}}">
<label>描述</label>
<input class="input" placeholder="简单描述一下这个相册" value="{{form.desc}}" bindinput="onDescInput" />
</view>
<view class="input-group" wx:if="{{type == 'album'}}">
<label>封面图片</label>
<view class="cover-pick-row">
<image wx:if="{{coverPreview}}" class="cover-preview" src="{{coverPreview}}" mode="aspectFill" />
<view class="cover-pick-btn" bindtap="chooseCover" wx:if="{{!coverPreview}}">
<text>+</text>
</view>
<view class="cover-pick-btn" bindtap="chooseCover" wx:else style="background:#f0f0f0;">
<text style="font-size:28rpx;">换</text>
</view>
</view>
</view>
<!-- ===== 日记 ===== -->
<block wx:if="{{type == 'diary'}}">
<!-- 标题 -->
<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="今天发生了什么有趣的事呢?&#10;写下你的心情和感受吧..." 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>
</block>
<!-- ===== 收藏歌曲 ===== -->
<block wx:if="{{type == 'song'}}">
<view class="input-group">
<label>歌曲名</label>
<input class="input" placeholder="例如:小幸运" value="{{form.song_name}}" bindinput="onTitleInput" />
</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>
</block>
<!-- 提交 -->
<button class="btn btn-primary submit-btn" bindtap="submit" hover-class="none" loading="{{submitting}}">{{btnText}}</button>
</block>
</view>
</scroll-view>
</view>
+208
View File
@@ -0,0 +1,208 @@
/* pages/publish/publish.wxss - 增强版编辑器样式 */
@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;
}
/* ===== 封面图片选择 ===== */
.cover-pick-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.cover-preview {
width: 200rpx;
height: 200rpx;
border-radius: 16rpx;
background: #eee;
}
.cover-pick-btn {
width: 120rpx;
height: 120rpx;
border-radius: 16rpx;
background: #fff;
border: 2rpx dashed #ddd;
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
color: #ccc;
}
/* ===== 背景图选择 ===== */
.bg-pick-row {
display: flex;
align-items: center;
gap: 16rpx;
flex-wrap: wrap;
}
.bg-preview {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
background: #eee;
}
.bg-pick-btn {
padding: 20rpx 32rpx;
border-radius: 16rpx;
background: #fff;
border: 2rpx dashed #ddd;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
color: #999;
min-height: 80rpx;
box-sizing: border-box;
}
.bg-remove-btn {
padding: 20rpx 24rpx;
border-radius: 16rpx;
background: #fff;
border: 2rpx solid #ff6b6b;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #ff6b6b;
min-height: 80rpx;
box-sizing: border-box;
}
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
+117
View File
@@ -0,0 +1,117 @@
const { getLayout } = require('../../utils/layout');
// pages/punch/punch.js - 情侣打卡
const { api, getImageUrl } = require('../../utils/request');
Page({
onResize: function() { this.setData(getLayout()); },
data: { loadError: false,
checkins: [],
coupleInfo: null,
myAvatar: '', partnerAvatar: '',
myName: '我', partnerName: 'TA',
weekBoth: 0, weekMy: 0, weekPartner: 0,
todayMy: 0, todayPartner: 0,
loading: true
},
onLoad: function() {
this.setData(getLayout());
this.loadCoupleInfo();
this.loadData();
},
onShow: function() { this.loadCoupleInfo(); this.loadData(); },
onPullDownRefresh: async function() {
await this.loadCoupleInfo();
await this.loadData();
wx.stopPullDownRefresh();
},
// 从接口加载情侣头像和名称
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({
coupleInfo: info,
myAvatar: getImageUrl(me.avatar || ''),
partnerAvatar: getImageUrl(partner.avatar || ''),
myName: me.nickname || '我',
partnerName: partner.nickname || 'TA'
});
},
loadData: async function() {
if (this._pageLoading) return;
this._pageLoading = true;
this.setData({loading:true, loadError:false});
try {
const [res, stats] = await Promise.all([
api.checkinList(),
api.checkinStats().catch(() => ({}))
]);
const list = (res.items || res || []).map(function(item) {
item.type = item.type || 'both';
var typeLabels = { both: '双方', first: '第一视角', her: '她打卡' };
item.type_label = typeLabels[item.type] || '双方';
item.frequency = item.frequency || 'daily';
var freqLabels = { daily: '每天', weekly: '每周', monthly: '每月', once: '一次', unlimited: '不限' };
item.freq_label = freqLabels[item.frequency] || '每天';
var periodLabels = { daily: '今日', weekly: '本周', monthly: '本月', once: '本次', unlimited: '' };
item.period_label = periodLabels[item.frequency] || '今日';
item.my_photo_url = item.my_photo ? getImageUrl(item.my_photo) : '';
item.partner_photo_url = item.partner_photo ? getImageUrl(item.partner_photo) : '';
// 打卡人头像
item.creator_avatar_url = item.creator_avatar ? getImageUrl(item.creator_avatar) : '';
item.my_checkin_avatar_url = item.my_checkin_avatar ? getImageUrl(item.my_checkin_avatar) : '';
item.partner_checkin_avatar_url = item.partner_checkin_avatar ? getImageUrl(item.partner_checkin_avatar) : '';
return item;
});
var data = {
checkins: list,
weekBoth: stats.week_both_count || 0,
weekMy: stats.week_my_count || 0,
weekPartner: stats.week_partner_count || 0,
todayMy: stats.today_my_count || 0,
todayPartner: stats.today_partner_count || 0,
loading: false
};
this.setData(data);
} catch(e) {
this.setData({loadError:true}); console.error(e); this.setData({ loading: false }); }
finally { this._pageLoading = false; this.setData({loading:false}); }
},
// ===== 点击卡片 → 详情弹窗 =====
openDetail: function(e) {
var idx = e.currentTarget.dataset.index;
var item = this.data.checkins[idx];
wx.navigateTo({ url: '/pages/checkin-detail/checkin-detail?id=' + item.id });
},
// ===== 跳转发布页 =====
showAddModal: function() { wx.navigateTo({ url: '/pages/publish/publish?type=checkin' }); },
// ===== 删除 =====
deleteCheckin: function(e) {
var id = e.currentTarget.dataset.id;
wx.showModal({
title: '确认删除', content: '确定要删除这个打卡项吗?',
success: async (res) => {
if (res.confirm) { try { await api.checkinDelete({ id: id }); wx.showToast({ title: '已删除', icon: 'success' }); this.loadData(); } catch(err) {} }
}
});
},
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"
}
}
+97
View File
@@ -0,0 +1,97 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/punch/punch.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">LITTLE PROMISES</text><text class="j-page-title">把约定变成习惯</text></view><button class="j-button j-button-small" bindtap="showAddModal">+ 添加</button></view>
<page-state wx:if="{{loading}}" kind="loading" title="正在加载" /><page-state wx:elif="{{loadError}}" kind="error" title="加载失败" description="请检查网络后重新加载" action="重试" bind:action="loadData" />
<!-- 顶部 Banner - 卡片式 -->
<view class="punch-banner-card" wx:if="{{!loading && !loadError}}">
<view class="promise-summary-head"><text class="c-small-title">一起坚持的小约定</text><view class="promise-avatars"><view class="banner-avatar-circle"><image wx:if="{{myAvatar}}" src="{{myAvatar}}" mode="aspectFill" /><text wx:else>我</text></view><view class="banner-avatar-circle"><image wx:if="{{partnerAvatar}}" src="{{partnerAvatar}}" mode="aspectFill" /><text wx:else>TA</text></view></view></view>
<view class="banner-stat-grid">
<view class="banner-stat-card">
<text class="stat-num">{{weekBoth}}</text>
<text class="stat-label">本周共同打卡</text>
</view>
<view class="banner-stat-card">
<text class="stat-num">{{todayMy}}</text>
<text class="stat-label">我今天</text>
</view>
<view class="banner-stat-card">
<text class="stat-num">{{todayPartner}}</text>
<text class="stat-label">TA今天</text>
</view>
</view>
</view>
<!-- 打卡列表 -->
<view class="checkin-cards" wx:if="{{!loading && !loadError && checkins.length > 0}}">
<view class="checkin-card" wx:for="{{checkins}}" wx:key="id" bindtap="openDetail" data-index="{{index}}" bindlongpress="deleteCheckin" data-id="{{item.id}}">
<!-- 主题 -->
<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">{{item.freq_label}}</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>
</view>
<page-state wx:if="{{!loading && !loadError && !checkins.length}}" title="从一个小约定开始" description="一起运动、按时吃饭,也可以成为日常仪式" action="添加打卡" bind:action="showAddModal" />
</view>
</scroll-view>
<!-- 浮动按钮 -->
</view>
+11
View File
@@ -0,0 +1,11 @@
@import '../../styles/common.wxss';
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
@import '../../styles/companion.wxss';
.punch-banner-card { background:#F1EBE2; border:1rpx solid #E9DDD0; padding:28rpx; border-radius:16rpx; margin-bottom:32rpx; }
.promise-summary-head { display:flex; justify-content:space-between; align-items:center; gap:16rpx; margin-bottom:24rpx; }
.promise-avatars { display:flex; padding-right:8rpx; }.banner-avatar-circle { width:44rpx; height:44rpx; border-radius:50%; overflow:hidden; border:3rpx solid #F1EBE2; background:#F4E2E3; color:#943C58; font-size:20rpx; display:flex; align-items:center; justify-content:center; }.banner-avatar-circle + .banner-avatar-circle { margin-left:-8rpx; }.banner-avatar-circle image { width:100%; height:100%; }
.banner-stat-grid { display:flex; gap:10rpx; }.banner-stat-card { flex:1; display:flex; flex-direction:column; align-items:flex-start; border-right:1rpx solid #DFD2C3; padding-left:20rpx; }.banner-stat-card:first-child { padding-left:0; }.banner-stat-card:last-child { border:0; }
.stat-num { font-size:44rpx; line-height:1.2; font-weight:500; color:#943C58; }.stat-label { color:#85776D; font-size:21rpx; line-height:1.5; margin-top:8rpx; }
.checkin-cards { display:flex; flex-direction:column; gap:20rpx; }
.checkin-card:active { opacity:.8; }
+370
View File
@@ -0,0 +1,370 @@
const { getLayout } = require('../../utils/layout');
// pages/settings/settings.js
const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request');
const { requestSubscribe, getSubscribeStatus } = require('../../utils/subscribe');
Page({
onResize: function() { this.setData(getLayout()); },
data: {
showBgModal: false,
bgPreview: '',
bgFilePath: '',
hasPartner: false,
bindingKnown: false, bindingError:false,
// 解绑状态
unbindStatus: '', // '' / 'pending' / 'agreed' / 'rejected' / 'cancelled' / 'expired'
unbindIsRequester: false, // 当前用户是否是发起方
unbindRequesterName: '', // 发起方昵称
unbindRemaining: 0, // 剩余秒数
// 消息通知设置
showSubscribeModal: false,
subscribeStatus: {},
subscribeItems: [],
subscribeLoading: false,
// 我们past
showAboutModal: false,
// 联系我们
showContactModal: false,
},
onLoad: function() {
this.setData(getLayout());},
onPullDownRefresh: function() {
wx.stopPullDownRefresh();
},
onShow: async function() {
this.setData({ bindingKnown: false, bindingError:false });
try {
var info = await api.coupleInfo();
var hasPartner = !!(info && !info.not_bound && info.id);
this.setData({ hasPartner: hasPartner, bindingKnown: true });
if (hasPartner) {
wx.setStorageSync('coupleId', info.id);
this.loadUnbindStatus();
} else {
wx.removeStorageSync('coupleId');
this.setData({ unbindStatus: '', unbindIsRequester: false, unbindRequesterName: '' });
}
} catch (e) {
this.setData({bindingError:true});
console.error('获取绑定状态失败', e);
}
},
goBind: function() {
wx.redirectTo({ url: '/pages/profile/profile?bind=1' });
},
// 加载解绑状态
loadUnbindStatus: function() {
var self = this;
api.unbindStatus().then(function(res) {
if (res && res.status && res.status !== 'none') {
self.setData({
unbindStatus: res.status,
unbindIsRequester: !!res.is_requester,
unbindRequesterName: res.requester_name || '对方',
unbindRemaining: res.remaining_seconds || 0
});
} else {
self.setData({ unbindStatus: '', unbindIsRequester: false, unbindRequesterName: '' });
}
}).catch(function() {});
},
noop: function() {},
// ===== 消息通知管理(统一使用一个模板) =====
showSubscribeModal: async function() {
this.setData({ showSubscribeModal: true, subscribeLoading: true });
try {
// forceRefresh=true 确保从后端获取最新状态
const status = await getSubscribeStatus(true);
this.setData({
subscribeStatus: status,
subscribeChecked: !!status['notify'],
subscribeLoading: false
});
} catch (e) {
this.setData({ subscribeLoading: false });
}
},
hideSubscribeModal: function() {
this.setData({ showSubscribeModal: false });
},
// ===== 我们past =====
showAboutModal: function() {
this.setData({ showAboutModal: true });
},
hideAboutModal: function() {
this.setData({ showAboutModal: false });
},
// ===== 联系我们 =====
showContactModal: function() {
this.setData({ showContactModal: true });
},
hideContactModal: function() {
this.setData({ showContactModal: false });
},
toggleSubscribe: async function(e) {
const type = 'notify';
const current = this.data.subscribeChecked;
// 防止重复点击
if (this._subscribing) return;
this._subscribing = true;
if (current) {
// 取消订阅
try {
await api.unsubscribeTemplate({ template_type: type });
this.setData({ subscribeChecked: false });
wx.setStorageSync('subscribed_notify', false);
wx.showToast({ title: '已关闭通知', icon: 'success' });
} catch (e) {
wx.showToast({ title: '操作失败', icon: 'none' });
}
} else {
// 请求订阅授权
try {
const results = await requestSubscribe(type);
if (results[type]) {
this.setData({ subscribeChecked: true });
wx.setStorageSync('subscribed_notify', true);
wx.showToast({ title: '已开启通知', icon: 'success' });
} else {
wx.showToast({ title: '订阅被拒绝,可在设置中重新开启', icon: 'none' });
}
} catch (e) {
wx.showToast({ title: '订阅失败', icon: 'none' });
}
}
this._subscribing = false;
},
// ===== 背景图 =====
showBgModal: async function() {
this.setData({ showBgModal: true, bgPreview: '', bgFilePath: '' });
try {
var cfg = await api.getBgConfig();
if (!this.data.bgFilePath) this.setData({ bgPreview: cfg && cfg.image ? getImageUrl(cfg.image) : '' });
} catch (e) { console.error(e); }
},
hideBgModal: function() { this.setData({ showBgModal: false }); },
// 背景编辑页左滑返回设置页面
bgTouchStartX: 0, bgTouchStartY: 0,
onBgTouchStart: function(e) {
this.bgTouchStartX = e.touches[0].clientX;
this.bgTouchStartY = e.touches[0].clientY;
},
onBgTouchEnd: function(e) {
var dx = this.bgTouchStartX - e.changedTouches[0].clientX;
var dy = Math.abs(this.bgTouchStartY - e.changedTouches[0].clientY);
if (dx < 20 && dy < 20) return;
if (Math.abs(dx) > dy && dx > 80) this.hideBgModal();
},
chooseBgImage: function() {
var self = this;
wx.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'],
success: function(res) {
self.setData({ bgPreview: res.tempFilePaths[0], bgFilePath: res.tempFilePaths[0] });
}
});
},
saveBgImage: async function() {
var fp = this.data.bgFilePath;
if (!fp) {
if (this.data.bgPreview) {
wx.showToast({ title: '背景已设置', icon: 'success' });
this.hideBgModal();
return;
}
wx.showToast({ title: '请先选择图片', icon: 'none' }); return;
}
var self = this;
wx.showLoading({ title: '上传中...' });
try {
var data = await uploadFileCompat({
url: API_BASE + '/setBgImage',
filePath: fp,
name: 'bg',
formData: {}
});
wx.hideLoading();
if (data && data.path) {
var imgUrl = getImageUrl(data.path);
wx.showToast({ title: '背景已保存 ✨', icon: 'success' });
self.setData({ bgPreview: imgUrl, bgFilePath: '' });
self.hideBgModal();
} else {
var errMsg = (data && data.error) || '上传失败';
if (data && data.code) errMsg += ' (错误码: ' + data.code + ')';
wx.showToast({ title: errMsg, icon: 'none' });
}
} catch(e) {
wx.hideLoading();
var errMsg = (e && e.error) || '上传失败';
if (e && e.code) errMsg += ' (错误码: ' + e.code + ')';
wx.showToast({ title: errMsg, icon: 'none' });
}
},
removeBgImage: async function() {
try { await api.deleteBgImage(); } catch(e) {}
wx.showToast({ title: '已恢复默认', icon: 'success' });
this.setData({ bgPreview: '', bgFilePath: '' });
this.hideBgModal();
},
// ===== 解除绑定(根据状态显示不同操作)=====
showUnbind: function() {
var status = this.data.unbindStatus;
var isRequester = this.data.unbindIsRequester;
if (status === 'pending') {
if (isRequester) {
// 发起方:撤销解绑
this.doCancelUnbind();
} else {
// 对方:同意或拒绝
this.doRespondUnbind();
}
} else if (status === 'agreed') {
wx.showToast({ title: '双方已同意解绑,正在处理中', icon: 'none' });
wx.navigateTo({ url: '/pages/unbind/unbind' });
} else {
// 无解绑请求或已结束:发起新解绑
this.doRequestUnbind();
}
},
// 发起解绑请求
doRequestUnbind: function() {
var self = this;
wx.showModal({
title: '💔 解除绑定',
content: '发起解绑请求后,对方需要在72小时内确认同意。\n\n对方同意后,所有情侣记录将被永久删除(记忆日、日记、相册、纪念日、情话、歌曲、打卡),仅保留双方个人资料。\n\n你也可以在对方确认前撤销请求。',
cancelText: '取消',
confirmText: '发起解绑',
confirmColor: '#FF4757',
success: function(res) {
if (res.confirm) {
wx.showLoading({ title: '发送请求...' });
api.unbindRequest().then(function() {
wx.hideLoading();
wx.showToast({ title: '解绑请求已发送', icon: 'success' });
self.loadUnbindStatus();
wx.navigateTo({ url: '/pages/unbind/unbind' });
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
},
// 撤销解绑请求
doCancelUnbind: function() {
var self = this;
wx.showModal({
title: '↩️ 撤销解绑',
content: '确定要撤销解绑请求吗?对方将不会收到任何通知。',
cancelText: '取消',
confirmText: '撤销请求',
confirmColor: '#FF6B9D',
success: function(res) {
if (res.confirm) {
wx.showLoading({ title: '撤销中...' });
api.unbindCancel().then(function() {
wx.hideLoading();
wx.showToast({ title: '已撤销解绑请求', icon: 'success' });
self.loadUnbindStatus();
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
},
// 回应解绑请求(对方视角)
doRespondUnbind: function() {
var self = this;
var name = this.data.unbindRequesterName;
wx.showActionSheet({
itemList: ['✅ 同意解绑', '❌ 拒绝解绑'],
success: function(res) {
if (res.tapIndex === 0) {
// 同意
wx.showModal({
title: '⚠️ 确认同意',
content: name + ' 发起了解绑请求。\n\n同意后,所有情侣记录将被永久删除(记忆日、日记、相册、歌曲、打卡),仅保留双方个人资料。\n\n此操作不可恢复!',
cancelText: '取消',
confirmText: '同意解绑',
confirmColor: '#FF4757',
success: function(r) {
if (r.confirm) {
wx.showLoading({ title: '处理中...' });
api.unbindRespond({ agree: true }).then(function() {
wx.hideLoading();
wx.showToast({ title: '已同意解绑', icon: 'success' });
// 清除本地缓存
wx.removeStorageSync('coupleId');
wx.removeStorageSync('partnerId');
wx.removeStorageSync('partnerName');
wx.removeStorageSync('partnerAvatar');
setTimeout(function() {
wx.reLaunch({ url: '/pages/index/index' });
}, 1500);
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
} else if (res.tapIndex === 1) {
// 拒绝
wx.showModal({
title: '拒绝解绑',
content: '确定要拒绝 ' + name + ' 的解绑请求吗?',
cancelText: '取消',
confirmText: '拒绝',
confirmColor: '#FF6B9D',
success: function(r) {
if (r.confirm) {
wx.showLoading({ title: '处理中...' });
api.unbindRespond({ agree: false }).then(function() {
wx.hideLoading();
wx.showToast({ title: '已拒绝解绑请求', icon: 'success' });
self.loadUnbindStatus();
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
}
}
});
},
// 导航
goToHistory: function() { wx.navigateTo({ url: '/pages/history/history' }); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); },
goToProfile: function() { wx.navigateBack(); },
});
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
},
"enablePullDownRefresh": true
}
+227
View File
@@ -0,0 +1,227 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/settings/settings.wxml -->
<app-nav title="设置" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="j-body">
<view class="section-heading">外观与通知</view>
<!-- 首页背景 -->
<view class="j-profile-setting" bindtap="showBgModal">
<view class="setting-info">
<view class="j-small-title">首页背景</view>
<view class="j-caption">自定义上传背景图片</view>
</view>
<text class="j-link">›</text>
</view>
<!-- 消息通知 -->
<view class="j-profile-setting" bindtap="showSubscribeModal">
<view class="setting-info">
<view class="j-small-title">消息通知</view>
<view class="j-caption">管理订阅消息推送</view>
</view>
<text class="j-link">›</text>
</view>
<view class="section-heading">关系与账号</view>
<!-- 操作记录 -->
<view class="j-profile-setting" bindtap="goToHistory">
<view class="setting-info">
<view class="j-small-title">操作记录</view>
<view class="j-caption">查看双方所有操作日志</view>
</view>
<text class="j-link">›</text>
</view>
<page-state wx:if="{{bindingError}}" kind="error" title="绑定状态加载失败" action="重试" bind:action="onShow" />
<view class="j-profile-setting" bindtap="onShow" wx:if="{{!bindingKnown && !bindingError}}">
<view class="setting-info"><view class="j-small-title">正在获取绑定状态</view><view class="j-caption">若加载失败,点击重试</view></view>
<text class="j-link">›</text>
</view>
<view class="j-profile-setting" bindtap="goBind" wx:if="{{bindingKnown && !hasPartner}}">
<view class="setting-info">
<view class="j-small-title" style="color:#B74768;">绑定情侣</view>
<view class="j-caption">输入对方 ID 或分享邀请</view>
</view>
<text class="j-link">›</text>
</view>
<!-- 解除绑定(根据状态动态显示) -->
<view class="j-profile-setting" bindtap="showUnbind" wx:if="{{bindingKnown && hasPartner}}">
<view class="setting-info">
<view wx:if="{{unbindStatus == 'pending' && unbindIsRequester}}" class="j-small-title" style="color:#FF9800;">取消解绑请求</view>
<view wx:elif="{{unbindStatus == 'pending' && !unbindIsRequester}}" class="j-small-title" style="color:#FF4757;">待回应的解绑请求</view>
<view wx:else class="j-small-title" style="color:#FF4757;">解除绑定</view>
<view wx:if="{{unbindStatus == 'pending' && unbindIsRequester}}" class="j-caption">点击撤销已发起的请求</view>
<view wx:elif="{{unbindStatus == 'pending' && !unbindIsRequester}}" class="j-caption">{{unbindRequesterName}} 发起了解绑</view>
<view wx:else class="j-caption">发起解绑需对方确认</view>
</view>
<text class="j-link">›</text>
</view>
<view class="section-heading">关于与帮助</view>
<!-- 我们past -->
<view class="j-profile-setting" bindtap="showAboutModal">
<view class="setting-info">
<view class="j-small-title">我们past</view>
<view class="j-caption">了解本小程序的功能</view>
</view>
<text class="j-link">›</text>
</view>
<!-- 联系我们 -->
<view class="j-profile-setting" bindtap="showContactModal">
<view class="setting-info">
<view class="j-small-title">联系我们</view>
<view class="j-caption">软件功能建议与反馈</view>
</view>
<text class="j-link">›</text>
</view>
</view>
</scroll-view>
<!-- 背景图编辑页 - 全屏背景预览 -->
<view class="edit-page" wx:if="{{showBgModal}}">
<!-- 全屏背景 -->
<image wx:if="{{bgPreview}}" src="{{bgPreview}}" mode="aspectFill" class="bg-full" catchtouchstart="onBgTouchStart" catchtouchend="onBgTouchEnd" />
<view wx:else class="bg-placeholder" catchtouchstart="onBgTouchStart" catchtouchend="onBgTouchEnd"></view>
<!-- 左滑或点击空白区域返回 -->
<view class="edit-nav" bindtap="hideBgModal">
<view class="back-btn-wrap transparent">
<image src="/images/back.svg" mode="aspectFit" class="back-nav-icon" />
</view>
</view>
<!-- 底部操作区 -->
<view class="bg-edit-footer">
<view class="bg-edit-card">
<text class="modal-subtitle" style="text-align:center;display:block;margin-bottom:24rpx;">上传你喜欢的背景图片</text>
<view class="bg-btn-row">
<button class="btn btn-select" bindtap="chooseBgImage" hover-class="none">📷 选择</button>
<button class="btn btn-save" bindtap="saveBgImage" hover-class="none">💾 保存</button>
<button class="btn btn-restore" bindtap="removeBgImage" hover-class="none">🔄 恢复</button>
</view>
</view>
</view>
</view>
<!-- 消息通知管理弹窗 -->
<view class="modal-overlay" wx:if="{{showSubscribeModal}}" bindtap="hideSubscribeModal">
<view class="modal" catchtap="noop" style="max-height:75vh;overflow-y:auto;">
<text class="modal-heart">🔔</text>
<text class="title">消息通知</text>
<text class="modal-subtitle">开启后,当TA有操作时会通过微信服务通知提醒你</text>
<!-- 加载状态 -->
<view wx:if="{{subscribeLoading}}" class="subscribe-loading">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 通知开关 -->
<view wx:else class="subscribe-section">
<view class="subscribe-card {{subscribeChecked ? 'active' : ''}}">
<view class="subscribe-card-left">
<view class="subscribe-icon-wrap" style="background:{{subscribeChecked ? '#FFE4EC' : '#F5F5F5'}};">
<text class="subscribe-icon">💕</text>
</view>
<view class="subscribe-info">
<text class="subscribe-name">操作通知</text>
<text class="subscribe-desc">TA有新的操作时通知你</text>
</view>
</view>
<switch checked="{{subscribeChecked}}" bindchange="toggleSubscribe" color="#B74768" style="transform:scale(0.75);" />
</view>
<!-- 状态提示 -->
<view class="subscribe-tip {{subscribeChecked ? 'on' : 'off'}}">
<block wx:if="{{subscribeChecked}}">
<text class="tip-icon">✅</text>
<text class="tip-text">通知已开启,TA的操作会第一时间通知你</text>
</block>
<block wx:else>
<text class="tip-icon">💡</text>
<text class="tip-text">开启后即可收到TA的操作通知</text>
</block>
</view>
<!-- 说明 -->
<view class="subscribe-note">
<text class="note-title">📌 温馨提示</text>
<text class="note-item">• 通知通过微信「服务通知」推送</text>
<text class="note-item">• 每次操作后可能会请求一次订阅授权</text>
<text class="note-item">• 若收不到通知,可重新开启此开关</text>
</view>
</view>
</view>
</view>
<!-- 固定标题和关闭按钮,长内容独立滚动 -->
<view class="modal-overlay info-overlay" wx:if="{{showAboutModal}}" bindtap="hideAboutModal">
<view class="modal info-dialog" catchtap="noop">
<view class="info-header">
<image class="info-mark" src="/images/book.svg" mode="aspectFit" />
<text class="info-title">我们 past</text>
<text class="info-caption">把日常,写成我们的故事</text>
</view>
<scroll-view class="info-body" scroll-y show-scrollbar="{{false}}">
<view class="info-content">
<text class="about-intro">属于两个人的小世界,珍藏每一次相遇,也记录平凡日常里的心动。</text>
<view class="about-block">
<text class="about-block-title">我们的生活手账</text>
<text class="about-item">日记 · 写下心情与故事</text>
<text class="about-item">相册 · 珍藏照片与美好瞬间</text>
<text class="about-item">记忆日 · 记住每一个重要日子</text>
<text class="about-item">歌曲 · 收藏一起听过的旋律</text>
<text class="about-item">打卡 · 一起完成日常约定</text>
<text class="about-item">宠物 · 照顾属于我们的伙伴</text>
<text class="about-item">姨姨 · 记录生理期与身体状态</text>
</view>
<view class="about-block">
<text class="about-block-title">让陪伴更近一点</text>
<text class="about-item">情侣绑定 · 邀请另一半,共享记录</text>
<text class="about-item">消息通知 · 订阅对方的动态提醒</text>
<text class="about-item">首页背景 · 换成喜欢的照片</text>
<text class="about-item">所在城市 · 分享彼此的位置</text>
</view>
<text class="about-version info-signature">让所有的爱有迹可循</text>
</view>
</scroll-view>
<view class="info-footer"><button class="info-dismiss" bindtap="hideAboutModal">知道了</button></view>
</view>
</view>
<!-- 联系我们弹窗 -->
<view class="modal-overlay" wx:if="{{showContactModal}}" bindtap="hideContactModal">
<view class="modal" catchtap="noop">
<text class="modal-heart">💬</text>
<text class="title">联系我们</text>
<text class="modal-subtitle">软件功能建议与反馈</text>
<view class="about-section">
<text class="about-intro">如果你在使用过程中有任何软件功能建议、优化想法或遇到的问题,欢迎随时联系我们,我们会认真听取每一位用户的声音,不断把「我们past」做得更好。💕</text>
</view>
<view class="about-block">
<text class="about-block-title">📮 联系微信</text>
<text class="about-item">微信:e909e10</text>
<text class="about-item">添加时请备注「我们past」,方便我们快速响应</text>
</view>
<view class="about-foot">
<text class="about-version">让所有的爱有迹可循</text>
</view>
</view>
</view>
</view>
+346
View File
@@ -0,0 +1,346 @@
/* pages/settings/settings.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; }
/* 全屏编辑页样式 */
.edit-page {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 1000;
display: flex;
flex-direction: column;
}
.bg-full {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 0;
}
.bg-placeholder {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 0;
background: linear-gradient(135deg, #F4E2E3 0%, #FFF0F5 50%, #F8E1FF 100%);
}
.edit-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 100rpx 0 0 32rpx;
background: transparent;
height: 180rpx;
}
.back-btn-wrap {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(255,255,255,0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
transition: all 0.3s ease;
}
.back-btn-wrap.transparent {
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: none;
}
.back-nav-icon {
width: 36rpx;
height: 36rpx;
opacity: 0.7;
}
.edit-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.edit-body {
flex: 1;
padding: 0 48rpx 120rpx;
overflow-y: auto;
}
.back-btn {
width: 56rpx; height: 56rpx;
border-radius: 50%;
background: rgba(255,255,255,0.9);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.08);
}
.back-icon {
width: 32rpx; height: 32rpx;
}
/* 背景编辑页卡片 - 固定在底部导航栏上方 */
.bg-edit-footer {
position: fixed;
bottom: 360rpx;
left: 0;
right: 0;
z-index: 10;
padding: 0 32rpx;
}
.bg-edit-card {
width: 100%;
background: transparent;
border-radius: 32rpx;
padding: 0;
}
/* 恢复默认背景按钮 */
.btn-restore {
background: transparent;
color: #FF6B81;
border: 4rpx solid #FF6B81;
margin-top: 16rpx;
font-size: 28rpx;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-restore:active {
background: rgba(255,107,129,0.08);
transform: scale(0.97);
}
/* ===== 消息通知弹窗样式 ===== */
.subscribe-loading {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0;
}
.loading-spinner {
width: 48rpx;
height: 48rpx;
border: 4rpx solid #F4E2E3;
border-top-color: #B74768;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 26rpx;
color: #999;
margin-top: 16rpx;
}
.subscribe-section {
margin: 24rpx 0 8rpx;
}
.subscribe-card {
display: flex;
align-items: center;
justify-content: space-between;
background: #FAFAFA;
border-radius: 20rpx;
padding: 24rpx 20rpx;
transition: all 0.3s ease;
border: 2rpx solid transparent;
}
.subscribe-card.active {
background: #FFF5F7;
border-color: #FFD6E0;
}
.subscribe-card-left {
display: flex;
align-items: center;
gap: 20rpx;
flex: 1;
margin-right: 16rpx;
}
.subscribe-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.3s ease;
}
.subscribe-icon {
font-size: 32rpx;
}
.subscribe-info {
flex: 1;
min-width: 0;
}
.subscribe-name {
font-size: 28rpx;
font-weight: 600;
color: #333;
display: block;
}
.subscribe-desc {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
display: block;
}
.subscribe-tip {
display: flex;
align-items: center;
gap: 10rpx;
padding: 16rpx 20rpx;
border-radius: 16rpx;
margin-top: 16rpx;
font-size: 24rpx;
}
.subscribe-tip.on {
background: #E8F5E9;
color: #2E7D32;
}
.subscribe-tip.off {
background: #FFF8E1;
color: #F57F17;
}
.tip-icon {
flex-shrink: 0;
}
.tip-text {
flex: 1;
}
.subscribe-note {
background: #F8F8F8;
border-radius: 16rpx;
padding: 20rpx;
margin-top: 20rpx;
}
.note-title {
font-size: 24rpx;
font-weight: 600;
color: #666;
display: block;
margin-bottom: 12rpx;
}
.note-item {
font-size: 22rpx;
color: #999;
display: block;
line-height: 1.8;
}
/* 背景编辑页按钮 - 水平排列,统一大小 */
.bg-btn-row {
display: flex;
gap: 16rpx;
}
.bg-btn-row .btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
padding: 0;
font-size: 26rpx;
font-weight: 600;
transition: all 0.3s ease;
border: none;
text-align: center;
line-height: 88rpx;
margin: 0;
}
.bg-btn-row .btn:active {
transform: scale(0.94);
}
/* 三个按钮统一为半透明白色毛玻璃风格 */
.bg-btn-row .btn-select,
.bg-btn-row .btn-save,
.bg-btn-row .btn-restore {
background: rgba(255, 255, 255, 0.7);
color: #FF6B81;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 2rpx solid rgba(255, 107, 129, 0.25);
box-shadow: none;
}
.bg-btn-row .btn-select:active,
.bg-btn-row .btn-save:active,
.bg-btn-row .btn-restore:active {
background: rgba(255, 107, 129, 0.15);
transform: scale(0.94);
}
.bg-edit-card .btn-restore:active {
background: rgba(255, 107, 129, 0.12);
}
/* ===== 关于我们 ===== */
.about-section {
margin: 28rpx 0 8rpx;
}
.about-intro {
font-size: 26rpx;
color: #666;
line-height: 1.7;
text-align: center;
display: block;
}
.about-block {
background: #FFF9F4;
border-radius: 20rpx;
padding: 24rpx 28rpx;
margin-top: 24rpx;
border: 2rpx solid rgba(255, 107, 157, 0.12);
}
.about-block-title {
font-size: 26rpx;
font-weight: 700;
color: #B74768;
display: block;
margin-bottom: 16rpx;
}
.about-item {
font-size: 24rpx;
color: #555;
display: block;
line-height: 1.9;
}
.about-foot {
margin-top: 28rpx;
text-align: center;
}
.about-version {
font-size: 22rpx;
color: #bbb;
}
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
/* 弹窗位于页面滚动容器外,盖住底部导航。 */
.modal-overlay { z-index:1200; }
.info-dialog { display:flex; flex-direction:column; height:76vh; padding:0; overflow:hidden; max-width:650rpx; }
.info-header { flex-shrink:0; display:flex; flex-direction:column; align-items:center; padding:36rpx 32rpx 24rpx; border-bottom:1rpx solid #E9DDD0; }
.info-mark { width:48rpx; height:48rpx; margin-bottom:16rpx; }
.info-title { font-size:34rpx; font-weight:600; color:#352E2A; }
.info-caption { font-size:24rpx; color:#85776D; margin-top:10rpx; }
.info-body { flex:1; height:0; min-height:0; }
.info-content { padding:28rpx 32rpx; }
.about-intro { color:#85776D; text-align:left; line-height:1.75; }
.about-block { background:#FAF5EE; border:1rpx solid #E9DDD0; border-radius:16rpx; padding:24rpx; }
.about-block-title { color:#352E2A; font-weight:600; }
.about-item { color:#65594F; line-height:1.8; margin-top:8rpx; }
.info-signature { display:block; margin-top:28rpx; text-align:center; color:#A39386; }
.info-footer { flex-shrink:0; padding:20rpx 32rpx; border-top:1rpx solid #E9DDD0; }
.info-dismiss { margin:0; width:100%; padding:0; line-height:76rpx; border-radius:16rpx; background:#B74768; color:#FFFDF8; font-size:28rpx; }
.info-dismiss::after { border:0; }
@@ -0,0 +1,195 @@
const { getLayout } = require('../../utils/layout');
// pages/timeline-detail/timeline-detail.js
const { api, getImageUrl } = require('../../utils/request');
const { createSwipeBack } = require('../../utils/util');
// 重复周期映射
var repeatUnitMap = { 'day': '天', 'week': '周', 'month': '月', 'year': '年' };
var weekdayNames = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
// 计算距离今天最近的重复日期
function calcNearestRepeatDate(eventDate, today, interval, unit) {
var base = new Date(eventDate);
base.setHours(0,0,0,0);
if (unit === 'day') {
var diff = Math.floor((today - base) / 86400000);
var cycles = Math.floor(diff / interval);
var nearest = new Date(base.getTime() + cycles * interval * 86400000);
nearest.setHours(0,0,0,0);
if (nearest > today) {
nearest = new Date(nearest.getTime() - interval * 86400000);
nearest.setHours(0,0,0,0);
}
return nearest;
} else if (unit === 'week') {
var diff = Math.floor((today - base) / 86400000);
var cycles = Math.floor(diff / (7 * interval));
var nearest = new Date(base.getTime() + cycles * 7 * interval * 86400000);
nearest.setHours(0,0,0,0);
if (nearest > today) {
nearest = new Date(nearest.getTime() - 7 * interval * 86400000);
nearest.setHours(0,0,0,0);
}
return nearest;
} else if (unit === 'month') {
var nearest = new Date(base);
nearest.setHours(0,0,0,0);
while (true) {
var next = new Date(nearest);
next.setMonth(next.getMonth() + interval);
next.setHours(0,0,0,0);
if (next > today) break;
nearest = next;
}
return nearest;
} else if (unit === 'year') {
var nearest = new Date(base);
nearest.setHours(0,0,0,0);
while (true) {
var next = new Date(nearest);
next.setFullYear(next.getFullYear() + interval);
next.setHours(0,0,0,0);
if (next > today) break;
nearest = next;
}
return nearest;
}
return new Date(eventDate);
}
Page({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
item: null,
detailId: 0,
bgImageUrl: '',
daysNumber: '',
daysSign: '',
weekdayText: '',
isAnniversary: true
},
onLoad: function(options) {
this.setData(getLayout());
var id = options.id;
if (id) {
this.setData({ detailId: parseInt(id) });
this.loadDetail(parseInt(id));
}
var self = this;
var swipe = createSwipeBack(function() { wx.navigateBack(); });
this.onTouchStart = swipe.onTouchStart;
this.onTouchEnd = swipe.onTouchEnd;
},
retryDetail: function() { this.loadDetail(this.data.detailId); },
onShow: function() {
if (this.data.detailId) {
this.loadDetail(this.data.detailId);
}
},
loadDetail: async function(id) {
if (this._pageLoading) return;
this._pageLoading = true;
this.setData({loading:true, loadError:false});
try {
var item = await api.timelineDetail(id);
var myUid = wx.getStorageSync('userId');
var cid = parseInt(item.created_by) || 0;
// 获取情侣信息
try {
var info = await api.coupleInfo();
var p1 = info.partner1 || {};
var p2 = info.partner2 || {};
var nameMap = {};
var avatarMap = {};
if (p1.id) { nameMap[p1.id] = p1.nickname || p1.username || 'TA'; avatarMap[p1.id] = getImageUrl(p1.avatar); }
if (p2.id) { nameMap[p2.id] = p2.nickname || p2.username || 'TA'; avatarMap[p2.id] = getImageUrl(p2.avatar); }
item.creator_name = nameMap[cid] || (cid == myUid ? '我' : 'TA');
item.creator_avatar = avatarMap[cid] || '';
} catch(e) {
item.creator_name = cid == myUid ? '我' : 'TA';
item.creator_avatar = '';
}
item.creator_initial = (item.creator_name || '?')[0].toUpperCase();
// 计算天数
var today = new Date(); today.setHours(0,0,0,0);
var daysNumber = '';
var daysSign = '';
var isAnniversary = true;
if (item.event_date) {
var ed = new Date(item.event_date.replace(/-/g, '/'));
ed.setHours(0,0,0,0);
var repeatInterval = parseInt(item.repeat_interval) || 0;
var repeatUnit = item.repeat_unit || '';
var diff;
if (repeatInterval > 0 && repeatUnit) {
var baseDate = calcNearestRepeatDate(ed, today, repeatInterval, repeatUnit);
diff = Math.floor((today - baseDate) / 86400000);
isAnniversary = diff >= 0;
daysSign = isAnniversary ? '+' : '-';
daysNumber = Math.abs(diff);
item.repeat_label = repeatUnitMap[repeatUnit] + '重复';
} else {
diff = Math.floor((today - ed) / 86400000);
isAnniversary = diff >= 0;
daysSign = isAnniversary ? '+' : '-';
daysNumber = Math.abs(diff);
}
}
// 星期
var eventDateObj = new Date(item.event_date.replace(/-/g, '/'));
var weekdayText = weekdayNames[eventDateObj.getDay()] || '';
item.is_anniversary = isAnniversary;
this.setData({
item: item,
daysNumber: daysNumber,
daysSign: daysSign,
weekdayText: weekdayText,
isAnniversary: isAnniversary
});
// 从API数据加载背景图
this.loadBgImage();
} catch(e) {
this.setData({loadError:true});
console.error(e);
wx.showToast({ title: (e && (e.error || e.errMsg || e.message)) || '加载失败', icon: 'none' });
}
finally { this._pageLoading = false; this.setData({loading:false}); }
},
// ===== 背景图片 =====
loadBgImage: function() {
var item = this.data.item;
if (item && item.bg_image) {
var fullUrl = getImageUrl(item.bg_image);
this.setData({ bgImageUrl: fullUrl });
} else {
this.setData({ bgImageUrl: '' });
}
},
// ===== 编辑 =====
goEdit: function() {
var item = this.data.item;
if (item && item.id) {
wx.navigateTo({
url: '/pages/publish/publish?type=timeline&edit=' + item.id
});
}
},
// ===== 导航 =====
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,49 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/timeline-detail/timeline-detail.wxml -->
<page-meta page-style="{{bgImageUrl ? 'background: transparent;' : ''}}"></page-meta>
<!-- 背景图片(固定全屏,参考首页实现) -->
<image wx:if="{{bgImageUrl}}" src="{{bgImageUrl}}" mode="aspectFill" class="detail-bg-custom" />
<view class="detail-bg-overlay" wx:if="{{bgImageUrl}}"></view>
<app-nav title="记忆日详情" back="{{true}}" />
<scroll-view class="page-bg {{bgImageUrl ? 'page-bg-has-bg' : ''}}" scroll-y show-scrollbar="{{false}}" bounces="{{true}}" style="height:100vh;"
bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<view class="container {{bgImageUrl ? 'has-bg' : ''}}" bindtap="goEdit">
<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-hero">
<view class="detail-hero-num-wrap">
<text class="detail-hero-num">{{daysNumber}}</text>
<text class="detail-hero-sign">{{daysSign}}</text>
</view>
<!-- <text class="detail-hero-unit">天</text> -->
<view class="detail-hero-bar"></view>
</view>
<!-- 事件标题 -->
<view class="detail-title-wrap" wx:if="{{item.title}}">
<text class="detail-title">{{item.title}}</text>
</view>
<!-- 地点 . 备注 同排 -->
<view class="detail-info-line" wx:if="{{item.location || item.content}}">
<text>{{item.location}}{{item.location && item.content ? ' . ' : ''}}{{item.content}}</text>
</view>
<!-- 标签 + 日期同排 -->
<view class="detail-meta-line">
<text class="detail-meta-tag">{{isAnniversary ? '纪念日' : '目标日'}} {{item.event_date}}({{weekdayText}})</text>
</view>
</block>
</view>
</scroll-view>
</view>
@@ -0,0 +1,165 @@
/* pages/timeline-detail/timeline-detail.wxss */
@import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; }
.container { padding: 380rpx 24rpx 180rpx; position: relative; z-index: 1; }
.page-bg-has-bg .container { padding-top: 400rpx; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; padding-top: 0; }
.page-bg-has-bg { background: transparent !important; }
.page-bg-has-bg::after { display: none !important; }
/* ========== 背景图片(参考首页实现) ========== */
.detail-bg-custom {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
.detail-bg-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
background: rgba(0,0,0,0.45);
pointer-events: none;
}
.has-bg .detail-hero-num,
.has-bg .detail-hero-sign,
.has-bg .detail-hero-unit,
.has-bg .detail-hero-bar,
.has-bg .detail-title,
.has-bg .detail-info-line text,
.has-bg .detail-meta-tag,
.has-bg .detail-meta-date,
.has-bg .detail-repeat text {
color: rgba(255,255,255,0.9) !important;
}
.has-bg .detail-hero-bar {
background: rgba(255,255,255,0.35) !important;
}
.has-bg .detail-repeat {
background: rgba(255,255,255,0.15);
}
.tab-bar-transparent {
display: none !important;
}
.top-nav-card-transparent {
background: transparent !important;
border-bottom: none !important;
}
.nav-back-hidden {
display: none !important;
}
.top-nav-card-transparent .nav-back-icon {
filter: brightness(0) invert(1) !important;
}
/* 无背景图时,top-nav-card 背景与 page 背景融合 */
.top-nav-card:not(.top-nav-card-transparent) {
background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%);
}
/* ========== 主区域(点击编辑) ========== */
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 60vh;
}
/* ========== 大大的天数 ========== */
.detail-hero {
text-align: center;
margin-bottom: 40rpx;
}
.detail-hero-num-wrap {
display: inline-flex;
align-items: flex-start;
position: relative;
}
.detail-hero-num {
font-size: 120rpx;
font-weight: 900;
color: #B74768;
line-height: 1;
display: inline;
}
.detail-hero-sign {
font-size: 40rpx;
font-weight: 700;
color: #B74768;
vertical-align: super;
line-height: 1;
margin-left: 4rpx;
}
.detail-hero-unit {
font-size: 40rpx;
font-weight: 600;
color: #B74768;
margin-left: 8rpx;
display: inline;
}
.detail-hero-bar {
width: 400rpx;
height: 4rpx;
background: #B74768;
opacity: 0.25;
border-radius: 4rpx;
margin: 40rpx auto 0;
}
.page-bg-has-bg .detail-hero-bar {
background: rgba(255,255,255,0.5) !important;
opacity: 1 !important;
}
/* ========== 事件标题 ========== */
.detail-title-wrap {
text-align: center;
margin-bottom: 20rpx;
}
.detail-title {
font-size: 36rpx;
font-weight: 700;
color: #333;
line-height: 1.4;
}
/* ========== 信息行 ========== */
.detail-info-line {
text-align: center;
margin-bottom: 12rpx;
}
.detail-info-line text {
font-size: 28rpx;
color: #666;
line-height: 1.6;
}
/* ========== 重复标签 ========== */
.detail-repeat {
display: inline-block;
padding: 8rpx 24rpx;
background: #FFF0F3;
border-radius: 30rpx;
font-size: 22rpx;
color: #B74768;
margin-bottom: 24rpx;
}
/* ========== 空状态 ========== */
.empty-state { text-align: center; padding: 80rpx; color: #999; font-size: 26rpx; }
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
+194
View File
@@ -0,0 +1,194 @@
const { getLayout } = require('../../utils/layout');
// pages/timeline/timeline.js
const { api, getImageUrl } = require('../../utils/request');
// 重复周期映射
var repeatUnitMap = { 'day': '天', 'week': '周', 'month': '月', 'year': '年' };
// 计算距离今天最近的重复日期(返回 Date 对象,已归零)
function calcNearestRepeatDate(eventDate, today, interval, unit) {
var base = new Date(eventDate);
base.setHours(0,0,0,0);
if (unit === 'day') {
var diff = Math.floor((today - base) / 86400000);
var cycles = Math.floor(diff / interval);
var nearest = new Date(base.getTime() + cycles * interval * 86400000);
nearest.setHours(0,0,0,0);
if (nearest > today) {
nearest = new Date(nearest.getTime() - interval * 86400000);
nearest.setHours(0,0,0,0);
}
return nearest;
} else if (unit === 'week') {
var diff = Math.floor((today - base) / 86400000);
var cycles = Math.floor(diff / (7 * interval));
var nearest = new Date(base.getTime() + cycles * 7 * interval * 86400000);
nearest.setHours(0,0,0,0);
if (nearest > today) {
nearest = new Date(nearest.getTime() - 7 * interval * 86400000);
nearest.setHours(0,0,0,0);
}
return nearest;
} else if (unit === 'month') {
var nearest = new Date(base);
nearest.setHours(0,0,0,0);
while (true) {
var next = new Date(nearest);
next.setMonth(next.getMonth() + interval);
next.setHours(0,0,0,0);
if (next > today) break;
nearest = next;
}
return nearest;
} else if (unit === 'year') {
var nearest = new Date(base);
nearest.setHours(0,0,0,0);
while (true) {
var next = new Date(nearest);
next.setFullYear(next.getFullYear() + interval);
next.setHours(0,0,0,0);
if (next > today) break;
nearest = next;
}
return nearest;
}
return new Date(eventDate);
}
Page({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
list: []
},
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/timeline/timeline', 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.timelineList();
const myUid = wx.getStorageSync('userId');
var nameMap = {};
var avatarMap = {};
try {
const info = await api.coupleInfo();
var p1 = info.partner1 || {};
var p2 = info.partner2 || {};
if (p1.id) { nameMap[p1.id] = p1.nickname || p1.username || 'TA'; avatarMap[p1.id] = getImageUrl(p1.avatar); }
if (p2.id) { nameMap[p2.id] = p2.nickname || p2.username || 'TA'; avatarMap[p2.id] = getImageUrl(p2.avatar); }
} catch (e) {}
var today = new Date(); today.setHours(0,0,0,0);
var items = (list || []).map(function(item, idx) {
var cid = parseInt(item.created_by) || 0;
item.creator_name = nameMap[cid] || (cid == myUid ? '我' : 'TA');
item.creator_avatar = avatarMap[cid] || '';
item.creator_initial = (item.creator_name || '?')[0].toUpperCase();
item.side = idx % 2 === 0 ? 'left' : 'right';
item.bg_image_url = item.bg_image ? getImageUrl(item.bg_image) : '';
// 计算纪念日/倒数日(支持重复事件)
if (item.event_date) {
var ed = new Date(item.event_date.replace(/-/g, '/'));
ed.setHours(0,0,0,0);
var repeatInterval = parseInt(item.repeat_interval) || 0;
var repeatUnit = item.repeat_unit || '';
// 计算实际用来对比的基准日期
if (repeatInterval > 0 && repeatUnit) {
// 重复事件:以今天的日期为基准,计算最近一次重复的日期
var baseDate = calcNearestRepeatDate(ed, today, repeatInterval, repeatUnit);
var diff = Math.floor((today - baseDate) / 86400000);
if (diff >= 0) {
item.is_anniversary = true;
item.days_text = '已过去 ' + diff + ' 天';
item.date_tag = '纪念日';
item.repeat_label = repeatUnitMap[repeatUnit] + '重复';
} else {
item.is_anniversary = false;
item.days_text = '还有 ' + Math.abs(diff) + ' 天';
item.date_tag = '倒数日';
item.repeat_label = repeatUnitMap[repeatUnit] + '重复';
}
} else {
// 非重复事件:直接用事件日期计算
var diff = Math.floor((today - ed) / 86400000);
if (diff >= 0) {
item.is_anniversary = true;
item.days_text = '已过去 ' + diff + ' 天';
item.date_tag = '纪念日';
} else {
item.is_anniversary = false;
item.days_text = '还有 ' + Math.abs(diff) + ' 天';
item.date_tag = '倒数日';
}
}
} else {
item.is_anniversary = true;
item.days_text = '';
}
return item;
});
this.setData({ list: items });
} catch (e) {
this.setData({loadError:true});
console.error(e);
wx.showToast({ title: (e && (e.error || e.errMsg || e.message)) || '加载失败', icon: 'none' });
}
finally { this._pageLoading = false; this.setData({loading:false}); }
},
deleteTimeline: async function(e) {
var id = e.currentTarget.dataset.id;
wx.showModal({
title: '确认删除',
content: '确定要删除这条记录吗?',
success: async function(res) {
if (res.confirm) {
try {
await api.timelineDelete({ id: id });
wx.showToast({ title: '已删除', icon: 'success' });
// need to re-get this context
var pages = getCurrentPages();
var page = pages[pages.length - 1];
page.loadData();
} catch (err) {
wx.showToast({ title: err.error || '删除失败', icon: 'none' });
}
}
}
});
}, goToPublish: function() { wx.navigateTo({ url: '/pages/publish/publish?type=timeline' }); },
goToDetail: function(e) {
var id = e.currentTarget.dataset.id;
if (id) {
wx.navigateTo({ url: '/pages/timeline-detail/timeline-detail?id=' + id });
}
},
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); },
goBack: function() { wx.navigateBack(); },
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"
}
}
+55
View File
@@ -0,0 +1,55 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/timeline/timeline.wxml - 左右交替记忆日 -->
<app-nav title="记忆日" back="{{true}}" />
<!-- 添加按钮 -->
<scroll-view class="page-bg tl-scroll" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="container"><view class="j-row"><view><text class="j-eyebrow">MEMORIES OF US</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="tl-list" wx:if="{{!loading && !loadError && list.length > 0}}">
<view class="tl-item {{item.side}}" wx:for="{{list}}" wx:key="id" bindtap="goToDetail" bindlongpress="deleteTimeline" data-id="{{item.id}}">
<!-- 背景图片 -->
<view class="tl-bg-wrap" wx:if="{{item.bg_image_url}}">
<image class="tl-bg" src="{{item.bg_image_url}}" mode="aspectFill" />
<view class="tl-bg-overlay"></view>
</view>
<view class="tl-card {{item.is_anniversary ? '' : 'tl-card-countdown'}} {{item.bg_image_url ? 'tl-card-has-bg' : ''}}">
<view class="tl-creator">
<image wx:if="{{item.creator_avatar}}" class="tl-avatar" src="{{item.creator_avatar}}" mode="aspectFill" />
<view wx:else class="tl-avatar-placeholder">{{item.creator_initial}}</view>
<text class="tl-name">{{item.creator_name}}</text>
<text class="tl-date-tag {{item.is_anniversary ? 'tl-date-tag-anniversary' : 'tl-date-tag-countdown'}}">{{item.date_tag}}</text>
<text class="tl-date">{{item.event_date}}</text>
</view>
<view class="tl-card-head">
<text class="tl-title">{{item.title}}</text>
</view>
<view class="tl-card-head" wx:if="{{item.location}}">
<text class="tl-location">📍 {{item.location}}</text>
</view>
<view class="tl-card-head" wx:if="{{item.content}}">
<text class="tl-note">💬 {{item.content}}</text>
</view>
<view class="tl-days-wrap">
<text class="tl-repeat" wx:if="{{item.repeat_label}}">{{item.repeat_label}}</text>
<text class="tl-days {{item.is_anniversary ? '' : 'tl-days-countdown'}}">{{item.days_text}}</text>
</view>
</view>
</view>
</view>
<view class="empty-state" wx:if="{{!loading && !loadError && !list.length}}">
<text style="font-size:72rpx;">📅</text>
<text>还没有记忆日事件</text>
<text style="font-size:24rpx;color:#bbb;">点击上方添加记忆日</text>
</view>
</view>
</scroll-view>
</view>
+153
View File
@@ -0,0 +1,153 @@
/* pages/timeline/timeline.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; }
.container { padding: 16rpx 24rpx 300rpx; }
.tl-scroll { height: 100vh; }
/* ===== 列表 ===== */
.tl-list { display: flex; flex-direction: column; gap: 24rpx; }
/* ===== 卡片(与日记卡片等宽)===== */
.tl-item {
width: 100%;
}
.tl-card {
background: #fff;
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.04);
width: 100%;
box-sizing: border-box;
border: 2rpx solid transparent;
}
.tl-card-countdown {
background: linear-gradient(135deg, #F5F7FF, #EEF2FF);
border-color: #D6E0FF;
}
/* ===== 用户信息 + 日期并行 ===== */
.tl-creator {
display: flex;
align-items: flex-start;
gap: 12rpx;
}
.tl-item.left .tl-creator { justify-content: flex-start; }
.tl-item.right .tl-creator { justify-content: flex-start; flex-direction: row-reverse; }
.tl-avatar { width: 72rpx; height: 72rpx; border-radius: 50%; flex-shrink: 0; align-self: center; }
.tl-avatar-placeholder {
width: 72rpx; height: 72rpx; border-radius: 50%; flex-shrink: 0; align-self: center;
background: linear-gradient(135deg, #F4E2E3, #F0E6FF);
display: flex; align-items: center; justify-content: center;
font-size: 22rpx; font-weight: 700; color: #FF6B81;
}
.tl-name { font-size: 28rpx; color: #333; font-weight: 600; flex-shrink: 0; }
.tl-date { font-size: 22rpx; color: #999; white-space: nowrap; }
.tl-date-tag { font-size: 22rpx; white-space: nowrap; margin-right: 6rpx; }
.tl-date-tag-anniversary { color: #FF6B81; }
.tl-date-tag-countdown { color: #6B8CFF; }
.tl-location { font-size: 22rpx; color: #999; }
.tl-note { font-size: 24rpx; color: #888; line-height: 1.5; }
/* ===== 内容(与用户信息反方向)===== */
.tl-card-head {
display: flex;
margin-top: 6rpx;
}
.tl-card-head:first-of-type {
margin-top: 14rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
}
.tl-item.left .tl-card-head { justify-content: flex-end; }
.tl-item.right .tl-card-head { justify-content: flex-start; }
.tl-title { font-size: 30rpx; font-weight: 700; color: #333; }
/* ===== 已过去天数 ===== */
.tl-days-wrap {
display: flex;
margin-top: 16rpx;
padding-top: 14rpx;
border-top: 1rpx dashed #ffe0e5;
}
.tl-item.left .tl-days-wrap { justify-content: flex-end; }
.tl-item.right .tl-days-wrap { justify-content: flex-start; }
.tl-days {
display: inline-block;
font-size: 22rpx;
color: #FF6B81;
background: linear-gradient(135deg, #FFF0F5, #F4E2E3);
padding: 6rpx 20rpx;
border-radius: 20rpx;
font-weight: 500;
}
.tl-days-countdown {
color: #6B8CFF;
background: linear-gradient(135deg, #EEF0FF, #E4ECFF);
}
.tl-repeat {
display: inline-block;
font-size: 20rpx;
color: #999;
background: #f5f5f5;
padding: 4rpx 16rpx;
border-radius: 16rpx;
margin-right: 10rpx;
font-weight: 400;
}
/* ===== 列表背景图 ===== */
.tl-item {
position: relative;
overflow: hidden;
}
.tl-bg-wrap {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
border-radius: 24rpx;
overflow: hidden;
}
.tl-bg {
width: 100%;
height: 100%;
}
.tl-bg-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.55);
}
.tl-card-has-bg {
background: transparent !important;
border-color: rgba(255,255,255,0.3) !important;
position: relative;
z-index: 1;
color: #fff;
}
.tl-card-has-bg .tl-title,
.tl-card-has-bg .tl-name,
.tl-card-has-bg .tl-note,
.tl-card-has-bg .tl-location,
.tl-card-has-bg .tl-date {
color: #fff !important;
}
.tl-card-has-bg .tl-repeat {
background: rgba(255,255,255,0.2);
color: rgba(255,255,255,0.85);
}
.tl-card-has-bg .tl-days {
background: rgba(255,255,255,0.2);
color: #fff !important;
}
.tl-card-has-bg .tl-card-head:first-of-type {
border-top: none;
}
.tl-card-has-bg .tl-days-wrap {
border-top: none;
}
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
.tl-card { background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:16rpx; box-shadow:none; }.tl-card-countdown { background:#FFFDF8; border-color:#D9BBB0; }.tl-creator { flex-wrap:wrap; align-items:center; }.tl-item.right .tl-creator { flex-direction:row; }.tl-name { max-width:240rpx; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }.tl-card-head,.tl-days-wrap { justify-content:flex-start!important; }.tl-title { overflow-wrap:break-word; }.tl-days-countdown { color:#B74768; background:#F4E2E3; }
+209
View File
@@ -0,0 +1,209 @@
const { getLayout } = require('../../utils/layout');
// pages/unbind/unbind.js - 情侣解绑确认页
const { api } = require('../../utils/request');
Page({
onResize: function() { this.setData(getLayout()); },
data: {
loading: true, loadError: false,
status: 'none',
isRequester: false,
requesterName: '',
remaining: 0,
timerText: '',
createdTime: '',
respondedTime: '',
_timer: null,
},
onLoad: function() {
this.setData(getLayout());
},
onShow: function() {
this._visible = true;
// 每次页面显示时重新加载状态(对方可能已经操作了)
this.loadStatus();
},
onHide: function() { this._visible = false; this.stopTimers(); },
onUnload: function() { this._visible = false; this.stopTimers(); },
stopTimers: function() {
if (this.data._timer) clearInterval(this.data._timer);
if (this.data._pollTimer) clearInterval(this.data._pollTimer);
this.data._timer = null;
this.data._pollTimer = null;
},
loadStatus: function() {
if (this._statusLoading) return;
this._statusLoading = true;
var self = this;
self.stopTimers();
self.setData({ loading: true, loadError:false });
api.unbindStatus().then(function(res) {
var status = res.status || 'none';
var remaining = res.remaining_seconds || 0;
var timerText = self.formatRemaining(remaining);
var createdTime = res.created_at ? res.created_at.substring(0, 16).replace('T', ' ') : '';
var respondedTime = res.responded_at ? res.responded_at.substring(0, 16).replace('T', ' ') : '';
self.setData({
loading: false,
status: status,
isRequester: !!res.is_requester,
requesterName: res.requester_name || '对方',
remaining: remaining,
timerText: timerText,
createdTime: createdTime,
respondedTime: respondedTime,
});
if (!self._visible) return;
// 清除之前的轮询
if (self.data._pollTimer) {
clearInterval(self.data._pollTimer);
self.data._pollTimer = null;
}
// 如果状态为 agreed(双方已同意解绑),清除缓存后跳转到登录页
if (status === 'agreed') {
wx.removeStorageSync('coupleId');
wx.removeStorageSync('petHasPet');
wx.showToast({ title: '情侣关系已解除', icon: 'none' });
setTimeout(function() {
wx.reLaunch({ url: '/pages/index/index' });
}, 2000);
return;
}
// 启动倒计时
if (status === 'pending' && remaining > 0) {
self.startTimer(remaining);
// 如果是发起方,启动轮询检查对方是否已同意
if (self.data.isRequester) {
self.data._pollTimer = setInterval(function() {
api.unbindStatus().then(function(pollRes) {
if (pollRes.status === 'agreed') {
clearInterval(self.data._pollTimer);
self.data._pollTimer = null;
wx.removeStorageSync('coupleId');
wx.removeStorageSync('petHasPet');
wx.showToast({ title: '对方已同意解绑,正在跳转...', icon: 'none' });
setTimeout(function() {
wx.reLaunch({ url: '/pages/index/index' });
}, 2000);
}
}).catch(function() {});
}, 5000);
}
}
}).catch(function(err) {
self.setData({ loading: false, loadError:true });
}).finally(function() { self._statusLoading = false; });
},
startTimer: function(seconds) {
var self = this;
if (self.data._timer) clearInterval(self.data._timer);
self.data._remaining = seconds;
self.data._timer = setInterval(function() {
self.data._remaining--;
if (self.data._remaining <= 0) {
clearInterval(self.data._timer);
self.setData({ status: 'expired', timerText: '已过期' });
return;
}
self.setData({ timerText: self.formatRemaining(self.data._remaining) });
}, 1000);
},
formatRemaining: function(seconds) {
if (seconds <= 0) return '已过期';
var h = Math.floor(seconds / 3600);
var m = Math.floor((seconds % 3600) / 60);
var s = seconds % 60;
return h + '时 ' + m + '分 ' + s + '秒';
},
// 同意解绑
agreeUnbind: function() {
var self = this;
wx.showModal({
title: '💔 确认解绑',
content: '确定同意解除情侣绑定吗?\n\n确认后所有情侣记录(记忆日、日记、相册、歌曲、打卡)将被永久删除,且不可恢复!\n\n仅保留双方的个人资料。',
cancelText: '再想想',
confirmText: '确认解绑',
confirmColor: '#FF4757',
success: function(res) {
if (res.confirm) {
wx.showLoading({ title: '处理中...' });
api.unbindRespond({ agree: true }).then(function() {
wx.hideLoading();
wx.showToast({ title: '已解除绑定', icon: 'success' });
wx.removeStorageSync('coupleId');
wx.removeStorageSync('petHasPet');
self.setData({ status: 'agreed' });
setTimeout(function() { wx.reLaunch({ url: '/pages/index/index' }); }, 1500);
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
},
// 拒绝解绑
rejectUnbind: function() {
var self = this;
wx.showModal({
title: '拒绝解绑',
content: '确定拒绝对方的解绑请求吗?',
cancelText: '取消',
confirmText: '拒绝解绑',
success: function(res) {
if (res.confirm) {
wx.showLoading({ title: '处理中...' });
api.unbindRespond({ agree: false }).then(function() {
wx.hideLoading();
wx.showToast({ title: '已拒绝解绑', icon: 'success' });
self.setData({ status: 'rejected' });
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
},
// 撤销解绑请求
cancelUnbind: function() {
var self = this;
wx.showModal({
title: '撤销请求',
content: '确定撤销解绑请求吗?',
cancelText: '取消',
confirmText: '撤销',
success: function(res) {
if (res.confirm) {
wx.showLoading({ title: '处理中...' });
api.unbindCancel().then(function() {
wx.hideLoading();
wx.showToast({ title: '已撤销', icon: 'success' });
self.setData({ status: 'cancelled' });
}).catch(function(err) {
wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' });
});
}
}
});
},
goBack: function() { wx.navigateBack(); },
goHome: function() { wx.reLaunch({ url: '/pages/index/index' }); },
goToProfile: function() { wx.reLaunch({ url: '/pages/profile/profile' }); },
});
+8
View File
@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "解除绑定",
"navigationStyle": "custom",
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
}
}
+103
View File
@@ -0,0 +1,103 @@
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/unbind/unbind.wxml - 情侣解绑确认页 -->
<app-nav title="解绑" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="container" style="padding-top:120rpx;">
<!-- 加载中 -->
<page-state wx:if="{{loading}}" kind="loading" title="正在获取解绑状态" /><page-state wx:elif="{{loadError}}" kind="error" title="解绑状态加载失败" action="重试" bind:action="loadStatus" />
<!-- 无解绑请求 -->
<view class="empty-state" wx:elif="{{!loading && !loadError && status == 'none'}}">
<text class="empty-icon">💕</text>
<text class="empty-title">当前没有解绑请求</text>
<text class="empty-desc">在设置页面可以发起解绑</text>
</view>
<!-- 待对方确认(我是发起方) -->
<view class="unbind-card" wx:elif="{{!loading && !loadError && status == 'pending' && isRequester}}">
<view class="unbind-icon">⏳</view>
<text class="unbind-title">解绑请求已发送</text>
<text class="unbind-desc">等待对方确认同意解绑</text>
<view class="unbind-timer" wx:if="{{remaining > 0}}">
<text class="timer-label">剩余时间</text>
<text class="timer-value">{{timerText}}</text>
</view>
<view class="unbind-info">
<text>发起时间:{{createdTime}}</text>
<text>状态:待对方确认</text>
</view>
<button class="btn btn-outline" bindtap="cancelUnbind" style="margin-top:32rpx;border-color:#FF4757;color:#FF4757;">撤销解绑请求</button>
</view>
<!-- 待我确认(我是对方) -->
<view class="unbind-card" wx:elif="{{!loading && !loadError && status == 'pending' && !isRequester}}">
<view class="unbind-icon">💔</view>
<text class="unbind-title">{{requesterName}} 发起了解绑请求</text>
<text class="unbind-desc">对方希望解除情侣绑定,请确认或拒绝</text>
<view class="unbind-timer" wx:if="{{remaining > 0}}">
<text class="timer-label">剩余时间</text>
<text class="timer-value">{{timerText}}</text>
</view>
<view class="unbind-info">
<text>发起时间:{{createdTime}}</text>
<text>⚠️ 确认解绑后,所有情侣记录将被永久删除</text>
</view>
<view class="unbind-actions" style="margin-top:32rpx;">
<button class="btn btn-primary" bindtap="agreeUnbind" style="background:#FF4757;">💔 同意解绑</button>
<button class="btn btn-outline" bindtap="rejectUnbind" style="margin-top:16rpx;">拒绝解绑</button>
</view>
</view>
<!-- 已同意解绑 -->
<view class="unbind-card" wx:elif="{{!loading && !loadError && status == 'agreed'}}">
<view class="unbind-icon">💔</view>
<text class="unbind-title">已同意解绑</text>
<text class="unbind-desc">双方已确认解除情侣绑定,所有情侣数据已被永久删除</text>
<view class="unbind-info">
<text>回应时间:{{respondedTime}}</text>
</view>
<button class="btn btn-primary" bindtap="goHome" style="margin-top:32rpx;">返回首页</button>
</view>
<!-- 已拒绝解绑 -->
<view class="unbind-card" wx:elif="{{!loading && !loadError && status == 'rejected'}}">
<view class="unbind-icon">💕</view>
<text class="unbind-title">解绑请求已被拒绝</text>
<text class="unbind-desc">对方拒绝了你的解绑请求</text>
<view class="unbind-info">
<text>拒绝时间:{{respondedTime}}</text>
</view>
<button class="btn btn-primary" bindtap="goBack" style="margin-top:32rpx;">返回</button>
</view>
<!-- 已撤销 -->
<view class="unbind-card" wx:elif="{{!loading && !loadError && status == 'cancelled'}}">
<view class="unbind-icon">💕</view>
<text class="unbind-title">解绑请求已撤销</text>
<text class="unbind-desc">发起方已撤销了解绑请求</text>
<view class="unbind-info">
<text>撤销时间:{{respondedTime}}</text>
</view>
<button class="btn btn-primary" bindtap="goBack" style="margin-top:32rpx;">返回</button>
</view>
<!-- 已过期 -->
<view class="unbind-card" wx:elif="{{!loading && !loadError && status == 'expired'}}">
<view class="unbind-icon">⏰</view>
<text class="unbind-title">解绑请求已过期</text>
<text class="unbind-desc">超过72小时未回应,解绑请求已自动取消</text>
<view class="unbind-info">
<text>发起时间:{{createdTime}}</text>
</view>
<button class="btn btn-primary" bindtap="goBack" style="margin-top:32rpx;">返回</button>
</view>
</view>
</scroll-view>
</view>
+87
View File
@@ -0,0 +1,87 @@
/* pages/unbind/unbind.wxss - 情侣解绑确认页 */
@import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; }
.container { padding: 24rpx; }
/* 加载状态 */
.loading-state { text-align: center; padding: 200rpx 40rpx; }
.loading-icon { font-size: 64rpx; display: block; margin-bottom: 16rpx; }
.loading-text { font-size: 28rpx; color: #999; }
/* 空状态 */
.empty-state { text-align: center; padding: 200rpx 40rpx; }
.empty-icon { font-size: 80rpx; display: block; margin-bottom: 16rpx; }
.empty-title { font-size: 30rpx; color: #999; display: block; margin-bottom: 8rpx; }
.empty-desc { font-size: 24rpx; color: #bbb; display: block; }
/* 解绑卡片 */
.unbind-card {
background: white;
border-radius: 28rpx;
padding: 48rpx 36rpx;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
text-align: center;
margin-top: 60rpx;
}
.unbind-icon { font-size: 80rpx; display: block; margin-bottom: 24rpx; }
.unbind-title {
font-size: 34rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 12rpx;
}
.unbind-desc {
font-size: 26rpx;
color: #888;
display: block;
line-height: 1.6;
margin-bottom: 28rpx;
}
/* 倒计时 */
.unbind-timer {
background: #FFF8E1;
border-radius: 20rpx;
padding: 20rpx;
margin-bottom: 24rpx;
}
.timer-label {
font-size: 24rpx;
color: #FF9800;
display: block;
margin-bottom: 8rpx;
}
.timer-value {
font-size: 48rpx;
font-weight: 800;
color: #FF5722;
display: block;
font-variant-numeric: tabular-nums;
}
/* 信息区 */
.unbind-info {
background: #F9F9F9;
border-radius: 16rpx;
padding: 20rpx;
text-align: left;
}
.unbind-info text {
font-size: 24rpx;
color: #888;
display: block;
margin-bottom: 6rpx;
line-height: 1.5;
}
.unbind-info text:last-child { margin-bottom: 0; }
/* 按钮区域 */
.unbind-actions { display: flex; flex-direction: column; gap: 12rpx; }
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';