feat: 存储空间管理、虚拟支付接入与容量守卫

- 新增存储空间管理:会员套餐配置、用户容量/到期管理、后台支付记录查询
- 接入个人主体小程序虚拟支付:下单/发货推送/兜底查单(query_order)
- 修复 adminStorageUsers 等接口鉴权、out_trade_no 长度、前端 requestVirtualPayment 参数
- 前端上传前容量预检,超容引导升级无限空间
- 我的页会员卡片展示「无限空间」与已用容量/到期时间
- 后台「存储空间管理」新增支付记录标签页
This commit is contained in:
CodeBuddy
2026-10-26 23:07:09 +08:00
parent 0f5dc01674
commit 3ce6edc9a1
41 changed files with 2387 additions and 108 deletions
+85 -29
View File
@@ -5,12 +5,21 @@ const { getLayout } = require('../../utils/layout');
const { api, getImageUrl } = require('../../utils/request');
const { saveScrollPosition, resetScrollOnTabShow } = require('../../utils/scroll-pos');
const { requestSubscribe } = require('../../utils/subscribe');
const { guardStorageSpace } = require('../../utils/storage-guard');
function formatStorageBytes(bytes) {
bytes = bytes || 0;
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
Page(withDataCache({
onResize: function() { this.setData(getLayout()); },
data: {
scrollTop: 0,
statsLoading:false, statsError:false,
storageText:'—', storagePercent:0, storageOverQuota:false, storageVip:false, storageExpireText:'',
statisticItems:[
{key:'timeline',label:'记忆日',icon:'stat-timeline',count:'—',color:'pink'},
{key:'album',label:'相册',icon:'stat-album',count:'—',color:'yellow'},
@@ -191,6 +200,7 @@ Page(withDataCache({
}
this.loadData();
this.loadProfileStats();
this.loadStorageInfo();
if (!cid && this.data.showInvitePanel && !this._pollTimer) this.startPollCoupleStatus();
// 如果已绑定情侣,关闭邀请面板
if (cid && this.data.showInvitePanel) {
@@ -214,7 +224,7 @@ Page(withDataCache({
},
onPullDownRefresh: async function() {
await Promise.all([this.loadData(),this.loadProfileStats()]);
await Promise.all([this.loadData(),this.loadProfileStats(),this.loadStorageInfo()]);
wx.stopPullDownRefresh();
},
@@ -486,25 +496,29 @@ Page(withDataCache({
},
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' });
const self = this;
guardStorageSpace(0).then(function(ok) {
if (!ok) return;
wx.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const filePath = res.tempFiles[0].path;
self.setData({ editAvatarUrl: filePath });
// 上传头像
try {
const data = await api.uploadAvatar(filePath);
self.setData({
editAvatarUrl: getImageUrl(data.path),
editAvatarPath: data.path
});
wx.showToast({ title: '头像上传成功 ✨', icon: 'success' });
} catch (e) {
wx.showToast({ title: e.error || '上传失败', icon: 'none' });
}
}
}
});
});
},
@@ -640,16 +654,20 @@ Page(withDataCache({
},
choosePartnerAvatar: function() {
if (this.data.partnerSaving || this.data.partnerAvatarUploading || !this._editPartnerId) return;
const self = this;
const partnerId = this._editPartnerId;
wx.chooseImage({count:1,sizeType:['compressed'],sourceType:['album','camera'],success:async res=>{
if (!this.data.showEditPartnerModal || partnerId !== this._editPartnerId) return;
this.setData({partnerAvatarUploading:true});
try {
const uploaded = await api.uploadPartnerAvatar(res.tempFilePaths[0],partnerId);
this.setData({editPartnerAvatarUrl:getImageUrl(uploaded.path),editPartnerAvatarPath:uploaded.path});
} catch(error) { wx.showToast({title:error.error || '头像上传失败',icon:'none'}); }
finally { this.setData({partnerAvatarUploading:false}); }
}});
guardStorageSpace(0).then(function(ok) {
if (!ok) return;
wx.chooseImage({count:1,sizeType:['compressed'],sourceType:['album','camera'],success:async res=>{
if (!self.data.showEditPartnerModal || partnerId !== self._editPartnerId) return;
self.setData({partnerAvatarUploading:true});
try {
const uploaded = await api.uploadPartnerAvatar(res.tempFilePaths[0],partnerId);
self.setData({editPartnerAvatarUrl:getImageUrl(uploaded.path),editPartnerAvatarPath:uploaded.path});
} catch(error) { wx.showToast({title:error.error || '头像上传失败',icon:'none'}); }
finally { self.setData({partnerAvatarUploading:false}); }
}});
});
},
savePartnerProfile: async function() {
const d = this.data;
@@ -1014,6 +1032,44 @@ Page(withDataCache({
this.setData({ showShareBindModal: false });
},
loadStorageInfo: async function() {
try {
const d = await api.storageInfo();
const freeBytes = d.free_bytes || d.freeBytes || 0;
const usedBytes = d.used_bytes || d.usedBytes || 0;
const isVip = !!(d.is_vip || d.isVip);
const usedText = formatStorageBytes(usedBytes);
if (isVip) {
// 会员:展示「无限空间」+ 已用容量 + 到期时间,不再提示空间已满。
const expire = d.vip_expire_at || d.vipExpireAt;
this.setData({
storageVip: true,
storageText: '无限空间',
storagePercent: 0,
storageOverQuota: false,
storageUsedText: usedText,
storageExpireText: expire ? ('有效期至 ' + String(expire).slice(0, 10)) : '已开通无限空间'
});
} else {
const percent = freeBytes > 0 ? Math.min(100, Math.round(usedBytes / freeBytes * 100)) : 0;
this.setData({
storageVip: false,
storageText: usedText,
storagePercent: percent,
storageOverQuota: usedBytes > freeBytes,
storageUsedText: usedText,
storageExpireText: ''
});
}
} catch (e) {
console.error('加载存储信息失败', e);
}
},
goStorage: function() {
this._savePosBeforeLeave();
wx.navigateTo({ url: '/pages/storage/storage' });
},
loadProfileStats: async function() {
this.setData({statsLoading:true,statsError:false});
const keys=['timeline','album','diary','songs','checkin','pet'];
@@ -1046,4 +1102,4 @@ Page(withDataCache({
manageRelationship: function() { wx.navigateTo({ url: '/pages/unbind/unbind' }); },
goToSettings: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/settings/settings' }); },
goHome: function() { this._savePosBeforeLeave(); wx.switchTab({ url: '/pages/index/index' }); }
}, {"loadData": ["coupleInfo"], "loadProfileStats": ["timelineList","albumList","diaryList","songsList","checkinList","petInfo"]}));
}, {"loadData": ["coupleInfo"], "loadProfileStats": ["timelineList","albumList","diaryList","songsList","checkinList","petInfo"], "loadStorageInfo": ["storageInfo"]}));
+21
View File
@@ -10,6 +10,27 @@
<view wx:if="{{bindingKnown && hasPartner}}" class="j-relation-card" bindtap="showEditPartnerModal"><view class="j-row"><view><text class="j-small-title">{{partnerName}}</text><text class="j-caption" wx:if="{{partnerDisplayId}}">ID {{partnerDisplayId}}</text><text class="j-caption">{{partnerDetail}}</text></view><view class="j-avatar"><image wx:if="{{partnerAvatarUrl}}" src="{{partnerAvatarUrl}}" mode="aspectFill" /><text wx:else>{{partnerInitial}}</text></view></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 wx:if="{{storageVip}}" class="j-storage-card j-storage-vip" bindtap="goStorage">
<view class="j-storage-top">
<text class="j-storage-label">存储空间</text>
<text class="j-storage-val j-vip">👑 无限空间</text>
</view>
<view class="j-storage-bottom">
<text class="j-storage-hint">已用 {{storageUsedText}}{{storageExpireText ? ' · ' + storageExpireText : ''}}</text>
<text class="j-storage-cta">查看 ›</text>
</view>
</view>
<view wx:else class="j-storage-card" bindtap="goStorage">
<view class="j-storage-top">
<text class="j-storage-label">存储空间</text>
<text class="j-storage-val">{{storageText}}</text>
</view>
<view class="j-storage-bar"><view class="j-storage-fill" style="width:{{storagePercent}}%;"></view></view>
<view class="j-storage-bottom">
<text class="j-storage-hint">{{storageOverQuota ? '已超出免费空间,升级后可继续上传' : '已用 ' + storagePercent + '% 免费空间'}}</text>
<text class="j-storage-cta">{{storageOverQuota ? '升级无限空间 ›' : '查看 ›'}}</text>
</view>
</view>
<view class="profile-statistics">
<view class="profile-stat-grid">
<view class="profile-stat-item" wx:for="{{statisticItems}}" wx:key="key" data-key="{{item.key}}" bindtap="openStatistic" hover-class="profile-stat-pressed">
+18
View File
@@ -324,6 +324,24 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll
.profile-stat-pressed { opacity:.65; }
.profile-stats-retry { text-align:center; color:#B74768; font-size:22rpx; padding:24rpx 0 0; }
/* 存储空间入口卡片。 */
.j-storage-card { margin-top:32rpx; padding:26rpx 30rpx; background:#FFFDF8; border:1rpx solid #F1DCE4; border-radius:32rpx; }
.j-storage-top { display:flex; justify-content:space-between; align-items:baseline; }
.j-storage-label { font-family:'Kaiti SC','STKaiti','KaiTi',cursive; font-size:28rpx; color:#64424D; }
.j-storage-val { font-size:26rpx; font-weight:600; color:#B74768; }
.j-storage-bar { margin-top:18rpx; height:16rpx; background:#F5E9ED; border-radius:999rpx; overflow:hidden; }
.j-storage-fill { height:100%; background:linear-gradient(90deg,#FF9EB5,#B74768); border-radius:999rpx; transition:width .4s ease; }
.j-storage-bottom { margin-top:14rpx; display:flex; justify-content:space-between; align-items:center; }
.j-storage-hint { font-size:22rpx; color:#A39386; }
.j-storage-cta { font-size:24rpx; color:#B74768; font-weight:500; }
/* 会员(无限空间)卡片:玫红渐变,突出「无限空间」与到期时间。 */
.j-storage-vip { background:linear-gradient(135deg,#FF9EB5,#B74768); border:1rpx solid rgba(255,255,255,.4); border-radius:32rpx; }
.j-storage-vip .j-storage-label { color:#FFF; }
.j-storage-vip .j-storage-val.j-vip { color:#FFF; font-size:28rpx; }
.j-storage-vip .j-storage-hint { color:rgba(255,255,255,.9); }
.j-storage-vip .j-storage-cta { color:#FFF; }
/* 本人与对方资料卡共用完整样式,整卡点击编辑。 */
.j-body > .j-profile-summary,
.j-body > .j-relation-card {