feat(auth): access token 缩到 2 小时 + refresh token 机制 #3

Merged
Blizzard merged 58 commits from feat/dev into main 2026-07-30 10:18:07 +00:00
9 changed files with 546 additions and 268 deletions
Showing only changes of commit afdbf94450 - Show all commits
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" /> <link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title> <title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-DkaMHmZz.js"></script> <script type="module" crossorigin src="/admin/assets/index-DyVnSuaV.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-0lrkDIfq.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-PPcOOdtF.css">
</head> </head>
<body> <body>
<div id="root"></div> <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.SpeciesCat, "label": "猫猫"},
{"key": model.SpeciesDog, "label": "狗狗"}, {"key": model.SpeciesDog, "label": "狗狗"},
}, },
"stages": service.CareStages, "stages": service.CareStages,
"task_sheets": []string{"", "weight", "poop", "food", "symptom", "vaccine", "medicine"}, // 「关联记录事项」的可选值读 record_types 表,不再写死。
"plan_sheets": []string{"", "weight", "vaccine", "medicine", "report", "taskDetail"}, // 原来是两个硬编码数组(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"}, "reminder_types": []string{"vaccine", "deworm", "weight", "monthlyReport"},
"priorities": []string{"", "重要"}, "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) { 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 { if err != nil {
response.FailParams(c, err.Error()) respondErr(c, err)
return return
} }
response.OK(c, data) response.OK(c, tpl)
} }
type careTemplateReq struct { type careTemplateReq struct {
ID string `json:"id"`
Species string `json:"species"` Species string `json:"species"`
Stage string `json:"stage"` 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"` Items []model.CareTemplateItem `json:"items"`
} }
// AdminSaveCareTemplate PUT /api/admin/care-templates // AdminSaveCareTemplate POST /api/admin/care-templates 新增或更新一套
func (h *Handler) AdminSaveCareTemplate(c *gin.Context) { func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
var req careTemplateReq var req careTemplateReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error()) response.FailParams(c, err.Error())
return return
} }
if err := h.svc.SaveCareTemplate(req.Species, req.Stage, req.Items); err != nil { tpl := &model.CareTemplate{
response.FailErr(c, err) 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 return
} }
data, _ := h.svc.GetCareTemplate(req.Species, req.Stage) saved, _ := h.svc.AdminGetTemplate(tpl.ID)
response.OK(c, data) 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 { 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.DELETE("/record-types/:id", h.AdminDeleteRecordType)
g.GET("/care-templates/meta", h.AdminCareTemplateMeta) g.GET("/care-templates/meta", h.AdminCareTemplateMeta)
g.GET("/care-templates", h.AdminGetCareTemplate) g.GET("/care-templates", h.AdminListCareTemplates)
g.PUT("/care-templates", h.AdminSaveCareTemplate) 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.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
g.GET("/ai-quota", h.AdminGetAIQuota) 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 优先,无则内置默认) // GetCareTemplate 后台读取:返回某组合的明细(DB 优先,无则内置默认)
func (s *Service) GetCareTemplate(species, stage string) (map[string]any, error) { // AdminListTemplates 后台:全部方案(含停用),按物种阶段排
if !validSpecies(species) || !validStage(stage) { func (s *Service) AdminListTemplates() []model.CareTemplate {
return nil, errors.New("invalid species or stage") 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{ return all
"species": species, }
"stage": stage,
"items": s.careTemplateItems(species, stage), // AdminGetTemplate 后台:取一套
}, nil 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 后台保存:整表替换该组合的明细 // SaveCareTemplate 后台保存:整表替换该组合的明细
func (s *Service) SaveCareTemplate(species, stage string, items []model.CareTemplateItem) error { // SaveTemplate 存一套方案。id 为空则新建 —— 同一个 (物种, 阶段) 可以有多套,
if !validSpecies(species) || !validStage(stage) { // 用户建档时挑一套。原来是「按 species+stage 覆盖」,那个模型下
return errors.New("invalid species or 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 { return s.db.Transaction(func(tx *gorm.DB) error {
var tpl model.CareTemplate if in.ID == "" {
err := tx.Where("species = ? AND stage = ?", species, stage).First(&tpl).Error if err := tx.Create(in).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
tpl = model.CareTemplate{Species: species, Stage: stage}
if err := tx.Create(&tpl).Error; err != nil {
return err return err
} }
} else if err != nil { } else {
return err if err := tx.Model(&model.CareTemplate{}).Where("id = ?", in.ID).Updates(map[string]any{
} "species": in.Species, "stage": in.Stage, "name": in.Name,
if err := tx.Where("template_id = ?", tpl.ID).Delete(&model.CareTemplateItem{}).Error; err != nil { "summary": in.Summary, "sort": in.Sort, "enabled": in.Enabled,
return err }).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
} }
it.ID = "" if err := tx.Where("template_id = ?", in.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
it.TemplateID = tpl.ID return err
it.Sort = i }
clean = append(clean, it) }
for i := range clean {
clean[i].TemplateID = in.ID
} }
if len(clean) > 0 { if len(clean) > 0 {
if err := tx.Create(&clean).Error; err != nil { return tx.Create(&clean).Error
return err
}
} }
return nil return nil
}) })
+5 -6
View File
@@ -121,12 +121,11 @@ export const api = {
memberships: (params: any) => http.get<any, Page<any>>('/admin/memberships', { params }), memberships: (params: any) => http.get<any, Page<any>>('/admin/memberships', { params }),
careTemplateMeta: () => http.get<any, any>('/admin/care-templates/meta'), careTemplateMeta: () => http.get<any, any>('/admin/care-templates/meta'),
careTemplate: (species: string, stage: string) => // 方案改成「同一个物种阶段可以多套」,所以按 id 存取,不再按 species+stage 覆盖
http.get<any, { species: string; stage: string; items: any[] }>('/admin/care-templates', { careTemplates: () => http.get<any, any[]>('/admin/care-templates'),
params: { species, stage }, careTemplate: (id: string) => http.get<any, any>(`/admin/care-templates/${id}`),
}), saveCareTemplate: (t: any) => http.post<any, any>('/admin/care-templates', t),
saveCareTemplate: (species: string, stage: string, items: any[]) => deleteCareTemplate: (id: string) => http.delete(`/admin/care-templates/${id}`),
http.put<any, { items: any[] }>('/admin/care-templates', { species, stage, items }),
genCareTemplate: (species: string, stage: string) => genCareTemplate: (species: string, stage: string) =>
http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/generate', { http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/generate', {
species, species,
+409 -208
View File
@@ -1,285 +1,486 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { Cat, Dog, Wand2, Plus, Trash2, Save, Info } from 'lucide-react' import { Cat, Dog, Plus, Trash2, Wand2, Info } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input' 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 = [ const SPECIES = [
{ key: 'cat', label: '猫猫', Icon: Cat }, { key: 'cat', label: '猫猫', Icon: Cat },
{ key: 'dog', label: '狗狗', Icon: Dog }, { key: 'dog', label: '狗狗', Icon: Dog },
] ]
const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期'] const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期']
const SHEET_LABEL: Record<string, string> = {
'': '无', weight: '体重', poop: '便便', food: '饮食', symptom: '异常',
vaccine: '疫苗', medicine: '用药', report: '报告', taskDetail: '任务详情',
}
const REMINDER_LABEL: Record<string, string> = { const REMINDER_LABEL: Record<string, string> = {
vaccine: '疫苗', deworm: '驱虫', weight: '体重', monthlyReport: '月报', 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 REMINDER_TYPES = ['vaccine', 'deworm', 'weight', 'monthlyReport']
const REPEATS = [
const selectCls = { key: 'daily', label: '每天' },
'h-9 rounded-md border border-input bg-card px-2 text-sm shrink-0' { 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 = { type Item = {
_k: number _k: number
kind: 'task' | 'plan' | 'reminder' kind: 'plan' | 'reminder'
title: string title: string
description: string description: string
priority: string priority: string
sheet_type: string sheet_type: string
repeat: string
day: number day: number
day_label: string weekdays: string
month_day: number
reminder_type: string reminder_type: string
offset_days: number offset_days: number
frequency: string frequency: string
} }
type Tpl = {
id: string
species: string
stage: string
name: string
summary: string
sort: number
enabled: boolean
items?: any[]
}
let uid = 1 let uid = 1
function normalize(raw: any[]): Item[] { function toItem(r: any): Item {
return (raw || []).map((r) => ({ return {
_k: uid++, _k: uid++,
kind: r.kind, kind: r.kind === 'reminder' ? 'reminder' : 'plan',
title: r.title || '', title: r.title || '',
description: r.description || '', description: r.description || '',
priority: r.priority || '', priority: r.priority || '',
sheet_type: r.sheet_type || '', sheet_type: r.sheet_type || '',
repeat: r.repeat || 'once',
day: r.day ?? 0, day: r.day ?? 0,
day_label: r.day_label || '', weekdays: r.weekdays || '',
month_day: r.month_day ?? 1,
reminder_type: r.reminder_type || 'vaccine', reminder_type: r.reminder_type || 'vaccine',
offset_days: r.offset_days ?? 0, offset_days: r.offset_days ?? 0,
frequency: r.frequency || '', frequency: r.frequency || '',
})) }
} }
function blank(kind: Item['kind']): Item { function blank(kind: Item['kind']): Item {
return { return toItem({ kind, repeat: kind === 'plan' ? 'daily' : '' })
_k: uid++, kind, title: '', description: '', priority: '', sheet_type: '',
day: 0, day_label: '', reminder_type: 'vaccine', offset_days: 0, frequency: '',
}
} }
export default function CareTemplates() { export default function CareTemplates() {
const [species, setSpecies] = useState('cat') const [list, setList] = useState<Tpl[]>([])
const [stage, setStage] = useState('成年期') 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 [items, setItems] = useState<Item[]>([])
const [loading, setLoading] = useState(false) const [err, setErr] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [gen, setGen] = 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) { function reload() {
const my = ++reqId.current api.careTemplates().then((r) => setList(r || [])).catch(() => setList([]))
setLoading(true) }
setMsg('') 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 { try {
const data = await api.careTemplate(sp, st) await api.saveCareTemplate({ ...tpl, items })
if (my === reqId.current) { setOpen(false)
setItems(normalize(data.items)) reload()
setDirty(false) } catch (e: any) {
} // 后端会指名道姓说哪条配错了(「XX 选了每周,但没勾星期几」)
setErr(e?.message || '保存失败')
} finally { } finally {
if (my === reqId.current) setLoading(false) setSaving(false)
} }
} }
async function del(t: Tpl) {
useEffect(() => { if (!confirm(`删除方案「${t.name || t.stage}」?已经套用出去的计划不受影响。`)) return
load(species, stage) await api.deleteCareTemplate(t.id).catch((e: any) => alert(e?.message || '删除失败'))
}, [species, stage]) reload()
function update(k: number, key: keyof Item, value: any) {
setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [key]: value } : it)))
setDirty(true)
} }
function remove(k: number) { async function draft() {
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() {
setGen(true) setGen(true)
setMsg('')
try { try {
const data = await api.genCareTemplate(species, stage) const r: any = await api.genCareTemplate(tpl.species, tpl.stage)
setItems(normalize(data.items)) setItems((r?.items || []).map(toItem))
setDirty(true)
setMsg('已生成草稿,确认无误后点「保存该模板」')
} catch (e: any) { } catch (e: any) {
setMsg(e?.message || 'AI 生成失败') setErr(e?.message || 'AI 生成失败')
} finally { } finally {
setGen(false) setGen(false)
} }
} }
async function save() { const upd = (k: number, field: string, v: any) =>
setSaving(true) setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [field]: v } : it)))
setMsg('') const rm = (k: number) => setItems((prev) => prev.filter((it) => it._k !== k))
try { function toggleWd(it: Item, idx: number) {
await api.saveCareTemplate(species, stage, items) const n = String(idx + 1)
setDirty(false) const cur = it.weekdays.split(',').filter(Boolean)
setMsg('已保存') const next = cur.includes(n) ? cur.filter((x) => x !== n) : [...cur, n].sort()
} catch (e: any) { upd(it._k, 'weekdays', next.join(','))
setMsg(e?.message || '保存失败')
} finally {
setSaving(false)
}
} }
const tasks = useMemo(() => items.filter((i) => i.kind === 'task'), [items]) const plans = items.filter((i) => i.kind === 'plan')
const plans = useMemo(() => items.filter((i) => i.kind === 'plan'), [items]) const reminders = items.filter((i) => i.kind === 'reminder')
const reminders = useMemo(() => items.filter((i) => i.kind === 'reminder'), [items])
// 列表里展示「这套方案套一个月大概会排多少条」——只报明细条数会骗人:
// 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 ( return (
<div> <div>
<div className="flex items-start justify-between mb-4"> <div className="mb-6 flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="mt-1 text-sm text-muted-foreground">
/
</p> </p>
</div> </div>
<div className="flex gap-2"> <Button onClick={create}>
<Button variant="outline" onClick={generate} disabled={gen}> <Plus className="h-4 w-4" />
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 生成草稿'} </Button>
</Button>
<Button onClick={save} disabled={saving || !dirty}>
<Save className="h-4 w-4" /> {saving ? '保存中…' : '保存该模板'}
</Button>
</div>
</div> </div>
<div className="flex gap-2 mb-2"> <Card>
{SPECIES.map((s) => ( <CardContent className="pt-6">
<button <Table>
key={s.key} <TableHeader>
onClick={() => setSpecies(s.key)} <TableRow>
className={cn( <TableHead className="w-24">/</TableHead>
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors', <TableHead></TableHead>
species === s.key ? 'bg-primary/15 text-primary border-primary/40' : 'text-muted-foreground hover:bg-accent', <TableHead></TableHead>
)} <TableHead className="w-20"></TableHead>
> <TableHead className="w-32 text-right"></TableHead>
<s.Icon className="h-4 w-4" /> {s.label} </TableRow>
</button> </TableHeader>
))} <TableBody>
</div> {list.map((t) => {
<div className="flex flex-wrap gap-2 mb-4"> const c = spread(t)
{STAGES.map((st) => ( return (
<button <TableRow key={t.id}>
key={st} <TableCell className="text-muted-foreground">
onClick={() => setStage(st)} {t.species === 'dog' ? '狗' : '猫'} · {t.stage}
className={cn( </TableCell>
'rounded-full border px-3 py-1.5 text-sm transition-colors', <TableCell>
stage === st ? 'bg-primary/15 text-primary border-primary/40' : 'text-muted-foreground hover:bg-accent', <div className="font-medium">{t.name || '(未命名)'}</div>
)} <div className="text-xs text-muted-foreground">{t.summary}</div>
> </TableCell>
{st} <TableCell className="text-xs text-muted-foreground">
</button> {c.daily} · {c.weekly} · {c.monthly} · {c.once} · {c.rem}
))} </TableCell>
</div> <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 && ( <Dialog open={open} onOpenChange={setOpen}>
<div className="mb-4 flex items-center gap-2 rounded-md bg-primary/10 px-3 py-2 text-sm text-primary"> <DialogContent className="max-h-[88vh] max-w-4xl overflow-y-auto">
<Info className="h-4 w-4" /> {msg} <DialogHeader>
</div> <DialogTitle>{tpl.id ? '编辑方案' : '新建方案'}</DialogTitle>
)} </DialogHeader>
{loading ? ( <div className="grid gap-4">
<p className="text-sm text-muted-foreground"></p> <div className="grid grid-cols-2 gap-4">
) : ( <div className="grid gap-2">
<div className="space-y-4"> <Label></Label>
<Section title="今日任务" count={tasks.length} onAdd={() => add('task')}> <div className="flex gap-2">
{tasks.map((it) => ( {SPECIES.map((s) => (
<div key={it._k} className="flex flex-wrap items-center gap-2"> <Button
<Input className="flex-1 min-w-40" placeholder="任务标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} /> key={s.key}
<Input className="flex-1 min-w-40" placeholder="说明" value={it.description} onChange={(e) => update(it._k, 'description', e.target.value)} /> type="button"
<select className={selectCls} value={it.priority} onChange={(e) => update(it._k, 'priority', e.target.value)}> size="sm"
<option value=""></option> variant={tpl.species === s.key ? 'default' : 'outline'}
<option value="重要"></option> onClick={() => setTpl({ ...tpl, species: s.key })}
</select> >
<select className={selectCls} value={it.sheet_type} onChange={(e) => update(it._k, 'sheet_type', e.target.value)}> <s.Icon className="h-4 w-4" /> {s.label}
{TASK_SHEETS.map((s) => <option key={s} value={s}>·{SHEET_LABEL[s]}</option>)} </Button>
</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> </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> </div>
))} <div className="grid gap-2">
</Section> <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" /> 退 <div className="rounded-md border p-3">
</p> <div className="mb-3 flex items-center justify-between">
</div> <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> </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>
)
}