feat(admin): 补品种管理和记录事项两个后台页

## 这两个页面是漏的,不是新需求
Step 2 我说了「记录类型后台可配」,接口加了(GET/POST/DELETE
/api/admin/record-types)但 React 页面根本没建;品种那次同样只加了接口。
也就是说「可配」这件事在后台上一直是点不到的。补上。

## 品种管理
猫/狗切换,按首字母分段列出(和小程序端的 A-Z 索引对得上)。

首字母是手填的输入框,配了一句说明:多音字自己定(藏獒 Z、柴犬 C)。
不做自动转拼音——Go 没有标准拼音库,而且多音字自动转常出错,
错了以后用户在小程序里按字母索引就找不到那个品种。

## 记录事项
按分组列出 24 种,「表单字段」那列把 DSL 渲染成人话
(体重·数值kg / 状态·单选★),★ 表示这个单选落 category。

字段配置的写法说明直接放在编辑弹窗里,不是写进文档:这东西一年用两次,
去翻文档的成本比它本身还高。

内置那 9 种打了「内置」标签、隐藏删除按钮、code 输入框禁用 —— 但真正的
拦截在 service 层,前端只是别让人白点。

## 报错原样透出
两个页面的保存失败都把后端消息显示出来,不写成「保存失败」。
后端那些消息是特意写具体的(第几行、错在哪、几个档案在用),
包成一句「失败」等于把它们扔了。

## 验证(预生产库实跑 service 层)
  品种  首字母两位/中文 → 拒;物种 bird → 拒;同物种重名「布偶猫」→ 拒
        有 1 个档案在用时删 → 拒并报出数量;没人用 → 删成功
  事项  两个字段都落 category → 「第 2 行」;options 没选项 → 「第 1 行」;
        类型写成 num → 「第 1 行类型 num 不认识」;缺名称 → 「第 1 行缺名称」;
        第二个加 :- → 通过
  tsc --noEmit 通过;npm run build 后重启,/admin/ 返回的资源哈希
  和新构建一致(go:embed 是编译期打包的,不重启看不到新页面)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-30 11:32:50 +08:00
parent aefb1bc103
commit 69bd56671c
10 changed files with 586 additions and 63 deletions
+227
View File
@@ -0,0 +1,227 @@
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
type Breed = {
id: string
species: string
name: string
initial: string
sort: number
enabled: boolean
}
const empty: Breed = { id: '', species: 'cat', name: '', initial: '', sort: 0, enabled: true }
const SPECIES = [
{ key: 'cat', label: '猫' },
{ key: 'dog', label: '狗' },
]
export default function Breeds() {
const [species, setSpecies] = useState('cat')
const [list, setList] = useState<Breed[]>([])
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Breed>(empty)
const [err, setErr] = useState('')
function reload() {
api
.breeds(species)
.then((r) => setList(r || []))
.catch(() => setList([]))
}
useEffect(reload, [species])
function create() {
setForm({ ...empty, species })
setErr('')
setOpen(true)
}
function edit(b: Breed) {
setForm({ ...b })
setErr('')
setOpen(true)
}
async function save() {
setErr('')
try {
await api.saveBreed(form)
setOpen(false)
reload()
} catch (e: any) {
// 后端会明确说是重名还是首字母不合法,直接透出来比一句「保存失败」有用
setErr(e?.message || '保存失败')
}
}
async function del(b: Breed) {
if (!confirm(`删除「${b.name}」?`)) return
try {
await api.deleteBreed(b.id)
reload()
} catch (e: any) {
alert(e?.message || '删除失败')
}
}
const set = (k: keyof Breed) => (e: any) => setForm({ ...form, [k]: e.target.value })
// 按首字母分段展示,和小程序端的 A-Z 索引对得上
const groups: Record<string, Breed[]> = {}
list.forEach((b) => {
const k = (b.initial || '#').toUpperCase()
;(groups[k] = groups[k] || []).push(b)
})
const letters = Object.keys(groups).sort()
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<Button onClick={create}></Button>
</div>
<div className="mb-4 flex gap-2">
{SPECIES.map((s) => (
<Button
key={s.key}
variant={species === s.key ? 'default' : 'outline'}
size="sm"
onClick={() => setSpecies(s.key)}
>
{s.label}
</Button>
))}
<span className="ml-2 self-center text-sm text-muted-foreground">
{list.length} {letters.length}
</span>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16"></TableHead>
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-32 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{letters.map((L) =>
groups[L].map((b, i) => (
<TableRow key={b.id}>
<TableCell className="font-mono text-muted-foreground">
{i === 0 ? L : ''}
</TableCell>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell className="text-muted-foreground">{b.sort}</TableCell>
<TableCell>
{b.enabled ? (
<Badge></Badge>
) : (
<Badge variant="secondary"></Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => edit(b)}>
</Button>
<Button variant="ghost" size="sm" onClick={() => del(b)}>
</Button>
</TableCell>
</TableRow>
)),
)}
{!list.length && (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{form.id ? '编辑品种' : '新增品种'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label></Label>
<div className="flex gap-2">
{SPECIES.map((s) => (
<Button
key={s.key}
type="button"
variant={form.species === s.key ? 'default' : 'outline'}
size="sm"
onClick={() => setForm({ ...form, species: s.key })}
>
{s.label}
</Button>
))}
</div>
</div>
<div className="grid gap-2">
<Label></Label>
<Input value={form.name} onChange={set('name')} placeholder="例如:布偶猫" />
</div>
<div className="grid gap-2">
<Label>A-Z</Label>
<Input
value={form.initial}
maxLength={1}
onChange={(e) => setForm({ ...form, initial: e.target.value.toUpperCase() })}
placeholder="B"
className="w-20 font-mono"
/>
<p className="text-xs text-muted-foreground">
Z C
</p>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
value={form.sort}
onChange={(e) => setForm({ ...form, sort: Number(e.target.value) || 0 })}
className="w-28"
/>
</div>
<div className="flex items-center gap-2">
<input
id="bd-enabled"
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
/>
<Label htmlFor="bd-enabled" className="cursor-pointer">
</Label>
</div>
{err && <p className="text-sm text-destructive">{err}</p>}
<Button onClick={save}></Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
+275
View File
@@ -0,0 +1,275 @@
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
type Field = { kind: string; label: string; unit?: string; options?: string[]; to_category: boolean }
type RT = {
id: string
code: string
label: string
icon: string
group: string
sort: number
enabled: boolean
form: string
locked: boolean
fields_raw: string
fields: Field[]
}
const empty: any = {
id: '', code: '', label: '', icon: 'note', group: 'daily',
sort: 0, enabled: true, form: 'simple', locked: false, fields_raw: '',
}
const GROUPS = [
{ key: 'daily', label: '日常' },
{ key: 'health', label: '健康' },
{ key: 'care', label: '洗护' },
{ key: 'clean', label: '清洁' },
]
const gLabel = (k: string) => GROUPS.find((g) => g.key === k)?.label || k
// 字段配置那段紧凑写法的说明。放在弹窗里而不是文档里——
// 这东西一年用两次,去翻文档的成本比它本身还高
const HINT = `number:体重:kg 数值,落 num_value
options:状态:正常|软便|拉稀 单选,落 category
options:症状:呕吐|拉稀:- 末尾 - 表示不落 category(只进标题)
text:吃了什么 文本,只进标题
(留空) 只要「时间 + 描述 + 照片」`
export default function RecordTypes() {
const [list, setList] = useState<RT[]>([])
const [open, setOpen] = useState(false)
const [form, setForm] = useState<any>(empty)
const [err, setErr] = useState('')
function reload() {
api
.recordTypes()
.then((r) => setList(r || []))
.catch(() => setList([]))
}
useEffect(reload, [])
function create() {
setForm({ ...empty })
setErr('')
setOpen(true)
}
function edit(t: RT) {
setForm({ ...t })
setErr('')
setOpen(true)
}
async function save() {
setErr('')
try {
await api.saveRecordType(form)
setOpen(false)
reload()
} catch (e: any) {
// 字段配置写错了后端会说第几行错在哪,原样透出
setErr(e?.message || '保存失败')
}
}
async function del(t: RT) {
if (!confirm(`删除「${t.label}」?`)) return
try {
await api.deleteRecordType(t.id)
reload()
} catch (e: any) {
alert(e?.message || '删除失败')
}
}
const set = (k: string) => (e: any) => setForm({ ...form, [k]: e.target.value })
// 字段配置渲染成人话,省得盯着 DSL 猜
function fieldsText(t: RT) {
if (!t.fields?.length) return <span className="text-muted-foreground"> + + </span>
return (
<span className="space-x-2">
{t.fields.map((f, i) => (
<span key={i} className="whitespace-nowrap text-xs">
{f.label}
{f.kind === 'number' && `·数值${f.unit || ''}`}
{f.kind === 'text' && '·文本'}
{f.kind === 'options' && `·单选${f.to_category ? '★' : ''}`}
</span>
))}
</span>
)
}
const byGroup: Record<string, RT[]> = {}
list.forEach((t) => ((byGroup[t.group] = byGroup[t.group] || []).push(t)))
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
category
</p>
</div>
<Button onClick={create}></Button>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20"></TableHead>
<TableHead className="w-28"></TableHead>
<TableHead className="w-28">code</TableHead>
<TableHead></TableHead>
<TableHead className="w-16"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-32 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{GROUPS.map((g) =>
(byGroup[g.key] || []).map((t, i) => (
<TableRow key={t.id}>
<TableCell className="text-muted-foreground">{i === 0 ? g.label : ''}</TableCell>
<TableCell className="font-medium">{t.label}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{t.code}</TableCell>
<TableCell>{fieldsText(t)}</TableCell>
<TableCell className="text-muted-foreground">{t.sort}</TableCell>
<TableCell>
{t.enabled ? <Badge></Badge> : <Badge variant="secondary"></Badge>}
{t.locked && (
<Badge variant="outline" className="ml-1">
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => edit(t)}>
</Button>
{!t.locked && (
<Button variant="ghost" size="sm" onClick={() => del(t)}>
</Button>
)}
</TableCell>
</TableRow>
)),
)}
{!list.length && (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{form.id ? `编辑「${form.label}` : '新增事项'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label></Label>
<Input value={form.label} onChange={set('label')} placeholder="洗澡" />
</div>
<div className="grid gap-2">
<Label>code type</Label>
<Input
value={form.code}
onChange={set('code')}
placeholder="bath"
className="font-mono"
disabled={form.locked}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{GROUPS.map((g) => (
<Button
key={g.key}
type="button"
variant={form.group === g.key ? 'default' : 'outline'}
size="sm"
onClick={() => setForm({ ...form, group: g.key })}
>
{g.label}
</Button>
))}
</div>
</div>
<div className="grid gap-2">
<Label>pt-icon</Label>
<Input value={form.icon} onChange={set('icon')} placeholder="bath" className="font-mono" />
</div>
</div>
<div className="grid gap-2">
<Label> + + </Label>
<textarea
className="min-h-[120px] w-full rounded-md border border-input bg-card px-3 py-2 font-mono text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={form.fields_raw || ''}
onChange={set('fields_raw')}
placeholder="number:体重:kg"
/>
<pre className="whitespace-pre-wrap rounded bg-muted p-3 text-xs text-muted-foreground">
{HINT}
</pre>
<p className="text-xs text-muted-foreground">
category <code>:-</code>
</p>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Label></Label>
<Input
type="number"
value={form.sort}
onChange={(e) => setForm({ ...form, sort: Number(e.target.value) || 0 })}
className="w-24"
/>
</div>
<div className="flex items-center gap-2">
<input
id="rt-enabled"
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
/>
<Label htmlFor="rt-enabled" className="cursor-pointer">
</Label>
</div>
</div>
{form.locked && (
<p className="text-xs text-muted-foreground">
code code
</p>
)}
{err && <p className="text-sm text-destructive">{err}</p>}
<Button onClick={save}></Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}