feat: require couple binding and refine miniapp settings

Gate record features and storage entry points with invitation guidance; enforce record access in the backend. Add homepage greetings, right-swipe profile dismissal, and move the smoking switch to settings. Simplify API base configuration and remove the unused legacy tab bar component.
This commit is contained in:
2026-11-04 21:27:03 +08:00
parent 41bcb793da
commit b854d2873e
67 changed files with 430 additions and 227 deletions
+2 -2
View File
@@ -3,11 +3,11 @@ function page(name,api,wxExtra={},app={}){let definition;const storage=new Map([
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 first=page('settings/settings.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);
const e=page('settings/settings.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);
+4 -4
View File
@@ -2,14 +2,14 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const loader = require('./load-module.cjs');
test('development requests and file previews use local backend, trial and release use production', () => {
test('requests and file previews use the manually configured API base in every runtime', () => {
for (const version of ['develop','trial','release']) {
const wx = {getAccountInfoSync:()=>({miniProgram:{envVersion:version}})};
const load = loader({wx,console});
const env = load(path.join(__dirname,'../config/env.js'));
const configured = load(path.join(__dirname,'../config/env.js'));
const {API_BASE,getImageUrl} = load(path.join(__dirname,'../utils/request.js'));
assert.equal(env.env, version === 'develop' ? 'dev' : version);
assert.equal(API_BASE, version === 'develop' ? 'http://127.0.0.1:8080/api' : 'https://we.sundynix.cn/api');
assert.deepEqual(Object.keys(configured),['API_BASE']);
assert.equal(API_BASE,require('../config/env').API_BASE);
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),API_BASE+'/files/6083e119-6b26-441b-8b70-44d224366238');
}
});
+2 -2
View File
@@ -33,7 +33,7 @@ test('duplicate and ID-only uploads keep stable display URLs and file metadata',
assert.equal(uploadReference(result),id);
assert.equal(result.duplicate,true);
assert.equal(result.file.url,'https://minio/raw-object');
assert.equal(c.getImageUrl(result.path),'https://we.sundynix.cn/api/files/'+id);
assert.equal(c.getImageUrl(result.path),c.API_BASE+'/files/'+id);
assert.throws(()=>uploadReference({success:true,path:'http://tmp/new.jpg'}),/文件 ID/);
});
test('diary edit uploads HTTP temporary images, retains existing IDs and stops on upload failure', async () => {
@@ -56,5 +56,5 @@ test('media display keeps local assets and temporary paths without a backend dir
assert.equal(c.getImageUrl('/images/default-avatar.png'), '/images/default-avatar.png');
assert.equal(c.getImageUrl('wxfile://tmp/photo.jpg'), 'wxfile://tmp/photo.jpg');
assert.equal(c.getImageUrl('http://tmp/photo.jpg'), 'http://tmp/photo.jpg');
assert.equal(c.getImageUrl(id), 'https://we.sundynix.cn/api/files/' + id);
assert.equal(c.getImageUrl(id), c.API_BASE+'/files/' + id);
});
+1
View File
@@ -15,6 +15,7 @@ function records(api) {
Page:p=>definition=p, console:{error(){}}, setTimeout:f=>f(),
wx:{getStorageSync:()=> 'token',navigateTo:p=>routes.push(p.url)},
require(name) {
if(name.endsWith('/record-access'))return {guardRecordEntries:p=>p,withRecordAccess:p=>p};
if(name.endsWith('/list-pagination'))return require('../utils/list-pagination');
if(name.endsWith('/page-cache'))return {withDataCache:p=>p};
if(name.endsWith('/request'))return {api};
+1 -1
View File
@@ -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: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');}}
Component:c=>definition=c,require:name=>name.includes('record-access')?{requireRecordAccess:()=>true}: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');
+1 -1
View File
@@ -35,7 +35,7 @@ test('server relation changes discard cached data before another read',async()=>
await api.diaryList();await api.diaryList();assert.equal(reads,1);
await api.checkAuth();assert.equal(storage.get('coupleId'),'22');assert.equal(redirects,1);
const fresh=await api.diaryList();assert.equal(reads,2);assert.equal(fresh.items[0].title,'new space');
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),'https://we.sundynix.cn/api/files/6083e119-6b26-441b-8b70-44d224366238');
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),require('../config/env').API_BASE+'/files/6083e119-6b26-441b-8b70-44d224366238');
});
test('settings and profile consent retain couple cache and lead into cooling',async()=>{
+10 -6
View File
@@ -7,13 +7,17 @@ function setup(partner=false){
}
const start=(x,y,ignore=false)=>({touches:[{clientX:x,clientY:y}],target:{dataset:{swipeIgnore:ignore}}});
const end=(x,y)=>({changedTouches:[{clientX:x,clientY:y}]});
test('left swipe closes either editor once, restores tabs and never navigates away from profile',()=>{
for(const partner of [false,true]){const {p,calls}=setup(partner);p.onEditTouchStart(start(280,300));p.onEditTouchEnd(end(100,310));p.onEditTouchEnd(end(100,310));assert.equal(p.data.showEditProfileModal,false);assert.equal(p.data.showEditPartnerModal,false);assert.deepEqual(calls,[false,'keyboard']);}
test('leftward swipe keeps either editor open',()=>{
for(const partner of [false,true]){const {p,calls}=setup(partner);p.onEditTouchStart(start(280,300));p.onEditTouchEnd(end(100,310));assert.equal(partner?p.data.showEditPartnerModal:p.data.showEditProfileModal,true);assert.deepEqual(calls,[]);}
});
test('vertical scrolling, short or rightward gestures and text selection keep the editor open',()=>{
for(const [s,e] of [[start(280,300),end(240,305)],[start(280,300),end(160,500)],[start(100,300),end(280,310)],[start(280,300,true),end(100,310)]]){const {p,calls}=setup();p.onEditTouchStart(s);p.onEditTouchEnd(e);assert.equal(p.data.showEditProfileModal,true);assert.equal(calls.length,0);}
test('vertical scrolling, short gestures and text selection keep the editor open',()=>{
for(const [s,e] of [[start(40,300),end(80,305)],[start(40,300),end(160,500)],[start(40,300,true),end(180,310)]]){const {p,calls}=setup();p.onEditTouchStart(s);p.onEditTouchEnd(e);assert.equal(p.data.showEditProfileModal,true);assert.equal(calls.length,0);}
});
test('cancelled touches and partner saving never dismiss the editor',()=>{
const {p}=setup();p.onEditTouchStart(start(280,300));p.onEditTouchCancel();p.onEditTouchEnd(end(100,300));assert.equal(p.data.showEditProfileModal,true);
const e=setup(true);e.p.data.partnerSaving=true;e.p.onEditTouchStart(start(280,300));e.p.onEditTouchEnd(end(100,300));assert.equal(e.p.data.showEditPartnerModal,true);
const {p}=setup();p.onEditTouchStart(start(12,300));p.onEditTouchCancel();p.onEditTouchEnd(end(160,300));assert.equal(p.data.showEditProfileModal,true);
const e=setup(true);e.p.data.partnerSaving=true;e.p.onEditTouchStart(start(12,300));e.p.onEditTouchEnd(end(160,300));assert.equal(e.p.data.showEditPartnerModal,true);
});
test('right swipe closes either editor exactly once without navigating the tab page',()=>{
for(const partner of [false,true]){const {p,calls}=setup(partner);p.onEditTouchStart(start(12,300));p.onEditTouchEnd(end(160,310));p.onEditTouchEnd(end(160,310));assert.equal(p.data.showEditProfileModal,false);assert.equal(p.data.showEditPartnerModal,false);assert.deepEqual(calls,[false,'keyboard']);}
});
+56
View File
@@ -0,0 +1,56 @@
const test=require('node:test'), assert=require('node:assert/strict'), path=require('node:path');
const createLoader=require('./load-module.cjs');
const root=path.join(__dirname,'..');
function env(cid='') {
const modals=[],routes=[],storage={coupleId:cid};let page,component;
const app={globalData:{},ensureAuth:async()=>{}};
const wx={getStorageSync:k=>storage[k],removeStorageSync:k=>delete storage[k],showModal:o=>modals.push(o),switchTab:o=>routes.push(o.url),navigateTo:o=>routes.push(o.url),showToast(){}};
const load=createLoader({wx,console,Date,Promise,setTimeout,clearTimeout,setInterval,clearInterval,getApp:()=>app,Page:p=>page=p,Component:c=>component=c,getCurrentPages:()=>[{route:'pages/index/index',data:{}}]}, {'../../utils/request':{api:{},getImageUrl:v=>v},'../../utils/page-cache':{withDataCache:p=>p}});
return {wx,modals,routes,storage,app,load,page:()=>({...page,data:{...page.data},setData(v){Object.assign(this.data,v)}}),component:()=>component};
}
test('unbound entry modal is shared and confirmation opens the existing invitation panel',()=>{
const e=env(),guard=e.load(path.join(root,'utils/record-access.js'));
assert.equal(guard.requireRecordAccess(),false);guard.requireRecordAccess();assert.equal(e.modals.length,1);
const modal=e.modals[0];assert.equal(modal.confirmText,'立即邀请');modal.success({confirm:true});modal.complete();
assert.equal(e.app.globalData.openProfileBind,true);assert.deepEqual(e.routes,['/pages/profile/profile']);
e.storage.coupleId='couple';assert.equal(guard.requireRecordAccess(),true);
});
test('homepage record links and profile feature shortcuts reject unbound users before navigation',()=>{
for(const [file,methods] of [['index/index',['showMore','writeDiary','goToAlbum','goToCheckin']],['profile/profile',['openStatistic']]]) {
for(const method of methods){const e=env();e.load(path.join(root,'pages',file+'.js'));const p=e.page();p[method]({currentTarget:{dataset:{key:'diary'}}});assert.equal(e.routes.length,0);assert.equal(e.modals.length,1,method);}
}
});
test('record tab rejects unbound navigation and allows bound navigation',()=>{
for(const cid of ['', 'couple']){const e=env(cid);e.load(path.join(root,'custom-tab-bar/index.js'));const c=e.component();const p={...c.methods,setData(){}};p.navigate({currentTarget:{dataset:{tab:'records'}}});assert.equal(e.routes.length,cid?1:0);assert.equal(e.modals.length,cid?0:1);}
});
test('all protected direct links avoid loading records when unbound and cancellation returns home',async()=>{
const pages=['discover/discover','timeline/timeline','punch/punch','diary/diary','albums/albums','album/album','lovewords/lovewords','publish/publish','checkin-detail/checkin-detail','timeline-detail/timeline-detail','period/period','diary_detail/diary_detail','diary_edit/diary_edit','pet/pet','pet/achievements','smoking-detail/smoking-detail','storage/storage'];
for(const file of pages){const e=env();e.load(path.join(root,'pages',file+'.js'));const p=e.page();await p.onLoad({});await p.onShow();assert.equal(p.data.recordAccessBlocked,true,file);assert.equal(e.modals.length,1,file);e.modals[0].success({cancel:true});e.modals[0].complete();assert.deepEqual(e.routes,['/pages/index/index']);}
});
test('cold direct entry waits for login before deciding binding and loading the page',async()=>{
const e=env();let loaded=0,shown=0,finish;
e.app.ensureAuth=()=>new Promise(resolve=>finish=()=>{e.storage.coupleId='bound';resolve();});
const guard=e.load(path.join(root,'utils/record-access.js'));
const p=guard.withRecordAccess({onLoad(){loaded++;},onShow(){shown++;},setData(v){this.data={...this.data,...v};}});
const load=p.onLoad(),show=p.onShow();assert.equal(loaded,0);finish();await Promise.all([load,show]);assert.equal(loaded,1);assert.equal(shown,1);assert.equal(p.data.recordAccessBlocked,false);assert.equal(e.modals.length,0);
e.storage.coupleId='';await p.onShow();assert.equal(shown,1);assert.equal(e.modals.length,1);
});
test('authoritative backend denial clears stale binding and offers invitation',async()=>{
const e=env('stale-couple');
e.wx.request=o=>o.success({statusCode:403,data:{error:'请先绑定情侣,再使用记录功能',code:10002}});
const {api}=e.load(path.join(root,'utils/request.js'));
await assert.rejects(api.diaryAdd({content:'blocked'}),error=>error.code===10002);
assert.equal(e.storage.coupleId,undefined);assert.equal(e.modals.length,1);assert.equal(e.routes.length,0);
});
test('more and storage entries require binding while bound navigation stays available',()=>{
for(const cid of ['', 'couple']) {
for(const method of ['openStatistic','goStorage','goToSettings']) {
const e=env(cid);e.load(path.join(root,'pages/profile/profile.js'));const p=e.page();
p[method]({currentTarget:{dataset:{key:'settings'}}});
assert.equal(e.modals.length,cid?0:1,method);assert.equal(e.routes.length,cid?1:0,method);
if(cid) assert.equal(e.routes[0],method==='goStorage'?'/pages/storage/storage':'/pages/settings/settings');
}
}
});
+5 -5
View File
@@ -12,9 +12,9 @@ test('GET business parameters and POST bodies use clean paths with auth', async
const response = await api.diaryList({page:2,page_size:20});
assert.equal(response.page_size,20);
await api.periodVisibleSet({period_visible:0});
assert.equal(calls[0].url,'https://we.sundynix.cn/api/diaryList?page=2&pageSize=20');
assert.equal(calls[0].url,require('../config/env').API_BASE+'/diaryList?page=2&pageSize=20');
assert.equal(calls[0].header.Authorization,'Bearer test-token');
assert.equal(calls[1].url,'https://we.sundynix.cn/api/periodVisibleSet');
assert.equal(calls[1].url,require('../config/env').API_BASE+'/periodVisibleSet');
assert.equal(calls[1].data.periodVisible,0);
});
test('multipart upload retains action and token when Android omits headers',async()=>{
@@ -38,7 +38,7 @@ test('failed homepage load ends the recent-record spinner',async()=>{
let definition;
const api={coupleInfo:async()=>{throw { _statusCode:404 };}};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../pages/index/index.js'),'utf8'),{
Page(p){definition=p;},require(name){return name.endsWith('/list-pagination')?require('../utils/list-pagination'):name.endsWith('/page-cache')?{withDataCache:p=>p}:name.endsWith('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:{saveScrollPosition(){},getScrollPosition(){return 0;}};},
Page(p){definition=p;},require(name){return name.endsWith('/record-access')?{guardRecordEntries:p=>p}:name.endsWith('/list-pagination')?require('../utils/list-pagination'):name.endsWith('/page-cache')?{withDataCache:p=>p}:name.endsWith('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:{saveScrollPosition(){},getScrollPosition(){return 0;}};},
wx:{getStorageSync:()=> 'test-token'}, console:{error(){}}, Date, setTimeout
});
const page={...definition,data:{...definition.data},setData(data){Object.assign(this.data,data);}};
@@ -50,7 +50,7 @@ test('unbound newcomer loads homepage records without requesting couple-only pet
let definition;let petCalls=0;
const api={coupleInfo:async()=>({not_bound:true,partner1:{nickname:'新用户'}}),timelineList:async()=>[],albumList:async()=>[],diaryList:async()=>({items:[],total:0}),songsList:async()=>[],checkinList:async()=>({items:[]}),petInfo:async()=>{petCalls++;throw Error('请先绑定情侣');}};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../pages/index/index.js'),'utf8'),{
Page(p){definition=p;},require(name){return name.endsWith('/list-pagination')?require('../utils/list-pagination'):name.endsWith('/page-cache')?{withDataCache:p=>p}:name.endsWith('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:name.endsWith('/journal')?{diaryEntry:v=>v}:{};},
Page(p){definition=p;},require(name){return name.endsWith('/record-access')?{guardRecordEntries:p=>p}:name.endsWith('/list-pagination')?require('../utils/list-pagination'):name.endsWith('/page-cache')?{withDataCache:p=>p}:name.endsWith('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:name.endsWith('/journal')?{diaryEntry:v=>v}:{};},
wx:{getStorageSync:()=>1,removeStorageSync(){}},console,Date,setTimeout
});
const page={...definition,data:{...definition.data,coupleId:99},setData(data){Object.assign(this.data,data);}};
@@ -68,7 +68,7 @@ test('smoking visibility page uses the real request adapter and restores the per
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'));
require('./load-module.cjs')({wx,console,Date,Promise,Page:p=>definition=p},{'../../utils/page-cache':{withDataCache:p=>p}})(path.join(__dirname,'../pages/settings/settings.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);
+1 -1
View File
@@ -17,7 +17,7 @@ function setup({preflightQuota=false,uploadQuota=false,networkRetry=false,metada
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].url,c.API_BASE+'/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');
});