- 新增 admin/nav.ts: 唯一配置源 NAV_ITEMS(to/label/group/icon/hideFromSidebar) - COMMAND_PALETTE_PAGES / ROUTE_LABELS / NAV_SECTIONS 全部由其派生 - 删除 AdminLayout 中三份平行清单(已出现漂移: data-pipeline 不在侧栏、 「系统」vs「系统设置」命名不一致、monitoring/eval 顺序两处相反) - 已知微差: 命令面板「评估与监控」组内顺序统一为侧栏口径(eval 在前) - 行为不变: 路由/标签/分组标题均保持现状;data-pipeline 保持仅面板可达
330 lines
13 KiB
TypeScript
330 lines
13 KiB
TypeScript
/**
|
|
* Admin 后台 - 布局组件(报刊风)
|
|
*
|
|
* 与前台同一套纸色语言:报头式侧栏 + 报眉顶栏 + 细线分区。
|
|
* 响应式: 移动端汉堡菜单 + 抽屉侧栏,桌面端固定侧栏。
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
|
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
|
|
import { fetchHealth } from './dal'
|
|
import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav'
|
|
import Login from './Login'
|
|
import { useCommandPalette, CommandPalette } from './useCommandPalette'
|
|
|
|
// 线条风格 SVG 图标组件
|
|
function Icon({ name }: { name: string }) {
|
|
const common = 'nav-icon'
|
|
switch (name) {
|
|
case 'collection':
|
|
return (
|
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
|
</svg>
|
|
)
|
|
case 'chart':
|
|
return (
|
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="18" y1="20" x2="18" y2="10" />
|
|
<line x1="12" y1="20" x2="12" y2="4" />
|
|
<line x1="6" y1="20" x2="6" y2="14" />
|
|
</svg>
|
|
)
|
|
case 'target':
|
|
return (
|
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<circle cx="12" cy="12" r="6" />
|
|
<circle cx="12" cy="12" r="2" />
|
|
</svg>
|
|
)
|
|
case 'repeat':
|
|
return (
|
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="17 1 21 5 17 9" />
|
|
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
|
<polyline points="7 23 3 19 7 15" />
|
|
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
|
</svg>
|
|
)
|
|
case 'eval':
|
|
return (
|
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
<line x1="16" y1="13" x2="8" y2="13" />
|
|
<line x1="16" y1="17" x2="8" y2="17" />
|
|
<polyline points="10 9 9 9 8 9" />
|
|
</svg>
|
|
)
|
|
case 'monitor':
|
|
return (
|
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
|
<line x1="8" y1="21" x2="16" y2="21" />
|
|
<line x1="12" y1="17" x2="12" y2="21" />
|
|
</svg>
|
|
)
|
|
case 'settings':
|
|
return (
|
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="3" />
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 10 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0-4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
</svg>
|
|
)
|
|
case 'logs':
|
|
return (
|
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
<line x1="8" y1="13" x2="16" y2="13" />
|
|
<line x1="8" y1="17" x2="16" y2="17" />
|
|
</svg>
|
|
)
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
// 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 border-b border-ink-900 px-5 py-4">
|
|
<h1 className="font-brush text-xl text-ink-900">先知</h1>
|
|
</div>
|
|
|
|
{/* 导航:分组 + 小节标题 */}
|
|
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
|
|
{/* 仪表盘独立(始终第一项) */}
|
|
<NavLink
|
|
to="/admin"
|
|
end
|
|
className={({ isActive }) =>
|
|
`nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300 ${
|
|
isActive
|
|
? 'border-press bg-press-wash/60 font-medium text-press active'
|
|
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
|
}`
|
|
}
|
|
>
|
|
<Icon 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={({ isActive }) =>
|
|
`nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300 ${
|
|
isActive
|
|
? 'border-press bg-press-wash/60 font-medium text-press active'
|
|
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
|
}`
|
|
}
|
|
>
|
|
<Icon 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>
|
|
)
|
|
}
|