Files
Profeto/frontend/src/admin/AdminLayout.tsx
T
shangfangjian e8e626e3e4 fix: Admin Logo 引用毛体字体子集
font-serif → font-brush,应用 Liu Jian Mao Cao 毛体子集
(local subset woff2,仅「先知」两字),与前台报头统一字体。
2026-09-23 00:37:20 +08:00

254 lines
9.0 KiB
TypeScript

/**
* Admin 后台 - 布局组件(报刊风)
*
* 与前台同一套纸色语言:报头式侧栏 + 报眉顶栏 + 细线分区。
* 响应式: 移动端汉堡菜单 + 抽屉侧栏,桌面端固定侧栏。
*/
import { useState, useEffect, useCallback } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from '../api/api'
import { fetchHealth } from '../api/dal'
import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav'
import Login from './Login'
import { useCommandPalette, CommandPalette } from './useCommandPalette'
import { AdminIcon } from './AdminIcon'
// 图标已抽到 ./AdminIcon —— 此前这里是约 90 行的内联 switch,
// 让布局文件同时承担页面骨架与图标图形库两种职责。
/**
* 侧栏导航项的 className 生成器。
*
* 此前这段字符串在文件里出现了两次(仪表盘项 + 分组循环项),
* 逐字相同。改一次选中态样式要改两处 —— 与 App.tsx 里
* 被抽成 SiteLayout 的重复是同一型问题,这里同样收敛为单一来源。
*/
const SIDEBAR_LINK_BASE = 'nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300'
const SIDEBAR_LINK_ACTIVE = 'border-press bg-press-wash/60 font-medium text-press active'
const SIDEBAR_LINK_IDLE = 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
function sidebarLinkClass({ isActive }: { isActive: boolean }): string {
return `${SIDEBAR_LINK_BASE} ${isActive ? SIDEBAR_LINK_ACTIVE : SIDEBAR_LINK_IDLE}`
}
// D6: 导航三视图(侧栏/命令面板/面包屑)统一由 admin/nav.ts 的 NAV_ITEMS
// 单源派生 —— 此处的 NAV_PAGES/ROUTE_LABELS/NAV_SECTIONS 平行清单已删除,
// 新增页面/改标签只改 nav.ts 一处。
/** 报眉日期行,与前台同款式 */
function dateLine(): string {
return new Date().toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
})
}
export default function AdminLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [authed, setAuthed] = useState<boolean | null>(null)
const location = useLocation()
const palette = useCommandPalette(COMMAND_PALETTE_PAGES)
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
useEffect(() => {
let alive = true
fetchAuthState()
.then(s => alive && setAuthed(s.authenticated))
.catch(() => alive && setAuthed(false))
const onUnauthorized = () => setAuthed(false)
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
return () => {
alive = false
window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
}
}, [])
const handleLogout = useCallback(async () => {
try {
await logout()
} catch {
/* 会话可能已失效,直接切回登录页 */
}
setAuthed(false)
}, [])
const checkHealth = useCallback(async () => {
try {
const h = await fetchHealth()
setHealthOk(h?.status === 'healthy' || h?.status === 'ok')
} catch {
setHealthOk(false)
}
}, [])
useEffect(() => {
checkHealth()
const t = setInterval(checkHealth, 60_000)
return () => clearInterval(t)
}, [checkHealth])
// 路由变化时关闭移动端菜单
const closeSidebar = useCallback(() => setSidebarOpen(false), [])
useEffect(() => {
closeSidebar()
}, [location.pathname, closeSidebar])
// ESC 键关闭菜单
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSidebarOpen(false)
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [])
// 登录门禁:未登录只渲染登录页,不泄露后台任何内容
if (authed === null) {
return (
<div className="flex h-screen items-center justify-center bg-paper-50 text-xs text-ink-400">
正在验证登录状态…
</div>
)
}
if (!authed) {
return <Login onSuccess={() => setAuthed(true)} />
}
return (
<div className="flex h-screen flex-col overflow-hidden bg-paper-50 text-ink-800">
{/* ── 通栏顶部报眉 ── */}
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 bg-paper-50 px-4 lg:px-6">
<div className="flex items-center gap-4">
{/* 移动端汉堡按钮 */}
<button
onClick={() => setSidebarOpen(true)}
className="-ml-1 p-2 text-ink-500 hover:text-ink-900 lg:hidden"
aria-label="打开菜单"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* 面包屑 */}
<nav className="hidden items-center gap-1.5 text-2xs text-ink-400 sm:flex" aria-label="面包屑">
<a href="/admin" className="hover:text-ink-700">仪表盘</a>
{location.pathname !== '/admin' && (
<>
<span aria-hidden="true">/</span>
<span className="text-ink-600">{ROUTE_LABELS[location.pathname] ?? '未知页面'}</span>
</>
)}
</nav>
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
</div>
<div className="flex items-center gap-4 text-2xs">
<span className="inline-flex items-center gap-1.5 text-ink-500">
<span
className={`inline-block h-1.5 w-1.5 ${
healthOk === null ? 'bg-ink-300' : healthOk ? 'bg-ink-900' : 'bg-press'
}`}
aria-hidden="true"
/>
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
</span>
<button
onClick={handleLogout}
className="text-ink-500 transition-colors hover:text-press"
title="退出登录"
>
登出
</button>
</div>
</header>
{/* ── 主体:侧边栏 + 内容 ── */}
<div className="flex flex-1 overflow-hidden">
{/* ── 移动端遮罩层 ── */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-ink-900/40 lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* ── 侧边栏 ── */}
<aside
className={`
fixed inset-y-0 left-0 z-50 flex w-60 flex-shrink-0 flex-col border-r border-ink-900 bg-paper-50
transform transition-transform duration-200 ease-in-out
lg:relative lg:z-auto lg:translate-x-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}
aria-label="主导航"
>
{/* 侧栏顶部 */}
<div className="flex items-center justify-center border-b border-ink-900 px-5 py-5">
<h1 className="text-center text-3xl tracking-wide text-ink-900 font-brush">先知</h1>
</div>
{/* 导航:分组 + 小节标题 */}
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
{/* 仪表盘独立(始终第一项) */}
<NavLink to="/admin" end className={sidebarLinkClass}>
<AdminIcon name="chart" />
仪表盘
</NavLink>
{NAV_SECTIONS.map(section => (
<div key={section.title} className="mt-4">
<p className="mb-1 px-3 text-2xs font-medium uppercase tracking-widest text-ink-400">
{section.title}
</p>
<ul className="space-y-0.5">
{section.items.map(item => (
<li key={item.to}>
<NavLink to={item.to} className={sidebarLinkClass}>
<AdminIcon name={item.icon} />
{item.label}
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
{/* 底部 */}
<div className="border-t border-ink-200 px-4 py-3 space-y-2">
<a
href="/"
className="flex min-h-[36px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
>
<span aria-hidden="true"></span>
返回前台版面
</a>
<p className="text-2xs text-ink-300">v1.0</p>
</div>
</aside>
{/* ── 主内容区 ── */}
<main className="flex-1 overflow-y-auto p-4 lg:p-8" key={location.pathname}>
<div className="mx-auto max-w-6xl page-content-enter">
<Outlet />
</div>
</main>
</div>
{/* 命令面板(⌘K) */}
<CommandPalette
open={palette.open}
query={palette.query}
setQuery={palette.setQuery}
items={palette.items}
onClose={() => palette.setOpen(false)}
/>
</div>
)
}