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:
Blizzard
2026-07-17 16:31:37 +08:00
parent 7f050843f9
commit 6334dccf4a
10 changed files with 316 additions and 45 deletions
+7
View File
@@ -7,6 +7,13 @@
"runtimeArgs": ["run", "dev"],
"cwd": "web",
"port": 5173
},
{
"name": "admin",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "admin",
"port": 5174
}
]
}
+3 -1
View File
@@ -2,6 +2,7 @@ import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
import { getToken } from '@/api/client'
import { AdminLayout } from '@/components/admin-layout'
import LoginPage from '@/pages/login'
import DashboardPage from '@/pages/dashboard'
import PostsListPage from '@/pages/posts-list'
import PostEditPage from '@/pages/post-edit'
@@ -16,7 +17,8 @@ export default function App() {
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route element={<AdminLayout />}>
<Route index element={<PostsListPage />} />
<Route index element={<DashboardPage />} />
<Route path="posts" element={<PostsListPage />} />
<Route path="posts/new" element={<PostEditPage />} />
<Route path="posts/:id" element={<PostEditPage />} />
</Route>
+11
View File
@@ -61,3 +61,14 @@ export function updatePost(id: string, form: PostForm) {
export function deletePost(id: string) {
return request<null>(`/api/admin/posts/${id}`, { method: 'DELETE' })
}
export interface Stats {
total: number
published: number
draft: number
recent: PostListItem[]
}
export function fetchStats() {
return request<Stats>('/api/admin/stats')
}
+29 -44
View File
@@ -1,52 +1,37 @@
import { Link, Outlet, useNavigate } from 'react-router-dom'
import { LogOut, Moon, Sun } from 'lucide-react'
import { logout } from '@/api/auth'
import { useTheme } from '@/components/theme-provider'
import { useState } from 'react'
import { Outlet } from 'react-router-dom'
import { Menu } from 'lucide-react'
import { AdminSidebar } from '@/components/admin-sidebar'
import { LogoMark } from '@/components/logo'
export function AdminLayout() {
const navigate = useNavigate()
const { resolved, setTheme } = useTheme()
const [open, setOpen] = useState(false)
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-40 border-b border-hairline bg-ground/85 backdrop-blur">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
<Link to="/" className="flex items-center gap-2.5 font-mono text-[14px] font-semibold">
<LogoMark size={24} />
sundynix <em className="not-italic text-accent">admin</em>
</Link>
<div className="flex items-center gap-3">
<button
type="button"
aria-label={
resolved === 'dark' ? '切换到浅色主题' : '切换到深色主题'
}
onClick={() => setTheme(resolved === 'dark' ? 'light' : 'dark')}
className="flex size-8 items-center justify-center rounded-full border border-hairline-strong text-ink-2 transition-colors hover:border-accent hover:text-accent-ink"
>
{resolved === 'dark' ? (
<Sun className="size-4" />
) : (
<Moon className="size-4" />
)}
</button>
<button
type="button"
onClick={() => {
logout()
navigate('/login')
}}
className="flex items-center gap-1.5 rounded-[7px] border border-hairline-strong px-3 py-1.5 text-[13px] text-ink-2 transition-colors hover:border-accent hover:text-accent-ink"
>
<LogOut className="size-3.5" /> 退
</button>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-8">
<Outlet />
</main>
<div className="min-h-screen">
<AdminSidebar open={open} onClose={() => setOpen(false)} />
<div className="md:pl-[220px]">
{/* 移动端顶部条 */}
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-hairline bg-ground/85 px-4 backdrop-blur md:hidden">
<button
type="button"
onClick={() => setOpen(true)}
aria-label="打开菜单"
className="text-ink-2"
>
<Menu className="size-5" />
</button>
<span className="flex items-center gap-2 font-mono text-[14px] font-semibold">
<LogoMark size={20} /> sundynix{' '}
<em className="not-italic text-accent">admin</em>
</span>
</header>
<main className="mx-auto w-full max-w-5xl px-6 py-8">
<Outlet />
</main>
</div>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { Link, NavLink, useNavigate } from 'react-router-dom'
import { FileText, LayoutDashboard, LogOut, Moon, Sun, X } from 'lucide-react'
import { logout } from '@/api/auth'
import { useTheme } from '@/components/theme-provider'
import { LogoMark } from '@/components/logo'
import { cn } from '@/lib/utils'
const NAV = [
{ to: '/', label: '仪表盘', icon: LayoutDashboard, end: true },
{ to: '/posts', label: '文章', icon: FileText, end: false },
]
interface Props {
open: boolean
onClose: () => void
}
export function AdminSidebar({ open, onClose }: Props) {
const navigate = useNavigate()
const { resolved, setTheme } = useTheme()
const itemCls = ({ isActive }: { isActive: boolean }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-[14px] transition-colors',
isActive
? 'bg-accent-soft font-medium text-accent-ink'
: 'text-ink-2 hover:bg-ground hover:text-ink',
)
const bottomBtnCls =
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-[14px] text-ink-2 transition-colors hover:bg-ground hover:text-ink'
return (
<>
{open && (
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={onClose}
aria-hidden="true"
/>
)}
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 flex w-[220px] flex-col border-r border-hairline bg-surface transition-transform md:translate-x-0',
open ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="flex h-16 items-center justify-between border-b border-hairline px-5">
<Link
to="/"
onClick={onClose}
className="flex items-center gap-2.5 font-mono text-[14px] font-semibold"
>
<LogoMark size={24} />
sundynix <em className="not-italic text-accent">admin</em>
</Link>
<button
type="button"
onClick={onClose}
aria-label="关闭菜单"
className="text-ink-2 md:hidden"
>
<X className="size-5" />
</button>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={onClose}
className={itemCls}
>
<item.icon className="size-[18px]" />
{item.label}
</NavLink>
))}
</nav>
<div className="space-y-1 border-t border-hairline p-3">
<button
type="button"
onClick={() => setTheme(resolved === 'dark' ? 'light' : 'dark')}
className={bottomBtnCls}
>
{resolved === 'dark' ? (
<Sun className="size-[18px]" />
) : (
<Moon className="size-[18px]" />
)}
{resolved === 'dark' ? '浅色模式' : '深色模式'}
</button>
<button
type="button"
onClick={() => {
logout()
navigate('/login')
}}
className={bottomBtnCls}
>
<LogOut className="size-[18px]" /> 退
</button>
</div>
</aside>
</>
)
}
+11
View File
@@ -71,3 +71,14 @@ body {
background: var(--accent-soft);
color: var(--accent-ink);
}
/* 全局隐藏滚动条(保留滚动功能) */
* {
scrollbar-width: none;
-ms-overflow-style: none;
}
*::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
+114
View File
@@ -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>
)
}
+20
View File
@@ -156,3 +156,23 @@ func (h *AdminPostHandler) Delete(c *gin.Context) {
}
resp.OK(c, nil)
}
// Stats GET /api/admin/stats — 概览统计(总数 / 已发布 / 草稿 / 最近文章)
func (h *AdminPostHandler) Stats(c *gin.Context) {
var total, published int64
if err := h.db.Model(&model.Post{}).Count(&total).Error; err != nil {
resp.ServerError(c, "查询失败")
return
}
h.db.Model(&model.Post{}).Where("published_at IS NOT NULL").Count(&published)
var recent []model.PostListItem
h.db.Model(&model.Post{}).Order("updated_at DESC").Limit(5).Find(&recent)
resp.OK(c, gin.H{
"total": total,
"published": published,
"draft": total - published,
"recent": recent,
})
}
+1
View File
@@ -42,6 +42,7 @@ func New(db *gorm.DB) (*gin.Engine, error) {
adminAPI := api.Group("/admin", middleware.Auth())
{
posts := handler.NewAdminPostHandler(db)
adminAPI.GET("/stats", posts.Stats)
adminAPI.GET("/posts", posts.List)
adminAPI.POST("/posts", posts.Create)
adminAPI.GET("/posts/:id", posts.Get)
+11
View File
@@ -82,6 +82,17 @@ body {
color: var(--accent-ink);
}
/* 全局隐藏滚动条(保留滚动功能) */
* {
scrollbar-width: none;
-ms-overflow-style: none;
}
*::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
/* 品牌渐变文字(logo 同源:青→蓝→紫),克制使用 */
.text-brand-gradient {
background: linear-gradient(