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
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
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-DMg3rix-.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-D4fxTaca.css">
<script type="module" crossorigin src="/admin/assets/index-DkaMHmZz.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-0lrkDIfq.css">
</head>
<body>
<div id="root"></div>
+4
View File
@@ -10,6 +10,8 @@ import Posts from '@/pages/Posts'
import Comments from '@/pages/Comments'
import Articles from '@/pages/Articles'
import CareTemplates from '@/pages/CareTemplates'
import RecordTypes from '@/pages/RecordTypes'
import Breeds from '@/pages/Breeds'
import CommunityBot from '@/pages/CommunityBot'
import Feedback from '@/pages/Feedback'
import Members from '@/pages/Members'
@@ -37,6 +39,8 @@ export default function App() {
<Route path="comments" element={<Comments />} />
<Route path="articles" element={<Articles />} />
<Route path="care-templates" element={<CareTemplates />} />
<Route path="record-types" element={<RecordTypes />} />
<Route path="breeds" element={<Breeds />} />
<Route path="community-bot" element={<CommunityBot />} />
<Route path="feedback" element={<Feedback />} />
<Route path="members" element={<Members />} />
+3 -1
View File
@@ -1,5 +1,5 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, Sparkles, MessageCircle, Crown, LogOut } from 'lucide-react'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, ListChecks, Dna, Sparkles, MessageCircle, Crown, LogOut } from 'lucide-react'
import { api, clearToken } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -12,6 +12,8 @@ const nav = [
{ to: '/comments', label: '评论管理', icon: MessagesSquare },
{ to: '/articles', label: '文章管理', icon: FileText },
{ to: '/care-templates', label: '养护模板', icon: ClipboardList },
{ to: '/record-types', label: '记录事项', icon: ListChecks },
{ to: '/breeds', label: '品种管理', icon: Dna },
{ to: '/community-bot', label: '社区运营', icon: Sparkles },
{ to: '/feedback', label: '意见反馈', icon: MessageCircle },
{ to: '/members', label: '会员管理', icon: Crown },
+11
View File
@@ -143,6 +143,17 @@ export const api = {
),
saveAIQuota: (c: any) => http.put<any, any>('/admin/ai-quota', c),
// 记录类型(小程序「记一笔」的那些事项)
recordTypes: () => http.get<any, any[]>('/admin/record-types'),
saveRecordType: (t: any) => http.post('/admin/record-types', t),
deleteRecordType: (id: string) => http.delete(`/admin/record-types/${id}`),
// 品种
breeds: (species?: string) =>
http.get<any, any[]>('/admin/breeds', { params: species ? { species } : {} }),
saveBreed: (b: any) => http.post('/admin/breeds', b),
deleteBreed: (id: string) => http.delete(`/admin/breeds/${id}`),
communityBot: () =>
http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>(
'/admin/community-bot',
+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>
)
}