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

- 新增存储空间管理:会员套餐配置、用户容量/到期管理、后台支付记录查询
- 接入个人主体小程序虚拟支付:下单/发货推送/兜底查单(query_order)
- 修复 adminStorageUsers 等接口鉴权、out_trade_no 长度、前端 requestVirtualPayment 参数
- 前端上传前容量预检,超容引导升级无限空间
- 我的页会员卡片展示「无限空间」与已用容量/到期时间
- 后台「存储空间管理」新增支付记录标签页
This commit is contained in:
CodeBuddy
2026-10-30 23:45:39 +08:00
parent b7c6224e54
commit bf99e52937
41 changed files with 2387 additions and 108 deletions
+1
View File
@@ -9,6 +9,7 @@
"pages/album/album",
"pages/lovewords/lovewords",
"pages/settings/settings",
"pages/storage/storage",
"pages/history/history",
"pages/profile/profile",
"pages/publish/publish",
+21 -14
View File
@@ -6,6 +6,7 @@ const { getLayout } = require('../../utils/layout');
// pages/album/album.js - 日记式分组相册
const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request');
const { requestSubscribeAfterAction } = require('../../utils/subscribe');
const { guardStorageSpace } = require('../../utils/storage-guard');
Page(withDataCache(withListPagination({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
@@ -202,7 +203,9 @@ Page(withDataCache(withListPagination({
chooseImage: function() {
var self = this;
wx.chooseImage({
guardStorageSpace(0).then(function(ok) {
if (!ok) return;
wx.chooseImage({
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
@@ -229,6 +232,7 @@ Page(withDataCache(withListPagination({
console.error('Choose image failed:', err);
wx.showToast({ title: '选择照片失败', icon: 'none' });
}
});
});
},
onUploadInput: function(e) {
@@ -273,19 +277,22 @@ Page(withDataCache(withListPagination({
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)
});
}
guardStorageSpace(0).then(function(ok) {
if (!ok) return;
var current = self.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)
});
}
});
});
},
+7 -3
View File
@@ -4,6 +4,7 @@ const { getLayout } = require('../../utils/layout');
const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request');
const { createSwipeBack } = require('../../utils/util');
const { requestSubscribeAfterAction } = require('../../utils/subscribe');
const { guardStorageSpace } = require('../../utils/storage-guard');
const COOLDOWN = 5000;
Page(withDataCache({
onResize: function() { this.setData(getLayout()); },
@@ -163,9 +164,12 @@ Page(withDataCache({
chooseCheckPhoto: function() {
var self = this;
wx.chooseImage({
count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'],
success: function(res) { self.setData({ checkPhoto: res.tempFilePaths[0] }); }
guardStorageSpace(0).then(function(ok) {
if (!ok) return;
wx.chooseImage({
count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'],
success: function(res) { self.setData({ checkPhoto: res.tempFilePaths[0] }); }
});
});
},
+11 -7
View File
@@ -1,5 +1,6 @@
const { getLayout } = require('../../utils/layout');
const { api, API_BASE, getImageUrl, uploadFileCompat } = require('../../utils/request');
const { guardStorageSpace } = require('../../utils/storage-guard');
var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '😎', '🤔', '😭', '😋', '😌'];
var WEATHER_OPTIONS = ['☀️', '⛅', '☁️', '🌧️', '⛈️', '❄️', '🌤️', '🌥️', '🌈', '🌪️', '🌫️', '🌙'];
@@ -71,13 +72,16 @@ Page({
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) });
}
guardStorageSpace(0).then(function(ok) {
if (!ok) return;
wx.chooseImage({
count: 9 - self.data.tempImages.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: function(res) {
self.setData({ tempImages: self.data.tempImages.concat(res.tempFilePaths) });
}
});
});
},
+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 {
+34 -24
View File
@@ -3,6 +3,7 @@ const { getLayout } = require('../../utils/layout');
const { api, API_BASE, uploadFileCompat } = require('../../utils/request');
const { createSwipeBack } = require('../../utils/util');
const { requestSubscribeAfterAction } = require('../../utils/subscribe');
const { guardStorageSpace } = require('../../utils/storage-guard');
var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '😎', '🤔', '😭', '😋', '😌'];
var WEATHER_OPTIONS = ['☀️', '⛅', '☁️', '🌧️', '⛈️', '❄️', '🌤️', '🌥️', '🌈', '🌪️', '🌫️', '🌙'];
@@ -293,28 +294,34 @@ Page({
// ===== 封面图片选择(相册)=====
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();
}
guardStorageSpace(0).then(function(ok) {
if (!ok) return;
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();
}
guardStorageSpace(0).then(function(ok) {
if (!ok) return;
wx.chooseImage({
count: 9 - self.data.tempImages.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: function(res) {
self.setData({ tempImages: self.data.tempImages.concat(res.tempFilePaths) });
self._saveDraft();
}
});
});
},
@@ -534,14 +541,17 @@ Page({
// ===== 背景图 =====
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: '' });
}
guardStorageSpace(0).then(function(ok) {
if (!ok) return;
wx.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: function(res) {
var filePath = res.tempFilePaths[0];
self.setData({ bgImageUrl: filePath, bgImagePath: '' });
}
});
});
},
+8 -4
View File
@@ -2,6 +2,7 @@ const { getLayout } = require('../../utils/layout');
// pages/settings/settings.js
const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request');
const { requestSubscribe, getSubscribeStatus } = require('../../utils/subscribe');
const { guardStorageSpace } = require('../../utils/storage-guard');
Page({
onResize: function() { this.setData(getLayout()); },
data: {
@@ -196,10 +197,13 @@ Page({
},
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] });
}
guardStorageSpace(0).then(function(ok) {
if (!ok) return;
wx.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'],
success: function(res) {
self.setData({ bgPreview: res.tempFilePaths[0], bgFilePath: res.tempFilePaths[0] });
}
});
});
},
saveBgImage: async function() {
+134
View File
@@ -0,0 +1,134 @@
const { getLayout } = require('../../utils/layout');
const { api } = require('../../utils/request');
function formatBytes(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({
onResize: function () { this.setData(getLayout()); },
data: {
loading: true,
error: false,
usedBytes: 0,
usedText: '0 MB',
freeMb: 200,
freeText: '200 MB',
percent: 0,
overQuota: false,
isVip: false,
vipExpireText: '',
plans: [],
paying: false,
},
onLoad: function () { this.setData(getLayout()); },
onShow: function () { this.loadInfo(); },
onPullDownRefresh: function () { this.loadInfo().then(function () { wx.stopPullDownRefresh(); }); },
loadInfo: function () {
var self = this;
this.setData({ loading: true, error: false });
return api.storageInfo().then(function (d) {
var freeBytes = d.free_bytes || d.freeBytes || 0;
var usedBytes = d.used_bytes || d.usedBytes || 0;
var percent = freeBytes > 0 ? Math.min(100, Math.round(usedBytes / freeBytes * 100)) : 0;
var overQuota = usedBytes > freeBytes;
var plans = (d.plans || []).map(function (p) {
var cents = p.price_cents || p.priceCents || 0;
return {
key: p.key,
name: p.name,
priceText: (cents / 100).toFixed(cents % 100 === 0 ? 0 : 1),
unit: p.key === 'year' ? '年' : '月',
productId: p.product_id || p.productId,
};
});
var vipExpireText = '';
if (d.is_vip || d.isVip) {
var expire = d.vip_expire_at || d.vipExpireAt ? String(d.vip_expire_at || d.vipExpireAt).slice(0, 10) : '';
vipExpireText = expire ? ('有效期至 ' + expire) : '已开通无限空间';
}
self.setData({
loading: false,
usedBytes: usedBytes,
usedText: formatBytes(usedBytes),
freeMb: d.free_mb || d.freeMb || 200,
freeText: formatBytes(freeBytes),
percent: percent,
overQuota: overQuota,
isVip: !!(d.is_vip || d.isVip),
vipExpireText: vipExpireText,
plans: plans,
});
}).catch(function (e) {
self.setData({ loading: false, error: true });
console.error('加载存储信息失败', e);
});
},
buyPlan: function (e) {
var key = e.currentTarget.dataset.key;
var self = this;
if (this.data.paying) return;
var plan = null;
for (var i = 0; i < this.data.plans.length; i++) {
if (this.data.plans[i].key === key) { plan = this.data.plans[i]; break; }
}
if (!plan) return;
wx.showLoading({ title: '下单中...', mask: true });
this.setData({ paying: true });
api.storageCreateOrder({ plan_key: key }).then(function (order) {
wx.hideLoading();
if (!order || !(order.sign_data || order.signData)) {
self.setData({ paying: false });
wx.showToast({ title: '下单失败', icon: 'none' });
return;
}
self.callVirtualPay(order);
}).catch(function (err) {
wx.hideLoading();
self.setData({ paying: false });
wx.showToast({ title: (err && err.error) || '下单失败', icon: 'none' });
});
},
callVirtualPay: function (order) {
var self = this;
if (!wx.requestVirtualPayment) {
this.setData({ paying: false });
wx.showModal({
title: '版本过低',
content: '请升级微信至最新版本后再试。',
showCancel: false
});
return;
}
// wx.requestVirtualPayment 仅接受 signData / paySig / signature / mode 四个参数;
// offerId、env、productId、goodsPrice 等均已编码在 signData JSON 字符串内,不可再平铺为顶层参数。
wx.requestVirtualPayment({
signData: order.sign_data || order.signData,
paySig: order.pay_sig || order.paySig,
signature: order.signature,
mode: order.mode || 'short_series_goods',
success: function () {
self.setData({ paying: false });
wx.showToast({ title: '支付成功', icon: 'success' });
setTimeout(function () { self.loadInfo(); }, 1200);
},
fail: function (err) {
self.setData({ paying: false });
var msg = '支付未完成';
var m = (err && err.errMsg) || '';
if (m.indexOf('cancel') >= 0) msg = '已取消支付';
else if (m.indexOf('fail') >= 0) msg = '支付失败,请稍后重试';
wx.showToast({ title: msg, icon: 'none' });
}
});
},
});
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
},
"enablePullDownRefresh": true
}
+52
View File
@@ -0,0 +1,52 @@
<view class="app-shell app-shell--secondary j-handwritten-page" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/storage/storage.wxml -->
<app-nav title="存储空间" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="j-body">
<!-- 已用空间 -->
<view class="storage-card">
<view class="storage-card-head">
<view class="storage-title">已用空间</view>
<view class="storage-vip" wx:if="{{isVip}}">👑 无限空间</view>
</view>
<view class="storage-used">
<text class="storage-num">{{usedText}}</text>
<text class="storage-of" wx:if="{{!isVip}}"> / {{freeText}} 免费</text>
</view>
<view class="storage-bar" wx:if="{{!isVip}}">
<view class="storage-bar-fill" style="width: {{percent}}%;"></view>
</view>
<view class="storage-hint" wx:if="{{isVip}}">{{vipExpireText}}</view>
<view class="storage-hint over" wx:elif="{{overQuota}}">已超出免费空间,升级后即可继续上传</view>
<view class="storage-hint" wx:else>已使用 {{percent}}% 免费空间</view>
</view>
<!-- 套餐 -->
<view class="section-heading">升级无限空间</view>
<view class="plan-list">
<view class="plan-card" wx:for="{{plans}}" wx:key="key" bindtap="buyPlan" data-key="{{item.key}}">
<view class="plan-info">
<view class="plan-name">{{item.name}}</view>
<view class="plan-desc">无限存储空间,照片视频想存就存</view>
</view>
<view class="plan-price">
<text class="plan-amount">¥{{item.priceText}}</text>
<text class="plan-unit">/{{item.unit}}</text>
</view>
</view>
</view>
<!-- 说明 -->
<view class="storage-note">
<view class="note-title">📌 说明</view>
<view class="note-item">• 默认提供 {{freeMb}}MB 免费空间</view>
<view class="note-item">• 开通会员后,照片、视频等不再限制存储空间</view>
<view class="note-item">• 会员到期后,已上传内容仍可正常查看</view>
</view>
<page-state wx:if="{{error}}" kind="error" title="加载失败" action="重试" bind:action="loadInfo" />
</view>
</scroll-view>
</view>
+157
View File
@@ -0,0 +1,157 @@
/* pages/storage/storage.wxss */
@import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%); height: 100vh; overflow: hidden; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
/* 已用空间卡片 */
.storage-card {
background: #FFFFFF;
border-radius: 28rpx;
padding: 36rpx 32rpx;
margin: 16rpx 0 8rpx;
box-shadow: 0 8rpx 32rpx rgba(183, 71, 104, 0.08);
border: 2rpx solid rgba(255, 107, 157, 0.10);
}
.storage-card-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.storage-title {
font-size: 28rpx;
font-weight: 600;
color: #666;
}
.storage-vip {
font-size: 24rpx;
font-weight: 600;
color: #B74768;
background: #FFE4EC;
padding: 6rpx 18rpx;
border-radius: 999rpx;
}
.storage-used {
margin-top: 16rpx;
display: flex;
align-items: baseline;
}
.storage-num {
font-size: 56rpx;
font-weight: 700;
color: #B74768;
line-height: 1;
}
.storage-of {
font-size: 26rpx;
color: #999;
margin-left: 10rpx;
}
.storage-bar {
margin-top: 24rpx;
height: 16rpx;
background: #F5E9ED;
border-radius: 999rpx;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
background: linear-gradient(90deg, #FF9EB5, #B74768);
border-radius: 999rpx;
transition: width 0.4s ease;
}
.storage-hint {
margin-top: 16rpx;
font-size: 24rpx;
color: #999;
}
.storage-hint.over {
color: #FF4757;
font-weight: 500;
}
/* 套餐卡片 */
.plan-list {
margin-top: 8rpx;
}
.plan-card {
display: flex;
align-items: center;
justify-content: space-between;
background: #FFFFFF;
border-radius: 24rpx;
padding: 30rpx 28rpx;
margin-bottom: 20rpx;
box-shadow: 0 6rpx 24rpx rgba(183, 71, 104, 0.06);
border: 2rpx solid rgba(255, 107, 157, 0.10);
transition: all 0.3s ease;
}
.plan-card:active {
transform: scale(0.98);
border-color: rgba(183, 71, 104, 0.35);
background: #FFF8FA;
}
.plan-info {
flex: 1;
min-width: 0;
margin-right: 16rpx;
}
.plan-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
display: block;
}
.plan-desc {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
display: block;
}
.plan-price {
display: flex;
align-items: baseline;
flex-shrink: 0;
}
.plan-amount {
font-size: 40rpx;
font-weight: 700;
color: #B74768;
}
.plan-unit {
font-size: 22rpx;
color: #999;
margin-left: 4rpx;
}
/* 说明 */
.storage-note {
background: #FFF9F4;
border-radius: 20rpx;
padding: 24rpx 28rpx;
margin-top: 12rpx;
border: 2rpx solid rgba(255, 107, 157, 0.12);
}
.note-title {
font-size: 26rpx;
font-weight: 700;
color: #B74768;
display: block;
margin-bottom: 14rpx;
}
.note-item {
font-size: 24rpx;
color: #666;
display: block;
line-height: 1.9;
}
.section-heading {
font-family: 'Kaiti SC', 'STKaiti', 'KaiTi', cursive;
font-weight: 400;
letter-spacing: 1rpx;
}
@import '../../styles/design.wxss';
@import '../../styles/journal.wxss';
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+26
View File
@@ -63,6 +63,25 @@ function formatUploadError(data) {
return msg;
}
// 存储配额错误码(与后端一致),上传超限时引导用户付费。
var STORAGE_QUOTA_EXCEEDED = 10001;
function isStorageQuotaError(data) {
return !!(data && data.code === STORAGE_QUOTA_EXCEEDED);
}
function maybeRedirectStorage(data) {
if (!isStorageQuotaError(data)) return false;
wx.showModal({
title: '存储空间不足',
content: '免费空间已用完,升级为无限空间会员后即可继续上传。',
confirmText: '去升级',
cancelText: '暂不',
success: function (res) {
if (res.confirm) wx.navigateTo({ url: '/pages/storage/storage' });
}
});
return true;
}
/**
* 通用请求方法
* @param {string} action - API action 名称
@@ -219,6 +238,7 @@ const api = {
);
wx.hideLoading();
if (data && !data.error && (data.path || data.success)) { dataCache.mutation('uploadAvatar', data); return data; }
maybeRedirectStorage(data);
wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
@@ -239,6 +259,7 @@ const api = {
);
wx.hideLoading();
if (data && !data.error && (data.path || data.success)) { dataCache.mutation('uploadAvatar', data); return data; }
maybeRedirectStorage(data);
wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
@@ -336,6 +357,7 @@ const api = {
});
wx.hideLoading();
if (data && !data.error && (data.success || data.path || data.uploaded)) { dataCache.mutation('photoUpload', data); return data; }
maybeRedirectStorage(data);
wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
@@ -376,6 +398,10 @@ const api = {
getBgConfig: () => request('getBgConfig', null, 'GET'),
deleteBgImage: () => request('deleteBgImage', {}),
// 存储空间
storageInfo: () => request('storageInfo', null, 'GET'),
storageCreateOrder: (data) => request('storageCreateOrder', data),
// 操作记录
activityLogList: (data) => request('activityLogList', data, 'GET'),
+43
View File
@@ -0,0 +1,43 @@
// utils/storage-guard.js
// 存储空间统一拦截:上传照片/图片前检查剩余空间,超出时在按钮处直接阻止上传。
const { api } = require('./request');
// 判断是否超出配额(会员无限空间,不拦截)
function quotaExceeded(d, extraBytes) {
if (!d) return false;
const isVip = d.is_vip || d.isVip;
if (isVip) return false;
const free = d.free_bytes || d.freeBytes || 0;
const used = d.used_bytes || d.usedBytes || 0;
return used + (extraBytes || 0) > free;
}
// 空间不足时的统一提示
function showQuotaBlocked() {
wx.showModal({
title: '存储空间不足',
content: '当前存储空间已满,请升级为无限空间会员后再上传照片。',
confirmText: '去升级',
cancelText: '取消',
success: function (res) {
if (res.confirm) wx.navigateTo({ url: '/pages/storage/storage' });
}
});
}
// 上传前检查:空间充足 resolve(true);不足则弹窗提示并 resolve(false);查询失败不阻断上传(由服务端二次校验兜底)。
function guardStorageSpace(extraBytes) {
return api.storageInfo()
.then(function (d) {
if (quotaExceeded(d, extraBytes || 0)) {
showQuotaBlocked();
return false;
}
return true;
})
.catch(function () {
return true;
});
}
module.exports = { guardStorageSpace, quotaExceeded, showQuotaBlocked };