Files
pika/web/admin/src/admin/pages/AdminLayout/index.tsx
T
dushixiang 1af64eccd9 refactor: simplify theme system to filesystem-only with self-contained portal
主题系统从带数据库/事务/repo 层/远程下载/配置管理/主题市场的重型架构,
彻底简化为纯文件系统操作:

后端:
- 主题数据全部存放在 data/themes/ 目录,不经过数据库
- 删除主题配置功能(managed/raw/redirect schema、配置存取 API、
  ThemeConfiguration model、theme_configurations 表、repo 层)
- 删除远程下载(URL 安装、GitHub Release 更新、SSRF 防护、SHA-256)
- 删除版本范围检查(semver 解析/比较/匹配、pikaVersion 校验)
- 删除主题市场
- ThemeService 不再持有 db,合并为 theme.go + theme_util.go 两个文件
- 主题类型从 models 包移到 service 包(非数据库模型)
- 保留:ZIP 上传解压(含目录穿越防护)、目录扫描列表、启用/删除、
  覆盖安装、默认主题回退、启动恢复、入口 HTML 渲染缓存

前端 portal(官方默认主题):
- 从 47 个碎片文件精简到 15 个,按标准目录组织
- 删除 theme-sdk 包,API 客户端和类型内联回 portal,完全自包含
- 按职责拆分:types.ts / api.ts / constants.ts / lib/utils.ts /
  contexts/ColorMode.tsx / hooks/index.ts / components.tsx / pages/
- 每个页面一个文件,图表和子组件内联,fork 二开只需看一个文件
2026-08-09 22:45:43 +08:00

381 lines
17 KiB
TypeScript

import {type JSX, useEffect, useMemo, useRef, useState} from 'react';
import {Outlet, useLocation, useNavigate} from 'react-router-dom';
import type {MenuProps} from 'antd';
import {App as AntApp, Avatar, Button, ConfigProvider, Dropdown, Space, theme} from 'antd';
import {
Activity,
AlertTriangle,
BookOpen,
Eye,
Globe,
Key,
LogOut,
Moon,
Server,
Settings,
Sun,
User as UserIcon
} from 'lucide-react';
import {logout} from '@/api/auth.ts';
import type {User} from '@/types';
import {cn} from '@/lib/utils';
import {getServerVersion, type VersionInfo} from "@/api/version.ts";
import {flushSync} from "react-dom";
import {ScrollArea} from "@radix-ui/react-scroll-area";
import {useColorMode} from '@/shared/contexts/ColorModeContext';
interface NavItem {
key: string;
label: string;
path: string;
icon: JSX.Element;
}
const SIDEBAR_WIDTH = 240;
const HEADER_HEIGHT = 56;
const AdminLayout = () => {
const navigate = useNavigate();
const location = useLocation();
const {message: messageApi, modal} = AntApp.useApp();
const [userInfo, setUserInfo] = useState<User | null>(null);
const [version, setVersion] = useState<VersionInfo>();
const {resolvedColorMode: appliedTheme, setColorMode: setTheme} = useColorMode();
const themeButtonRef = useRef<HTMLButtonElement>(null);
const menuItems: NavItem[] = useMemo(
() => [
{
key: 'agents',
label: '探针管理',
path: '/admin/agents',
icon: <Server className="h-4 w-4" strokeWidth={2}/>,
},
{
key: 'monitors',
label: '服务监控',
path: '/admin/monitors',
icon: <Activity className="h-4 w-4" strokeWidth={2}/>,
},
{
key: 'ddns',
label: 'DDNS',
path: '/admin/ddns',
icon: <Globe className="h-4 w-4" strokeWidth={2}/>,
},
{
key: 'comm-keys',
label: '通信密钥',
path: '/admin/api-keys',
icon: <Key className="h-4 w-4" strokeWidth={2}/>,
},
{
key: 'api-keys',
label: 'API密钥',
path: '/admin/manage-api-keys',
icon: <Key className="h-4 w-4" strokeWidth={2}/>,
},
{
key: 'alert-records',
label: '告警记录',
path: '/admin/alert-records',
icon: <AlertTriangle className="h-4 w-4" strokeWidth={2}/>,
},
{
key: 'settings',
label: '系统设置',
path: '/admin/settings',
icon: <Settings className="h-4 w-4" strokeWidth={2}/>,
},
],
[],
);
useEffect(() => {
const token = localStorage.getItem('token');
const userInfoStr = localStorage.getItem('userInfo');
if (!token || !userInfoStr) {
navigate('/admin/login');
return;
}
setUserInfo(JSON.parse(userInfoStr));
// 获取服务端版本信息
getServerVersion()
.then((res) => {
setVersion(res.data);
})
.catch((err) => {
console.error('获取版本信息失败:', err);
});
}, [navigate, location]);
const handleLogout = () => {
modal.confirm({
title: '确认退出',
content: '确定要退出登录吗?',
onOk: async () => {
try {
await logout();
} finally {
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
messageApi.success('已退出登录');
navigate('/');
}
},
});
};
const userMenuItems: MenuProps['items'] = [
{
key: 'logout',
icon: <LogOut size={16} strokeWidth={2}/>,
label: '退出登录',
onClick: handleLogout,
},
];
const handleNavigate = (item: NavItem) => {
navigate(item.path);
};
// 切换主题的函数,带动画效果
const toggleTheme = async () => {
const newTheme = appliedTheme === 'dark' ? 'light' : 'dark';
if (
!themeButtonRef.current ||
!document.startViewTransition ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
// 如果不支持 View Transition API 或用户偏好减少动画,直接切换
setTheme(newTheme);
return;
}
await document.startViewTransition(() => {
flushSync(() => {
setTheme(newTheme);
});
}).ready;
const {top, left, width, height} = themeButtonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const right = window.innerWidth - left;
const bottom = window.innerHeight - top;
const maxRadius = Math.hypot(
Math.max(left, right),
Math.max(top, bottom),
);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
);
};
return (
<ConfigProvider
theme={{
algorithm: appliedTheme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<AntApp>
<div className="min-h-screen bg-white dark:bg-[#141414]">
{/* 顶部导航栏 */}
<header
className="fixed top-0 left-0 right-0 z-[300] h-14 border-b border-white/20 dark:border-white/10 bg-[#060b16]/95 dark:bg-[#141414]/95 backdrop-blur">
<div className="flex h-full items-center justify-between px-4">
<div className="flex items-center gap-3 text-white">
<div className="flex items-center justify-center">
<img
src={"/api/logo"}
alt="Logo"
className="h-10 w-10 object-contain rounded-md"
onError={(e) => {
e.currentTarget.src = '/logo.png';
}}
/>
</div>
<div>
<p className="text-xs uppercase tracking-[0.3em] text-white/60">{window.SystemConfig?.SystemNameZh}</p>
<p className="text-sm font-semibold">控制台</p>
</div>
</div>
<Space size={8} className="flex h-full items-center">
<Button
type="text"
icon={<Eye className="h-4 w-4" strokeWidth={2}/>}
onClick={() => window.open('/', '_blank')}
className="hidden !h-9 !items-center !rounded-full !px-3 !text-xs !text-white/80 hover:!bg-white/10 sm:!inline-flex"
>
公共页面
</Button>
<Button
type="text"
icon={<BookOpen className="h-4 w-4" strokeWidth={2}/>}
onClick={() => navigate('/admin/agents-install/one-click')}
className="!h-9 !items-center !rounded-full !px-3 !text-xs !text-white hover:!bg-blue-500/10"
>
部署指南
</Button>
{/* 主题切换按钮 */}
<button
ref={themeButtonRef}
type="button"
onClick={toggleTheme}
className="inline-flex h-9 items-center rounded-full p-2 text-white/80 hover:bg-white/10 transition-all"
title={appliedTheme === 'dark' ? "切换到浅色模式" : "切换到暗黑模式"}
>
{appliedTheme === 'dark' ? (
<Sun className="h-4 w-4" strokeWidth={2}/>
) : (
<Moon className="h-4 w-4" strokeWidth={2}/>
)}
</button>
<Dropdown menu={{items: userMenuItems}} placement="bottomRight" trigger={['click']}>
<button
type="button"
className="flex cursor-pointer items-center gap-2 rounded-full border border-white/20 bg-white/5 px-2.5 py-1 text-left text-white transition-colors hover:border-white/40"
>
<Avatar
size={24}
icon={<UserIcon className="h-3.5 w-3.5" strokeWidth={2}/>}
className="!bg-white/20"
/>
<span className="text-xs font-medium">
{userInfo?.username || '访客'}
</span>
</button>
</Dropdown>
</Space>
</div>
</header>
{/* 侧边栏 */}
<aside
className="fixed left-0 z-[200] hidden h-screen overflow-hidden border-r border-white/60 dark:border-white/10 bg-white/90 dark:bg-[#141414]/90 shadow-sm backdrop-blur lg:block"
style={{
width: SIDEBAR_WIDTH,
paddingTop: HEADER_HEIGHT,
}}
>
<div className="flex h-full flex-col">
<div className="px-4 py-4">
<p className="text-xs uppercase tracking-[0.3em] text-gray-400 dark:text-slate-500">导航</p>
<p className="mt-1 text-lg font-semibold text-gray-900 dark:text-slate-100">管理面板</p>
</div>
{/* 菜单区域 */}
<ScrollArea className="px-3 pb-6 space-y-1 h-[calc(100vh-228px)]">
{menuItems.map((item) => {
const isActive = location.pathname.startsWith(item.path);
return (
<button
key={item.key}
type="button"
onClick={() => handleNavigate(item)}
className={cn(
'group relative flex w-full items-center gap-3 rounded-2xl px-3 py-2 text-sm transition-all cursor-pointer',
isActive
? 'bg-gradient-to-r from-blue-500/10 to-blue-500/5 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-800'
)}
>
<span
className={cn(
'flex h-8 w-8 items-center justify-center rounded-xl bg-white dark:bg-slate-800 text-gray-500 dark:text-slate-400 shadow-sm',
isActive && 'bg-blue-600 dark:bg-blue-500 text-white'
)}
>
{item.icon}
</span>
<span className="truncate font-medium">{item.label}</span>
{isActive &&
<span
className="ml-auto text-[10px] uppercase text-blue-500 dark:text-blue-400">当前</span>}
</button>
);
})}
</ScrollArea>
{/* 版本信息 */}
{version && (
<div className="border-t border-gray-100 dark:border-slate-800 px-4 py-4">
<div className="rounded-2xl bg-gray-50/90 dark:bg-slate-800/90 p-3 shadow-inner">
<p className="text-[11px] uppercase tracking-[0.25em] text-gray-400 dark:text-slate-500">版本信息</p>
<div className="mt-2 flex items-end justify-between">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-slate-100">Server: {version.version}</p>
<p className="text-sm font-semibold text-gray-900 dark:text-slate-100">Agent: {version.agentVersion}</p>
<p className="text-[11px] text-gray-500 dark:text-slate-400 uppercase tracking-[0.1em]">
{window.SystemConfig?.SystemNameEn}
</p>
</div>
</div>
</div>
</div>
)}
</div>
</aside>
{/* 主内容区 */}
<div className="flex flex-col bg-white dark:bg-[#141414]"
style={{paddingTop: HEADER_HEIGHT, minHeight: `calc(100vh - ${HEADER_HEIGHT}px)`}}>
{/* 内容区域 */}
<main className="flex-grow bg-white dark:bg-[#141414] pb-20 pt-5 lg:ml-[240px] lg:pb-10">
<div className="w-full px-4 pb-4 lg:px-8">
<Outlet/>
</div>
</main>
</div>
{/* 移动端底部导航栏 */}
<nav
className="fixed bottom-0 left-0 right-0 z-[300] border-t border-gray-200 dark:border-white/10 bg-white/95 dark:bg-[#141414]/95 backdrop-blur lg:hidden">
<div className="grid h-16 grid-cols-5">
{menuItems.map((item) => {
const isActive = location.pathname.startsWith(item.path);
return (
<button
key={item.key}
type="button"
onClick={() => handleNavigate(item)}
className={cn(
'flex flex-col items-center justify-center gap-1 text-xs font-medium',
isActive ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-slate-400'
)}
>
<span
className={cn('rounded-full p-2', isActive ? 'bg-blue-50 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400' : 'text-current')}>
{item.icon}
</span>
<span>{item.label}</span>
</button>
);
})}
</div>
</nav>
</div>
</AntApp>
</ConfigProvider>
);
};
export default AdminLayout;