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
|
||||
})
|
||||
|
||||
@@ -121,12 +121,11 @@ export const api = {
|
||||
memberships: (params: any) => http.get<any, Page<any>>('/admin/memberships', { params }),
|
||||
|
||||
careTemplateMeta: () => http.get<any, any>('/admin/care-templates/meta'),
|
||||
careTemplate: (species: string, stage: string) =>
|
||||
http.get<any, { species: string; stage: string; items: any[] }>('/admin/care-templates', {
|
||||
params: { species, stage },
|
||||
}),
|
||||
saveCareTemplate: (species: string, stage: string, items: any[]) =>
|
||||
http.put<any, { items: any[] }>('/admin/care-templates', { species, stage, items }),
|
||||
// 方案改成「同一个物种阶段可以多套」,所以按 id 存取,不再按 species+stage 覆盖
|
||||
careTemplates: () => http.get<any, any[]>('/admin/care-templates'),
|
||||
careTemplate: (id: string) => http.get<any, any>(`/admin/care-templates/${id}`),
|
||||
saveCareTemplate: (t: any) => http.post<any, any>('/admin/care-templates', t),
|
||||
deleteCareTemplate: (id: string) => http.delete(`/admin/care-templates/${id}`),
|
||||
genCareTemplate: (species: string, stage: string) =>
|
||||
http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/generate', {
|
||||
species,
|
||||
|
||||
@@ -1,285 +1,486 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Cat, Dog, Wand2, Plus, Trash2, Save, Info } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Cat, Dog, Plus, Trash2, Wand2, Info } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
|
||||
const SPECIES = [
|
||||
{ key: 'cat', label: '猫猫', Icon: Cat },
|
||||
{ key: 'dog', label: '狗狗', Icon: Dog },
|
||||
]
|
||||
const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期']
|
||||
|
||||
const SHEET_LABEL: Record<string, string> = {
|
||||
'': '无', weight: '体重', poop: '便便', food: '饮食', symptom: '异常',
|
||||
vaccine: '疫苗', medicine: '用药', report: '报告', taskDetail: '任务详情',
|
||||
}
|
||||
const REMINDER_LABEL: Record<string, string> = {
|
||||
vaccine: '疫苗', deworm: '驱虫', weight: '体重', monthlyReport: '月报',
|
||||
}
|
||||
const TASK_SHEETS = ['', 'weight', 'poop', 'food', 'symptom', 'vaccine', 'medicine']
|
||||
const PLAN_SHEETS = ['', 'weight', 'vaccine', 'medicine', 'report', 'taskDetail']
|
||||
const REMINDER_TYPES = ['vaccine', 'deworm', 'weight', 'monthlyReport']
|
||||
|
||||
const selectCls =
|
||||
'h-9 rounded-md border border-input bg-card px-2 text-sm shrink-0'
|
||||
const REPEATS = [
|
||||
{ key: 'daily', label: '每天' },
|
||||
{ key: 'weekly', label: '每周' },
|
||||
{ key: 'monthly', label: '每月' },
|
||||
{ key: 'once', label: '一次性' },
|
||||
]
|
||||
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']
|
||||
const selectCls = 'h-9 rounded-md border border-input bg-card px-2 text-sm shrink-0'
|
||||
|
||||
type Item = {
|
||||
_k: number
|
||||
kind: 'task' | 'plan' | 'reminder'
|
||||
kind: 'plan' | 'reminder'
|
||||
title: string
|
||||
description: string
|
||||
priority: string
|
||||
sheet_type: string
|
||||
repeat: string
|
||||
day: number
|
||||
day_label: string
|
||||
weekdays: string
|
||||
month_day: number
|
||||
reminder_type: string
|
||||
offset_days: number
|
||||
frequency: string
|
||||
}
|
||||
type Tpl = {
|
||||
id: string
|
||||
species: string
|
||||
stage: string
|
||||
name: string
|
||||
summary: string
|
||||
sort: number
|
||||
enabled: boolean
|
||||
items?: any[]
|
||||
}
|
||||
|
||||
let uid = 1
|
||||
function normalize(raw: any[]): Item[] {
|
||||
return (raw || []).map((r) => ({
|
||||
function toItem(r: any): Item {
|
||||
return {
|
||||
_k: uid++,
|
||||
kind: r.kind,
|
||||
kind: r.kind === 'reminder' ? 'reminder' : 'plan',
|
||||
title: r.title || '',
|
||||
description: r.description || '',
|
||||
priority: r.priority || '',
|
||||
sheet_type: r.sheet_type || '',
|
||||
repeat: r.repeat || 'once',
|
||||
day: r.day ?? 0,
|
||||
day_label: r.day_label || '',
|
||||
weekdays: r.weekdays || '',
|
||||
month_day: r.month_day ?? 1,
|
||||
reminder_type: r.reminder_type || 'vaccine',
|
||||
offset_days: r.offset_days ?? 0,
|
||||
frequency: r.frequency || '',
|
||||
}))
|
||||
}
|
||||
}
|
||||
function blank(kind: Item['kind']): Item {
|
||||
return {
|
||||
_k: uid++, kind, title: '', description: '', priority: '', sheet_type: '',
|
||||
day: 0, day_label: '', reminder_type: 'vaccine', offset_days: 0, frequency: '',
|
||||
}
|
||||
return toItem({ kind, repeat: kind === 'plan' ? 'daily' : '' })
|
||||
}
|
||||
|
||||
export default function CareTemplates() {
|
||||
const [species, setSpecies] = useState('cat')
|
||||
const [stage, setStage] = useState('成年期')
|
||||
const [list, setList] = useState<Tpl[]>([])
|
||||
const [types, setTypes] = useState<any[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [tpl, setTpl] = useState<Tpl>({
|
||||
id: '', species: 'cat', stage: '成年期', name: '', summary: '', sort: 0, enabled: true,
|
||||
})
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [gen, setGen] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const reqId = useRef(0)
|
||||
|
||||
async function load(sp: string, st: string) {
|
||||
const my = ++reqId.current
|
||||
setLoading(true)
|
||||
setMsg('')
|
||||
function reload() {
|
||||
api.careTemplates().then((r) => setList(r || [])).catch(() => setList([]))
|
||||
}
|
||||
useEffect(() => {
|
||||
reload()
|
||||
// 「关联记录事项」的可选值来自 record_types,不再是写死的几个。
|
||||
// 原来那两个硬编码数组里还有 report / taskDetail 这种早就不存在的值
|
||||
api.careTemplateMeta().then((m: any) => setTypes(m?.record_types || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function create() {
|
||||
setTpl({ id: '', species: 'cat', stage: '成年期', name: '', summary: '', sort: 0, enabled: true })
|
||||
setItems([])
|
||||
setErr('')
|
||||
setOpen(true)
|
||||
}
|
||||
async function edit(t: Tpl) {
|
||||
setErr('')
|
||||
const full = await api.careTemplate(t.id)
|
||||
setTpl({ ...t })
|
||||
setItems(((full as any)?.items || []).map(toItem))
|
||||
setOpen(true)
|
||||
}
|
||||
async function save() {
|
||||
setErr('')
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = await api.careTemplate(sp, st)
|
||||
if (my === reqId.current) {
|
||||
setItems(normalize(data.items))
|
||||
setDirty(false)
|
||||
}
|
||||
await api.saveCareTemplate({ ...tpl, items })
|
||||
setOpen(false)
|
||||
reload()
|
||||
} catch (e: any) {
|
||||
// 后端会指名道姓说哪条配错了(「XX 选了每周,但没勾星期几」)
|
||||
setErr(e?.message || '保存失败')
|
||||
} finally {
|
||||
if (my === reqId.current) setLoading(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(species, stage)
|
||||
}, [species, stage])
|
||||
|
||||
function update(k: number, key: keyof Item, value: any) {
|
||||
setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [key]: value } : it)))
|
||||
setDirty(true)
|
||||
async function del(t: Tpl) {
|
||||
if (!confirm(`删除方案「${t.name || t.stage}」?已经套用出去的计划不受影响。`)) return
|
||||
await api.deleteCareTemplate(t.id).catch((e: any) => alert(e?.message || '删除失败'))
|
||||
reload()
|
||||
}
|
||||
function remove(k: number) {
|
||||
setItems((prev) => prev.filter((it) => it._k !== k))
|
||||
setDirty(true)
|
||||
}
|
||||
function add(kind: Item['kind']) {
|
||||
setItems((prev) => [...prev, blank(kind)])
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
async function draft() {
|
||||
setGen(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const data = await api.genCareTemplate(species, stage)
|
||||
setItems(normalize(data.items))
|
||||
setDirty(true)
|
||||
setMsg('已生成草稿,确认无误后点「保存该模板」')
|
||||
const r: any = await api.genCareTemplate(tpl.species, tpl.stage)
|
||||
setItems((r?.items || []).map(toItem))
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || 'AI 生成失败')
|
||||
setErr(e?.message || 'AI 生成失败')
|
||||
} finally {
|
||||
setGen(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setMsg('')
|
||||
try {
|
||||
await api.saveCareTemplate(species, stage, items)
|
||||
setDirty(false)
|
||||
setMsg('已保存')
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
const upd = (k: number, field: string, v: any) =>
|
||||
setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [field]: v } : it)))
|
||||
const rm = (k: number) => setItems((prev) => prev.filter((it) => it._k !== k))
|
||||
function toggleWd(it: Item, idx: number) {
|
||||
const n = String(idx + 1)
|
||||
const cur = it.weekdays.split(',').filter(Boolean)
|
||||
const next = cur.includes(n) ? cur.filter((x) => x !== n) : [...cur, n].sort()
|
||||
upd(it._k, 'weekdays', next.join(','))
|
||||
}
|
||||
|
||||
const tasks = useMemo(() => items.filter((i) => i.kind === 'task'), [items])
|
||||
const plans = useMemo(() => items.filter((i) => i.kind === 'plan'), [items])
|
||||
const reminders = useMemo(() => items.filter((i) => i.kind === 'reminder'), [items])
|
||||
const plans = items.filter((i) => i.kind === 'plan')
|
||||
const reminders = items.filter((i) => i.kind === 'reminder')
|
||||
|
||||
// 列表里展示「这套方案套一个月大概会排多少条」——只报明细条数会骗人:
|
||||
// 4 条「每天」套一整月就是 120 多条
|
||||
function spread(t: Tpl) {
|
||||
const its = t.items || []
|
||||
const c = { daily: 0, weekly: 0, monthly: 0, once: 0, rem: 0 }
|
||||
its.forEach((i: any) => {
|
||||
if (i.kind === 'reminder') c.rem++
|
||||
else if (i.repeat === 'daily') c.daily++
|
||||
else if (i.repeat === 'weekly') c.weekly++
|
||||
else if (i.repeat === 'monthly') c.monthly++
|
||||
else c.once++
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">养护模板</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
建档时按宠物的物种和阶段套用;改动只影响之后建档 / 重新建档的宠物。
|
||||
<h1 className="text-2xl font-bold">养护方案</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
用户建档最后一步从这里挑一套,也可以选「先空着自己排」。同一个物种阶段可以配多套。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={generate} disabled={gen}>
|
||||
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 生成草稿'}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving || !dirty}>
|
||||
<Save className="h-4 w-4" /> {saving ? '保存中…' : '保存该模板'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={create}>
|
||||
<Plus className="h-4 w-4" /> 新建方案
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-2">
|
||||
{SPECIES.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setSpecies(s.key)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
||||
species === s.key ? 'bg-primary/15 text-primary border-primary/40' : 'text-muted-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<s.Icon className="h-4 w-4" /> {s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{STAGES.map((st) => (
|
||||
<button
|
||||
key={st}
|
||||
onClick={() => setStage(st)}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
||||
stage === st ? 'bg-primary/15 text-primary border-primary/40' : 'text-muted-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{st}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">物种/阶段</TableHead>
|
||||
<TableHead>方案名</TableHead>
|
||||
<TableHead>内容</TableHead>
|
||||
<TableHead className="w-20">状态</TableHead>
|
||||
<TableHead className="w-32 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list.map((t) => {
|
||||
const c = spread(t)
|
||||
return (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{t.species === 'dog' ? '狗' : '猫'} · {t.stage}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{t.name || '(未命名)'}</div>
|
||||
<div className="text-xs text-muted-foreground">{t.summary}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
每天 {c.daily} · 每周 {c.weekly} · 每月 {c.monthly} · 一次性 {c.once} · 提醒 {c.rem}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.enabled ? <Badge>启用</Badge> : <Badge variant="secondary">停用</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => edit(t)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => del(t)}>
|
||||
删除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{!list.length && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||
还没有方案 —— 用户建档后计划页会是空的,只能自己排
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{msg && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md bg-primary/10 px-3 py-2 text-sm text-primary">
|
||||
<Info className="h-4 w-4" /> {msg}
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[88vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tpl.id ? '编辑方案' : '新建方案'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Section title="今日任务" count={tasks.length} onAdd={() => add('task')}>
|
||||
{tasks.map((it) => (
|
||||
<div key={it._k} className="flex flex-wrap items-center gap-2">
|
||||
<Input className="flex-1 min-w-40" placeholder="任务标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
|
||||
<Input className="flex-1 min-w-40" placeholder="说明" value={it.description} onChange={(e) => update(it._k, 'description', e.target.value)} />
|
||||
<select className={selectCls} value={it.priority} onChange={(e) => update(it._k, 'priority', e.target.value)}>
|
||||
<option value="">普通</option>
|
||||
<option value="重要">重要</option>
|
||||
</select>
|
||||
<select className={selectCls} value={it.sheet_type} onChange={(e) => update(it._k, 'sheet_type', e.target.value)}>
|
||||
{TASK_SHEETS.map((s) => <option key={s} value={s}>关联·{SHEET_LABEL[s]}</option>)}
|
||||
</select>
|
||||
<DelBtn onClick={() => remove(it._k)} />
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="30 天计划" count={plans.length} onAdd={() => add('plan')}>
|
||||
{plans.map((it) => (
|
||||
<div key={it._k} className="flex flex-wrap items-center gap-2">
|
||||
<Input type="number" className="w-16" placeholder="第N天" value={it.day} onChange={(e) => update(it._k, 'day', Number(e.target.value))} />
|
||||
<Input className="w-24" placeholder="标签" value={it.day_label} onChange={(e) => update(it._k, 'day_label', e.target.value)} />
|
||||
<Input className="flex-1 min-w-36" placeholder="节点标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
|
||||
<Input className="flex-1 min-w-36" placeholder="说明" value={it.description} onChange={(e) => update(it._k, 'description', e.target.value)} />
|
||||
<select className={selectCls} value={it.sheet_type} onChange={(e) => update(it._k, 'sheet_type', e.target.value)}>
|
||||
{PLAN_SHEETS.map((s) => <option key={s} value={s}>弹层·{SHEET_LABEL[s]}</option>)}
|
||||
</select>
|
||||
<DelBtn onClick={() => remove(it._k)} />
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="提醒" count={reminders.length} onAdd={() => add('reminder')}>
|
||||
{reminders.map((it) => (
|
||||
<div key={it._k} className="flex flex-wrap items-center gap-2">
|
||||
<select className={selectCls} value={it.reminder_type} onChange={(e) => update(it._k, 'reminder_type', e.target.value)}>
|
||||
{REMINDER_TYPES.map((s) => <option key={s} value={s}>{REMINDER_LABEL[s]}</option>)}
|
||||
</select>
|
||||
<Input className="flex-1 min-w-40" placeholder="提醒标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-sm text-muted-foreground">到期+</span>
|
||||
<Input type="number" className="w-16" value={it.offset_days} onChange={(e) => update(it._k, 'offset_days', Number(e.target.value))} />
|
||||
<span className="text-sm text-muted-foreground">天</span>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>物种</Label>
|
||||
<div className="flex gap-2">
|
||||
{SPECIES.map((s) => (
|
||||
<Button
|
||||
key={s.key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={tpl.species === s.key ? 'default' : 'outline'}
|
||||
onClick={() => setTpl({ ...tpl, species: s.key })}
|
||||
>
|
||||
<s.Icon className="h-4 w-4" /> {s.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Input className="flex-1 min-w-36" placeholder="或填频率(到期天数填0)" value={it.frequency} onChange={(e) => update(it._k, 'frequency', e.target.value)} />
|
||||
<DelBtn onClick={() => remove(it._k)} />
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
<div className="grid gap-2">
|
||||
<Label>阶段</Label>
|
||||
<select
|
||||
className={selectCls}
|
||||
value={tpl.stage}
|
||||
onChange={(e) => setTpl({ ...tpl, stage: e.target.value })}
|
||||
>
|
||||
{STAGES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>方案名(用户建档时看到的就是这个)</Label>
|
||||
<Input
|
||||
value={tpl.name}
|
||||
onChange={(e) => setTpl({ ...tpl, name: e.target.value })}
|
||||
placeholder="幼猫标准照护"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>一句话摘要</Label>
|
||||
<Input
|
||||
value={tpl.summary}
|
||||
onChange={(e) => setTpl({ ...tpl, summary: e.target.value })}
|
||||
placeholder="每天清砂盆和喂食,每周称体重,含疫苗驱虫提醒"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="ct-enabled"
|
||||
type="checkbox"
|
||||
checked={tpl.enabled}
|
||||
onChange={(e) => setTpl({ ...tpl, enabled: e.target.checked })}
|
||||
/>
|
||||
<Label htmlFor="ct-enabled" className="cursor-pointer">
|
||||
启用(停用后建档时挑不到)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5" /> 模板留空时建档会回退内置默认,不会让新宠没有任务。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* ── 计划事项 ── */}
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="font-medium">计划事项({plans.length})</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={draft} disabled={gen}>
|
||||
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 起草'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setItems([...items, blank('plan')])}>
|
||||
<Plus className="h-4 w-4" /> 加一条
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 flex items-start gap-2 rounded bg-muted p-2 text-xs text-muted-foreground">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
「关联记录事项」= 用户在小程序点这条计划时,直接跳到哪个记录表单。
|
||||
比如「记录一次体重」关联体重,点了就是体重表单,不用自己去记录页找。
|
||||
这个下拉读的是记录事项那一页的配置。
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{plans.map((it) => (
|
||||
<div key={it._k} className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="min-w-40 flex-1"
|
||||
placeholder="做什么"
|
||||
value={it.title}
|
||||
onChange={(e) => upd(it._k, 'title', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="min-w-40 flex-1"
|
||||
placeholder="说明"
|
||||
value={it.description}
|
||||
onChange={(e) => upd(it._k, 'description', e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className={selectCls}
|
||||
value={it.repeat}
|
||||
onChange={(e) => upd(it._k, 'repeat', e.target.value)}
|
||||
>
|
||||
{REPEATS.map((r) => (
|
||||
<option key={r.key} value={r.key}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{it.repeat === 'weekly' && (
|
||||
<div className="flex gap-1">
|
||||
{WEEKDAYS.map((w, i) => (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => toggleWd(it, i)}
|
||||
className={
|
||||
'h-7 w-7 rounded text-xs ' +
|
||||
(it.weekdays.split(',').includes(String(i + 1))
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border border-input bg-card')
|
||||
}
|
||||
>
|
||||
{w}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{it.repeat === 'monthly' && (
|
||||
<Input
|
||||
type="number"
|
||||
className="w-20"
|
||||
placeholder="几号"
|
||||
value={it.month_day}
|
||||
onChange={(e) => upd(it._k, 'month_day', Number(e.target.value) || 1)}
|
||||
/>
|
||||
)}
|
||||
{it.repeat === 'once' && (
|
||||
<Input
|
||||
type="number"
|
||||
className="w-24"
|
||||
placeholder="第N天"
|
||||
value={it.day}
|
||||
onChange={(e) => upd(it._k, 'day', Number(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
className={selectCls}
|
||||
value={it.priority}
|
||||
onChange={(e) => upd(it._k, 'priority', e.target.value)}
|
||||
>
|
||||
<option value="">普通</option>
|
||||
<option value="重要">重要</option>
|
||||
</select>
|
||||
<select
|
||||
className={selectCls}
|
||||
value={it.sheet_type}
|
||||
onChange={(e) => upd(it._k, 'sheet_type', e.target.value)}
|
||||
>
|
||||
<option value="">不关联</option>
|
||||
{types.map((t: any) => (
|
||||
<option key={t.code} value={t.code}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="ghost" size="sm" onClick={() => rm(it._k)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!plans.length && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">还没有计划事项</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 提醒 ── */}
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="font-medium">到期提醒({reminders.length})</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setItems([...items, blank('reminder')])}>
|
||||
<Plus className="h-4 w-4" /> 加一条
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
提醒不占计划页的格子,只在到期时冒出来。疫苗和驱虫这两类建档时就会自动建
|
||||
(漏针是真会出事的),不用等用户挑方案。
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{reminders.map((it) => (
|
||||
<div key={it._k} className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className={selectCls}
|
||||
value={it.reminder_type}
|
||||
onChange={(e) => upd(it._k, 'reminder_type', e.target.value)}
|
||||
>
|
||||
{REMINDER_TYPES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{REMINDER_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
className="min-w-40 flex-1"
|
||||
placeholder="提醒标题"
|
||||
value={it.title}
|
||||
onChange={(e) => upd(it._k, 'title', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder="N天后到期"
|
||||
value={it.offset_days}
|
||||
onChange={(e) => upd(it._k, 'offset_days', Number(e.target.value) || 0)}
|
||||
/>
|
||||
<Input
|
||||
className="w-40"
|
||||
placeholder="频率说明"
|
||||
value={it.frequency}
|
||||
onChange={(e) => upd(it._k, 'frequency', e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => rm(it._k)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!reminders.length && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">还没有提醒</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存方案'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, count, onAdd, children }: { title: string; count: number; onAdd: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="font-medium">
|
||||
{title} <span className="text-muted-foreground">{count}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onAdd}>
|
||||
<Plus className="h-4 w-4" /> 添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{count === 0 ? <p className="text-sm text-muted-foreground">暂无,点「添加」新增一条。</p> : children}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function DelBtn({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground shrink-0" onClick={onClick} aria-label="删除">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user