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, '保存失败');
});
},
});
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
}
}
+70
View File
@@ -0,0 +1,70 @@
<nav-bar title="添加{{type.label}}记录" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body ar-body">
<!-- 宠物 + 时间。多宠家庭最容易记错到别的宠物身上,所以放第一行 -->
<view class="card ar-card">
<view class="ar-row" bindtap="pickPet">
<text class="ar-k">记录宠物</text>
<text class="ar-v">{{pet.name || '选择'}}</text>
<pt-icon name="next" size="{{26}}"></pt-icon>
</view>
<picker mode="date" value="{{date}}" bindchange="onDate">
<view class="ar-row ar-last">
<text class="ar-k">记录时间</text>
<text class="ar-v">{{dateLabel}}</text>
<pt-icon name="next" size="{{26}}"></pt-icon>
</view>
</picker>
</view>
<!-- 类型专属字段。整段由后端 record_types.fields 驱动,
前端不认识「体重」「金额」这些词,只认识 number / options / text -->
<view wx:if="{{fields.length}}" class="card ar-card">
<block wx:for="{{fields}}" wx:key="index">
<!-- 数值 -->
<view wx:if="{{item.kind === 'number'}}" class="ar-row {{index === fields.length - 1 ? 'ar-last' : ''}}">
<text class="ar-k">{{item.label}}</text>
<input class="ar-in" type="digit" placeholder="填写{{item.label}}" placeholder-class="ar-ph"
value="{{values[index]}}" data-index="{{index}}" bindinput="onField"/>
<text wx:if="{{item.unit}}" class="ar-unit">{{item.unit}}</text>
</view>
<!-- 文本 -->
<view wx:elif="{{item.kind === 'text'}}" class="ar-row {{index === fields.length - 1 ? 'ar-last' : ''}}">
<text class="ar-k">{{item.label}}</text>
<input class="ar-in" placeholder="填写{{item.label}}" placeholder-class="ar-ph"
value="{{values[index]}}" data-index="{{index}}" bindinput="onField"/>
</view>
<!-- 单选 -->
<view wx:else class="ar-opt-block {{index === fields.length - 1 ? 'ar-last' : ''}}">
<text class="ar-k">{{item.label}}</text>
<view class="ar-opts">
<view wx:for="{{item.options}}" wx:for-item="o" wx:key="*this"
class="ar-opt {{values[index] === o ? 'on' : ''}}"
data-index="{{index}}" data-val="{{o}}" bindtap="onPickOpt">{{o}}</view>
</view>
</view>
</block>
</view>
<view class="card ar-card">
<view class="ar-h">描述</view>
<textarea class="ar-ta" placeholder="请输入你想要记的内容~" placeholder-class="ar-ph"
value="{{note}}" maxlength="500" bindinput="onNote"></textarea>
<view class="ar-media">
<view wx:for="{{images}}" wx:key="id" class="ar-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="ar-del" catchtap="onRemoveImage" data-index="{{index}}"><pt-icon name="close" size="{{22}}"></pt-icon></view>
</view>
<view wx:if="{{images.length < 3}}" class="ar-add" bindtap="onPickImage">
<pt-icon name="photo" size="{{40}}"></pt-icon>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 保存键固定在底部:表单长了以后不该滚到最下面才能保存 -->
<view class="ar-foot">
<view class="btn btn-primary btn-block {{saving ? 'ar-busy' : ''}}" bindtap="onSave">{{saving ? '保存中…' : '保存'}}</view>
</view>
+51
View File
@@ -0,0 +1,51 @@
/* 底部固定保存键,内容要让开它 */
.ar-body{padding-bottom:calc(180rpx + env(safe-area-inset-bottom))}
.ar-card{padding:0 var(--pad-x)}
.ar-row{
display:flex;align-items:center;gap:var(--sp-3);
min-height:112rpx;border-bottom:1rpx solid var(--line);
}
.ar-last,.ar-row:last-child{border-bottom:0}
.ar-k{flex:none;font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.ar-v{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text-2)}
.ar-in{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text)}
.ar-ph{color:var(--muted2)}
.ar-unit{flex:none;font-size:var(--fs-sm);color:var(--muted)}
/* 单选独占一段:选项多的时候挤在一行右侧会换行成一团 */
.ar-opt-block{padding:var(--sp-4) 0;border-bottom:1rpx solid var(--line)}
.ar-opts{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:var(--sp-3)}
.ar-opt{
padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full);
background:var(--surface-2);border:1rpx solid var(--line);
font-size:var(--fs-sm);color:var(--text-2);
}
.ar-opt.on{
background:var(--primary-soft);border-color:var(--primary);
color:var(--primary-ink);font-weight:var(--fw-b);
}
.ar-h{font-size:var(--fs-md);font-weight:var(--fw-b);padding:var(--sp-4) 0 var(--sp-2)}
.ar-ta{width:100%;min-height:200rpx;font-size:var(--fs-md);line-height:1.6}
.ar-media{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0 var(--sp-4)}
.ar-thumb{position:relative;width:132rpx;height:132rpx;border-radius:var(--r-sm);overflow:hidden}
.ar-thumb image{width:100%;height:100%}
.ar-del{
position:absolute;top:0;right:0;width:40rpx;height:40rpx;
display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,.55);color:#fff;border-radius:0 var(--r-sm) 0 var(--r-sm);
}
.ar-add{
width:132rpx;height:132rpx;border-radius:var(--r-sm);
background:var(--surface-2);color:var(--muted2);
display:flex;align-items:center;justify-content:center;
}
.ar-foot{
position:fixed;left:0;right:0;bottom:0;z-index:50;
padding:var(--sp-4) var(--pad-x) calc(var(--sp-4) + env(safe-area-inset-bottom));
background:#fff;box-shadow:0 -12rpx 32rpx rgba(70,48,25,.06);
}
.ar-busy{opacity:.6}