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
})
+5 -6
View File
@@ -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,
+409 -208
View File
@@ -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>
)
}