feat(auth): access token 缩到 2 小时 + refresh token 机制 #3
@@ -38,45 +38,60 @@ func seedRecordTypes(db *gorm.DB) error {
|
|||||||
// 代码分支(体重回写档案、食便相关性、消费统计、疫苗完成度…),并且被
|
// 代码分支(体重回写档案、食便相关性、消费统计、疫苗完成度…),并且被
|
||||||
// care_template_items.sheet_type 引用,后台不许删,只能停用。
|
// care_template_items.sheet_type 引用,后台不许删,只能停用。
|
||||||
// form=full 表示 bottom-sheet 里有它专用的 wx:elif 分支。
|
// form=full 表示 bottom-sheet 里有它专用的 wx:elif 分支。
|
||||||
full := func(code, label, icon, group string, sort int) model.RecordType {
|
// fields 是表单额外字段的配置,一行一个(写法见 model.RecordType.Fields 的注释)。
|
||||||
return model.RecordType{Code: code, Label: label, Icon: icon, GroupKey: group,
|
// 空 = 只要「时间 + 描述 + 照片」。
|
||||||
Sort: sort, Enabled: true, Form: model.RecordFormFull, Locked: true}
|
//
|
||||||
|
// 原有 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
|
||||||
}
|
}
|
||||||
simple := func(code, label, icon, group string, sort int) model.RecordType {
|
|
||||||
return model.RecordType{Code: code, Label: label, Icon: icon, GroupKey: group,
|
return model.RecordType{Code: code, Label: label, Icon: icon, GroupKey: group,
|
||||||
Sort: sort, Enabled: true, Form: model.RecordFormSimple, Locked: false}
|
Sort: sort, Enabled: true, Form: form, Locked: locked, Fields: fields}
|
||||||
|
}
|
||||||
|
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
|
d, h, c, cl := model.RecordGroupDaily, model.RecordGroupHealth, model.RecordGroupCare, model.RecordGroupClean
|
||||||
types := []model.RecordType{
|
types := []model.RecordType{
|
||||||
// 日常
|
// 日常
|
||||||
full("weight", "体重", "weight", d, 10),
|
full("weight", "体重", "weight", d, 10, "number:体重:kg"),
|
||||||
full("poop", "排便", "poop", d, 20),
|
full("poop", "排便", "poop", d, 20, "options:状态:正常|软便|拉稀"),
|
||||||
full("food", "饮食", "food", d, 30),
|
full("food", "饮食", "food", d, 30, "text:吃了什么\noptions:食欲:正常|偏少|不吃"),
|
||||||
simple("water", "喝水", "water", d, 40),
|
simple("water", "喝水", "water", d, 40, "number:饮水量:ml"),
|
||||||
full("cost", "记账", "cost", d, 50),
|
full("cost", "记账", "cost", d, 50, "number:金额:元\noptions:类别:医疗|食品|用品|美容|其他"),
|
||||||
full("photo", "照片", "photo", d, 60),
|
full("photo", "照片", "photo", d, 60, ""),
|
||||||
// 健康
|
// 健康
|
||||||
full("symptom", "异常", "symptom", h, 10),
|
full("symptom", "异常", "symptom", h, 10,
|
||||||
full("vaccine", "疫苗", "vaccine", h, 20),
|
"options:发生了什么:呕吐|拉稀|精神差|皮肤红|其他:-\noptions:严重程度:低|中|高"),
|
||||||
full("deworm", "驱虫", "deworm", h, 30),
|
full("vaccine", "疫苗", "vaccine", h, 20, "text:疫苗名称"),
|
||||||
simple("checkup", "体检", "checkup", h, 40),
|
full("deworm", "驱虫", "deworm", h, 30, "options:类型:体内|体外|体内外同驱"),
|
||||||
simple("vet", "看病", "vet", h, 50),
|
simple("checkup", "体检", "checkup", h, 40, "text:在哪做的"),
|
||||||
full("medicine", "给药", "medicine", h, 60),
|
simple("vet", "看病", "vet", h, 50, "text:诊断\nnumber:花费:元"),
|
||||||
simple("supplement", "保健品", "supplement", h, 70),
|
full("medicine", "给药", "medicine", h, 60, "text:药品名称\ntext:剂量"),
|
||||||
|
simple("supplement", "保健品", "supplement", h, 70, "text:名称\ntext:剂量"),
|
||||||
// 洗护
|
// 洗护
|
||||||
simple("bath", "洗澡", "bath", c, 10),
|
simple("bath", "洗澡", "bath", c, 10, ""),
|
||||||
simple("nail", "剪指甲", "nail", c, 20),
|
simple("nail", "剪指甲", "nail", c, 20, ""),
|
||||||
simple("ear", "洗耳朵", "ear", c, 30),
|
simple("ear", "洗耳朵", "ear", c, 30, ""),
|
||||||
simple("tooth", "刷牙", "tooth", c, 40),
|
simple("tooth", "刷牙", "tooth", c, 40, ""),
|
||||||
simple("brush", "梳毛", "brush", c, 50),
|
simple("brush", "梳毛", "brush", c, 50, ""),
|
||||||
simple("groom", "美容", "groom", c, 60),
|
simple("groom", "美容", "groom", c, 60, "number:花费:元"),
|
||||||
// 清洁
|
// 清洁
|
||||||
simple("litter", "换猫砂", "litter", cl, 10),
|
simple("litter", "换猫砂", "litter", cl, 10, ""),
|
||||||
simple("litterbox", "洗猫砂盆", "litterbox", cl, 20),
|
simple("litterbox", "洗猫砂盆", "litterbox", cl, 20, ""),
|
||||||
simple("bowl", "洗食盆", "bowl", cl, 30),
|
simple("bowl", "洗食盆", "bowl", cl, 30, ""),
|
||||||
simple("waterbowl", "洗水盆", "waterbowl", cl, 40),
|
simple("waterbowl", "洗水盆", "waterbowl", cl, 40, ""),
|
||||||
simple("clean", "消毒", "clean", cl, 50),
|
simple("clean", "消毒", "clean", cl, 50, ""),
|
||||||
}
|
}
|
||||||
return db.Create(&types).Error
|
return db.Create(&types).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ type recordTypeReq struct {
|
|||||||
Sort int `json:"sort"`
|
Sort int `json:"sort"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Form string `json:"form"`
|
Form string `json:"form"`
|
||||||
|
Fields string `json:"fields_raw"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminListRecordTypes GET /api/admin/record-types 连停用的一起返回
|
// AdminListRecordTypes GET /api/admin/record-types 连停用的一起返回
|
||||||
@@ -308,6 +309,7 @@ func (h *Handler) AdminSaveRecordType(c *gin.Context) {
|
|||||||
t := &model.RecordType{
|
t := &model.RecordType{
|
||||||
Code: req.Code, Label: req.Label, Icon: req.Icon,
|
Code: req.Code, Label: req.Label, Icon: req.Icon,
|
||||||
GroupKey: req.Group, Sort: req.Sort, Enabled: req.Enabled, Form: req.Form,
|
GroupKey: req.Group, Sort: req.Sort, Enabled: req.Enabled, Form: req.Form,
|
||||||
|
Fields: req.Fields,
|
||||||
}
|
}
|
||||||
t.ID = req.ID
|
t.ID = req.ID
|
||||||
if err := h.svc.SaveRecordType(t); err != nil {
|
if err := h.svc.SaveRecordType(t); err != nil {
|
||||||
|
|||||||
@@ -44,4 +44,39 @@ type RecordType struct {
|
|||||||
// Locked 有代码分支或模板引用依赖它,后台不许删。
|
// Locked 有代码分支或模板引用依赖它,后台不许删。
|
||||||
// 硬拦在 service 层,不是靠前端隐藏按钮。
|
// 硬拦在 service 层,不是靠前端隐藏按钮。
|
||||||
Locked bool `json:"locked"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
@@ -28,10 +29,80 @@ type RecordTypeGroup struct {
|
|||||||
Items []model.RecordType `json:"items"`
|
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 小程序用:只返回启用的,按分组归好
|
// ListRecordTypes 小程序用:只返回启用的,按分组归好
|
||||||
func (s *Service) ListRecordTypes() []RecordTypeGroup {
|
func (s *Service) ListRecordTypes() []RecordTypeGroup {
|
||||||
var all []model.RecordType
|
var all []model.RecordType
|
||||||
s.db.Where("enabled = ?", true).Order("sort asc, id asc").Find(&all)
|
s.db.Where("enabled = ?", true).Order("sort asc, id asc").Find(&all)
|
||||||
|
fillFields(all)
|
||||||
if len(all) == 0 {
|
if len(all) == 0 {
|
||||||
// 表被清空了。这里不硬编码兜底一份——那等于把「可配置」又变回代码里的常量,
|
// 表被清空了。这里不硬编码兜底一份——那等于把「可配置」又变回代码里的常量,
|
||||||
// 下次改配置的人会发现改了没用。宁可返回空让记录页明显是空的,也别静默用旧值。
|
// 下次改配置的人会发现改了没用。宁可返回空让记录页明显是空的,也别静默用旧值。
|
||||||
@@ -57,6 +128,7 @@ func (s *Service) ListRecordTypes() []RecordTypeGroup {
|
|||||||
func (s *Service) AdminListRecordTypes() []model.RecordType {
|
func (s *Service) AdminListRecordTypes() []model.RecordType {
|
||||||
var all []model.RecordType
|
var all []model.RecordType
|
||||||
s.db.Order("group_key asc, sort asc, id asc").Find(&all)
|
s.db.Order("group_key asc, sort asc, id asc").Find(&all)
|
||||||
|
fillFields(all)
|
||||||
if all == nil {
|
if all == nil {
|
||||||
return []model.RecordType{}
|
return []model.RecordType{}
|
||||||
}
|
}
|
||||||
@@ -75,6 +147,10 @@ func (s *Service) SaveRecordType(in *model.RecordType) error {
|
|||||||
if in.GroupKey == "" {
|
if in.GroupKey == "" {
|
||||||
in.GroupKey = model.RecordGroupDaily
|
in.GroupKey = model.RecordGroupDaily
|
||||||
}
|
}
|
||||||
|
// 字段配置写错了必须在这里挡住,不能存进库等用户点开表单才暴露
|
||||||
|
if _, err := parseFields(in.Fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if in.ID == "" {
|
if in.ID == "" {
|
||||||
// 新增:Code 不能和已有的撞
|
// 新增: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{
|
return s.db.Model(&model.RecordType{}).Where("id = ?", in.ID).Updates(map[string]any{
|
||||||
"code": in.Code, "label": in.Label, "icon": in.Icon,
|
"code": in.Code, "label": in.Label, "icon": in.Icon,
|
||||||
"group_key": in.GroupKey, "sort": in.Sort, "enabled": in.Enabled,
|
"group_key": in.GroupKey, "sort": in.Sort, "enabled": in.Enabled,
|
||||||
|
"fields": in.Fields,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"pages/home/home",
|
"pages/home/home",
|
||||||
"pages/plan/plan",
|
"pages/plan/plan",
|
||||||
"pages/record/record",
|
"pages/record/record",
|
||||||
|
"pages/addrecord/addrecord",
|
||||||
|
"pages/history/history",
|
||||||
"pages/report/report",
|
"pages/report/report",
|
||||||
"pages/community/community",
|
"pages/community/community",
|
||||||
"pages/learn/learn",
|
"pages/learn/learn",
|
||||||
|
|||||||
@@ -18,19 +18,6 @@ function daysLater(n) {
|
|||||||
|
|
||||||
// "2026-07-30" → "2026-07-30T12:00:00+08:00"
|
// "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) {
|
function fmtAgo(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@@ -78,36 +65,7 @@ const REMINDER_TYPES = [
|
|||||||
];
|
];
|
||||||
const REMINDER_LABELS = REMINDER_TYPES.map((t) => t.label);
|
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 IDENTITY = ['petName', 'anonymous', 'official'];
|
||||||
const PTAG = ['晒宠', '求助', '经验', '避坑'];
|
const PTAG = ['晒宠', '求助', '经验', '避坑'];
|
||||||
|
|
||||||
@@ -124,22 +82,7 @@ Component({
|
|||||||
pet: {},
|
pet: {},
|
||||||
segSel: {},
|
segSel: {},
|
||||||
optSel: {},
|
optSel: {},
|
||||||
dateVals: { vaccine: '', deworm: '' },
|
|
||||||
// 通用简易记录(洗护/清洁那 15 种共用)
|
// 通用简易记录(洗护/清洁那 15 种共用)
|
||||||
isSimple: false,
|
|
||||||
simpleLabel: '',
|
|
||||||
simpleDate: '',
|
|
||||||
simpleNote: '',
|
|
||||||
simpleImages: [],
|
|
||||||
simplePlaceholder: '',
|
|
||||||
wInput: '',
|
|
||||||
wNote: '',
|
|
||||||
poopNote: '',
|
|
||||||
foodText: '',
|
|
||||||
medName: '',
|
|
||||||
medDose: '',
|
|
||||||
costNote: '',
|
|
||||||
costAmount: '',
|
|
||||||
postContent: '',
|
postContent: '',
|
||||||
postImages: [],
|
postImages: [],
|
||||||
manageTasks: [],
|
manageTasks: [],
|
||||||
@@ -193,14 +136,9 @@ Component({
|
|||||||
if (fresh) {
|
if (fresh) {
|
||||||
patch.segSel = {};
|
patch.segSel = {};
|
||||||
patch.optSel = {};
|
patch.optSel = {};
|
||||||
patch.costAmount = '';
|
|
||||||
patch.postContent = '';
|
patch.postContent = '';
|
||||||
patch.postImages = [];
|
patch.postImages = [];
|
||||||
patch.poopNote = '';
|
|
||||||
patch.foodText = '';
|
|
||||||
patch.medName = '';
|
|
||||||
patch.medDose = '';
|
patch.medDose = '';
|
||||||
patch.costNote = '';
|
|
||||||
patch.manageTasks = [];
|
patch.manageTasks = [];
|
||||||
patch.taskForm = { id: '', title: '', description: '', priority: '', sheetIdx: 0 };
|
patch.taskForm = { id: '', title: '', description: '', priority: '', sheetIdx: 0 };
|
||||||
patch.taskSheetLabels = taskSheets().map((x) => x.label);
|
patch.taskSheetLabels = taskSheets().map((x) => x.label);
|
||||||
@@ -220,26 +158,7 @@ Component({
|
|||||||
patch.cmFocus = false;
|
patch.cmFocus = false;
|
||||||
patch.kbHeight = 0;
|
patch.kbHeight = 0;
|
||||||
patch.saving = false;
|
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') {
|
if (type === 'editPet') {
|
||||||
// 预填当前宠物档案
|
// 预填当前宠物档案
|
||||||
patch.addName = pet.name || '';
|
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) {
|
goSheet(e) {
|
||||||
this.setType(e.currentTarget.dataset.type, true);
|
this.setType(e.currentTarget.dataset.type, true);
|
||||||
},
|
},
|
||||||
@@ -459,35 +368,6 @@ Component({
|
|||||||
const optSel = Object.assign({}, this.data.optSel, { [group]: Number(index) });
|
const optSel = Object.assign({}, this.data.optSel, { [group]: Number(index) });
|
||||||
this.setData({ optSel });
|
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 }); },
|
onPostInput(e) { this.setData({ postContent: e.detail.value }); },
|
||||||
loadComments() {
|
loadComments() {
|
||||||
if (!this.data.postId) return;
|
if (!this.data.postId) return;
|
||||||
@@ -753,85 +633,13 @@ Component({
|
|||||||
const v = this.data.optSel[group];
|
const v = this.data.optSel[group];
|
||||||
return v === undefined ? 0 : v;
|
return v === undefined ? 0 : v;
|
||||||
},
|
},
|
||||||
// 与 optIdx 的区别:没选就是没选,不当成选了第 0 项
|
// 记录表单整体搬去 pages/addrecord 之后,这里只剩「生成就医前摘要」
|
||||||
optPicked(group) {
|
// 会往 health_records 写一条 note —— 它是个动作留痕,不是用户填的表单
|
||||||
return this.data.optSel[group] !== undefined;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 异常观察 → 生成风险评估(真调后端 AI,失败回退)
|
|
||||||
// 依据当前弹层类型组装一条健康记录
|
|
||||||
buildRecord() {
|
buildRecord() {
|
||||||
const t = this.data.innerType;
|
if (this.data.innerType === 'vetSummary') {
|
||||||
// 简易类型统一在这里组装。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: '生成就医前摘要' };
|
return { type: 'note', icon: '📄', title: '生成就医前摘要' };
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createAndClose(rec) {
|
async createAndClose(rec) {
|
||||||
@@ -859,48 +667,8 @@ Component({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onSave() {
|
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());
|
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() {
|
petFormBody() {
|
||||||
const isDog = this.segIdx('addType') === 1;
|
const isDog = this.segIdx('addType') === 1;
|
||||||
return {
|
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}}">
|
<scroll-view wx:if="{{innerType !== 'comments'}}" class="sheet-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||||
<!-- 记录体重 -->
|
<!-- 那 9 种记录表单(体重/排便/饮食/异常/给药/记账/疫苗/驱虫/照片)已经整体
|
||||||
<block wx:if="{{innerType === 'weight'}}">
|
搬到 pages/addrecord —— 字段由后端 record_types.fields 驱动,一个页面吃掉
|
||||||
<view class="sheet-h3">记录体重</view>
|
全部 24 种,不再每种一个 wx:elif。弹层现在只管「不是记一笔」的那些事:
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 就医前摘要 -->
|
<!-- 就医前摘要 -->
|
||||||
<block wx:elif="{{innerType === 'vetSummary'}}">
|
<block wx:elif="{{innerType === 'vetSummary'}}">
|
||||||
<view class="sheet-h3">就医前摘要</view>
|
<view class="sheet-h3">就医前摘要</view>
|
||||||
@@ -173,86 +108,6 @@ module.exports.sel = function (map, key, index, def) {
|
|||||||
</block>
|
</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'}}">
|
<block wx:elif="{{innerType === 'reminders'}}">
|
||||||
<view class="sheet-h3">提醒中心</view>
|
<view class="sheet-h3">提醒中心</view>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
const store = require('../../utils/store.js');
|
||||||
|
const api = require('../../utils/api.js');
|
||||||
|
const upload = require('../../utils/upload.js');
|
||||||
|
const recordTypes = require('../../utils/recordTypes.js');
|
||||||
|
const { toastErr } = require('../../utils/ui.js');
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return n < 10 ? '0' + n : '' + n;
|
||||||
|
}
|
||||||
|
function today() {
|
||||||
|
const d = new Date();
|
||||||
|
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
||||||
|
}
|
||||||
|
// "2026-07-30" → "2026.07.30"
|
||||||
|
function fmtDate(s) {
|
||||||
|
return (s || '').replace(/-/g, '.');
|
||||||
|
}
|
||||||
|
// 后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期它解不出来会静默回退成
|
||||||
|
// time.Now()——选「昨天」会存成今天且不报错。时区要带真实偏移:转成 UTC 的 Z 形式
|
||||||
|
// 落库再转回本地时会把边界日期挪一天。取正午同样是为了远离日界。
|
||||||
|
function dayToISO(day) {
|
||||||
|
if (!day) return '';
|
||||||
|
const off = -new Date().getTimezoneOffset();
|
||||||
|
const sign = off >= 0 ? '+' : '-';
|
||||||
|
const a = Math.abs(off);
|
||||||
|
return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
code: '',
|
||||||
|
type: {},
|
||||||
|
fields: [],
|
||||||
|
values: [], // 和 fields 同下标
|
||||||
|
pet: {},
|
||||||
|
date: '',
|
||||||
|
dateLabel: '',
|
||||||
|
note: '',
|
||||||
|
images: [],
|
||||||
|
saving: false,
|
||||||
|
},
|
||||||
|
onLoad(q) {
|
||||||
|
const code = (q && q.type) || '';
|
||||||
|
const d = today();
|
||||||
|
this.setData({ code, date: d, dateLabel: fmtDate(d) });
|
||||||
|
store
|
||||||
|
.ready()
|
||||||
|
.then(() => recordTypes.load())
|
||||||
|
.then(() => this.applyType())
|
||||||
|
.catch((e) => toastErr(e));
|
||||||
|
},
|
||||||
|
applyType() {
|
||||||
|
const t = recordTypes.get(this.data.code);
|
||||||
|
if (!t) {
|
||||||
|
wx.showToast({ title: '这个记录类型不存在了', icon: 'none' });
|
||||||
|
return setTimeout(() => wx.navigateBack(), 800);
|
||||||
|
}
|
||||||
|
const fields = t.fields || [];
|
||||||
|
// 单选默认不预选:预选了用户不点也会存下一个他没确认过的值
|
||||||
|
this.setData({ type: t, fields, values: fields.map(() => ''), pet: store.getPet() || {} });
|
||||||
|
},
|
||||||
|
// 多宠时切换记到哪只身上。用 actionSheet 而不是自己画一个选择器
|
||||||
|
pickPet() {
|
||||||
|
const pets = store.getPets() || [];
|
||||||
|
if (pets.length < 2) return;
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList: pets.map((p) => p.name),
|
||||||
|
success: (r) => {
|
||||||
|
const p = pets[r.tapIndex];
|
||||||
|
store.switchPet(p.id);
|
||||||
|
this.setData({ pet: p });
|
||||||
|
},
|
||||||
|
fail: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDate(e) {
|
||||||
|
this.setData({ date: e.detail.value, dateLabel: fmtDate(e.detail.value) });
|
||||||
|
},
|
||||||
|
onField(e) {
|
||||||
|
this.setData({ ['values[' + e.currentTarget.dataset.index + ']']: e.detail.value });
|
||||||
|
},
|
||||||
|
onPickOpt(e) {
|
||||||
|
const { index, val } = e.currentTarget.dataset;
|
||||||
|
// 再点一下取消选择——单选组里没有「不选」按钮,只能靠反选
|
||||||
|
this.setData({ ['values[' + index + ']']: this.data.values[index] === val ? '' : val });
|
||||||
|
},
|
||||||
|
onNote(e) {
|
||||||
|
this.setData({ note: e.detail.value });
|
||||||
|
},
|
||||||
|
onPickImage() {
|
||||||
|
upload
|
||||||
|
.chooseAndUploadImage()
|
||||||
|
.then((f) => this.setData({ images: this.data.images.concat([f]) }))
|
||||||
|
.catch((e) => {
|
||||||
|
if (e && e.canceled) return;
|
||||||
|
toastErr(e, '上传失败');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onRemoveImage(e) {
|
||||||
|
const imgs = this.data.images.slice();
|
||||||
|
imgs.splice(e.currentTarget.dataset.index, 1);
|
||||||
|
this.setData({ images: imgs });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 按字段配置组装请求体。哪个字段落到 num_value / category 完全由后端的
|
||||||
|
// to_category / kind 决定,前端不认识「体重」「金额」这些业务词
|
||||||
|
buildBody() {
|
||||||
|
const { fields, values } = this.data;
|
||||||
|
const body = { type: this.data.code, occurred_at: dayToISO(this.data.date) };
|
||||||
|
const parts = [];
|
||||||
|
fields.forEach((f, i) => {
|
||||||
|
const v = (values[i] || '').toString().trim();
|
||||||
|
if (!v) return;
|
||||||
|
if (f.kind === 'number') {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
if (!isNaN(n)) {
|
||||||
|
body.num_value = n;
|
||||||
|
parts.push(v + (f.unit || ''));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (f.to_category) body.category = v;
|
||||||
|
parts.push(v);
|
||||||
|
});
|
||||||
|
body.title = parts.length ? this.data.type.label + ':' + parts.join(' ') : this.data.type.label;
|
||||||
|
body.description = (this.data.note || '').trim();
|
||||||
|
const img = this.data.images[0];
|
||||||
|
if (img) {
|
||||||
|
body.image_file_id = img.id;
|
||||||
|
body.image_url = img.url;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
// 必填校验:数值字段空着存下来是一条没有数值的体重记录,趋势图上是个洞
|
||||||
|
missingField() {
|
||||||
|
const { fields, values } = this.data;
|
||||||
|
for (let i = 0; i < fields.length; i++) {
|
||||||
|
if (fields[i].kind === 'number' && !(values[i] || '').toString().trim()) {
|
||||||
|
return fields[i].label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
onSave() {
|
||||||
|
if (this.data.saving) return;
|
||||||
|
const id = store.currentPetId();
|
||||||
|
if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' });
|
||||||
|
const miss = this.missingField();
|
||||||
|
if (miss) return wx.showToast({ title: '请填写' + miss, icon: 'none' });
|
||||||
|
|
||||||
|
this.setData({ saving: true });
|
||||||
|
api
|
||||||
|
.createRecord(id, this.buildBody())
|
||||||
|
.then(() => {
|
||||||
|
this.setData({ saving: false });
|
||||||
|
wx.showToast({ title: '已记录', icon: 'success' });
|
||||||
|
setTimeout(() => wx.navigateBack(), 600);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
this.setData({ saving: false });
|
||||||
|
toastErr(e, '保存失败');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {
|
||||||
|
"nav-bar": "/components/nav-bar/nav-bar",
|
||||||
|
"pt-icon": "/components/pt-icon/index"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<nav-bar title="添加{{type.label}}记录" show-back="{{true}}"></nav-bar>
|
||||||
|
|
||||||
|
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||||
|
<view class="page-body ar-body">
|
||||||
|
<!-- 宠物 + 时间。多宠家庭最容易记错到别的宠物身上,所以放第一行 -->
|
||||||
|
<view class="card ar-card">
|
||||||
|
<view class="ar-row" bindtap="pickPet">
|
||||||
|
<text class="ar-k">记录宠物</text>
|
||||||
|
<text class="ar-v">{{pet.name || '选择'}}</text>
|
||||||
|
<pt-icon name="next" size="{{26}}"></pt-icon>
|
||||||
|
</view>
|
||||||
|
<picker mode="date" value="{{date}}" bindchange="onDate">
|
||||||
|
<view class="ar-row ar-last">
|
||||||
|
<text class="ar-k">记录时间</text>
|
||||||
|
<text class="ar-v">{{dateLabel}}</text>
|
||||||
|
<pt-icon name="next" size="{{26}}"></pt-icon>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 类型专属字段。整段由后端 record_types.fields 驱动,
|
||||||
|
前端不认识「体重」「金额」这些词,只认识 number / options / text -->
|
||||||
|
<view wx:if="{{fields.length}}" class="card ar-card">
|
||||||
|
<block wx:for="{{fields}}" wx:key="index">
|
||||||
|
<!-- 数值 -->
|
||||||
|
<view wx:if="{{item.kind === 'number'}}" class="ar-row {{index === fields.length - 1 ? 'ar-last' : ''}}">
|
||||||
|
<text class="ar-k">{{item.label}}</text>
|
||||||
|
<input class="ar-in" type="digit" placeholder="填写{{item.label}}" placeholder-class="ar-ph"
|
||||||
|
value="{{values[index]}}" data-index="{{index}}" bindinput="onField"/>
|
||||||
|
<text wx:if="{{item.unit}}" class="ar-unit">{{item.unit}}</text>
|
||||||
|
</view>
|
||||||
|
<!-- 文本 -->
|
||||||
|
<view wx:elif="{{item.kind === 'text'}}" class="ar-row {{index === fields.length - 1 ? 'ar-last' : ''}}">
|
||||||
|
<text class="ar-k">{{item.label}}</text>
|
||||||
|
<input class="ar-in" placeholder="填写{{item.label}}" placeholder-class="ar-ph"
|
||||||
|
value="{{values[index]}}" data-index="{{index}}" bindinput="onField"/>
|
||||||
|
</view>
|
||||||
|
<!-- 单选 -->
|
||||||
|
<view wx:else class="ar-opt-block {{index === fields.length - 1 ? 'ar-last' : ''}}">
|
||||||
|
<text class="ar-k">{{item.label}}</text>
|
||||||
|
<view class="ar-opts">
|
||||||
|
<view wx:for="{{item.options}}" wx:for-item="o" wx:key="*this"
|
||||||
|
class="ar-opt {{values[index] === o ? 'on' : ''}}"
|
||||||
|
data-index="{{index}}" data-val="{{o}}" bindtap="onPickOpt">{{o}}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card ar-card">
|
||||||
|
<view class="ar-h">描述</view>
|
||||||
|
<textarea class="ar-ta" placeholder="请输入你想要记的内容~" placeholder-class="ar-ph"
|
||||||
|
value="{{note}}" maxlength="500" bindinput="onNote"></textarea>
|
||||||
|
<view class="ar-media">
|
||||||
|
<view wx:for="{{images}}" wx:key="id" class="ar-thumb">
|
||||||
|
<image src="{{item.url}}" mode="aspectFill"></image>
|
||||||
|
<view class="ar-del" catchtap="onRemoveImage" data-index="{{index}}"><pt-icon name="close" size="{{22}}"></pt-icon></view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{images.length < 3}}" class="ar-add" bindtap="onPickImage">
|
||||||
|
<pt-icon name="photo" size="{{40}}"></pt-icon>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 保存键固定在底部:表单长了以后不该滚到最下面才能保存 -->
|
||||||
|
<view class="ar-foot">
|
||||||
|
<view class="btn btn-primary btn-block {{saving ? 'ar-busy' : ''}}" bindtap="onSave">{{saving ? '保存中…' : '保存'}}</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/* 底部固定保存键,内容要让开它 */
|
||||||
|
.ar-body{padding-bottom:calc(180rpx + env(safe-area-inset-bottom))}
|
||||||
|
.ar-card{padding:0 var(--pad-x)}
|
||||||
|
|
||||||
|
.ar-row{
|
||||||
|
display:flex;align-items:center;gap:var(--sp-3);
|
||||||
|
min-height:112rpx;border-bottom:1rpx solid var(--line);
|
||||||
|
}
|
||||||
|
.ar-last,.ar-row:last-child{border-bottom:0}
|
||||||
|
.ar-k{flex:none;font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
|
||||||
|
.ar-v{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text-2)}
|
||||||
|
.ar-in{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text)}
|
||||||
|
.ar-ph{color:var(--muted2)}
|
||||||
|
.ar-unit{flex:none;font-size:var(--fs-sm);color:var(--muted)}
|
||||||
|
|
||||||
|
/* 单选独占一段:选项多的时候挤在一行右侧会换行成一团 */
|
||||||
|
.ar-opt-block{padding:var(--sp-4) 0;border-bottom:1rpx solid var(--line)}
|
||||||
|
.ar-opts{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:var(--sp-3)}
|
||||||
|
.ar-opt{
|
||||||
|
padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full);
|
||||||
|
background:var(--surface-2);border:1rpx solid var(--line);
|
||||||
|
font-size:var(--fs-sm);color:var(--text-2);
|
||||||
|
}
|
||||||
|
.ar-opt.on{
|
||||||
|
background:var(--primary-soft);border-color:var(--primary);
|
||||||
|
color:var(--primary-ink);font-weight:var(--fw-b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-h{font-size:var(--fs-md);font-weight:var(--fw-b);padding:var(--sp-4) 0 var(--sp-2)}
|
||||||
|
.ar-ta{width:100%;min-height:200rpx;font-size:var(--fs-md);line-height:1.6}
|
||||||
|
|
||||||
|
.ar-media{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0 var(--sp-4)}
|
||||||
|
.ar-thumb{position:relative;width:132rpx;height:132rpx;border-radius:var(--r-sm);overflow:hidden}
|
||||||
|
.ar-thumb image{width:100%;height:100%}
|
||||||
|
.ar-del{
|
||||||
|
position:absolute;top:0;right:0;width:40rpx;height:40rpx;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
background:rgba(0,0,0,.55);color:#fff;border-radius:0 var(--r-sm) 0 var(--r-sm);
|
||||||
|
}
|
||||||
|
.ar-add{
|
||||||
|
width:132rpx;height:132rpx;border-radius:var(--r-sm);
|
||||||
|
background:var(--surface-2);color:var(--muted2);
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-foot{
|
||||||
|
position:fixed;left:0;right:0;bottom:0;z-index:50;
|
||||||
|
padding:var(--sp-4) var(--pad-x) calc(var(--sp-4) + env(safe-area-inset-bottom));
|
||||||
|
background:#fff;box-shadow:0 -12rpx 32rpx rgba(70,48,25,.06);
|
||||||
|
}
|
||||||
|
.ar-busy{opacity:.6}
|
||||||
@@ -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: '用肉垫计划记录毛孩子的成长' };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)}
|
||||||
@@ -270,6 +270,11 @@ Page({
|
|||||||
})
|
})
|
||||||
.catch((e) => toastErr(e, '操作失败'));
|
.catch((e) => toastErr(e, '操作失败'));
|
||||||
},
|
},
|
||||||
|
// 记一笔的每一项都进独立页面。原来是开弹层——24 项的表单塞弹层里太挤,
|
||||||
|
// 而且弹层高度受 sheet-scroll 的 78vh 限制,字段一多就变成内滚
|
||||||
|
goAdd(e) {
|
||||||
|
wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + e.currentTarget.dataset.type });
|
||||||
|
},
|
||||||
openSheet(e) {
|
openSheet(e) {
|
||||||
this.openSheetType(e.currentTarget.dataset.type);
|
this.openSheetType(e.currentTarget.dataset.type);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
<view class="link" bindtap="goRecord">更多</view>
|
<view class="link" bindtap="goRecord">更多</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="quick-grid">
|
<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}}
|
<pt-icon name="{{item.icon}}" size="{{44}}" fallback="note"></pt-icon>{{item.label}}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
+12
-238
@@ -1,122 +1,14 @@
|
|||||||
const store = require('../../utils/store.js');
|
const store = require('../../utils/store.js');
|
||||||
const api = require('../../utils/api.js');
|
|
||||||
const { toastErr } = require('../../utils/ui.js');
|
|
||||||
const recordTypes = require('../../utils/recordTypes.js');
|
const recordTypes = require('../../utils/recordTypes.js');
|
||||||
|
|
||||||
function pad(n) {
|
// 这一页只干一件事:把后端配置的记录事项按分组列出来,点一个进添加页。
|
||||||
return n < 10 ? '0' + n : '' + n;
|
// 原来它还带着健康洞察、体重趋势、健康时间轴——那三块搬到 pages/history 了,
|
||||||
}
|
// 从报告页进。一个「我要记一笔」的页面上摆着三块只读图表,
|
||||||
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;
|
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: { pet: {}, typeGroups: [], sheetShow: false, sheetType: '' },
|
||||||
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: '',
|
|
||||||
},
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
this._unsub = store.subscribe((pet) => {
|
this._unsub = store.subscribe((pet) => this.setData({ pet }));
|
||||||
this.setData({ pet });
|
|
||||||
if (!this._inited) return; // 首次由 onShow 加载
|
|
||||||
this.loadRecords();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onUnload() {
|
onUnload() {
|
||||||
if (this._unsub) this._unsub();
|
if (this._unsub) this._unsub();
|
||||||
@@ -125,57 +17,9 @@ Page({
|
|||||||
this.loadTypes();
|
this.loadTypes();
|
||||||
store
|
store
|
||||||
.ready()
|
.ready()
|
||||||
.then(() => {
|
.then(() => this.setData({ pet: store.getPet() }))
|
||||||
this._inited = true;
|
|
||||||
this.setData({ pet: store.getPet() });
|
|
||||||
this.loadRecords();
|
|
||||||
})
|
|
||||||
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
|
.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() {
|
loadTypes() {
|
||||||
if (this.data.typeGroups.length) return;
|
if (this.data.typeGroups.length) return;
|
||||||
recordTypes
|
recordTypes
|
||||||
@@ -183,86 +27,16 @@ Page({
|
|||||||
.then((groups) => this.setData({ typeGroups: groups }))
|
.then((groups) => this.setData({ typeGroups: groups }))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
},
|
},
|
||||||
loadRecords() {
|
goAdd(e) {
|
||||||
const id = store.currentPetId();
|
wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + e.currentTarget.dataset.type });
|
||||||
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() {
|
// 没建档时要能建档,所以这页留一个只用于 addPet 的弹层
|
||||||
const id = store.currentPetId();
|
onAddPet() {
|
||||||
if (!id) return;
|
this.setData({ sheetType: 'addPet', sheetShow: true });
|
||||||
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() {
|
closeSheet() {
|
||||||
this.setData({ sheetShow: false });
|
this.setData({ sheetShow: false });
|
||||||
},
|
},
|
||||||
onSheetSaved() {
|
|
||||||
this.loadRecords();
|
|
||||||
},
|
|
||||||
onAddPet() {
|
|
||||||
this.openSheetType('addPet');
|
|
||||||
},
|
|
||||||
onShareAppMessage() {
|
onShareAppMessage() {
|
||||||
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
|
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
"nav-bar": "/components/nav-bar/nav-bar",
|
"nav-bar": "/components/nav-bar/nav-bar",
|
||||||
"pet-switch": "/components/pet-switch/pet-switch",
|
"pet-switch": "/components/pet-switch/pet-switch",
|
||||||
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
|
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
|
||||||
"fab": "/components/fab/fab",
|
"pt-icon": "/components/pt-icon/index"
|
||||||
"pt-icon": "/components/pt-icon/index",
|
|
||||||
"seg-tabs": "/components/seg-tabs/index"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="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 class="quick-grid cols-3">
|
||||||
<view wx:for="{{item.items}}" wx:for-item="t" wx:key="code" class="quick {{item.tone}}"
|
<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}}
|
<pt-icon name="{{t.icon}}" size="{{44}}" fallback="note"></pt-icon>{{t.label}}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view wx:if="{{!typeGroups.length}}" class="empty">还没有配置可记事项</view>
|
<view wx:if="{{!typeGroups.length}}" class="empty">还没有配置可记事项</view>
|
||||||
</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>
|
</view>
|
||||||
</scroll-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>
|
||||||
|
|||||||
@@ -1,59 +1,3 @@
|
|||||||
.mini-chart{
|
/* 分组宫格的样式在 app.wxss(记录页和历史页都用过,页面级 wxss 跨不了页)。
|
||||||
height:256rpx;border-radius:var(--r-md);margin-top:var(--sp-4);position:relative;overflow:hidden;
|
趋势图和时间轴的样式跟着那三块一起搬到 pages/history 了。
|
||||||
background:
|
这页只剩「没建档」的空态,那套也在 app.wxss。 */
|
||||||
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)}
|
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ Page({
|
|||||||
onAddPet() {
|
onAddPet() {
|
||||||
this.setData({ sheetType: 'addPet', sheetShow: true });
|
this.setData({ sheetType: 'addPet', sheetShow: true });
|
||||||
},
|
},
|
||||||
|
goHistory() {
|
||||||
|
wx.navigateTo({ url: '/pages/history/history' });
|
||||||
|
},
|
||||||
onShareAppMessage() {
|
onShareAppMessage() {
|
||||||
// 带上宠物名,转发出去别人一眼知道是谁家的
|
// 带上宠物名,转发出去别人一眼知道是谁家的
|
||||||
const n = (this.data.pet && this.data.pet.name) || '我家毛孩子';
|
const n = (this.data.pet && this.data.pet.name) || '我家毛孩子';
|
||||||
|
|||||||
@@ -25,6 +25,16 @@
|
|||||||
</view>
|
</view>
|
||||||
</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="card">
|
||||||
<view class="section-head"><view class="sh-title">健康摘要</view><view class="link" data-type="vetSummary" bindtap="openSheet">导出</view></view>
|
<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>
|
<view class="record-row"><text class="bold">疫苗进度</text><text class="muted">{{summary.vaccine_progress || '—'}}</text></view>
|
||||||
|
|||||||
Reference in New Issue
Block a user