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:
@@ -7,6 +7,13 @@
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"cwd": "web",
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"cwd": "admin",
|
||||
"port": 5174
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+3
-1
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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"
|
||||
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"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="打开菜单"
|
||||
className="text-ink-2"
|
||||
>
|
||||
{resolved === 'dark' ? (
|
||||
<Sun className="size-4" />
|
||||
) : (
|
||||
<Moon className="size-4" />
|
||||
)}
|
||||
<Menu className="size-5" />
|
||||
</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>
|
||||
<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 flex-1 px-6 py-8">
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user