feat(admin): 养护方案页重写 —— 多套、重复规则、"弹层"改成真实的记录事项

## 你问的那个「弹层」是什么
sheet_type = 用户在小程序点这条计划时,直接跳到哪个记录表单。
「体重记录」这条关联 weight,点了就是体重表单,不用自己去记录页找。

叫「弹层」是因为那时候记录表单是底部弹出的浮层。三件事让这个名字烂掉了:
  1. 记录表单上一批已经搬成 pages/addrecord 独立页面了
  2. 可选值写死 6 个,而记录事项现在有 24 种且后台可配 —— 新加的一个都选不到
  3. 里头的 report / taskDetail 早就不存在,选了等于点了没反应

现在改成「关联记录事项」,下拉读 record_types 表。后台加一种事项,
这里立刻能选到。

## 这一页原来和后端已经对不上了
它的结构(今日任务 / 30 天计划 / 提醒 三段、第 N 天偏移)就是上一个提交在后端
推翻掉的那套。你在上面配的东西一部分已经不会生效——所以先改它,不等前端。

  三段 → 两段(计划事项 / 到期提醒)
  「第 N 天 + 标签」 → 重复规则:每天 / 每周(勾星期几)/ 每月(几号)/ 一次性
  一个 (物种,阶段) 一套 → 多套,列表页展示,每套有名字和摘要

## 后端跟着改成按 id 存取
原来是「按 species+stage 覆盖」:GET/PUT /admin/care-templates?species=&stage=。
那个模型下「让用户选方案」无从选起。改成
  GET    /admin/care-templates       列全部
  GET    /admin/care-templates/:id
  POST   /admin/care-templates       新增或更新
  DELETE /admin/care-templates/:id

## 唯一索引是真拦路石
从模型 tag 里去掉 uniqueIndex:idx_species_stage 之后,新增第二套仍然报
Duplicate entry —— **AutoMigrate 会加索引但不会删已有的**。写了个一次性程序
DropIndex 才通。这个坑值得记:改索引不能只改 tag。

## 校验放服务端
一条配错(「每周」没勾星期几)会让套用时静默少排几条,用户看不出是配错了。
所以保存时就拦,并且报出是哪一条:「梳毛」选了每周,但没勾星期几。

## 列表里报「展开后大概多少条」
只报明细条数会骗人:4 条「每天」套一整月就是 120 多条。
列表和小程序端的方案卡都按重复方式分开报数。

## 验证(预生产库实跑 service 层)
  8 套现有方案迁移后都有名字
  没名字 / 每周没勾星期几 / 每月几号填 0 → 三条校验都拦住并指名道姓
  (猫,成年期) 成功配出第二套「成年猫省心版」,小程序端能看到 2 套可选
  tsc --noEmit 通过;npm run build 后重启,/admin/ 资源哈希与新构建一致

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-30 11:59:52 +08:00
parent 8b1b4cde02
commit afdbf94450
9 changed files with 546 additions and 268 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<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-DkaMHmZz.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-0lrkDIfq.css">
<script type="module" crossorigin src="/admin/assets/index-DyVnSuaV.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-PPcOOdtF.css">
</head>
<body>
<div id="root"></div>
+40 -12
View File
@@ -377,43 +377,71 @@ func (h *Handler) AdminCareTemplateMeta(c *gin.Context) {
{"key": model.SpeciesCat, "label": "猫猫"},
{"key": model.SpeciesDog, "label": "狗狗"},
},
"stages": service.CareStages,
"task_sheets": []string{"", "weight", "poop", "food", "symptom", "vaccine", "medicine"},
"plan_sheets": []string{"", "weight", "vaccine", "medicine", "report", "taskDetail"},
"stages": service.CareStages,
// 「关联记录事项」的可选值读 record_types 表,不再写死。
// 原来是两个硬编码数组(task_sheets / plan_sheets),里头还有
// report / taskDetail 这种早就不存在的值 —— 选了等于点了没反应;
// 而后台新加的 24 种事项一个都选不到
"record_types": h.svc.AdminListRecordTypes(),
"repeats": []string{"once", "daily", "weekly", "monthly"},
"reminder_types": []string{"vaccine", "deworm", "weight", "monthlyReport"},
"priorities": []string{"", "重要"},
})
}
// AdminGetCareTemplate GET /api/admin/care-templates?species=&stage=
// AdminListCareTemplates GET /api/admin/care-templates 全部方案
func (h *Handler) AdminListCareTemplates(c *gin.Context) {
response.OK(c, h.svc.AdminListTemplates())
}
// AdminGetCareTemplate GET /api/admin/care-templates/:id 一套方案
func (h *Handler) AdminGetCareTemplate(c *gin.Context) {
data, err := h.svc.GetCareTemplate(c.Query("species"), c.Query("stage"))
tpl, err := h.svc.AdminGetTemplate(idParam(c, "id"))
if err != nil {
response.FailParams(c, err.Error())
respondErr(c, err)
return
}
response.OK(c, data)
response.OK(c, tpl)
}
type careTemplateReq struct {
ID string `json:"id"`
Species string `json:"species"`
Stage string `json:"stage"`
Name string `json:"name"`
Summary string `json:"summary"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
Items []model.CareTemplateItem `json:"items"`
}
// AdminSaveCareTemplate PUT /api/admin/care-templates
// AdminSaveCareTemplate POST /api/admin/care-templates 新增或更新一套
func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
var req careTemplateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SaveCareTemplate(req.Species, req.Stage, req.Items); err != nil {
response.FailErr(c, err)
tpl := &model.CareTemplate{
Species: req.Species, Stage: req.Stage, Name: req.Name,
Summary: req.Summary, Sort: req.Sort, Enabled: req.Enabled,
}
tpl.ID = req.ID
if err := h.svc.SaveTemplate(tpl, req.Items); err != nil {
respondErr(c, err)
return
}
data, _ := h.svc.GetCareTemplate(req.Species, req.Stage)
response.OK(c, data)
saved, _ := h.svc.AdminGetTemplate(tpl.ID)
response.OK(c, saved)
}
// AdminDeleteCareTemplate DELETE /api/admin/care-templates/:id
func (h *Handler) AdminDeleteCareTemplate(c *gin.Context) {
if err := h.svc.DeleteTemplate(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
type careGenReq struct {
+4 -2
View File
@@ -163,8 +163,10 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.DELETE("/record-types/:id", h.AdminDeleteRecordType)
g.GET("/care-templates/meta", h.AdminCareTemplateMeta)
g.GET("/care-templates", h.AdminGetCareTemplate)
g.PUT("/care-templates", h.AdminSaveCareTemplate)
g.GET("/care-templates", h.AdminListCareTemplates)
g.GET("/care-templates/:id", h.AdminGetCareTemplate)
g.POST("/care-templates", h.AdminSaveCareTemplate)
g.DELETE("/care-templates/:id", h.AdminDeleteCareTemplate)
g.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
g.GET("/ai-quota", h.AdminGetAIQuota)
+82 -34
View File
@@ -106,51 +106,99 @@ func (s *Service) careTemplateItems(species, stage string) []model.CareTemplateI
}
// GetCareTemplate 后台读取:返回某组合的明细(DB 优先,无则内置默认)
func (s *Service) GetCareTemplate(species, stage string) (map[string]any, error) {
if !validSpecies(species) || !validStage(stage) {
return nil, errors.New("invalid species or stage")
// AdminListTemplates 后台:全部方案(含停用),按物种阶段排
func (s *Service) AdminListTemplates() []model.CareTemplate {
var all []model.CareTemplate
s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
Order("species asc, stage asc, sort asc, id asc").Find(&all)
if all == nil {
return []model.CareTemplate{}
}
return map[string]any{
"species": species,
"stage": stage,
"items": s.careTemplateItems(species, stage),
}, nil
return all
}
// AdminGetTemplate 后台:取一套
func (s *Service) AdminGetTemplate(id string) (*model.CareTemplate, error) {
var tpl model.CareTemplate
if err := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
First(&tpl, "id = ?", id).Error; err != nil {
return nil, ErrNotFound
}
return &tpl, nil
}
// DeleteTemplate 删一套方案。已经套用出去的计划节点不受影响——
// 套用时就已经展开成实体行、和模板断开了
func (s *Service) DeleteTemplate(id string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("template_id = ?", id).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
return tx.Delete(&model.CareTemplate{}, "id = ?", id).Error
})
}
// SaveCareTemplate 后台保存:整表替换该组合的明细
func (s *Service) SaveCareTemplate(species, stage string, items []model.CareTemplateItem) error {
if !validSpecies(species) || !validStage(stage) {
return errors.New("invalid species or stage")
// SaveTemplate 存一套方案。id 为空则新建 —— 同一个 (物种, 阶段) 可以有多套,
// 用户建档时挑一套。原来是「按 species+stage 覆盖」,那个模型下
// 「让用户选方案」根本无从选起
func (s *Service) SaveTemplate(in *model.CareTemplate, items []model.CareTemplateItem) error {
if !validSpecies(in.Species) || !validStage(in.Stage) {
return errors.New("物种或阶段不合法")
}
if strings.TrimSpace(in.Name) == "" {
return errors.New("方案要有名字 —— 用户建档时看到的就是它")
}
// 明细先校验一遍再落库:一条配错(比如「每周」没勾星期几)会让套用时
// 静默少排几条,用户看不出来是配错了
clean := make([]model.CareTemplateItem, 0, len(items))
for i := range items {
it := items[i]
it.Title = strings.TrimSpace(it.Title)
if it.Title == "" {
continue
}
if it.Kind != model.CareKindReminder {
it.Kind = model.CareKindPlan
switch it.Repeat {
case model.RepeatDaily, model.RepeatMonthly:
case model.RepeatWeekly:
if len(weekdaysOf(it.Weekdays)) == 0 {
return errors.New("「" + it.Title + "」选了每周,但没勾星期几")
}
default:
it.Repeat = model.RepeatOnce
}
if it.Repeat == model.RepeatMonthly && (it.MonthDay < 1 || it.MonthDay > 31) {
return errors.New("「" + it.Title + "」选了每月,几号要填 1-31")
}
}
it.ID = ""
it.Sort = i
clean = append(clean, it)
}
return s.db.Transaction(func(tx *gorm.DB) error {
var tpl model.CareTemplate
err := tx.Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
tpl = model.CareTemplate{Species: species, Stage: stage}
if err := tx.Create(&tpl).Error; err != nil {
if in.ID == "" {
if err := tx.Create(in).Error; err != nil {
return err
}
} else if err != nil {
return err
}
if err := tx.Where("template_id = ?", tpl.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
clean := make([]model.CareTemplateItem, 0, len(items))
for i := range items {
it := items[i]
if strings.TrimSpace(it.Title) == "" {
continue
} else {
if err := tx.Model(&model.CareTemplate{}).Where("id = ?", in.ID).Updates(map[string]any{
"species": in.Species, "stage": in.Stage, "name": in.Name,
"summary": in.Summary, "sort": in.Sort, "enabled": in.Enabled,
}).Error; err != nil {
return err
}
it.ID = ""
it.TemplateID = tpl.ID
it.Sort = i
clean = append(clean, it)
if err := tx.Where("template_id = ?", in.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
}
for i := range clean {
clean[i].TemplateID = in.ID
}
if len(clean) > 0 {
if err := tx.Create(&clean).Error; err != nil {
return err
}
return tx.Create(&clean).Error
}
return nil
})