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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user