Files
sundynix-pets/pets-fe/pages/history/history.js
T
Blizzard aefb1bc103 feat: 建档改成三步向导页,品种改成后台可配的选择器
## pages/petform 三步向导
  ① 名字 + 传头像
  ② 猫还是狗 → 选品种
  ③ 性别 / 生日 / 到家日期 / 体重 / 毛色 / 阶段

**编辑时不分步**,一屏全放开:用户是来改某一项的,不该被按顺序走一遍。
同一个页面两种形态,靠 ?id= 区分。

每一步只校验这一步的东西。三步全填完才校验的话,用户在第三步才被告知
第一步没填名字,还得翻回去。

进度条没用 seg-tabs:那是「可以随意切」的语义,这里有顺序,
点第三步跳过前两步会拿到一份没名字的档案。

## 生日和到家日期分开
原来是一个「生日 / 到家时间」字段。领养的成年猫狗生日常常是不知道的,
而「一起生活了多少天」要按到家日算——合成一个字段两件事都说不准。
验过:到家 4 个月前 → 第 121 天;按生日算会是 801 天。

模型里 Birthday 和 ArrivedAt 本来就是两个字段,只是表单没露出来。

## 品种:自由文本 → 后台可配的选择器
原来品种是个输入框,用户手打「柯基」「柯基犬」「威尔士柯基」算三个品种,
以后想按品种做体重基准、常见病提示这类事就没法做。

新增 breeds 表,预置 34 种猫 + 45 种狗,按首字母分组、右侧 A-Z 索引跳转、
首字母吸顶。留了「不确定」——领养的串串确实说不出品种,逼着选一个只会
得到假数据。换物种会清掉已选品种(原来那个一定不对了)。

**首字母存字段不运行时算**:Go 没有标准拼音库,而多音字自动转常出错——
「藏獒」按 cang 还是 zang 分组、「柴犬」的柴,人来定比库来猜靠谱。
后台新增品种时手填这一位,服务端校验必须是单个 A-Z。

已有档案在用的品种不许删(删了那些档案的品种就变成查不到的字符串),
想隐藏用 enabled=false。同物种下不许重名——品种是统计口径,重名会把
同一种猫劈成两半。

预置数据自己填错了两条,实跑时看出来的:「苏格兰柯利犬」我按「柯利」
填了 K,应该按「苏格兰」算 S;「双血统边牧」根本不是品种,是血统描述。
seed 源和库里都改了。

## 弹层继续瘦
addPet/editPet 两支删掉,连带 10 个方法、ageFromBirthday/GENDERS/STAGES
和 5 个 add* 字段。累计(从记录表单搬走那次算起):
  js   1110 → 734 行
  wxml  485 → 307 行

多宠管理里「点某只去编辑」原来是 setType('editPet'),那支没了会开一个
空白弹层——改成关弹层再跳页。8 个页面的入口全换完,grep 无残留。

## 验证(预生产库实跑)
  品种接口   猫 34 种 16 个首字母、狗 45 种 17 个首字母;非法 species 返回 []
  建档      生日 / 到家日期 分别落库,品种毛色阶段都对
  编辑      只改名字,到家日期没被冲掉
  修正      柯利犬 K→S、双血统边牧已删

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:26:51 +08:00

265 lines
8.8 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() + '日';
}
// 记录类型 → 配色。和首页快速记录入口用同一套分组:
// 体重/疫苗=橙、便便/饮食=绿、异常/用药=蓝、消费/照片=紫
// 时间轴每条记录的色调。这页只读历史,不需要整份类型表,
// 但仍要按分组着色——所以引一份轻量的 code→组 映射
const recordTypes = require('../../utils/recordTypes.js');
function toneOf(type) {
const t = recordTypes.get(type);
return (t && t.tone) || 'tone-1';
}
// 「记一笔」的入口不再写死在这里:类型和分组由后端 record_types 表提供
// (后台可配),前端只负责渲染。utils/recordTypes.js 是两处共用的缓存。
function mapRecord(r) {
return {
id: r.id,
// 图标按 type 取(9 值枚举,可靠);老数据 icon 字段里的 emoji 只作兜底
type: r.type || r.icon || '',
tone: toneOf(r.type),
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,
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() {
// 时间轴的分组配色要读类型缓存。这页可能是从报告页直接进来的,
// 缓存没经过首页/记录页热过,所以自己拉一次(load 内部只会真请求一次)
recordTypes.load().then(() => this.setData({ timeline: this.data.timeline })).catch(() => {});
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() {
wx.navigateTo({ url: '/pages/petform/petform' });
},
onShareAppMessage() {
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
},
onShareTimeline() {
return { title: '用肉垫计划记录毛孩子的成长' };
},
});