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
+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>
)
}