主题系统从带数据库/事务/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 二开只需看一个文件
381 lines
17 KiB
TypeScript
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;
|