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:
@@ -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, '保存失败');
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user