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
+44 -29
View File
@@ -38,45 +38,60 @@ func seedRecordTypes(db *gorm.DB) error {
// 代码分支(体重回写档案、食便相关性、消费统计、疫苗完成度…),并且被
// care_template_items.sheet_type 引用,后台不许删,只能停用。
// form=full 表示 bottom-sheet 里有它专用的 wx:elif 分支。
full := func(code, label, icon, group string, sort int) model.RecordType {
// fields 是表单额外字段的配置,一行一个(写法见 model.RecordType.Fields 的注释)。
// 空 = 只要「时间 + 描述 + 照片」。
//
// 原有 9 种的配置必须和现在的统计口径严丝合缝:
// weight number → num_valueCreateRecord 靠它回写宠物档案
// cost 金额→num_value + 类别→category,账单是按 category 分组聚合的
// poop 状态→category,食便相关性分析排除 category='正常'
// symptom 两个单选:症状不占 category,严重程度占——周报按 category='高' 数高风险
mk := func(code, label, icon, group string, sort int, locked bool, fields string) model.RecordType {
form := model.RecordFormSimple
if locked {
form = model.RecordFormFull
}
return model.RecordType{Code: code, Label: label, Icon: icon, GroupKey: group,
Sort: sort, Enabled: true, Form: model.RecordFormFull, Locked: true}
Sort: sort, Enabled: true, Form: form, Locked: locked, Fields: fields}
}
simple := func(code, label, icon, group string, sort int) model.RecordType {
return model.RecordType{Code: code, Label: label, Icon: icon, GroupKey: group,
Sort: sort, Enabled: true, Form: model.RecordFormSimple, Locked: false}
full := func(code, label, icon, group string, sort int, fields string) model.RecordType {
return mk(code, label, icon, group, sort, true, fields)
}
simple := func(code, label, icon, group string, sort int, fields string) model.RecordType {
return mk(code, label, icon, group, sort, false, fields)
}
d, h, c, cl := model.RecordGroupDaily, model.RecordGroupHealth, model.RecordGroupCare, model.RecordGroupClean
types := []model.RecordType{
// 日常
full("weight", "体重", "weight", d, 10),
full("poop", "排便", "poop", d, 20),
full("food", "饮食", "food", d, 30),
simple("water", "喝水", "water", d, 40),
full("cost", "记账", "cost", d, 50),
full("photo", "照片", "photo", d, 60),
full("weight", "体重", "weight", d, 10, "number:体重:kg"),
full("poop", "排便", "poop", d, 20, "options:状态:正常|软便|拉稀"),
full("food", "饮食", "food", d, 30, "text:吃了什么\noptions:食欲:正常|偏少|不吃"),
simple("water", "喝水", "water", d, 40, "number:饮水量:ml"),
full("cost", "记账", "cost", d, 50, "number:金额:元\noptions:类别:医疗|食品|用品|美容|其他"),
full("photo", "照片", "photo", d, 60, ""),
// 健康
full("symptom", "异常", "symptom", h, 10),
full("vaccine", "疫苗", "vaccine", h, 20),
full("deworm", "驱虫", "deworm", h, 30),
simple("checkup", "体检", "checkup", h, 40),
simple("vet", "看病", "vet", h, 50),
full("medicine", "给药", "medicine", h, 60),
simple("supplement", "保健品", "supplement", h, 70),
full("symptom", "异常", "symptom", h, 10,
"options:发生了什么:呕吐|拉稀|精神差|皮肤红|其他:-\noptions:严重程度:低|中|高"),
full("vaccine", "疫苗", "vaccine", h, 20, "text:疫苗名称"),
full("deworm", "驱虫", "deworm", h, 30, "options:类型:体内|体外|体内外同驱"),
simple("checkup", "体检", "checkup", h, 40, "text:在哪做的"),
simple("vet", "看病", "vet", h, 50, "text:诊断\nnumber:花费:元"),
full("medicine", "给药", "medicine", h, 60, "text:药品名称\ntext:剂量"),
simple("supplement", "保健品", "supplement", h, 70, "text:名称\ntext:剂量"),
// 洗护
simple("bath", "洗澡", "bath", c, 10),
simple("nail", "剪指甲", "nail", c, 20),
simple("ear", "洗耳朵", "ear", c, 30),
simple("tooth", "刷牙", "tooth", c, 40),
simple("brush", "梳毛", "brush", c, 50),
simple("groom", "美容", "groom", c, 60),
simple("bath", "洗澡", "bath", c, 10, ""),
simple("nail", "剪指甲", "nail", c, 20, ""),
simple("ear", "洗耳朵", "ear", c, 30, ""),
simple("tooth", "刷牙", "tooth", c, 40, ""),
simple("brush", "梳毛", "brush", c, 50, ""),
simple("groom", "美容", "groom", c, 60, "number:花费:元"),
// 清洁
simple("litter", "换猫砂", "litter", cl, 10),
simple("litterbox", "洗猫砂盆", "litterbox", cl, 20),
simple("bowl", "洗食盆", "bowl", cl, 30),
simple("waterbowl", "洗水盆", "waterbowl", cl, 40),
simple("clean", "消毒", "clean", cl, 50),
simple("litter", "换猫砂", "litter", cl, 10, ""),
simple("litterbox", "洗猫砂盆", "litterbox", cl, 20, ""),
simple("bowl", "洗食盆", "bowl", cl, 30, ""),
simple("waterbowl", "洗水盆", "waterbowl", cl, 40, ""),
simple("clean", "消毒", "clean", cl, 50, ""),
}
return db.Create(&types).Error
}
+2
View File
@@ -291,6 +291,7 @@ type recordTypeReq struct {
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
Form string `json:"form"`
Fields string `json:"fields_raw"`
}
// AdminListRecordTypes GET /api/admin/record-types 连停用的一起返回
@@ -308,6 +309,7 @@ func (h *Handler) AdminSaveRecordType(c *gin.Context) {
t := &model.RecordType{
Code: req.Code, Label: req.Label, Icon: req.Icon,
GroupKey: req.Group, Sort: req.Sort, Enabled: req.Enabled, Form: req.Form,
Fields: req.Fields,
}
t.ID = req.ID
if err := h.svc.SaveRecordType(t); err != nil {
+35
View File
@@ -44,4 +44,39 @@ type RecordType struct {
// Locked 有代码分支或模板引用依赖它,后台不许删。
// 硬拦在 service 层,不是靠前端隐藏按钮。
Locked bool `json:"locked"`
// Fields 表单字段配置,一行一个。后台用紧凑写法编辑:
//
// number:体重:kg 数值,落 num_value
// options:状态:正常|软便|拉稀 单选,落 category
// options:症状:呕吐|拉稀|精神差:- 单选,末尾 - 表示不落 category(只进标题)
// text:吃了什么 文本,只进标题
//
// 空 = 这个事项只要「时间 + 描述 + 照片」(洗澡、换猫砂这类)。
//
// 为什么要有「不落 category」这个开关:异常观察有两个单选(症状 + 严重程度),
// 而 category 只有一个坑,周报的高风险数按 category='高' 统计,
// 所以必须能指定是哪一个占这个坑。
Fields string `gorm:"type:text" json:"fields_raw"`
// ParsedFields 由 Fields 解析出来,前端直接照它渲染表单,不用自己写解析器
ParsedFields []RecordField `gorm:"-" json:"fields"`
}
// 字段类型
const (
FieldNumber = "number"
FieldOptions = "options"
FieldText = "text"
)
// RecordField 一个表单字段
type RecordField struct {
Kind string `json:"kind"`
Label string `json:"label"`
Unit string `json:"unit,omitempty"`
Options []string `json:"options,omitempty"`
// ToCategory 这个字段的值是否落到 health_records.category。
// 同一个事项里最多一个字段能占它
ToCategory bool `json:"to_category"`
}
+77
View File
@@ -4,6 +4,7 @@ import (
"errors"
"log"
"strconv"
"strings"
"gorm.io/gorm"
@@ -28,10 +29,80 @@ type RecordTypeGroup struct {
Items []model.RecordType `json:"items"`
}
// parseFields 把后台那份紧凑写法解析成结构。
// 解析放服务端而不是前端:写错了要在后台保存时就报出来,
// 不能等用户点开表单才发现渲染不出东西
func parseFields(raw string) ([]model.RecordField, error) {
out := []model.RecordField{}
catTaken := false
for i, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
seg := strings.Split(line, ":")
no := strconv.Itoa(i + 1)
if len(seg) < 2 {
return nil, errors.New("第 " + no + " 行格式不对,至少要 类型:名称")
}
f := model.RecordField{Kind: strings.TrimSpace(seg[0]), Label: strings.TrimSpace(seg[1])}
if f.Label == "" {
return nil, errors.New("第 " + no + " 行缺名称")
}
extra := ""
if len(seg) > 2 {
extra = strings.TrimSpace(seg[2])
}
switch f.Kind {
case model.FieldNumber:
f.Unit = extra
// 数值一律落 num_value,不占 category
case model.FieldOptions:
for _, o := range strings.Split(extra, "|") {
if o = strings.TrimSpace(o); o != "" {
f.Options = append(f.Options, o)
}
}
if len(f.Options) == 0 {
return nil, errors.New("第 " + no + " 行 options 没有选项,用 | 分隔")
}
// 末段是 - 表示放弃 category
f.ToCategory = !(len(seg) > 3 && strings.TrimSpace(seg[3]) == "-")
case model.FieldText:
// 文本只进标题
default:
return nil, errors.New("第 " + no + " 行类型 " + f.Kind + " 不认识,只能是 number / options / text")
}
if f.ToCategory {
if catTaken {
return nil, errors.New("第 " + no + " 行:一个事项里只能有一个字段落 category" +
"多出来的那个末尾加 :- ")
}
catTaken = true
}
out = append(out, f)
}
return out, nil
}
// fillFields 读取时把解析结果挂上。解析失败不让整个接口挂掉——
// 那会让一条配错的数据把整个记录页打空;只让这一种事项没有额外字段
func fillFields(list []model.RecordType) {
for i := range list {
f, err := parseFields(list[i].Fields)
if err != nil {
log.Printf("[warn] 记录类型 %s 的字段配置解析失败,按无额外字段处理:%v", list[i].Code, err)
f = []model.RecordField{}
}
list[i].ParsedFields = f
}
}
// ListRecordTypes 小程序用:只返回启用的,按分组归好
func (s *Service) ListRecordTypes() []RecordTypeGroup {
var all []model.RecordType
s.db.Where("enabled = ?", true).Order("sort asc, id asc").Find(&all)
fillFields(all)
if len(all) == 0 {
// 表被清空了。这里不硬编码兜底一份——那等于把「可配置」又变回代码里的常量,
// 下次改配置的人会发现改了没用。宁可返回空让记录页明显是空的,也别静默用旧值。
@@ -57,6 +128,7 @@ func (s *Service) ListRecordTypes() []RecordTypeGroup {
func (s *Service) AdminListRecordTypes() []model.RecordType {
var all []model.RecordType
s.db.Order("group_key asc, sort asc, id asc").Find(&all)
fillFields(all)
if all == nil {
return []model.RecordType{}
}
@@ -75,6 +147,10 @@ func (s *Service) SaveRecordType(in *model.RecordType) error {
if in.GroupKey == "" {
in.GroupKey = model.RecordGroupDaily
}
// 字段配置写错了必须在这里挡住,不能存进库等用户点开表单才暴露
if _, err := parseFields(in.Fields); err != nil {
return err
}
if in.ID == "" {
// 新增:Code 不能和已有的撞
@@ -105,6 +181,7 @@ func (s *Service) SaveRecordType(in *model.RecordType) error {
return s.db.Model(&model.RecordType{}).Where("id = ?", in.ID).Updates(map[string]any{
"code": in.Code, "label": in.Label, "icon": in.Icon,
"group_key": in.GroupKey, "sort": in.Sort, "enabled": in.Enabled,
"fields": in.Fields,
}).Error
}
+2
View File
@@ -4,6 +4,8 @@
"pages/home/home",
"pages/plan/plan",
"pages/record/record",
"pages/addrecord/addrecord",
"pages/history/history",
"pages/report/report",
"pages/community/community",
"pages/learn/learn",
+5 -237
View File
@@ -18,19 +18,6 @@ function daysLater(n) {
// "2026-07-30" → "2026-07-30T12:00:00+08:00"
//
// 后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期它解不出来会静默
// 回退成 time.Now()——用户选了「昨天洗澡」会存成今天,而且不报错。
// 时区必须带真实偏移:转成 UTC 的 Z 形式,落库再转回本地时会把边界日期挪一天
// (首页任务那个 bug 就是这么来的)。取正午同样是为了远离日界。
function dayToISO(day) {
if (!day) return '';
const off = -new Date().getTimezoneOffset(); // 东八区是 +480
const sign = off >= 0 ? '+' : '-';
const a = Math.abs(off);
const p = (x) => (x < 10 ? '0' + x : '' + x);
return day + 'T12:00:00' + sign + p(Math.floor(a / 60)) + ':' + p(a % 60);
}
// 相对时间。社区里关心的是「多久以前发的」,精确到秒没意义
function fmtAgo(iso) {
if (!iso) return '';
@@ -78,36 +65,7 @@ const REMINDER_TYPES = [
];
const REMINDER_LABELS = REMINDER_TYPES.map((t) => t.label);
// 简易表单的备注提示。给一句具体的比「请输入备注」有用得多——
// 用户看到「用了什么沐浴露、有没有吹干」才知道这栏该写什么
const SIMPLE_HINT = {
water: '大概喝了多少、换水了没有',
bath: '用了什么沐浴露、有没有吹干',
nail: '剪了几只爪、有没有出血',
ear: '耳道干不干净、有没有异味',
tooth: '用了什么牙膏、配合度怎么样',
brush: '掉毛多不多、有没有打结',
groom: '在哪家做的、剪了什么造型、花了多少',
litter: '换了多少、用的什么砂',
litterbox: '洗了几个、有没有消毒',
bowl: '有没有用洗碗液、有没有滑腻感',
waterbowl: '滤芯还好吗、有没有水垢',
clean: '消了哪些地方、用的什么消毒液',
checkup: '在哪家做的、结果怎么样、下次什么时候',
vet: '什么症状、医生怎么说、开了什么药',
supplement: '吃的什么、多大剂量、吃多久',
};
// 异常观察的四组选项。原来散在 AI 风险评估那个方法里当局部变量,
// 现在 buildRecord 也要用,提到模块级
const SYMPTOMS = ['呕吐', '拉稀', '精神差', '皮肤红'];
const DURATIONS = ['刚刚', '半天', '1天以上'];
const SPIRITS = ['正常', '一般', '明显变差'];
const SEVERITY = ['低', '中', '高'];
const POOP = ['正常', '软便', '拉稀'];
const FOOD = ['正常', '偏少', '不吃'];
const COST = ['食品', '医疗', '用品'];
const IDENTITY = ['petName', 'anonymous', 'official'];
const PTAG = ['晒宠', '求助', '经验', '避坑'];
@@ -124,22 +82,7 @@ Component({
pet: {},
segSel: {},
optSel: {},
dateVals: { vaccine: '', deworm: '' },
// 通用简易记录(洗护/清洁那 15 种共用)
isSimple: false,
simpleLabel: '',
simpleDate: '',
simpleNote: '',
simpleImages: [],
simplePlaceholder: '',
wInput: '',
wNote: '',
poopNote: '',
foodText: '',
medName: '',
medDose: '',
costNote: '',
costAmount: '',
postContent: '',
postImages: [],
manageTasks: [],
@@ -193,14 +136,9 @@ Component({
if (fresh) {
patch.segSel = {};
patch.optSel = {};
patch.costAmount = '';
patch.postContent = '';
patch.postImages = [];
patch.poopNote = '';
patch.foodText = '';
patch.medName = '';
patch.medDose = '';
patch.costNote = '';
patch.manageTasks = [];
patch.taskForm = { id: '', title: '', description: '', priority: '', sheetIdx: 0 };
patch.taskSheetLabels = taskSheets().map((x) => x.label);
@@ -220,26 +158,7 @@ Component({
patch.cmFocus = false;
patch.kbHeight = 0;
patch.saving = false;
patch.simpleNote = '';
patch.simpleImages = [];
}
// 是否走通用简易表单。缓存热的时候是同步的;万一没热就按「不是」处理,
// 那 9 种的分支写死在 wxml 里不依赖这份缓存,不会因此打不开
const simple = recordTypes.isSimple(type);
patch.isSimple = simple;
if (simple) {
const label = recordTypes.label(type);
patch.simpleLabel = label;
patch.simpleDate = daysLater(0);
patch.simplePlaceholder = SIMPLE_HINT[type] || ('这次' + label + '的情况,几个字就行');
}
if (type === 'weight') {
patch.wInput = (pet.weight || '').replace('kg', '');
patch.wNote = '';
}
// 提醒日期给个合理的默认值。原本写死成固定日期,时间一过就成了「提醒昨天」
if (type === 'vaccine') patch.dateVals = { ...this.data.dateVals, vaccine: daysLater(30) };
if (type === 'deworm') patch.dateVals = { ...this.data.dateVals, deworm: daysLater(90) };
if (type === 'editPet') {
// 预填当前宠物档案
patch.addName = pet.name || '';
@@ -279,16 +198,6 @@ Component({
}
},
onActivatePro() {
api
.activatePro()
.then((p) => {
this.setData({ proInfo: p });
wx.showToast({ title: '已开通 Pro', icon: 'success' });
setTimeout(() => this.close(), 800);
})
.catch((e) => wx.showToast({ title: e.message || '开通失败', icon: 'none' }));
},
goSheet(e) {
this.setType(e.currentTarget.dataset.type, true);
},
@@ -459,35 +368,6 @@ Component({
const optSel = Object.assign({}, this.data.optSel, { [group]: Number(index) });
this.setData({ optSel });
},
onSimpleDate(e) {
this.setData({ simpleDate: e.detail.value });
},
onSimpleNote(e) {
this.setData({ simpleNote: e.detail.value });
},
onPickSimpleImage() {
upload
.chooseAndUploadImage()
.then((f) => this.setData({ simpleImages: [f] }))
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
onRemoveSimpleImage() {
this.setData({ simpleImages: [] });
},
onDate(e) {
this.setData({ [`dateVals.${e.currentTarget.dataset.key}`]: e.detail.value });
},
onWInput(e) { this.setData({ wInput: e.detail.value }); },
onWNote(e) { this.setData({ wNote: e.detail.value }); },
onPoopNote(e) { this.setData({ poopNote: e.detail.value }); },
onFoodText(e) { this.setData({ foodText: e.detail.value }); },
onMedName(e) { this.setData({ medName: e.detail.value }); },
onMedDose(e) { this.setData({ medDose: e.detail.value }); },
onCostNote(e) { this.setData({ costNote: e.detail.value }); },
onCostInput(e) { this.setData({ costAmount: e.detail.value }); },
onPostInput(e) { this.setData({ postContent: e.detail.value }); },
loadComments() {
if (!this.data.postId) return;
@@ -753,85 +633,13 @@ Component({
const v = this.data.optSel[group];
return v === undefined ? 0 : v;
},
// 与 optIdx 的区别:没选就是没选,不当成选了第 0 项
optPicked(group) {
return this.data.optSel[group] !== undefined;
},
// 异常观察 → 生成风险评估(真调后端 AI,失败回退)
// 依据当前弹层类型组装一条健康记录
// 记录表单整体搬去 pages/addrecord 之后,这里只剩「生成就医前摘要」
// 会往 health_records 写一条 note —— 它是个动作留痕,不是用户填的表单
buildRecord() {
const t = this.data.innerType;
// 简易类型统一在这里组装。icon 故意留空:那 9 种老类型的 icon 里存的是
// emoji(写库值,不能动),新类型要是存 pt-icon 名,这一列就变成两套语义了。
// 渲染本来就按 type 取图标(type code 和图标名是同一个词),不需要这一列
if (this.data.isSimple) {
const img = (this.data.simpleImages || [])[0];
return {
type: t,
title: this.data.simpleLabel || t,
description: (this.data.simpleNote || '').trim(),
occurred_at: dayToISO(this.data.simpleDate),
image_file_id: img ? img.id : '',
image_url: img ? img.url : '',
};
}
switch (t) {
case 'poop': {
const s = POOP[this.segIdx('poopState')];
return {
type: 'poop', icon: '💩', title: '排便记录:' + s, category: s,
description: (this.data.poopNote || '').trim(),
};
}
case 'food': {
const a = FOOD[this.segIdx('food')];
const what = (this.data.foodText || '').trim();
return {
type: 'food', icon: '🍽️',
title: what ? '饮食记录:' + what : '饮食记录:食欲' + a,
category: a,
description: what ? '食欲' + a : '',
};
}
case 'medicine': {
const name = (this.data.medName || '').trim();
const dose = (this.data.medDose || '').trim();
return {
type: 'medicine', icon: '💊',
title: name ? '用药:' + name : '用药记录',
description: dose,
};
}
case 'cost': {
const c = COST[this.segIdx('cost')];
const amt = parseFloat(this.data.costAmount) || 0;
return {
type: 'cost', icon: '💰', title: '消费记录:¥' + amt, num_value: amt, category: c,
description: (this.data.costNote || '').trim(),
};
}
case 'vaccine':
return { type: 'vaccine', icon: '💉', title: '疫苗提醒:' + this.data.dateVals.vaccine };
case 'deworm':
return { type: 'deworm', icon: '🛡️', title: '驱虫提醒:' + this.data.dateVals.deworm };
case 'symptom': {
// 原来这一支不落库:symptom 只是收集表单,真正建记录是在 AI 风险评估
// 之后的 risk 那一步。AI 下掉后必须自己存,否则「记了异常但没存下来」。
//
// category 存严重程度(高/中/低)——这是周报 high_risk_count 的依据
// report.go:46 按 category='高' 过滤)。原来这个值是 AI 给的,
// 现在改成用户自己选:谁看着它谁最清楚,比规则化猜一个准。
const sy = SYMPTOMS[this.optIdx('symp')];
const lv = SEVERITY[this.segIdx('sev')] || '中';
const desc = ['持续' + DURATIONS[this.segIdx('dur')], '精神' + SPIRITS[this.segIdx('spirit')]].join('');
return { type: 'symptom', icon: '🤒', title: '异常观察:' + sy, category: lv, description: desc };
}
case 'vetSummary':
return { type: 'note', icon: '📄', title: '生成就医前摘要' };
default:
return null;
if (this.data.innerType === 'vetSummary') {
return { type: 'note', icon: '📄', title: '生成就医前摘要' };
}
return null;
},
async createAndClose(rec) {
@@ -859,48 +667,8 @@ Component({
},
onSave() {
const t = this.data.innerType;
if (t === 'cost' && !(parseFloat(this.data.costAmount) > 0)) {
wx.showToast({ title: '先填个金额', icon: 'none' });
return;
}
if (t === 'medicine' && !(this.data.medName || '').trim()) {
wx.showToast({ title: '先填药品名称', icon: 'none' });
return;
}
this.createAndClose(this.buildRecord());
},
onSaveWeight() {
const w = (this.data.wInput || '').toString().replace(/\s+/g, '').replace('kg', '');
const num = parseFloat(w);
// 原来空值会静默存成「上次的体重」,等于凭空造一条假数据
if (!w || !(num > 0)) {
wx.showToast({ title: '填一个有效体重', icon: 'none' });
return;
}
const weight = w + 'kg';
this.createAndClose({ type: 'weight', icon: '⚖️', title: '体重 ' + weight, num_value: num, description: (this.data.wNote || '').trim() });
},
onSavePhoto() {
const id = store.currentPetId();
if (!id) return this.close();
upload
.chooseAndUploadImage()
.then((f) =>
api.createRecord(id, { type: 'photo', icon: '📷', title: '成长照片', image_file_id: f.id, image_url: f.url }),
)
.then((saved) => {
wx.showToast({ title: '照片已保存', icon: 'success' });
this.triggerEvent('saved', saved);
this.close();
})
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
// 组装宠物档案表单
petFormBody() {
const isDog = this.segIdx('addType') === 1;
return {
@@ -92,75 +92,10 @@ module.exports.sel = function (map, key, index, def) {
<scroll-view wx:if="{{innerType !== 'comments'}}" class="sheet-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<!-- 记录体重 -->
<block wx:if="{{innerType === 'weight'}}">
<view class="sheet-h3">记录体重</view>
<view class="sheet-p">趋势比单次数值更重要。幼年期建议每周记录 1-2 次。</view>
<view class="field"><label>{{pet.name}} 当前体重</label>
<input class="input" value="{{wInput}}" bindinput="onWInput" type="digit"/></view>
<view class="field"><label>备注</label>
<textarea class="textarea" placeholder="例如:最近食欲不错,活动量正常" placeholder-class="placeholder" value="{{wNote}}" bindinput="onWNote"></textarea></view>
<button class="btn btn-primary btn-block" bindtap="onSaveWeight">保存记录</button>
</block>
<!-- 记录便便 -->
<block wx:elif="{{innerType === 'poop'}}">
<view class="sheet-h3">记录便便</view>
<view class="sheet-p">选择状态并补充备注,几秒完成。</view>
<view class="field"><label>状态</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'poopState',0,0)?'selected':''}}" data-group="poopState" data-index="0" bindtap="onSeg">正常</view>
<view class="seg-btn {{u.sel(segSel,'poopState',1,0)?'selected':''}}" data-group="poopState" data-index="1" bindtap="onSeg">软便</view>
<view class="seg-btn {{u.sel(segSel,'poopState',2,0)?'selected':''}}" data-group="poopState" data-index="2" bindtap="onSeg">拉稀</view>
</view></view>
<view class="field"><label>颜色/备注</label>
<textarea class="textarea" placeholder="颜色、次数、是否带血、是否有异味等" placeholder-class="placeholder" value="{{poopNote}}" bindinput="onPoopNote"></textarea></view>
<button class="btn btn-primary btn-block" bindtap="onSave">保存记录</button>
</block>
<!-- 记录饮食 -->
<block wx:elif="{{innerType === 'food'}}">
<view class="sheet-h3">记录饮食</view>
<view class="field"><label>今天吃了什么?</label>
<input class="input" placeholder="例如:幼猫粮 45g" placeholder-class="placeholder" value="{{foodText}}" bindinput="onFoodText"/></view>
<view class="field"><label>食欲</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'food',0,0)?'selected':''}}" data-group="food" data-index="0" bindtap="onSeg">正常</view>
<view class="seg-btn {{u.sel(segSel,'food',1,0)?'selected':''}}" data-group="food" data-index="1" bindtap="onSeg">偏少</view>
<view class="seg-btn {{u.sel(segSel,'food',2,0)?'selected':''}}" data-group="food" data-index="2" bindtap="onSeg">不吃</view>
</view></view>
<button class="btn btn-primary btn-block" bindtap="onSave">保存记录</button>
</block>
<!-- 异常观察 -->
<block wx:elif="{{innerType === 'symptom'}}">
<view class="sheet-h3">异常观察</view>
<view class="sheet-p">记下来,趋势和就医摘要都会用到。持续变差或伴随便血请尽快就医。</view>
<view class="field"><label>发生了什么?</label><view class="mini-options">
<view class="option {{u.sel(optSel,'symp',0,0)?'selected':''}}" data-group="symp" data-index="0" bindtap="onOpt">呕吐</view>
<view class="option {{u.sel(optSel,'symp',1,0)?'selected':''}}" data-group="symp" data-index="1" bindtap="onOpt">拉稀</view>
<view class="option {{u.sel(optSel,'symp',2,0)?'selected':''}}" data-group="symp" data-index="2" bindtap="onOpt">精神差</view>
<view class="option {{u.sel(optSel,'symp',3,0)?'selected':''}}" data-group="symp" data-index="3" bindtap="onOpt">皮肤红</view>
</view></view>
<view class="field"><label>持续多久?</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'dur',0,0)?'selected':''}}" data-group="dur" data-index="0" bindtap="onSeg">刚刚</view>
<view class="seg-btn {{u.sel(segSel,'dur',1,0)?'selected':''}}" data-group="dur" data-index="1" bindtap="onSeg">半天</view>
<view class="seg-btn {{u.sel(segSel,'dur',2,0)?'selected':''}}" data-group="dur" data-index="2" bindtap="onSeg">1天以上</view>
</view></view>
<view class="field"><label>精神状态</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'spirit',0,0)?'selected':''}}" data-group="spirit" data-index="0" bindtap="onSeg">正常</view>
<view class="seg-btn {{u.sel(segSel,'spirit',1,0)?'selected':''}}" data-group="spirit" data-index="1" bindtap="onSeg">一般</view>
<view class="seg-btn {{u.sel(segSel,'spirit',2,0)?'selected':''}}" data-group="spirit" data-index="2" bindtap="onSeg">明显变差</view>
</view></view>
<view class="field"><label>严重程度</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'sev',0,1)?'selected':''}}" data-group="sev" data-index="0" bindtap="onSeg">轻微</view>
<view class="seg-btn {{u.sel(segSel,'sev',1,1)?'selected':''}}" data-group="sev" data-index="1" bindtap="onSeg">需留意</view>
<view class="seg-btn {{u.sel(segSel,'sev',2,1)?'selected':''}}" data-group="sev" data-index="2" bindtap="onSeg">严重</view>
</view></view>
<view class="sheet-actions">
<button class="btn btn-ghost" data-type="vetSummary" bindtap="goSheet">就医摘要</button>
<button class="btn btn-primary" bindtap="onSave">保存记录</button>
</view>
</block>
<!-- 那 9 种记录表单(体重/排便/饮食/异常/给药/记账/疫苗/驱虫/照片)已经整体
搬到 pages/addrecord —— 字段由后端 record_types.fields 驱动,一个页面吃掉
全部 24 种,不再每种一个 wx:elif。弹层现在只管「不是记一笔」的那些事:
提醒、海报、档案、发帖、评论…… -->
<!-- 就医前摘要 -->
<block wx:elif="{{innerType === 'vetSummary'}}">
<view class="sheet-h3">就医前摘要</view>
@@ -173,86 +108,6 @@ module.exports.sel = function (map, key, index, def) {
</block>
<!-- 记录用药 -->
<block wx:elif="{{innerType === 'medicine'}}">
<view class="sheet-h3">记录用药</view>
<view class="field"><label>药品名称</label><input class="input" placeholder="例如:体内驱虫药" placeholder-class="placeholder" value="{{medName}}" bindinput="onMedName"/></view>
<view class="field"><label>剂量/备注</label><input class="input" placeholder="例如:按 2.8kg 剂量" placeholder-class="placeholder" value="{{medDose}}" bindinput="onMedDose"/></view>
<button class="btn btn-primary btn-block" bindtap="onSave">保存记录</button>
</block>
<!-- 记一笔消费 -->
<block wx:elif="{{innerType === 'cost'}}">
<view class="sheet-h3">记一笔消费</view>
<view class="field"><label>金额</label><input class="input" placeholder="例如:168" placeholder-class="placeholder" type="digit" value="{{costAmount}}" bindinput="onCostInput"/></view>
<view class="field"><label>类别</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'cost',0,0)?'selected':''}}" data-group="cost" data-index="0" bindtap="onSeg">食品</view>
<view class="seg-btn {{u.sel(segSel,'cost',1,0)?'selected':''}}" data-group="cost" data-index="1" bindtap="onSeg">医疗</view>
<view class="seg-btn {{u.sel(segSel,'cost',2,0)?'selected':''}}" data-group="cost" data-index="2" bindtap="onSeg">用品</view>
</view></view>
<view class="field"><label>备注</label><input class="input" placeholder="例如:幼猫粮" placeholder-class="placeholder" value="{{costNote}}" bindinput="onCostNote"/></view>
<button class="btn btn-primary btn-block" bindtap="onSave">保存记录</button>
</block>
<!-- 疫苗提醒 -->
<block wx:elif="{{innerType === 'vaccine'}}">
<view class="sheet-h3">疫苗提醒</view>
<view class="sheet-p">为 {{pet.name}} 设置疫苗提醒。接种前后避免洗澡和长途出行。</view>
<view class="field"><label>提醒日期</label>
<picker mode="date" value="{{dateVals.vaccine}}" data-key="vaccine" bindchange="onDate">
<view class="picker-box">{{dateVals.vaccine}}</view>
</picker></view>
<button class="btn btn-primary btn-block" bindtap="onSave">保存提醒</button>
</block>
<!-- 驱虫提醒 -->
<block wx:elif="{{innerType === 'deworm'}}">
<view class="sheet-h3">驱虫提醒</view>
<view class="sheet-p">设置下次驱虫日期,到点提醒你。</view>
<view class="field"><label>下次驱虫日期</label>
<picker mode="date" value="{{dateVals.deworm}}" data-key="deworm" bindchange="onDate">
<view class="picker-box">{{dateVals.deworm}}</view>
</picker></view>
<button class="btn btn-primary btn-block" bindtap="onSave">保存提醒</button>
</block>
<!-- 成长照片 -->
<block wx:elif="{{innerType === 'photo'}}">
<view class="sheet-h3">成长照片</view>
<view class="sheet-p">上传成长照片,用于宠物档案与成长报告。</view>
<view class="poster" style="margin-bottom:28rpx">
<view class="poster-avatar">{{pet.emoji}}</view>
<view class="bold" style="font-size:30rpx">{{pet.name}} 的成长照片</view>
<view class="muted" style="font-size:24rpx;margin-top:12rpx">支持 jpg / png,单张不超过 10MB</view>
</view>
<button class="btn btn-primary btn-block" bindtap="onSavePhoto">选择图片上传</button>
</block>
<!-- 通用简易记录。洗护/清洁那 15 种共用这一支:
它们只需要「什么时候 + 备注 + 可选照片」,各写一个 wx:elif 会把这个
文件从 400 行推到 700 行,而且每加一种类型都要发版。
isSimple 由 JS 按后端返回的 form 字段算,和上面那 9 种永不重叠 -->
<block wx:elif="{{isSimple}}">
<view class="sheet-h3">记录{{simpleLabel}}</view>
<view class="sheet-p">选个时间、写句备注就行,需要的话可以附张照片。</view>
<view class="field"><label>什么时候</label>
<picker mode="date" value="{{simpleDate}}" bindchange="onSimpleDate">
<view class="picker-box">{{simpleDate}}</view>
</picker></view>
<view class="field"><label>备注</label>
<textarea class="textarea" placeholder="{{simplePlaceholder}}" placeholder-class="placeholder"
value="{{simpleNote}}" bindinput="onSimpleNote"></textarea></view>
<view class="field"><label>照片(可选)</label>
<view class="img-picker">
<view wx:for="{{simpleImages}}" wx:key="id" class="img-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="img-del" catchtap="onRemoveSimpleImage" data-index="{{index}}"><pt-icon name="close" size="{{24}}"></pt-icon></view>
</view>
<view wx:if="{{simpleImages.length < 1}}" class="img-add" bindtap="onPickSimpleImage"><pt-icon name="plus" size="{{48}}"></pt-icon></view>
</view>
</view>
<button class="btn btn-primary btn-block" bindtap="onSave">保存记录</button>
</block>
<!-- 提醒中心 -->
<block wx:elif="{{innerType === 'reminders'}}">
<view class="sheet-h3">提醒中心</view>
+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}
+264
View File
@@ -0,0 +1,264 @@
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() {
this.openSheetType('addPet');
},
onShareAppMessage() {
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
},
onShareTimeline() {
return { title: '用肉垫计划记录毛孩子的成长' };
},
});
+10
View File
@@ -0,0 +1,10 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index",
"seg-tabs": "/components/seg-tabs/index"
}
}
+75
View File
@@ -0,0 +1,75 @@
<nav-bar title="记录与趋势" show-back="{{true}}"></nav-bar>
<view class="top-bar"><pet-switch bind:add="onAddPet"></pet-switch></view>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<view wx:if="{{!pet.id}}" class="no-pet">
<view class="no-pet-ic"><pt-icon name="community" size="{{52}}"></pt-icon></view>
<view class="no-pet-b">还没有毛孩子的档案</view>
<view class="no-pet-p">建好档并记上几笔,这里就会长出趋势和洞察。</view>
<view class="btn btn-dark" bindtap="onAddPet"><pt-icon name="plus" size="{{30}}"></pt-icon>建一份档案</view>
</view>
<view wx:if="{{insights.length}}" class="card">
<view class="section-head"><view class="sh-title">健康洞察</view><view class="tiny">来自你的记录</view></view>
<view wx:for="{{insights}}" wx:key="title" class="ins ins-{{item.level}}" data-index="{{index}}" bindtap="onTapInsight">
<view class="ins-head">
<view class="ins-ic"><pt-icon name="{{item.icon}}" size="{{30}}"></pt-icon></view>
<view class="ins-title">{{item.title}}</view>
</view>
<view class="ins-detail">{{item.detail}}</view>
<view class="ins-ev">{{item.evidence}}</view>
</view>
</view>
<view class="card">
<view class="section-head"><view class="sh-title">体重趋势</view><view class="link" data-type="weight" bindtap="openSheet">记录</view></view>
<view wx:if="{{trendStats}}" class="trend-stats">最新 {{trendStats.latest}}kg · 较首次 {{trendStats.delta >= 0 ? '+' : ''}}{{trendStats.delta}}kg · 共 {{trendStats.count}} 次</view>
<block wx:if="{{trendSvg}}">
<view class="mini-chart">
<image class="chart-line" mode="scaleToFill" src="{{trendSvg}}"></image>
<view class="dot-layer">
<view wx:for="{{trendDots}}" wx:key="index"
class="dot {{selIdx === index ? 'on' : ''}}"
style="left:{{item.xp}}%;top:{{item.yp}}%"
data-index="{{index}}" bindtap="onTapPoint"></view>
</view>
</view>
<view wx:if="{{selDot}}" class="point-detail">
<view class="pd-main">
<text class="pd-date">{{selDot.date}}</text>
<text class="pd-weight">{{selDot.value}}kg</text>
<text wx:if="{{selDot.delta !== null}}" class="pd-delta {{selDot.delta > 0 ? 'up' : (selDot.delta < 0 ? 'down' : '')}}">{{selDot.delta > 0 ? '+' : ''}}{{selDot.delta}}kg</text>
</view>
<view wx:if="{{selDot.note}}" class="pd-note"><pt-icon name="note" size="{{26}}"></pt-icon> {{selDot.note}}</view>
<view wx:else class="pd-note muted">这次没有填备注</view>
</view>
<view class="chart-hint">点击圆点查看每次记录</view>
</block>
<view wx:else class="empty">先记录几次体重,这里会自动画出趋势曲线</view>
</view>
<view class="card">
<view class="section-head"><view class="sh-title">健康时间轴</view><view class="link" data-type="vetSummary" bindtap="openSheet">摘要</view></view>
<seg-tabs items="{{recFilters}}" current="{{recFilterIdx}}" bind:change="onFilterTap"></seg-tabs>
<view class="health-timeline">
<view wx:for="{{timeline}}" wx:key="id" class="health-event"
bindlongpress="onDeleteRecord" data-index="{{index}}">
<view class="event-dot {{item.tone}}"><pt-icon name="{{item.type}}" size="{{34}}" fallback="note"></pt-icon></view>
<view class="he-body">
<text class="he-b">{{item.title}}</text><view class="he-p">{{item.desc}}</view>
<image wx:if="{{item.image}}" class="he-img" src="{{item.image}}" mode="aspectFill" catchtap="previewImage" data-src="{{item.image}}"></image>
</view>
</view>
<view wx:if="{{timeline.length === 0}}" class="empty">这个分类下还没有记录</view>
</view>
<view wx:if="{{timeline.length}}" class="tl-tip">长按任意一条可删除</view>
<view wx:if="{{recHasMore}}" class="tl-more" bindtap="loadMoreRecords">查看更多(共 {{recTotal}} 条)</view>
<view wx:elif="{{recPage > 1}}" class="tl-more" bindtap="collapseRecords">收起</view>
</view>
</view>
</scroll-view>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet" bind:saved="onSheetSaved"></bottom-sheet>
+59
View File
@@ -0,0 +1,59 @@
.mini-chart{
height:256rpx;border-radius:var(--r-md);margin-top:var(--sp-4);position:relative;overflow:hidden;
background:
linear-gradient(180deg,rgba(115,190,157,.16),rgba(115,190,157,0)),
repeating-linear-gradient(0deg,#fff,#fff 60rpx,#EFE8DE 62rpx);
}
.chart-line{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx;width:auto;height:auto}
.dot-layer{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx}
.dot{position:absolute;width:40rpx;height:40rpx;margin:-20rpx 0 0 -20rpx;border-radius:50%;}
.dot::after{content:'';position:absolute;left:50%;top:50%;width:18rpx;height:18rpx;margin:-9rpx 0 0 -9rpx;border-radius:50%;background:#fff;border:4rpx solid var(--green);box-sizing:border-box}
.dot.on::after{width:26rpx;height:26rpx;margin:-13rpx 0 0 -13rpx;background:var(--green);box-shadow:0 0 0 6rpx rgba(115,190,157,.25)}
.trend-stats{margin-top:var(--sp-3);font-size:var(--fs-sm);color:var(--muted)}
.point-detail{
margin-top:var(--sp-3);padding:var(--sp-4);border-radius:var(--r-md);
background:var(--green-soft);border:1rpx solid #DCEFE4;
}
.pd-main{display:flex;align-items:baseline;gap:var(--sp-3)}
.pd-date{font-size:var(--fs-sm);color:var(--muted)}
.pd-weight{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--green-ink)}
.pd-delta{font-size:var(--fs-sm);font-weight:var(--fw-b)}
.pd-delta.up{color:var(--red-ink)}
.pd-delta.down{color:var(--green-ink)}
.pd-note{margin-top:var(--sp-2);font-size:var(--fs-md);color:var(--text-2);line-height:1.5}
.pd-note.muted{color:var(--muted)}
.chart-hint{margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted);text-align:center}
.health-timeline{display:flex;flex-direction:column;gap:var(--sp-2)}
.health-event{display:flex;gap:var(--sp-3);background:var(--surface-2);border-radius:var(--r-md);padding:var(--sp-4)}
/* 底色和文字色由 .tone-N 提供(记录类型决定),这里只管形状 */
.event-dot{
width:64rpx;height:64rpx;border-radius:var(--r-sm);flex:none;
display:flex;align-items:center;justify-content:center;
}
.he-b{font-size:var(--fs-md);font-weight:var(--fw-b)}
.he-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:6rpx}
.he-body{flex:1;min-width:0}
.he-img{margin-top:var(--sp-2);width:220rpx;height:220rpx;border-radius:var(--r-sm)}
.tl-tip{margin-top:var(--sp-3);text-align:center;color:var(--muted);font-size:var(--fs-cap)}
.tl-more{
margin-top:var(--sp-3);padding:var(--sp-3);text-align:center;color:var(--primary-dark);
background:var(--surface-2);border-radius:var(--r-md);font-size:var(--fs-sm);font-weight:var(--fw-b);
}
/* 健康洞察 */
.ins{border-radius:var(--r-md);padding:var(--sp-4);margin-bottom:var(--sp-2)}
.ins:last-child{margin-bottom:0}
.ins-info{background:var(--surface-2)}
.ins-warn{background:var(--primary-soft)}
.ins-alert{background:var(--red-soft)}
.ins-head{display:flex;align-items:flex-start;gap:var(--sp-2)}
.ins-ic{flex:none;margin-top:2rpx}
.ins-info .ins-ic{color:var(--green-ink)}
.ins-warn .ins-ic{color:var(--primary-ink)}
.ins-alert .ins-ic{color:var(--red-ink)}
.ins-title{flex:1;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.4}
.ins-detail{color:var(--text-2);font-size:var(--fs-sm);line-height:1.6;margin-top:var(--sp-2)}
.ins-ev{color:var(--muted);font-size:var(--fs-cap);margin-top:var(--sp-2)}
+5
View File
@@ -270,6 +270,11 @@ Page({
})
.catch((e) => toastErr(e, '操作失败'));
},
// 记一笔的每一项都进独立页面。原来是开弹层——24 项的表单塞弹层里太挤,
// 而且弹层高度受 sheet-scroll 的 78vh 限制,字段一多就变成内滚
goAdd(e) {
wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + e.currentTarget.dataset.type });
},
openSheet(e) {
this.openSheetType(e.currentTarget.dataset.type);
},
+1 -1
View File
@@ -111,7 +111,7 @@
<view class="link" bindtap="goRecord">更多</view>
</view>
<view class="quick-grid">
<view wx:for="{{quickItems}}" wx:key="code" class="quick {{item.tone}}" data-type="{{item.code}}" bindtap="openSheet">
<view wx:for="{{quickItems}}" wx:key="code" class="quick {{item.tone}}" data-type="{{item.code}}" bindtap="goAdd">
<pt-icon name="{{item.icon}}" size="{{44}}" fallback="note"></pt-icon>{{item.label}}
</view>
</view>
+12 -238
View File
@@ -1,122 +1,14 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
const recordTypes = require('../../utils/recordTypes.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() + '日';
}
// 记录类型 → 配色。和首页快速记录入口用同一套分组:
// 体重/疫苗=橙、便便/饮食=绿、异常/用药=蓝、消费/照片=紫
// 时间轴每条记录的色调跟着它所属的分组走,和「记一笔」的宫格是同一套色。
// 原来这里是 9 种写死的映射,新类型会全掉到 tone-1,一片橙色分不出类别
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;
// 这一页只干一件事:把后端配置的记录事项按分组列出来,点一个进添加页。
// 原来它还带着健康洞察、体重趋势、健康时间轴——那三块搬到 pages/history 了,
// 从报告页进。一个「我要记一笔」的页面上摆着三块只读图表,
// 用户每次都得先滚过去才能找到要点的东西。
Page({
data: {
pet: {},
timeline: [],
recPage: 1,
recTotal: 0,
recHasMore: false,
recFilters: REC_FILTERS,
recFilterIdx: 0,
typeGroups: [],
insights: [],
trendSvg: '',
trendStats: null,
trendDots: [],
selDot: null,
selIdx: -1,
sheetShow: false,
sheetType: '',
},
data: { pet: {}, typeGroups: [], sheetShow: false, sheetType: '' },
onLoad() {
this._unsub = store.subscribe((pet) => {
this.setData({ pet });
if (!this._inited) return; // 首次由 onShow 加载
this.loadRecords();
});
this._unsub = store.subscribe((pet) => this.setData({ pet }));
},
onUnload() {
if (this._unsub) this._unsub();
@@ -125,57 +17,9 @@ Page({
this.loadTypes();
store
.ready()
.then(() => {
this._inited = true;
this.setData({ pet: store.getPet() });
this.loadRecords();
})
.then(() => this.setData({ pet: store.getPet() }))
.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);
},
// 类型只在缓存没热时真正请求;弹层也读这份缓存判断走哪种表单,
// 所以这一步必须在用户能点开弹层之前完成
loadTypes() {
if (this.data.typeGroups.length) return;
recordTypes
@@ -183,86 +27,16 @@ Page({
.then((groups) => this.setData({ typeGroups: groups }))
.catch(() => {});
},
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();
goAdd(e) {
wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + e.currentTarget.dataset.type });
},
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 });
// 没建档时要能建档,所以这页留一个只用于 addPet 的弹层
onAddPet() {
this.setData({ sheetType: 'addPet', sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onSheetSaved() {
this.loadRecords();
},
onAddPet() {
this.openSheetType('addPet');
},
onShareAppMessage() {
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
},
+1 -3
View File
@@ -3,8 +3,6 @@
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index",
"seg-tabs": "/components/seg-tabs/index"
"pt-icon": "/components/pt-icon/index"
}
}
+3 -61
View File
@@ -19,73 +19,15 @@
<view class="tg-head"><view class="tg-bar"></view>{{item.label}}<text class="tg-n">{{item.items.length}}</text></view>
<view class="quick-grid cols-3">
<view wx:for="{{item.items}}" wx:for-item="t" wx:key="code" class="quick {{item.tone}}"
data-type="{{t.code}}" bindtap="openSheet">
data-type="{{t.code}}" bindtap="goAdd">
<pt-icon name="{{t.icon}}" size="{{44}}" fallback="note"></pt-icon>{{t.label}}
</view>
</view>
</view>
<view wx:if="{{!typeGroups.length}}" class="empty">还没有配置可记事项</view>
</view>
<view wx:if="{{insights.length}}" class="card">
<view class="section-head"><view class="sh-title">健康洞察</view><view class="tiny">来自你的记录</view></view>
<view wx:for="{{insights}}" wx:key="title" class="ins ins-{{item.level}}" data-index="{{index}}" bindtap="onTapInsight">
<view class="ins-head">
<view class="ins-ic"><pt-icon name="{{item.icon}}" size="{{30}}"></pt-icon></view>
<view class="ins-title">{{item.title}}</view>
</view>
<view class="ins-detail">{{item.detail}}</view>
<view class="ins-ev">{{item.evidence}}</view>
</view>
</view>
<view class="card">
<view class="section-head"><view class="sh-title">体重趋势</view><view class="link" data-type="weight" bindtap="openSheet">记录</view></view>
<view wx:if="{{trendStats}}" class="trend-stats">最新 {{trendStats.latest}}kg · 较首次 {{trendStats.delta >= 0 ? '+' : ''}}{{trendStats.delta}}kg · 共 {{trendStats.count}} 次</view>
<block wx:if="{{trendSvg}}">
<view class="mini-chart">
<image class="chart-line" mode="scaleToFill" src="{{trendSvg}}"></image>
<view class="dot-layer">
<view wx:for="{{trendDots}}" wx:key="index"
class="dot {{selIdx === index ? 'on' : ''}}"
style="left:{{item.xp}}%;top:{{item.yp}}%"
data-index="{{index}}" bindtap="onTapPoint"></view>
</view>
</view>
<view wx:if="{{selDot}}" class="point-detail">
<view class="pd-main">
<text class="pd-date">{{selDot.date}}</text>
<text class="pd-weight">{{selDot.value}}kg</text>
<text wx:if="{{selDot.delta !== null}}" class="pd-delta {{selDot.delta > 0 ? 'up' : (selDot.delta < 0 ? 'down' : '')}}">{{selDot.delta > 0 ? '+' : ''}}{{selDot.delta}}kg</text>
</view>
<view wx:if="{{selDot.note}}" class="pd-note"><pt-icon name="note" size="{{26}}"></pt-icon> {{selDot.note}}</view>
<view wx:else class="pd-note muted">这次没有填备注</view>
</view>
<view class="chart-hint">点击圆点查看每次记录</view>
</block>
<view wx:else class="empty">先记录几次体重,这里会自动画出趋势曲线</view>
</view>
<view class="card">
<view class="section-head"><view class="sh-title">健康时间轴</view><view class="link" data-type="vetSummary" bindtap="openSheet">摘要</view></view>
<seg-tabs items="{{recFilters}}" current="{{recFilterIdx}}" bind:change="onFilterTap"></seg-tabs>
<view class="health-timeline">
<view wx:for="{{timeline}}" wx:key="id" class="health-event"
bindlongpress="onDeleteRecord" data-index="{{index}}">
<view class="event-dot {{item.tone}}"><pt-icon name="{{item.type}}" size="{{34}}" fallback="note"></pt-icon></view>
<view class="he-body">
<text class="he-b">{{item.title}}</text><view class="he-p">{{item.desc}}</view>
<image wx:if="{{item.image}}" class="he-img" src="{{item.image}}" mode="aspectFill" catchtap="previewImage" data-src="{{item.image}}"></image>
</view>
</view>
<view wx:if="{{timeline.length === 0}}" class="empty">这个分类下还没有记录</view>
</view>
<view wx:if="{{timeline.length}}" class="tl-tip">长按任意一条可删除</view>
<view wx:if="{{recHasMore}}" class="tl-more" bindtap="loadMoreRecords">查看更多(共 {{recTotal}} 条)</view>
<view wx:elif="{{recPage > 1}}" class="tl-more" bindtap="collapseRecords">收起</view>
</view>
</view>
</scroll-view>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet" bind:saved="onSheetSaved"></bottom-sheet>
<!-- 只用于「没建档 → 建一份档案」 -->
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
+3 -59
View File
@@ -1,59 +1,3 @@
.mini-chart{
height:256rpx;border-radius:var(--r-md);margin-top:var(--sp-4);position:relative;overflow:hidden;
background:
linear-gradient(180deg,rgba(115,190,157,.16),rgba(115,190,157,0)),
repeating-linear-gradient(0deg,#fff,#fff 60rpx,#EFE8DE 62rpx);
}
.chart-line{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx;width:auto;height:auto}
.dot-layer{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx}
.dot{position:absolute;width:40rpx;height:40rpx;margin:-20rpx 0 0 -20rpx;border-radius:50%;}
.dot::after{content:'';position:absolute;left:50%;top:50%;width:18rpx;height:18rpx;margin:-9rpx 0 0 -9rpx;border-radius:50%;background:#fff;border:4rpx solid var(--green);box-sizing:border-box}
.dot.on::after{width:26rpx;height:26rpx;margin:-13rpx 0 0 -13rpx;background:var(--green);box-shadow:0 0 0 6rpx rgba(115,190,157,.25)}
.trend-stats{margin-top:var(--sp-3);font-size:var(--fs-sm);color:var(--muted)}
.point-detail{
margin-top:var(--sp-3);padding:var(--sp-4);border-radius:var(--r-md);
background:var(--green-soft);border:1rpx solid #DCEFE4;
}
.pd-main{display:flex;align-items:baseline;gap:var(--sp-3)}
.pd-date{font-size:var(--fs-sm);color:var(--muted)}
.pd-weight{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--green-ink)}
.pd-delta{font-size:var(--fs-sm);font-weight:var(--fw-b)}
.pd-delta.up{color:var(--red-ink)}
.pd-delta.down{color:var(--green-ink)}
.pd-note{margin-top:var(--sp-2);font-size:var(--fs-md);color:var(--text-2);line-height:1.5}
.pd-note.muted{color:var(--muted)}
.chart-hint{margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted);text-align:center}
.health-timeline{display:flex;flex-direction:column;gap:var(--sp-2)}
.health-event{display:flex;gap:var(--sp-3);background:var(--surface-2);border-radius:var(--r-md);padding:var(--sp-4)}
/* 底色和文字色由 .tone-N 提供(记录类型决定),这里只管形状 */
.event-dot{
width:64rpx;height:64rpx;border-radius:var(--r-sm);flex:none;
display:flex;align-items:center;justify-content:center;
}
.he-b{font-size:var(--fs-md);font-weight:var(--fw-b)}
.he-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:6rpx}
.he-body{flex:1;min-width:0}
.he-img{margin-top:var(--sp-2);width:220rpx;height:220rpx;border-radius:var(--r-sm)}
.tl-tip{margin-top:var(--sp-3);text-align:center;color:var(--muted);font-size:var(--fs-cap)}
.tl-more{
margin-top:var(--sp-3);padding:var(--sp-3);text-align:center;color:var(--primary-dark);
background:var(--surface-2);border-radius:var(--r-md);font-size:var(--fs-sm);font-weight:var(--fw-b);
}
/* 健康洞察 */
.ins{border-radius:var(--r-md);padding:var(--sp-4);margin-bottom:var(--sp-2)}
.ins:last-child{margin-bottom:0}
.ins-info{background:var(--surface-2)}
.ins-warn{background:var(--primary-soft)}
.ins-alert{background:var(--red-soft)}
.ins-head{display:flex;align-items:flex-start;gap:var(--sp-2)}
.ins-ic{flex:none;margin-top:2rpx}
.ins-info .ins-ic{color:var(--green-ink)}
.ins-warn .ins-ic{color:var(--primary-ink)}
.ins-alert .ins-ic{color:var(--red-ink)}
.ins-title{flex:1;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.4}
.ins-detail{color:var(--text-2);font-size:var(--fs-sm);line-height:1.6;margin-top:var(--sp-2)}
.ins-ev{color:var(--muted);font-size:var(--fs-cap);margin-top:var(--sp-2)}
/* 分组宫格的样式在 app.wxss(记录页和历史页都用过,页面级 wxss 跨不了页)。
趋势图和时间轴的样式跟着那三块一起搬到 pages/history 了。
这页只剩「没建档」的空态,那套也在 app.wxss。 */
+3
View File
@@ -57,6 +57,9 @@ Page({
onAddPet() {
this.setData({ sheetType: 'addPet', sheetShow: true });
},
goHistory() {
wx.navigateTo({ url: '/pages/history/history' });
},
onShareAppMessage() {
// 带上宠物名,转发出去别人一眼知道是谁家的
const n = (this.data.pet && this.data.pet.name) || '我家毛孩子';
+10
View File
@@ -25,6 +25,16 @@
</view>
</view>
<!-- 洞察/趋势/时间轴从记录页搬过来了,报告页才是「看数据」的地方 -->
<view class="profile-list">
<view class="profile-row" bindtap="goHistory">
<view class="pr-ic"><pt-icon name="trend" size="{{34}}"></pt-icon></view>
<text class="pr-label">记录与趋势</text>
<text class="pr-val">洞察 · 体重 · 时间轴</text>
<view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view>
</view>
</view>
<view class="card">
<view class="section-head"><view class="sh-title">健康摘要</view><view class="link" data-type="vetSummary" bindtap="openSheet">导出</view></view>
<view class="record-row"><text class="bold">疫苗进度</text><text class="muted">{{summary.vaccine_progress || '—'}}</text></view>