feat: add account deletion, announcements and subscription renewal
Support personal records and persistent smoking visibility. Add upload quota preflight, file cleanup, privacy entry, image caching and revision-based page refresh across the backend, admin and mini program.
This commit is contained in:
+29
-3
@@ -14,8 +14,10 @@ App({
|
||||
onLaunch: function() {
|
||||
this.removeLegacyDataCaches();
|
||||
this._authListeners = [];
|
||||
require('./utils/subscribe').resetLoginRenewal();
|
||||
// 冷启动始终换取新 token,所有业务请求共享这一次登录。
|
||||
this.silentLogin().catch(function() {});
|
||||
if (wx.getStorageSync('accountDeleted')) this.setAuthState('error','账号已注销。再次进入将创建新账号,原数据不会恢复。');
|
||||
else this.silentLogin().catch(function() {});
|
||||
this.checkNetwork();
|
||||
wx.onNetworkStatusChange((res) => {
|
||||
this.globalData.isConnected = res.isConnected;
|
||||
@@ -23,6 +25,16 @@ App({
|
||||
});
|
||||
},
|
||||
|
||||
finishAccountDeletion: function() {
|
||||
require('./utils/data-cache').clear();
|
||||
require('./utils/announcement-session').clear();
|
||||
require('./utils/subscribe').resetLoginRenewal();
|
||||
this._authGeneration=(this._authGeneration||0)+1;this._authPromise=null;
|
||||
wx.clearStorageSync();
|
||||
wx.setStorageSync('accountDeleted',true);
|
||||
this.globalData.token=null;this.globalData.userId=null;
|
||||
this.setAuthState('error','账号已注销。再次进入将创建新账号,原数据不会恢复。');
|
||||
},
|
||||
onRelationChanged: function() {
|
||||
if (this._relationRedirect) return;
|
||||
wx.removeStorageSync('petHasPet');
|
||||
@@ -35,13 +47,19 @@ App({
|
||||
this._relationSync = true;
|
||||
const before = String(wx.getStorageSync('coupleId') || '');
|
||||
try {
|
||||
await api.checkAuth();
|
||||
const auth = await api.checkAuth({cache:false, silent:true});
|
||||
const changed = require('./utils/data-cache').applyRemoteRevision(auth.data_revision);
|
||||
if (before !== String(wx.getStorageSync('coupleId') || '')) {
|
||||
this.onRelationChanged();
|
||||
} else if (changed && typeof getCurrentPages === 'function') {
|
||||
const pages = getCurrentPages();
|
||||
const page = pages[pages.length - 1];
|
||||
if (page && page.onDataChanged) page.onDataChanged();
|
||||
}
|
||||
} catch(e) {} finally { this._relationSync = false; }
|
||||
};
|
||||
sync();
|
||||
if (this.globalData.authState === 'ready') require('./utils/subscribe').prepareLoginRenewal();
|
||||
if (!this._relationTimer) this._relationTimer = setInterval(sync, 30000);
|
||||
},
|
||||
onHide: function() { if(this._relationTimer) clearInterval(this._relationTimer); this._relationTimer=null; },
|
||||
@@ -65,6 +83,7 @@ App({
|
||||
},
|
||||
// 多个旧 token 请求同时返回 401 时,只刷新一次;较晚返回的旧请求复用新 token。
|
||||
refreshAuth: function(failedToken) {
|
||||
if(wx.getStorageSync('accountDeleted')) return Promise.reject({error:'账号已注销',accountDeleted:true});
|
||||
if (this._authPromise) return this._authPromise;
|
||||
if (this.globalData.authState === 'ready' && wx.getStorageSync('token') !== failedToken) return Promise.resolve();
|
||||
return this.silentLogin();
|
||||
@@ -109,20 +128,24 @@ App({
|
||||
|
||||
silentLogin: function() {
|
||||
if (this._authPromise) return this._authPromise;
|
||||
const generation=this._authGeneration||0;
|
||||
this.setAuthState('loading');
|
||||
this._authPromise = this.performLogin().then((res) => {
|
||||
this.setAuthState('ready');
|
||||
require('./utils/subscribe').prepareLoginRenewal();
|
||||
return res;
|
||||
}).catch((error) => {
|
||||
if(generation!==(this._authGeneration||0))throw error;
|
||||
wx.removeStorageSync('token');
|
||||
this.globalData.token = null;
|
||||
var failure = { error: (error && error.error) || '登录失败,请检查网络后重试', authFailed: true };
|
||||
this.setAuthState('error', failure.error);
|
||||
throw failure;
|
||||
}).finally(() => { this._authPromise = null; });
|
||||
}).finally(() => { if(generation===(this._authGeneration||0))this._authPromise = null; });
|
||||
return this._authPromise;
|
||||
},
|
||||
performLogin: async function() {
|
||||
const generation=this._authGeneration||0;
|
||||
var loginRes = await new Promise(function(resolve, reject) {
|
||||
var done = false;
|
||||
var timer = setTimeout(function() {
|
||||
@@ -136,7 +159,9 @@ App({
|
||||
if (!loginRes.code) throw { error: '未能获取微信登录凭证,请重试' };
|
||||
// 定位授权不阻塞登录,城市信息由页面后续的定位接口更新。
|
||||
var res = await api.wechatLogin(loginRes.code, '');
|
||||
if (generation!==(this._authGeneration||0)) throw {error:'账号状态已变更,请重新进入'};
|
||||
if (!res || !res.token || !res.user_id) throw { error: '登录响应不完整,请重试' };
|
||||
wx.removeStorageSync('accountDeleted');
|
||||
wx.setStorageSync('token', res.token);
|
||||
wx.setStorageSync('userId', res.user_id);
|
||||
wx.setStorageSync('coupleId', res.couple_id || '');
|
||||
@@ -144,6 +169,7 @@ App({
|
||||
wx.setStorageSync('nickname', res.nickname || '');
|
||||
wx.setStorageSync('region', res.region || '');
|
||||
wx.setStorageSync('hasLoggedIn', true);
|
||||
require('./utils/data-cache').applyRemoteRevision(res.data_revision);
|
||||
this.globalData.token = res.token;
|
||||
this.globalData.userId = res.user_id;
|
||||
return res;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { tryLoginRenewal } = require('../utils/subscribe');
|
||||
const { tabForPage, paths } = require('../utils/navigation');
|
||||
Component({
|
||||
data: { active: '', hidden: false },
|
||||
@@ -11,6 +12,7 @@ Component({
|
||||
this.setData({ active: tabForPage(current && current.route), hidden: !!(current && current.data && (current.data.publishOpen || current.data.showEditProfileModal || current.data.showEditPartnerModal)) });
|
||||
},
|
||||
navigate: function(e) {
|
||||
tryLoginRenewal();
|
||||
var tab = e.currentTarget.dataset.tab;
|
||||
if (!paths[tab]) return;
|
||||
var pages = getCurrentPages();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { uploadReference } = require('../../utils/file-media');
|
||||
const { itemsOf, startList, current, mergeUnique, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
|
||||
const { currentUserLocation } = require('../../utils/user-location');
|
||||
@@ -6,9 +7,9 @@ const { withDataCache } = require('../../utils/page-cache');
|
||||
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({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: { loading: true, loadError: false,
|
||||
batches: [],
|
||||
@@ -317,9 +318,8 @@ Page(withDataCache(withListPagination({
|
||||
var successCount = 0;
|
||||
var failCount = 0;
|
||||
var failReasons = [];
|
||||
var quotaReached = false;
|
||||
|
||||
// ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
|
||||
await requestSubscribeAfterAction('photo');
|
||||
|
||||
try {
|
||||
var albumId = /^-\d+$/.test(String(this.data.albumId)) ? null : this.data.albumId;
|
||||
@@ -372,6 +372,12 @@ Page(withDataCache(withListPagination({
|
||||
failReasons.push('第' + (i+1) + '张: 上传返回无路径 - ' + JSON.stringify(uploadRes));
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && Number(e.code) === 10001) {
|
||||
quotaReached = true;
|
||||
failCount += paths.length - i;
|
||||
wx.hideLoading();
|
||||
break;
|
||||
}
|
||||
failCount++;
|
||||
failReasons.push('第' + (i+1) + '张: ' + (e.error || e.errMsg || e.message || JSON.stringify(e)));
|
||||
// 内容安全违规:明确提示用户
|
||||
@@ -390,7 +396,7 @@ Page(withDataCache(withListPagination({
|
||||
|
||||
var msg = '上传完成 ' + successCount + '/' + paths.length;
|
||||
if (failCount > 0) msg += '(' + failCount + '张失败)';
|
||||
wx.showToast({ title: msg, icon: successCount > 0 ? 'success' : 'none', duration: 2000 });
|
||||
if (!quotaReached) wx.showToast({ title: msg, icon: successCount > 0 ? 'success' : 'none', duration: 2000 });
|
||||
|
||||
// 如果有失败,输出详细原因到控制台
|
||||
if (failCount > 0 && failReasons.length > 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/album/album.wxml -->
|
||||
<app-nav title="相册详情" back="{{true}}" />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/albums/albums.js
|
||||
const { api, getImageUrl } = require('../../utils/request');
|
||||
Page(withDataCache(withListPagination({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: {
|
||||
loading: true, loadError: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/albums/albums.wxml - 相册列表 -->
|
||||
<app-nav title="{{uploadMode ? '选择相册' : '相册'}}" back="{{true}}" />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { uploadReference } = require('../../utils/file-media');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
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 { guardStorageSpace } = require('../../utils/storage-guard');
|
||||
const COOLDOWN = 5000;
|
||||
Page(withDataCache({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: { loading: true, loadError: false, item: null, logs: [], logsPage: 1, logsTotal: 0, logsHasMore: false, logsLoading: false, logsError: false, freqLabel: '', typeLabel: '', myStreak: 0, partnerStreak: 0, showCheckModal: false, checkPhoto: '', checkNote: '', lastActionTime: 0, detailId: 0, myAvatar: '', partnerAvatar: '', myName: '我', partnerName: 'TA' },
|
||||
|
||||
@@ -180,8 +181,6 @@ Page(withDataCache({
|
||||
this._submittingCheck = true;
|
||||
|
||||
try {
|
||||
// 为本人接收TA的提醒补充授权。
|
||||
await requestSubscribeAfterAction('checkin');
|
||||
var photoPath = '';
|
||||
if (this.data.checkPhoto) {
|
||||
wx.showLoading({ title: '上传中...' });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/checkin-detail/checkin-detail.wxml - 打卡详情 -->
|
||||
<app-nav title="打卡详情" back="{{true}}" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
@@ -6,6 +7,7 @@ const { api, getImageUrl } = require('../../utils/request');
|
||||
const { diaryEntry } = require('../../utils/journal');
|
||||
const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos');
|
||||
Page(withDataCache(withListPagination({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: {
|
||||
loading: true, loadError: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<import src="../../templates/journal.wxml" />
|
||||
<launch-transition />
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}"><app-nav title="日记" back="{{true}}" />
|
||||
<view capture-bind:tap="onLoginSubscribeTap" 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" />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { itemsOf, totalOf, mergeUnique } = require('../../utils/list-pagination');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
const { api, getImageUrl } = require('../../utils/request');
|
||||
Page(withDataCache({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
noop: function() {},
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: { loadError: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<app-nav title="日记详情" back="{{true}}" /><view class="page-bg">
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { uploadReference } = require('../../utils/file-media');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
const { api, API_BASE, getImageUrl, uploadFileCompat } = require('../../utils/request');
|
||||
@@ -7,6 +8,7 @@ var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '
|
||||
var WEATHER_OPTIONS = ['☀️', '⛅', '☁️', '🌧️', '⛈️', '❄️', '🌤️', '🌥️', '🌈', '🌪️', '🌫️', '🌙'];
|
||||
|
||||
Page({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: {
|
||||
editorContent: '', loading: true, loadError: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" 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;">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { itemsOf, totalOf } = require('../../utils/list-pagination');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
@@ -7,6 +8,7 @@ const { diaryEntry, albumEntry, memoryEntry } = require('../../utils/journal');
|
||||
const { saveScrollPosition, resetScrollOnTabShow } = require('../../utils/scroll-pos');
|
||||
|
||||
Page(withDataCache({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: {
|
||||
stats: null, loading: true, loadError: false, refreshing: false, publishOpen: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<import src="../../templates/journal.wxml" />
|
||||
<launch-transition />
|
||||
<view class="app-shell j-handwritten-page" 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>
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell j-handwritten-page" 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"><text class="j-small-title">一起的日常</text><button class="j-button j-button-small" bindtap="showPublish">+ 记录</button></view>
|
||||
<view class="j-tools records-tools"><view class="j-tool-grid"><view bindtap="goToSongs"><image src="/images/stat-music.svg" /><text>歌曲</text></view><view bindtap="goToCheckin"><image src="/images/stat-check.svg" /><text>打卡</text></view><view bindtap="goToPet"><image src="/images/stat-pet.svg" /><text>宠物</text></view><view bindtap="goToPeriod"><image src="/images/stat-j-flower.svg" /><text>姨姨</text></view></view></view>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/history/history.js
|
||||
const { api } = require('../../utils/request');
|
||||
@@ -23,6 +24,7 @@ var ACTION_CONFIG = {
|
||||
};
|
||||
|
||||
Page({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: { loadError: false, list: [], page: 1, totalPages: 0, pages: [], loading: true },
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/history/history.wxml - 情侣操作记录(优化版) -->
|
||||
<app-nav title="操作记录" back="{{true}}" />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const announcementSession = require('../../utils/announcement-session');
|
||||
const { itemsOf, totalOf } = require('../../utils/list-pagination');
|
||||
let lastLocationReport = 0;
|
||||
let locationReportUser = null;
|
||||
@@ -9,8 +11,10 @@ const { diaryEntry } = require('../../utils/journal');
|
||||
const { saveScrollPosition, resetScrollOnTabShow } = require('../../utils/scroll-pos');
|
||||
|
||||
Page(withDataCache({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: {
|
||||
showAnnouncement:false, announcement:null,
|
||||
userId: null,
|
||||
coupleId: null,
|
||||
|
||||
@@ -53,6 +57,8 @@ Page(withDataCache({
|
||||
},
|
||||
|
||||
onShow: function() {
|
||||
this._announcementActive=true;
|
||||
this.loadAnnouncement();
|
||||
resetScrollOnTabShow(this, 'index');
|
||||
const tabBar = this.getTabBar && this.getTabBar();
|
||||
if (tabBar) tabBar.syncActive();
|
||||
@@ -72,6 +78,26 @@ Page(withDataCache({
|
||||
this.fetchIpLocation();
|
||||
},
|
||||
|
||||
onHide: function() { this._announcementActive=false; },
|
||||
onUnload: function() { this._announcementActive=false; },
|
||||
loadAnnouncement: function() {
|
||||
if(this._announcementLoading)return this._announcementLoading;
|
||||
this._announcementLoading=(async()=>{
|
||||
try {
|
||||
const notice=await api.announcement();
|
||||
if(this._announcementActive===false)return;
|
||||
const userId=wx.getStorageSync('userId');
|
||||
const show=announcementSession.shouldShow(notice,userId);
|
||||
this.setData({announcement:notice, showAnnouncement:show});
|
||||
if(show)announcementSession.markShown(notice,userId);
|
||||
} catch(e) {} // 公告读取失败不阻塞首页。
|
||||
})().finally(()=>{this._announcementLoading=null;});
|
||||
return this._announcementLoading;
|
||||
},
|
||||
closeAnnouncement: function() {
|
||||
this.setData({showAnnouncement:false});
|
||||
},
|
||||
preventNoticeMove: function() {},
|
||||
// 保存滚动位置
|
||||
onScroll: function(e) {
|
||||
this._scrollTop = e.detail.scrollTop;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<import src="../../templates/journal.wxml" />
|
||||
<launch-transition />
|
||||
<view class="app-shell {{bgImage ? 'home-has-background' : ''}}" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell {{bgImage ? 'home-has-background' : ''}}" 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><view class="home-handwritten-title"><text class="j-page-title">让所有的爱有迹可循</text></view></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}}">
|
||||
@@ -39,3 +39,5 @@
|
||||
<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>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{showAnnouncement}}" class="notice-overlay" catchtouchmove="preventNoticeMove"><view class="notice-dialog"><text class="notice-label">我们 past · 公告</text><text class="notice-title">{{announcement.title}}</text><scroll-view scroll-y class="notice-content"><text selectable>{{announcement.content}}</text></scroll-view><button class="notice-dismiss" bindtap="closeAnnouncement">知道了</button></view></view>
|
||||
|
||||
@@ -55,3 +55,5 @@
|
||||
.home-has-background .j-section-head { text-shadow:0 1rpx 5rpx rgba(255,253,248,.85); }
|
||||
|
||||
.home-has-background .home-couple-panel { padding:24rpx; border:1rpx solid rgba(233,221,208,.65); border-radius:24rpx; background:rgba(255,253,248,.82); }
|
||||
|
||||
.notice-overlay {position:fixed;inset:0;z-index:12000;display:flex;align-items:center;justify-content:center;background:rgba(49,36,42,.46);padding:48rpx;box-sizing:border-box;}.notice-dialog{width:100%;max-width:640rpx;background:#FFFCF8;border-radius:36rpx;padding:44rpx;box-sizing:border-box;}.notice-label{display:block;color:#9B7B83;font-size:22rpx;}.notice-title{display:block;font-size:36rpx;color:#64424D;margin:16rpx 0 28rpx;}.notice-content{max-height:50vh;white-space:pre-wrap;font-size:28rpx;line-height:1.8;color:#5F5557;}.notice-dismiss{margin-top:32rpx;background:#667E75;color:#fff;border-radius:20rpx;font-size:28rpx;}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/lovewords/lovewords.js - 一起听过的歌
|
||||
const { api } = require('../../utils/request');
|
||||
Page(withDataCache(withListPagination({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: { loading: true, loadError: false,
|
||||
list: [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/lovewords/lovewords.wxml - 一起听过的歌 -->
|
||||
<app-nav title="一起听的歌" back="{{true}}" />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/period/period.js - 姨姨(美柚风格日历模式)
|
||||
const { api } = require('../../utils/request');
|
||||
|
||||
|
||||
Page({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: {
|
||||
// 日历
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" 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>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/pet/achievements.js
|
||||
const { api } = require('../../utils/request');
|
||||
|
||||
Page(withDataCache({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: { loading: true, loadError: false,
|
||||
achievements: [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/pet/achievements.wxml -->
|
||||
<app-nav title="宠物成就" back="{{true}}" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/pet/pet.js
|
||||
@@ -20,6 +21,7 @@ const BREED_EMOJIS = {
|
||||
const STAGE_NAMES = ['', '幼犬期', '少年期', '青年期', '成年期', '终极期'];
|
||||
|
||||
Page(withDataCache({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: { loading: true, loadError: false, needsBinding: false,
|
||||
hasPet: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/pet/pet.wxml -->
|
||||
<app-nav title="宠物养成" back="{{true}}" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { uploadReference } = require('../../utils/file-media');
|
||||
const { itemsOf, totalOf } = require('../../utils/list-pagination');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
@@ -5,7 +6,6 @@ const { getLayout } = require('../../utils/layout');
|
||||
// pages/profile/profile.js - 与原网站一致的个人中心
|
||||
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) {
|
||||
@@ -16,6 +16,7 @@ function formatStorageBytes(bytes) {
|
||||
}
|
||||
|
||||
Page(withDataCache({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: {
|
||||
scrollTop: 0,
|
||||
@@ -904,8 +905,6 @@ Page(withDataCache({
|
||||
const { inviteMessage } = this.data;
|
||||
if (!inviteTargetId) { wx.showToast({ title: '请输入对方ID', icon: 'none' }); return; }
|
||||
if (!/^[0-9]{6}$/.test(inviteTargetId)) { wx.showToast({ title: 'ID为6位数字', icon: 'none' }); return; }
|
||||
// 邀请成功且对方接受时,需要通知邀请人,提前申请一次性订阅授权
|
||||
requestSubscribe('notify').catch(function() {});
|
||||
try {
|
||||
wx.showLoading({ title: '发送中...' });
|
||||
const res = await api.invitePartner({ target_user_id: inviteTargetId, message: inviteMessage });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell j-handwritten-page" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell j-handwritten-page" 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>
|
||||
|
||||
@@ -325,7 +325,7 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll
|
||||
.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-card { margin-top:32rpx; padding:26rpx 30rpx; background:linear-gradient(135deg,#FFF7F9,#FBE8EF); 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; }
|
||||
@@ -335,12 +335,12 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll
|
||||
.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-storage-vip { background:linear-gradient(135deg,#F8D6E1,#E9A9BF); border:1rpx solid #E8B5C7; border-radius:32rpx; }
|
||||
.j-storage-vip .j-storage-label { color:#6E3549; }
|
||||
.j-storage-vip .j-storage-val.j-vip { color:#6E3549; font-size:28rpx; }
|
||||
.j-storage-vip .j-storage-hint { color:#704052; }
|
||||
.j-storage-vip .j-storage-cta { color:#6E3549; }
|
||||
|
||||
/* 本人与对方资料卡共用完整样式,整卡点击编辑。 */
|
||||
.j-body > .j-profile-summary,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { uploadReference } = require('../../utils/file-media');
|
||||
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');
|
||||
const { guardStorageSpace } = require('../../utils/storage-guard');
|
||||
|
||||
var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '😎', '🤔', '😭', '😋', '😌'];
|
||||
@@ -18,6 +18,7 @@ var TYPE_CONFIG = {
|
||||
};
|
||||
|
||||
Page({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: {
|
||||
type: '',
|
||||
@@ -360,17 +361,6 @@ Page({
|
||||
if (!form.title) { throw new Error('请填写打卡标题'); }
|
||||
}
|
||||
|
||||
// ★ 在用户点击手势上下文中提前请求订阅消息(符合微信规范)
|
||||
// 为本人接收TA的提醒补充授权,拒绝或保存失败也不影响发布
|
||||
var sceneMap = {
|
||||
diary: 'diary', album: 'photo', checkin: 'checkin',
|
||||
timeline: 'timeline', song: 'song'
|
||||
};
|
||||
var scene = sceneMap[type];
|
||||
if (scene) {
|
||||
await requestSubscribeAfterAction(scene);
|
||||
}
|
||||
|
||||
// 再执行异步操作(上传/API调用)
|
||||
if (type === 'timeline') {
|
||||
var editId = this.data.editId;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/publish/publish.wxml - 统一发布页(增强版编辑器) -->
|
||||
<app-nav title="{{titleText}}" back="{{true}}" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/punch/punch.js - 情侣打卡
|
||||
const { api, getImageUrl } = require('../../utils/request');
|
||||
Page(withDataCache(withListPagination({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: { loadError: false,
|
||||
checkins: [],
|
||||
checkins: [], smokingVisible: false, smokingKnown: false, smokingSaving: false, smokingError: false,
|
||||
coupleInfo: null,
|
||||
myAvatar: '', partnerAvatar: '',
|
||||
myName: '我', partnerName: 'TA',
|
||||
@@ -20,8 +22,20 @@ Page(withDataCache(withListPagination({
|
||||
this.loadCoupleInfo();
|
||||
this.loadData();
|
||||
},
|
||||
onShow: function() { this.loadCoupleInfo(); this.loadData(); },
|
||||
onShow: function() { this.loadSmokingPreference(); this.loadCoupleInfo(); this.loadData(); },
|
||||
|
||||
loadSmokingPreference: async function() {
|
||||
try { const auth=await api.checkAuth(); this.setData({smokingVisible:Number(auth.user.smoking_card_visible)!==0, smokingKnown:true, smokingError:false}); }
|
||||
catch(e) { this.setData({smokingError:true}); }
|
||||
},
|
||||
toggleSmoking: async function(e) {
|
||||
if (this.data.smokingSaving || !this.data.smokingKnown) return;
|
||||
const previous=this.data.smokingVisible;
|
||||
this.setData({smokingSaving:true});
|
||||
try { const result=await api.smokingCardVisible({visible:e.detail.value?1:0}); this.setData({smokingVisible:Number(result.card_visible)===1}); }
|
||||
catch(error) { this.setData({smokingVisible:previous}); wx.showToast({title:error.error||'保存失败,请重试',icon:'none'}); }
|
||||
finally { this.setData({smokingSaving:false}); }
|
||||
},
|
||||
onSmokingUpdated: function() { this.loadData(); },
|
||||
|
||||
onPullDownRefresh: async function() {
|
||||
@@ -120,4 +134,4 @@ Page(withDataCache(withListPagination({
|
||||
goBack: function() { wx.navigateBack(); },
|
||||
goHome: function() { wx.switchTab({ url: '/pages/index/index' }); },
|
||||
goProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); }
|
||||
}, 'loadData'), {"loadData": ["checkinList", "checkinStats", "coupleInfo"]}));
|
||||
}, 'loadData'), {"loadData": ["checkinList", "checkinStats", "coupleInfo"], "loadSmokingPreference":["checkAuth"], "loadCoupleInfo":["coupleInfo"]}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/punch/punch.wxml - 情侣打卡 -->
|
||||
<app-nav title="打卡" back="{{true}}" />
|
||||
@@ -25,7 +25,8 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<smoking-card id="smoking" bind:updated="onSmokingUpdated" />
|
||||
<view class="smoking-preference"><view><text class="j-small-title">抽烟记录</text><text class="j-caption">显示设置保存到账号,重新安装后仍生效</text></view><switch wx:if="{{smokingKnown}}" checked="{{smokingVisible}}" disabled="{{smokingSaving}}" color="#667E75" bindchange="toggleSmoking" /><text wx:elif="{{smokingError}}" bindtap="loadSmokingPreference">重试</text><text wx:else>加载中</text></view>
|
||||
<smoking-card wx:if="{{smokingKnown && smokingVisible}}" id="smoking" bind:updated="onSmokingUpdated" />
|
||||
|
||||
<!-- 打卡列表 -->
|
||||
<view class="checkin-cards" wx:if="{{!loading && !loadError && checkins.length > 0}}">
|
||||
|
||||
@@ -13,3 +13,5 @@
|
||||
@import '../../styles/button-theme.wxss';
|
||||
|
||||
@import '../../styles/typography.wxss';
|
||||
|
||||
.smoking-preference { display:flex; align-items:center; justify-content:space-between; gap:20rpx; margin:26rpx 0; padding:24rpx; background:#F2F7F4; border-radius:24rpx; }
|
||||
|
||||
@@ -1,7 +1,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 { requestSubscribe, getSubscribeStatus, isTemplateConfigured, subscribeErrorMessage } = require('../../utils/subscribe');
|
||||
const { guardStorageSpace } = require('../../utils/storage-guard');
|
||||
Page({
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
@@ -23,6 +23,7 @@ Page({
|
||||
// 消息通知设置
|
||||
showSubscribeModal: false,
|
||||
subscribeStatus: {},
|
||||
notifyAvailable: typeof isTemplateConfigured === 'function' && isTemplateConfigured('notify'),
|
||||
subscribeItems: [],
|
||||
subscribeLoading: false,
|
||||
|
||||
@@ -63,6 +64,25 @@ Page({
|
||||
wx.switchTab({ url: '/pages/profile/profile' });
|
||||
},
|
||||
|
||||
deleteAccount: async function() {
|
||||
if(this._deletingAccount)return;
|
||||
this._deletingAccount=true;
|
||||
try {
|
||||
const preview=await api.accountDeletionPreview();
|
||||
const counts=preview.summary.counts;
|
||||
const text='已上传文件 '+counts.files+' 个,相册 '+counts.albums+' 个,照片 '+counts.photos+' 张,日记 '+counts.diaries+' 篇,记忆日 '+counts.timeline+' 条,打卡 '+counts.checkins+' 条,抽烟记录 '+counts.smoking+' 条,生理期日记录 '+counts.period+' 条。';
|
||||
const labels={past_song:'歌曲',past_diary_comment:'评论',past_period_record:'生理期周期',past_period_sex_record:'亲密记录',past_storage_order:'订单',past_subscribe_template:'消息订阅'};
|
||||
const more=Object.keys(labels).map(key=>labels[key]+' '+((preview.summary.record_counts||{})[key]||0)+' 条').join(',');
|
||||
const confirm=options=>new Promise(resolve=>wx.showModal({...options,success:r=>resolve(r.confirm),fail:()=>resolve(false)}));
|
||||
if(!await confirm({title:'注销账号前,请确认',content:text+' 其他数据:'+more+'。注销后,账号、所有内容及文件关联会被删除,无法恢复;剩余空间权益将失效。',confirmText:'继续注销',confirmColor:'#B74768'}))return;
|
||||
if(!await confirm({title:'最后一次确认',content:'确定永久注销并删除全部数据?独占文件将从存储中清理;与其他用户重复的物理文件只移除你的关联,不会删除他人的内容。',confirmText:'确认注销',confirmColor:'#B74768'}))return;
|
||||
wx.showLoading({title:'正在注销',mask:true});
|
||||
await api.accountDelete({confirmed:true,confirmation_text:'注销并删除全部数据',confirmation_token:preview.confirmation_token});
|
||||
getApp().finishAccountDeletion();
|
||||
wx.reLaunch({url:'/pages/index/index'});
|
||||
} catch(error) {wx.showToast({title:error.error||'注销失败,请稍后重试',icon:'none',duration:3000});}
|
||||
finally {wx.hideLoading();this._deletingAccount=false;}
|
||||
},
|
||||
// 加载解绑状态
|
||||
loadUnbindStatus: function() {
|
||||
var self = this;
|
||||
@@ -82,7 +102,7 @@ Page({
|
||||
|
||||
noop: function() {},
|
||||
|
||||
// ===== 消息通知管理(统一使用一个模板) =====
|
||||
// ===== 消息通知管理(按场景独立授权) =====
|
||||
showSubscribeModal: async function() {
|
||||
this.setData({ showSubscribeModal: true, subscribeLoading: true });
|
||||
try {
|
||||
@@ -110,6 +130,18 @@ Page({
|
||||
this.setData({ showAboutModal: false });
|
||||
},
|
||||
|
||||
openPrivacyContract: function() {
|
||||
if (typeof wx.openPrivacyContract !== 'function') {
|
||||
wx.showToast({ title: '请升级微信后查看隐私协议', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
wx.openPrivacyContract({
|
||||
fail: function() {
|
||||
wx.showToast({ title: '隐私协议暂时无法打开,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ===== 联系我们 =====
|
||||
showContactModal: function() {
|
||||
this.setData({ showContactModal: true });
|
||||
@@ -120,7 +152,12 @@ Page({
|
||||
|
||||
toggleSubscribe: async function(e) {
|
||||
const type = 'notify';
|
||||
const current = this.data.subscribeChecked;
|
||||
if (typeof isTemplateConfigured === 'function' && !isTemplateConfigured(type)) {
|
||||
wx.showToast({title:'该提醒暂未配置模板',icon:'none'});
|
||||
return;
|
||||
}
|
||||
const field = 'subscribeChecked';
|
||||
const current = this.data[field];
|
||||
|
||||
// 防止重复点击
|
||||
if (this._subscribing) return;
|
||||
@@ -130,8 +167,8 @@ Page({
|
||||
// 取消订阅
|
||||
try {
|
||||
await api.unsubscribeTemplate({ template_type: type });
|
||||
this.setData({ subscribeChecked: false });
|
||||
wx.setStorageSync('subscribed_notify', false);
|
||||
this.setData({ [field]: false });
|
||||
wx.setStorageSync('subscribed_' + type, false);
|
||||
wx.showToast({ title: '已关闭通知', icon: 'success' });
|
||||
} catch (e) {
|
||||
wx.showToast({ title: '操作失败', icon: 'none' });
|
||||
@@ -141,33 +178,35 @@ Page({
|
||||
try {
|
||||
const results = await requestSubscribe(type);
|
||||
if (results[type]) {
|
||||
this.setData({ subscribeChecked: true });
|
||||
wx.setStorageSync('subscribed_notify', true);
|
||||
this.setData({ [field]: true });
|
||||
wx.setStorageSync('subscribed_' + type, true);
|
||||
wx.showToast({ title: '已开启通知', icon: 'success' });
|
||||
} else {
|
||||
wx.showToast({ title: '订阅被拒绝,可在设置中重新开启', icon: 'none' });
|
||||
}
|
||||
} catch (e) {
|
||||
wx.showToast({ title: '订阅失败', icon: 'none' });
|
||||
wx.showModal({title:'无法订阅提醒',content:subscribeErrorMessage(e),showCancel:false});
|
||||
}
|
||||
}
|
||||
|
||||
this._subscribing = false;
|
||||
},
|
||||
|
||||
renewSubscribe: async function() {
|
||||
renewSubscribe: async function(e) {
|
||||
const type = 'notify';
|
||||
const field = 'subscribeChecked';
|
||||
if (this._subscribing) return;
|
||||
this._subscribing = true;
|
||||
try {
|
||||
const results = await requestSubscribe('notify');
|
||||
if (results.notify) {
|
||||
this.setData({ subscribeChecked: true });
|
||||
const results = await requestSubscribe(type);
|
||||
if (results[type]) {
|
||||
this.setData({ [field]: true });
|
||||
wx.showToast({ title: '已订阅一次提醒', icon: 'success' });
|
||||
} else {
|
||||
wx.showToast({ title: '本次未授权提醒', icon: 'none' });
|
||||
}
|
||||
} catch (e) {
|
||||
wx.showToast({ title: '订阅失败,请重试', icon: 'none' });
|
||||
wx.showModal({title:'无法订阅提醒',content:subscribeErrorMessage(e),showCancel:false});
|
||||
} finally {
|
||||
this._subscribing = false;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
</view>
|
||||
|
||||
<view class="section-heading">关系与账号</view>
|
||||
<view class="j-profile-setting" wx:if="{{bindingKnown && !hasPartner}}" bindtap="deleteAccount"><view class="setting-info"><view class="j-small-title" style="color:#B74768">注销账号</view><view class="j-caption">未绑定时可注销,确认后删除全部个人数据</view></view><text class="j-link">›</text></view>
|
||||
<!-- 操作记录 -->
|
||||
<view class="j-profile-setting" bindtap="goToHistory">
|
||||
<view class="setting-info">
|
||||
@@ -73,6 +74,14 @@
|
||||
<text class="j-link">›</text>
|
||||
</view>
|
||||
|
||||
<view class="j-profile-setting" bindtap="openPrivacyContract">
|
||||
<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="showContactModal">
|
||||
<view class="setting-info">
|
||||
@@ -133,11 +142,11 @@
|
||||
<text class="subscribe-icon">💕</text>
|
||||
</view>
|
||||
<view class="subscribe-info">
|
||||
<text class="subscribe-name">操作通知</text>
|
||||
<text class="subscribe-desc">TA有新的操作时通知你</text>
|
||||
<text class="subscribe-name">操作与打卡通知</text>
|
||||
<text class="subscribe-desc">{{notifyAvailable ? 'TA有新动态或完成打卡时通知你' : '暂未配置当前小程序的通知模板'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<switch checked="{{subscribeChecked}}" bindchange="toggleSubscribe" color="#B74768" style="transform:scale(0.75);" />
|
||||
<switch disabled="{{!notifyAvailable}}" checked="{{subscribeChecked}}" bindchange="toggleSubscribe" color="#B74768" style="transform:scale(0.75);" />
|
||||
</view>
|
||||
|
||||
<!-- 状态提示 -->
|
||||
@@ -148,11 +157,11 @@
|
||||
</block>
|
||||
<block wx:else>
|
||||
<text class="tip-icon">💡</text>
|
||||
<text class="tip-text">开启并授权后,可接收一次TA的操作提醒</text>
|
||||
<text class="tip-text">开启并授权后,可接收一次TA的提醒</text>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<button wx:if="{{subscribeChecked}}" class="subscribe-renew" bindtap="renewSubscribe">订阅一次 TA 的提醒</button>
|
||||
<button wx:if="{{notifyAvailable && subscribeChecked}}" class="subscribe-renew" bindtap="renewSubscribe">订阅一次 TA 的提醒</button>
|
||||
|
||||
<!-- 说明 -->
|
||||
<view class="subscribe-note">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
Page({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
data:{},
|
||||
onLoad(){this.setData(getLayout());},
|
||||
onResize(){this.setData(getLayout());},
|
||||
|
||||
@@ -1 +1 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}"><launch-transition /><app-nav title="抽烟记录" back="{{true}}" /><scroll-view class="page-bg" scroll-y bindscrolltolower="loadMore" lower-threshold="120" show-scrollbar="{{false}}"><view class="c-body"><smoking-card id="smoking" detailed="{{true}}" /></view></scroll-view></view>
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}"><launch-transition /><app-nav title="抽烟记录" back="{{true}}" /><scroll-view class="page-bg" scroll-y bindscrolltolower="loadMore" lower-threshold="120" show-scrollbar="{{false}}"><view class="c-body"><smoking-card id="smoking" detailed="{{true}}" /></view></scroll-view></view>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
const { api } = require('../../utils/request');
|
||||
|
||||
@@ -8,7 +10,8 @@ function formatBytes(bytes) {
|
||||
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
Page({
|
||||
Page(withDataCache({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function () { this.setData(getLayout()); },
|
||||
|
||||
data: {
|
||||
@@ -157,4 +160,4 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
}, {loadInfo:['storageInfo']}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary j-handwritten-page" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary j-handwritten-page" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/storage/storage.wxml -->
|
||||
<app-nav title="存储空间" back="{{true}}" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/timeline-detail/timeline-detail.js
|
||||
@@ -59,6 +60,7 @@ function calcNearestRepeatDate(eventDate, today, interval, unit) {
|
||||
}
|
||||
|
||||
Page(withDataCache({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: { loading: true, loadError: false,
|
||||
item: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary {{bgImageUrl ? 'detail-immersive' : ''}}" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary {{bgImageUrl ? 'detail-immersive' : ''}}" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/timeline-detail/timeline-detail.wxml -->
|
||||
<page-meta page-style="{{bgImageUrl ? 'background: transparent;' : ''}}"></page-meta>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
|
||||
const { withDataCache } = require('../../utils/page-cache');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
@@ -58,6 +59,7 @@ function calcNearestRepeatDate(eventDate, today, interval, unit) {
|
||||
}
|
||||
|
||||
Page(withDataCache(withListPagination({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: { loading: true, loadError: false,
|
||||
list: []
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/timeline/timeline.wxml - 左右交替记忆日 -->
|
||||
<app-nav title="记忆日" back="{{true}}" />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const { tryLoginRenewal } = require('../../utils/subscribe');
|
||||
const { getLayout } = require('../../utils/layout');
|
||||
// pages/unbind/unbind.js - 情侣解绑确认页
|
||||
const { api } = require('../../utils/request');
|
||||
|
||||
Page({
|
||||
onLoginSubscribeTap: function() { return tryLoginRenewal(); },
|
||||
onResize: function() { this.setData(getLayout()); },
|
||||
data: {
|
||||
loading: true, loadError: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<view class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<view capture-bind:tap="onLoginSubscribeTap" class="app-shell app-shell--secondary" style="{{layoutStyle}}">
|
||||
<launch-transition />
|
||||
<!-- pages/unbind/unbind.wxml - 情侣解绑确认页 -->
|
||||
<app-nav title="解绑" back="{{true}}" />
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
const test=require('node:test');const assert=require('node:assert/strict');const path=require('node:path');const loader=require('./load-module.cjs');
|
||||
function page(name,api,wxExtra={},app={}){let definition;const storage=new Map([['userId','user']]);const dialogs=[],toasts=[];const wx={getStorageSync:k=>storage.get(k),setStorageSync:(k,v)=>storage.set(k,v),removeStorageSync:k=>storage.delete(k),showModal:o=>dialogs.push(o),showToast:o=>toasts.push(o),showLoading(){},hideLoading(){},reLaunch(){},...wxExtra};loader({Page:p=>definition=p,wx,getApp:()=>app,console,Date,Promise,setTimeout,clearTimeout},{'../../utils/request':{api,getImageUrl:v=>v},'../../utils/page-cache':{withDataCache:p=>p},'../../utils/layout':{getLayout:()=>({})}})(path.join(__dirname,'../pages',name));return {p:{...definition,data:JSON.parse(JSON.stringify(definition.data)),setData(v){Object.assign(this.data,v);}},storage,dialogs,toasts};}
|
||||
const flush=async()=>{for(let i=0;i<30;i++)await Promise.resolve();};
|
||||
test('smoking preference is restored from server on a newly created page, even without local storage',async()=>{
|
||||
let visible=0;const api={checkAuth:async()=>({user:{smoking_card_visible:visible}}),smokingCardVisible:async d=>{visible=d.visible;return {card_visible:visible};}};
|
||||
const first=page('punch/punch.js',api);await first.p.loadSmokingPreference();assert.equal(first.p.data.smokingVisible,false);await first.p.toggleSmoking({detail:{value:true}});assert.equal(visible,1);
|
||||
const installed=page('punch/punch.js',api);await installed.p.loadSmokingPreference();assert.equal(installed.p.data.smokingVisible,true);assert.equal(installed.storage.has('smokingVisible'),false);
|
||||
});
|
||||
test('failed smoking preference save retains the last server state',async()=>{
|
||||
const e=page('punch/punch.js',{checkAuth:async()=>({user:{smoking_card_visible:0}}),smokingCardVisible:async()=>{throw {error:'offline'};}});await e.p.loadSmokingPreference();await e.p.toggleSmoking({detail:{value:true}});assert.equal(e.p.data.smokingVisible,false);assert.equal(e.p.data.smokingSaving,false);
|
||||
});
|
||||
test('announcement is dismissible per revision and server disable immediately hides it',async()=>{
|
||||
let data={enabled:true,title:'公告',content:'Hello',revision:'v1'};const e=page('index/index.js',{announcement:async()=>data});await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,true);e.p.closeAnnouncement();await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,false);data={...data,content:'Updated',revision:'v2'};await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,true);data={...data,enabled:false};await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,false);
|
||||
});
|
||||
test('account deletion does not submit when either confirmation is cancelled',async()=>{
|
||||
for(const cancelAt of [0,1]){let deleted=0;const e=page('settings/settings.js',{accountDeletionPreview:async()=>({summary:{counts:{files:2,photos:1}},confirmation_token:'proof'}),accountDelete:async()=>{deleted++;}});const pending=e.p.deleteAccount();await flush();assert.equal(e.dialogs.length,1);e.dialogs[0].success({confirm:cancelAt!==0});await flush();if(cancelAt===1)e.dialogs[1].success({confirm:false});await pending;assert.equal(deleted,0);assert.equal(e.p._deletingAccount,false);}
|
||||
});
|
||||
test('account deletion submits the server proof only after both confirmations and clears the session',async()=>{
|
||||
let payload,cleared=false;const e=page('settings/settings.js',{accountDeletionPreview:async()=>({summary:{counts:{files:2,photos:1}},confirmation_token:'proof'}),accountDelete:async d=>{payload=d;return {success:true};}},{},{finishAccountDeletion(){cleared=true;}});const pending=e.p.deleteAccount();await flush();e.dialogs[0].success({confirm:true});await flush();assert.equal(payload,undefined);e.dialogs[1].success({confirm:true});await pending;assert.equal(payload.confirmed,true);assert.equal(payload.confirmation_token,'proof');assert.equal(cleared,true);
|
||||
});
|
||||
|
||||
test('announcement login-once mode resets only with a new runtime session and isolates accounts',()=>{
|
||||
const create=()=>loader({}) (path.join(__dirname,'../utils/announcement-session.js'));
|
||||
const session=create(),notice={enabled:true,mode:'login_once',content:'Hello',revision:'v1'};
|
||||
assert.equal(session.shouldShow(notice,'a'),true);session.markShown(notice,'a');assert.equal(session.shouldShow(notice,'a'),false);assert.equal(session.shouldShow(notice,'b'),true);assert.equal(create().shouldShow(notice,'a'),true);
|
||||
session.clear();assert.equal(session.shouldShow(notice,'a'),true);
|
||||
});
|
||||
test('always mode returns on each homepage entry while off and blank content never display',async()=>{
|
||||
let data={enabled:true,mode:'always',content:'Hello',revision:'v1'};const e=page('index/index.js',{announcement:async()=>data});
|
||||
await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,true);e.p.closeAnnouncement();await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,true);
|
||||
data={...data,mode:'off'};await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,false);
|
||||
data={...data,mode:'always',content:' \n '};await e.p.loadAnnouncement();assert.equal(e.p.data.showAnnouncement,false);
|
||||
});
|
||||
|
||||
test('concurrent homepage notice reads share a request and do not dismiss a fresh popup',async()=>{
|
||||
let done,calls=0;const e=page('index/index.js',{announcement:()=>{calls++;return new Promise(resolve=>done=resolve);}});const a=e.p.loadAnnouncement(),b=e.p.loadAnnouncement();assert.equal(calls,1);done({mode:'login_once',enabled:true,content:'hello',revision:'v1'});await Promise.all([a,b]);assert.equal(e.p.data.showAnnouncement,true);
|
||||
});
|
||||
test('a hidden homepage cannot consume a once-per-login notification',async()=>{
|
||||
let done;const e=page('index/index.js',{announcement:()=>new Promise(resolve=>done=resolve)});const a=e.p.loadAnnouncement();e.p.onHide();done({mode:'login_once',enabled:true,content:'hello',revision:'v1'});await a;assert.equal(e.p.data.showAnnouncement,false);e.p._announcementActive=true;const b=e.p.loadAnnouncement();done({mode:'login_once',enabled:true,content:'hello',revision:'v1'});await b;assert.equal(e.p.data.showAnnouncement,true);
|
||||
});
|
||||
@@ -47,11 +47,11 @@ test('blank edit address uses current user location; a supplied address wins',as
|
||||
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},' 手填地址 '));await p.savePhotoInfo();assert.equal(e.calls[1].location,'手填地址');assert.equal(e.calls[1].lat,0);
|
||||
});
|
||||
|
||||
function uploadSetup(auth) {
|
||||
function uploadSetup(auth, uploadError) {
|
||||
let definition;const calls=[],modals=[],toasts=[];
|
||||
const wx={getStorageSync:()=>'',showToast:v=>toasts.push(v),showModal:v=>modals.push(v),showLoading(){},hideLoading(){},switchTab:v=>calls.push(['switchTab',v]),redirectTo:v=>calls.push(['redirectTo',v]),chooseImage:()=>calls.push(['picker'])};
|
||||
const api={checkAuth:async()=>auth(),storageInfo:async()=>({is_unlimited:true}),albumList:async()=>({items:[]}),albumAdd:async()=>{calls.push(['albumAdd']);return {id:'c224bc8c-0848-442d-abf6-350fab34d669'};},photoAdd:async data=>{calls.push(['photoAdd',data]);return {success:true};}};
|
||||
const load=createLoader({wx,console,Date,Promise,Page:p=>definition=p},{'../../utils/request':{api,getImageUrl:v=>v,API_BASE:'https://we.sundynix.cn/api',uploadFileCompat:async()=>{calls.push(['upload']);return {file_id:'6487966a-28ac-41d1-97b6-26f279b11fa9'};}},'../../utils/user-location':{currentUserLocation:async()=>({location:'',lat:0,lng:0})}});
|
||||
const load=createLoader({wx,console,Date,Promise,Page:p=>definition=p},{'../../utils/request':{api,getImageUrl:v=>v,API_BASE:'https://we.sundynix.cn/api',uploadFileCompat:async()=>{calls.push(['upload']);if(uploadError)throw uploadError;return {file_id:'6487966a-28ac-41d1-97b6-26f279b11fa9'};}},'../../utils/user-location':{currentUserLocation:async()=>({location:'',lat:0,lng:0})}});
|
||||
load(path.join(__dirname,'../pages/album/album.js'));
|
||||
const page={...definition,data:{...definition.data,albumId:'c224bc8c-0848-442d-abf6-350fab34d669',uploadFilePaths:['photo.jpg'],photoFormList:[{}]},setData(v){Object.assign(this.data,v);},loadData(){calls.push(['reload']);}};
|
||||
return {page,calls,modals,toasts};
|
||||
@@ -69,3 +69,11 @@ test('stale demo album is replaced with a real personal album before transferrin
|
||||
const e=uploadSetup(()=>({logged_in:true,user:{couple_id:null}}));e.page.data.albumId=-1;await e.page.uploadAll();
|
||||
assert.equal(e.calls[0][0],'albumAdd');assert.equal(e.calls[1][0],'upload');assert.equal(e.calls[2][0],'photoAdd');assert.equal(e.calls[2][1].album_id,'c224bc8c-0848-442d-abf6-350fab34d669');
|
||||
});
|
||||
|
||||
test('quota rejection stops an album batch without another upload or a toast covering the upgrade dialog',async()=>{
|
||||
const e=uploadSetup(()=>({logged_in:true}),{code:10001,error:'空间不足'});
|
||||
e.page.data.uploadFilePaths=['one.jpg','two.jpg','three.jpg'];e.page.data.photoFormList=[{},{},{}];
|
||||
await e.page.uploadAll();
|
||||
assert.equal(e.calls.filter(c=>c[0]==='upload').length,1);assert.equal(e.calls.filter(c=>c[0]==='photoAdd').length,0);
|
||||
assert.equal(e.page.data.uploading,false);assert.equal(e.toasts.length,0);assert.equal(e.page.data.uploadFilePaths.length,3);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ function setup() {
|
||||
let app;
|
||||
const wx = {
|
||||
getStorageSync: key => storage.get(key), setStorageSync: (key,value) => storage.set(key,value),
|
||||
removeStorageSync: key => storage.delete(key), getStorageInfoSync: () => ({keys:[...storage.keys()]}),
|
||||
clearStorageSync:()=>storage.clear(), removeStorageSync: key => storage.delete(key), getStorageInfoSync: () => ({keys:[...storage.keys()]}),
|
||||
login: options => logins.push(options), request: options => requests.push(options),
|
||||
getNetworkType: options => options.success({networkType:'wifi'}), onNetworkStatusChange(){},
|
||||
hideLoading(){}, showToast(){}
|
||||
@@ -19,7 +19,7 @@ function setup() {
|
||||
module:{exports:{}}, require: name => loadModule(path.join(__dirname,'../utils', name + '.js'))};
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../utils/request.js'),'utf8'),ctx);
|
||||
const request = ctx.module.exports;
|
||||
ctx.require = () => request;
|
||||
ctx.require = name => name === './utils/request' ? request : loadModule(path.join(__dirname,'..', name + '.js'));
|
||||
ctx.App = definition => {app=definition;};
|
||||
vm.runInNewContext('(function(){' + fs.readFileSync(path.join(__dirname,'../app.js'),'utf8') + '\n})()',ctx);
|
||||
return {app,wx,storage,logins,requests,api:request.api};
|
||||
@@ -70,3 +70,7 @@ test('parallel 401 responses share token refresh and retry at most once',async()
|
||||
retried[0].success({statusCode:401,data:{error:'still unauthorized'}});retried[1].success({statusCode:200,data:[]});
|
||||
await failed;await two;assert.equal(env.logins.length,2);
|
||||
});
|
||||
|
||||
test('account deletion clears drafts and credentials and prevents automatic account recreation',async()=>{
|
||||
const env=setup();env.storage.set('token','private');env.storage.set('publishDraft','private draft');env.app.finishAccountDeletion();assert.equal(env.storage.has('publishDraft'),false);assert.equal(env.storage.has('token'),false);assert.equal(env.storage.get('accountDeleted'),true);assert.equal(env.app.globalData.authState,'error');await assert.rejects(env.app.refreshAuth('private'));assert.equal(env.logins.length,0);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ test('identical reads share a request, return independent objects and expire', a
|
||||
a.items[0].title='formatted';assert.equal(b.items[0].title,'old');
|
||||
assert.equal((await e.api.diaryList({page:1,page_size:20})).items[0].title,'old');assert.equal(e.requests.length,1);
|
||||
const different=e.api.diaryList({page:2,page_size:20});await e.flush();assert.equal(e.requests.length,2);e.respond(1,{items:[]});await different;
|
||||
e.advance(45001);const expired=e.api.diaryList({page:1,page_size:20});await e.flush();assert.equal(e.requests.length,3);e.respond(2,{items:[]});await expired;
|
||||
e.advance(e.cache.TTL+1);const expired=e.api.diaryList({page:1,page_size:20});await e.flush();assert.equal(e.requests.length,3);e.respond(2,{items:[]});await expired;
|
||||
});
|
||||
test('successful mutations invalidate related reads; unrelated and failed writes retain cache',async()=>{
|
||||
const e=setup();const diary=e.api.diaryList(),album=e.api.albumList();await e.flush();e.respond(0,{items:[]});e.respond(1,[]);await Promise.all([diary,album]);
|
||||
@@ -50,7 +50,7 @@ test('each upper page observes changes independently and pull refresh bypasses c
|
||||
await Promise.all([a.loadData(),b.loadData()]);assert.equal(loads,2);
|
||||
e.cache.mutation('diaryUpdate',{success:true});pending=Promise.all([a.loadData(),b.loadData()]);await e.flush();assert.equal(e.requests.length,2);e.respond(1,{items:[{id:1}]});await pending;assert.equal(loads,4);
|
||||
pending=a.onPullDownRefresh();await e.flush();assert.equal(e.requests.length,3);e.respond(2,{items:[]});await pending;
|
||||
e.advance(45001);pending=b.loadData();await e.flush();assert.equal(e.requests.length,4);e.respond(3,{items:[]});await pending;
|
||||
e.advance(e.cache.TTL+1);pending=b.loadData();await e.flush();assert.equal(e.requests.length,4);e.respond(3,{items:[]});await pending;
|
||||
});
|
||||
test('permission reads remain live and longer profile TTL does not extend list TTL',async()=>{
|
||||
const e=setup();let p=e.api.periodList();await e.flush();e.respond(0,{cycles:[]});await p;
|
||||
@@ -99,3 +99,19 @@ test('photo metadata updates invalidate album and photo caches',async()=>{
|
||||
pending=e.api.photoUpdate({id:1,taken_at:'',location:'公园',activity:'散步'});await e.flush();e.respond(2,{success:true});await pending;
|
||||
pending=Promise.all([e.api.photos(7),e.api.albumList()]);await e.flush();assert.equal(e.requests.length,5);e.respond(3,[]);e.respond(4,[]);await pending;
|
||||
});
|
||||
|
||||
test('storage reads are reused and upload or confirmed payment invalidates their usage',async()=>{
|
||||
const e=setup();let p=e.api.storageInfo();await e.flush();e.respond(0,{used_bytes:10});await p;
|
||||
await e.api.storageInfo();assert.equal(e.requests.length,1);
|
||||
e.cache.mutation('upload',{success:true});p=e.api.storageInfo();await e.flush();assert.equal(e.requests.length,2);e.respond(1,{used_bytes:20});await p;
|
||||
e.cache.mutation('storageOrderStatus',{status:'paid'});p=e.api.storageInfo();await e.flush();assert.equal(e.requests.length,3);e.respond(2,{quota_bytes:1024});await p;
|
||||
});
|
||||
test('remote versions retain unchanged data and invalidate every page after partner or admin changes',async()=>{
|
||||
const e=setup();e.cache.applyRemoteRevision('initial');let loads=0;
|
||||
const page=e.withDataCache({data:{},async load(){loads++;this.data.items=await e.api.diaryList();}},{load:['diaryList']});
|
||||
let p=page.load();await e.flush();e.respond(0,{items:[]});await p;
|
||||
assert.equal(e.cache.applyRemoteRevision('initial'),false);await page.load();assert.equal(e.requests.length,1);
|
||||
assert.equal(e.cache.applyRemoteRevision('partner-change'),true);p=page.onDataChanged();await e.flush();e.respond(1,{items:[{id:1}]});await p;assert.equal(loads,2);assert.equal(page.data.items.items[0].id,1);
|
||||
const stale=e.api.photos(7);await e.flush();e.cache.applyRemoteRevision('admin-change');e.respond(2,{items:[{id:'old'}]});await stale;
|
||||
p=e.api.photos(7);await e.flush();assert.equal(e.requests.length,4);e.respond(3,{items:[{id:'new'}]});await p;
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ function client(extra = {}) {
|
||||
const calls = [];
|
||||
const load = createLoader({console, Promise, Date, wx: {
|
||||
getStorageSync: () => 'token', hideLoading() {},
|
||||
getFileInfo(o){o.success({size:100,digest:'a'.repeat(32)});},
|
||||
request(o) { calls.push(o); o.success({statusCode:200, data:{success:true}}); }, ...extra
|
||||
}});
|
||||
return {...load(path.join(__dirname, '../utils/request.js')), calls};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const path=require('node:path');
|
||||
function setup(){
|
||||
const id='K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU';
|
||||
const storage={userId:'user',coupleId:'couple',token:'token'},reads=[],prompts=[],saves=[],ui=[];
|
||||
const app={globalData:{authState:'ready'}};
|
||||
const load=require('./load-module.cjs')({console:{warn(){},error(){}},Promise,Date,getApp:()=>app,wx:{
|
||||
getStorageSync:key=>storage[key],setStorageSync:(key,value)=>storage[key]=value,
|
||||
getSetting:o=>reads.push(o),requestSubscribeMessage:o=>prompts.push(o),
|
||||
showToast:o=>ui.push(o),showModal:o=>ui.push(o)
|
||||
}},{'./request':{api:{subscribeTemplate:async(data,options)=>{saves.push({data,options});return {success:true}}}}});
|
||||
const helper=load(path.join(__dirname,'../utils/subscribe.js'));
|
||||
const ready=(settings={mainSwitch:true,itemSettings:{[id]:'accept'}})=>reads.at(-1).success({subscriptionsSetting:settings});
|
||||
return {id,storage,reads,prompts,saves,ui,app,helper,ready};
|
||||
}
|
||||
test('login only reads remembered settings; first real tap renews synchronously once per cold session',async()=>{
|
||||
const e=setup();e.helper.resetLoginRenewal();e.helper.prepareLoginRenewal();
|
||||
assert.equal(e.reads[0].withSubscriptions,true);assert.equal(e.prompts.length,0);assert.equal(e.helper.tryLoginRenewal(),undefined);
|
||||
e.ready();const pending=e.helper.tryLoginRenewal();assert.equal(e.prompts.length,1);
|
||||
assert.equal(e.helper.tryLoginRenewal(),undefined);
|
||||
e.prompts[0].success({[e.id]:'accept'});await pending;
|
||||
assert.equal(e.saves[0].data.template_type,'notify');assert.equal(e.saves[0].options.silent,true);assert.equal(e.ui.length,0);
|
||||
e.ready();e.helper.prepareLoginRenewal();e.ready();e.helper.tryLoginRenewal();assert.equal(e.prompts.length,1);
|
||||
e.helper.resetLoginRenewal();e.helper.prepareLoginRenewal();e.ready();e.helper.tryLoginRenewal();assert.equal(e.prompts.length,2);
|
||||
});
|
||||
test('unknown permission, not remembered, rejected, banned and disabled main switch never request subscription',()=>{
|
||||
for(const settings of [{},{mainSwitch:true},{mainSwitch:true,itemSettings:{}},{mainSwitch:true,itemSettings:{bad:'accept'}},{mainSwitch:true,itemSettings:{K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU:'reject'}},{mainSwitch:false,itemSettings:{K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU:'accept'}}]){
|
||||
const e=setup();e.helper.prepareLoginRenewal();e.ready(settings);e.helper.tryLoginRenewal();assert.equal(e.prompts.length,0);assert.equal(e.ui.length,0);
|
||||
}
|
||||
});
|
||||
test('unbound, locally disabled or unauthenticated users never auto-renew',()=>{
|
||||
for(const change of [e=>e.storage.coupleId='',e=>e.storage.subscribed_notify=false,e=>e.app.globalData.authState='loading']){
|
||||
const e=setup();e.helper.prepareLoginRenewal();e.ready();change(e);e.helper.tryLoginRenewal();assert.equal(e.prompts.length,0);
|
||||
}
|
||||
});
|
||||
test('settings failures and stale callbacks fail closed',()=>{
|
||||
const e=setup();e.helper.prepareLoginRenewal();e.reads[0].fail();e.helper.tryLoginRenewal();assert.equal(e.prompts.length,0);
|
||||
e.helper.prepareLoginRenewal();const stale=e.reads.at(-1);e.helper.resetLoginRenewal();stale.success({subscriptionsSetting:{mainSwitch:true,itemSettings:{[e.id]:'accept'}}});e.helper.tryLoginRenewal();assert.equal(e.prompts.length,0);
|
||||
e.helper.prepareLoginRenewal();e.ready();e.helper.prepareLoginRenewal();e.helper.tryLoginRenewal();assert.equal(e.prompts.length,0);
|
||||
});
|
||||
test('renewal failures remain silent and do not retry repeatedly during the same session',async()=>{
|
||||
const e=setup();e.helper.prepareLoginRenewal();e.ready();const pending=e.helper.tryLoginRenewal();e.prompts[0].fail({errCode:20001});await pending;
|
||||
assert.equal(e.ui.length,0);e.ready();e.helper.tryLoginRenewal();assert.equal(e.prompts.length,1);
|
||||
});
|
||||
@@ -25,7 +25,7 @@ test('native custom tabs retain the three main pages; pages never mount a duplic
|
||||
test('component selects the main page, hides on child routes and avoids reloading its current tab',()=>{
|
||||
let definition,route='pages/profile/profile',pageData={};const navigations=[];
|
||||
vm.runInNewContext(fs.readFileSync(path.join(root,'custom-tab-bar/index.js'),'utf8'),{
|
||||
Component:c=>definition=c,require:()=>({tabForPage,paths}),getCurrentPages:()=>[{route,data:pageData}],wx:{switchTab:o=>{navigations.push(o.url);o.complete();},reLaunch(){assert.fail('normal tab switching must retain pages');}}
|
||||
Component:c=>definition=c,require:name=>name.includes('subscribe')?{tryLoginRenewal(){}}:({tabForPage,paths}),getCurrentPages:()=>[{route,data:pageData}],wx:{switchTab:o=>{navigations.push(o.url);o.complete();},reLaunch(){assert.fail('normal tab switching must retain pages');}}
|
||||
});
|
||||
const component={...definition.methods,data:{...definition.data},setData(v){Object.assign(this.data,v);}};
|
||||
definition.lifetimes.attached.call(component);assert.equal(component.data.active,'profile');
|
||||
|
||||
@@ -19,7 +19,7 @@ test('GET business parameters and POST bodies use clean paths with auth', async
|
||||
});
|
||||
test('multipart upload retains action and token when Android omits headers',async()=>{
|
||||
let upload;
|
||||
const {uploadFileCompat}=load('utils/request.js',{getStorageSync:()=> 'test-token',compressImage(o){o.fail();},uploadFile(o){upload=o;o.success({statusCode:200,data:'{"success":true,"path":"uploads/test.jpg"}'});} });
|
||||
const {uploadFileCompat}=load('utils/request.js',{getStorageSync:()=> 'test-token',getFileInfo(o){o.success({size:100,digest:'a'.repeat(32)});},request(o){o.success({statusCode:200,data:{success:true}});},compressImage(o){o.fail();},uploadFile(o){upload=o;o.success({statusCode:200,data:'{"success":true,"path":"uploads/test.jpg"}'});} });
|
||||
await uploadFileCompat({url:'https://we.sundynix.cn/api/uploadPartnerAvatar',filePath:'test.jpg',name:'file',formData:{partner_id:87}});
|
||||
assert.equal(upload.url,'https://we.sundynix.cn/api/uploadPartnerAvatar?token=test-token');
|
||||
assert.equal(upload.formData.action,'uploadPartnerAvatar');
|
||||
@@ -59,3 +59,27 @@ test('unbound newcomer loads homepage records without requesting couple-only pet
|
||||
assert.equal(page.data.homeError,false);assert.equal(page.data.statsError,false);assert.equal(page.data.statsLoading,false);
|
||||
assert.equal(page.data.recentDiary.length,0);assert.equal(page.data.stats.pet,0);
|
||||
});
|
||||
|
||||
test('smoking visibility page uses the real request adapter and restores the persisted response',async()=>{
|
||||
let definition,visible=1;const calls=[];
|
||||
const wx={getStorageSync:()=> 'test-token',showToast(){},request(o){
|
||||
calls.push(o);
|
||||
if(o.url.endsWith('/checkAuth'))o.success({statusCode:200,data:{user:{smokingCardVisible:visible}}});
|
||||
else if(o.url.endsWith('/smokingCardVisible')){visible=o.data.visible;o.success({statusCode:200,data:{success:true,cardVisible:visible}});}
|
||||
else throw new Error('Unexpected request: '+o.url);
|
||||
}};
|
||||
require('./load-module.cjs')({wx,console,Date,Promise,Page:p=>definition=p},{'../../utils/page-cache':{withDataCache:p=>p}})(path.join(__dirname,'../pages/punch/punch.js'));
|
||||
const makePage=()=>({...definition,data:JSON.parse(JSON.stringify(definition.data)),setData(p){Object.assign(this.data,p);}});
|
||||
const page=makePage();await page.loadSmokingPreference();assert.equal(page.data.smokingVisible,true);
|
||||
await page.toggleSmoking({detail:{value:false}});assert.equal(visible,0);assert.equal(page.data.smokingVisible,false);
|
||||
const save=calls.find(c=>c.url.endsWith('/smokingCardVisible'));assert.equal(save.method,'POST');assert.equal(save.data.visible,0);assert.equal(save.header.Authorization,'Bearer test-token');
|
||||
const reopened=makePage();await reopened.loadSmokingPreference();assert.equal(reopened.data.smokingVisible,false);
|
||||
await reopened.toggleSmoking({detail:{value:true}});assert.equal(visible,1);assert.equal(reopened.data.smokingVisible,true);
|
||||
});
|
||||
|
||||
test('silent subscription persistence never hides a business spinner or shows a network toast',async()=>{
|
||||
const ui=[];
|
||||
const {api}=load('utils/request.js',{getStorageSync:()=> 'token',hideLoading:()=>ui.push('hide'),showToast:()=>ui.push('toast'),request:o=>o.fail({errMsg:'offline'})});
|
||||
await assert.rejects(api.subscribeTemplate({template_type:'notify',template_id:'template'},{silent:true}));
|
||||
assert.equal(ui.length,0);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ test('finite paid space uses its quota, zero is respected, unlimited is explicit
|
||||
test('storage page renders 1-cent plans and custom month/capacity values',async()=>{
|
||||
let definition;
|
||||
const info={freeBytes:200*1024*1024,quotaBytes:1024*1024*1024,usedBytes:512*1024*1024,isVip:true,isUnlimited:false,vipExpireAt:'2026-12-02',plans:[{key:'custom',name:'测试套餐',priceCents:1,quotaMb:1024,months:3}]};
|
||||
const load=loader({Page(p){definition=p},wx:{},console},{'../../utils/request':{api:{storageInfo:async()=>info}}});
|
||||
const load=loader({Page(p){definition=p},wx:{getStorageSync:()=> 'user'},console},{'../../utils/request':{api:{storageInfo:async()=>info}}});
|
||||
load(path.join(__dirname,'../pages/storage/storage.js'));
|
||||
const page={...definition,data:{...definition.data},setData(d){Object.assign(this.data,d)}};
|
||||
await page.loadInfo();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const path=require('node:path');
|
||||
const template='tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8';
|
||||
const template='K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU';
|
||||
function setup(save){
|
||||
const storage={coupleId:10},calls=[];
|
||||
const wx={getStorageSync:key=>storage[key],setStorageSync:(key,v)=>storage[key]=v,requestSubscribeMessage:o=>calls.push(o)};
|
||||
@@ -24,18 +24,6 @@ test('failed persistence never reports successful subscription and a later tap c
|
||||
fail=false;pending=e.helper.requestSubscribe('notify');assert.equal(e.calls.length,2);e.calls[1].success({[template]:'accept'});
|
||||
assert.equal((await pending).notify,true);
|
||||
});
|
||||
test('each subsequent user action can replenish authorization; denial does not prevent later attempts',async()=>{
|
||||
const e=setup(async()=>({success:true}));
|
||||
let pending=e.helper.requestSubscribeAfterAction('diary');assert.equal(e.calls.length,1);e.calls[0].success({[template]:'reject'});await pending;
|
||||
pending=e.helper.requestSubscribeAfterAction('photo');assert.equal(e.calls.length,2);e.calls[1].success({[template]:'accept'});await pending;
|
||||
pending=e.helper.requestSubscribeAfterAction('checkin');assert.equal(e.calls.length,3);e.calls[2].fail(Error('cancelled'));await pending;
|
||||
pending=e.helper.requestSubscribeAfterAction('checkin');assert.equal(e.calls.length,4);e.calls[3].success({[template]:'accept'});await pending;
|
||||
});
|
||||
test('explicitly disabled notifications and unbound users do not prompt during business actions',async()=>{
|
||||
const e=setup(async()=>({success:true}));e.storage.subscribed_notify=false;await e.helper.requestSubscribeAfterAction('diary');assert.equal(e.calls.length,0);
|
||||
delete e.storage.subscribed_notify;delete e.storage.coupleId;await e.helper.requestSubscribeAfterAction('diary');assert.equal(e.calls.length,0);
|
||||
});
|
||||
|
||||
test('manual renewal requests authorization again without disabling existing notifications',async()=>{
|
||||
let page, calls=0, cancelled=0, complete;
|
||||
const loader=require('./load-module.cjs')({Page:p=>page=p,wx:{showToast(){}}},{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const path=require('node:path');
|
||||
function setup({preflightQuota=false,uploadQuota=false,networkRetry=false,metadataFailure=false}={}){
|
||||
const checks=[],uploads=[],modals=[],navigation=[];
|
||||
const load=require('./load-module.cjs')({console,Date,Promise,wx:{
|
||||
getStorageSync:()=> 'token',hideLoading(){},showLoading(){},showToast(){},
|
||||
compressImage(o){o.success({tempFilePath:'compressed.jpg'});},
|
||||
getFileInfo(o){if(metadataFailure)return o.fail();o.success({size:o.filePath==='compressed.jpg'?100:200,digest:'a'.repeat(32)});},
|
||||
request(o){checks.push(o);o.success({statusCode:preflightQuota?403:200,data:preflightQuota?{error:'空间不足',code:10001}:{success:true}});},
|
||||
uploadFile(o){uploads.push(o);if(networkRetry&&uploads.length===1)return o.fail({errMsg:'network'});o.success({statusCode:uploadQuota?403:200,data:JSON.stringify(uploadQuota?{error:'空间不足',code:10001}:{success:true,fileId:'file-id'})});},
|
||||
showModal(o){modals.push(o);},navigateTo(o){navigation.push(o.url);}
|
||||
}});
|
||||
const client=load(path.join(__dirname,'../utils/request.js'));
|
||||
return {...client,checks,uploads,modals,navigation};
|
||||
}
|
||||
const options={url:'https://we.sundynix.cn/api/upload',filePath:'original.jpg'};
|
||||
test('preflight checks the compressed bytes before any upload',async()=>{
|
||||
const c=setup();await c.uploadFileCompat(options);
|
||||
assert.equal(c.checks[0].url,'https://we.sundynix.cn/api/uploadPreflight');
|
||||
assert.equal(c.checks[0].data.size,100);assert.equal(c.checks[0].data.md5,'a'.repeat(32));
|
||||
assert.equal(c.uploads[0].filePath,'compressed.jpg');
|
||||
});
|
||||
test('preflight quota rejection blocks bytes, prompts once and offers purchase',async()=>{
|
||||
const c=setup({preflightQuota:true});
|
||||
await assert.rejects(c.uploadFileCompat(options),e=>e.code===10001);
|
||||
assert.equal(c.uploads.length,0);assert.equal(c.checks.length,1);assert.equal(c.modals.length,1);
|
||||
assert.equal(c.modals[0].title,'存储空间不足');c.modals[0].success({confirm:true});
|
||||
assert.deepEqual(c.navigation,['/pages/storage/storage']);
|
||||
});
|
||||
test('authoritative backend quota rejection after a successful preflight also prompts',async()=>{
|
||||
const c=setup({uploadQuota:true});await assert.rejects(c.uploadFileCompat(options),e=>e.code===10001);
|
||||
assert.equal(c.uploads.length,1);assert.equal(c.modals.length,1);
|
||||
c.modals[0].success({confirm:false});assert.equal(c.navigation.length,0);
|
||||
});
|
||||
test('avatar, partner avatar and album uploads all use quota preflight and show one modal on rejection',async()=>{
|
||||
for(const run of [c=>c.api.uploadAvatar('original.jpg'),c=>c.api.uploadPartnerAvatar('original.jpg','partner'),c=>c.api.photoUpload('original.jpg','album')]){
|
||||
const c=setup({uploadQuota:true});await assert.rejects(run(c),e=>e.code===10001);
|
||||
assert.equal(c.checks.length,1);assert.equal(c.uploads.length,1);assert.equal(c.modals.length,1);
|
||||
}
|
||||
});
|
||||
test('network fallback rechecks the original file size; unreadable files never upload',async()=>{
|
||||
const c=setup({networkRetry:true});await c.uploadFileCompat(options);
|
||||
assert.deepEqual(c.checks.map(o=>o.data.size),[100,200]);assert.equal(c.uploads[1].filePath,'original.jpg');
|
||||
const bad=setup({metadataFailure:true});await assert.rejects(bad.uploadFileCompat(options));assert.equal(bad.uploads.length,0);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// Memory only: a cold start begins a new login session. Never persist this in storage.
|
||||
const seen = new Map();
|
||||
function shouldShow(notice, userId) {
|
||||
if (!notice || !notice.enabled || !String(notice.content || '').trim()) return false;
|
||||
const mode = notice.mode || 'login_once';
|
||||
if (mode === 'off') return false;
|
||||
if (mode === 'always') return true;
|
||||
return mode === 'login_once' && seen.get(String(userId || '')) !== notice.revision;
|
||||
}
|
||||
function markShown(notice, userId) {
|
||||
if (notice && (notice.mode || 'login_once') === 'login_once') seen.set(String(userId || ''), notice.revision);
|
||||
}
|
||||
function clear() { seen.clear(); }
|
||||
module.exports = { shouldShow, markShown, clear };
|
||||
@@ -1,7 +1,8 @@
|
||||
// 仅保存本次进程内的数据,不写入手机本地存储。
|
||||
const TTL = 45000;
|
||||
const TTL = 300000;
|
||||
const policies = {
|
||||
coupleInfo: 300000, getBgConfig: 300000,
|
||||
coupleInfo: TTL, getBgConfig: TTL, myInfo: TTL, storageInfo: TTL, announcement: TTL,
|
||||
checkAuth: 30000, unbindStatus: 30000,
|
||||
timelineList: TTL, timelineDetail: TTL, albumList: TTL, photos: TTL,
|
||||
diaryList: TTL, diaryDetail: TTL, diaryCommentList: TTL,
|
||||
songsList: TTL, checkinList: TTL, checkinStats: TTL, checkinDetail: TTL,
|
||||
@@ -18,12 +19,20 @@ let entries = new Map();
|
||||
let versions = {};
|
||||
let scope = '';
|
||||
let epoch = 0;
|
||||
let remoteRevision;
|
||||
function currentScope() {
|
||||
const next = String(wx.getStorageSync('userId') || '') + ':' + String(wx.getStorageSync('coupleId') || '');
|
||||
if (next !== scope) { scope = next; clear(); }
|
||||
return scope;
|
||||
}
|
||||
function clear() { entries.clear(); versions = {}; epoch++; }
|
||||
function clear() { entries.clear(); versions = {}; remoteRevision = undefined; epoch++; }
|
||||
function applyRemoteRevision(revision) {
|
||||
currentScope();
|
||||
if (!revision) return false;
|
||||
if (remoteRevision === undefined) { remoteRevision = revision; return false; }
|
||||
if (remoteRevision === revision) return false;
|
||||
clear(); remoteRevision = revision; return true;
|
||||
}
|
||||
function signature(actions) {
|
||||
currentScope();
|
||||
return epoch + ':' + actions.map(action => action + ':' + (versions[action] || 0)).join('|');
|
||||
@@ -82,7 +91,10 @@ function mutation(action, result) {
|
||||
else if (/^pet(Adopt|DailyAction|Rename)$/.test(action)) group = 'pet';
|
||||
else if (/^(setBgImage|deleteBgImage)$/.test(action)) group = 'background';
|
||||
if (group) invalidate(groups[group].concat(['activityLogList']));
|
||||
if (group === 'couple') invalidate(['myInfo','checkAuth']);
|
||||
if (action === 'smokingCardVisible') invalidate(['checkAuth','myInfo']);
|
||||
if (/^(upload|photoUpload|uploadAvatar|uploadPartnerAvatar|setBgImage|deleteBgImage|setAlbumCover|photoDelete|albumDelete|accountDelete)$/.test(action) || (action === 'storageOrderStatus' && result && result.status === 'paid')) invalidate(['storageInfo']);
|
||||
// 展示作者资料的列表也需要更新头像/昵称。
|
||||
if (group === 'couple') invalidate(groups.timeline.concat(groups.diary, groups.checkin));
|
||||
}
|
||||
module.exports = { read, invalidate, mutation, clear, signature, policies, TTL };
|
||||
module.exports = { read, invalidate, mutation, clear, signature, policies, TTL, applyRemoteRevision };
|
||||
|
||||
@@ -17,12 +17,12 @@ function withDataCache(definition, loaders) {
|
||||
const ttl = Math.min(...dependencies.map(action => cache.policies[action] || cache.TTL));
|
||||
if (previous && previous.signature === signature && previous.key === key) {
|
||||
if (previous.pending) return previous.pending;
|
||||
if (Date.now() - previous.time < ttl && !this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError) return Promise.resolve();
|
||||
if (Date.now() - previous.time < ttl && !this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError && !this.data.error && !this.data.smokingError) return Promise.resolve();
|
||||
}
|
||||
const record = { signature, key, time: 0 };
|
||||
this._dataLoads[name] = record;
|
||||
record.pending = Promise.resolve().then(() => load.apply(this, args)).then(result => {
|
||||
if (!this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError) {
|
||||
if (!this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError && !this.data.error && !this.data.smokingError) {
|
||||
record.time = Date.now();
|
||||
if (name !== 'loadComments') this._hasCachedData = true;
|
||||
}
|
||||
@@ -32,6 +32,9 @@ function withDataCache(definition, loaders) {
|
||||
return record.pending;
|
||||
};
|
||||
});
|
||||
definition.onDataChanged = function() {
|
||||
return Promise.all(Object.keys(loaders).map(name => this[name]())).catch(error => { console.warn('刷新变动数据失败', error); });
|
||||
};
|
||||
['onPullDownRefresh', 'onRefresh'].forEach(name => {
|
||||
const refresh = definition[name];
|
||||
if (refresh) definition[name] = function(...args) {
|
||||
|
||||
+63
-132
@@ -69,16 +69,22 @@ var STORAGE_QUOTA_EXCEEDED = 10001;
|
||||
function isStorageQuotaError(data) {
|
||||
return !!(data && data.code === STORAGE_QUOTA_EXCEEDED);
|
||||
}
|
||||
var storageModalOpen = false;
|
||||
function maybeRedirectStorage(data) {
|
||||
if (!isStorageQuotaError(data)) return false;
|
||||
if (data._storagePromptShown || storageModalOpen) return true;
|
||||
data._storagePromptShown = true;
|
||||
storageModalOpen = true;
|
||||
try { wx.hideLoading(); } catch(e) {}
|
||||
wx.showModal({
|
||||
title: '存储空间不足',
|
||||
content: '存储空间已用完,请选择更大容量的套餐后继续上传。',
|
||||
content: '剩余存储空间不足以保存这张图片,请购买更大容量的套餐后继续上传。',
|
||||
confirmText: '去升级',
|
||||
cancelText: '暂不',
|
||||
success: function (res) {
|
||||
if (res.confirm) wx.navigateTo({ url: '/pages/storage/storage' });
|
||||
}
|
||||
},
|
||||
complete: function() { storageModalOpen = false; }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -96,32 +102,33 @@ async function waitForAuth() {
|
||||
var app = authApp();
|
||||
if (app && app.ensureAuth) await app.ensureAuth();
|
||||
}
|
||||
async function request(action, data = null, method = 'POST') {
|
||||
async function request(action, data = null, method = 'POST', options = {}) {
|
||||
const name = camelName(action);
|
||||
await waitForAuthIfNeeded(name);
|
||||
if (method === 'GET') return dataCache.read(name, toWire(data), () => authenticatedRequest(action, data, method));
|
||||
const result = await authenticatedRequest(action, data, method);
|
||||
if (method === 'GET' && options.cache === false) return authenticatedRequest(action, data, method, options);
|
||||
if (method === 'GET') return dataCache.read(name, toWire(data), () => authenticatedRequest(action, data, method, options));
|
||||
const result = await authenticatedRequest(action, data, method, options);
|
||||
dataCache.mutation(name, result);
|
||||
return result;
|
||||
}
|
||||
async function waitForAuthIfNeeded(action) {
|
||||
if (action !== 'wechatLogin') await waitForAuth();
|
||||
}
|
||||
async function authenticatedRequest(action, data = null, method = 'POST') {
|
||||
if (camelName(action) === 'wechatLogin') return sendRequest(action, data, method);
|
||||
async function authenticatedRequest(action, data = null, method = 'POST', options = {}) {
|
||||
if (camelName(action) === 'wechatLogin') return sendRequest(action, data, method, options);
|
||||
await waitForAuth();
|
||||
var token = wx.getStorageSync('token');
|
||||
try {
|
||||
return await sendRequest(action, data, method);
|
||||
return await sendRequest(action, data, method, options);
|
||||
} catch (error) {
|
||||
var app = authApp();
|
||||
if (error._statusCode !== 401 || !app || !app.refreshAuth) throw error;
|
||||
await app.refreshAuth(token);
|
||||
// 每个业务请求最多重试一次,防止 401 导致无限登录。
|
||||
return sendRequest(action, data, method);
|
||||
return sendRequest(action, data, method, options);
|
||||
}
|
||||
}
|
||||
function sendRequest(action, data = null, method = 'POST') {
|
||||
function sendRequest(action, data = null, method = 'POST', options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = wx.getStorageSync('token');
|
||||
const header = { 'Content-Type': 'application/json' };
|
||||
@@ -161,16 +168,20 @@ function sendRequest(action, data = null, method = 'POST') {
|
||||
const previous = String(wx.getStorageSync('coupleId') || '');
|
||||
if (next !== previous) { wx.setStorageSync('coupleId', next); dataCache.clear(); const app = typeof getApp === 'function' && getApp(); if(app && app.onRelationChanged) app.onRelationChanged(); }
|
||||
}
|
||||
try { wx.hideLoading(); } catch(e) {}
|
||||
if (!options.silent) { try { wx.hideLoading(); } catch(e) {} }
|
||||
if (res.statusCode >= 400 || (res.data && res.data.error)) {
|
||||
reject({ ...fromWire(res.data), _statusCode: res.statusCode });
|
||||
} else {
|
||||
resolve(fromWire(res.data));
|
||||
const result = fromWire(res.data);
|
||||
if (camelName(action) === 'storageOrderStatus') dataCache.mutation('storageOrderStatus', result);
|
||||
resolve(result);
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
try { wx.hideLoading(); } catch(e) {}
|
||||
wx.showToast({ title: '网络请求失败', icon: 'none' });
|
||||
if (!options.silent) {
|
||||
try { wx.hideLoading(); } catch(e) {}
|
||||
wx.showToast({ title: '网络请求失败', icon: 'none' });
|
||||
}
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
@@ -179,54 +190,17 @@ function sendRequest(action, data = null, method = 'POST') {
|
||||
|
||||
// ========== API 方法封装 ==========
|
||||
const api = {
|
||||
announcement: () => request('announcement', null, 'GET'),
|
||||
accountDeletionPreview: () => request('accountDeletionPreview', null, 'POST'),
|
||||
accountDelete: (data) => request('accountDelete', data),
|
||||
// 用户相关
|
||||
wechatLogin: (code, region) => request('wechatLogin', { code: code, region: region || '' }),
|
||||
checkAuth: () => request('checkAuth', null, 'GET'),
|
||||
checkAuth: (options) => request('checkAuth', null, 'GET', options),
|
||||
logout: () => request('logout', null, 'POST'),
|
||||
updateProfile: (data) => request('updateProfile', data),
|
||||
updateLocation: (location) => request('updateLocation', { location: location }),
|
||||
updateIpLocation: (data) => request('updateIpLocation', data || {}, 'POST'),
|
||||
_uploadFileDirect: async function(url, filePath, name, formData) {
|
||||
await waitForAuth();
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = wx.getStorageSync('token');
|
||||
var uploadUrl = url;
|
||||
var sep = uploadUrl.indexOf('?') > -1 ? '&' : '?';
|
||||
uploadUrl += sep + 'token=' + encodeURIComponent(token || '');
|
||||
var finalFormData = toWire(formData || {});
|
||||
finalFormData.token = token || '';
|
||||
// 从 URL 中提取 action 参数并添加到 formData(解决非开发者模式下 URL 参数被剥离的问题)
|
||||
var actionMatch = url.match(/\/api\/([^/?]+)|[?&]action=([^&]+)/);
|
||||
if (actionMatch) finalFormData.action = decodeURIComponent(actionMatch[1] || actionMatch[2]);
|
||||
var finalHeader = {};
|
||||
if (token) finalHeader['Authorization'] = 'Bearer ' + token;
|
||||
function doUpload(fp) {
|
||||
return new Promise((res, rej) => {
|
||||
wx.uploadFile({
|
||||
url: uploadUrl,
|
||||
filePath: fp,
|
||||
name: name || 'file',
|
||||
header: finalHeader,
|
||||
formData: finalFormData,
|
||||
timeout: 120000,
|
||||
success: (r) => {
|
||||
var parsed = parseUploadResponse(r.data);
|
||||
if (parsed) res(parsed);
|
||||
else rej({ error: '解析响应失败', raw: r.data });
|
||||
},
|
||||
fail: (e) => rej({ error: '网络异常', errMsg: e.errMsg })
|
||||
});
|
||||
});
|
||||
}
|
||||
compressForUpload(filePath).then((compressedPath) => {
|
||||
doUpload(compressedPath).then(resolve).catch(() => {
|
||||
doUpload(filePath).then(resolve).catch(reject);
|
||||
});
|
||||
}).catch(() => {
|
||||
doUpload(filePath).then(resolve).catch(reject);
|
||||
});
|
||||
});
|
||||
},
|
||||
_uploadFileDirect: (url, filePath, name, formData) => uploadFileCompat({url, filePath, name, formData}),
|
||||
|
||||
uploadAvatar: async (filePath) => {
|
||||
wx.showLoading({ title: '上传中...' });
|
||||
@@ -239,12 +213,11 @@ 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' });
|
||||
if (!maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
|
||||
throw data || { error: '上传失败' };
|
||||
} catch(e) {
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: formatUploadError(e), icon: 'none' });
|
||||
if (!maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
@@ -260,12 +233,11 @@ 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' });
|
||||
if (!maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
|
||||
throw data || { error: '上传失败' };
|
||||
} catch(e) {
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: formatUploadError(e), icon: 'none' });
|
||||
if (!maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
@@ -303,67 +275,16 @@ const api = {
|
||||
photoUpload: async (filePath, albumId) => {
|
||||
wx.showLoading({ title: '上传中...' });
|
||||
try {
|
||||
await waitForAuth();
|
||||
// 直接使用 wx.uploadFile,兼容安卓端不发送自定义 header 的问题
|
||||
const token = wx.getStorageSync('token');
|
||||
const uploadUrl = API_BASE + '/photoUpload?token=' + encodeURIComponent(token || '');
|
||||
const baseFormData = { albumId: albumId, token: token || '', action: 'photoUpload' };
|
||||
const data = await new Promise((resolve, reject) => {
|
||||
// 先压缩图片
|
||||
compressForUpload(filePath).then((compressedPath) => {
|
||||
wx.uploadFile({
|
||||
url: uploadUrl,
|
||||
filePath: compressedPath,
|
||||
name: 'photos',
|
||||
formData: baseFormData,
|
||||
timeout: 120000,
|
||||
success: (res) => {
|
||||
var parsed = parseUploadResponse(res.data);
|
||||
if (parsed) resolve(parsed);
|
||||
else reject({ error: '解析响应失败', raw: res.data });
|
||||
},
|
||||
fail: (err) => {
|
||||
// 压缩后上传失败,尝试原路径
|
||||
wx.uploadFile({
|
||||
url: uploadUrl,
|
||||
filePath: filePath,
|
||||
name: 'photos',
|
||||
formData: baseFormData,
|
||||
timeout: 120000,
|
||||
success: (r) => {
|
||||
var parsed = parseUploadResponse(r.data);
|
||||
if (parsed) resolve(parsed);
|
||||
else reject({ error: '解析响应失败', raw: r.data });
|
||||
},
|
||||
fail: (e2) => reject({ error: '网络异常', errMsg: e2.errMsg })
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catch(() => {
|
||||
// 压缩失败直接上传原路径
|
||||
wx.uploadFile({
|
||||
url: uploadUrl,
|
||||
filePath: filePath,
|
||||
name: 'photos',
|
||||
formData: baseFormData,
|
||||
timeout: 120000,
|
||||
success: (r) => {
|
||||
var parsed = parseUploadResponse(r.data);
|
||||
if (parsed) resolve(parsed);
|
||||
else reject({ error: '解析响应失败', raw: r.data });
|
||||
},
|
||||
fail: (e2) => reject({ error: '网络异常', errMsg: e2.errMsg })
|
||||
});
|
||||
});
|
||||
const data = await uploadFileCompat({
|
||||
url: API_BASE + '/photoUpload', filePath, name: 'photos', formData: {albumId}
|
||||
});
|
||||
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' });
|
||||
if (!maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
|
||||
throw data || { error: '上传失败' };
|
||||
} catch(e) {
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: formatUploadError(e), icon: 'none' });
|
||||
if (!maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
@@ -388,6 +309,7 @@ const api = {
|
||||
// 打卡
|
||||
smokingInfo: (userId, data = {}) => request("smokingInfo", { page: 1, page_size: 20, ...data, user_id: userId || "" }, "GET"),
|
||||
smokingAction: (data) => request("smokingAction", data),
|
||||
smokingCardVisible: (data) => request("smokingCardVisible", data),
|
||||
checkinList: (data = {}) => request('checkinList', { page: 1, page_size: 20, ...data }, 'GET'),
|
||||
checkinAdd: (data) => request('checkinAdd', data),
|
||||
checkinDo: (data) => request('checkinDo', data),
|
||||
@@ -408,7 +330,7 @@ const api = {
|
||||
activityLogList: (data) => request('activityLogList', data, 'GET'),
|
||||
|
||||
// 订阅消息
|
||||
subscribeTemplate: (data) => request('subscribeTemplate', data),
|
||||
subscribeTemplate: (data, options) => request('subscribeTemplate', data, 'POST', options),
|
||||
unsubscribeTemplate: (data) => request('unsubscribeTemplate', data),
|
||||
mySubscribeTemplates: () => request('mySubscribeTemplates', null, 'GET'),
|
||||
|
||||
@@ -488,7 +410,18 @@ async function uploadFileCompat(options) {
|
||||
uploadUrl += sep + 'token=' + encodeURIComponent(token);
|
||||
}
|
||||
|
||||
function doUpload(fp) {
|
||||
async function doUpload(fp) {
|
||||
// Hash and measure the compressed file that will actually be uploaded.
|
||||
const info = await new Promise((resolve, reject) => wx.getFileInfo({
|
||||
filePath: fp, digestAlgorithm: 'md5', success: resolve,
|
||||
fail: () => reject({error:'读取文件信息失败,请重新选择图片'})
|
||||
}));
|
||||
try {
|
||||
await request('uploadPreflight', {size: info.size, md5: info.digest});
|
||||
} catch(error) {
|
||||
maybeRedirectStorage(error);
|
||||
throw error;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: uploadUrl,
|
||||
@@ -500,6 +433,7 @@ async function uploadFileCompat(options) {
|
||||
success: (res) => {
|
||||
var data = parseUploadResponse(res.data);
|
||||
if (data) {
|
||||
if (maybeRedirectStorage(data)) { reject(data); return; }
|
||||
if (res.statusCode < 400 && !data.error && (data.success || data.path || data.uploaded)) {
|
||||
dataCache.mutation(actionMatch ? decodeURIComponent(actionMatch[1] || actionMatch[2]) : '', data);
|
||||
}
|
||||
@@ -509,24 +443,21 @@ async function uploadFileCompat(options) {
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject({ error: '网络异常', errMsg: err.errMsg });
|
||||
reject({ error: '网络异常', errMsg: err.errMsg, _uploadNetworkError: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 先压缩图片,确保 Android 临时文件路径可读
|
||||
compressForUpload(filePath).then((compressedPath) => {
|
||||
doUpload(compressedPath).then(resolve).catch(() => {
|
||||
// 压缩后上传失败,尝试原路径
|
||||
doUpload(filePath).then(resolve).catch(reject);
|
||||
});
|
||||
}).catch(() => {
|
||||
// 压缩失败时尝试直接上传
|
||||
doUpload(filePath).then(resolve).catch(reject);
|
||||
});
|
||||
});
|
||||
const compressedPath = await compressForUpload(filePath);
|
||||
try {
|
||||
return await doUpload(compressedPath);
|
||||
} catch(error) {
|
||||
// Business/preflight failures never retry. A network retry with the original
|
||||
// file must pass a fresh quota check because it may be larger.
|
||||
if (error._uploadNetworkError && compressedPath !== filePath) return doUpload(filePath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { api, getImageUrl, API_BASE, uploadFileCompat, compressForUpload };
|
||||
|
||||
+57
-20
@@ -6,9 +6,9 @@
|
||||
*/
|
||||
const { api } = require('./request');
|
||||
|
||||
// 统一使用「每日记录提醒」模板,所有操作都通过这个模板通知
|
||||
// 操作和打卡共用同一模板、同一份一次性授权。
|
||||
const SUBSCRIBE_TEMPLATES = {
|
||||
notify: 'tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8',
|
||||
notify: 'K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU',
|
||||
};
|
||||
|
||||
// 只合并正在进行的授权请求,后续用户点击仍可补充一次性授权。
|
||||
@@ -19,7 +19,7 @@ let _pendingSubscribe = null;
|
||||
* @param {string|string[]} templateTypes - 模板类型或模板类型数组
|
||||
* @returns {Promise<object>} 订阅结果 { templateType: true/false }
|
||||
*/
|
||||
function requestSubscribe(templateTypes) {
|
||||
function requestSubscribe(templateTypes, options = {}) {
|
||||
if (_pendingSubscribe) return _pendingSubscribe;
|
||||
const pending = new Promise((resolve, reject) => {
|
||||
if (!Array.isArray(templateTypes)) {
|
||||
@@ -49,7 +49,7 @@ function requestSubscribe(templateTypes) {
|
||||
});
|
||||
|
||||
// 将授权结果同步到后端
|
||||
syncSubscribeResults(results).then(() => resolve(results), reject);
|
||||
syncSubscribeResults(results, options).then(() => resolve(results), reject);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[subscribe] 请求订阅失败:', err);
|
||||
@@ -57,31 +57,22 @@ function requestSubscribe(templateTypes) {
|
||||
}
|
||||
});
|
||||
});
|
||||
_pendingSubscribe = pending.finally(() => { _pendingSubscribe = null; });
|
||||
_pendingSubscribe = pending.finally(() => { _pendingSubscribe = null; prepareLoginRenewal(); });
|
||||
return _pendingSubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将订阅结果同步到后端
|
||||
*/
|
||||
function syncSubscribeResults(results) {
|
||||
function syncSubscribeResults(results, options) {
|
||||
return Promise.all(Object.keys(results).filter(type => results[type]).map(type => {
|
||||
return api.subscribeTemplate({template_type:type, template_id:SUBSCRIBE_TEMPLATES[type]}).then(result => {
|
||||
return api.subscribeTemplate({template_type:type, template_id:SUBSCRIBE_TEMPLATES[type]}, options).then(result => {
|
||||
if (!result || !result.success) throw new Error('订阅授权保存失败');
|
||||
wx.setStorageSync('subscribed_notify', true);
|
||||
wx.setStorageSync('subscribed_' + type, true);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// 必须由用户点击直接调用;补充的是当前用户接收TA提醒的次数。
|
||||
function requestSubscribeAfterAction(scene) {
|
||||
if (wx.getStorageSync('subscribed_notify') === false || !wx.getStorageSync('coupleId')) return Promise.resolve({});
|
||||
return requestSubscribe('notify').catch(error => {
|
||||
console.warn('[subscribe] 授权或保存失败,下次点击可重试:', error);
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前的订阅状态
|
||||
* 从后端获取
|
||||
@@ -93,7 +84,7 @@ async function getSubscribeStatus(forceRefresh = false) {
|
||||
const res = await api.mySubscribeTemplates();
|
||||
const status = {};
|
||||
Object.keys(SUBSCRIBE_TEMPLATES).forEach(type => {
|
||||
status[type] = !!res.templates[type];
|
||||
status[type] = !!SUBSCRIBE_TEMPLATES[type] && res.templates[type] === SUBSCRIBE_TEMPLATES[type];
|
||||
});
|
||||
return status;
|
||||
} catch (e) {
|
||||
@@ -103,6 +94,52 @@ async function getSubscribeStatus(forceRefresh = false) {
|
||||
|
||||
module.exports = {
|
||||
requestSubscribe,
|
||||
requestSubscribeAfterAction,
|
||||
getSubscribeStatus
|
||||
resetLoginRenewal,
|
||||
prepareLoginRenewal,
|
||||
tryLoginRenewal,
|
||||
getSubscribeStatus,
|
||||
isTemplateConfigured: type => !!SUBSCRIBE_TEMPLATES[type],
|
||||
subscribeErrorMessage
|
||||
};
|
||||
|
||||
function subscribeErrorMessage(error) {
|
||||
const code = Number(error && error.errCode);
|
||||
if (code === 20001) return '微信未找到该提醒模板,请核对当前小程序的模板配置';
|
||||
if (code === 20004) return '微信消息通知总开关已关闭,请在小程序设置中开启';
|
||||
if (code === 10002 || code === 10003) return '网络异常,暂时无法订阅,请稍后重试';
|
||||
return error && (error.error || error.message) || '订阅失败,请稍后重试';
|
||||
}
|
||||
|
||||
// Reading settings at login never asks for authorization. Only a later real tap
|
||||
// may renew, and only when WeChat reports a remembered acceptance.
|
||||
let renewal = {generation:0,userId:'',allowed:false,attempted:false};
|
||||
function resetLoginRenewal() {
|
||||
renewal = {generation:renewal.generation+1,userId:'',allowed:false,attempted:false};
|
||||
}
|
||||
function prepareLoginRenewal() {
|
||||
renewal.allowed = false;
|
||||
if (typeof wx.getSetting !== 'function' || typeof wx.getStorageSync !== 'function') return;
|
||||
const userId = String(wx.getStorageSync('userId') || '');
|
||||
if (!userId) return;
|
||||
if (renewal.userId && renewal.userId !== userId) resetLoginRenewal();
|
||||
renewal.userId = userId;
|
||||
const generation = ++renewal.generation;
|
||||
try {
|
||||
wx.getSetting({withSubscriptions:true,success(result){
|
||||
if (renewal.generation !== generation || String(wx.getStorageSync('userId') || '') !== userId) return;
|
||||
const settings = result.subscriptionsSetting || {};
|
||||
renewal.allowed = settings.mainSwitch === true && !!settings.itemSettings && settings.itemSettings[SUBSCRIBE_TEMPLATES.notify] === 'accept';
|
||||
},fail(){ /* Unknown permission means no automatic request. */ }});
|
||||
} catch(error) { /* Unsupported API: keep the manual entry. */ }
|
||||
}
|
||||
function tryLoginRenewal() {
|
||||
const app = typeof getApp === 'function' ? getApp() : null;
|
||||
if (!app || !app.globalData || app.globalData.authState !== 'ready' || !renewal.allowed || renewal.attempted) return;
|
||||
if (!wx.getStorageSync('coupleId') || wx.getStorageSync('subscribed_notify') === false || String(wx.getStorageSync('userId') || '') !== renewal.userId) return;
|
||||
renewal.attempted = true;
|
||||
// Intentionally no await before requesting: preserve this tap's user gesture.
|
||||
return requestSubscribe('notify', {silent:true}).catch(error => {
|
||||
console.warn('[subscribe] 本次静默续订未完成,保留手动订阅入口:',error);
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user