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.
476 lines
19 KiB
JavaScript
476 lines
19 KiB
JavaScript
const { fileId, normalizeUploadResult, filePayload } = require('./file-media');
|
|
const { camelName, toWire, fromWire } = require('./api-naming');
|
|
const dataCache = require('./data-cache');
|
|
/**
|
|
* 网络请求工具 - 对接后端 API
|
|
*/
|
|
const { API_BASE } = require('../config/env');
|
|
|
|
/**
|
|
* 将文件 UUID 或 /api/files/{id} 转成完整访问地址。
|
|
* 完整 URL 和小程序本地图片路径直接使用。
|
|
*/
|
|
function getImageUrl(path) {
|
|
if (!path) return '';
|
|
if (typeof path === 'object') path = fileId(path) || path.path || path.url || '';
|
|
if (typeof path !== 'string') return '';
|
|
if (path.startsWith('/api/files/')) return API_BASE.replace(/\/api$/, '') + path;
|
|
if (fileId(path) === path) return API_BASE + '/files/' + path;
|
|
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* 解析上传响应,兼容各种格式,提取 error 和 code 字段
|
|
* @param {*} raw - 原始响应数据
|
|
* @returns {object|null} 解析后的对象,包含可能的 error 和 code 字段
|
|
*/
|
|
function parseUploadResponse(raw) {
|
|
if (!raw) return null;
|
|
if (typeof raw === 'object') return normalizeUploadResult(fromWire(raw));
|
|
var str = String(raw).trim();
|
|
// 去除 BOM 和不可见字符
|
|
str = str.replace(/^\uFEFF/, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
|
try {
|
|
return normalizeUploadResult(fromWire(JSON.parse(str)));
|
|
} catch(e) {
|
|
// 尝试提取 JSON 片段(Android 端可能返回带额外字符的响应)
|
|
var match = str.match(/\{[\s\S]*\}/);
|
|
if (match) {
|
|
try { return normalizeUploadResult(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 normalizeUploadResult({ 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;
|
|
}
|
|
|
|
function maybeRedirectBinding(data) {
|
|
if (!data || Number(data.code) !== 10002) return false;
|
|
if (!data._bindingPromptShown) {
|
|
data._bindingPromptShown = true;
|
|
wx.removeStorageSync('coupleId');
|
|
dataCache.clear();
|
|
require('./record-access').requireRecordAccess();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// 存储配额错误码(与后端一致),上传超限时引导用户付费。
|
|
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: '剩余存储空间不足以保存这张图片,请购买更大容量的套餐后继续上传。',
|
|
confirmText: '去升级',
|
|
cancelText: '暂不',
|
|
success: function (res) {
|
|
if (res.confirm) wx.navigateTo({ url: '/pages/storage/storage' });
|
|
},
|
|
complete: function() { storageModalOpen = false; }
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 通用请求方法
|
|
* @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', options = {}) {
|
|
const name = camelName(action);
|
|
await waitForAuthIfNeeded(name);
|
|
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', 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, 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, options);
|
|
}
|
|
}
|
|
function sendRequest(action, data = null, method = 'POST', options = {}) {
|
|
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(filePayload(camelName(action), 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(); }
|
|
}
|
|
if (!options.silent) { try { wx.hideLoading(); } catch(e) {} }
|
|
if (res.statusCode >= 400 || (res.data && res.data.error)) {
|
|
maybeRedirectBinding(res.data);
|
|
reject({ ...fromWire(res.data), _statusCode: res.statusCode });
|
|
} else {
|
|
const result = fromWire(res.data);
|
|
if (camelName(action) === 'storageOrderStatus') dataCache.mutation('storageOrderStatus', result);
|
|
resolve(result);
|
|
}
|
|
},
|
|
fail: (err) => {
|
|
if (!options.silent) {
|
|
try { wx.hideLoading(); } catch(e) {}
|
|
wx.showToast({ title: '网络请求失败', icon: 'none' });
|
|
}
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ========== 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: (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: (url, filePath, name, formData) => uploadFileCompat({url, filePath, name, formData}),
|
|
|
|
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; }
|
|
if (!maybeRedirectBinding(data) && !maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
|
|
throw data || { error: '上传失败' };
|
|
} catch(e) {
|
|
wx.hideLoading();
|
|
if (!maybeRedirectBinding(e) && !maybeRedirectStorage(e)) 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; }
|
|
if (!maybeRedirectBinding(data) && !maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
|
|
throw data || { error: '上传失败' };
|
|
} catch(e) {
|
|
wx.hideLoading();
|
|
if (!maybeRedirectBinding(e) && !maybeRedirectStorage(e)) 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 {
|
|
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; }
|
|
if (!maybeRedirectBinding(data) && !maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
|
|
throw data || { error: '上传失败' };
|
|
} catch(e) {
|
|
wx.hideLoading();
|
|
if (!maybeRedirectBinding(e) && !maybeRedirectStorage(e)) 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 || "" }, "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),
|
|
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', {}),
|
|
|
|
// 存储空间
|
|
storageInfo: () => request('storageInfo', null, 'GET'),
|
|
storageCreateOrder: (data) => request('storageCreateOrder', data),
|
|
storageOrderStatus: (number) => request('storageOrderStatus', { out_trade_no: number }, 'GET'),
|
|
|
|
// 操作记录
|
|
activityLogList: (data) => request('activityLogList', data, 'GET'),
|
|
|
|
// 订阅消息
|
|
subscribeTemplate: (data, options) => request('subscribeTemplate', data, 'POST', options),
|
|
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);
|
|
}
|
|
|
|
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,
|
|
filePath: fp,
|
|
name: name || 'file',
|
|
timeout: timeout || 120000,
|
|
header: finalHeader,
|
|
formData: finalFormData,
|
|
success: (res) => {
|
|
var data = parseUploadResponse(res.data);
|
|
if (data) {
|
|
if (maybeRedirectBinding(data) || 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);
|
|
}
|
|
resolve(data);
|
|
} else {
|
|
reject({ error: '解析响应失败', raw: res.data });
|
|
}
|
|
},
|
|
fail: (err) => {
|
|
reject({ error: '网络异常', errMsg: err.errMsg, _uploadNetworkError: true });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
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 };
|