支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。

引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-29 14:09:58 +08:00
parent 962bb15298
commit 9487c8ab02
37 changed files with 2025 additions and 403 deletions

View File

@@ -1,12 +1,15 @@
import { useEffect } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import {
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft,
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft, Moon, Sun, Menu, X,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { useOverlayA11y } from '../hooks/useOverlayA11y';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import BackToTop from '../components/BackToTop';
const NAV = [
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
@@ -20,7 +23,17 @@ const NAV = [
/** React 管理后台布局,与前台 SPA 风格统一 */
export default function AdminLayout() {
const { user, loading } = useAuth();
const { theme, toggle } = useTheme();
const isNarrow = useMediaQuery('(max-width: 768px)');
const [navOpen, setNavOpen] = useState(false);
const nav = useNavigate();
const drawerRef = useRef<HTMLElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const closeNav = useCallback(() => setNavOpen(false), []);
useOverlayA11y(isNarrow && navOpen, closeNav, drawerRef, {
initialFocusRef: closeRef,
});
useEffect(() => {
if (loading) return;
@@ -34,15 +47,50 @@ export default function AdminLayout() {
}
}, [user, loading, nav]);
useEffect(() => {
if (!isNarrow) setNavOpen(false);
}, [isNarrow]);
useEffect(() => {
if (!(isNarrow && navOpen)) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [isNarrow, navOpen]);
if (loading) {
return <div className="flex justify-center py-24"><Spinner size="lg" /></div>;
}
if (!user || user.role !== 'admin') return null;
const navLinks = NAV.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
onClick={closeNav}
>
<Icon size={16} aria-hidden />
{label}
</NavLink>
));
return (
<div className="admin-shell">
<header className="admin-topbar">
<div className="admin-topbar-brand">
{isNarrow && (
<button
type="button"
className="header-icon-btn"
aria-label={navOpen ? '关闭导航' : '打开导航'}
aria-expanded={navOpen}
aria-controls="admin-nav-drawer"
onClick={() => setNavOpen(v => !v)}
>
{navOpen ? <X size={18} aria-hidden /> : <Menu size={18} aria-hidden />}
</button>
)}
<div className="admin-topbar-mark"></div>
<div>
<div className="admin-topbar-title"></div>
@@ -50,48 +98,77 @@ export default function AdminLayout() {
</div>
</div>
<div className="admin-topbar-actions">
<button
type="button"
className="header-icon-btn"
onClick={toggle}
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
>
{theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
</button>
<button type="button" className="admin-link-btn" onClick={() => nav('/')}>
<ArrowLeft size={16} />
<ArrowLeft size={16} aria-hidden />
{!isNarrow && '返回论坛'}
</button>
<span className="admin-topbar-user">{user.nickname}</span>
</div>
</header>
<div className="admin-body">
<aside className="admin-sidebar">
{NAV.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
>
<Icon size={16} />
{label}
</NavLink>
))}
</aside>
{!isNarrow && (
<aside className="admin-sidebar">
{navLinks}
</aside>
)}
<main className="admin-main">
<Outlet />
</main>
</div>
{isNarrow && navOpen && (
<div className="admin-nav-drawer-root">
<button
type="button"
className="aside-drawer-backdrop"
aria-label="关闭导航"
tabIndex={-1}
onClick={closeNav}
/>
<aside
id="admin-nav-drawer"
ref={drawerRef}
className="admin-nav-drawer"
role="dialog"
aria-modal="true"
aria-label="管理导航"
>
<div className="admin-nav-drawer-head">
<span></span>
<button
ref={closeRef}
type="button"
className="header-icon-btn"
aria-label="关闭"
onClick={closeNav}
>
<X size={18} aria-hidden />
</button>
</div>
<nav className="admin-nav-drawer-body">
{navLinks}
</nav>
</aside>
</div>
)}
<BackToTop />
</div>
);
}
/** 管理页通用权限守卫 */
/** 管理页就绪状态(鉴权由 AdminLayout 负责,此处不再重复跳转) */
export function useAdminGuard() {
const { user, loading } = useAuth();
const nav = useNavigate();
useEffect(() => {
if (loading) return;
if (!user) nav('/login');
else if (user.role !== 'admin') {
notify.warning('需要管理员权限');
nav('/');
}
}, [user, loading, nav]);
return { user, loading, ready: !loading && !!user && user.role === 'admin' };
}