同步小程序与管理端更新,完善抽烟记录和解绑冷静期

This commit is contained in:
2026-10-09 22:15:51 +08:00
parent a68a4ef411
commit b10fc3c4ac
149 changed files with 3794 additions and 589 deletions
+88
View File
@@ -0,0 +1,88 @@
// 仅保存本次进程内的数据,不写入手机本地存储。
const TTL = 45000;
const policies = {
coupleInfo: 300000, getBgConfig: 300000,
timelineList: TTL, timelineDetail: TTL, albumList: TTL, photos: TTL,
diaryList: TTL, diaryDetail: TTL, diaryCommentList: TTL,
songsList: TTL, checkinList: TTL, checkinStats: TTL, checkinDetail: TTL,
smokingInfo: TTL,
petInfo: TTL, petAchievements: TTL, activityLogList: TTL
};
const groups = {
couple: ['coupleInfo'], timeline: ['timelineList', 'timelineDetail'],
album: ['albumList', 'photos'], diary: ['diaryList', 'diaryDetail', 'diaryCommentList'],
songs: ['songsList'], checkin: ['checkinList', 'checkinStats', 'checkinDetail'],
pet: ['petInfo', 'petAchievements'], background: ['getBgConfig']
};
let entries = new Map();
let versions = {};
let scope = '';
let epoch = 0;
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 signature(actions) {
currentScope();
return epoch + ':' + actions.map(action => action + ':' + (versions[action] || 0)).join('|');
}
function invalidate(actions) {
currentScope();
actions.forEach(action => { versions[action] = (versions[action] || 0) + 1; });
entries.forEach((entry, key) => { if (actions.indexOf(entry.action) >= 0) entries.delete(key); });
}
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === 'object') {
const out = {};
Object.keys(value).sort().forEach(key => {
if (value[key] !== undefined && value[key] !== null) out[key] = stable(value[key]);
});
return out;
}
return value;
}
function copy(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); }
async function read(action, data, fetch) {
currentScope();
if (!policies[action]) return fetch();
const key = action + ':' + JSON.stringify(stable(data || {}));
let entry = entries.get(key);
if (entry && entry.pending) return entry.pending.then(copy);
if (entry && Date.now() - entry.time < policies[action]) return copy(entry.value);
entry = { action };
// 不缓存失败;失效或身份切换后,旧请求不能写回缓存。
entries.set(key, entry);
entry.pending = Promise.resolve().then(fetch).then(value => {
if (entries.get(key) === entry) {
entry.value = copy(value); entry.time = Date.now(); entry.pending = null;
// 限制详情/分页缓存数量,避免长时间浏览占用过多内存。
if (entries.size > 150) entries.delete(entries.keys().next().value);
}
return value;
}, error => {
if (entries.get(key) === entry) entries.delete(key);
throw error;
});
return entry.pending.then(copy);
}
function mutation(action, result) {
if (result && (result.error || result.success === false)) return;
if (/^(logout|respondInvitation|acceptShareInvite|unbindRequest|unbindRespond|unbindCancel)$/.test(action)) { clear(); return; }
let group;
if (/^(updateProfile|updatePartnerProfile|updateCoupleInfo|updateLocation|updateIpLocation|uploadAvatar|uploadPartnerAvatar)$/.test(action)) group = 'couple';
else if (/^timeline(Add|Update|Delete)$/.test(action)) group = 'timeline';
else if (/^(album(Add|Update|Delete)|photo(Add|Update|Delete|Upload))$/.test(action)) group = 'album';
else if (/^diary(Add|Update|Delete|CommentAdd|CommentDelete)$/.test(action)) group = 'diary';
else if (/^songs(Add|Update|Delete)$/.test(action)) group = 'songs';
else if (action === "smokingAction") { invalidate(["smokingInfo"]); group = "checkin"; }
else if (/^checkin(Add|Do|Delete)$/.test(action)) { invalidate(groups.pet); group = 'checkin'; }
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(groups.timeline.concat(groups.diary, groups.checkin));
}
module.exports = { read, invalidate, mutation, clear, signature, policies, TTL };
+44
View File
@@ -0,0 +1,44 @@
const cache = require('./data-cache');
// 页面各自记录版本,三级页面修改不会被某一个上层页面“消费”掉。
function withDataCache(definition, loaders) {
const actions = Array.from(new Set([].concat(...Object.keys(loaders).map(name => loaders[name]))));
Object.keys(loaders).forEach(name => {
const load = definition[name];
if (!load) return;
definition[name] = function(...args) {
if (!this._dataLoads) this._dataLoads = {};
const dependencies = loaders[name];
const signature = cache.signature(dependencies);
const previous = this._dataLoads[name];
const key = JSON.stringify(args);
if (previous && previous.pending && (previous.signature !== signature || previous.key !== key)) {
return previous.pending.then(() => this[name](...args));
}
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();
}
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) {
record.time = Date.now();
if (name !== 'loadComments') this._hasCachedData = true;
}
record.pending = null;
return result;
}, error => { record.pending = null; throw error; });
return record.pending;
};
});
['onPullDownRefresh', 'onRefresh'].forEach(name => {
const refresh = definition[name];
if (refresh) definition[name] = function(...args) {
cache.invalidate(actions);
return refresh.apply(this, args);
};
});
return definition;
}
module.exports = { withDataCache };
+12
View File
@@ -0,0 +1,12 @@
function photoForm(photo) {
return { taken_at: String(photo.taken_at || '').slice(0, 10), location: photo.location || '', activity: photo.activity || '' };
}
function metadataRows(photos) {
const rows = photos.map((photo, index) => ({ ...photoForm(photo), id: photo.id, photoIndex: index, label: photos.length > 1 ? '第 ' + (index + 1) + ' 张' : '' }));
if (!rows.length) return [];
const same = rows.every(row => row.taken_at === rows[0].taken_at && row.location === rows[0].location && row.activity === rows[0].activity);
if (same) rows.splice(1);
if (same) rows[0].label = '';
return rows.filter(row => row.taken_at || row.location || row.activity);
}
module.exports = { photoForm, metadataRows };
+32 -5
View File
@@ -1,4 +1,5 @@
const { camelName, toWire, fromWire } = require('./api-naming');
const dataCache = require('./data-cache');
/**
* 网络请求工具 - 对接后端 API
*/
@@ -13,6 +14,7 @@ const MEDIA_BASE = 'https://past.sundynix.cn/backend/';
function getImageUrl(path) {
if (!path) return '';
if (path.startsWith('http://') || path.startsWith('https://')) return path;
if (path.startsWith('/backend/')) return MEDIA_BASE + path.substring('/backend/'.length);
if (path.startsWith('/')) return MEDIA_BASE + path.substring(1);
return MEDIA_BASE + path;
}
@@ -75,6 +77,17 @@ async function waitForAuth() {
if (app && app.ensureAuth) await app.ensureAuth();
}
async function request(action, data = null, method = 'POST') {
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);
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);
await waitForAuth();
var token = wx.getStorageSync('token');
@@ -121,6 +134,13 @@ function sendRequest(action, data = null, method = 'POST') {
data: requestData,
header: header,
success: (res) => {
const relation = res.header && (res.header['X-Couple-ID'] !== undefined ? res.header['X-Couple-ID'] : res.header['x-couple-id']);
const owner = res.header && (res.header['X-User-ID'] || res.header['x-user-id']);
if (relation !== undefined && String(owner) === String(wx.getStorageSync('userId'))) {
const next = String(relation) === '0' ? '' : String(relation);
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 (res.statusCode >= 400 || (res.data && res.data.error)) {
reject({ ...fromWire(res.data), _statusCode: res.statusCode });
@@ -198,7 +218,7 @@ const api = {
{}
);
wx.hideLoading();
if (data && (data.path || data.success)) return data;
if (data && !data.error && (data.path || data.success)) { dataCache.mutation('uploadAvatar', data); return data; }
wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
@@ -218,7 +238,7 @@ const api = {
{ partner_id: partnerId }
);
wx.hideLoading();
if (data && (data.path || data.success)) return data;
if (data && !data.error && (data.path || data.success)) { dataCache.mutation('uploadAvatar', data); return data; }
wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
@@ -238,6 +258,7 @@ const api = {
unbindStatus: () => request('unbindStatus', null, 'GET'),
myInvitations: () => request('myInvitations', null, 'GET'),
respondInvitation: (data) => request('respondInvitation', data),
generateInviteLink: () => request('generateInviteLink', null, 'POST'),
acceptShareInvite: (data) => request('acceptShareInvite', data),
updatePartnerProfile: (data) => request('updatePartnerProfile', data),
@@ -255,6 +276,7 @@ const api = {
albumDelete: (data) => request('albumDelete', data),
photoDelete: (data) => request('photoDelete', data),
photoAdd: (data) => request('photoAdd', data),
photoUpdate: (data) => request('photoUpdate', data),
photos: (albumId, data, method) => request('photos', { album_id: albumId, ...data }, method || 'GET'),
photoUpload: async (filePath, albumId) => {
wx.showLoading({ title: '上传中...' });
@@ -313,7 +335,7 @@ const api = {
});
});
wx.hideLoading();
if (data && (data.success || data.path || data.uploaded)) return data;
if (data && !data.error && (data.success || data.path || data.uploaded)) { dataCache.mutation('photoUpload', data); return data; }
wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
@@ -341,6 +363,8 @@ const api = {
songsDelete: (data) => request('songsDelete', data),
// 打卡
smokingInfo: (userId) => request("smokingInfo", { user_id: userId || 0 }, "GET"),
smokingAction: (data) => request("smokingAction", data),
checkinList: () => request('checkinList', null, 'GET'),
checkinAdd: (data) => request('checkinAdd', data),
checkinDo: (data) => request('checkinDo', data),
@@ -369,7 +393,7 @@ const api = {
// 姨姨
// 权限和周期数据会随伴侣设置立即变化,始终请求最新结果。
periodList: () => request('periodList', null, 'GET'),
periodList: (userId) => request('periodList', userId ? {user_id:userId} : null, 'GET'),
periodAdd: (data) => request('periodAdd', data),
periodUpdate: (data) => request('periodUpdate', data),
periodDelete: (data) => request('periodDelete', data),
@@ -379,7 +403,7 @@ const api = {
periodSexDelete: (data) => request('periodSexDelete', data),
periodSexList: (data) => request('periodSexList', data, 'GET'),
// 姨姨权限(对方可见)
periodVisibleGet: () => request('periodVisibleGet', null, 'GET'),
periodVisibleGet: (userId) => request('periodVisibleGet', userId ? {user_id:userId} : null, 'GET'),
periodVisibleSet: (data) => request('periodVisibleSet', data),
periodSourceSelect: (data) => request('periodSourceSelect', data),
};
@@ -448,6 +472,9 @@ async function uploadFileCompat(options) {
success: (res) => {
var data = parseUploadResponse(res.data);
if (data) {
if (res.statusCode < 400 && !data.error && (data.success || data.path || data.uploaded)) {
dataCache.mutation(actionMatch ? decodeURIComponent(actionMatch[1] || actionMatch[2]) : '', data);
}
resolve(data);
} else {
reject({ error: '解析响应失败', raw: res.data });
+18 -1
View File
@@ -1,9 +1,25 @@
/**
* 页面滚动位置管理工具
* 保存和恢复底部导航栏页面的滚动位置,切换 Tab 后返回时保持浏览位置
* 保存页面滚动位置;主 Tab 切换时回到顶部,子页面返回时保留位置
*/
const SCROLL_POS_KEY = 'tabScrollPositions';
let lastShownTab = '';
/** 主 Tab 的 onShow 调用,兼容底部按钮和代码中的 switchTab。 */
function resetScrollOnTabShow(page, pageKey) {
const switched = lastShownTab !== pageKey;
lastShownTab = pageKey;
if (!switched) return;
const current = page._scrollTop == null ? page.data.scrollTop : page._scrollTop;
page._scrollTop = 0;
saveScrollPosition(pageKey, 0);
if (!current && !page.data.scrollTop) return;
// 手动滚动不会更新绑定的 scrollTop,先同步实际位置再发出回顶指令。
page.setData({ scrollTop: current || page.data.scrollTop }, function() {
page.setData({ scrollTop: 0 });
});
}
/**
* 保存指定页面的滚动位置
@@ -37,6 +53,7 @@ function getScrollPosition(pageKey) {
}
module.exports = {
resetScrollOnTabShow,
saveScrollPosition,
getScrollPosition
};
+35
View File
@@ -0,0 +1,35 @@
const { api } = require('./request');
let pending = null;
let cached = null;
let cachedAt = 0;
let user = null;
function address(value) {
const text = String(value || '').trim();
return /^-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?$/.test(text) ? '' : text;
}
async function currentUserLocation() {
const uid = wx.getStorageSync('userId');
if (user !== uid) { user = uid; pending = null; cached = null; cachedAt = 0; }
if (cached && Date.now() - cachedAt < 45000) return { ...cached };
if (pending) return pending;
const task = (async () => {
let coords = { lat: 0, lng: 0 };
try {
if (wx.getFuzzyLocation) coords = await new Promise(resolve => wx.getFuzzyLocation({ type: 'gcj02', success: res => resolve({lat: res.latitude, lng: res.longitude}), fail: () => resolve(coords) }));
const result = await api.updateIpLocation(coords);
if (address(result && result.location)) return { ...coords, location: address(result.location) };
} catch (error) {}
try {
const info = await api.coupleInfo();
const me = [info.partner1, info.partner2].find(partner => partner && String(partner.id) === String(uid));
return { ...coords, location: address(me && me.location) };
} catch (error) { return { ...coords, location: '' }; }
})();
pending = task;
try {
const result = await task;
if (user === uid && pending === task && result.location) { cached = result; cachedAt = Date.now(); }
return result;
} finally { if (pending === task) pending = null; }
}
module.exports = { currentUserLocation };