feat: 引导页阶段按生日推断 + 8 套养护模板按临床共识重配

## 引导页三个逻辑漏洞

1. **阶段和生日脱节**:用户填了 3 个月的生日,却能手选「成年期」,
   而阶段决定疫苗针次、驱虫周期和每天要做的事,选错整套计划都是偏的。
   现在按生日自动推断(猫狗同:12 月龄成年、7 岁进老年),仍可手改,
   但改成矛盾值时会弹框说明代价;服务端 NormalizeStage 兜底。

2. **`goals` 是假选择**:6 个勾选框收上来只存进 pets.goals,一处没用过。
   现在真正生效:体重提醒、月度报告按勾选生成,勾了「想控制养宠开销」
   会多一条记账任务。疫苗和驱虫不给关——狂犬是法定要求,漏驱虫真会出事。

3. **「刚到家 0-30 天」和年龄阶段互斥**:挤在一个四选一里,刚接回家的
   成年猫只能二选一。现在拆开:stage 只存年龄阶段,新增 pets.arrived_at,
   到家不满 30 天时把安置期那套任务和计划叠加上去(同标题去重)。

顺带:生日/到家日期不能选未来(前后端各拦一道)、体重范围校验。

## 疫苗提醒改为按月龄算

原来不管多大都排「14 天后首针」,一只 8 月龄才接回家的猫会被安排去打首免。
现在 dueDaysFor 按真实月龄推:<2 月龄等到 8 周龄、2-5 月龄间隔 21 天、
5-12 月龄两周内补齐、成年走年度加强。标题也跟着改写,否则用户照着
「年度疫苗加强」去打错针。

年度疫苗不再编一个假的到期日(原来写 30 天后),改用频率描述。

## 8 套模板按临床共识重配

依据 WSAVA 疫苗指南、ESCCAP/CAPC 驱虫建议、AAHA 生命阶段指南整理:
猫三联 3 针间隔 3-4 周末针不早于 16 周龄、犬联苗 2-3 针 + 3 月龄狂犬、
体内驱虫 6 月龄前每月一次之后每 3 个月、幼崽 3-6 月龄每天 3-4 餐、
成年犬每天 2 次共 30-60 分钟遛狗、老年建议半年一次体检。
每套从 11 条扩到 10-14 条,描述写成能直接照做的具体动作。

后台新增「恢复默认」:启动 seed 改为只补缺失、不覆盖已有(管理员可能
改过),内置模板更新后必须从后台显式推一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-29 11:58:00 +08:00
parent 35fc57e8de
commit d9d7e128ff
14 changed files with 583 additions and 200 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-1jDx3IlD.js"></script>
<script type="module" crossorigin src="/admin/assets/index-D8GFrwWw.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BjLxu16U.css">
</head>
<body>
+21 -4
View File
@@ -227,7 +227,7 @@ func (h *Handler) AdminListArticles(c *gin.Context) {
}
type articleReq struct {
ID string `json:"id"`
ID string `json:"id"`
Icon string `json:"icon"`
Title string `json:"title"`
Description string `json:"description"`
@@ -305,9 +305,9 @@ func (h *Handler) AdminGetCareTemplate(c *gin.Context) {
}
type careTemplateReq struct {
Species string `json:"species"`
Stage string `json:"stage"`
Items []model.CareTemplateItem `json:"items"`
Species string `json:"species"`
Stage string `json:"stage"`
Items []model.CareTemplateItem `json:"items"`
}
// AdminSaveCareTemplate PUT /api/admin/care-templates
@@ -325,6 +325,23 @@ func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
response.OK(c, data)
}
// AdminResetCareTemplate POST /api/admin/care-templates/reset
// 把某组合恢复成内置默认。启动时的 seed 只补缺失、不覆盖已有,
// 想把新版内置模板推到线上必须走这个显式动作。
func (h *Handler) AdminResetCareTemplate(c *gin.Context) {
var req careGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
items, err := h.svc.ResetCareTemplate(req.Species, req.Stage)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"species": req.Species, "stage": req.Stage, "items": items})
}
type careGenReq struct {
Species string `json:"species"`
Stage string `json:"stage"`
+21 -9
View File
@@ -12,14 +12,15 @@ import (
)
type petReq struct {
Name string `json:"name"`
Emoji string `json:"emoji"`
Type string `json:"type"`
Gender string `json:"gender"`
Birthday string `json:"birthday"` // YYYY-MM-DD
Weight string `json:"weight"`
Stage string `json:"stage"`
Age string `json:"age"`
Name string `json:"name"`
Emoji string `json:"emoji"`
Type string `json:"type"`
Gender string `json:"gender"`
Birthday string `json:"birthday"` // YYYY-MM-DD
ArrivedAt string `json:"arrived_at"` // YYYY-MM-DD,到家日期
Weight string `json:"weight"`
Stage string `json:"stage"`
Age string `json:"age"`
Color string `json:"color"`
Breed string `json:"breed"`
AvatarFileID string `json:"avatar_file_id"`
@@ -34,9 +35,20 @@ func (r petReq) toInput() service.PetInput {
}
if r.Birthday != "" {
if t, err := time.Parse("2006-01-02", r.Birthday); err == nil {
in.Birthday = &t
// 生日不接受未来日期:前端限制过一道,服务端不能只信前端
if !t.After(time.Now()) {
in.Birthday = &t
}
}
}
if r.ArrivedAt != "" {
if t, err := time.Parse("2006-01-02", r.ArrivedAt); err == nil && !t.After(time.Now()) {
in.ArrivedAt = &t
}
}
// 阶段以生日为准。用户在引导页可以手改,但服务端只认年龄阶段三选一——
// 「刚到家」是叠加包不是年龄段,混进来会让整套模板取错。
in.Stage = service.NormalizeStage(service.SpeciesOf(r.Type), in.Stage, in.Birthday)
if r.Goals != nil {
if b, err := jsonMarshal(r.Goals); err == nil {
in.Goals = datatypes.JSON(b)
+2 -1
View File
@@ -17,8 +17,9 @@ type Pet struct {
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
Birthday *time.Time `json:"birthday"`
ArrivedAt *time.Time `json:"arrived_at"` // 到家日期。30 天内会额外叠加一套安置期任务
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
Stage string `gorm:"size:32" json:"stage"` // 刚到家 0-30 天 / 幼年期 / 成年期 / 老年期
Stage string `gorm:"size:32" json:"stage"` // 年龄阶段:幼年期 / 成年期 / 老年期(由生日推断,可手改)
Age string `gorm:"size:32" json:"age"`
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
Breed string `gorm:"size:64" json:"breed"` // 品种
+1
View File
@@ -144,6 +144,7 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.GET("/care-templates", h.AdminGetCareTemplate)
g.PUT("/care-templates", h.AdminSaveCareTemplate)
g.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
g.POST("/care-templates/reset", h.AdminResetCareTemplate)
g.GET("/community-bot", h.AdminGetCommunityBot)
g.PUT("/community-bot", h.AdminSaveCommunityBot)
+189 -96
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
@@ -12,9 +13,54 @@ import (
"github.com/sundynix/pets-be/internal/model"
)
// CareSpecies / CareStages 支持的物种与阶段(阶段字符串与 onboarding 一致)
// 模板分组。注意 StageNewHome 不是年龄阶段,而是「刚到家」叠加包:
// 建档时先按年龄阶段取一套,若到家不足 30 天,再把这一套叠上去。
const (
StageNewHome = "刚到家 0-30 天"
StageJuvenile = "幼年期"
StageAdult = "成年期"
StageSenior = "老年期"
)
// CareSpecies / CareStages 支持的物种与分组(字符串与 onboarding 一致)
var CareSpecies = []string{model.SpeciesCat, model.SpeciesDog}
var CareStages = []string{"刚到家 0-30 天", "幼年期", "成年期", "老年期"}
var CareStages = []string{StageNewHome, StageJuvenile, StageAdult, StageSenior}
// AgeStages 只含年龄阶段,用于推断和校验(不含叠加包)
var AgeStages = []string{StageJuvenile, StageAdult, StageSenior}
// StageFromBirthday 由生日推断年龄阶段。
// 猫:12 月龄成年、7 岁进入老年。
// 犬:12 月龄成年(大型犬实际 18-24 月,这里取通用值)、7 岁进入老年。
// 拿不到生日就回落到成年期——它的模板最保守,不会给幼崽排年度疫苗、
// 也不会给成年犬排"每周两次称重"。
func StageFromBirthday(species string, birthday *time.Time) string {
if birthday == nil || birthday.IsZero() {
return StageAdult
}
months := monthsSince(*birthday)
switch {
case months < 12:
return StageJuvenile
case months >= 7*12:
return StageSenior
default:
return StageAdult
}
}
// monthsSince 从某天到今天的整月数(不足一月算 0)
func monthsSince(t time.Time) int {
now := time.Now()
m := (now.Year()-t.Year())*12 + int(now.Month()) - int(t.Month())
if now.Day() < t.Day() {
m--
}
if m < 0 {
m = 0
}
return m
}
// SpeciesOf 由宠物类型(猫猫/狗狗)映射到模板物种键
func SpeciesOf(petType string) string {
@@ -140,7 +186,7 @@ func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
Title, Description, Priority, SheetType string
} `json:"tasks"`
Plan []struct {
Day int
Day int
DayLabel, Title, Description, SheetType string
} `json:"plan"`
Reminders []struct {
@@ -174,16 +220,19 @@ func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
}
// SeedCareTemplates 启动幂等 seed:库中无任何模板时写入 8 套内置默认
// 按组合补齐:只创建缺失的那几套,已有的(可能被管理员改过)一律不动。
// 想把内置默认推到已有组合上,走后台的「恢复默认」——那是显式操作,不该由启动时偷偷覆盖。
func (s *Service) SeedCareTemplates() error {
var count int64
if err := s.db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
for _, sp := range CareSpecies {
for _, stage := range CareStages {
var count int64
if err := s.db.Model(&model.CareTemplate{}).
Where("species = ? AND stage = ?", sp, stage).Count(&count).Error; err != nil {
return err
}
if count > 0 {
continue
}
tpl := model.CareTemplate{Species: sp, Stage: stage, Items: builtinCareItems(sp, stage)}
if err := s.db.Create(&tpl).Error; err != nil {
return err
@@ -193,6 +242,18 @@ func (s *Service) SeedCareTemplates() error {
return nil
}
// ResetCareTemplate 把某个组合恢复成内置默认(后台按钮触发)
func (s *Service) ResetCareTemplate(species, stage string) ([]model.CareTemplateItem, error) {
if !validSpecies(species) || !validStage(stage) {
return nil, errors.New("物种或阶段不合法")
}
items := builtinCareItems(species, stage)
if err := s.SaveCareTemplate(species, stage, items); err != nil {
return nil, err
}
return s.careTemplateItems(species, stage), nil
}
// ---- 明细构造小工具 ----
func careTask(title, desc, priority, sheet string) model.CareTemplateItem {
@@ -207,6 +268,18 @@ func careReminder(rtype, title string, offset int, freq string) model.CareTempla
// builtinCareItems 内置默认模板(猫/狗 × 4 阶段,共 8 套)。
// 兽医常识向:狗每个阶段都含遛狗/牵引,猫含猫砂盆/梳毛/饮水;疫苗驱虫节奏按物种阶段区分。
// builtinCareItems 内置默认养护模板(猫/狗 × 4 组,共 8 套)。
//
// 内容依据主流小动物临床共识(WSAVA 疫苗指南、ESCCAP/CAPC 驱虫建议、AAHA
// 生命阶段指南)与国内实际情况整理:
// - 猫三联(FVRCP) 幼猫共 3 针,8-9 周龄起,每针间隔 3-4 周,末针不早于 16 周龄
// - 犬联苗幼犬 2-3 针,6-8 周龄起,间隔 3-4 周,末针不早于 16 周龄;
// 狂犬 3 月龄首针、其后每年一次(国内为法定要求)
// - 体内驱虫:6 月龄前每月一次,之后每 3 个月一次;体外每月一次(随环境调整)
// - 猫 12 月龄成年、7 岁进入老年;犬 12 月龄成年(大型犬 18-24 月)、7 岁进入老年
//
// 「刚到家 0-30 天」不是年龄阶段,而是叠加包:建档时按年龄阶段取一套,
// 若到家不足 30 天,再把这一套的任务和计划叠加上去(见 seedPetDefaults)。
func builtinCareItems(species, stage string) []model.CareTemplateItem {
if species == model.SpeciesDog {
return builtinDog(stage)
@@ -216,59 +289,63 @@ func builtinCareItems(species, stage string) []model.CareTemplateItem {
func builtinCat(stage string) []model.CareTemplateItem {
switch stage {
case "刚到家 0-30 天":
case StageNewHome:
return []model.CareTemplateItem{
careTask("记录一次体重", "到家先称个底,之后每周一次看增长", "重要", "weight"),
careTask("清理猫砂盆并观察", "换环境应激常见软便,留意颜色和次数", "", "poop"),
careTask("陪玩 15 分钟", "逗猫棒建立信任,缓解应激,别强抱", "", ""),
carePlan(0, "今天", "安顿与环境适应", "猫砂盆、食盆、水碗、猫窝固定位置,给它安静空间", "taskDetail"),
carePlan(3, "第3天", "观察进食与排便", "食欲和便便恢复正常说明适应良好", "poop"),
carePlan(7, "第7天", "第一次体重记录", "作为后续趋势基线", "weight"),
carePlan(14, "第14天", "预约首次疫苗/体检", "确认医院、驱虫和免疫计划", "vaccine"),
careReminder("vaccine", "首次疫苗接种", 14, ""),
careReminder("deworm", "首次体内外驱虫", 7, ""),
careReminder("weight", "体重记录", 0, "每周一次"),
careTask("记录一次体重", "到家当天称一次做基线,之后每周一次看趋势", "重要", "weight"),
careTask("保持原来的猫粮", "换环境已经够折腾了,粮先别换;要换也要 7 天过渡", "重要", "food"),
careTask("清理猫砂盆并观察", "应激性软便很常见,超过 3 天不好转要就医", "", "poop"),
careTask("陪玩 15 分钟", "用逗猫棒隔着距离玩,头两天别强行抱它", "", ""),
carePlan(0, "天", "布置一个能躲的角落", "猫砂盆、食盆、水碗分开放;纸箱或猫窝给它安全感", ""),
carePlan(1, "第2天", "先只开放一个房间", "空间由小到大,全屋乱窜会加重应激", ""),
carePlan(3, "第3天", "观察食欲与排便", "肯吃、肯拉、肯出来走动,就是适应良好", "poop"),
carePlan(7, "第7天", "第一次正式称重", "作为之后所有体重趋势的基线", "weight"),
carePlan(14, "第14天", "预约首次体检与驱虫", "带一份新鲜粪便去,可以顺便查体内寄生虫", "vaccine"),
carePlan(30, "第30天", "确认免疫计划已排好", "把三针联苗的时间点定下来,别拖到成年", "vaccine"),
}
case StageJuvenile:
return []model.CareTemplateItem{
careTask("记录一次体重", "幼猫每周称 1-2 次,稳定增重比单次数字重要", "重要", "weight"),
careTask("定时定量喂食", "3-6 月龄每天 3-4 餐,6 月龄后 2-3 餐,自由采食容易过胖", "", "food"),
careTask("检查免疫进度", "猫三联共 3 针,每针间隔 3-4 周,末针不早于 16 周龄", "重要", "vaccine"),
careTask("陪玩与社会化", "每天 2 次各 10-15 分钟;多摸爪子和耳朵,长大好剪指甲", "", ""),
carePlan(0, "今天", "对照月龄核免疫和驱虫", "算清楚还差几针、下次驱虫什么时候", "vaccine"),
carePlan(3, "第3天", "换粮观察", "换粮按 7 天过渡,每天替换约 1/7,软便就放慢", "food"),
carePlan(7, "第7天", "体重趋势检查", "掉秤或长期不增重要查球虫、寄生虫", "weight"),
carePlan(14, "第14天", "体内外驱虫", "6 月龄前体内每月一次,体外按环境每月一次", "medicine"),
carePlan(21, "第21天", "养成梳毛和剪指甲的习惯", "从小做,成年后不抗拒;顺便摸一遍有没有硬块", ""),
carePlan(30, "第30天", "月度复盘 + 绝育咨询", "6-8 月龄可以和医生聊绝育时间", "report"),
careReminder("vaccine", "下一针猫三联", 21, "幼猫共 3 针,间隔 3-4 周"),
careReminder("deworm", "体内外驱虫", 30, "6 月龄前每月一次"),
careReminder("weight", "体重记录", 0, "每周 1-2 次"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
case "幼年期":
case StageSenior:
return []model.CareTemplateItem{
careTask("记录一次体重", "幼猫长得快,每周记 2 次", "重要", "weight"),
careTask("梳毛并陪玩", "每天梳毛+两次互动,帮助社会化、减少毛球", "", ""),
careTask("检查疫苗计划", "幼猫三针联苗按时打,别漏针", "重要", "vaccine"),
carePlan(0, "今天", "确认免疫与驱虫计划", "对照月龄核对疫苗针次和驱虫时间", "vaccine"),
carePlan(3, "第3天", "观察饮水与排便", "换粮期留意软便,7 天过渡换粮", "poop"),
carePlan(7, "第7天", "体重趋势检查", "每周称重,稳定增长是关键指标", "weight"),
carePlan(14, "第14天", "驱虫与饮食复盘", "确认驱虫到期、复盘换粮情况", "medicine"),
careReminder("vaccine", "第 2 针疫苗", 18, ""),
careReminder("deworm", "体内外驱虫", 30, ""),
careReminder("weight", "体重记录", 0, "每周二、周五"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
case "老年期":
return []model.CareTemplateItem{
careTask("记录一次体重", "老年猫消瘦要警惕,别忽视", "重要", "weight"),
careTask("关注饮水量与排尿", "多饮多尿常是肾病/糖尿信号,异常尽快就医", "重要", "symptom"),
careTask("清理猫砂盆并观察", "关注尿量、尿色和排尿是否困难", "", "poop"),
carePlan(0, "今天", "老年健康自查", "精神、食欲、饮水、体重整体过一遍", "taskDetail"),
carePlan(7, "第7天", "体重与饮水记录", "两项一起看,慢病早发现", "weight"),
carePlan(14, "第14天", "用药与慢病回顾", "整理长期用药和体检结果", "medicine"),
carePlan(30, "第30天", "月度报告 / 建议体检", "老年猫建议每半年一次体检", "report"),
careReminder("vaccine", "年度疫苗加强", 30, ""),
careReminder("deworm", "体内外驱虫", 90, ""),
careTask("记录一次体重", "老年猫掉秤常是甲亢或慢性肾病的第一个信号", "重要", "weight"),
careTask("关注饮水量与尿量", "又喝又尿是慢性肾病的典型表现,比食欲变化更早出现", "重要", "symptom"),
careTask("观察进食与口腔", "牙口疼会让它挑软食甚至不吃,别只当成挑嘴", "", "food"),
carePlan(0, "今天", "老年状态整体自查", "精神、食欲、饮水、体重、被毛过一遍", ""),
carePlan(7, "第7天", "体重与饮水一起记", "两条曲线放在一起看,慢病能早发现几个月", "weight"),
carePlan(14, "第14天", "整理用药与体检报告", "长期药别断,检查单留好方便对比", "medicine"),
carePlan(21, "第21天", "关节与活动观察", "跳不上高处、理毛变少、猫砂盆边缘迈不过去都要留意", "symptom"),
carePlan(30, "第30天", "安排半年一次体检", "老年猫建议每 6-12 个月一次,含血检和尿检", "report"),
careReminder("vaccine", "年度疫苗加强", 0, "每年一次"),
careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"),
careReminder("weight", "体重记录", 0, "每两周一次"),
careReminder("monthlyReport", "月度报告 / 半年体检", 0, "每月 1 日"),
}
default: // 成年期
return []model.CareTemplateItem{
careTask("记录一次体重", "成年猫每月一次,防止肥胖", "", "weight"),
careTask("清理猫砂盆并观察", "泌尿问题早发现,公猫尤其注意尿闭", "", "poop"),
careTask("梳毛与口腔检查", "关注毛球、牙结石和皮肤状态", "", ""),
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、饮水、被毛过一遍", "taskDetail"),
carePlan(7, "第7天", "体重趋势检查", "对比上月,稳定为宜", "weight"),
carePlan(14, "第14天", "驱虫记录复盘", "确认体内外驱虫是否到期", "medicine"),
careTask("清理猫砂盆并观察", "公猫尿闭是急症,24 小时排不出尿必须马上就医", "重要", "poop"),
careTask("检查饮水情况", "吃干粮更要多喝水;流动水或多放几个水碗都管用", "", "symptom"),
careTask("梳毛", "每周 2-3 次,减少毛球,也是摸有没有硬块的机会", "", ""),
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、饮水、被毛、口腔过一遍", ""),
carePlan(7, "第7天", "体重记录", "成年猫每月一次即可;超重是绝大多数慢病的起点", "weight"),
carePlan(14, "第14天", "口腔检查", "牙龈红肿、口臭明显,该洗牙了", "symptom"),
carePlan(21, "第21天", "体外驱虫", "只在室内也可能被带进来的跳蚤找上", "medicine"),
carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"),
careReminder("vaccine", "年度疫苗加强", 30, ""),
careReminder("deworm", "体内驱虫", 90, ""),
careReminder("vaccine", "年度疫苗加强", 0, "每年一次"),
careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"),
careReminder("weight", "体重记录", 0, "每月一次"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
@@ -277,61 +354,77 @@ func builtinCat(stage string) []model.CareTemplateItem {
func builtinDog(stage string) []model.CareTemplateItem {
switch stage {
case "刚到家 0-30 天":
case StageNewHome:
return []model.CareTemplateItem{
careTask("记录一次体重", "到家先称个底,之后每周一次", "重要", "weight"),
careTask("定点排便训练", "固定厕所位置,成功就表扬,别惩罚", "", "poop"),
careTask("室内活动 / 自家院子", "疫苗未打全前少去人多狗多的地方,先在家熟悉环境", "", ""),
carePlan(0, "今天", "安顿与环境适应", "食盆、水碗、狗窝、厕所固定位置", "taskDetail"),
carePlan(3, "第3天", "观察进食与排便", "应激期常见软便,留意精神状态", "poop"),
carePlan(7, "第7天", "第一次体重记录", "作为后续趋势基线", "weight"),
carePlan(14, "第14天", "预约疫苗 / 体检", "确认联苗、狂犬和驱虫计划", "vaccine"),
careReminder("vaccine", "首针疫苗接种", 14, ""),
careReminder("deworm", "首次体内外驱虫", 7, ""),
careReminder("weight", "体重记录", 0, "每周一次"),
careTask("记录一次体重", "到家当天称一次做基线,之后每周一次", "重要", "weight"),
careTask("保持原来的狗粮", "换粮和换环境别撞在一起;要换按 7 天过渡", "重要", "food"),
careTask("定点排便训练", "固定位置、成功就立刻表扬;事后惩罚只会让它躲着拉", "", "poop"),
careTask("先在室内熟悉环境", "疫苗没打全之前不要去公共遛狗区和宠物店", "", ""),
carePlan(0, "天", "安顿:食盆、水碗、窝、厕所", "位置固定下来,别频繁挪动", ""),
carePlan(1, "第2天", "适应牵引绳", "先在家戴着走两圈,出门前就该习惯", ""),
carePlan(3, "第3天", "观察进食与排便", "应激期软便常见,精神差、带血要就医", "poop"),
carePlan(7, "第7天", "第一次正式称重", "作为之后所有体重趋势的基线", "weight"),
carePlan(14, "第14天", "预约疫苗与体检", "确认联苗针次,3 月龄以上要安排狂犬", "vaccine"),
carePlan(30, "第30天", "免疫打全后开始户外散步", "从安静路段开始,逐步接触人、车、其它狗", ""),
}
case StageJuvenile:
return []model.CareTemplateItem{
careTask("牵引散步与社会化", "3-16 周是社会化关键期;疫苗未全时可抱着或在自家院子接触外界", "重要", ""),
careTask("记录一次体重", "幼犬长得快,每周称 1-2 次", "重要", "weight"),
careTask("定时定量喂食", "3-6 月龄每天 3 餐,6 月龄后 2 餐", "", "food"),
careTask("基础训练 10 分钟", "坐下、召回、不咬手;短时高频比一次练很久有效", "", ""),
carePlan(0, "今天", "核对免疫进度", "联苗 2-3 针间隔 3-4 周,3 月龄安排狂犬", "vaccine"),
carePlan(3, "第3天", "换粮观察", "7 天过渡,软便就放慢速度", "food"),
carePlan(7, "第7天", "体重趋势检查", "增长停滞要查寄生虫和喂食量", "weight"),
carePlan(14, "第14天", "体内外驱虫", "6 月龄前体内每月一次,体外每月一次", "medicine"),
carePlan(21, "第21天", "巩固定点排便与独处训练", "从 5 分钟开始拉长独处时间,预防分离焦虑", ""),
carePlan(30, "第30天", "月度复盘 + 绝育咨询", "小型犬 6-9 月龄,大型犬建议 12-18 月龄再做,等骨骼长好", "report"),
careReminder("vaccine", "下一针联苗 / 狂犬", 21, "联苗间隔 3-4 周,3 月龄起狂犬"),
careReminder("deworm", "体内外驱虫", 30, "6 月龄前每月一次"),
careReminder("weight", "体重记录", 0, "每周 1-2 次"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
case "幼年期":
case StageSenior:
return []model.CareTemplateItem{
careTask("遛狗与基础训练", "每天 2 次,牵引、坐下、召回,社会化关键期", "重要", ""),
careTask("记录一次体重", "幼犬长得快,每周记 2 次", "重要", "weight"),
careTask("观察饮水与排便", "换粮、驱虫期留意软便", "", "poop"),
carePlan(0, "今天", "确认免疫与驱虫计划", "联苗+狂犬按月龄打,别漏针", "vaccine"),
carePlan(1, "天", "定点排便 + 牵引训练", "打好如厕和牵引基础", "taskDetail"),
carePlan(7, "第7天", "体重趋势检查", "每周称重,稳定增长", "weight"),
carePlan(14, "第14天", "驱虫与社会化复盘", "确认驱虫到期、多接触人和环境", "medicine"),
careReminder("vaccine", "下一针联苗", 18, ""),
careReminder("deworm", "体内外驱虫", 30, ""),
careReminder("weight", "体重记录", 0, "每周二、周五"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
case "老年期":
return []model.CareTemplateItem{
careTask("温和遛狗", "缩短时长、避免剧烈跳跃,保护关节", "重要", ""),
careTask("记录一次体重", "骤增骤减都要重视", "重要", "weight"),
careTask("观察关节 / 精神 / 饮水", "后腿无力、多饮多尿要及时就医", "", "symptom"),
carePlan(0, "今天", "老年健康自查", "精神、食欲、饮水、关节活动一起看", "taskDetail"),
carePlan(7, "第7天", "体重与饮水记录", "慢病早发现", "weight"),
carePlan(14, "第14天", "用药与慢病回顾", "整理长期用药和体检结果", "medicine"),
carePlan(30, "第30天", "月度报告 / 建议体检", "老年犬建议每半年一次体检", "report"),
careReminder("vaccine", "年度疫苗 + 狂犬加强", 30, ""),
careReminder("deworm", "体内外驱虫", 60, ""),
careTask("温和遛狗", "缩短单次时长、增加次数;避免跳跃和频繁爬楼梯", "重要", ""),
careTask("记录一次体重", "骤增骤减都要重视,老年犬掉秤要查甲状腺和肿瘤", "重要", "weight"),
careTask("观察关节与精神", "起身困难、后腿打滑、不愿上车,都是关节问题的早期表现", "", "symptom"),
carePlan(0, "今天", "老年状态整体自查", "精神、食欲、饮水、关节活动一起看", ""),
carePlan(7, "第7天", "体重与饮水一起记", "多饮多尿要查肾和血糖", "weight"),
carePlan(14, "第14天", "整理用药与体检报告", "长期药别断,检查单留好方便对比", "medicine"),
carePlan(21, "第21天", "口腔与体表触诊", "牙结石、乳腺和体表肿物,摸一遍心里有数", "symptom"),
carePlan(30, "第30天", "安排半年一次体检", "老年犬建议每 6 个月一次,含血检和胸腹部检查", "report"),
careReminder("vaccine", "年度疫苗 + 狂犬加强", 0, "每年一次,狂犬为法定要求"),
careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"),
careReminder("weight", "体重记录", 0, "每两周一次"),
careReminder("monthlyReport", "月度报告 / 半年体检", 0, "每月 1 日"),
}
default: // 成年期
return []model.CareTemplateItem{
careTask("遛狗", "每天 2 次共 30-60 分钟,消耗精力、防拆家", "重要", ""),
careTask("记录一次体重", "每月一次,控制体重防肥胖", "", "weight"),
careTask("刷牙 / 口腔检查", "每周 2-3 次,狗牙结石高发", "", ""),
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、被毛、指甲过一遍", "taskDetail"),
carePlan(7, "第7天", "体重趋势检查", "对比上月,稳定为宜", "weight"),
carePlan(14, "第14天", "驱虫记录复盘", "遛狗接触多,确认驱虫是否到期", "medicine"),
careTask("遛狗", "每天 2 次共 30-60 分钟;运动量不够是拆家和吠叫的头号原因", "重要", ""),
careTask("刷牙", "每周 2-3 次。3 岁以上的犬多数已有牙周问题", "", ""),
careTask("记录一次体重", "每月一次。摸得到肋骨但看不见,是标准体型", "", "weight"),
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、被毛、指甲、耳道过一遍", ""),
carePlan(7, "第7天", "体重记录", "对比上月,稳定为宜", "weight"),
carePlan(14, "第14天", "体外驱虫", "遛狗接触草地,蜱虫和跳蚤风险比猫高得多", "medicine"),
carePlan(21, "第21天", "指甲与耳道检查", "指甲过长会影响步态;垂耳犬更容易耳道发炎", "symptom"),
carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"),
careReminder("vaccine", "年度疫苗 + 狂犬加强", 30, ""),
careReminder("deworm", "体内驱虫", 60, ""),
careReminder("vaccine", "年度疫苗 + 狂犬加强", 0, "每年一次,狂犬为法定要求"),
careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"),
careReminder("weight", "体重记录", 0, "每月一次"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
}
}
// NormalizeStage 定下最终使用的年龄阶段。
// 用户手选优先(他可能比生日更清楚,比如领养来的估算年龄),
// 但只认三个年龄阶段;传了「刚到家」或空值时,一律按生日推断。
func NormalizeStage(species, picked string, birthday *time.Time) string {
for _, s := range AgeStages {
if picked == s {
return picked
}
}
return StageFromBirthday(species, birthday)
}
+156 -12
View File
@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"strings"
"time"
@@ -12,14 +13,15 @@ import (
// PetInput 建/改宠物入参
type PetInput struct {
Name string
Emoji string
Type string
Gender string
Birthday *time.Time
Weight string
Stage string
Age string
Name string
Emoji string
Type string
Gender string
Birthday *time.Time
ArrivedAt *time.Time
Weight string
Stage string
Age string
Color string
Breed string
AvatarFileID string
@@ -70,6 +72,7 @@ func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) {
Type: in.Type,
Gender: in.Gender,
Birthday: in.Birthday,
ArrivedAt: in.ArrivedAt,
Weight: normalizeWeight(in.Weight),
Stage: in.Stage,
Age: in.Age,
@@ -145,7 +148,32 @@ func (s *Service) Onboarding(userID string, in PetInput) (*model.Pet, error) {
// seedPetDefaults 为新宠按「物种 × 阶段」养护模板生成今日任务、提醒、30 天计划
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
today := time.Now()
items := s.careTemplateItems(SpeciesOf(pet.Type), pet.Stage)
species := SpeciesOf(pet.Type)
items := s.careTemplateItems(species, pet.Stage)
// 刚到家不到 30 天,把安置期那套的任务和计划叠加上来。
// 「刚到家」和「幼年/成年/老年」是两个正交的维度,原来挤在一个四选一里,
// 结果刚接回家的成年猫只能二选一,要么丢应激期照护、要么丢年龄段照护。
if isNewHome(pet.ArrivedAt, today) && pet.Stage != StageNewHome {
seen := map[string]bool{}
for _, it := range items {
seen[it.Kind+"|"+it.Title] = true
}
for _, it := range s.careTemplateItems(species, StageNewHome) {
if it.Kind != model.CareKindTask && it.Kind != model.CareKindPlan {
continue
}
// 两套模板会撞题(比如都有「清理猫砂盆并观察」),撞了就跳过,
// 否则用户第一天会看到两条一模一样的任务
if seen[it.Kind+"|"+it.Title] {
continue
}
seen[it.Kind+"|"+it.Title] = true
items = append(items, it)
}
}
goals := petGoals(pet)
var tasks []model.DailyTask
var reminders []model.Reminder
@@ -162,17 +190,28 @@ func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
Day: it.Day, DayLabel: it.DayLabel, Title: it.Title, Description: it.Description, SheetType: it.SheetType,
})
case model.CareKindReminder:
// 建档时勾的目标决定要不要建这条提醒。原来这几个勾选框收上来只是
// 存进 pets.goals,一处都没用过,等于让用户点了个寂寞。
if !goalWantsReminder(goals, it.ReminderType) {
continue
}
r := model.Reminder{
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
Title: it.Title, Frequency: it.Frequency,
Title: reminderTitle(it, pet), Frequency: it.Frequency,
}
if it.OffsetDays > 0 {
due := today.AddDate(0, 0, it.OffsetDays)
if days := dueDaysFor(it, pet, today); days > 0 {
due := today.AddDate(0, 0, days)
r.NextDueDate = &due
}
reminders = append(reminders, r)
}
}
if wantsGoal(goals, "开销") {
tasks = append(tasks, model.DailyTask{
PetID: pet.ID, UserID: pet.UserID, TaskDate: today,
Title: "记一笔养宠开销", Description: "粮、猫砂、医疗都记上,月底能看到花在哪", SheetType: "cost",
})
}
if len(tasks) > 0 {
if err := tx.Create(&tasks).Error; err != nil {
@@ -194,3 +233,108 @@ func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
}
return tx.Create(&plan).Error
}
// ---- 建档默认值的几个判断 ----
// isNewHome 到家不足 30 天。没填到家日期的按「不是新到家」处理,
// 宁可少排几条安置期任务,也不要给养了三年的狗排「先只开放一个房间」。
func isNewHome(arrivedAt *time.Time, now time.Time) bool {
if arrivedAt == nil || arrivedAt.IsZero() {
return false
}
return now.Sub(*arrivedAt) < 30*24*time.Hour && !arrivedAt.After(now)
}
// petGoals 取建档时勾选的目标
func petGoals(pet *model.Pet) []string {
if len(pet.Goals) == 0 {
return nil
}
var goals []string
if err := json.Unmarshal(pet.Goals, &goals); err != nil {
return nil
}
return goals
}
func wantsGoal(goals []string, keyword string) bool {
for _, g := range goals {
if strings.Contains(g, keyword) {
return true
}
}
return false
}
// goalWantsReminder 目标 → 该不该建这类提醒。
// 一条都没勾(比如从「添加宠物」弹层建的档)时全部建,不做减法。
func goalWantsReminder(goals []string, reminderType string) bool {
if len(goals) == 0 {
return true
}
switch reminderType {
case model.ReminderVaccine, model.ReminderDeworm:
// 疫苗和驱虫不给关。狂犬是法定要求,漏驱虫是真会出事的,
// 不能因为用户建档时没勾「疫苗驱虫怕忘记」就干脆不提醒。
return true
case model.ReminderWeight:
return wantsGoal(goals, "体重") || wantsGoal(goals, "健康")
case model.ReminderMonthlyReport:
return wantsGoal(goals, "报告")
}
return true
}
// dueDaysFor 算这条提醒几天后到期。
// 模板里的 OffsetDays 是「典型情况」的天数,疫苗和驱虫必须按真实月龄修正——
// 原来不管多大都排「14 天后首针」,一只 8 月龄才接回家的猫会被安排去打首免。
func dueDaysFor(it model.CareTemplateItem, pet *model.Pet, now time.Time) int {
months := -1
if pet.Birthday != nil && !pet.Birthday.IsZero() {
months = monthsSince(*pet.Birthday)
}
switch it.ReminderType {
case model.ReminderVaccine:
if months < 0 {
return it.OffsetDays // 不知道月龄就按模板给的典型值
}
switch {
case months < 2:
// 首免 8 周龄起。还没到就等到那天,已经到了就尽快约。
if d := 56 - int(now.Sub(*pet.Birthday).Hours()/24); d > 0 {
return d
}
return 7
case months < 5:
return 21 // 幼崽针次之间隔 3-4 周
case months < 12:
return 14 // 月龄偏大但多半没打全,两周内补齐
default:
return it.OffsetDays // 成年后走模板:年度加强用频率而非到期日
}
case model.ReminderDeworm:
if months >= 0 && months < 6 {
return 30 // 6 月龄前每月一次
}
return it.OffsetDays
}
return it.OffsetDays
}
// reminderTitle 修正提醒标题。
// 阶段是用户可以手改的,改错了模板就会给一只 3 月龄的幼崽发「年度疫苗加强」。
// 到期日已经按月龄算过(见 dueDaysFor),标题也得跟上,否则用户照着标题去打错针。
func reminderTitle(it model.CareTemplateItem, pet *model.Pet) string {
if it.ReminderType != model.ReminderVaccine || pet.Birthday == nil || pet.Birthday.IsZero() {
return it.Title
}
months := monthsSince(*pet.Birthday)
if months >= 12 {
return it.Title
}
if months < 2 {
return "首针疫苗接种"
}
return "下一针疫苗(按月龄安排)"
}
+5
View File
@@ -127,6 +127,11 @@ export const api = {
}),
saveCareTemplate: (species: string, stage: string, items: any[]) =>
http.put<any, { items: any[] }>('/admin/care-templates', { species, stage, items }),
resetCareTemplate: (species: string, stage: string) =>
http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/reset', {
species,
stage,
}),
genCareTemplate: (species: string, stage: string) =>
http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/generate', {
species,
+22 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Cat, Dog, Wand2, Plus, Trash2, Save, Info } from 'lucide-react'
import { Cat, Dog, Wand2, Plus, Trash2, Save, Info, RotateCcw } from 'lucide-react'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -106,6 +106,24 @@ export default function CareTemplates() {
setDirty(true)
}
// 恢复内置默认。启动时的 seed 只补缺失、不覆盖已有,
// 所以内置模板更新后必须从这里显式推一次
async function reset() {
if (!confirm(`把【${species === 'dog' ? '狗' : '猫'} · ${stage}】恢复成内置默认?当前改动会被覆盖。`)) return
setGen(true)
setMsg('')
try {
const data = await api.resetCareTemplate(species, stage)
setItems(normalize(data.items))
setDirty(false)
setMsg('已恢复为内置默认模板')
} catch (e: any) {
setMsg(e.message || '恢复失败')
} finally {
setGen(false)
}
}
async function generate() {
setGen(true)
setMsg('')
@@ -149,6 +167,9 @@ export default function CareTemplates() {
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={reset} disabled={gen}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={generate} disabled={gen}>
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 生成草稿'}
</Button>