feat(auth): access token 缩到 2 小时 + refresh token 机制 #3

Merged
Blizzard merged 58 commits from feat/dev into main 2026-07-30 10:18:07 +00:00
18 changed files with 735 additions and 168 deletions
Showing only changes of commit c4c0c2b7cd - Show all commits
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-DyVnSuaV.js"></script>
<script type="module" crossorigin src="/admin/assets/index-BOP-ztlG.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-PPcOOdtF.css">
</head>
<body>
+233
View File
@@ -0,0 +1,233 @@
package database
import (
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// 养护方案预置:5 个阶段 × 2 物种,各一套「标准照护」。
//
// 内容按各阶段的真实生理重点配,不是把同一份东西换个名字:
// 幼年期 疫苗序列 + 驱虫 + 社会化,大型犬还要控制生长速度
// 青年期 绝育窗口 + 换成年粮
// 成年期 维持期,重点是把基线数据记下来(体重趋势、年度体检)
// 中年期 年度血检开始有意义,体重和牙结石重点看
// 老年期 体检频率加倍,猫看肾和甲状腺、狗看关节和心脏
//
// 和 record_types / breeds 一样只在表为空时铺一次,之后一切以数据库为准。
//
// 这些是「结构」不是「内容」:方案表空着的话建档最后一步没东西可挑,
// 用户建完档只能自己从零排计划。
func plan(title, desc, repeat, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: repeat,
Title: title, Description: desc, SheetType: sheet}
}
func weekly(title, desc, weekdays, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatWeekly, sheet)
it.Weekdays = weekdays
return it
}
func monthly(title, desc string, day int, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatMonthly, sheet)
it.MonthDay = day
return it
}
func once(title, desc string, day int, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatOnce, sheet)
it.Day = day
return it
}
func remind(rtype, title string, offset int, freq string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype,
Title: title, OffsetDays: offset, Frequency: freq}
}
func important(it model.CareTemplateItem) model.CareTemplateItem {
it.Priority = "重要"
return it
}
// TplDef 一套方案的定义(迁移程序要读它)
type tplDef struct {
Species string
Stage string
Name string
Summary string
Items []model.CareTemplateItem
}
// CareTemplateDefs 导出给一次性迁移程序用
func CareTemplateDefs() []tplDef { return careTemplateDefs() }
// SeedCareTemplatesForce 不检查表空,直接铺。只给迁移程序用
func SeedCareTemplatesForce(db *gorm.DB) error { return insertCareTemplates(db) }
func careTemplateDefs() []tplDef {
const (
J = "幼年期"
Y = "青年期"
A = "成年期"
M = "中年期"
S = "老年期"
)
return []tplDef{
// ───────────────── 猫 ─────────────────
{model.SpeciesCat, J, "幼猫标准照护", "少量多餐、疫苗序列和社会化,六月龄前把基础打好", []model.CareTemplateItem{
important(plan("喂食 3-4 次", "幼猫胃容量小,一天分 3-4 顿,用幼猫粮", model.RepeatDaily, "food")),
plan("清理猫砂盆", "每天铲一次,顺手看便便形状和颜色", model.RepeatDaily, "litterbox"),
plan("陪玩 15 分钟", "逗猫棒模拟狩猎,消耗精力也是社会化", model.RepeatDaily, ""),
weekly("称一次体重", "幼猫每周都在长,体重曲线比单次数值有用", "1", "weight"),
weekly("检查耳朵和眼睛", "幼猫耳螨和眼部分泌物高发", "4", "ear"),
once("核对疫苗进度", "对照月龄看第几针,三针核心疫苗要在六月龄前打完", 2, "vaccine"),
once("首次体内驱虫", "幼猫驱虫比成猫频繁,按体重给药", 5, "deworm"),
remind("vaccine", "下一针疫苗", 21, "幼猫期每 3-4 周一针,共三针"),
remind("deworm", "体内外驱虫", 30, "幼猫每月一次"),
remind("weight", "记录体重", 7, "每周一次"),
}},
{model.SpeciesCat, Y, "青年猫标准照护", "绝育窗口、换成猫粮,开始把体重控住", []model.CareTemplateItem{
plan("喂食 2 次", "六月龄后可以改成早晚两顿", model.RepeatDaily, "food"),
plan("清理猫砂盆", "每天铲一次", model.RepeatDaily, "litterbox"),
plan("陪玩 15 分钟", "性成熟后精力旺盛,玩够了才不拆家", model.RepeatDaily, ""),
weekly("称一次体重", "绝育前后代谢会变,体重要盯紧", "1", "weight"),
weekly("梳毛", "换毛期减少毛球", "4", "brush"),
once("咨询绝育时间", "多数猫 6-8 月龄可做,术前要空腹和体检", 3, "vet"),
once("换成猫粮", "一岁前后用 7 天时间按比例过渡,别一次换完", 14, "food"),
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
}},
{model.SpeciesCat, A, "成年猫标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
plan("清理猫砂盆", "每天铲一次,便便异常是最早的信号", model.RepeatDaily, "litterbox"),
weekly("梳毛", "长毛猫每天,短毛猫每周两次", "2,5", "brush"),
weekly("刷牙", "牙结石是成年猫最常被忽略的问题", "6", "tooth"),
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
monthly("洗猫砂盆", "整盆倒空清洗一次", 15, "litterbox"),
once("安排年度体检", "成年期一年一次,做基础血检建立基线", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
{model.SpeciesCat, M, "中年猫标准照护", "七岁起代谢变慢,年度血检开始能查出早期问题", []model.CareTemplateItem{
plan("喂食和换水", "中年后容易发胖,按体重控制热量", model.RepeatDaily, "food"),
plan("清理猫砂盆", "尿量和尿块大小要留意,肾病最早从这里看出来", model.RepeatDaily, "litterbox"),
weekly("梳毛", "自我梳理会减少,需要帮忙", "2,5", "brush"),
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
monthly("称一次体重", "不明原因掉体重是中年猫要警惕的信号", 1, "weight"),
monthly("检查口腔", "看牙龈颜色和有没有结石", 15, ""),
once("安排年度体检", "血检 + 尿检,重点看肾功能和甲状腺", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("weight", "记录体重", 30, "每月一次"),
}},
{model.SpeciesCat, S, "老年猫标准照护", "体检半年一次,肾和甲状腺是重点", []model.CareTemplateItem{
important(plan("记录饮水量", "多饮多尿是慢性肾病最典型的早期表现", model.RepeatDaily, "water")),
plan("喂食", "老年猫可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
plan("清理猫砂盆", "尿块变大或变多要立刻记下来", model.RepeatDaily, "litterbox"),
weekly("梳毛", "老年猫自己舔不到的地方要帮它梳", "2,5", "brush"),
weekly("称一次体重", "老年期体重变化要按周盯,不是按月", "1", "weight"),
monthly("检查关节活动", "跳不上原来能跳的地方就是信号", 1, ""),
once("安排半年体检", "血检 + 尿检 + 甲状腺,老年期半年一次", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
// ───────────────── 狗 ─────────────────
{model.SpeciesDog, J, "幼犬标准照护", "疫苗序列、社会化,大型犬还要控制生长速度", []model.CareTemplateItem{
important(plan("喂食 3-4 次", "幼犬一天分 3-4 顿;大型犬别过量补钙,长太快伤关节", model.RepeatDaily, "food")),
plan("外出排便和散步", "疫苗打齐前避免去流浪动物多的地方", model.RepeatDaily, ""),
plan("基础训练 10 分钟", "坐、等待、唤回。幼犬期是社会化窗口,错过很难补", model.RepeatDaily, ""),
weekly("称一次体重", "幼犬长得快,体重曲线也是喂食量的依据", "1", "weight"),
weekly("检查爪垫和耳朵", "散步后看有没有扎伤,垂耳犬要重点看耳道", "4", "ear"),
once("核对疫苗进度", "对照月龄看第几针,狂犬疫苗按当地规定单独安排", 2, "vaccine"),
once("首次体内驱虫", "按体重给药,幼犬比成犬频繁", 5, "deworm"),
remind("vaccine", "下一针疫苗", 21, "幼犬期每 3-4 周一针"),
remind("deworm", "体内外驱虫", 30, "幼犬每月一次"),
remind("weight", "记录体重", 7, "每周一次"),
}},
{model.SpeciesDog, Y, "青年犬标准照护", "骨骼刚闭合,可以加运动量了;绝育窗口也在这里", []model.CareTemplateItem{
plan("喂食 2 次", "改成早晚两顿", model.RepeatDaily, "food"),
plan("散步 30 分钟以上", "骨骼闭合后可以正常运动,之前不宜长距离跑跳", model.RepeatDaily, ""),
plan("训练 10 分钟", "把幼犬期学的固化下来", model.RepeatDaily, ""),
weekly("称一次体重", "换成犬粮期间容易长胖", "1", "weight"),
weekly("梳毛", "换毛期每天,平时每周两次", "2,5", "brush"),
once("咨询绝育时间", "大型犬建议等骨骼闭合后再做", 3, "vet"),
once("换成犬粮", "用 7 天按比例过渡,别一次换完", 14, "food"),
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
}},
{model.SpeciesDog, A, "成年犬标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
plan("散步 30-60 分钟", "运动量不够是成年犬发胖和行为问题的主因", model.RepeatDaily, ""),
weekly("梳毛", "顺手摸一遍有没有肿块", "2,5", "brush"),
weekly("刷牙", "牙结石是成年犬最常被忽略的问题", "6", "tooth"),
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
monthly("剪指甲", "听到走路哒哒响就该剪了", 15, "nail"),
once("安排年度体检", "一年一次,做基础血检建立基线", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
{model.SpeciesDog, M, "中年犬标准照护", "体重和关节是重点,年度血检开始有意义", []model.CareTemplateItem{
plan("喂食和换水", "代谢变慢,按体重下调热量", model.RepeatDaily, "food"),
plan("散步 30 分钟", "保持规律但降低强度,避免急停急转", model.RepeatDaily, ""),
weekly("梳毛并摸一遍身体", "中年后肿块发生率上升,每周摸一遍是最便宜的筛查", "2,5", "brush"),
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
monthly("称一次体重", "中年犬超重会直接加重关节负担", 1, "weight"),
monthly("检查关节活动", "起身变慢、不愿上楼就是信号", 15, ""),
once("安排年度体检", "血检 + 关节检查,大型犬加拍髋关节", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("weight", "记录体重", 30, "每月一次"),
}},
{model.SpeciesDog, S, "老年犬标准照护", "体检半年一次,关节和心脏是重点", []model.CareTemplateItem{
important(plan("记录精神和食欲", "老年犬变化来得快,每天一句话记下来", model.RepeatDaily, "")),
plan("喂食", "老年犬可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
plan("散步 15-20 分钟", "短而多次,比一次走久对关节友好", model.RepeatDaily, ""),
weekly("梳毛并摸一遍身体", "重点摸有没有新长的肿块", "2,5", "brush"),
weekly("称一次体重", "老年期体重变化要按周盯", "1", "weight"),
monthly("检查关节和牙齿", "起身、上楼、咀嚼有没有变吃力", 1, ""),
once("安排半年体检", "血检 + 心脏听诊 + 关节,老年期半年一次", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
}
}
// seedCareTemplates 只在方案表为空时铺一次
func seedCareTemplates(db *gorm.DB) error {
var count int64
if err := db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
return insertCareTemplates(db)
}
func insertCareTemplates(db *gorm.DB) error {
for i, d := range careTemplateDefs() {
tpl := model.CareTemplate{
Species: d.Species, Stage: d.Stage, Name: d.Name,
Summary: d.Summary, Sort: (i + 1) * 10, Enabled: true,
}
if err := db.Create(&tpl).Error; err != nil {
return err
}
items := make([]model.CareTemplateItem, 0, len(d.Items))
for j, it := range d.Items {
it.TemplateID = tpl.ID
it.Sort = j
items = append(items, it)
}
if len(items) > 0 {
if err := db.Create(&items).Error; err != nil {
return err
}
}
}
return nil
}
+49 -46
View File
@@ -23,7 +23,10 @@ func Seed(db *gorm.DB, cfg *config.Config) error {
if err := seedRecordTypes(db); err != nil {
return err
}
return seedBreeds(db)
if err := seedBreeds(db); err != nil {
return err
}
return seedCareTemplates(db)
}
// seedBreeds 品种预置。同 record_types:只在表完全为空时铺一次。
@@ -76,51 +79,51 @@ func seedBreeds(db *gorm.DB) error {
{Species: model.SpeciesCat, Initial: "Y", Name: "英国短毛猫", Sort: 330},
{Species: model.SpeciesCat, Initial: "Z", Name: "中华田园猫", Sort: 340},
// 狗 46 种
{Species: model.SpeciesDog, Initial: "A", Name: "阿拉斯加雪橇犬", Sort: 10},
{Species: model.SpeciesDog, Initial: "A", Name: "阿富汗猎犬", Sort: 20},
{Species: model.SpeciesDog, Initial: "A", Name: "澳洲牧羊犬", Sort: 30},
{Species: model.SpeciesDog, Initial: "B", Name: "比熊犬", Sort: 40},
{Species: model.SpeciesDog, Initial: "B", Name: "比格犬", Sort: 50},
{Species: model.SpeciesDog, Initial: "B", Name: "边境牧羊犬", Sort: 60},
{Species: model.SpeciesDog, Initial: "B", Name: "巴哥犬", Sort: 70},
{Species: model.SpeciesDog, Initial: "B", Name: "博美犬", Sort: 80},
{Species: model.SpeciesDog, Initial: "B", Name: "巴吉度猎犬", Sort: 90},
{Species: model.SpeciesDog, Initial: "B", Name: "北京犬", Sort: 100},
{Species: model.SpeciesDog, Initial: "C", Name: "柴犬", Sort: 110},
{Species: model.SpeciesDog, Initial: "C", Name: "川东猎犬", Sort: 120},
{Species: model.SpeciesDog, Initial: "D", Name: "德国牧羊犬", Sort: 130},
{Species: model.SpeciesDog, Initial: "D", Name: "杜宾犬", Sort: 140},
{Species: model.SpeciesDog, Initial: "D", Name: "大丹犬", Sort: 150},
{Species: model.SpeciesDog, Initial: "D", Name: "斗牛犬", Sort: 160},
{Species: model.SpeciesDog, Initial: "F", Name: "法国斗牛犬", Sort: 170},
{Species: model.SpeciesDog, Initial: "G", Name: "贵宾犬(泰迪)", Sort: 180},
{Species: model.SpeciesDog, Initial: "G", Name: "古代牧羊犬", Sort: 190},
{Species: model.SpeciesDog, Initial: "H", Name: "哈士奇", Sort: 200},
{Species: model.SpeciesDog, Initial: "H", Name: "蝴蝶犬", Sort: 210},
{Species: model.SpeciesDog, Initial: "J", Name: "金毛寻回犬", Sort: 220},
{Species: model.SpeciesDog, Initial: "J", Name: "吉娃娃", Sort: 230},
{Species: model.SpeciesDog, Initial: "K", Name: "柯基犬", Sort: 240},
{Species: model.SpeciesDog, Initial: "K", Name: "可卡犬", Sort: 250},
{Species: model.SpeciesDog, Initial: "K", Name: "苏格兰柯利犬", Sort: 260},
{Species: model.SpeciesDog, Initial: "L", Name: "拉布拉多寻回犬", Sort: 270},
{Species: model.SpeciesDog, Initial: "L", Name: "罗威纳犬", Sort: 280},
{Species: model.SpeciesDog, Initial: "L", Name: "灵缇犬", Sort: 290},
{Species: model.SpeciesDog, Initial: "L", Name: "腊肠犬", Sort: 300},
{Species: model.SpeciesDog, Initial: "M", Name: "马尔济斯犬", Sort: 310},
{Species: model.SpeciesDog, Initial: "M", Name: "迷你雪纳瑞", Sort: 320},
{Species: model.SpeciesDog, Initial: "N", Name: "牛头梗", Sort: 330},
{Species: model.SpeciesDog, Initial: "Q", Name: "秋田犬", Sort: 340},
{Species: model.SpeciesDog, Initial: "S", Name: "萨摩耶", Sort: 350},
{Species: model.SpeciesDog, Initial: "S", Name: "松狮犬", Sort: 360},
{Species: model.SpeciesDog, Initial: "S", Name: "圣伯纳犬", Sort: 370},
{Species: model.SpeciesDog, Initial: "X", Name: "雪纳瑞", Sort: 390},
{Species: model.SpeciesDog, Initial: "X", Name: "西高地白梗", Sort: 400},
{Species: model.SpeciesDog, Initial: "X", Name: "喜乐蒂牧羊犬", Sort: 410},
{Species: model.SpeciesDog, Initial: "Y", Name: "约克夏梗", Sort: 420},
{Species: model.SpeciesDog, Initial: "Y", Name: "银狐犬", Sort: 430},
{Species: model.SpeciesDog, Initial: "Z", Name: "中华田园犬", Sort: 440},
{Species: model.SpeciesDog, Initial: "Z", Name: "中亚牧羊犬", Sort: 450},
{Species: model.SpeciesDog, Initial: "Z", Name: "藏獒", Sort: 460},
{Species: model.SpeciesDog, Initial: "A", Name: "阿拉斯加雪橇犬", SizeClass: "large", Sort: 10},
{Species: model.SpeciesDog, Initial: "A", Name: "阿富汗猎犬", SizeClass: "large", Sort: 20},
{Species: model.SpeciesDog, Initial: "A", Name: "澳洲牧羊犬", SizeClass: "medium", Sort: 30},
{Species: model.SpeciesDog, Initial: "B", Name: "比熊犬", SizeClass: "small", Sort: 40},
{Species: model.SpeciesDog, Initial: "B", Name: "比格犬", SizeClass: "medium", Sort: 50},
{Species: model.SpeciesDog, Initial: "B", Name: "边境牧羊犬", SizeClass: "medium", Sort: 60},
{Species: model.SpeciesDog, Initial: "B", Name: "巴哥犬", SizeClass: "small", Sort: 70},
{Species: model.SpeciesDog, Initial: "B", Name: "博美犬", SizeClass: "small", Sort: 80},
{Species: model.SpeciesDog, Initial: "B", Name: "巴吉度猎犬", SizeClass: "medium", Sort: 90},
{Species: model.SpeciesDog, Initial: "B", Name: "北京犬", SizeClass: "small", Sort: 100},
{Species: model.SpeciesDog, Initial: "C", Name: "柴犬", SizeClass: "medium", Sort: 110},
{Species: model.SpeciesDog, Initial: "C", Name: "川东猎犬", SizeClass: "medium", Sort: 120},
{Species: model.SpeciesDog, Initial: "D", Name: "德国牧羊犬", SizeClass: "large", Sort: 130},
{Species: model.SpeciesDog, Initial: "D", Name: "杜宾犬", SizeClass: "large", Sort: 140},
{Species: model.SpeciesDog, Initial: "D", Name: "大丹犬", SizeClass: "giant", Sort: 150},
{Species: model.SpeciesDog, Initial: "D", Name: "斗牛犬", SizeClass: "medium", Sort: 160},
{Species: model.SpeciesDog, Initial: "F", Name: "法国斗牛犬", SizeClass: "medium", Sort: 170},
{Species: model.SpeciesDog, Initial: "G", Name: "贵宾犬(泰迪)", SizeClass: "small", Sort: 180},
{Species: model.SpeciesDog, Initial: "G", Name: "古代牧羊犬", SizeClass: "medium", Sort: 190},
{Species: model.SpeciesDog, Initial: "H", Name: "哈士奇", SizeClass: "large", Sort: 200},
{Species: model.SpeciesDog, Initial: "H", Name: "蝴蝶犬", SizeClass: "small", Sort: 210},
{Species: model.SpeciesDog, Initial: "J", Name: "金毛寻回犬", SizeClass: "large", Sort: 220},
{Species: model.SpeciesDog, Initial: "J", Name: "吉娃娃", SizeClass: "small", Sort: 230},
{Species: model.SpeciesDog, Initial: "K", Name: "柯基犬", SizeClass: "small", Sort: 240},
{Species: model.SpeciesDog, Initial: "K", Name: "可卡犬", SizeClass: "medium", Sort: 250},
{Species: model.SpeciesDog, Initial: "K", Name: "苏格兰柯利犬", SizeClass: "medium", Sort: 260},
{Species: model.SpeciesDog, Initial: "L", Name: "拉布拉多寻回犬", SizeClass: "large", Sort: 270},
{Species: model.SpeciesDog, Initial: "L", Name: "罗威纳犬", SizeClass: "large", Sort: 280},
{Species: model.SpeciesDog, Initial: "L", Name: "灵缇犬", SizeClass: "large", Sort: 290},
{Species: model.SpeciesDog, Initial: "L", Name: "腊肠犬", SizeClass: "small", Sort: 300},
{Species: model.SpeciesDog, Initial: "M", Name: "马尔济斯犬", SizeClass: "small", Sort: 310},
{Species: model.SpeciesDog, Initial: "M", Name: "迷你雪纳瑞", SizeClass: "small", Sort: 320},
{Species: model.SpeciesDog, Initial: "N", Name: "牛头梗", SizeClass: "medium", Sort: 330},
{Species: model.SpeciesDog, Initial: "Q", Name: "秋田犬", SizeClass: "large", Sort: 340},
{Species: model.SpeciesDog, Initial: "S", Name: "萨摩耶", SizeClass: "large", Sort: 350},
{Species: model.SpeciesDog, Initial: "S", Name: "松狮犬", SizeClass: "medium", Sort: 360},
{Species: model.SpeciesDog, Initial: "S", Name: "圣伯纳犬", SizeClass: "giant", Sort: 370},
{Species: model.SpeciesDog, Initial: "X", Name: "雪纳瑞", SizeClass: "medium", Sort: 390},
{Species: model.SpeciesDog, Initial: "X", Name: "西高地白梗", SizeClass: "small", Sort: 400},
{Species: model.SpeciesDog, Initial: "X", Name: "喜乐蒂牧羊犬", SizeClass: "small", Sort: 410},
{Species: model.SpeciesDog, Initial: "Y", Name: "约克夏梗", SizeClass: "small", Sort: 420},
{Species: model.SpeciesDog, Initial: "Y", Name: "银狐犬", SizeClass: "medium", Sort: 430},
{Species: model.SpeciesDog, Initial: "Z", Name: "中华田园犬", SizeClass: "medium", Sort: 440},
{Species: model.SpeciesDog, Initial: "Z", Name: "中亚牧羊犬", SizeClass: "large", Sort: 450},
{Species: model.SpeciesDog, Initial: "Z", Name: "藏獒", SizeClass: "giant", Sort: 460},
}
for i := range breeds {
breeds[i].Enabled = true
+36 -5
View File
@@ -7,6 +7,7 @@ import (
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
@@ -46,9 +47,8 @@ func (r petReq) toInput() service.PetInput {
in.ArrivedAt = &t
}
}
// 阶段以生日为准。用户在引导页可以手改,但服务端只认年龄阶段三选一——
// 「刚到家」是叠加包不是年龄段,混进来会让整套模板取错
in.Stage = service.NormalizeStage(service.SpeciesOf(r.Type), in.Stage, in.Birthday)
// 阶段归一化不在这里做:狗要按品种查体型才能定阈值,需要访问 DB。
// 交给 handler 里 h.svc.NormalizeStage 处理
if r.Goals != nil {
if b, err := jsonMarshal(r.Goals); err == nil {
in.Goals = datatypes.JSON(b)
@@ -74,7 +74,9 @@ func (h *Handler) CreatePet(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
pet, err := h.svc.CreatePet(middleware.UserID(c), req.toInput())
in := req.toInput()
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
pet, err := h.svc.CreatePet(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
@@ -151,7 +153,9 @@ func (h *Handler) Onboarding(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
pet, err := h.svc.Onboarding(middleware.UserID(c), req.toInput())
in := req.toInput()
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
pet, err := h.svc.Onboarding(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
@@ -164,3 +168,30 @@ func (h *Handler) Onboarding(c *gin.Context) {
func (h *Handler) Breeds(c *gin.Context) {
response.OK(c, h.svc.ListBreeds(c.Query("species")))
}
// LifeStages GET /api/life-stages?species=dog&size=large
// 生命阶段的划分标准。建档时选阶段那里、和「该换方案了」的提示里都要展示依据,
// 所以直接把驱动判定的那张表吐出来 —— 不另写一份说明文案,免得两边漂
func (h *Handler) LifeStages(c *gin.Context) {
species := c.Query("species")
if species == "" {
species = model.SpeciesCat
}
response.OK(c, gin.H{
"species": species,
"size": c.Query("size"),
"stages": service.StageRules(species, c.Query("size")),
"sizes": service.SizeLabels,
})
}
// PetStageDrift GET /api/pets/:id/stage-drift
// 存的阶段和按生日算的对不上时,告诉前端可以提示用户换一套方案
func (h *Handler) PetStageDrift(c *gin.Context) {
pet, err := h.svc.GetPet(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, h.svc.CheckStageDrift(pet))
}
+7
View File
@@ -14,6 +14,13 @@ type Breed struct {
// 还是 zang 分组,人来定比库来猜靠谱。后台新增品种时手填这一位。
Initial string `gorm:"size:1;index" json:"initial"`
// SizeClass 成年体型,只有犬类用:small(<10kg) / medium(10-25) / large(25-40) / giant(>40)。
//
// 生命阶段的判定要靠它。狗的发育完全取决于体型——大丹犬 8 个月还在长骨头,
// 吉娃娃 8 个月已经性成熟;大丹犬 7 岁进老年,吉娃娃 7 岁还是中年。
// 不能靠当前体重判断(幼犬体重说明不了成年后多大),只能靠品种。
SizeClass string `gorm:"size:16;index" json:"size_class"`
Sort int `json:"sort"`
Enabled bool `gorm:"default:true" json:"enabled"`
}
+3 -1
View File
@@ -20,7 +20,9 @@ type Pet struct {
ArrivedAt *time.Time `json:"arrived_at"` // 到家日期。30 天内会额外叠加一套安置期任务
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
Stage string `gorm:"size:32" json:"stage"` // 年龄阶段:幼年期 / 成年期 / 老年期(由生日推断,可手改)
Age string `gorm:"size:32" json:"age"`
// Age 计算字段,不入库。原来是建档时算好存下来的字符串,
// 养半年后首页还显示「2个月」—— 存一个会过期的展示值本身就是 bug
Age string `gorm:"-" json:"age"`
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
Breed string `gorm:"size:64" json:"breed"` // 品种
HealthStatus string `gorm:"size:16;default:正常" json:"health_status"`
+2
View File
@@ -59,6 +59,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/record-types", h.RecordTypes)
g.GET("/breeds", h.Breeds)
g.GET("/life-stages", h.LifeStages)
g.GET("/pets/:id/records", h.ListRecords)
g.POST("/pets/:id/records", h.CreateRecord)
g.GET("/pets/:id/records/weight-trend", h.WeightTrend)
@@ -73,6 +74,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/pets/:id/plan", h.GetPlan)
g.GET("/pets/:id/plan/calendar", h.PlanCalendar)
g.GET("/pets/:id/stage-drift", h.PetStageDrift)
g.GET("/pets/:id/plan/month-status", h.PlanMonthStatus)
// 养护方案:列出可选的、套用一套到某个月
g.GET("/care-templates", h.CareTemplateOptions)
+4 -43
View File
@@ -13,41 +13,13 @@ import (
"github.com/sundynix/pets-be/internal/model"
)
// 模板分组。注意 StageNewHome 不是年龄阶段,而是「刚到家」叠加包
// 建档时先按年龄阶段取一套,若到家不足 30 天,再把这一套叠上去
const (
StageNewHome = "刚到家 0-30 天"
StageJuvenile = "幼年期"
StageAdult = "成年期"
StageSenior = "老年期"
)
// 阶段常量、CareStages 和判定逻辑都搬到 life_stage.go 了
// 那边一张表既驱动判定又直接吐给前端当「划分依据」,两个用途一份数据
// 阶段从 3 段(幼年/成年/老年)细分成 5 段,狗还按体型走不同阈值。
// CareSpecies / CareStages 支持的物种与分组(字符串与 onboarding 一致)
// CareSpecies 支持的物种(字符串与 onboarding 一致)
var CareSpecies = []string{model.SpeciesCat, model.SpeciesDog}
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 {
@@ -62,17 +34,6 @@ func monthsSince(t time.Time) int {
return m
}
// NormalizeStage 定下最终使用的年龄阶段。
// 用户手选优先(他可能比生日更清楚,比如领养来的估算年龄),
// 但只认三个年龄阶段;传了「刚到家」或空值时,一律按生日推断。
func NormalizeStage(species, picked string, birthday *time.Time) string {
for _, s := range AgeStages {
if picked == s {
return picked
}
}
return StageFromBirthday(species, birthday)
}
// SpeciesOf 由宠物类型(猫猫/狗狗)映射到模板物种键
func SpeciesOf(petType string) string {
+250
View File
@@ -0,0 +1,250 @@
package service
import (
"fmt"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// 生命阶段的划分标准。
//
// 这张表既用来判定阶段,也直接吐给前端当「划分依据」展示 —— 一份数据两个用途,
// 而不是「代码里判定 + 文档里写标准」。后者一定会漂:改了阈值忘了改文档,
// 用户看到的说明和实际行为对不上,比没有说明更糟。
//
// ── 为什么猫狗不能共用阈值 ──
//
// 猫的发育在品种间差别不大。狗完全取决于体型:
// 吉娃娃 8 个月已经性成熟、可以换成犬粮;大丹犬 8 个月还在长骨头,
// 这时候喂成犬粮(钙磷比和热量密度都不同)会加重关节负担。
// 吉娃娃 7 岁还在成年期(8 岁才进中年,能活 13-16 年);
// 大丹犬 7 岁已经是老年(平均寿命 8-10 年)。
//
// 原来代码里是一刀切「<12 月幼年,>=7 岁老年」,对猫勉强,对狗两头都错。
//
// 体型不能靠当前体重判断 —— 幼犬的体重说明不了它成年后多大。只能靠品种,
// 所以 breeds 表带 size_class。
// 五个阶段。标签故意做成物种中立(不分「幼猫期/幼犬期」):
// 物种本来就是另一个字段,分开会让 (species, stage) 的组合和模板配置都翻倍。
const (
StageJuvenile = "幼年期"
StageYoung = "青年期"
StageAdult = "成年期"
StageMature = "中年期"
StageSenior = "老年期"
// StageNewHome 和年龄正交:刚接回家的成年猫两套照护都需要。
// 它不参与年龄判定,是叠加上去的一层。
StageNewHome = "刚到家 0-30 天"
)
// CareStages 后台配方案时可选的阶段。刚到家放最前面,它是所有宠物都会经历的
var CareStages = []string{StageNewHome, StageJuvenile, StageYoung, StageAdult, StageMature, StageSenior}
// 犬类体型。分界取自成年体重,是行业通行的分法
const (
SizeSmall = "small" // < 10kg
SizeMedium = "medium" // 10-25kg
SizeLarge = "large" // 25-40kg
SizeGiant = "giant" // > 40kg
)
// SizeLabels 体型的中文名和依据,后台和小程序都用它
var SizeLabels = map[string]string{
SizeSmall: "小型犬(成年 <10kg",
SizeMedium: "中型犬(成年 10-25kg",
SizeLarge: "大型犬(成年 25-40kg",
SizeGiant: "巨型犬(成年 >40kg",
}
// StageRule 一个阶段的月龄区间和依据
type StageRule struct {
Stage string `json:"stage"`
FromMon int `json:"from_month"` // 含
ToMon int `json:"to_month"` // 不含;0 = 无上限
Range string `json:"range"` // 展示用,如「6-12 月」
Basis string `json:"basis"` // 划分依据,直接展示给用户
Focus string `json:"focus"` // 这个阶段的养护重点
}
// 猫:AAFP/AAHA 猫生命阶段指南那套,在「成年」里细分出中年
var catStages = []StageRule{
{StageJuvenile, 0, 6, "0-6 月", "断奶到六月龄是生长最快的阶段,三针核心疫苗和首次驱虫都在这里完成",
"疫苗序列、驱虫、社会化、少量多餐"},
{StageYoung, 6, 12, "6-12 月", "六月龄前后性成熟,绝育窗口;一岁前后换成猫粮",
"绝育安排、换成猫粮、开始控体重"},
{StageAdult, 12, 84, "1-7 岁", "身体状态最稳定的时期,重点是把基线数据记下来",
"每月称重、年度体检、口腔护理"},
{StageMature, 84, 132, "7-11 岁", "七岁起代谢变慢,年度血检开始能查出早期问题",
"年度血检、体重和牙结石重点看"},
{StageSenior, 132, 0, "11 岁以上", "十一岁后肾脏和甲状腺问题高发,体检频率要加倍",
"半年一次体检、肾和甲状腺、饮水量重点记"},
}
// 狗:同样五段,阈值按体型走。越大的犬种生长期越长、老得越早
var dogStages = map[string][]StageRule{
SizeSmall: {
{StageJuvenile, 0, 8, "0-8 月", "小型犬生长期短,八月龄基本定型", "疫苗序列、驱虫、社会化"},
{StageYoung, 8, 12, "8-12 月", "性成熟早,绝育窗口也早", "绝育安排、换成犬粮"},
{StageAdult, 12, 96, "1-8 岁", "小型犬寿命长,成年期也长", "每月称重、年度体检、牙结石"},
{StageMature, 96, 132, "8-11 岁", "八岁起进入中年,心脏和牙齿是重点", "年度血检、心脏听诊、洁牙"},
{StageSenior, 132, 0, "11 岁以上", "小型犬普遍能活到 13-16 岁,十一岁后算老年", "半年一次体检、关节和心脏"},
},
SizeMedium: {
{StageJuvenile, 0, 12, "0-12 月", "一岁前后完成生长", "疫苗序列、驱虫、社会化"},
{StageYoung, 12, 18, "12-18 月", "骨骼刚闭合,可以开始正常运动量", "换成犬粮、运动量上调"},
{StageAdult, 18, 84, "1.5-7 岁", "维持期", "每月称重、年度体检、牙结石"},
{StageMature, 84, 120, "7-10 岁", "七岁起体重和关节要重点看", "年度血检、体重控制、关节"},
{StageSenior, 120, 0, "10 岁以上", "中型犬平均寿命 10-13 岁", "半年一次体检、关节和肿瘤筛查"},
},
SizeLarge: {
{StageJuvenile, 0, 15, "0-15 月", "大型犬生长期长到 15 个月,这期间不能过量补钙或过早剧烈运动",
"控制生长速度、疫苗序列、避免跳跃"},
{StageYoung, 15, 24, "15-24 月", "两岁前骨骼才完全闭合", "换成犬粮、逐步加运动量"},
{StageAdult, 24, 72, "2-6 岁", "维持期较短", "每月称重、年度体检、髋关节"},
{StageMature, 72, 96, "6-8 岁", "六岁起就要按中年管,比中小型犬早", "年度血检、髋关节、体重控制"},
{StageSenior, 96, 0, "8 岁以上", "大型犬平均寿命 8-12 岁,八岁进老年", "半年一次体检、关节和心脏"},
},
SizeGiant: {
{StageJuvenile, 0, 18, "0-18 月", "巨型犬生长期最长,18 个月还在长;这阶段最忌过度补钙",
"严格控制生长速度、疫苗序列、限制跳跃"},
{StageYoung, 18, 24, "18-24 月", "两岁才算成年", "换成犬粮、关节保养开始"},
{StageAdult, 24, 60, "2-5 岁", "成年期很短", "每月称重、年度体检、心脏和关节"},
{StageMature, 60, 84, "5-7 岁", "五岁就要按中年管 —— 巨型犬老得最快", "半年一次血检、心脏、体重"},
{StageSenior, 84, 0, "7 岁以上", "巨型犬平均寿命 7-10 岁", "每半年体检、心脏和骨科肿瘤"},
},
}
// StageRules 某个物种(狗还要看体型)的完整划分标准,给前端展示用
func StageRules(species, sizeClass string) []StageRule {
if species == model.SpeciesDog {
if rs, ok := dogStages[sizeClass]; ok {
return rs
}
return dogStages[SizeMedium] // 品种没标体型时按中型算,最不容易错得离谱
}
return catStages
}
// stageOfMonths 按月龄在规则表里落位
func stageOfMonths(rules []StageRule, months int) string {
for _, r := range rules {
if months >= r.FromMon && (r.ToMon == 0 || months < r.ToMon) {
return r.Stage
}
}
return StageAdult
}
// StageFromAge 由物种、体型、月龄推断阶段
func StageFromAge(species, sizeClass string, months int) string {
if months < 0 {
return StageAdult // 没有生日只能按成年算,它是最不容易出错的默认
}
return stageOfMonths(StageRules(species, sizeClass), months)
}
// sizeClassOfBreed 查品种的体型。查不到返回空,上层按中型兜底
func (s *Service) sizeClassOfBreed(species, breed string) string {
if species != model.SpeciesDog || breed == "" {
return ""
}
var b model.Breed
if err := s.db.Where("species = ? AND name = ?", species, breed).First(&b).Error; err != nil {
return ""
}
return b.SizeClass
}
// StageOf 由生日推断阶段(狗会查品种体型)
func (s *Service) StageOf(species, breed string, birthday *time.Time) string {
if birthday == nil || birthday.IsZero() {
return StageAdult
}
return StageFromAge(species, s.sizeClassOfBreed(species, breed), monthsSince(*birthday))
}
// NormalizeStage 定下最终使用的阶段。
// 用户手选的优先(领养的猫生日是估的,主人按体型判断更准;而模型里的月龄阈值
// 对混种和边界体型都是一刀切);没选或选了不认识的值才按生日推。
func (s *Service) NormalizeStage(species, breed, picked string, birthday *time.Time) string {
for _, v := range CareStages {
if picked == v {
return picked
}
}
return s.StageOf(species, breed, birthday)
}
// AgeLabel 月龄的中文展示。不入库 —— 存下来第二天就过期了
func AgeLabel(birthday *time.Time) string {
if birthday == nil || birthday.IsZero() {
return ""
}
m := monthsSince(*birthday)
if m < 0 {
return ""
}
if m < 24 {
return fmt.Sprintf("%d个月", m)
}
if m%12 == 0 {
return fmt.Sprintf("%d岁", m/12)
}
return fmt.Sprintf("%d岁%d个月", m/12, m%12)
}
// StageDrift 「档案里存的阶段」和「按生日算出来的阶段」不一致。
//
// 为什么不自动改:
// 1. 用户可能手改过 —— 领养的猫生日是估的,主人按体型判断比月龄阈值准
// 2. 已经排好的这个月计划不该被推翻。套用方案是「往里加」,不是「替换」
// 3. 偷偷换会让用户莫名发现计划变了,找不到原因
//
// 所以系统只负责发现,用户负责决定:小程序端提示「它已经进入成年期了,
// 要不要换一套方案」,点了才套用。
type StageDrift struct {
Drifted bool `json:"drifted"`
Stored string `json:"stored"` // 档案里现在存的
Computed string `json:"computed"` // 按生日算出来的
AgeLabel string `json:"age_label"` // 「1岁3个月」
Basis string `json:"basis"` // 新阶段的划分依据,一并给出来
Focus string `json:"focus"` // 新阶段的养护重点
Size string `json:"size"` // 犬类的体型说明,猫为空
}
// CheckStageDrift 检查某只宠物的阶段有没有漂
func (s *Service) CheckStageDrift(pet *model.Pet) *StageDrift {
out := &StageDrift{Stored: pet.Stage, AgeLabel: AgeLabel(pet.Birthday)}
if pet.Birthday == nil || pet.Birthday.IsZero() {
return out // 没生日算不了,不提示
}
// 「刚到家」是叠加层不是年龄段,处在这个状态时不谈漂移
if pet.Stage == StageNewHome {
return out
}
species := SpeciesOf(pet.Type)
size := s.sizeClassOfBreed(species, pet.Breed)
out.Computed = StageFromAge(species, size, monthsSince(*pet.Birthday))
if species == model.SpeciesDog {
if l, ok := SizeLabels[size]; ok {
out.Size = l
} else {
out.Size = SizeLabels[SizeMedium] + "(品种未标体型,按中型算)"
}
}
if out.Computed == out.Stored || out.Computed == "" {
return out
}
out.Drifted = true
for _, r := range StageRules(species, size) {
if r.Stage == out.Computed {
out.Basis = r.Basis
out.Focus = r.Focus
break
}
}
return out
}
+9
View File
@@ -51,6 +51,11 @@ func (s *Service) ListPets(userID string) ([]model.Pet, error) {
urls := s.fileURLs(ids)
for i := range pets {
pets[i].AvatarURL = urls[pets[i].AvatarFileID]
// Age 和 Stage 都是「会过期」的值。Age 直接现算(不入库);
// Stage 存着但要校准 —— 半年前建档时是幼年期,现在可能已经进青年期了。
// 校准只改返回值不写库:用户手改过的阶段不该被静默冲掉,
// 「该换一套方案了」这件事要提示他、由他决定(见 StageDrift)
pets[i].Age = AgeLabel(pets[i].Birthday)
}
return pets, err
}
@@ -60,6 +65,7 @@ func (s *Service) GetPet(userID, petID string) (*model.Pet, error) {
p, err := s.ownedPet(userID, petID)
if err == nil && p != nil {
p.AvatarURL = s.fileURL(p.AvatarFileID)
p.Age = AgeLabel(p.Birthday)
}
return p, err
}
@@ -91,6 +97,9 @@ func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) {
}); err != nil {
return nil, err
}
// Age 是计算字段,创建后的返回也得填 —— 不填前端拿到的是空字符串
pet.Age = AgeLabel(pet.Birthday)
pet.AvatarURL = s.fileURL(pet.AvatarFileID)
return &pet, nil
}
+3
View File
@@ -46,5 +46,8 @@ func (s *Service) ownedPet(userID, petID string) (*model.Pet, error) {
if err != nil {
return nil, err
}
// Age 是计算字段(gorm:"-")。在这里填而不是在每个调用点填 ——
// ownedPet 是所有「取一只宠物」的路径的收口,漏一个就会返回空年龄
pet.Age = AgeLabel(pet.Birthday)
return &pet, nil
}
+11 -3
View File
@@ -13,7 +13,8 @@ const SPECIES = [
{ key: 'cat', label: '猫猫', Icon: Cat },
{ key: 'dog', label: '狗狗', Icon: Dog },
]
const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期']
// 阶段列表从后端 meta 来(service.CareStages)。写死在这里的话,
// 后端从 3 段细分成 5 段之后这一页就少两个选项,而且不会报错
const REMINDER_LABEL: Record<string, string> = {
vaccine: '疫苗', deworm: '驱虫', weight: '体重', monthlyReport: '月报',
}
@@ -78,6 +79,7 @@ function blank(kind: Item['kind']): Item {
export default function CareTemplates() {
const [list, setList] = useState<Tpl[]>([])
const [types, setTypes] = useState<any[]>([])
const [stages, setStages] = useState<string[]>([])
const [open, setOpen] = useState(false)
const [tpl, setTpl] = useState<Tpl>({
id: '', species: 'cat', stage: '成年期', name: '', summary: '', sort: 0, enabled: true,
@@ -94,7 +96,13 @@ export default function CareTemplates() {
reload()
// 「关联记录事项」的可选值来自 record_types,不再是写死的几个。
// 原来那两个硬编码数组里还有 report / taskDetail 这种早就不存在的值
api.careTemplateMeta().then((m: any) => setTypes(m?.record_types || [])).catch(() => {})
api
.careTemplateMeta()
.then((m: any) => {
setTypes(m?.record_types || [])
setStages(m?.stages || [])
})
.catch(() => {})
}, [])
function create() {
@@ -267,7 +275,7 @@ export default function CareTemplates() {
value={tpl.stage}
onChange={(e) => setTpl({ ...tpl, stage: e.target.value })}
>
{STAGES.map((s) => (
{stages.map((s) => (
<option key={s} value={s}>
{s}
</option>
+46 -5
View File
@@ -4,7 +4,9 @@ const upload = require('../../utils/upload.js');
const { toastErr } = require('../../utils/ui.js');
const GENDERS = ['男孩', '女孩', '不确定'];
const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期'];
// 阶段列表和划分标准都从后端来。原来这里写死 4 个,后端细分成 5 段
// (而且狗还按体型走不同阈值)之后就对不上了 —— 而且不会报错,只是少两个选项。
// 「刚到家 0-30 天」在后端列表的第一个,它和年龄正交,是叠加层
function pad2(n) {
return n < 10 ? '0' + n : '' + n;
@@ -41,7 +43,10 @@ Page({
step: 0,
steps: ['名字', '品种', '资料'],
genders: GENDERS,
stages: STAGES,
stages: [],
stageRules: [],
stageBasis: '',
sizeLabel: '',
today: '',
stageAuto: '',
form: {
@@ -57,6 +62,7 @@ Page({
},
onLoad(q) {
this.setData({ today: ymd(new Date()) });
this.loadStages();
const id = (q && q.id) || '';
if (!id) return;
// 编辑:不分步,一屏全放开——用户是来改某一项的,不该被按顺序走一遍
@@ -78,7 +84,7 @@ Page({
arrived_at: p.arrived_at ? String(p.arrived_at).slice(0, 10) : '',
weight: (p.weight || '').replace('kg', ''),
color: p.color || '',
stage: STAGES.indexOf(p.stage) >= 0 ? p.stage : '',
stage: p.stage || '',
},
});
})
@@ -102,19 +108,53 @@ Page({
if (sp === this.data.form.species) return;
// 换了物种,原来选的品种一定不对了
this.setData({ 'form.species': sp, 'form.breed': '', breedGroups: [] });
this.loadStages();
},
pickGender(e) {
this.setData({ 'form.gender': e.currentTarget.dataset.v });
},
pickStage(e) {
this.setData({ 'form.stage': e.currentTarget.dataset.v });
const v = e.currentTarget.dataset.v;
this.setData({ 'form.stage': v, stageBasis: this.basisOf(v) });
},
basisOf(stage) {
const r = (this.data.stageRules || []).find((x) => x.stage === stage);
return r ? r.range + ' · ' + r.basis : '';
},
// 阶段的划分标准要按物种和体型取:同样 10 个月,吉娃娃已经是青年期,
// 大丹犬还在幼年期。体型从选中品种的 size_class 来
loadStages() {
const f = this.data.form;
let size = '';
(this.data.breedGroups || []).forEach((g) =>
g.items.forEach((b) => {
if (b.name === f.breed) size = b.size_class || '';
}),
);
api
.lifeStages(f.species, size)
.then((d) => {
const rules = (d && d.stages) || [];
this.setData({
stageRules: rules,
// 阶段选项 = 「刚到家」+ 五个年龄段。刚到家是叠加层,后端的
// CareStages 里也在第一个,这里保持一致
stages: ['刚到家 0-30 天'].concat(rules.map((r) => r.stage)),
sizeLabel: (d && d.sizes && d.sizes[size]) || '',
stageBasis: this.basisOf(f.stage),
});
})
.catch(() => {});
},
onBirthday(e) {
const v = e.detail.value;
const auto = stageFromBirthday(v);
const patch = { 'form.birthday': v, stageAuto: auto };
// 用户还没手选过阶段就跟着生日走;选过就不动他的选择
if (auto && !this.data.form.stage) patch['form.stage'] = auto;
if (auto && !this.data.form.stage) {
patch['form.stage'] = auto;
patch.stageBasis = this.basisOf(auto);
}
this.setData(patch);
},
onArrived(e) {
@@ -142,6 +182,7 @@ Page({
noop() {},
pickBreed(e) {
this.setData({ 'form.breed': e.currentTarget.dataset.name, breedShow: false });
this.loadStages(); // 换了品种可能换了体型,阶段阈值跟着变
},
clearBreed() {
// 「不确定」要留:领养的串串确实说不出品种,逼着选一个只会得到假数据
+5 -1
View File
@@ -78,7 +78,11 @@
<view wx:for="{{stages}}" wx:key="*this" class="option {{form.stage === item ? 'selected' : ''}}"
data-v="{{item}}" bindtap="pickStage">{{item}}</view>
</view>
<view wx:if="{{stageAuto}}" class="tiny">按生日推算是「{{stageAuto}}」,可以手改</view>
<!-- 把划分标准摆出来。用户不该猜「幼年期」是几个月到几个月,
狗还得知道自己这个体型的阈值和别的体型不一样 -->
<view wx:if="{{stageBasis}}" class="pf-basis">{{stageBasis}}</view>
<view wx:if="{{sizeLabel}}" class="tiny">{{sizeLabel}}——阶段阈值按体型算</view>
<view wx:if="{{stageAuto && stageAuto !== form.stage}}" class="tiny">按生日推算是「{{stageAuto}}」,可以手改</view>
</view>
</view>
</block>
+7
View File
@@ -84,3 +84,10 @@
font-size:var(--fs-cap);color:var(--muted);
padding:2rpx 8rpx;line-height:1.4;
}
/* 阶段的划分依据。摆出来而不是让用户猜「幼年期」是几个月到几个月 */
.pf-basis{
margin-top:var(--sp-2);background:var(--primary-soft);color:var(--primary-ink);
border-radius:var(--r-sm);padding:var(--sp-2) var(--sp-3);
font-size:var(--fs-cap);line-height:1.6;
}
+6
View File
@@ -54,6 +54,12 @@ const api = {
getPets: () => request({ url: '/api/pets' }),
homeSummary: (id) => request({ url: `/api/pets/${id}/home-summary` }),
getPet: (id) => request({ url: `/api/pets/${id}` }),
// 生命阶段的划分标准。判定和展示用同一份数据 —— 不另写一份说明文案,
// 否则改了阈值忘了改文案,用户看到的依据和实际行为对不上
lifeStages: (species, size) =>
request({ url: `/api/life-stages?species=${species}&size=${size || ''}` }),
petStageDrift: (petId) => request({ url: `/api/pets/${petId}/stage-drift` }),
// 品种(后台可配),按首字母分组
breeds: (species) => request({ url: '/api/breeds?species=' + species }),
createPet: (body) => request({ url: '/api/pets', method: 'POST', data: body }),