diff --git a/weapp/app.json b/weapp/app.json
index cc4ef18..52047cd 100644
--- a/weapp/app.json
+++ b/weapp/app.json
@@ -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",
diff --git a/weapp/pages/album/album.js b/weapp/pages/album/album.js
index 2ea8123..34cb9f7 100644
--- a/weapp/pages/album/album.js
+++ b/weapp/pages/album/album.js
@@ -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)
+ });
+ }
+ });
});
},
diff --git a/weapp/pages/checkin-detail/checkin-detail.js b/weapp/pages/checkin-detail/checkin-detail.js
index e095b56..198316e 100644
--- a/weapp/pages/checkin-detail/checkin-detail.js
+++ b/weapp/pages/checkin-detail/checkin-detail.js
@@ -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] }); }
+ });
});
},
diff --git a/weapp/pages/diary_edit/diary_edit.js b/weapp/pages/diary_edit/diary_edit.js
index 446378f..7716559 100644
--- a/weapp/pages/diary_edit/diary_edit.js
+++ b/weapp/pages/diary_edit/diary_edit.js
@@ -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) });
+ }
+ });
});
},
diff --git a/weapp/pages/profile/profile.js b/weapp/pages/profile/profile.js
index 6e87250..fced185 100644
--- a/weapp/pages/profile/profile.js
+++ b/weapp/pages/profile/profile.js
@@ -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"]}));
diff --git a/weapp/pages/profile/profile.wxml b/weapp/pages/profile/profile.wxml
index 5a06c35..5cce863 100644
--- a/weapp/pages/profile/profile.wxml
+++ b/weapp/pages/profile/profile.wxml
@@ -10,6 +10,27 @@
{{partnerName}}ID {{partnerDisplayId}}{{partnerDetail}}{{partnerInitial}}
另一半还没有加入邀请TA,共写这本生活手账绑定情侣
+
+
+ 存储空间
+ 👑 无限空间
+
+
+ 已用 {{storageUsedText}}{{storageExpireText ? ' · ' + storageExpireText : ''}}
+ 查看 ›
+
+
+
+
+ 存储空间
+ {{storageText}}
+
+
+
+ {{storageOverQuota ? '已超出免费空间,升级后可继续上传' : '已用 ' + storagePercent + '% 免费空间'}}
+ {{storageOverQuota ? '升级无限空间 ›' : '查看 ›'}}
+
+
diff --git a/weapp/pages/profile/profile.wxss b/weapp/pages/profile/profile.wxss
index 028f022..9042eb0 100644
--- a/weapp/pages/profile/profile.wxss
+++ b/weapp/pages/profile/profile.wxss
@@ -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 {
diff --git a/weapp/pages/publish/publish.js b/weapp/pages/publish/publish.js
index 7012efd..fd3e95d 100644
--- a/weapp/pages/publish/publish.js
+++ b/weapp/pages/publish/publish.js
@@ -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: '' });
+ }
+ });
});
},
diff --git a/weapp/pages/settings/settings.js b/weapp/pages/settings/settings.js
index a4ab007..f422b81 100644
--- a/weapp/pages/settings/settings.js
+++ b/weapp/pages/settings/settings.js
@@ -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: {
@@ -213,10 +214,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() {
diff --git a/weapp/pages/storage/storage.js b/weapp/pages/storage/storage.js
new file mode 100644
index 0000000..16cf167
--- /dev/null
+++ b/weapp/pages/storage/storage.js
@@ -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' });
+ }
+ });
+ },
+});
diff --git a/weapp/pages/storage/storage.json b/weapp/pages/storage/storage.json
new file mode 100644
index 0000000..34ce546
--- /dev/null
+++ b/weapp/pages/storage/storage.json
@@ -0,0 +1,7 @@
+{
+ "usingComponents": {
+ "app-nav": "/components/app-nav/app-nav",
+ "page-state": "/components/page-state/page-state"
+ },
+ "enablePullDownRefresh": true
+}
diff --git a/weapp/pages/storage/storage.wxml b/weapp/pages/storage/storage.wxml
new file mode 100644
index 0000000..20a1924
--- /dev/null
+++ b/weapp/pages/storage/storage.wxml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+ 已用空间
+ 👑 无限空间
+
+
+ {{usedText}}
+ / {{freeText}} 免费
+
+
+
+
+ {{vipExpireText}}
+ 已超出免费空间,升级后即可继续上传
+ 已使用 {{percent}}% 免费空间
+
+
+
+ 升级无限空间
+
+
+
+ {{item.name}}
+ 无限存储空间,照片视频想存就存
+
+
+ ¥{{item.priceText}}
+ /{{item.unit}}
+
+
+
+
+
+
+ 📌 说明
+ • 默认提供 {{freeMb}}MB 免费空间
+ • 开通会员后,照片、视频等不再限制存储空间
+ • 会员到期后,已上传内容仍可正常查看
+
+
+
+
+
+
diff --git a/weapp/pages/storage/storage.wxss b/weapp/pages/storage/storage.wxss
new file mode 100644
index 0000000..9fe4abf
--- /dev/null
+++ b/weapp/pages/storage/storage.wxss
@@ -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';
diff --git a/weapp/utils/request.js b/weapp/utils/request.js
index 784f4c1..38a6962 100644
--- a/weapp/utils/request.js
+++ b/weapp/utils/request.js
@@ -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'),
diff --git a/weapp/utils/storage-guard.js b/weapp/utils/storage-guard.js
new file mode 100644
index 0000000..e5947b8
--- /dev/null
+++ b/weapp/utils/storage-guard.js
@@ -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 };
diff --git a/部署验收清单.md b/部署验收清单.md
new file mode 100644
index 0000000..827d4a3
--- /dev/null
+++ b/部署验收清单.md
@@ -0,0 +1,87 @@
+# 虚拟支付接入修复 + 部署验收清单
+
+## 一、本次修复的两个问题
+
+### 问题 1:后台「存储空间管理」提示「请先登录」
+- **根因**:新增的 `admin_storage_users`、`admin_storage_user_update` 两个接口未加入后台鉴权白名单,被当成普通用户接口校验,所以返回「请先登录」。
+- **修复**:已加入 `adminActions` 白名单,现在走后台 Cookie 会话鉴权,可正常拉取用户列表。
+
+### 问题 2:前端存储支付页显示「支付失败,请稍后再试」
+- **根因 A(后端)**:订单号 `out_trade_no` 为 `"SP" + 32 位随机串 = 34 位`,**超过微信要求的 8–32 位**,被微信拒绝。
+ - 修复:改为 `"S" + 31 位随机串 = 32 位`。
+- **根因 B(前端)**:`wx.requestVirtualPayment` 误把 `offerId / env / productId / goodsPrice / buyQuantity / currencyType` 作为**顶层参数**传入。这些字段只能存在于 `signData` JSON 字符串内部,顶层仅允许 `signData / paySig / signature / mode` 四个参数。
+ - 修复:`pages/storage/storage.js` 已改为只传这四个参数。
+
+### 额外增强:兜底查单(query_order)
+- 新增服务端主动查单逻辑:服务启动后定时(默认 5 分钟)扫描 24 小时内未发货订单,调用微信 `query_order` 接口,已支付但推送丢失的订单会**自动补发货开通会员**,已关闭订单自动标记 `closed`。
+- 新增 `internal/virtualpay` 的 `QueryOrder` 方法(签名:`pay_sig = HMAC-SHA256(AppKey, "/xpay/query_order&" + body)`)。
+
+---
+
+## 二、部署步骤
+
+1. **后端**:用 `dist/past-windows-deploy.zip` 替换服务器上的部署目录并重启。
+2. **小程序前端**:`weapp/pages/storage/storage.js` 已改动,请在微信开发者工具重新上传并提交审核发布。
+3. **配置**:按下面清单逐项配置虚拟支付。
+
+---
+
+## 三、部署前检查清单(逐项验收)
+
+> 对照微信个人主体虚拟支付接入文档,逐项验收。
+
+| # | 检查项 | 本项目验收方法 / 操作 |
+|---|--------|------------------------|
+| 1 | 开放条件:个人主体 + 大陆身份证 + 服务类目「工具」+ 已完成认证与备案 | 微信公众平台(MP)后台确认 |
+| 2 | 全终端月支付限额 10 万元,已了解并告知用户 | 确认知晓,可在会员页注明 |
+| 3 | MP 后台「虚拟支付」能力已开通 | MP 后台 → 功能 → 虚拟支付,确认状态为已开通 |
+| 4 | 已拿到 AppID / OfferID / 现网 AppKey | 见下方「配置项 A」 |
+| 5 | 需 iOS 支付时已配置小程序简称 | 仅 iOS 需要,Android 可跳过 |
+| 6 | 消息推送接收已部署 | 服务已提供回调地址(见「配置项 B」) |
+| 7 | MP 后台已配置发货推送 URL,并完成一笔测试支付 | 见下方「配置项 B」+「沙箱测试」 |
+| 8 | 服务器验签逻辑通过 | 已实现 `sha1(sort(token,timestamp,nonce))` 明文验签,`token` 需与 MP 后台一致 |
+| 9 | 发货推送已做幂等 | 已实现:按 `out_trade_no` + `status='success'` 去重,重复推送不会重复开通 |
+| 10 | 前端 `wx.requestVirtualPayment` 打通 | 本次已修复参数,见「问题 2」 |
+| 11 | 兜底查单逻辑就绪(query_order) | 本次已新增,服务启动后自动运行 |
+| 12 | 退款规则 / 结算周期与费率已说明 | 虚拟支付结算周期 T+1,费率见 MP 后台说明,需在会员页告知 |
+| 13 | 上线后小额真单验证 | 正式上线后做一笔真实小额支付,确认到账与发货 |
+
+---
+
+## 配置项 A:支付通道配置(后台「存储空间」→「存储收费设置」)
+
+| 字段 | 说明 | 沙箱(测试) | 正式(上线) |
+|------|------|-------------|-------------|
+| `pay_env` | 支付环境 | `1` | **`0`** |
+| `pay_offer_id` | 支付应用 ID | 沙箱 OfferID | 正式 OfferID |
+| `pay_app_key` | 正式环境 AppKey | 留空 | **正式 AppKey** |
+| `pay_sandbox_key` | 沙箱 AppKey | 沙箱 AppKey | 可留空 |
+| `month_product_id` | 包月道具 ID | 沙箱道具 ID | 正式道具 ID |
+| `year_product_id` | 包年道具 ID | 沙箱道具 ID | 正式道具 ID |
+| `month_price_cents` | 包月价格(分) | 与道具价格一致 | 与道具价格一致 |
+| `year_price_cents` | 包年价格(分) | 与道具价格一致 | 与道具价格一致 |
+| `pay_mode` | 支付模式 | `short_series_goods` | `short_series_goods` |
+
+> **重要**:`month_product_id / year_product_id` 必须在微信「虚拟支付 → 道具管理」里创建,且**道具价格(分)必须与 `month_price_cents / year_price_cents` 完全一致**,否则支付时价格校验失败。
+
+> `config.yaml` 里 `virtual_pay.env` 默认是 `1`(沙箱)。**正式上线前务必改成 `0`**,或在后台把 `pay_env` 改为 `0`(后台优先)。
+
+---
+
+## 配置项 B:发货推送回调(微信后台「消息推送」)
+
+- **URL**:`https://你的域名/api/storage_pay_notify`
+- **Token**:与 `config.yaml` 的 `virtual_pay.notify_token` 保持一致
+- **消息加密方式**:选择 **明文模式**(当前代码按明文模式验签;若选安全模式需另行实现 AES 解密)
+
+配置 URL 时微信会发起 GET 验证,服务端已实现 `echostr` 回显 + 签名校验,验证通过即可。
+
+---
+
+## 沙箱测试流程(上线前必做)
+
+1. 后台将 `pay_env` 设为 `1`,填入沙箱 OfferID / 沙箱 AppKey / 沙箱道具 ID。
+2. 用测试微信号打开小程序,进入「我的 → 存储空间」发起支付。
+3. 确认能拉起 `wx.requestVirtualPayment`,完成沙箱支付。
+4. 观察:支付成功后会员自动到账;若推送延迟,5 分钟内兜底查单会自动补发货。
+5. 测试通过后,把 `pay_env` 改为 `0`、换成正式 OfferID / AppKey / 道具 ID,再提交审核发布。