Files
sundynix-pets/pets-fe/pages/record/record.js
T
Blizzard 75765b3ee8 feat: 健康洞察——把散落的记录变成能看懂的结论
之前用户记几十条数据只换回一条折线,数据没有回到他身上,所以不记也不亏。
新增 GET /api/pets/:id/insights,只看最近 90 天(更早的对「现在怎么样」
没参考价值,还会让关联分析找出一堆巧合),产出最多 6 条按严重度排序的结论:

1. 体重趋势 —— 单次数字没意义,跨度 ≥14 天的连续同向变化才算。
   跌 10% 报警建议血检,涨 15% 提醒控制,跌 5% 持续观察
2. 换粮 → 软便的时间关联 —— 找异常排便前 72 小时内的饮食记录。
   这是用户最看不出来的一类:两条记录隔两三天、分属不同类型,翻时间轴翻不出来
3. 异常扎堆 —— 两周内 ≥2 次异常。偶发一次和反复出现完全不是一回事
4. 逾期提醒 —— 设了不看等于没设
5. 同龄体重对比 —— 同物种、月龄差 2 个月内其它宠物的中位数。
   样本 <8 只直接不显示:拿 3 只算出来的「中位数」比不给还糟
6. 记录习惯 —— 数据不足时negative空手而归,给一句「再记几条就能看出规律」

每条都带 evidence(支撑它的具体数据)和 action(点击直接跳到对应记录入口),
结论不能只是断言。文案沿用 AI 助手同一套护栏:不做诊断,异常一律写清
什么时候必须就医。

前端落在记录页体重趋势卡上方,三档配色区分严重度。

实测(造 4 条体重下滑 + 换粮 + 软便 + 两次异常):
🔴 体重掉了 13%,建议就医检查(39 天内 4.50→3.90kg)
🔴 两周内记了 2 次异常
🟠 这次「软便」之前 2 天有过饮食变化(7月20日换粮 → 7月22日软便)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:03:36 +08:00

278 lines
9.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() + '日';
}
// 记录类型 → 配色。和首页快速记录入口用同一套分组:
// 体重/疫苗=橙、便便/饮食=绿、异常/用药=蓝、消费/照片=紫
const TYPE_TONE = {
weight: 'tone-1', vaccine: 'tone-1', deworm: 'tone-1',
poop: 'tone-2', food: 'tone-2',
symptom: 'tone-3', medicine: 'tone-3',
cost: 'tone-4', photo: 'tone-4',
};
// 记一笔的九个入口
const RECORD_ITEMS = [
{ type: 'weight', icon: 'weight', label: '体重', tone: 'tone-1' },
{ type: 'food', icon: 'food', label: '饮食', tone: 'tone-2' },
{ type: 'poop', icon: 'poop', label: '便便', tone: 'tone-2' },
{ type: 'symptom', icon: 'symptom', label: '异常', tone: 'tone-3' },
{ type: 'medicine', icon: 'medicine', label: '用药', tone: 'tone-3' },
{ type: 'cost', icon: 'cost', label: '消费', tone: 'tone-4' },
{ type: 'vaccine', icon: 'vaccine', label: '疫苗', tone: 'tone-1' },
{ type: 'deworm', icon: 'deworm', label: '驱虫', tone: 'tone-1' },
{ type: 'photo', icon: 'photo', label: '照片', tone: 'tone-4' },
];
function mapRecord(r) {
return {
id: r.id,
// 图标按 type 取(9 值枚举,可靠);老数据 icon 字段里的 emoji 只作兜底
type: r.type || r.icon || '',
tone: TYPE_TONE[r.type] || 'tone-1',
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 };
}
// 洞察等级 → 图标。alert 用警告号,其余按类别给
const INS_ICON = { alert: 'warn', warn: 'warn', info: 'trend' };
function mapInsight(i) {
return { ...i, icon: i.level === 'info' ? (INS_ICON.info) : INS_ICON.alert };
}
const REC_PAGE = 6;
Page({
data: {
pet: {},
timeline: [],
recPage: 1,
recTotal: 0,
recHasMore: false,
recFilters: REC_FILTERS,
recFilterIdx: 0,
recordItems: RECORD_ITEMS,
insights: [],
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;
},
// 来自 seg-tabs 的 change 事件
onFilterTap(e) {
const i = Number(e.detail.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' }));
},
});
},
// 健康洞察:把散落的记录变成结论。点一条直接跳到对应的记录入口
loadInsights() {
const id = store.currentPetId();
if (!id) return this.setData({ insights: [] });
api
.petInsights(id)
.then((list) => this.setData({ insights: (list || []).map(mapInsight) }))
.catch(() => this.setData({ insights: [] }));
},
onTapInsight(e) {
const it = this.data.insights[e.currentTarget.dataset.index];
if (it && it.action) this.openSheetType(it.action);
},
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();
this.loadInsights();
},
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() {
wx.navigateTo({ url: '/pages/ai/ai' });
},
onShareAppMessage() {
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
},
onShareTimeline() {
return { title: '用肉垫计划记录毛孩子的成长' };
},
});