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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>admin</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-BjLxu16U.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -325,6 +325,23 @@ func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
|
|||||||
response.OK(c, data)
|
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 {
|
type careGenReq struct {
|
||||||
Species string `json:"species"`
|
Species string `json:"species"`
|
||||||
Stage string `json:"stage"`
|
Stage string `json:"stage"`
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type petReq struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Gender string `json:"gender"`
|
Gender string `json:"gender"`
|
||||||
Birthday string `json:"birthday"` // YYYY-MM-DD
|
Birthday string `json:"birthday"` // YYYY-MM-DD
|
||||||
|
ArrivedAt string `json:"arrived_at"` // YYYY-MM-DD,到家日期
|
||||||
Weight string `json:"weight"`
|
Weight string `json:"weight"`
|
||||||
Stage string `json:"stage"`
|
Stage string `json:"stage"`
|
||||||
Age string `json:"age"`
|
Age string `json:"age"`
|
||||||
@@ -34,9 +35,20 @@ func (r petReq) toInput() service.PetInput {
|
|||||||
}
|
}
|
||||||
if r.Birthday != "" {
|
if r.Birthday != "" {
|
||||||
if t, err := time.Parse("2006-01-02", r.Birthday); err == nil {
|
if t, err := time.Parse("2006-01-02", r.Birthday); err == nil {
|
||||||
|
// 生日不接受未来日期:前端限制过一道,服务端不能只信前端
|
||||||
|
if !t.After(time.Now()) {
|
||||||
in.Birthday = &t
|
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 r.Goals != nil {
|
||||||
if b, err := jsonMarshal(r.Goals); err == nil {
|
if b, err := jsonMarshal(r.Goals); err == nil {
|
||||||
in.Goals = datatypes.JSON(b)
|
in.Goals = datatypes.JSON(b)
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ type Pet struct {
|
|||||||
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
|
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
|
||||||
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
|
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
|
||||||
Birthday *time.Time `json:"birthday"`
|
Birthday *time.Time `json:"birthday"`
|
||||||
|
ArrivedAt *time.Time `json:"arrived_at"` // 到家日期。30 天内会额外叠加一套安置期任务
|
||||||
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
|
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"`
|
Age string `gorm:"size:32" json:"age"`
|
||||||
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
|
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
|
||||||
Breed string `gorm:"size:64" json:"breed"` // 品种
|
Breed string `gorm:"size:64" json:"breed"` // 品种
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
|
|||||||
g.GET("/care-templates", h.AdminGetCareTemplate)
|
g.GET("/care-templates", h.AdminGetCareTemplate)
|
||||||
g.PUT("/care-templates", h.AdminSaveCareTemplate)
|
g.PUT("/care-templates", h.AdminSaveCareTemplate)
|
||||||
g.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
|
g.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
|
||||||
|
g.POST("/care-templates/reset", h.AdminResetCareTemplate)
|
||||||
|
|
||||||
g.GET("/community-bot", h.AdminGetCommunityBot)
|
g.GET("/community-bot", h.AdminGetCommunityBot)
|
||||||
g.PUT("/community-bot", h.AdminSaveCommunityBot)
|
g.PUT("/community-bot", h.AdminSaveCommunityBot)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
@@ -12,9 +13,54 @@ import (
|
|||||||
"github.com/sundynix/pets-be/internal/model"
|
"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 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 由宠物类型(猫猫/狗狗)映射到模板物种键
|
// SpeciesOf 由宠物类型(猫猫/狗狗)映射到模板物种键
|
||||||
func SpeciesOf(petType string) string {
|
func SpeciesOf(petType string) string {
|
||||||
@@ -174,16 +220,19 @@ func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SeedCareTemplates 启动幂等 seed:库中无任何模板时写入 8 套内置默认
|
// SeedCareTemplates 启动幂等 seed:库中无任何模板时写入 8 套内置默认
|
||||||
|
// 按组合补齐:只创建缺失的那几套,已有的(可能被管理员改过)一律不动。
|
||||||
|
// 想把内置默认推到已有组合上,走后台的「恢复默认」——那是显式操作,不该由启动时偷偷覆盖。
|
||||||
func (s *Service) SeedCareTemplates() error {
|
func (s *Service) SeedCareTemplates() error {
|
||||||
|
for _, sp := range CareSpecies {
|
||||||
|
for _, stage := range CareStages {
|
||||||
var count int64
|
var count int64
|
||||||
if err := s.db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
|
if err := s.db.Model(&model.CareTemplate{}).
|
||||||
|
Where("species = ? AND stage = ?", sp, stage).Count(&count).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return nil
|
continue
|
||||||
}
|
}
|
||||||
for _, sp := range CareSpecies {
|
|
||||||
for _, stage := range CareStages {
|
|
||||||
tpl := model.CareTemplate{Species: sp, Stage: stage, Items: builtinCareItems(sp, stage)}
|
tpl := model.CareTemplate{Species: sp, Stage: stage, Items: builtinCareItems(sp, stage)}
|
||||||
if err := s.db.Create(&tpl).Error; err != nil {
|
if err := s.db.Create(&tpl).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -193,6 +242,18 @@ func (s *Service) SeedCareTemplates() error {
|
|||||||
return nil
|
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 {
|
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 套)。
|
||||||
// 兽医常识向:狗每个阶段都含遛狗/牵引,猫含猫砂盆/梳毛/饮水;疫苗驱虫节奏按物种阶段区分。
|
// 兽医常识向:狗每个阶段都含遛狗/牵引,猫含猫砂盆/梳毛/饮水;疫苗驱虫节奏按物种阶段区分。
|
||||||
|
// 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 {
|
func builtinCareItems(species, stage string) []model.CareTemplateItem {
|
||||||
if species == model.SpeciesDog {
|
if species == model.SpeciesDog {
|
||||||
return builtinDog(stage)
|
return builtinDog(stage)
|
||||||
@@ -216,59 +289,63 @@ func builtinCareItems(species, stage string) []model.CareTemplateItem {
|
|||||||
|
|
||||||
func builtinCat(stage string) []model.CareTemplateItem {
|
func builtinCat(stage string) []model.CareTemplateItem {
|
||||||
switch stage {
|
switch stage {
|
||||||
case "刚到家 0-30 天":
|
case StageNewHome:
|
||||||
return []model.CareTemplateItem{
|
return []model.CareTemplateItem{
|
||||||
careTask("记录一次体重", "到家先称个底,之后每周一次看增长", "重要", "weight"),
|
careTask("记录一次体重", "到家当天称一次做基线,之后每周一次看趋势", "重要", "weight"),
|
||||||
careTask("清理猫砂盆并观察", "换环境应激常见软便,留意颜色和次数", "", "poop"),
|
careTask("保持原来的猫粮", "换环境已经够折腾了,粮先别换;要换也要 7 天过渡", "重要", "food"),
|
||||||
careTask("陪玩 15 分钟", "逗猫棒建立信任,缓解应激,别强抱", "", ""),
|
careTask("清理猫砂盆并观察", "应激性软便很常见,超过 3 天不好转要就医", "", "poop"),
|
||||||
carePlan(0, "今天", "安顿与环境适应", "猫砂盆、食盆、水碗、猫窝固定位置,给它安静空间", "taskDetail"),
|
careTask("陪玩 15 分钟", "用逗猫棒隔着距离玩,头两天别强行抱它", "", ""),
|
||||||
carePlan(3, "第3天", "观察进食与排便", "食欲和便便恢复正常说明适应良好", "poop"),
|
carePlan(0, "今天", "布置一个能躲的角落", "猫砂盆、食盆、水碗分开放;纸箱或猫窝给它安全感", ""),
|
||||||
carePlan(7, "第7天", "第一次体重记录", "作为后续趋势基线", "weight"),
|
carePlan(1, "第2天", "先只开放一个房间", "空间由小到大,全屋乱窜会加重应激", ""),
|
||||||
carePlan(14, "第14天", "预约首次疫苗/体检", "确认医院、驱虫和免疫计划", "vaccine"),
|
carePlan(3, "第3天", "观察食欲与排便", "肯吃、肯拉、肯出来走动,就是适应良好", "poop"),
|
||||||
careReminder("vaccine", "首次疫苗接种", 14, ""),
|
carePlan(7, "第7天", "第一次正式称重", "作为之后所有体重趋势的基线", "weight"),
|
||||||
careReminder("deworm", "首次体内外驱虫", 7, ""),
|
carePlan(14, "第14天", "预约首次体检与驱虫", "带一份新鲜粪便去,可以顺便查体内寄生虫", "vaccine"),
|
||||||
careReminder("weight", "体重记录", 0, "每周一次"),
|
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 日"),
|
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
|
||||||
}
|
}
|
||||||
case "幼年期":
|
case StageSenior:
|
||||||
return []model.CareTemplateItem{
|
return []model.CareTemplateItem{
|
||||||
careTask("记录一次体重", "幼猫长得快,每周记 2 次", "重要", "weight"),
|
careTask("记录一次体重", "老年猫掉秤常是甲亢或慢性肾病的第一个信号", "重要", "weight"),
|
||||||
careTask("梳毛并陪玩", "每天梳毛+两次互动,帮助社会化、减少毛球", "", ""),
|
careTask("关注饮水量与尿量", "又喝又尿是慢性肾病的典型表现,比食欲变化更早出现", "重要", "symptom"),
|
||||||
careTask("检查疫苗计划", "幼猫三针联苗按时打,别漏针", "重要", "vaccine"),
|
careTask("观察进食与口腔", "牙口疼会让它挑软食甚至不吃,别只当成挑嘴", "", "food"),
|
||||||
carePlan(0, "今天", "确认免疫与驱虫计划", "对照月龄核对疫苗针次和驱虫时间", "vaccine"),
|
carePlan(0, "今天", "老年状态整体自查", "精神、食欲、饮水、体重、被毛过一遍", ""),
|
||||||
carePlan(3, "第3天", "观察饮水与排便", "换粮期留意软便,7 天过渡换粮", "poop"),
|
carePlan(7, "第7天", "体重与饮水一起记", "两条曲线放在一起看,慢病能早发现几个月", "weight"),
|
||||||
carePlan(7, "第7天", "体重趋势检查", "每周称重,稳定增长是关键指标", "weight"),
|
carePlan(14, "第14天", "整理用药与体检报告", "长期药别断,检查单留好方便对比", "medicine"),
|
||||||
carePlan(14, "第14天", "驱虫与饮食复盘", "确认驱虫到期、复盘换粮情况", "medicine"),
|
carePlan(21, "第21天", "关节与活动观察", "跳不上高处、理毛变少、猫砂盆边缘迈不过去都要留意", "symptom"),
|
||||||
careReminder("vaccine", "第 2 针疫苗", 18, ""),
|
carePlan(30, "第30天", "安排半年一次体检", "老年猫建议每 6-12 个月一次,含血检和尿检", "report"),
|
||||||
careReminder("deworm", "体内外驱虫", 30, ""),
|
careReminder("vaccine", "年度疫苗加强", 0, "每年一次"),
|
||||||
careReminder("weight", "体重记录", 0, "每周二、周五"),
|
careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"),
|
||||||
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, ""),
|
|
||||||
careReminder("weight", "体重记录", 0, "每两周一次"),
|
careReminder("weight", "体重记录", 0, "每两周一次"),
|
||||||
careReminder("monthlyReport", "月度报告 / 半年体检", 0, "每月 1 日"),
|
careReminder("monthlyReport", "月度报告 / 半年体检", 0, "每月 1 日"),
|
||||||
}
|
}
|
||||||
default: // 成年期
|
default: // 成年期
|
||||||
return []model.CareTemplateItem{
|
return []model.CareTemplateItem{
|
||||||
careTask("记录一次体重", "成年猫每月一次,防止肥胖", "", "weight"),
|
careTask("清理猫砂盆并观察", "公猫尿闭是急症,24 小时排不出尿必须马上就医", "重要", "poop"),
|
||||||
careTask("清理猫砂盆并观察", "泌尿问题早发现,公猫尤其注意尿闭", "", "poop"),
|
careTask("检查饮水情况", "吃干粮更要多喝水;流动水或多放几个水碗都管用", "", "symptom"),
|
||||||
careTask("梳毛与口腔检查", "关注毛球、牙结石和皮肤状态", "", ""),
|
careTask("梳毛", "每周 2-3 次,减少毛球,也是摸有没有硬块的机会", "", ""),
|
||||||
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、饮水、被毛过一遍", "taskDetail"),
|
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、饮水、被毛、口腔过一遍", ""),
|
||||||
carePlan(7, "第7天", "体重趋势检查", "对比上月,稳定为宜", "weight"),
|
carePlan(7, "第7天", "体重记录", "成年猫每月一次即可;超重是绝大多数慢病的起点", "weight"),
|
||||||
carePlan(14, "第14天", "驱虫记录复盘", "确认体内外驱虫是否到期", "medicine"),
|
carePlan(14, "第14天", "口腔检查", "牙龈红肿、口臭明显,该洗牙了", "symptom"),
|
||||||
|
carePlan(21, "第21天", "体外驱虫", "只在室内也可能被带进来的跳蚤找上", "medicine"),
|
||||||
carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"),
|
carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"),
|
||||||
careReminder("vaccine", "年度疫苗加强", 30, ""),
|
careReminder("vaccine", "年度疫苗加强", 0, "每年一次"),
|
||||||
careReminder("deworm", "体内外驱虫", 90, ""),
|
careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"),
|
||||||
careReminder("weight", "体重记录", 0, "每月一次"),
|
careReminder("weight", "体重记录", 0, "每月一次"),
|
||||||
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
|
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
|
||||||
}
|
}
|
||||||
@@ -277,61 +354,77 @@ func builtinCat(stage string) []model.CareTemplateItem {
|
|||||||
|
|
||||||
func builtinDog(stage string) []model.CareTemplateItem {
|
func builtinDog(stage string) []model.CareTemplateItem {
|
||||||
switch stage {
|
switch stage {
|
||||||
case "刚到家 0-30 天":
|
case StageNewHome:
|
||||||
return []model.CareTemplateItem{
|
return []model.CareTemplateItem{
|
||||||
careTask("记录一次体重", "到家先称个底,之后每周一次", "重要", "weight"),
|
careTask("记录一次体重", "到家当天称一次做基线,之后每周一次", "重要", "weight"),
|
||||||
careTask("定点排便训练", "固定厕所位置,成功就表扬,别惩罚", "", "poop"),
|
careTask("保持原来的狗粮", "换粮和换环境别撞在一起;要换按 7 天过渡", "重要", "food"),
|
||||||
careTask("室内活动 / 自家院子", "疫苗未打全前少去人多狗多的地方,先在家熟悉环境", "", ""),
|
careTask("定点排便训练", "固定位置、成功就立刻表扬;事后惩罚只会让它躲着拉", "", "poop"),
|
||||||
carePlan(0, "今天", "安顿与环境适应", "食盆、水碗、狗窝、厕所固定位置", "taskDetail"),
|
careTask("先在室内熟悉环境", "疫苗没打全之前不要去公共遛狗区和宠物店", "", ""),
|
||||||
carePlan(3, "第3天", "观察进食与排便", "应激期常见软便,留意精神状态", "poop"),
|
carePlan(0, "今天", "安顿:食盆、水碗、窝、厕所", "位置固定下来,别频繁挪动", ""),
|
||||||
carePlan(7, "第7天", "第一次体重记录", "作为后续趋势基线", "weight"),
|
carePlan(1, "第2天", "适应牵引绳", "先在家戴着走两圈,出门前就该习惯", ""),
|
||||||
carePlan(14, "第14天", "预约疫苗 / 体检", "确认联苗、狂犬和驱虫计划", "vaccine"),
|
carePlan(3, "第3天", "观察进食与排便", "应激期软便常见,精神差、带血要就医", "poop"),
|
||||||
careReminder("vaccine", "首针疫苗接种", 14, ""),
|
carePlan(7, "第7天", "第一次正式称重", "作为之后所有体重趋势的基线", "weight"),
|
||||||
careReminder("deworm", "首次体内外驱虫", 7, ""),
|
carePlan(14, "第14天", "预约疫苗与体检", "确认联苗针次,3 月龄以上要安排狂犬", "vaccine"),
|
||||||
careReminder("weight", "体重记录", 0, "每周一次"),
|
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 日"),
|
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
|
||||||
}
|
}
|
||||||
case "幼年期":
|
case StageSenior:
|
||||||
return []model.CareTemplateItem{
|
return []model.CareTemplateItem{
|
||||||
careTask("遛狗与基础训练", "每天 2 次,牵引、坐下、召回,社会化关键期", "重要", ""),
|
careTask("温和遛狗", "缩短单次时长、增加次数;避免跳跃和频繁爬楼梯", "重要", ""),
|
||||||
careTask("记录一次体重", "幼犬长得快,每周记 2 次", "重要", "weight"),
|
careTask("记录一次体重", "骤增骤减都要重视,老年犬掉秤要查甲状腺和肿瘤", "重要", "weight"),
|
||||||
careTask("观察饮水与排便", "换粮、驱虫期留意软便", "", "poop"),
|
careTask("观察关节与精神", "起身困难、后腿打滑、不愿上车,都是关节问题的早期表现", "", "symptom"),
|
||||||
carePlan(0, "今天", "确认免疫与驱虫计划", "联苗+狂犬按月龄打,别漏针", "vaccine"),
|
carePlan(0, "今天", "老年状态整体自查", "精神、食欲、饮水、关节活动一起看", ""),
|
||||||
carePlan(1, "明天", "定点排便 + 牵引训练", "打好如厕和牵引基础", "taskDetail"),
|
carePlan(7, "第7天", "体重与饮水一起记", "多饮多尿要查肾和血糖", "weight"),
|
||||||
carePlan(7, "第7天", "体重趋势检查", "每周称重,稳定增长", "weight"),
|
carePlan(14, "第14天", "整理用药与体检报告", "长期药别断,检查单留好方便对比", "medicine"),
|
||||||
carePlan(14, "第14天", "驱虫与社会化复盘", "确认驱虫到期、多接触人和环境", "medicine"),
|
carePlan(21, "第21天", "口腔与体表触诊", "牙结石、乳腺和体表肿物,摸一遍心里有数", "symptom"),
|
||||||
careReminder("vaccine", "下一针联苗", 18, ""),
|
carePlan(30, "第30天", "安排半年一次体检", "老年犬建议每 6 个月一次,含血检和胸腹部检查", "report"),
|
||||||
careReminder("deworm", "体内外驱虫", 30, ""),
|
careReminder("vaccine", "年度疫苗 + 狂犬加强", 0, "每年一次,狂犬为法定要求"),
|
||||||
careReminder("weight", "体重记录", 0, "每周二、周五"),
|
careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"),
|
||||||
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, ""),
|
|
||||||
careReminder("weight", "体重记录", 0, "每两周一次"),
|
careReminder("weight", "体重记录", 0, "每两周一次"),
|
||||||
careReminder("monthlyReport", "月度报告 / 半年体检", 0, "每月 1 日"),
|
careReminder("monthlyReport", "月度报告 / 半年体检", 0, "每月 1 日"),
|
||||||
}
|
}
|
||||||
default: // 成年期
|
default: // 成年期
|
||||||
return []model.CareTemplateItem{
|
return []model.CareTemplateItem{
|
||||||
careTask("遛狗", "每天 2 次共 30-60 分钟,消耗精力、防拆家", "重要", ""),
|
careTask("遛狗", "每天 2 次共 30-60 分钟;运动量不够是拆家和吠叫的头号原因", "重要", ""),
|
||||||
careTask("记录一次体重", "每月一次,控制体重防肥胖", "", "weight"),
|
careTask("刷牙", "每周 2-3 次。3 岁以上的犬多数已有牙周问题", "", ""),
|
||||||
careTask("刷牙 / 口腔检查", "每周 2-3 次,狗牙结石高发", "", ""),
|
careTask("记录一次体重", "每月一次。摸得到肋骨但看不见,是标准体型", "", "weight"),
|
||||||
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、被毛、指甲过一遍", "taskDetail"),
|
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、被毛、指甲、耳道过一遍", ""),
|
||||||
carePlan(7, "第7天", "体重趋势检查", "对比上月,稳定为宜", "weight"),
|
carePlan(7, "第7天", "体重记录", "对比上月,稳定为宜", "weight"),
|
||||||
carePlan(14, "第14天", "驱虫记录复盘", "遛狗接触多,确认驱虫是否到期", "medicine"),
|
carePlan(14, "第14天", "体外驱虫", "遛狗接触草地,蜱虫和跳蚤风险比猫高得多", "medicine"),
|
||||||
|
carePlan(21, "第21天", "指甲与耳道检查", "指甲过长会影响步态;垂耳犬更容易耳道发炎", "symptom"),
|
||||||
carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"),
|
carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"),
|
||||||
careReminder("vaccine", "年度疫苗 + 狂犬加强", 30, ""),
|
careReminder("vaccine", "年度疫苗 + 狂犬加强", 0, "每年一次,狂犬为法定要求"),
|
||||||
careReminder("deworm", "体内外驱虫", 60, ""),
|
careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"),
|
||||||
careReminder("weight", "体重记录", 0, "每月一次"),
|
careReminder("weight", "体重记录", 0, "每月一次"),
|
||||||
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ type PetInput struct {
|
|||||||
Type string
|
Type string
|
||||||
Gender string
|
Gender string
|
||||||
Birthday *time.Time
|
Birthday *time.Time
|
||||||
|
ArrivedAt *time.Time
|
||||||
Weight string
|
Weight string
|
||||||
Stage string
|
Stage string
|
||||||
Age string
|
Age string
|
||||||
@@ -70,6 +72,7 @@ func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) {
|
|||||||
Type: in.Type,
|
Type: in.Type,
|
||||||
Gender: in.Gender,
|
Gender: in.Gender,
|
||||||
Birthday: in.Birthday,
|
Birthday: in.Birthday,
|
||||||
|
ArrivedAt: in.ArrivedAt,
|
||||||
Weight: normalizeWeight(in.Weight),
|
Weight: normalizeWeight(in.Weight),
|
||||||
Stage: in.Stage,
|
Stage: in.Stage,
|
||||||
Age: in.Age,
|
Age: in.Age,
|
||||||
@@ -145,7 +148,32 @@ func (s *Service) Onboarding(userID string, in PetInput) (*model.Pet, error) {
|
|||||||
// seedPetDefaults 为新宠按「物种 × 阶段」养护模板生成今日任务、提醒、30 天计划
|
// seedPetDefaults 为新宠按「物种 × 阶段」养护模板生成今日任务、提醒、30 天计划
|
||||||
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
|
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
|
||||||
today := time.Now()
|
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 tasks []model.DailyTask
|
||||||
var reminders []model.Reminder
|
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,
|
Day: it.Day, DayLabel: it.DayLabel, Title: it.Title, Description: it.Description, SheetType: it.SheetType,
|
||||||
})
|
})
|
||||||
case model.CareKindReminder:
|
case model.CareKindReminder:
|
||||||
|
// 建档时勾的目标决定要不要建这条提醒。原来这几个勾选框收上来只是
|
||||||
|
// 存进 pets.goals,一处都没用过,等于让用户点了个寂寞。
|
||||||
|
if !goalWantsReminder(goals, it.ReminderType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
r := model.Reminder{
|
r := model.Reminder{
|
||||||
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
|
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 {
|
if days := dueDaysFor(it, pet, today); days > 0 {
|
||||||
due := today.AddDate(0, 0, it.OffsetDays)
|
due := today.AddDate(0, 0, days)
|
||||||
r.NextDueDate = &due
|
r.NextDueDate = &due
|
||||||
}
|
}
|
||||||
reminders = append(reminders, r)
|
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 len(tasks) > 0 {
|
||||||
if err := tx.Create(&tasks).Error; err != nil {
|
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
|
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 "下一针疫苗(按月龄安排)"
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
saveCareTemplate: (species: string, stage: string, items: any[]) =>
|
saveCareTemplate: (species: string, stage: string, items: any[]) =>
|
||||||
http.put<any, { items: any[] }>('/admin/care-templates', { species, stage, items }),
|
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) =>
|
genCareTemplate: (species: string, stage: string) =>
|
||||||
http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/generate', {
|
http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/generate', {
|
||||||
species,
|
species,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
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 { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -106,6 +106,24 @@ export default function CareTemplates() {
|
|||||||
setDirty(true)
|
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() {
|
async function generate() {
|
||||||
setGen(true)
|
setGen(true)
|
||||||
setMsg('')
|
setMsg('')
|
||||||
@@ -149,6 +167,9 @@ export default function CareTemplates() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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}>
|
<Button variant="outline" onClick={generate} disabled={gen}>
|
||||||
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 生成草稿'}
|
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 生成草稿'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,6 +3,36 @@ const api = require('../../utils/api.js');
|
|||||||
|
|
||||||
const GENDERS = ['男孩', '女孩', '不确定'];
|
const GENDERS = ['男孩', '女孩', '不确定'];
|
||||||
|
|
||||||
|
// 与后端 service.AgeStages 一致:「刚到家」不是年龄阶段,它由到家日期单独决定
|
||||||
|
const STAGE_OPTIONS = [
|
||||||
|
{ key: '幼年期', desc: '疫苗针次、驱虫、体重增长、社会化训练' },
|
||||||
|
{ key: '成年期', desc: '体重控制、口腔、日常健康管理' },
|
||||||
|
{ key: '老年期', desc: '慢病监测、半年体检、用药与护理' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function today() {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n) => (n < 10 ? '0' + n : '' + n);
|
||||||
|
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthsSince(dateStr) {
|
||||||
|
const b = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
let m = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth());
|
||||||
|
if (now.getDate() < b.getDate()) m--;
|
||||||
|
return m < 0 ? 0 : m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与后端 StageFromBirthday 同一套规则:12 月龄成年、7 岁进入老年
|
||||||
|
function stageFromBirthday(birthday) {
|
||||||
|
if (!birthday) return '';
|
||||||
|
const m = monthsSince(birthday);
|
||||||
|
if (m < 12) return '幼年期';
|
||||||
|
if (m >= 84) return '老年期';
|
||||||
|
return '成年期';
|
||||||
|
}
|
||||||
|
|
||||||
function ageFromBirthday(bdayStr) {
|
function ageFromBirthday(bdayStr) {
|
||||||
if (!bdayStr) return '';
|
if (!bdayStr) return '';
|
||||||
const b = new Date(bdayStr);
|
const b = new Date(bdayStr);
|
||||||
@@ -21,7 +51,11 @@ Page({
|
|||||||
birthday: '',
|
birthday: '',
|
||||||
genderIdx: 0,
|
genderIdx: 0,
|
||||||
weight: '',
|
weight: '',
|
||||||
stage: '幼年期',
|
arrivedAt: '',
|
||||||
|
today: '',
|
||||||
|
stage: '',
|
||||||
|
stageAuto: '',
|
||||||
|
stageOptions: STAGE_OPTIONS,
|
||||||
goals: [
|
goals: [
|
||||||
{ label: '不知道每天该做什么', on: true },
|
{ label: '不知道每天该做什么', on: true },
|
||||||
{ label: '疫苗驱虫怕忘记', on: true },
|
{ label: '疫苗驱虫怕忘记', on: true },
|
||||||
@@ -33,6 +67,7 @@ Page({
|
|||||||
submitting: false,
|
submitting: false,
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
|
this.setData({ today: today(), arrivedAt: today() });
|
||||||
// 已有宠物则直接进首页
|
// 已有宠物则直接进首页
|
||||||
store
|
store
|
||||||
.ready()
|
.ready()
|
||||||
@@ -52,7 +87,15 @@ Page({
|
|||||||
},
|
},
|
||||||
onName(e) { this.setData({ petName: e.detail.value }); },
|
onName(e) { this.setData({ petName: e.detail.value }); },
|
||||||
onWeight(e) { this.setData({ weight: e.detail.value }); },
|
onWeight(e) { this.setData({ weight: e.detail.value }); },
|
||||||
onBirthday(e) { this.setData({ birthday: e.detail.value }); },
|
onBirthday(e) {
|
||||||
|
const birthday = e.detail.value;
|
||||||
|
const auto = stageFromBirthday(birthday);
|
||||||
|
const patch = { birthday, stageAuto: auto };
|
||||||
|
// 用户手动选过就尊重他的选择,否则跟着生日走
|
||||||
|
if (!this._stageTouched) patch.stage = auto;
|
||||||
|
this.setData(patch);
|
||||||
|
},
|
||||||
|
onArrived(e) { this.setData({ arrivedAt: e.detail.value }); },
|
||||||
selectGender(e) { this.setData({ genderIdx: Number(e.currentTarget.dataset.index) }); },
|
selectGender(e) { this.setData({ genderIdx: Number(e.currentTarget.dataset.index) }); },
|
||||||
// 先随便看看:不建数据,直接进首页浏览(无宠物时首页显示空态)
|
// 先随便看看:不建数据,直接进首页浏览(无宠物时首页显示空态)
|
||||||
skipBrowse() { wx.switchTab({ url: '/pages/home/home' }); },
|
skipBrowse() { wx.switchTab({ url: '/pages/home/home' }); },
|
||||||
@@ -61,10 +104,39 @@ Page({
|
|||||||
wx.showToast({ title: '给它起个名字吧', icon: 'none' });
|
wx.showToast({ title: '给它起个名字吧', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const w = parseFloat(this.data.weight);
|
||||||
|
// 体重会进趋势图和用药剂量参考,乱填不如不填
|
||||||
|
if (this.data.weight && !(w > 0 && w < 120)) {
|
||||||
|
wx.showToast({ title: '体重看起来不太对', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.data.birthday) {
|
||||||
|
wx.showToast({ title: '生日决定疫苗和驱虫节奏,估个大概也行', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.nextStep();
|
this.nextStep();
|
||||||
},
|
},
|
||||||
chooseStage(e) {
|
chooseStage(e) {
|
||||||
this.setData({ stage: e.currentTarget.dataset.stage });
|
const stage = e.currentTarget.dataset.stage;
|
||||||
|
const auto = this.data.stageAuto;
|
||||||
|
// 改成和生日矛盾的阶段是允许的(领养估的生日可能不准,大型犬发育也慢),
|
||||||
|
// 但要先说清楚代价:阶段决定疫苗针次怎么排,选错会漏针
|
||||||
|
if (auto && stage !== auto) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '和生日对不上',
|
||||||
|
content: `按生日算它应该在「${auto}」。改成「${stage}」的话,疫苗针次和驱虫周期会按${stage}安排。确定要改吗?`,
|
||||||
|
confirmText: '确定改',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
this._stageTouched = true;
|
||||||
|
this.setData({ stage });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._stageTouched = true;
|
||||||
|
this.setData({ stage });
|
||||||
},
|
},
|
||||||
toggleGoal(e) {
|
toggleGoal(e) {
|
||||||
const i = e.currentTarget.dataset.index;
|
const i = e.currentTarget.dataset.index;
|
||||||
@@ -80,8 +152,9 @@ Page({
|
|||||||
type: this.data.petType,
|
type: this.data.petType,
|
||||||
gender: GENDERS[this.data.genderIdx],
|
gender: GENDERS[this.data.genderIdx],
|
||||||
birthday: this.data.birthday,
|
birthday: this.data.birthday,
|
||||||
|
arrived_at: this.data.arrivedAt,
|
||||||
weight: this.data.weight,
|
weight: this.data.weight,
|
||||||
stage: this.data.stage,
|
stage: this.data.stage || this.data.stageAuto,
|
||||||
age: ageFromBirthday(this.data.birthday),
|
age: ageFromBirthday(this.data.birthday),
|
||||||
goals: this.data.goals.filter((g) => g.on).map((g) => g.label),
|
goals: this.data.goals.filter((g) => g.on).map((g) => g.label),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,10 +40,15 @@
|
|||||||
<view class="form-card">
|
<view class="form-card">
|
||||||
<view class="field"><label>宠物名字</label>
|
<view class="field"><label>宠物名字</label>
|
||||||
<input class="input" value="{{petName}}" placeholder="例如:奶球" placeholder-class="placeholder" bindinput="onName"/></view>
|
<input class="input" value="{{petName}}" placeholder="例如:奶球" placeholder-class="placeholder" bindinput="onName"/></view>
|
||||||
<view class="field"><label>生日 / 到家时间</label>
|
<view class="field"><label>生日</label>
|
||||||
<picker mode="date" value="{{birthday}}" bindchange="onBirthday">
|
<picker mode="date" value="{{birthday}}" end="{{today}}" bindchange="onBirthday">
|
||||||
<view class="picker-box">{{birthday || '选择日期'}}</view>
|
<view class="picker-box">{{birthday || '选择日期(不确定可以估个大概)'}}</view>
|
||||||
</picker></view>
|
</picker></view>
|
||||||
|
<view class="field"><label>到家日期</label>
|
||||||
|
<picker mode="date" value="{{arrivedAt}}" end="{{today}}" bindchange="onArrived">
|
||||||
|
<view class="picker-box">{{arrivedAt || '选择日期'}}</view>
|
||||||
|
</picker>
|
||||||
|
<view class="field-tip">刚到家不满 30 天的话,会额外安排一组安置期任务</view></view>
|
||||||
<view class="field"><label>性别</label><view class="seg">
|
<view class="field"><label>性别</label><view class="seg">
|
||||||
<view class="seg-btn {{genderIdx === 0 ? 'selected' : ''}}" data-index="0" bindtap="selectGender">男孩</view>
|
<view class="seg-btn {{genderIdx === 0 ? 'selected' : ''}}" data-index="0" bindtap="selectGender">男孩</view>
|
||||||
<view class="seg-btn {{genderIdx === 1 ? 'selected' : ''}}" data-index="1" bindtap="selectGender">女孩</view>
|
<view class="seg-btn {{genderIdx === 1 ? 'selected' : ''}}" data-index="1" bindtap="selectGender">女孩</view>
|
||||||
@@ -58,16 +63,16 @@
|
|||||||
<!-- step 3 阶段 -->
|
<!-- step 3 阶段 -->
|
||||||
<view wx:elif="{{step === 3}}" class="step">
|
<view wx:elif="{{step === 3}}" class="step">
|
||||||
<view class="step-title">它现在处于哪个阶段?</view>
|
<view class="step-title">它现在处于哪个阶段?</view>
|
||||||
<view class="step-sub">阶段会决定今日任务、提醒和 AI 建议。</view>
|
<view class="step-sub">阶段决定疫苗节奏、驱虫周期和每天要做的事,选错整套计划都会偏。</view>
|
||||||
|
<view wx:if="{{stageAuto}}" class="stage-auto">
|
||||||
|
<pt-icon name="check" size="{{28}}"></pt-icon>已按生日自动判断为「{{stageAuto}}」,不对可以改
|
||||||
|
</view>
|
||||||
<view class="stage-list">
|
<view class="stage-list">
|
||||||
<view class="stage-card {{stage === '幼年期' ? 'selected' : ''}}" data-stage="幼年期" bindtap="chooseStage">
|
<view wx:for="{{stageOptions}}" wx:key="key"
|
||||||
<view class="stage-b">幼年期</view><view class="stage-p">疫苗、驱虫、体重增长、行为训练</view></view>
|
class="stage-card {{stage === item.key ? 'selected' : ''}}" data-stage="{{item.key}}" bindtap="chooseStage">
|
||||||
<view class="stage-card {{stage === '刚到家 0-30 天' ? 'selected' : ''}}" data-stage="刚到家 0-30 天" bindtap="chooseStage">
|
<view class="stage-b">{{item.key}}<text wx:if="{{item.key === stageAuto}}" class="stage-hint">按生日推荐</text></view>
|
||||||
<view class="stage-b">刚到家 0-30 天</view><view class="stage-p">适应环境、饮食稳定、基础照护</view></view>
|
<view class="stage-p">{{item.desc}}</view>
|
||||||
<view class="stage-card {{stage === '成年期' ? 'selected' : ''}}" data-stage="成年期" bindtap="chooseStage">
|
</view>
|
||||||
<view class="stage-b">成年期</view><view class="stage-p">体重、饮食、日常健康管理</view></view>
|
|
||||||
<view class="stage-card {{stage === '老年期' ? 'selected' : ''}}" data-stage="老年期" bindtap="chooseStage">
|
|
||||||
<view class="stage-b">老年期</view><view class="stage-p">慢病、体检、用药和护理</view></view>
|
|
||||||
</view>
|
</view>
|
||||||
<button class="btn btn-primary btn-block" bindtap="nextStep">下一步</button>
|
<button class="btn btn-primary btn-block" bindtap="nextStep">下一步</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -75,7 +80,7 @@
|
|||||||
<!-- step 4 目标 -->
|
<!-- step 4 目标 -->
|
||||||
<view wx:elif="{{step === 4}}" class="step">
|
<view wx:elif="{{step === 4}}" class="step">
|
||||||
<view class="step-title">你最想解决什么?</view>
|
<view class="step-title">你最想解决什么?</view>
|
||||||
<view class="step-sub">多选,我们会据此为你定制计划。</view>
|
<view class="step-sub">勾上的会变成真的提醒和任务,没勾的不会打扰你。</view>
|
||||||
<view class="check-list">
|
<view class="check-list">
|
||||||
<view wx:for="{{goals}}" wx:key="label" class="check {{item.on ? 'selected' : ''}}" data-index="{{index}}" bindtap="toggleGoal">
|
<view wx:for="{{goals}}" wx:key="label" class="check {{item.on ? 'selected' : ''}}" data-index="{{index}}" bindtap="toggleGoal">
|
||||||
<view class="box"><pt-icon wx:if="{{item.on}}" name="check" size="{{26}}"></pt-icon></view>{{item.label}}
|
<view class="box"><pt-icon wx:if="{{item.on}}" name="check" size="{{26}}"></pt-icon></view>{{item.label}}
|
||||||
|
|||||||
@@ -57,3 +57,14 @@
|
|||||||
width:44rpx;height:44rpx;border-radius:50%;background:var(--green);color:#fff;
|
width:44rpx;height:44rpx;border-radius:50%;background:var(--green);color:#fff;
|
||||||
display:flex;align-items:center;justify-content:center;flex:none;
|
display:flex;align-items:center;justify-content:center;flex:none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-tip{margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-cap);line-height:1.5}
|
||||||
|
.stage-auto{
|
||||||
|
display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-3);
|
||||||
|
padding:var(--sp-3) var(--sp-4);border-radius:var(--r-md);
|
||||||
|
background:var(--green-soft);color:var(--green-ink);font-size:var(--fs-sm);
|
||||||
|
}
|
||||||
|
.stage-hint{
|
||||||
|
margin-left:var(--sp-2);padding:2rpx var(--sp-2);border-radius:var(--r-full);
|
||||||
|
background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-cap);font-weight:var(--fw);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user