更新最新代码

This commit is contained in:
CodeBuddy
2026-10-26 17:32:47 +08:00
commit 0f5dc01674
315 changed files with 42640 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
function camelName(key) { return key.replace(/_([a-z0-9])/g, function(_, c) { return c.toUpperCase(); }); }
function snakeName(key) { return key.replace(/[A-Z]/g, function(c) { return '_' + c.toLowerCase(); }); }
function mapKeys(value, name) {
if (Array.isArray(value)) return value.map(function(v) { return mapKeys(v, name); });
if (value && typeof value === 'object') { var out = {}; Object.keys(value).forEach(function(key) { out[name(key)] = mapKeys(value[key], name); }); return out; }
return value;
}
module.exports = { camelName, toWire: function(v) { return mapKeys(v, camelName); }, fromWire: function(v) { return mapKeys(v, snakeName); } };
+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 };
+16
View File
@@ -0,0 +1,16 @@
const { getImageUrl } = require('./request');
function diaryEntry(item) {
var images = item.images || [];
if (typeof images === 'string') { try { images = JSON.parse(images); } catch (e) { images = []; } }
if (!Array.isArray(images)) images = [];
var date = String(item.created_at || '').slice(0, 10);
var characters = Array.from(String(item.content || '').replace(/\s+/g, ' '));
var summary = characters.slice(0, 100).join('') + (characters.length > 100 ? '…' : '');
return Object.assign({}, item, { title: item.title || '日常的一页', summary: summary, date: date, dateLabel: date.replace(/-/g, '.'), cover: getImageUrl(images[0]), imageCount: images.length, author: item.creator_name || item.created_by_name || '' });
}
function albumEntry(item) { return Object.assign({}, item, { cover: getImageUrl(item.cover), title: item.title || '我们的相册' }); }
function memoryEntry(item) {
var date = String(item.event_date || '').slice(0, 10);
return Object.assign({}, item, { date: date, dateMonth: date.slice(5, 7), dateDay: date.slice(8, 10) });
}
module.exports = { diaryEntry, albumEntry, memoryEntry };
+16
View File
@@ -0,0 +1,16 @@
// 只计算设备布局,不保存接口数据。
function getLayout() {
var info = {};
try { info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); } catch (e) {}
var status = info.statusBarHeight || 20;
var capsule;
try { capsule = wx.getMenuButtonBoundingClientRect(); } catch (e) {}
var bar = capsule && capsule.height > 0 && capsule.top >= status
? capsule.height + (capsule.top - status) * 2 : 44;
var height = status + bar;
var width = info.windowWidth || 375;
var side = capsule && capsule.left > width / 2 ? width - capsule.left + 12 : 100;
return { statusBarHeight: status, barHeight: bar, navHeight: height, navSide: side,
layoutStyle: '--nav-height:' + height + 'px;--status-height:' + status + 'px;' };
}
module.exports = { getLayout };
+29
View File
@@ -0,0 +1,29 @@
function itemsOf(result) { return Array.isArray(result) ? result : (result && result.items) || []; }
function totalOf(result) { return result && result.total != null ? Number(result.total) : itemsOf(result).length; }
function startList(page, number) {
number = Number(number) || 1;
if (page._paginationFirst || (number > 1 && (page.data.loadingMore || page.data.loading || page.data.loadError || !page.data.hasMore))) return null;
if (number === 1) { page._paginationFirst = true; page._paginationGeneration = (page._paginationGeneration || 0) + 1; }
const ticket = { page: number, generation: page._paginationGeneration };
page.setData(number === 1 ? {loading: !page._hasCachedData, loadError:false, loadingMore:false, moreError:false} : {loadingMore:true, moreError:false});
return ticket;
}
function current(page, ticket) { return ticket.generation === page._paginationGeneration; }
function mergeUnique(previous, added) {
const seen=new Set();return previous.concat(added).filter(item=>{const id=String(item.id);if(seen.has(id))return false;seen.add(id);return true;});
}
function applyList(page, ticket, result, items, field, extra) {
if (!current(page,ticket)) return;
const list=ticket.page===1?items:mergeUnique(page.data[field]||[],items);
const total=totalOf(result);
page.setData(Object.assign({[field]:list,page:ticket.page,total,hasMore:!Array.isArray(result)&&(result.has_more != null ? !!result.has_more : ticket.page*20<total),moreError:false},extra));
}
function failList(page,ticket) { if(current(page,ticket))page.setData(ticket.page===1?{loadError:true}:{moreError:true}); }
function finishList(page,ticket) { if(current(page,ticket)){if(ticket.page===1)page._paginationFirst=false;page.setData({loading:false,loadingMore:false});} }
function withListPagination(definition, loader) {
Object.assign(definition.data,{page:1,total:0,hasMore:false,loadingMore:false,moreError:false});
const load=definition[loader];
definition.loadMore=function(){return load.call(this,(this.data.page||1)+1);};
return definition;
}
module.exports={itemsOf,totalOf,startList,current,mergeUnique,applyList,failList,finishList,withListPagination};
+7
View File
@@ -0,0 +1,7 @@
// 底部导航只属于三个主页面,二级页面由顶部返回按钮导航。
const paths = {home:'pages/index/index',records:'pages/discover/discover',profile:'pages/profile/profile'};
function tabForPage(route) {
const current = String(route || '').replace(/^\//, '').split('?')[0];
return Object.keys(paths).find(function(tab) { return paths[tab] === current; }) || '';
}
module.exports = { tabForPage, paths };
+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 };
+504
View File
@@ -0,0 +1,504 @@
const { camelName, toWire, fromWire } = require('./api-naming');
const dataCache = require('./data-cache');
/**
* 网络请求工具 - 对接后端 API
*/
const API_BASE = 'https://past.sundynix.cn/api';
const MEDIA_BASE = 'https://past.sundynix.cn/backend/';
/**
* 将相对路径转为完整URL(供小程序端显示图片使用)
* @param {string} path - 相对路径,如 "uploads/avatars/xxx.jpg"
* @returns {string} 完整URL,如 "http://192.168.2.103/uploads/avatars/xxx.jpg"
*/
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;
}
/**
* 解析上传响应,兼容各种格式,提取 error 和 code 字段
* @param {*} raw - 原始响应数据
* @returns {object|null} 解析后的对象,包含可能的 error 和 code 字段
*/
function parseUploadResponse(raw) {
if (!raw) return null;
if (typeof raw === 'object') return fromWire(raw);
var str = String(raw).trim();
// 去除 BOM 和不可见字符
str = str.replace(/^\uFEFF/, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
try {
return fromWire(JSON.parse(str));
} catch(e) {
// 尝试提取 JSON 片段(Android 端可能返回带额外字符的响应)
var match = str.match(/\{[\s\S]*\}/);
if (match) {
try { return fromWire(JSON.parse(match[0])); } catch(e2) {}
}
// 最后尝试:查找 "success" 或 "path" 关键词,手动构建响应对象
if (str.indexOf('success') > -1 || str.indexOf('path') > -1) {
var pathMatch = str.match(/"path"\s*:\s*"([^"]+)"/);
if (pathMatch) {
return { success: true, path: pathMatch[1] };
}
}
return null;
}
}
/**
* 格式化上传错误信息,包含错误码
* @param {object} data - 解析后的响应数据
* @returns {string} 格式化的错误信息
*/
function formatUploadError(data) {
if (!data) return '上传失败';
var msg = data.error || '上传失败';
if (data.code) {
msg += ' (错误码: ' + data.code + ')';
}
return msg;
}
/**
* 通用请求方法
* @param {string} action - API action 名称
* @param {object} data - 请求数据
* @param {string} method - 请求方法 GET/POST
*/
function authApp() {
return typeof getApp === 'function' ? getApp() : null;
}
async function waitForAuth() {
var app = authApp();
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');
try {
return await sendRequest(action, data, method);
} catch (error) {
var app = authApp();
if (error._statusCode !== 401 || !app || !app.refreshAuth) throw error;
await app.refreshAuth(token);
// 每个业务请求最多重试一次,防止 401 导致无限登录。
return sendRequest(action, data, method);
}
}
function sendRequest(action, data = null, method = 'POST') {
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('token');
const header = { 'Content-Type': 'application/json' };
if (token) {
header['Authorization'] = 'Bearer ' + token;
}
let url = API_BASE + '/' + encodeURIComponent(camelName(action));
let requestData = toWire(data);
if (method === 'GET' && data) {
const params = [];
for (let key in data) {
if (data[key] !== null && data[key] !== undefined) {
params.push(camelName(key) + '=' + encodeURIComponent(data[key]));
}
}
if (params.length > 0) {
url += '?' + params.join('&');
}
requestData = null;
}
// 后端返回 no-store;请求地址仅保留业务参数。
wx.request({
url: url,
method: method,
timeout: 15000,
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 });
} else {
resolve(fromWire(res.data));
}
},
fail: (err) => {
try { wx.hideLoading(); } catch(e) {}
wx.showToast({ title: '网络请求失败', icon: 'none' });
reject(err);
}
});
});
}
// ========== API 方法封装 ==========
const api = {
// 用户相关
wechatLogin: (code, region) => request('wechatLogin', { code: code, region: region || '' }),
checkAuth: () => request('checkAuth', null, 'GET'),
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);
});
});
},
uploadAvatar: async (filePath) => {
wx.showLoading({ title: '上传中...' });
try {
const data = await api._uploadFileDirect(
API_BASE + '/uploadAvatar',
filePath,
'avatar',
{}
);
wx.hideLoading();
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) {
wx.hideLoading();
wx.showToast({ title: formatUploadError(e), icon: 'none' });
throw e;
}
},
uploadPartnerAvatar: async (filePath, partnerId) => {
wx.showLoading({ title: '上传中...' });
try {
const data = await api._uploadFileDirect(
API_BASE + '/uploadPartnerAvatar',
filePath,
'avatar',
{ partner_id: partnerId }
);
wx.hideLoading();
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) {
wx.hideLoading();
wx.showToast({ title: formatUploadError(e), icon: 'none' });
throw e;
}
},
// 情侣相关
coupleInfo: () => request('coupleInfo', null, 'GET'),
updateCoupleInfo: (data) => request('updateCoupleInfo', data),
invitePartner: (data) => request('invitePartner', data),
unbindRequest: () => request('unbindRequest', null, 'POST'),
unbindRespond: (data) => request('unbindRespond', data),
unbindCancel: () => request('unbindCancel', null, 'POST'),
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),
// 时间线
timelineList: (data = {}) => request('timelineList', { page: 1, page_size: 20, ...data }, 'GET'),
timelineAdd: (data) => request('timelineAdd', data),
timelineUpdate: (data) => request('timelineUpdate', data),
timelineDelete: (data) => request('timelineDelete', data),
timelineDetail: (id) => request('timelineDetail', { id: id }, 'GET'),
// 相册
albumList: (data, method) => request('albumList', { page: 1, page_size: 20, ...data }, method || 'GET'),
albumAdd: (data) => request('albumAdd', data),
albumUpdate: (data) => request('albumUpdate', data),
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', { page: 1, page_size: 20, album_id: albumId, ...data }, method || 'GET'),
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 })
});
});
});
wx.hideLoading();
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) {
wx.hideLoading();
wx.showToast({ title: formatUploadError(e), icon: 'none' });
throw e;
}
},
// 日记
diaryList: (data) => request('diaryList', data || null, 'GET'),
diaryAdd: (data) => request('diaryAdd', data),
diaryUpdate: (data) => request('diaryUpdate', data),
diaryDelete: (data) => request('diaryDelete', data),
diaryDetail: (id) => request('diaryDetail', { id: id }, 'GET'),
diaryCommentList: (data) => request('diaryCommentList', { page: 1, page_size: 20, ...data }, 'GET'),
diaryCommentAdd: (data) => request('diaryCommentAdd', data),
diaryCommentDelete: (data) => request('diaryCommentDelete', data),
// 点赞/收藏功能已移除
// 一起听过的歌
songsList: (data = {}) => request('songsList', { page: 1, page_size: 20, ...data }, 'GET'),
songsAdd: (data) => request('songsAdd', data),
songsUpdate: (data) => request('songsUpdate', data),
songsDelete: (data) => request('songsDelete', data),
// 打卡
smokingInfo: (userId, data = {}) => request("smokingInfo", { page: 1, page_size: 20, ...data, user_id: userId || 0 }, "GET"),
smokingAction: (data) => request("smokingAction", data),
checkinList: (data = {}) => request('checkinList', { page: 1, page_size: 20, ...data }, 'GET'),
checkinAdd: (data) => request('checkinAdd', data),
checkinDo: (data) => request('checkinDo', data),
checkinStats: () => request('checkinStats', null, 'GET'),
checkinDetail: (id, pagination = {}) => request('checkinDetail', { ...pagination, id: id }, 'GET'),
checkinDelete: (data) => request('checkinDelete', data),
// 背景图
getBgConfig: () => request('getBgConfig', null, 'GET'),
deleteBgImage: () => request('deleteBgImage', {}),
// 操作记录
activityLogList: (data) => request('activityLogList', data, 'GET'),
// 订阅消息
subscribeTemplate: (data) => request('subscribeTemplate', data),
unsubscribeTemplate: (data) => request('unsubscribeTemplate', data),
mySubscribeTemplates: () => request('mySubscribeTemplates', null, 'GET'),
// 宠物养成
petInfo: () => request('petInfo', null, 'GET'),
petAdopt: (data) => request('petAdopt', data),
petDailyAction: (data) => request('petDailyAction', data),
petAchievements: () => request('petAchievements', null, 'GET'),
petRename: (data) => request('petRename', data),
// 姨姨
// 权限和周期数据会随伴侣设置立即变化,始终请求最新结果。
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),
periodDailySave: (data) => request('periodDailySave', data),
// 爱爱记录(支持一天多条)
periodSexAdd: (data) => request('periodSexAdd', data),
periodSexDelete: (data) => request('periodSexDelete', data),
periodSexList: (data) => request('periodSexList', data, 'GET'),
// 姨姨权限(对方可见)
periodVisibleGet: (userId) => request('periodVisibleGet', userId ? {user_id:userId} : null, 'GET'),
periodVisibleSet: (data) => request('periodVisibleSet', data),
periodSourceSelect: (data) => request('periodSourceSelect', data),
};
/**
* Android 兼容上传:先压缩图片再上传
* 解决 Android 端 wx.uploadFile 上传照片失败的问题
* @param {string} filePath - 原始图片路径
* @returns {Promise<string>} 压缩后的图片路径
*/
function compressForUpload(filePath) {
return new Promise((resolve) => {
if (!filePath) { resolve(filePath); return; }
// Android 端 chooseImage 返回的路径可能是 http://tmp/xxx 或 wxfile://xxx 格式
// 统一尝试压缩,压缩失败也不影响上传
wx.compressImage({
src: filePath,
quality: 80,
success: (res) => {
resolve(res.tempFilePath);
},
fail: () => {
// 压缩失败,使用原路径
resolve(filePath);
}
});
});
}
/**
* Android 兼容的 wx.uploadFile 封装
* 先压缩图片,再上传,确保 Android 端能正常上传
*/
async function uploadFileCompat(options) {
await waitForAuth();
const { url, filePath, name, formData, timeout, header } = options;
const token = wx.getStorageSync('token');
const finalHeader = header || {};
if (token && !finalHeader['Authorization']) {
finalHeader['Authorization'] = 'Bearer ' + token;
}
const finalFormData = toWire(formData || {});
if (token && !finalFormData.token) {
finalFormData.token = token;
}
// 从 URL 中提取 action 参数并添加到 formData(解决非开发者模式下 URL 参数被剥离的问题)
var actionMatch = url.match(/\/api\/([^/?]+)|[?&]action=([^&]+)/);
if (actionMatch) finalFormData.action = decodeURIComponent(actionMatch[1] || actionMatch[2]);
// 将 token 追加到 URL 参数中,解决 Android 端 wx.uploadFile 不传自定义 header 的问题
var uploadUrl = url;
if (token) {
var sep = uploadUrl.indexOf('?') > -1 ? '&' : '?';
uploadUrl += sep + 'token=' + encodeURIComponent(token);
}
function doUpload(fp) {
return new Promise((resolve, reject) => {
wx.uploadFile({
url: uploadUrl,
filePath: fp,
name: name || 'file',
timeout: timeout || 120000,
header: finalHeader,
formData: finalFormData,
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 });
}
},
fail: (err) => {
reject({ error: '网络异常', errMsg: err.errMsg });
}
});
});
}
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);
});
});
}
module.exports = { api, getImageUrl, API_BASE, uploadFileCompat, compressForUpload };
+59
View File
@@ -0,0 +1,59 @@
/**
* 页面滚动位置管理工具
* 保存页面滚动位置;主 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 });
});
}
/**
* 保存指定页面的滚动位置
* @param {string} pageKey - 页面标识(index/diary/profile)
* @param {number} scrollTop - scroll-view 的滚动位置
*/
function saveScrollPosition(pageKey, scrollTop) {
try {
var positions = wx.getStorageSync(SCROLL_POS_KEY) || {};
positions[pageKey] = {
scrollTop: scrollTop,
timestamp: Date.now()
};
wx.setStorageSync(SCROLL_POS_KEY, positions);
} catch (e) {}
}
/**
* 获取指定页面保存的滚动位置
* @param {string} pageKey - 页面标识
* @returns {number} 保存的 scrollTop,没有则返回 0
*/
function getScrollPosition(pageKey) {
try {
var positions = wx.getStorageSync(SCROLL_POS_KEY) || {};
var pos = positions[pageKey];
return pos ? (pos.scrollTop || 0) : 0;
} catch (e) {
return 0;
}
}
module.exports = {
resetScrollOnTabShow,
saveScrollPosition,
getScrollPosition
};
+108
View File
@@ -0,0 +1,108 @@
/**
* 微信订阅消息工具
* 统一管理订阅消息的请求和状态
*
* 服务端记录的是授权登记,不代表微信侧仍有剩余次数。
*/
const { api } = require('./request');
// 统一使用「每日记录提醒」模板,所有操作都通过这个模板通知
const SUBSCRIBE_TEMPLATES = {
notify: 'tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8',
};
// 只合并正在进行的授权请求,后续用户点击仍可补充一次性授权。
let _pendingSubscribe = null;
/**
* 请求用户订阅指定模板
* @param {string|string[]} templateTypes - 模板类型或模板类型数组
* @returns {Promise<object>} 订阅结果 { templateType: true/false }
*/
function requestSubscribe(templateTypes) {
if (_pendingSubscribe) return _pendingSubscribe;
const pending = new Promise((resolve, reject) => {
if (!Array.isArray(templateTypes)) {
templateTypes = [templateTypes];
}
// 获取模板ID列表
const tmplIds = templateTypes
.map(type => SUBSCRIBE_TEMPLATES[type])
.filter(id => id && id !== 'TEMPLATE_ID_');
if (tmplIds.length === 0) {
console.warn('[subscribe] 模板ID未配置,请在微信小程序管理后台申请模板后更新');
resolve({});
return;
}
wx.requestSubscribeMessage({
tmplIds: tmplIds,
success: (res) => {
// 处理每个模板的授权结果
const results = {};
templateTypes.forEach((type) => {
const tmplId = SUBSCRIBE_TEMPLATES[type];
const status = res[tmplId];
results[type] = status === 'accept';
});
// 将授权结果同步到后端
syncSubscribeResults(results).then(() => resolve(results), reject);
},
fail: (err) => {
console.error('[subscribe] 请求订阅失败:', err);
reject(err);
}
});
});
_pendingSubscribe = pending.finally(() => { _pendingSubscribe = null; });
return _pendingSubscribe;
}
/**
* 将订阅结果同步到后端
*/
function syncSubscribeResults(results) {
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 => {
if (!result || !result.success) throw new Error('订阅授权保存失败');
wx.setStorageSync('subscribed_notify', 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 {};
});
}
/**
* 获取用户当前的订阅状态
* 从后端获取
* @param {boolean} forceRefresh - 保留旧调用参数
* @returns {Promise<object>} 订阅状态 { template_type: true/false }
*/
async function getSubscribeStatus(forceRefresh = false) {
try {
const res = await api.mySubscribeTemplates();
const status = {};
Object.keys(SUBSCRIBE_TEMPLATES).forEach(type => {
status[type] = !!res.templates[type];
});
return status;
} catch (e) {
return {};
}
}
module.exports = {
requestSubscribe,
requestSubscribeAfterAction,
getSubscribeStatus
};
+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 };
+31
View File
@@ -0,0 +1,31 @@
/**
* 工具函数
*/
/**
* 左滑返回手势
* @param {Function} callback 触发返回时执行的回调
* @returns {{ touchStartX: number, onTouchStart: Function, onTouchEnd: Function }}
*/
const createSwipeBack = (callback) => {
let touchStartX = 0
return {
onTouchStart: function(e) {
var t = e.touches && e.touches[0];
if (t) touchStartX = t.clientX;
},
onTouchEnd: function(e) {
var t = e.changedTouches && e.changedTouches[0];
if (!t) return;
var endX = t.clientX;
var dist = endX - touchStartX;
if (touchStartX < 80 && dist > 60) {
callback();
}
}
}
}
module.exports = {
createSwipeBack
}