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
10 changed files with 586 additions and 63 deletions
Showing only changes of commit 69bd56671c - 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
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-DMg3rix-.js"></script> <script type="module" crossorigin src="/admin/assets/index-DkaMHmZz.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-D4fxTaca.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-0lrkDIfq.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+4
View File
@@ -10,6 +10,8 @@ import Posts from '@/pages/Posts'
import Comments from '@/pages/Comments' import Comments from '@/pages/Comments'
import Articles from '@/pages/Articles' import Articles from '@/pages/Articles'
import CareTemplates from '@/pages/CareTemplates' import CareTemplates from '@/pages/CareTemplates'
import RecordTypes from '@/pages/RecordTypes'
import Breeds from '@/pages/Breeds'
import CommunityBot from '@/pages/CommunityBot' import CommunityBot from '@/pages/CommunityBot'
import Feedback from '@/pages/Feedback' import Feedback from '@/pages/Feedback'
import Members from '@/pages/Members' import Members from '@/pages/Members'
@@ -37,6 +39,8 @@ export default function App() {
<Route path="comments" element={<Comments />} /> <Route path="comments" element={<Comments />} />
<Route path="articles" element={<Articles />} /> <Route path="articles" element={<Articles />} />
<Route path="care-templates" element={<CareTemplates />} /> <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="community-bot" element={<CommunityBot />} />
<Route path="feedback" element={<Feedback />} /> <Route path="feedback" element={<Feedback />} />
<Route path="members" element={<Members />} /> <Route path="members" element={<Members />} />
+3 -1
View File
@@ -1,5 +1,5 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom' 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 { api, clearToken } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -12,6 +12,8 @@ const nav = [
{ to: '/comments', label: '评论管理', icon: MessagesSquare }, { to: '/comments', label: '评论管理', icon: MessagesSquare },
{ to: '/articles', label: '文章管理', icon: FileText }, { to: '/articles', label: '文章管理', icon: FileText },
{ to: '/care-templates', label: '养护模板', icon: ClipboardList }, { to: '/care-templates', label: '养护模板', icon: ClipboardList },
{ to: '/record-types', label: '记录事项', icon: ListChecks },
{ to: '/breeds', label: '品种管理', icon: Dna },
{ to: '/community-bot', label: '社区运营', icon: Sparkles }, { to: '/community-bot', label: '社区运营', icon: Sparkles },
{ to: '/feedback', label: '意见反馈', icon: MessageCircle }, { to: '/feedback', label: '意见反馈', icon: MessageCircle },
{ to: '/members', label: '会员管理', icon: Crown }, { 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), 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: () => communityBot: () =>
http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>( http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>(
'/admin/community-bot', '/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>
)
}