feat: rebuild pet app and backend around core care flows
This commit is contained in:
+54
-159
@@ -1,167 +1,62 @@
|
||||
const { request, uploadFile, setToken, setRefreshToken } = require('./request.js');
|
||||
|
||||
// 登录成功后统一落盘 access + refresh 两个令牌
|
||||
function saveTokens(data) {
|
||||
setToken(data.token);
|
||||
setRefreshToken(data.refresh_token);
|
||||
return data;
|
||||
}
|
||||
|
||||
// wx.login 取 code
|
||||
function wxLogin() {
|
||||
const { baseURL } = require("./config");
|
||||
function request(path, method = "GET", data, auth = true) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.login({
|
||||
success: (r) => (r.code ? resolve(r.code) : reject(new Error('wx.login 未返回 code'))),
|
||||
const token = getApp().globalData.token || wx.getStorageSync("pet_token");
|
||||
wx.request({
|
||||
url: baseURL + path,
|
||||
method,
|
||||
data,
|
||||
header: token && auth ? { Authorization: "Bearer " + token } : {},
|
||||
success(res) {
|
||||
if (res.data && res.data.code === 0) resolve(res.data.data);
|
||||
else {
|
||||
if (res.statusCode === 401) {
|
||||
wx.removeStorageSync("pet_token");
|
||||
getApp().globalData.token = "";
|
||||
}
|
||||
const error = new Error((res.data && res.data.message) || "请求失败");
|
||||
error.status = res.statusCode;
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 登录:优先微信 code2session,失败回退开发态 Mock 登录(后端 dev_login=true)
|
||||
async function login() {
|
||||
async function secure(path, method, data) {
|
||||
await getApp().login();
|
||||
try {
|
||||
const code = await wxLogin();
|
||||
// noAuthRetry:登录接口失败时也会返回 40100,不能再触发「续期→重新登录」,否则会自己套自己
|
||||
const data = await request({ url: '/api/auth/wechat', method: 'POST', data: { code }, noAuthRetry: true });
|
||||
return saveTokens(data);
|
||||
} catch (e) {
|
||||
console.warn('[api] 微信登录失败,回退 Mock 登录:', e && e.message);
|
||||
const data = await request({
|
||||
url: '/api/auth/login',
|
||||
method: 'POST',
|
||||
data: { nickname: '开发用户' },
|
||||
noAuthRetry: true,
|
||||
});
|
||||
return saveTokens(data);
|
||||
return await request(path, method, data);
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
await getApp().login();
|
||||
return request(path, method, data);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
login,
|
||||
uploadFile,
|
||||
|
||||
// 用户
|
||||
getProfile: () => request({ url: '/api/user/profile' }),
|
||||
bindPhone: (code) => request({ url: '/api/user/phone', method: 'POST', data: { code } }),
|
||||
updateProfile: (body) => request({ url: '/api/user/profile', method: 'PUT', data: body }),
|
||||
|
||||
// 用户
|
||||
userSummary: () => request({ url: '/api/user/summary' }),
|
||||
// 记录类型(后台可配)。分组好的,前端直接渲染
|
||||
recordTypes: () => request({ url: '/api/record-types' }),
|
||||
updateDecoration: (body) => request({ url: '/api/user/decoration', method: 'PUT', data: body }),
|
||||
|
||||
// 宠物
|
||||
getPets: () => request({ url: '/api/pets' }),
|
||||
homeSummary: (id) => request({ url: `/api/pets/${id}/home-summary` }),
|
||||
getPet: (id) => request({ url: `/api/pets/${id}` }),
|
||||
// 养护方案:可选列表 + 套用到某个月
|
||||
careTemplates: (species, stage) =>
|
||||
request({ url: '/api/care-templates?species=' + species + '&stage=' + encodeURIComponent(stage) }),
|
||||
applyTemplate: (petId, templateID, monthOffset) =>
|
||||
request({ url: `/api/pets/${petId}/apply-template`, method: 'POST',
|
||||
data: { template_id: templateID, month_offset: monthOffset || 0 } }),
|
||||
|
||||
// 生命阶段的划分标准。判定和展示用同一份数据 —— 不另写一份说明文案,
|
||||
// 否则改了阈值忘了改文案,用户看到的依据和实际行为对不上
|
||||
lifeStages: (species, size) =>
|
||||
request({ url: `/api/life-stages?species=${species}&size=${size || ''}` }),
|
||||
petIDCard: (id) => request({ url: `/api/pets/${id}/id-card` }),
|
||||
petStageDrift: (petId) => request({ url: `/api/pets/${petId}/stage-drift` }),
|
||||
|
||||
// 品种(后台可配),按首字母分组
|
||||
breeds: (species) => request({ url: '/api/breeds?species=' + species }),
|
||||
createPet: (body) => request({ url: '/api/pets', method: 'POST', data: body }),
|
||||
updatePet: (id, body) => request({ url: `/api/pets/${id}`, method: 'PUT', data: body }),
|
||||
deletePet: (id) => request({ url: `/api/pets/${id}`, method: 'DELETE' }),
|
||||
onboarding: (body) => request({ url: '/api/onboarding', method: 'POST', data: body }),
|
||||
|
||||
// 记录
|
||||
getRecords: (id, { type, page, pageSize } = {}) => {
|
||||
const qs = [];
|
||||
if (type) qs.push(`type=${type}`);
|
||||
if (page) qs.push(`page=${page}`);
|
||||
if (pageSize) qs.push(`page_size=${pageSize}`);
|
||||
return request({ url: `/api/pets/${id}/records${qs.length ? '?' + qs.join('&') : ''}` });
|
||||
},
|
||||
createRecord: (id, body) => request({ url: `/api/pets/${id}/records`, method: 'POST', data: body }),
|
||||
deleteRecord: (recordId) => request({ url: `/api/records/${recordId}`, method: 'DELETE' }),
|
||||
petInsights: (id) => request({ url: `/api/pets/${id}/insights` }),
|
||||
weightTrend: (id) => request({ url: `/api/pets/${id}/records/weight-trend` }),
|
||||
|
||||
// 任务
|
||||
getTasks: (id, date) => request({ url: `/api/pets/${id}/tasks${date ? `?date=${date}` : ''}` }),
|
||||
createTask: (id, body) => request({ url: `/api/pets/${id}/tasks`, method: 'POST', data: body }),
|
||||
updateTask: (taskId, body) => request({ url: `/api/tasks/${taskId}`, method: 'PUT', data: body }),
|
||||
deleteTask: (taskId) => request({ url: `/api/tasks/${taskId}`, method: 'DELETE' }),
|
||||
toggleTask: (taskId) => request({ url: `/api/tasks/${taskId}/toggle`, method: 'POST' }),
|
||||
completeAllTasks: (id) => request({ url: `/api/pets/${id}/tasks/complete-all`, method: 'POST' }),
|
||||
|
||||
// 计划
|
||||
getPlan: (id) => request({ url: `/api/pets/${id}/plan` }),
|
||||
planCalendar: (id, month) =>
|
||||
request({ url: `/api/pets/${id}/plan/calendar${month ? `?month=${month}` : ''}` }),
|
||||
dayPlan: (id, date) => request({ url: `/api/pets/${id}/day-plan?date=${date}` }),
|
||||
// AI 相关:用户端入口已全部下掉,后端 10 个路由和配额配置都还在。
|
||||
// 想开回来把这几行解注释、再把入口接上就行,不用重写。
|
||||
// createAIPlan: (id, input) => request({ url: `/api/pets/${id}/ai-plan`, method: 'POST', data: { input } }),
|
||||
// applyAIPlan: (planId) => request({ url: `/api/ai-plan/${planId}/apply`, method: 'POST' }),
|
||||
// 用户自己管理计划节点
|
||||
planMonthStatus: (petId) => request({ url: `/api/pets/${petId}/plan/month-status` }),
|
||||
addPlanTask: (petId, body) => request({ url: `/api/pets/${petId}/plan-tasks`, method: 'POST', data: body }),
|
||||
updatePlanTask: (taskId, body) => request({ url: `/api/plan-tasks/${taskId}`, method: 'PUT', data: body }),
|
||||
deletePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}`, method: 'DELETE' }),
|
||||
togglePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}/toggle`, method: 'POST' }),
|
||||
|
||||
// 提醒
|
||||
getReminders: (id) => request({ url: `/api/pets/${id}/reminders` }),
|
||||
createReminder: (id, body) => request({ url: `/api/pets/${id}/reminders`, method: 'POST', data: body }),
|
||||
updateReminder: (remId, body) => request({ url: `/api/reminders/${remId}`, method: 'PUT', data: body }),
|
||||
deleteReminder: (remId) => request({ url: `/api/reminders/${remId}`, method: 'DELETE' }),
|
||||
|
||||
// 报告
|
||||
weeklyReport: (id) => request({ url: `/api/pets/${id}/report/weekly` }),
|
||||
getBill: (id, period) => request({ url: `/api/pets/${id}/bill?period=${period || 'month'}` }),
|
||||
healthSummary: (id) => request({ url: `/api/pets/${id}/health-summary` }),
|
||||
getPoster: (id) => request({ url: `/api/pets/${id}/poster` }),
|
||||
|
||||
// 社区
|
||||
listPosts: (tab, page) =>
|
||||
request({ url: `/api/posts?tab=${encodeURIComponent(tab || '')}&page=${page || 1}&page_size=10` }),
|
||||
createPost: (body) => request({ url: '/api/posts', method: 'POST', data: body }),
|
||||
likePost: (id) => request({ url: `/api/posts/${id}/like`, method: 'POST' }),
|
||||
userCard: (userId) => request({ url: `/api/users/${userId}/profile` }),
|
||||
userPosts: (userId, page) => request({ url: `/api/users/${userId}/posts?page=${page || 1}&page_size=10` }),
|
||||
relations: (userId, kind, page) =>
|
||||
request({ url: `/api/users/${userId}/relations?kind=${kind}&page=${page || 1}&page_size=20` }),
|
||||
followUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'POST' }),
|
||||
unfollowUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'DELETE' }),
|
||||
deletePost: (id) => request({ url: `/api/posts/${id}`, method: 'DELETE' }),
|
||||
deleteComment: (id) => request({ url: `/api/comments/${id}`, method: 'DELETE' }),
|
||||
listComments: (id) => request({ url: `/api/posts/${id}/comments` }),
|
||||
createComment: (id, content, images, parentId) =>
|
||||
request({
|
||||
url: `/api/posts/${id}/comments`,
|
||||
method: 'POST',
|
||||
data: { content, images: images || [], parent_id: parentId || '' },
|
||||
}),
|
||||
listReplies: (commentId) => request({ url: `/api/comments/${commentId}/replies` }),
|
||||
|
||||
// 文章
|
||||
listArticles: () => request({ url: '/api/articles' }),
|
||||
submitFeedback: (body) => request({ url: '/api/feedback', method: 'POST', data: body }),
|
||||
getArticle: (id) => request({ url: `/api/articles/${id}` }),
|
||||
|
||||
// Pro
|
||||
getPro: () => request({ url: '/api/pro' }),
|
||||
activatePro: () => request({ url: '/api/pro/activate', method: 'POST' }),
|
||||
|
||||
// AI
|
||||
// aiMessages: (session, limit) =>
|
||||
// request({ url: `/api/ai/messages?session=${session || ''}&limit=${limit || 30}` }),
|
||||
// aiChat: (body) => request({ url: '/api/ai/chat', method: 'POST', data: body }),
|
||||
// assessSymptom: (id, body) =>
|
||||
// request({ url: `/api/pets/${id}/ai/assess-symptom`, method: 'POST', data: body }),
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
function toast(error) {
|
||||
wx.showToast({ title: error.message || "操作失败", icon: "none" });
|
||||
}
|
||||
function upload(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = getApp().globalData.token;
|
||||
wx.uploadFile({
|
||||
url: baseURL + "/files",
|
||||
filePath: path,
|
||||
name: "file",
|
||||
header: { Authorization: "Bearer " + token },
|
||||
success(res) {
|
||||
try {
|
||||
const body = JSON.parse(res.data);
|
||||
if (body.code === 0) resolve(body.data);
|
||||
else reject(new Error(body.message));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
module.exports = { request, secure, toast, upload };
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// 后端地址。本地联调:微信开发者工具需在「详情 → 本地设置」勾选「不校验合法域名」。
|
||||
// 上线时改为你的 https 域名,并在微信公众平台配置 request 合法域名。
|
||||
//const BASE_URL = 'http://192.168.31.4:9090';
|
||||
const BASE_URL = 'https://pet.sundynix.cn';
|
||||
const baseURL = 'http://192.168.100.3:9090/api'
|
||||
//const baseURL = 'https://pet.sundynix.cn'
|
||||
|
||||
|
||||
module.exports = { BASE_URL };
|
||||
module.exports = { baseURL};
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// 宠物档案里那些「有数据但没露出」的东西。字段后端早就返回了,只是没人用。
|
||||
// 单独放一个文件而不是塞在 home.js 里:身份卡、宠友主页以后都要用同一套算法。
|
||||
|
||||
// 星座。按公历日期分段,边界日取「这一天起算下一个星座」
|
||||
const ZODIAC = [
|
||||
[1, 20, '水瓶座'], [2, 19, '双鱼座'], [3, 21, '白羊座'], [4, 20, '金牛座'],
|
||||
[5, 21, '双子座'], [6, 22, '巨蟹座'], [7, 23, '狮子座'], [8, 23, '处女座'],
|
||||
[9, 23, '天秤座'], [10, 24, '天蝎座'], [11, 23, '射手座'], [12, 22, '摩羯座'],
|
||||
];
|
||||
function zodiac(birthday) {
|
||||
if (!birthday) return '';
|
||||
const p = String(birthday).slice(0, 10).split('-');
|
||||
if (p.length < 3) return '';
|
||||
const m = +p[1], d = +p[2];
|
||||
if (!m || !d) return '';
|
||||
// 12 月 22 日之后到 1 月 19 日都是摩羯
|
||||
for (let i = 0; i < ZODIAC.length; i++) {
|
||||
const [zm, zd, name] = ZODIAC[i];
|
||||
if (m === zm) return d >= zd ? name : ZODIAC[(i + 11) % 12][2];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// 一起生活了多少天。优先用到家日期——「我们一起生活的第 N 天」说的是
|
||||
// 它到你家之后的日子,不是它出生之后的日子。没填到家日就退回建档日。
|
||||
// 一起生活了多少天,只从「到家日期」算 —— 没填到家日就返回 null(首页显示「-」)。
|
||||
// 原来会退回建档日兜底,但那是「在这个 App 上建档的日子」,不是「它到你家的日子」,
|
||||
// 领养多年后才装 App 的会算出个很小的数,反而误导
|
||||
function daysTogether(pet) {
|
||||
const from = (pet && pet.arrived_at) || '';
|
||||
if (!from) return null;
|
||||
const d = new Date(String(from).slice(0, 10) + 'T00:00:00');
|
||||
if (isNaN(d.getTime())) return null;
|
||||
const days = Math.floor((Date.now() - d.getTime()) / 86400000) + 1;
|
||||
return days > 0 ? days : null;
|
||||
}
|
||||
|
||||
// 性别徽章。后端存的是「男孩 / 女孩 / 不确定」
|
||||
function genderBadge(gender) {
|
||||
if (gender === '男孩') return { icon: '♂', tone: 'male' };
|
||||
if (gender === '女孩') return { icon: '♀', tone: 'female' };
|
||||
return { icon: '', tone: '' };
|
||||
}
|
||||
|
||||
module.exports = { zodiac, daysTogether, genderBadge };
|
||||
@@ -1,78 +0,0 @@
|
||||
// 记录类型缓存。类型从后端来(后台可配),记录页要拿它渲染分组宫格,
|
||||
// 弹层要拿它判断走专用表单还是通用简易表单。两边必须是同一份数据——
|
||||
// 各自请求一次的话,后台改完配置两边会短暂不一致。
|
||||
const api = require('./api.js');
|
||||
|
||||
// 四个分组对应的色系。这是纯表现层的东西,不进后端配置:
|
||||
// 后端存 group key,色值留在前端,改配色不用动数据
|
||||
const GROUP_TONE = {
|
||||
daily: 'tone-1',
|
||||
health: 'tone-2',
|
||||
care: 'tone-4',
|
||||
clean: 'tone-3',
|
||||
};
|
||||
|
||||
let groups = null; // [{ key, label, items: [...] }]
|
||||
let byCode = {};
|
||||
let loading = null;
|
||||
|
||||
function index(gs) {
|
||||
groups = (gs || []).map((g) => ({
|
||||
...g,
|
||||
tone: GROUP_TONE[g.key] || 'tone-1',
|
||||
items: (g.items || []).map((t) => ({ ...t, tone: GROUP_TONE[g.key] || 'tone-1' })),
|
||||
}));
|
||||
byCode = {};
|
||||
groups.forEach((g) => g.items.forEach((t) => (byCode[t.code] = t)));
|
||||
return groups;
|
||||
}
|
||||
|
||||
// load 只真正请求一次;并发调用共用同一个 promise
|
||||
function load() {
|
||||
if (groups) return Promise.resolve(groups);
|
||||
if (loading) return loading;
|
||||
loading = api
|
||||
.recordTypes()
|
||||
.then((gs) => {
|
||||
loading = null;
|
||||
return index(gs);
|
||||
})
|
||||
.catch((e) => {
|
||||
loading = null;
|
||||
throw e;
|
||||
});
|
||||
return loading;
|
||||
}
|
||||
|
||||
// get 是同步的,给弹层用——弹层打开时不该再等一次网络请求。
|
||||
// 记录页/首页先 load 过,缓存已经热了;万一没热,返回 undefined,
|
||||
// 调用方按「专用表单」处理(那 9 种的分支是写死在 wxml 里的,不依赖这份缓存)
|
||||
function get(code) {
|
||||
return byCode[code];
|
||||
}
|
||||
|
||||
// isSimple 走通用简易表单的类型。缓存没热时返回 false,
|
||||
// 宁可打不开也不要用错的表单存下一条字段不全的记录
|
||||
function isSimple(code) {
|
||||
const t = byCode[code];
|
||||
return !!t && t.form === 'simple';
|
||||
}
|
||||
|
||||
function label(code) {
|
||||
const t = byCode[code];
|
||||
return (t && t.label) || '';
|
||||
}
|
||||
|
||||
// 后台改了配置之后强制重取
|
||||
function reset() {
|
||||
groups = null;
|
||||
byCode = {};
|
||||
loading = null;
|
||||
}
|
||||
|
||||
// groups 同步读缓存,给「任务关联记录类型」那个下拉用
|
||||
function all() {
|
||||
return groups;
|
||||
}
|
||||
|
||||
module.exports = { load, get, isSimple, label, reset, groups: all, GROUP_TONE };
|
||||
@@ -1,167 +0,0 @@
|
||||
const { BASE_URL } = require('./config.js');
|
||||
|
||||
const TOKEN_KEY = 'pets_token';
|
||||
const REFRESH_KEY = 'pets_refresh_token';
|
||||
|
||||
function getToken() {
|
||||
return wx.getStorageSync(TOKEN_KEY) || '';
|
||||
}
|
||||
function setToken(t) {
|
||||
wx.setStorageSync(TOKEN_KEY, t);
|
||||
}
|
||||
function getRefreshToken() {
|
||||
return wx.getStorageSync(REFRESH_KEY) || '';
|
||||
}
|
||||
function setRefreshToken(t) {
|
||||
if (t) wx.setStorageSync(REFRESH_KEY, t);
|
||||
}
|
||||
function clearToken() {
|
||||
wx.removeStorageSync(TOKEN_KEY);
|
||||
wx.removeStorageSync(REFRESH_KEY);
|
||||
}
|
||||
|
||||
// token 失效时的重新登录钩子,由 store 注册(放在这里避免 request ← api ← store 循环依赖)
|
||||
let reAuth = null;
|
||||
function setReAuthHandler(fn) {
|
||||
reAuth = fn;
|
||||
}
|
||||
|
||||
const TIMEOUT = 15000;
|
||||
|
||||
// access token 只有 2 小时,过期后先拿 refresh token 换新的(用户无感),
|
||||
// 换不到(刷新令牌也过期/被吊销)才退回 wx.login 重新登录。
|
||||
// 单飞:并发请求同时 401 时只发一次刷新,否则刷新令牌会被并发轮换掉。
|
||||
let refreshing = null;
|
||||
|
||||
function refreshAccessToken() {
|
||||
if (!refreshing) {
|
||||
const rt = getRefreshToken();
|
||||
refreshing = (rt
|
||||
? request({ url: '/api/auth/refresh', method: 'POST', data: { refresh_token: rt }, noAuthRetry: true })
|
||||
: Promise.reject(new Error('没有刷新令牌'))
|
||||
).then((data) => {
|
||||
setToken(data.token);
|
||||
setRefreshToken(data.refresh_token);
|
||||
return data.token;
|
||||
});
|
||||
const done = () => {
|
||||
refreshing = null;
|
||||
};
|
||||
refreshing.then(done, done);
|
||||
}
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
// 恢复登录态:先续期,续不了就整套重新登录
|
||||
function recoverAuth() {
|
||||
return refreshAccessToken().catch(() => {
|
||||
clearToken();
|
||||
if (!reAuth) throw new Error('登录已过期');
|
||||
return reAuth();
|
||||
});
|
||||
}
|
||||
|
||||
// 统一请求:注入 token,解析 {code,message,data},成功返回 data,失败 reject(Error)。
|
||||
// token 失效(40100)时自动续期/重新登录并重试一次,避免 JWT 过期后小程序静默白屏。
|
||||
function request(options, _retried) {
|
||||
const { url, method = 'GET', data, header = {}, noAuthRetry } = options;
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = getToken();
|
||||
wx.request({
|
||||
url: BASE_URL + url,
|
||||
method,
|
||||
data,
|
||||
timeout: TIMEOUT,
|
||||
header: Object.assign(
|
||||
{ 'Content-Type': 'application/json' },
|
||||
token ? { Authorization: 'Bearer ' + token } : {},
|
||||
header,
|
||||
),
|
||||
success(res) {
|
||||
const body = res.data;
|
||||
if (body && typeof body === 'object' && 'code' in body) {
|
||||
if (body.code === 0) return resolve(body.data);
|
||||
if (body.code === 40100) {
|
||||
// 只重试一次,防止刷新/登录接口本身出问题时无限循环
|
||||
if (_retried || noAuthRetry) {
|
||||
clearToken();
|
||||
return reject(new Error(body.message || '登录已过期,请重进小程序'));
|
||||
}
|
||||
return recoverAuth()
|
||||
.then(() => request(options, true).then(resolve, reject))
|
||||
.catch(() => {
|
||||
clearToken();
|
||||
reject(new Error(body.message || '登录已过期,请重进小程序'));
|
||||
});
|
||||
}
|
||||
return reject(new Error(body.message || '请求失败'));
|
||||
}
|
||||
resolve(body);
|
||||
},
|
||||
fail(err) {
|
||||
const msg = (err && err.errMsg) || '网络错误';
|
||||
reject(new Error(/timeout/i.test(msg) ? '网络超时,请稍后重试' : msg));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 文件上传(multipart)。三重兜底,应对「第一次报错、重试就好」的偶发:
|
||||
// 1) 上传前若还没 token(冷启动竞态),先续期拿到再传;
|
||||
// 2) token 过期(40100)→ 续期后重试;
|
||||
// 3) 网络 fail / 网关 5xx(响应非 JSON)等瞬时错误 → 自动重试一次。
|
||||
// 后端按 MD5 去重,重复上传同一张图返回同一条记录,重试是安全的。
|
||||
function uploadFile(filePath, _retried) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 瞬时错误:还没重试过就延后再传一次,否则直接失败
|
||||
const softRetry = (err) => {
|
||||
if (_retried) return reject(err);
|
||||
setTimeout(() => uploadFile(filePath, true).then(resolve, reject), 500);
|
||||
};
|
||||
const fire = () => {
|
||||
const token = getToken();
|
||||
wx.uploadFile({
|
||||
url: BASE_URL + '/api/upload',
|
||||
filePath,
|
||||
name: 'file',
|
||||
timeout: 60000,
|
||||
header: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
success(res) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(res.data);
|
||||
} catch (e) {
|
||||
// 非 JSON 多为网关 5xx / 服务重启窗口,按瞬时错误重试
|
||||
return softRetry(new Error('上传失败,请重试'));
|
||||
}
|
||||
if (body.code === 0) return resolve(body.data);
|
||||
if (body.code === 40100 && !_retried) {
|
||||
return recoverAuth()
|
||||
.then(() => uploadFile(filePath, true).then(resolve, reject))
|
||||
.catch(() => reject(new Error('登录已过期,请重进小程序')));
|
||||
}
|
||||
reject(new Error(body.message || '上传失败'));
|
||||
},
|
||||
fail(err) {
|
||||
softRetry(new Error((err && err.errMsg) || '上传失败'));
|
||||
},
|
||||
});
|
||||
};
|
||||
// 冷启动竞态:点得太快、登录还没完成就上传,先把 token 拿到再传
|
||||
if (!getToken() && !_retried) {
|
||||
return recoverAuth().then(fire, fire);
|
||||
}
|
||||
fire();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request,
|
||||
uploadFile,
|
||||
getToken,
|
||||
setToken,
|
||||
getRefreshToken,
|
||||
setRefreshToken,
|
||||
clearToken,
|
||||
setReAuthHandler,
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
// 全局状态:登录、宠物列表、当前宠物;服务端驱动 + 发布订阅
|
||||
const api = require('./api.js');
|
||||
const { setReAuthHandler } = require('./request.js');
|
||||
|
||||
const state = {
|
||||
user: null,
|
||||
pets: [],
|
||||
currentId: 0,
|
||||
};
|
||||
|
||||
const listeners = new Set();
|
||||
let bootstrapPromise = null;
|
||||
|
||||
function getPets() {
|
||||
return state.pets;
|
||||
}
|
||||
function getPet() {
|
||||
return state.pets.find((p) => p.id === state.currentId) || state.pets[0] || {};
|
||||
}
|
||||
function currentPetId() {
|
||||
const p = getPet();
|
||||
return p && p.id;
|
||||
}
|
||||
|
||||
function notify() {
|
||||
const pet = getPet();
|
||||
listeners.forEach((fn) => {
|
||||
try {
|
||||
fn(pet);
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// 登录 + 拉宠物列表(只跑一次)
|
||||
// token 失效时清掉引导缓存并重新登录一次,让 request 层能自动重试
|
||||
setReAuthHandler(async () => {
|
||||
bootstrapPromise = null;
|
||||
await ready();
|
||||
});
|
||||
|
||||
function ready() {
|
||||
if (!bootstrapPromise) {
|
||||
bootstrapPromise = (async () => {
|
||||
const res = await api.login();
|
||||
state.user = res.user;
|
||||
await loadPets();
|
||||
})().catch((e) => {
|
||||
bootstrapPromise = null; // 允许重试
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return bootstrapPromise;
|
||||
}
|
||||
|
||||
async function loadPets() {
|
||||
const pets = await api.getPets();
|
||||
state.pets = pets || [];
|
||||
if (!state.currentId && state.pets[0]) {
|
||||
state.currentId = state.pets[0].id;
|
||||
}
|
||||
// 当前宠物被删或不存在时回退第一只
|
||||
if (!state.pets.find((p) => p.id === state.currentId) && state.pets[0]) {
|
||||
state.currentId = state.pets[0].id;
|
||||
}
|
||||
notify();
|
||||
return state.pets;
|
||||
}
|
||||
|
||||
function switchPet(id) {
|
||||
if (state.currentId === id) return;
|
||||
state.currentId = id;
|
||||
notify();
|
||||
}
|
||||
|
||||
// 本地更新当前宠物字段(服务端已改动后同步 UI)
|
||||
function patchCurrent(patch) {
|
||||
const pet = getPet();
|
||||
Object.assign(pet, patch);
|
||||
notify();
|
||||
}
|
||||
|
||||
function setUser(u) {
|
||||
state.user = u;
|
||||
}
|
||||
function getUser() {
|
||||
return state.user;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ready,
|
||||
loadPets,
|
||||
getPets,
|
||||
getPet,
|
||||
currentPetId,
|
||||
switchPet,
|
||||
patchCurrent,
|
||||
subscribe,
|
||||
setUser,
|
||||
getUser,
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
// tabBar 高亮同步。
|
||||
//
|
||||
// 本来想让 custom-tab-bar 自己在 pageLifetimes.show 里算,页面一行都不用写。
|
||||
// 实测不行:自定义 tabBar 不在页面节点树里,pageLifetimes 拿不到可靠的时机,
|
||||
// 结果是切到「我的」时高亮还留在「首页」。只能回到官方那套「页面 onShow 里推一把」。
|
||||
//
|
||||
// 但索引仍然由 tabBar 按当前路由自己算,页面这边不写死 0/1/2/3/4——
|
||||
// 调 tab 顺序时改一个 LIST 就够了,五个页面一行都不用动。
|
||||
function syncTabBar(page) {
|
||||
const bar = page.getTabBar && page.getTabBar();
|
||||
if (bar && bar.syncSelected) bar.syncSelected();
|
||||
}
|
||||
|
||||
module.exports = { syncTabBar };
|
||||
@@ -1,15 +0,0 @@
|
||||
// 轻量 UI 提示:统一错误反馈,避免请求失败时页面一片空白、用户不知道发生了什么。
|
||||
let lastMsg = '';
|
||||
let lastAt = 0;
|
||||
|
||||
// 提示错误。相同文案 3 秒内不重复弹,避免多个并发请求同时失败时刷屏。
|
||||
function toastErr(e, fallback) {
|
||||
const msg = (e && e.message) || fallback || '加载失败';
|
||||
const now = Date.now();
|
||||
if (msg === lastMsg && now - lastAt < 3000) return;
|
||||
lastMsg = msg;
|
||||
lastAt = now;
|
||||
wx.showToast({ title: msg, icon: 'none', duration: 2000 });
|
||||
}
|
||||
|
||||
module.exports = { toastErr };
|
||||
@@ -1,55 +0,0 @@
|
||||
// 公共图片上传:选图(仅图片)→ 上传后端(/api/upload,MD5 去重)→ 返回 {id,url,md5}
|
||||
const { uploadFile } = require('./request.js');
|
||||
|
||||
const IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
|
||||
|
||||
// 仅允许图片:按扩展名二次校验(chooseMedia mediaType 已限制,这里双保险)
|
||||
function isImagePath(p) {
|
||||
const m = /\.([a-zA-Z0-9]+)(\?.*)?$/.exec(p || '');
|
||||
return !!m && IMG_EXT.indexOf(m[1].toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
// 选择并上传图片,返回 Promise<[{id,url,md5}]>。用户取消时 reject({canceled:true})。
|
||||
function chooseAndUploadImages(count) {
|
||||
count = count || 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.chooseMedia({
|
||||
count,
|
||||
mediaType: ['image'], // 仅图片,不允许视频/文件
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success(res) {
|
||||
const files = (res.tempFiles || []).filter((f) => isImagePath(f.tempFilePath));
|
||||
if (!files.length) {
|
||||
reject(new Error('只能上传图片文件'));
|
||||
return;
|
||||
}
|
||||
wx.showLoading({ title: '上传中...', mask: true });
|
||||
Promise.all(files.map((f) => uploadFile(f.tempFilePath)))
|
||||
.then((list) => {
|
||||
wx.hideLoading();
|
||||
resolve(list);
|
||||
})
|
||||
.catch((e) => {
|
||||
wx.hideLoading();
|
||||
reject(e);
|
||||
});
|
||||
},
|
||||
fail(err) {
|
||||
const msg = (err && err.errMsg) || '';
|
||||
if (/cancel/i.test(msg)) {
|
||||
reject({ canceled: true });
|
||||
return;
|
||||
}
|
||||
reject(new Error(msg || '选择图片失败'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 单张图片,返回 Promise<{id,url,md5}>
|
||||
function chooseAndUploadImage() {
|
||||
return chooseAndUploadImages(1).then((list) => list[0]);
|
||||
}
|
||||
|
||||
module.exports = { chooseAndUploadImages, chooseAndUploadImage, isImagePath };
|
||||
Reference in New Issue
Block a user