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:
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
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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