feat: 添加记录独立成页,表单字段由后端配置驱动

## 弹层里塞 24 个表单是走不通的
弹层高度受 .sheet-scroll 的 78vh 限制,字段一多就变成内滚;24 项的选择器
挤在弹层里更难看(用户原话「很丑」)。整体搬成页面。

## pages/addrecord —— 一个页面吃掉全部 24 种
形态:记录宠物 / 记录时间 / 类型专属字段 / 描述 + 照片 / 底部固定保存键。

关键在「类型专属字段」整段是后端驱动的:前端不认识「体重」「金额」这些业务词,
只认识 number / options / text 三种渲染方式,以及每个字段的值该落到哪儿。

record_types 加了 fields 列,后台用紧凑写法配(一行一个):

  number:体重:kg                    数值 → num_value
  options:状态:正常|软便|拉稀          单选 → category
  options:症状:呕吐|拉稀|精神差:-      末尾 - 表示不落 category,只进标题
  text:吃了什么                      文本 → 只进标题
  (空)                            只要 时间+描述+照片

解析放服务端不放前端:配错了要在后台保存时就报出来(第几行、错在哪),
不能等用户点开表单才发现渲染不出东西。读取时解析失败只让这一种事项没有额外
字段并打 warn,不让一条烂配置把整个记录页打空。

## 那个 - 开关不是过度设计
异常观察有两个单选(症状 + 严重程度),而 category 只有一个坑,
周报的高风险数按 category='高' 统计(report.go:46)——必须能指定谁占这个坑。
同理账单按 cost 的 category 分组聚合(report.go:134),
食便相关性排除 poop 的 category='正常'。这三处口径都得严丝合缝对上。

## 记录页瘦成纯列表
只留分组宫格。健康洞察 / 体重趋势 / 健康时间轴整页搬到 pages/history,
从报告页「记录与趋势」进——报告页才是看数据的地方,而一个「我要记一笔」的
页面上摆三块只读图表,用户每次都得先滚过去才能找到要点的东西。

**没有直接删掉那三块**:时间轴是唯一能看和删历史记录的地方,报告页只有
周报/账单这些聚合。删了用户就再也看不到自己记过什么。搬页面用的是 git 里
改动前的完整版复制,比往 report.js 里合并代码安全。

## 弹层瘦了一圈
9 个记录分支删掉,连带 17 个方法、SIMPLE_HINT、buildRecord 的 7 个 case
和一批只有它们在用的 data 字段。
  js   1089 → 857 行
  wxml  480 → 335 行
弹层现在只管「不是记一笔」的事:提醒、海报、档案、发帖、评论、导出、反馈。
buildRecord 只剩 vetSummary 一个分支(它写一条 note 留痕,不是用户填的表单)。

## 验证(预生产库实跑)
24 种的字段配置逐个核对解析结果,然后照 addrecord 的 buildBody 组装落库:
  体重  title='体重:4.6kg'        num=4.6            → 宠物档案回写成 4.6kg ✓
  记账  title='记账:128元 医疗'     num=128 cat=医疗    → 账单 total=128 分类[(医疗,128)] ✓
  异常  title='异常:呕吐 高'        cat=高             → 周报 high_risk_count=1 ✓
  排便  cat=软便                                    食便相关性口径保住
  饮食  title='饮食:幼猫粮 45g 偏少' cat=偏少
  喝水  num=180(新类型带数值,老代码没有它的分支也不影响)
  洗澡  title='洗澡'(无额外字段)
  看病  num=320(两个字段:文本+数值)
体重趋势 1 个点、值 4.6,没被其他 7 条污染。

回填踩过一次坑:fields 列是服务启动时 AutoMigrate 才建的,我在启动前就跑了
回填,Update 报错被忽略、13 行静默没写进去。第二版加了 HasColumn 前置检查
和逐行错误统计才发现。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-30 10:25:42 +08:00
parent 29d2cd3faf
commit 3ea50efb7d
23 changed files with 906 additions and 777 deletions
+164
View File
@@ -0,0 +1,164 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const upload = require('../../utils/upload.js');
const recordTypes = require('../../utils/recordTypes.js');
const { toastErr } = require('../../utils/ui.js');
function pad2(n) {
return n < 10 ? '0' + n : '' + n;
}
function today() {
const d = new Date();
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
// "2026-07-30" → "2026.07.30"
function fmtDate(s) {
return (s || '').replace(/-/g, '.');
}
// 后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期它解不出来会静默回退成
// time.Now()——选「昨天」会存成今天且不报错。时区要带真实偏移:转成 UTC 的 Z 形式
// 落库再转回本地时会把边界日期挪一天。取正午同样是为了远离日界。
function dayToISO(day) {
if (!day) return '';
const off = -new Date().getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const a = Math.abs(off);
return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60);
}
Page({
data: {
code: '',
type: {},
fields: [],
values: [], // 和 fields 同下标
pet: {},
date: '',
dateLabel: '',
note: '',
images: [],
saving: false,
},
onLoad(q) {
const code = (q && q.type) || '';
const d = today();
this.setData({ code, date: d, dateLabel: fmtDate(d) });
store
.ready()
.then(() => recordTypes.load())
.then(() => this.applyType())
.catch((e) => toastErr(e));
},
applyType() {
const t = recordTypes.get(this.data.code);
if (!t) {
wx.showToast({ title: '这个记录类型不存在了', icon: 'none' });
return setTimeout(() => wx.navigateBack(), 800);
}
const fields = t.fields || [];
// 单选默认不预选:预选了用户不点也会存下一个他没确认过的值
this.setData({ type: t, fields, values: fields.map(() => ''), pet: store.getPet() || {} });
},
// 多宠时切换记到哪只身上。用 actionSheet 而不是自己画一个选择器
pickPet() {
const pets = store.getPets() || [];
if (pets.length < 2) return;
wx.showActionSheet({
itemList: pets.map((p) => p.name),
success: (r) => {
const p = pets[r.tapIndex];
store.switchPet(p.id);
this.setData({ pet: p });
},
fail: () => {},
});
},
onDate(e) {
this.setData({ date: e.detail.value, dateLabel: fmtDate(e.detail.value) });
},
onField(e) {
this.setData({ ['values[' + e.currentTarget.dataset.index + ']']: e.detail.value });
},
onPickOpt(e) {
const { index, val } = e.currentTarget.dataset;
// 再点一下取消选择——单选组里没有「不选」按钮,只能靠反选
this.setData({ ['values[' + index + ']']: this.data.values[index] === val ? '' : val });
},
onNote(e) {
this.setData({ note: e.detail.value });
},
onPickImage() {
upload
.chooseAndUploadImage()
.then((f) => this.setData({ images: this.data.images.concat([f]) }))
.catch((e) => {
if (e && e.canceled) return;
toastErr(e, '上传失败');
});
},
onRemoveImage(e) {
const imgs = this.data.images.slice();
imgs.splice(e.currentTarget.dataset.index, 1);
this.setData({ images: imgs });
},
// 按字段配置组装请求体。哪个字段落到 num_value / category 完全由后端的
// to_category / kind 决定,前端不认识「体重」「金额」这些业务词
buildBody() {
const { fields, values } = this.data;
const body = { type: this.data.code, occurred_at: dayToISO(this.data.date) };
const parts = [];
fields.forEach((f, i) => {
const v = (values[i] || '').toString().trim();
if (!v) return;
if (f.kind === 'number') {
const n = parseFloat(v);
if (!isNaN(n)) {
body.num_value = n;
parts.push(v + (f.unit || ''));
}
return;
}
if (f.to_category) body.category = v;
parts.push(v);
});
body.title = parts.length ? this.data.type.label + '' + parts.join(' ') : this.data.type.label;
body.description = (this.data.note || '').trim();
const img = this.data.images[0];
if (img) {
body.image_file_id = img.id;
body.image_url = img.url;
}
return body;
},
// 必填校验:数值字段空着存下来是一条没有数值的体重记录,趋势图上是个洞
missingField() {
const { fields, values } = this.data;
for (let i = 0; i < fields.length; i++) {
if (fields[i].kind === 'number' && !(values[i] || '').toString().trim()) {
return fields[i].label;
}
}
return '';
},
onSave() {
if (this.data.saving) return;
const id = store.currentPetId();
if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' });
const miss = this.missingField();
if (miss) return wx.showToast({ title: '请填写' + miss, icon: 'none' });
this.setData({ saving: true });
api
.createRecord(id, this.buildBody())
.then(() => {
this.setData({ saving: false });
wx.showToast({ title: '已记录', icon: 'success' });
setTimeout(() => wx.navigateBack(), 600);
})
.catch((e) => {
this.setData({ saving: false });
toastErr(e, '保存失败');
});
},
});