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"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"cwd": "web",
|
"cwd": "web",
|
||||||
"port": 5173
|
"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 { getToken } from '@/api/client'
|
||||||
import { AdminLayout } from '@/components/admin-layout'
|
import { AdminLayout } from '@/components/admin-layout'
|
||||||
import LoginPage from '@/pages/login'
|
import LoginPage from '@/pages/login'
|
||||||
|
import DashboardPage from '@/pages/dashboard'
|
||||||
import PostsListPage from '@/pages/posts-list'
|
import PostsListPage from '@/pages/posts-list'
|
||||||
import PostEditPage from '@/pages/post-edit'
|
import PostEditPage from '@/pages/post-edit'
|
||||||
|
|
||||||
@@ -16,7 +17,8 @@ export default function App() {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<AdminLayout />}>
|
<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/new" element={<PostEditPage />} />
|
||||||
<Route path="posts/:id" element={<PostEditPage />} />
|
<Route path="posts/:id" element={<PostEditPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -61,3 +61,14 @@ export function updatePost(id: string, form: PostForm) {
|
|||||||
export function deletePost(id: string) {
|
export function deletePost(id: string) {
|
||||||
return request<null>(`/api/admin/posts/${id}`, { method: 'DELETE' })
|
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 { useState } from 'react'
|
||||||
import { LogOut, Moon, Sun } from 'lucide-react'
|
import { Outlet } from 'react-router-dom'
|
||||||
import { logout } from '@/api/auth'
|
import { Menu } from 'lucide-react'
|
||||||
import { useTheme } from '@/components/theme-provider'
|
import { AdminSidebar } from '@/components/admin-sidebar'
|
||||||
import { LogoMark } from '@/components/logo'
|
import { LogoMark } from '@/components/logo'
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
const navigate = useNavigate()
|
const [open, setOpen] = useState(false)
|
||||||
const { resolved, setTheme } = useTheme()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="min-h-screen">
|
||||||
<header className="sticky top-0 z-40 border-b border-hairline bg-ground/85 backdrop-blur">
|
<AdminSidebar open={open} onClose={() => setOpen(false)} />
|
||||||
<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">
|
<div className="md:pl-[220px]">
|
||||||
<LogoMark size={24} />
|
{/* 移动端顶部条 */}
|
||||||
sundynix <em className="not-italic text-accent">admin</em>
|
<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">
|
||||||
</Link>
|
<button
|
||||||
<div className="flex items-center gap-3">
|
type="button"
|
||||||
<button
|
onClick={() => setOpen(true)}
|
||||||
type="button"
|
aria-label="打开菜单"
|
||||||
aria-label={
|
className="text-ink-2"
|
||||||
resolved === 'dark' ? '切换到浅色主题' : '切换到深色主题'
|
>
|
||||||
}
|
<Menu className="size-5" />
|
||||||
onClick={() => setTheme(resolved === 'dark' ? 'light' : 'dark')}
|
</button>
|
||||||
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"
|
<span className="flex items-center gap-2 font-mono text-[14px] font-semibold">
|
||||||
>
|
<LogoMark size={20} /> sundynix{' '}
|
||||||
{resolved === 'dark' ? (
|
<em className="not-italic text-accent">admin</em>
|
||||||
<Sun className="size-4" />
|
</span>
|
||||||
) : (
|
</header>
|
||||||
<Moon className="size-4" />
|
|
||||||
)}
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
</button>
|
<Outlet />
|
||||||
<button
|
</main>
|
||||||
type="button"
|
</div>
|
||||||
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>
|
</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);
|
background: var(--accent-soft);
|
||||||
color: var(--accent-ink);
|
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)
|
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())
|
adminAPI := api.Group("/admin", middleware.Auth())
|
||||||
{
|
{
|
||||||
posts := handler.NewAdminPostHandler(db)
|
posts := handler.NewAdminPostHandler(db)
|
||||||
|
adminAPI.GET("/stats", posts.Stats)
|
||||||
adminAPI.GET("/posts", posts.List)
|
adminAPI.GET("/posts", posts.List)
|
||||||
adminAPI.POST("/posts", posts.Create)
|
adminAPI.POST("/posts", posts.Create)
|
||||||
adminAPI.GET("/posts/:id", posts.Get)
|
adminAPI.GET("/posts/:id", posts.Get)
|
||||||
|
|||||||
@@ -82,6 +82,17 @@ body {
|
|||||||
color: var(--accent-ink);
|
color: var(--accent-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全局隐藏滚动条(保留滚动功能) */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 品牌渐变文字(logo 同源:青→蓝→紫),克制使用 */
|
/* 品牌渐变文字(logo 同源:青→蓝→紫),克制使用 */
|
||||||
.text-brand-gradient {
|
.text-brand-gradient {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
|
|||||||
Reference in New Issue
Block a user