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