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:
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user