feat(admin): 侧边栏布局 + 仪表盘 + 全局隐藏滚动条
- 全局隐藏滚动条(web + admin),保留正常滚动 - admin 顶栏改侧边栏导航(仪表盘/文章),响应式移动端抽屉 - 路由重构:首页→仪表盘,文章列表移到 /posts - 仪表盘页:文章总数/已发布/草稿统计卡片 + 最近更新列表 - 后端 GET /api/admin/stats Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CheckCircle2, FileEdit, FileText, Plus } from 'lucide-react'
|
||||
import { fetchStats, type Stats } from '@/api/posts'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchStats()
|
||||
.then((s) => {
|
||||
if (!cancelled) setStats(s)
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) setError(e.message)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cards = [
|
||||
{ label: '文章总数', value: stats?.total, icon: FileText },
|
||||
{ label: '已发布', value: stats?.published, icon: CheckCircle2 },
|
||||
{ label: '草稿', value: stats?.draft, icon: FileEdit },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-[650] tracking-[-0.01em]">仪表盘</h1>
|
||||
<p className="mt-1 text-[13.5px] text-ink-2">内容概览</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/posts/new"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-[13.5px] font-medium text-ground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Plus className="size-4" /> 新建文章
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg border border-hairline bg-surface px-4 py-3 text-[13.5px] text-ink-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{cards.map((c) => (
|
||||
<div
|
||||
key={c.label}
|
||||
className="rounded-xl border border-hairline bg-surface p-5"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[13px] text-ink-2">{c.label}</span>
|
||||
<c.icon className="size-[18px] text-ink-3" />
|
||||
</div>
|
||||
<div className="text-[28px] font-[650] tabular-nums">
|
||||
{c.value ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-[15px] font-semibold">最近更新</h2>
|
||||
<Link
|
||||
to="/posts"
|
||||
className="font-mono text-[12.5px] text-accent-ink hover:underline"
|
||||
>
|
||||
全部文章 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-hairline bg-surface">
|
||||
{stats && stats.recent.length > 0 ? (
|
||||
stats.recent.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/posts/${post.id}`}
|
||||
className="flex items-center justify-between gap-4 border-b border-hairline px-5 py-3.5 transition-colors last:border-b-0 hover:bg-ground"
|
||||
>
|
||||
<span className="truncate text-[14px] font-medium">
|
||||
{post.title}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-3">
|
||||
{post.published_at ? (
|
||||
<span className="rounded-full bg-accent-soft px-2.5 py-0.5 text-[12px] text-accent-ink">
|
||||
已发布
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-code px-2.5 py-0.5 text-[12px] text-ink-3">
|
||||
草稿
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-[12px] tabular-nums text-ink-3">
|
||||
{post.updated_at.slice(0, 10)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<p className="px-5 py-10 text-center text-[13.5px] text-ink-3">
|
||||
{stats ? '还没有文章,点右上角新建' : '加载中…'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user