Files
sundynix-pets/pets-fe/pages/record/record.js
T
Blizzard 7093e6517d perf(fe): 修复 token 过期白屏、请求翻倍与失败伪装成空态
token 过期后小程序静默白屏(影响所有用户)
- JWT 有效期 7 天,过期后 40100 只 clearToken 不重新登录,而
  store.ready 的 promise 已缓存不会重跑,加上 23 处静默 catch,
  用户看到的是「什么都没有、也没有任何提示」,只能重启小程序
- request 层加 token 失效钩子:自动重新登录并重试一次(只重试一次,
  防止登录接口本身故障时死循环);store 注册钩子重置引导缓存
- 补 15s 请求超时、60s 上传超时,超时给明确文案(原先默认 60s 卡住无反馈)

请求翻倍
- 每个 tab 页同时用 store.subscribe 和 onShow→ready().then() 加载,
  而 ready() 内部 loadPets 会 notify 触发 subscriber,首次进入请求翻倍
- subscriber 改为只处理「切换宠物 / 数据变更」,首次加载交给 onShow
- 首页 bind:save 与组件实际触发的 saved 事件名不一致,onSheetSave
  从未生效;一并修正,并去掉关闭弹层时的全量重载

失败伪装成空态
- 社区请求失败时显示「还没有帖子,来发第一条吧」,把故障说成没数据。
  改为区分「加载失败(可点击重试)」「关注 tab 为空」「确实没有帖子」
- 新增 utils/ui.js 统一错误提示(同文案 3 秒内去重,避免并发失败刷屏),
  首页与记录页主数据加载失败不再静默

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 17:56:35 +08:00

231 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
function pad(n) {
return n < 10 ? '0' + n : '' + n;
}
function fmtTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const hm = pad(d.getHours()) + ':' + pad(d.getMinutes());
if (d.toDateString() === now.toDateString()) return '今天 ' + hm;
const y = new Date(now.getTime() - 86400000);
if (d.toDateString() === y.toDateString()) return '昨天 ' + hm;
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
function mapRecord(r) {
return {
id: r.id,
icon: r.icon || '✍️',
title: r.title,
desc: fmtTime(r.occurred_at) + (r.description ? '' + r.description : ''),
image: r.image_url || '',
};
}
// 时间轴类型筛选
const REC_FILTERS = [
{ key: '', label: '全部' },
{ key: 'weight', label: '体重' },
{ key: 'poop', label: '便便' },
{ key: 'food', label: '饮食' },
{ key: 'symptom', label: '异常' },
{ key: 'medicine', label: '用药' },
{ key: 'vaccine', label: '疫苗' },
{ key: 'cost', label: '消费' },
{ key: 'photo', label: '照片' },
];
function fmtDay(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
// 用真实体重点生成趋势图:返回 { svg(折线), dots(可点圆点,坐标为百分比) }
function buildChart(points) {
const vals = points.map((p) => Number(p.value) || 0);
const W = 300, H = 100, pad = 12, n = vals.length;
let min = Math.min.apply(null, vals);
let max = Math.max.apply(null, vals);
if (max === min) max = min + 1;
const xs = (i) => (n === 1 ? W / 2 : pad + ((W - 2 * pad) * i) / (n - 1));
const ys = (v) => H - pad - ((H - 2 * pad) * (v - min)) / (max - min);
const pts = vals.map((v, i) => xs(i).toFixed(1) + ',' + ys(v).toFixed(1)).join(' ');
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none">' +
'<polyline points="' + pts + '" fill="none" stroke="#73BE9D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>';
const dots = points.map((p, i) => ({
xp: Number(((xs(i) / W) * 100).toFixed(2)),
yp: Number(((ys(vals[i]) / H) * 100).toFixed(2)),
value: vals[i],
date: fmtDay(p.occurred_at),
note: p.note || '',
delta: i > 0 ? Number((vals[i] - vals[i - 1]).toFixed(2)) : null,
}));
return { svg: 'data:image/svg+xml,' + encodeURIComponent(svg), dots };
}
const REC_PAGE = 6;
Page({
data: {
pet: {},
timeline: [],
recPage: 1,
recTotal: 0,
recHasMore: false,
recFilters: REC_FILTERS,
recFilterIdx: 0,
trendSvg: '',
trendStats: null,
trendDots: [],
selDot: null,
selIdx: -1,
sheetShow: false,
sheetType: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.setData({ pet });
if (!this._inited) return; // 首次由 onShow 加载
this.loadRecords();
});
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 2 });
}
store
.ready()
.then(() => {
this._inited = true;
this.setData({ pet: store.getPet() });
this.loadRecords();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
curFilter() {
return this.data.recFilters[this.data.recFilterIdx].key;
},
onFilterTap(e) {
const i = Number(e.currentTarget.dataset.index);
if (i === this.data.recFilterIdx) return;
this.setData({ recFilterIdx: i }, () => this.loadRecords());
},
// 长按删除一条记录
onDeleteRecord(e) {
const r = this.data.timeline[e.currentTarget.dataset.index];
if (!r || !r.id) return;
wx.showModal({
title: '删除记录',
content: '确定删除「' + r.title + '」?删除后不可恢复。',
confirmColor: '#D9534F',
success: (res) => {
if (!res.confirm) return;
api
.deleteRecord(r.id)
.then(() => {
wx.showToast({ title: '已删除', icon: 'success' });
this.loadRecords();
})
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
loadRecords() {
const id = store.currentPetId();
if (!id) return;
api
.getRecords(id, { page: 1, pageSize: REC_PAGE, type: this.curFilter() })
.then((res) => {
const list = (res.list || []).map(mapRecord);
this.setData({ timeline: list, recPage: 1, recTotal: res.total || 0, recHasMore: list.length < (res.total || 0) });
})
.catch((e) => toastErr(e));
this.loadTrend();
},
loadMoreRecords() {
const id = store.currentPetId();
if (!id) return;
const next = this.data.recPage + 1;
api
.getRecords(id, { page: next, pageSize: REC_PAGE, type: this.curFilter() })
.then((res) => {
const merged = this.data.timeline.concat((res.list || []).map(mapRecord));
this.setData({ timeline: merged, recPage: next, recTotal: res.total || 0, recHasMore: merged.length < (res.total || 0) });
})
.catch(() => {});
},
collapseRecords() {
this.loadRecords();
},
loadTrend() {
const id = store.currentPetId();
if (!id) {
this.setData({ trendSvg: '', trendStats: null, trendDots: [], selDot: null, selIdx: -1 });
return;
}
api
.weightTrend(id)
.then((points) => {
points = points || [];
if (points.length < 2) {
this.setData({ trendSvg: '', trendStats: null, trendDots: [], selDot: null, selIdx: -1 });
return;
}
const vals = points.map((p) => Number(p.value) || 0);
const latest = vals[vals.length - 1];
const delta = Number((latest - vals[0]).toFixed(2));
const chart = buildChart(points);
const lastIdx = chart.dots.length - 1;
this.setData({
trendSvg: chart.svg,
trendDots: chart.dots,
selDot: chart.dots[lastIdx], // 默认选中最新一次
selIdx: lastIdx,
trendStats: { latest, delta, count: points.length },
});
})
.catch(() => {});
},
onTapPoint(e) {
const i = e.currentTarget.dataset.index;
this.setData({ selDot: this.data.trendDots[i], selIdx: i });
},
previewImage(e) {
const src = e.currentTarget.dataset.src;
if (src) wx.previewImage({ urls: [src], current: src });
},
openSheet(e) {
this.openSheetType(e.currentTarget.dataset.type);
},
openSheetType(type) {
this.setData({ sheetType: type, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onSheetSaved() {
this.loadRecords();
},
onAddPet() {
this.openSheetType('addPet');
},
onFab() {
this.openSheetType('ai');
},
onShareAppMessage() {
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
},
onShareTimeline() {
return { title: '用肉垫计划记录毛孩子的成长' };
},
});