diff --git a/pets-be/internal/database/seed.go b/pets-be/internal/database/seed.go
index dfb4824..aed89b5 100644
--- a/pets-be/internal/database/seed.go
+++ b/pets-be/internal/database/seed.go
@@ -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_value,CreateRecord 靠它回写宠物档案
+ // 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
}
diff --git a/pets-be/internal/handler/admin.go b/pets-be/internal/handler/admin.go
index b8c56c2..fda0c22 100644
--- a/pets-be/internal/handler/admin.go
+++ b/pets-be/internal/handler/admin.go
@@ -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 {
diff --git a/pets-be/internal/model/record_type.go b/pets-be/internal/model/record_type.go
index 147c977..2a336ac 100644
--- a/pets-be/internal/model/record_type.go
+++ b/pets-be/internal/model/record_type.go
@@ -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"`
}
diff --git a/pets-be/internal/service/record_type.go b/pets-be/internal/service/record_type.go
index 494e515..8d1bdbf 100644
--- a/pets-be/internal/service/record_type.go
+++ b/pets-be/internal/service/record_type.go
@@ -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
}
diff --git a/pets-fe/app.json b/pets-fe/app.json
index 3f04630..6d92f94 100644
--- a/pets-fe/app.json
+++ b/pets-fe/app.json
@@ -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",
diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.js b/pets-fe/components/bottom-sheet/bottom-sheet.js
index 9465b1d..d8c235f 100644
--- a/pets-fe/components/bottom-sheet/bottom-sheet.js
+++ b/pets-fe/components/bottom-sheet/bottom-sheet.js
@@ -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 {
diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.wxml b/pets-fe/components/bottom-sheet/bottom-sheet.wxml
index beb167a..d8bfcb8 100644
--- a/pets-fe/components/bottom-sheet/bottom-sheet.wxml
+++ b/pets-fe/components/bottom-sheet/bottom-sheet.wxml
@@ -92,75 +92,10 @@ module.exports.sel = function (map, key, index, def) {
-
-
- 记录体重
- 趋势比单次数值更重要。幼年期建议每周记录 1-2 次。
-
-
-
-
-
-
-
-
-
- 记录便便
- 选择状态并补充备注,几秒完成。
-
- 正常
- 软便
- 拉稀
-
-
-
-
-
-
-
-
- 记录饮食
-
-
-
- 正常
- 偏少
- 不吃
-
-
-
-
-
-
- 异常观察
- 记下来,趋势和就医摘要都会用到。持续变差或伴随便血请尽快就医。
-
- 呕吐
- 拉稀
- 精神差
- 皮肤红
-
-
- 刚刚
- 半天
- 1天以上
-
-
- 正常
- 一般
- 明显变差
-
-
- 轻微
- 需留意
- 严重
-
-
-
-
-
-
-
+
就医前摘要
@@ -173,86 +108,6 @@ module.exports.sel = function (map, key, index, def) {
-
- 记录用药
-
-
-
-
-
-
-
- 记一笔消费
-
-
- 食品
- 医疗
- 用品
-
-
-
-
-
-
-
- 疫苗提醒
- 为 {{pet.name}} 设置疫苗提醒。接种前后避免洗澡和长途出行。
-
-
- {{dateVals.vaccine}}
-
-
-
-
-
-
- 驱虫提醒
- 设置下次驱虫日期,到点提醒你。
-
-
- {{dateVals.deworm}}
-
-
-
-
-
-
- 成长照片
- 上传成长照片,用于宠物档案与成长报告。
-
- {{pet.emoji}}
- {{pet.name}} 的成长照片
- 支持 jpg / png,单张不超过 10MB
-
-
-
-
-
-
- 记录{{simpleLabel}}
- 选个时间、写句备注就行,需要的话可以附张照片。
-
-
- {{simpleDate}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
提醒中心
diff --git a/pets-fe/pages/addrecord/addrecord.js b/pets-fe/pages/addrecord/addrecord.js
new file mode 100644
index 0000000..5b6843e
--- /dev/null
+++ b/pets-fe/pages/addrecord/addrecord.js
@@ -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, '保存失败');
+ });
+ },
+});
diff --git a/pets-fe/pages/addrecord/addrecord.json b/pets-fe/pages/addrecord/addrecord.json
new file mode 100644
index 0000000..6594cd8
--- /dev/null
+++ b/pets-fe/pages/addrecord/addrecord.json
@@ -0,0 +1,6 @@
+{
+ "usingComponents": {
+ "nav-bar": "/components/nav-bar/nav-bar",
+ "pt-icon": "/components/pt-icon/index"
+ }
+}
diff --git a/pets-fe/pages/addrecord/addrecord.wxml b/pets-fe/pages/addrecord/addrecord.wxml
new file mode 100644
index 0000000..361118f
--- /dev/null
+++ b/pets-fe/pages/addrecord/addrecord.wxml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+ 记录宠物
+ {{pet.name || '选择'}}
+
+
+
+
+ 记录时间
+ {{dateLabel}}
+
+
+
+
+
+
+
+
+
+
+ {{item.label}}
+
+ {{item.unit}}
+
+
+
+ {{item.label}}
+
+
+
+
+ {{item.label}}
+
+ {{o}}
+
+
+
+
+
+
+ 描述
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pets-fe/pages/addrecord/addrecord.wxss b/pets-fe/pages/addrecord/addrecord.wxss
new file mode 100644
index 0000000..a6e4cf7
--- /dev/null
+++ b/pets-fe/pages/addrecord/addrecord.wxss
@@ -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}
diff --git a/pets-fe/pages/history/history.js b/pets-fe/pages/history/history.js
new file mode 100644
index 0000000..2ba7cc0
--- /dev/null
+++ b/pets-fe/pages/history/history.js
@@ -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 =
+ '';
+ 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: '用肉垫计划记录毛孩子的成长' };
+ },
+});
diff --git a/pets-fe/pages/history/history.json b/pets-fe/pages/history/history.json
new file mode 100644
index 0000000..58b7029
--- /dev/null
+++ b/pets-fe/pages/history/history.json
@@ -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"
+ }
+}
diff --git a/pets-fe/pages/history/history.wxml b/pets-fe/pages/history/history.wxml
new file mode 100644
index 0000000..aae27f3
--- /dev/null
+++ b/pets-fe/pages/history/history.wxml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+ 还没有毛孩子的档案
+ 建好档并记上几笔,这里就会长出趋势和洞察。
+ 建一份档案
+
+
+
+ 健康洞察来自你的记录
+
+
+
+ {{item.title}}
+
+ {{item.detail}}
+ {{item.evidence}}
+
+
+
+
+ 体重趋势记录
+ 最新 {{trendStats.latest}}kg · 较首次 {{trendStats.delta >= 0 ? '+' : ''}}{{trendStats.delta}}kg · 共 {{trendStats.count}} 次
+
+
+
+
+
+
+
+
+
+ {{selDot.date}}
+ {{selDot.value}}kg
+ {{selDot.delta > 0 ? '+' : ''}}{{selDot.delta}}kg
+
+ {{selDot.note}}
+ 这次没有填备注
+
+ 点击圆点查看每次记录
+
+ 先记录几次体重,这里会自动画出趋势曲线
+
+
+
+ 健康时间轴摘要
+
+
+
+
+
+
+ {{item.title}}{{item.desc}}
+
+
+
+ 这个分类下还没有记录
+
+ 长按任意一条可删除
+ 查看更多(共 {{recTotal}} 条)
+ 收起
+
+
+
+
+
diff --git a/pets-fe/pages/history/history.wxss b/pets-fe/pages/history/history.wxss
new file mode 100644
index 0000000..25c7996
--- /dev/null
+++ b/pets-fe/pages/history/history.wxss
@@ -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)}
diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js
index fa07592..88833b0 100644
--- a/pets-fe/pages/home/home.js
+++ b/pets-fe/pages/home/home.js
@@ -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);
},
diff --git a/pets-fe/pages/home/home.wxml b/pets-fe/pages/home/home.wxml
index 5fe9ea9..70654dd 100644
--- a/pets-fe/pages/home/home.wxml
+++ b/pets-fe/pages/home/home.wxml
@@ -111,7 +111,7 @@
更多
-
+
{{item.label}}
diff --git a/pets-fe/pages/record/record.js b/pets-fe/pages/record/record.js
index 7e843b3..b056506 100644
--- a/pets-fe/pages/record/record.js
+++ b/pets-fe/pages/record/record.js
@@ -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 =
- '';
- 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' };
},
diff --git a/pets-fe/pages/record/record.json b/pets-fe/pages/record/record.json
index 58b7029..381942f 100644
--- a/pets-fe/pages/record/record.json
+++ b/pets-fe/pages/record/record.json
@@ -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"
}
}
diff --git a/pets-fe/pages/record/record.wxml b/pets-fe/pages/record/record.wxml
index 88efe54..bcf28f9 100644
--- a/pets-fe/pages/record/record.wxml
+++ b/pets-fe/pages/record/record.wxml
@@ -19,73 +19,15 @@
{{item.label}}{{item.items.length}}
+ data-type="{{t.code}}" bindtap="goAdd">
{{t.label}}
还没有配置可记事项
-
-
- 健康洞察来自你的记录
-
-
-
- {{item.title}}
-
- {{item.detail}}
- {{item.evidence}}
-
-
-
-
- 体重趋势记录
- 最新 {{trendStats.latest}}kg · 较首次 {{trendStats.delta >= 0 ? '+' : ''}}{{trendStats.delta}}kg · 共 {{trendStats.count}} 次
-
-
-
-
-
-
-
-
-
- {{selDot.date}}
- {{selDot.value}}kg
- {{selDot.delta > 0 ? '+' : ''}}{{selDot.delta}}kg
-
- {{selDot.note}}
- 这次没有填备注
-
- 点击圆点查看每次记录
-
- 先记录几次体重,这里会自动画出趋势曲线
-
-
-
- 健康时间轴摘要
-
-
-
-
-
-
- {{item.title}}{{item.desc}}
-
-
-
- 这个分类下还没有记录
-
- 长按任意一条可删除
- 查看更多(共 {{recTotal}} 条)
- 收起
-
-
+
+
diff --git a/pets-fe/pages/record/record.wxss b/pets-fe/pages/record/record.wxss
index 25c7996..25bbe1a 100644
--- a/pets-fe/pages/record/record.wxss
+++ b/pets-fe/pages/record/record.wxss
@@ -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。 */
diff --git a/pets-fe/pages/report/report.js b/pets-fe/pages/report/report.js
index 2a6f904..69494e5 100644
--- a/pets-fe/pages/report/report.js
+++ b/pets-fe/pages/report/report.js
@@ -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) || '我家毛孩子';
diff --git a/pets-fe/pages/report/report.wxml b/pets-fe/pages/report/report.wxml
index d6d63c4..a4cb5d4 100644
--- a/pets-fe/pages/report/report.wxml
+++ b/pets-fe/pages/report/report.wxml
@@ -25,6 +25,16 @@
+
+
+
+
+ 记录与趋势
+ 洞察 · 体重 · 时间轴
+
+
+
+
健康摘要导出
疫苗进度{{summary.vaccine_progress || '—'}}