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, '保存失败');
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user