优化重构管理页面

This commit is contained in:
dushixiang committed 2026-08-12 22:55:36 +08:00
1 parent ef34036bc0
commit 435245d639
24 files changed
+688 -682

No files matched your search

+21 -20
View File
@@ -15,34 +15,35 @@ export interface Action {
interface PageHeaderProps {
title: string;
description?: string;
extra?: React.ReactNode;
actions?: Action[];
}
/**
* 统一的页面头部组件
*/
export const PageHeader: React.FC<PageHeaderProps> = ({title, description, actions}) => {
export const PageHeader: React.FC<PageHeaderProps> = ({title, extra, actions}) => {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">{title}</h1>
{description && <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>}
<div className="flex min-h-[42px] flex-wrap items-center justify-between gap-3 max-sm:items-start">
<div className="max-sm:w-full">
<h1 className="m-0 text-xl leading-tight font-semibold tracking-[-0.025em] text-[#1f2329] dark:text-[#e6e8ec] max-sm:pt-1.5 max-sm:text-[19px]">{title}</h1>
</div>
{actions && actions.length > 0 && (
<div className="flex flex-wrap items-center gap-3">
{actions.map((action) => (
<Button
key={action.key}
type={action.type || 'default'}
icon={action.icon}
onClick={action.onClick}
danger={action.danger}
disabled={action.disabled}
loading={action.loading}
>
{action.label}
</Button>
{(extra || (actions && actions.length > 0)) && (
<div className="flex flex-wrap items-center justify-end gap-[7px] max-sm:w-full max-sm:justify-start max-sm:gap-1.5">
{extra}
{actions?.map((action) => (
<div key={action.key} className="shrink-0">
<Button
type={action.type || 'default'}
icon={action.icon}
onClick={action.onClick}
danger={action.danger}
disabled={action.disabled}
loading={action.loading}
>
{action.label}
</Button>
</div>
))}
</div>
)}
+63 -126
View File
@@ -2,71 +2,14 @@
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* Tailwind v4 暗色模式配置 */
@variant dark (&:where(.dark, .dark *));
/* 通用细滚动条样式 */
.tag-filter-scrollbar,
.thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgb(203, 213, 225) transparent;
scrollbar-gutter: stable;
}
.tag-filter-scrollbar::-webkit-scrollbar,
.thin-scrollbar::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.tag-filter-scrollbar::-webkit-scrollbar-track,
.thin-scrollbar::-webkit-scrollbar-track {
background: rgb(241, 245, 249);
border-radius: 9999px;
}
.tag-filter-scrollbar::-webkit-scrollbar-thumb,
.thin-scrollbar::-webkit-scrollbar-thumb {
background: rgb(203, 213, 225);
border-radius: 9999px;
transition: background-color 0.2s;
}
.tag-filter-scrollbar::-webkit-scrollbar-thumb:hover,
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgb(148, 163, 184);
}
/* 暗色模式 */
.dark .tag-filter-scrollbar,
.dark .thin-scrollbar {
scrollbar-color: rgb(71, 85, 105) transparent;
}
.dark .tag-filter-scrollbar::-webkit-scrollbar-track,
.dark .thin-scrollbar::-webkit-scrollbar-track {
background: rgb(30, 41, 59);
}
.dark .tag-filter-scrollbar::-webkit-scrollbar-thumb,
.dark .thin-scrollbar::-webkit-scrollbar-thumb {
background: rgb(71, 85, 105);
}
.dark .tag-filter-scrollbar::-webkit-scrollbar-thumb:hover,
.dark .thin-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgb(100, 116, 139);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -85,95 +28,89 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--radius: 0.75rem;
--background: oklch(0.98 0.004 238);
--foreground: oklch(0.19 0.025 248);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.19 0.025 248);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--popover-foreground: oklch(0.19 0.025 248);
--primary: oklch(0.546 0.245 262.881);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.58 0.16 255);
}
.dark {
--background: oklch(0.145 0 0);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
* { @apply border-border outline-ring/50; }
html { min-width: 320px; }
body {
@apply bg-background text-foreground;
min-width: 320px;
min-height: 100vh;
margin: 0;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
@apply bg-background text-foreground;
}
}
}
.tag-filter-scrollbar,
.thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
.tag-filter-scrollbar::-webkit-scrollbar,
.thin-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.tag-filter-scrollbar::-webkit-scrollbar-thumb,
.thin-scrollbar::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #cbd5e1;
}
.dark .tag-filter-scrollbar,
.dark .thin-scrollbar {
scrollbar-color: #475569 transparent;
}
.dark .tag-filter-scrollbar::-webkit-scrollbar-thumb,
.dark .thin-scrollbar::-webkit-scrollbar-thumb {
background: #475569;
}
+51 -35
View File
@@ -1,11 +1,12 @@
import {type RefObject} from 'react';
import {useNavigate} from 'react-router-dom';
import {useLocation, useNavigate} from 'react-router-dom';
import type {MenuProps} from 'antd';
import {App as AntApp, Avatar, Button, Dropdown, Space} from 'antd';
import {BookOpen, Eye, LogOut, Moon, Sun, User as UserIcon} from 'lucide-react';
import {App as AntApp, Avatar, Button, Dropdown, Tooltip} from 'antd';
import {BookOpen, ChevronRight, Eye, LogOut, Moon, Sun, User as UserIcon} from 'lucide-react';
import {logout} from '@/api/auth';
import {useRuntimeConfig} from '@/api/runtime';
import type {User} from '@/types';
import {menuItems} from './menu';
interface HeaderProps {
userInfo: User | null;
@@ -16,8 +17,13 @@ interface HeaderProps {
export const AdminHeader = ({userInfo, appliedTheme, themeButtonRef, onToggleTheme}: HeaderProps) => {
const navigate = useNavigate();
const location = useLocation();
const {message: messageApi, modal} = AntApp.useApp();
const {data: runtime} = useRuntimeConfig();
const activeItem = menuItems.find((item) => location.pathname === item.path || location.pathname.startsWith(`${item.path}/`));
const currentPageLabel = location.pathname.startsWith('/admin/agents-install/')
? '部署指南'
: activeItem?.label || '管理后台';
const handleLogout = () => {
modal.confirm({
@@ -41,61 +47,71 @@ export const AdminHeader = ({userInfo, appliedTheme, themeButtonRef, onToggleThe
];
return (
<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">
<header className="fixed inset-x-0 top-0 z-[300] h-[60px] border-b border-[#e8ebf0] bg-white/95 backdrop-blur-xl dark:border-[#272b33] dark:bg-[#0f1115]/95 lg:left-[248px]">
<div className="flex h-full items-center gap-4 px-4 lg:px-8">
<div className="ml-[46px] flex min-w-0 items-center gap-2.5 lg:hidden">
<div className="grid size-[38px] shrink-0 place-items-center overflow-hidden">
<img
src="/api/logo"
alt="Logo"
className="h-10 w-10 object-contain rounded-md"
className="size-[31px] object-contain"
onError={(e) => { e.currentTarget.src = '/logo.png'; }}
/>
</div>
<div>
<p className="text-xs uppercase tracking-[0.3em] text-white/60">{runtime?.system.nameZh}</p>
<p className="text-sm font-semibold">控制台</p>
<div className="min-w-0 leading-[1.2]">
<p className="m-0 overflow-hidden text-sm font-bold text-ellipsis whitespace-nowrap text-[#1f2329] dark:text-[#e6e8ec]">{runtime?.system.nameZh || 'Pika'}</p>
<p className="mt-[3px] mb-0 text-[10px] text-slate-400 max-sm:hidden">管理控制台</p>
</div>
</div>
<Space size={8} className="flex h-full items-center">
<div className="hidden min-w-0 items-center gap-[7px] text-xs font-medium text-slate-400 lg:flex">
<span>控制中心</span>
<ChevronRight size={13}/>
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-[#4e5969] dark:text-[#b7bcc5]">{currentPageLabel}</span>
</div>
<div className="ml-auto flex items-center gap-1">
<div className="max-sm:hidden">
<Button
type="text"
icon={<Eye size={16} strokeWidth={2}/>}
onClick={() => window.open('/', '_blank')}
style={{color: appliedTheme === 'dark' ? '#cbd5e1' : '#646a73', fontSize: 12}}
>
公共页面
</Button>
</div>
<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}/>}
icon={<BookOpen size={16} 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"
style={{color: appliedTheme === 'dark' ? '#cbd5e1' : '#646a73', fontSize: 12}}
>
部署指南
</Button>
<button
ref={themeButtonRef}
type="button"
onClick={onToggleTheme}
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>
<Tooltip title={appliedTheme === 'dark' ? '切换到浅色模式' : '切换到暗黑模式'}>
<button
ref={themeButtonRef}
type="button"
onClick={onToggleTheme}
className="grid size-9 cursor-pointer place-items-center rounded-[9px] border-0 bg-transparent text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
aria-label={appliedTheme === 'dark' ? '切换到浅色模式' : '切换到暗黑模式'}
>
{appliedTheme === 'dark' ? <Sun size={17} strokeWidth={2}/> : <Moon size={17} strokeWidth={2}/>}
</button>
</Tooltip>
<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"
className="flex min-w-0 cursor-pointer items-center gap-2 rounded-[10px] border-0 bg-transparent py-[3px] pr-2 pl-1 text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
<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>
<Avatar size={28} icon={<UserIcon size={15} strokeWidth={2}/>} style={{background: '#dbeafe', color: '#2563eb'}}/>
<span className="max-w-[120px] overflow-hidden text-xs font-semibold text-ellipsis whitespace-nowrap max-sm:hidden">{userInfo?.username || '访客'}</span>
</button>
</Dropdown>
</Space>
</div>
</div>
</header>
);
+64 -27
View File
@@ -1,38 +1,75 @@
import {useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import {ChevronRight, Menu, X} from 'lucide-react';
import {menuItems} from './menu';
import {cn} from '@/lib/utils';
import {useRuntimeConfig} from '@/api/runtime';
/** 移动端底部导航。菜单可横向滚动,避免固定列数截断。 */
/** 小屏抽屉导航,与桌面侧栏保持一致的信息层级。 */
export const AdminMobileNav = () => {
const [open, setOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const {data: runtime} = useRuntimeConfig();
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [open]);
const goTo = (path: string) => {
navigate(path);
setOpen(false);
};
return (
<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="flex overflow-x-auto thin-scrollbar">
{menuItems.map((item) => {
const isActive = location.pathname.startsWith(item.path);
return (
<button
key={item.key}
type="button"
onClick={() => navigate(item.path)}
className={cn(
'flex min-w-[64px] flex-col items-center justify-center gap-1 py-2 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>
<>
<button type="button" className="fixed top-2.5 left-3 z-[330] grid size-10 cursor-pointer place-items-center rounded-[11px] border border-[#dbe4ee] bg-white text-slate-700 dark:border-slate-700 dark:bg-[#171a21] dark:text-slate-200 lg:hidden" onClick={() => setOpen(true)} aria-label="打开导航">
<Menu size={19}/>
</button>
{open && (
<div className="fixed inset-0 z-[500]">
<button type="button" className="absolute inset-0 w-full cursor-pointer border-0 bg-slate-950/60 backdrop-blur-[3px]" onClick={() => setOpen(false)} aria-label="关闭导航遮罩"/>
<aside className="absolute inset-y-0 left-0 w-[min(86vw,320px)] overflow-y-auto bg-[#18202d] text-white shadow-[18px_0_50px_rgba(2,6,23,0.24)] dark:bg-[#111827]">
<div className="flex h-[60px] items-center gap-[11px] border-b border-white/[0.09] px-3.5">
<div className="grid size-9 shrink-0 place-items-center">
<img src="/api/logo" alt="Logo" className="size-[27px] object-contain" onError={(event) => { event.currentTarget.src = '/logo.png'; }}/>
</div>
<div className="min-w-0 leading-[1.2]">
<div className="overflow-hidden text-sm font-semibold tracking-[0.02em] text-ellipsis whitespace-nowrap">{runtime?.system.nameZh || 'Pika'}</div>
<div className="mt-1 overflow-hidden text-[10px] font-medium tracking-[0.11em] text-ellipsis whitespace-nowrap uppercase text-blue-100/60">管理控制台</div>
</div>
<button type="button" className="ml-auto grid size-9 cursor-pointer place-items-center rounded-[10px] border-0 bg-transparent text-slate-400 hover:bg-white/[0.08] hover:text-white" onClick={() => setOpen(false)} aria-label="关闭导航">
<X size={19}/>
</button>
</div>
<div className="px-[22px] pt-[18px] pb-2 text-[10px] font-semibold tracking-[0.13em] text-gray-200/45">管理</div>
<nav className="flex flex-col gap-1 px-3 pb-6" aria-label="主导航">
{menuItems.map((item) => {
const active = location.pathname === item.path || location.pathname.startsWith(`${item.path}/`);
return (
<button
key={item.key}
type="button"
onClick={() => goTo(item.path)}
className={cn(
'grid cursor-pointer grid-cols-[34px_minmax(0,1fr)_16px] items-center gap-2.5 rounded-[11px] border-0 px-2.5 py-[7px] text-left',
active ? 'bg-[#1677ff]/20 text-[#f4f8ff]' : 'bg-transparent text-blue-50/90 hover:bg-white/[0.08]',
)}
aria-current={active ? 'page' : undefined}
>
<span className={cn('grid size-[34px] place-items-center bg-transparent', active ? 'text-[#69a7ff]' : 'text-blue-50/70')}>{item.icon}</span>
<span className="min-w-0 text-[13px] font-semibold">
<span>{item.label}</span>
</span>
<ChevronRight size={15}/>
</button>
);
})}
</nav>
</aside>
</div>
)}
</>
);
};
+26 -31
View File
@@ -1,64 +1,59 @@
import {useLocation, useNavigate} from 'react-router-dom';
import {menuItems, SIDEBAR_WIDTH, HEADER_HEIGHT} from './menu';
import {menuItems} from './menu';
import {cn} from '@/lib/utils';
import {useRuntimeConfig} from '@/api/runtime';
import type {VersionInfo} from '@/api/version';
export const AdminSider = ({version}: {version?: VersionInfo}) => {
export const AdminSider = () => {
const location = useLocation();
const navigate = useNavigate();
const {data: runtime} = useRuntimeConfig();
return (
<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}}
>
<aside className="fixed inset-y-0 left-0 z-[320] hidden w-[248px] overflow-hidden bg-[#18202d] text-white dark:bg-[#111827] lg:block">
<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 className="flex h-[60px] shrink-0 items-center gap-[11px] border-b border-white/[0.09] px-4">
<div className="grid size-9 shrink-0 place-items-center">
<img
src="/api/logo"
alt="Logo"
className="size-[27px] object-contain"
onError={(event) => { event.currentTarget.src = '/logo.png'; }}
/>
</div>
<div className="min-w-0 leading-[1.2]">
<div className="overflow-hidden text-sm font-semibold tracking-[0.02em] text-ellipsis whitespace-nowrap text-white">{runtime?.system.nameZh || 'Pika'}</div>
<div className="mt-1 overflow-hidden text-[10px] font-medium tracking-[0.11em] text-ellipsis whitespace-nowrap uppercase text-blue-100/60">{runtime?.system.nameEn || 'Monitor'}</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 pb-6 space-y-1 thin-scrollbar">
<div className="px-[22px] pt-[18px] pb-2 text-[10px] font-semibold tracking-[0.13em] text-gray-200/45">管理</div>
<nav className="thin-scrollbar flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-3 pb-5">
{menuItems.map((item) => {
const isActive = location.pathname.startsWith(item.path);
const isActive = location.pathname === item.path || location.pathname.startsWith(`${item.path}/`);
return (
<button
key={item.key}
type="button"
onClick={() => navigate(item.path)}
className={cn(
'group relative flex w-full items-center gap-3 rounded-2xl px-3 py-2 text-sm transition-all cursor-pointer',
'flex min-h-[42px] cursor-pointer items-center gap-2.5 rounded-lg border-0 px-2.5 py-1.5 text-left text-[13px] transition-colors',
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',
? 'bg-[#1677ff]/20 text-[#f4f8ff] hover:bg-[#1677ff]/20'
: 'bg-transparent text-gray-200/80 hover:bg-white/[0.06] hover:text-white',
)}
aria-current={isActive ? 'page' : undefined}
>
<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',
'grid size-8 shrink-0 place-items-center bg-transparent',
isActive ? 'text-[#69a7ff]' : 'text-gray-200/65',
)}>
{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>}
<span className="overflow-hidden font-semibold text-ellipsis whitespace-nowrap">{item.label}</span>
</button>
);
})}
</nav>
{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">
<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]">{runtime?.system.nameEn}</p>
</div>
</div>
</div>
)}
</div>
</aside>
);
+65 -15
View File
@@ -1,22 +1,72 @@
import {useEffect, useRef, useState} from 'react';
import {useEffect, useMemo, useRef, useState} from 'react';
import {Outlet, useLocation, useNavigate} from 'react-router-dom';
import {ConfigProvider, theme} from 'antd';
import {ConfigProvider, theme, type ThemeConfig} from 'antd';
import {AdminHeader} from './Header';
import {AdminSider} from './Sider';
import {AdminMobileNav} from './MobileNav';
import {useThemeToggle} from './useThemeToggle';
import {HEADER_HEIGHT} from './menu';
import {getServerVersion, type VersionInfo} from '@/api/version';
import type {User} from '@/types';
const AdminLayout = () => {
const navigate = useNavigate();
const location = useLocation();
const isWideCompactPage = [
'/admin/agents',
'/admin/monitors',
'/admin/ddns',
'/admin/api-keys',
'/admin/manage-api-keys',
'/admin/alert-records',
'/admin/agents-install/one-click',
'/admin/agents-install/manual',
].includes(location.pathname);
const [userInfo, setUserInfo] = useState<User | null>(null);
const [version, setVersion] = useState<VersionInfo>();
const themeButtonRef = useRef<HTMLButtonElement>(null);
const {appliedTheme, toggleTheme} = useThemeToggle(themeButtonRef);
const themeConfig = useMemo<ThemeConfig>(() => ({
algorithm: appliedTheme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorPrimary: '#1677ff',
colorInfo: '#1677ff',
colorBgLayout: appliedTheme === 'dark' ? '#0f1115' : '#f5f7fa',
colorBgContainer: appliedTheme === 'dark' ? '#171a21' : '#ffffff',
colorBgElevated: appliedTheme === 'dark' ? '#1c2028' : '#ffffff',
colorBorder: appliedTheme === 'dark' ? '#30343d' : '#d9dee7',
colorBorderSecondary: appliedTheme === 'dark' ? '#272b33' : '#e8ebf0',
colorText: appliedTheme === 'dark' ? '#e6e8ec' : '#1f2329',
colorTextSecondary: appliedTheme === 'dark' ? '#9ba1ab' : '#646a73',
borderRadius: 8,
borderRadiusLG: 10,
controlHeight: 36,
boxShadowSecondary: appliedTheme === 'dark'
? '0 16px 40px rgba(0, 0, 0, 0.28)'
: '0 16px 40px rgba(15, 23, 42, 0.10)',
},
components: {
Button: {primaryShadow: 'none', borderRadius: 8},
Card: {
headerBg: 'transparent',
headerFontSize: 13,
headerHeight: 46,
headerPadding: 18,
bodyPadding: 18,
},
Table: {
headerBg: appliedTheme === 'dark' ? '#1c2028' : '#f7f8fa',
headerColor: appliedTheme === 'dark' ? '#aeb4be' : '#4e5969',
headerSplitColor: 'transparent',
borderColor: appliedTheme === 'dark' ? '#272b33' : '#edf0f4',
rowHoverBg: appliedTheme === 'dark' ? '#1b2028' : '#f7faff',
fixedHeaderSortActiveBg: appliedTheme === 'dark' ? '#20242c' : '#f1f4f8',
headerSortActiveBg: appliedTheme === 'dark' ? '#20242c' : '#f1f4f8',
headerBorderRadius: 0,
},
Tabs: {horizontalItemGutter: 24},
},
}), [appliedTheme]);
// 鉴权检查只在挂载时执行一次(不再依赖 location 导致每次路由切换都重查)
useEffect(() => {
const token = localStorage.getItem('token');
@@ -26,26 +76,26 @@ const AdminLayout = () => {
return;
}
setUserInfo(JSON.parse(userInfoStr));
getServerVersion().then((res) => setVersion(res.data)).catch((err) => console.error('获取版本信息失败:', err));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<ConfigProvider theme={{algorithm: appliedTheme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm}}>
<div className="min-h-screen bg-white dark:bg-[#141414]">
<ConfigProvider theme={themeConfig}>
<div className="min-h-screen bg-[#f5f7fa] text-[#1f2329] dark:bg-[#0f1115] dark:text-[#e6e8ec]">
<AdminHeader
userInfo={userInfo}
appliedTheme={appliedTheme}
themeButtonRef={themeButtonRef}
onToggleTheme={toggleTheme}
/>
<AdminSider version={version}/>
<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">
<AdminSider/>
<div className="min-h-screen" style={{paddingTop: HEADER_HEIGHT}}>
<main
className={isWideCompactPage
? 'px-4 pt-4 pb-[88px] lg:ml-[248px] lg:px-8 lg:pt-4 lg:pb-10'
: 'px-4 pt-[22px] pb-[88px] lg:ml-[248px] lg:px-8 lg:pt-6 lg:pb-10'}
style={{minHeight: `calc(100vh - ${HEADER_HEIGHT}px)`}}
>
<div className={isWideCompactPage ? 'w-full' : 'mx-auto w-full max-w-[1320px]'}>
<Outlet/>
</div>
</main>
+9 -9
View File
@@ -9,14 +9,14 @@ export interface NavItem {
}
export const menuItems: NavItem[] = [
{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}/>},
{key: 'agents', label: '探针管理', path: '/admin/agents', icon: <Server size={17} strokeWidth={2}/>},
{key: 'monitors', label: '服务监控', path: '/admin/monitors', icon: <Activity size={17} strokeWidth={2}/>},
{key: 'ddns', label: 'DDNS', path: '/admin/ddns', icon: <Globe size={17} strokeWidth={2}/>},
{key: 'comm-keys', label: '通信密钥', path: '/admin/api-keys', icon: <Key size={17} strokeWidth={2}/>},
{key: 'api-keys', label: 'API 密钥', path: '/admin/manage-api-keys', icon: <Key size={17} strokeWidth={2}/>},
{key: 'alert-records', label: '告警记录', path: '/admin/alert-records', icon: <AlertTriangle size={17} strokeWidth={2}/>},
{key: 'settings', label: '系统设置', path: '/admin/settings', icon: <Settings size={17} strokeWidth={2}/>},
];
export const SIDEBAR_WIDTH = 240;
export const HEADER_HEIGHT = 56;
export const SIDEBAR_WIDTH = 248;
export const HEADER_HEIGHT = 60;
+30 -28
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {useNavigate, useParams, useSearchParams} from 'react-router-dom';
import type {TabsProps} from 'antd';
import {Alert, Button, Card, Space, Spin, Tabs, Tag} from 'antd';
import {Alert, Spin, Tabs, Tag} from 'antd';
import {Activity, ArrowLeft, FileWarning, Lock, Shield, TrendingUp} from 'lucide-react';
import {useQuery} from '@tanstack/react-query';
import {getAgentForAdmin} from '@/api/agent.ts';
@@ -10,6 +10,8 @@ import AgentAudit from './AgentAudit';
import TamperProtection from './TamperProtection';
import SSHLoginMonitor from './SSHLoginMonitor';
import TrafficStats from './TrafficStats';
import {PageHeader} from '@/components/PageHeader';
import {PagePanel} from '@/components/PagePanel';
const AgentDetail = () => {
const {id} = useParams<{ id: string }>();
@@ -133,38 +135,38 @@ const AgentDetail = () => {
return (
<div className="space-y-4 lg:space-y-6">
{/* 探针状态卡片 */}
<Card variant="outlined">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Activity size={32} className="text-blue-500"/>
<div>
<h2 className="text-xl font-semibold m-0">{agent.name || agent.hostname}</h2>
<Space className="mt-1">
<span className="text-gray-500">{agent.hostname}</span>
{agent.status === 1 ? (
<Tag color="success">在线</Tag>
) : (
<Tag color="error">离线</Tag>
)}
</Space>
<PageHeader
title={agent.name || agent.hostname}
actions={[{
key: 'back',
label: '返回列表',
icon: <ArrowLeft size={16}/>,
onClick: () => navigate('/admin/agents'),
}]}
/>
<PagePanel>
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400">
<Activity size={21}/>
</div>
<div>
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">当前连接状态</div>
<div className="mt-1">
{agent.status === 1 ? <Tag color="success">在线</Tag> : <Tag color="error">离线</Tag>}
</div>
</div>
<Button
icon={<ArrowLeft size={16}/>}
onClick={() => navigate('/admin/agents')}
>
返回列表
</Button>
</div>
</Card>
</PagePanel>
{/* Tabs 内容 */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
/>
<PagePanel>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
/>
</PagePanel>
</div>
);
};
+62 -46
View File
@@ -1,5 +1,5 @@
import React, { type ReactElement, useMemo, useState } from 'react';
import { App, Button, Card, Space, Tabs, Typography } from 'antd';
import {App, Button, Card, Space, Typography} from 'antd';
import { CopyIcon } from 'lucide-react';
import copy from 'copy-to-clipboard';
import linuxPng from '../../assets/os/linux.png';
@@ -16,7 +16,6 @@ import {
import { useAgentInstallConfig } from './useAgentInstallConfig';
const { Text } = Typography;
const { TabPane } = Tabs;
type OSType = 'linux-amd64' | 'linux-arm64' | 'linux-loong64' | 'darwin-amd64' | 'darwin-arm64' | 'windows-amd64' | 'windows-arm64';
@@ -142,9 +141,12 @@ curl -L "${backendServerUrl}${config.downloadUrl}?key=${selectedApiKey}" -o ${AG
];
};
const selectedOSConfig = osConfigs[selectedOS];
const selectedOSSteps = getManualInstallSteps(selectedOS);
return (
<AgentInstallLayout activeKey="manual">
<Space direction="vertical" className="w-full">
<Space orientation={'vertical'} className={'w-full'}>
<ApiChooser
apiKeys={apiKeys}
selectedApiKey={selectedApiKeyId}
@@ -156,52 +158,66 @@ curl -L "${backendServerUrl}${config.downloadUrl}?key=${selectedApiKey}" -o ${AG
setSelectedApiKeyId(apiKey.id);
}}
/>
<Tabs
activeKey={selectedOS}
onChange={(key) => setSelectedOS(key as OSType)}
>
{Object.entries(osConfigs).map(([key, config]) => (
<TabPane
tab={
<div className="flex items-center gap-2">
<Card title="选择系统与架构">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{Object.entries(osConfigs).map(([key, config]) => {
const active = key === selectedOS;
return (
<button
key={key}
type="button"
onClick={() => setSelectedOS(key as OSType)}
className={active
? 'flex min-h-11 items-center gap-2 rounded-lg border border-[#1677ff] bg-[#eaf2ff] px-3 text-left text-[13px] font-semibold text-[#145dcc] dark:border-[#4086e8] dark:bg-[#1677ff]/15 dark:text-[#75adff]'
: 'flex min-h-11 items-center gap-2 rounded-lg border border-[#d9dee7] bg-transparent px-3 text-left text-[13px] text-[#4e5969] hover:border-[#8dbbfa] hover:bg-[#f7faff] dark:border-[#30343d] dark:text-[#b7bcc5] dark:hover:border-[#42689c] dark:hover:bg-[#20242c]'}
>
{config.icon}
<span>{config.name}</span>
</div>
}
key={key}
>
<Space direction="vertical" className="w-full">
<Card type="inner" title="手动安装步骤">
<Space direction="vertical" className="w-full" size="middle">
{getManualInstallSteps(key as OSType).map((step, index) => (
<div key={index}>
<Text strong
className="block mb-2 text-gray-900 dark:text-slate-100">{step.title}</Text>
<pre
className="m-0 overflow-auto text-sm bg-gray-50 dark:bg-slate-800 p-3 rounded text-gray-900 dark:text-slate-100">
<code>{step.command}</code>
</pre>
<Button
type="link"
onClick={() => void copyToClipboard(step.command)}
icon={<CopyIcon className="h-4 w-4" />}
size="small"
style={{ margin: 0, padding: 0 }}
disabled={!selectedApiKey}
>
复制
</Button>
</div>
))}
</Space>
</Card>
</button>
);
})}
</div>
</Card>
<ServiceHelper os={key} />
<ConfigHelper />
</Space>
</TabPane>
))}
</Tabs>
<Card title={`${selectedOSConfig.name} 安装步骤`}>
<div className="space-y-5">
{selectedOSSteps.map((step, index) => (
<div
key={step.title}
className="grid gap-3 border-b border-slate-200 pb-5 last:border-0 last:pb-0 dark:border-slate-700 lg:grid-cols-[150px_minmax(0,1fr)]"
>
<div className="flex items-start gap-2">
<span className="grid size-6 shrink-0 place-items-center rounded-full bg-[#eaf2ff] text-xs font-semibold text-[#145dcc] dark:bg-[#1677ff]/15 dark:text-[#75adff]">
{index + 1}
</span>
<Text strong className="pt-0.5 text-gray-900 dark:text-slate-100">
{step.title.replace(/^\d+\.\s*/, '')}
</Text>
</div>
<div className="min-w-0">
<pre className="m-0 overflow-auto rounded-lg border border-slate-200 bg-slate-950 p-4 text-[13px] leading-6 text-slate-100 dark:border-slate-700">
<code>{step.command}</code>
</pre>
<Button
type="link"
onClick={() => void copyToClipboard(step.command)}
icon={<CopyIcon className="h-4 w-4"/>}
size="small"
style={{margin: '8px 0 0', padding: 0}}
disabled={!selectedApiKey}
>
复制
</Button>
</div>
</div>
))}
</div>
</Card>
<div className="grid items-start gap-4 xl:grid-cols-2">
<ServiceHelper os={selectedOS}/>
<ConfigHelper/>
</div>
</Space>
</AgentInstallLayout>
);
+25 -30
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, App, Button, Card, Input, Select, Space, Spin, Typography } from 'antd';
import { Alert, App, Button, Card, Input, Select, Spin, Typography } from 'antd';
import { CopyIcon, Plus } from 'lucide-react';
import copy from 'copy-to-clipboard';
import { Link } from 'react-router-dom';
@@ -109,9 +109,10 @@ const AgentInstallOneClick = () => {
return (
<AgentInstallLayout activeKey="one-click">
<Space direction="vertical" className="w-full">
<Card type="inner" title="配置选项">
<Space direction="vertical" className="w-full">
<div className="space-y-4">
<div className="grid items-start gap-4 xl:grid-cols-[minmax(360px,0.8fr)_minmax(0,1.2fr)]">
<Card title="安装配置">
<div className="space-y-5">
<div>
<div className="mb-1 text-gray-600 dark:text-slate-400">
服务端地址 <span className="text-xs text-gray-400">(必填)</span>
@@ -218,45 +219,39 @@ const AgentInstallOneClick = () => {
allowClear
/>
</div>
</Space>
</div>
</Card>
<Alert
description="一键安装脚本仅支持 Linux/macOS 系统。"
type="info"
showIcon
className="mt-2"
/>
{!effectiveServerUrl && (
<Alert
description="请先配置服务端地址后再生成安装命令。"
type="warning"
showIcon
className="mt-2"
/>
)}
<Card type="inner" title="一键安装">
<Card title="运行安装命令">
<div className="mb-4 space-y-2">
<Alert description="支持 Linux 和 macOS,脚本会自动识别系统与架构。" type="info" showIcon/>
{!effectiveServerUrl && (
<Alert description="请先配置服务端地址后再生成安装命令。" type="warning" showIcon/>
)}
</div>
<Paragraph type="secondary" className="mb-3 text-gray-600 dark:text-slate-400">
脚本会自动检测系统架构并下载对应版本的探针,然后完成注册和安装。
配置完成后,在目标服务器终端中执行以下命令:
</Paragraph>
<pre
className="m-0 overflow-auto text-sm bg-gray-50 dark:bg-slate-800 p-3 rounded text-gray-900 dark:text-slate-100">
<code>{installCommand}</code>
<pre className="m-0 min-h-28 overflow-auto rounded-lg border border-slate-200 bg-slate-950 p-4 text-[13px] leading-6 text-slate-100 dark:border-slate-700">
<code>{installCommand || '# 完成左侧配置后生成安装命令'}</code>
</pre>
<Button
type="link"
type="primary"
onClick={() => void copyToClipboard(installCommand)}
icon={<CopyIcon className="h-4 w-4" />}
style={{ margin: 0, padding: 0 }}
style={{marginTop: 14}}
disabled={!selectedApiKey || !effectiveServerUrl}
>
复制命令
</Button>
</Card>
</div>
<ServiceHelper os={AGENT_NAME} />
<ConfigHelper />
</Space>
<div className="grid items-start gap-4 xl:grid-cols-2">
<ServiceHelper os={AGENT_NAME}/>
<ConfigHelper/>
</div>
</div>
<ApiKeyModal
open={isCreateModalVisible}
@@ -268,4 +263,4 @@ const AgentInstallOneClick = () => {
);
};
export default AgentInstallOneClick;
export default AgentInstallOneClick;
+40 -35
View File
@@ -1,8 +1,9 @@
import React, { type ReactNode, useState } from 'react';
import { Alert, Button, Card, Input, Select, Space, Typography } from 'antd';
import { Alert, Button, Card, Input, Select, Typography } from 'antd';
import { Link, useNavigate } from 'react-router-dom';
import { Plus } from 'lucide-react';
import {ArrowLeft, Plus, TerminalSquare, Wrench} from 'lucide-react';
import type { ApiKey } from '@/types';
import {PageHeader} from '@/components/PageHeader';
import ApiKeyModal from '../ApiKeys/ApiKeyModal';
const { Paragraph, Text } = Typography;
@@ -31,37 +32,42 @@ export const AgentInstallLayout = ({ activeKey, children }: { activeKey: Install
const navigate = useNavigate();
return (
<Space direction="vertical" className="w-full" size="large">
<div className="flex gap-2 items-center">
<div
className="text-sm cursor-pointer hover:underline text-gray-600 dark:text-slate-300"
onClick={() => navigate(-1)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && navigate(-1)}
>
返回 |
</div>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-slate-100">探针部署指南</h1>
<div className="space-y-4">
<PageHeader
title="探针部署指南"
actions={[{
key: 'back',
label: '返回探针列表',
icon: <ArrowLeft size={16}/>,
onClick: () => navigate('/admin/agents'),
}]}
/>
<div className="flex items-center gap-1 rounded-[10px] border border-[#e8ebf0] bg-white p-1 dark:border-[#272b33] dark:bg-[#171a21]">
<button
type="button"
onClick={() => navigate('/admin/agents-install/one-click')}
className={activeKey === 'one-click'
? 'flex min-h-9 items-center gap-2 rounded-lg border-0 bg-[#eaf2ff] px-3 text-[13px] font-semibold text-[#145dcc] dark:bg-[#1677ff]/15 dark:text-[#75adff]'
: 'flex min-h-9 items-center gap-2 rounded-lg border-0 bg-transparent px-3 text-[13px] text-[#646a73] hover:bg-[#f5f6f8] dark:text-[#9ba1ab] dark:hover:bg-[#20242c]'}
>
<TerminalSquare size={16}/>
一键安装
</button>
<button
type="button"
onClick={() => navigate('/admin/agents-install/manual')}
className={activeKey === 'manual'
? 'flex min-h-9 items-center gap-2 rounded-lg border-0 bg-[#eaf2ff] px-3 text-[13px] font-semibold text-[#145dcc] dark:bg-[#1677ff]/15 dark:text-[#75adff]'
: 'flex min-h-9 items-center gap-2 rounded-lg border-0 bg-transparent px-3 text-[13px] text-[#646a73] hover:bg-[#f5f6f8] dark:text-[#9ba1ab] dark:hover:bg-[#20242c]'}
>
<Wrench size={16}/>
手动安装
</button>
</div>
<Space size={8}>
<Button
type={activeKey === 'one-click' ? 'primary' : 'default'}
onClick={() => navigate('/admin/agents-install/one-click')}
>
一键安装
</Button>
<Button
type={activeKey === 'manual' ? 'primary' : 'default'}
onClick={() => navigate('/admin/agents-install/manual')}
>
手动安装
</Button>
</Space>
{children}
</Space>
</div>
);
};
@@ -199,23 +205,22 @@ ${agentCmd} version`;
};
export const ServiceHelper = ({ os }: { os: string }) => (
<Card type="inner" title="服务管理命令">
<Card title="服务管理命令">
<Paragraph type="secondary" className="mb-3 text-gray-600 dark:text-slate-400">
注册完成后,您可以使用以下命令管理探针服务:
</Paragraph>
<pre
className="m-0 overflow-auto text-sm bg-gray-50 dark:bg-slate-800 p-3 rounded text-gray-900 dark:text-slate-100">
<pre className="m-0 max-h-[320px] overflow-auto rounded-lg border border-slate-200 bg-slate-950 p-4 text-[13px] leading-6 text-slate-100 dark:border-slate-700">
<code>{getCommonCommands(os)}</code>
</pre>
</Card>
);
export const ConfigHelper = () => (
<Card type="inner" title="配置文件说明">
<Card title="配置文件说明">
<Paragraph className="text-gray-900 dark:text-slate-100">
注册完成后,配置文件会保存在:
</Paragraph>
<ul className="space-y-2 text-gray-600 dark:text-slate-400">
<ul className="m-0 space-y-2 pl-5 text-gray-600 dark:text-slate-400">
<li>
<Text code>{CONFIG_PATH}</Text> - 配置文件路径
</li>
+80 -91
View File
@@ -1,7 +1,7 @@
import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import type {MenuProps} from 'antd';
import {App, Button, Divider, Dropdown, Form, Input, Select, Space, Table, Tag, message} from 'antd';
import {App, Button, Divider, Dropdown, Form, Input, Select, Space, Tag, message} from 'antd';
import type {ColumnsType} from 'antd/es/table';
import {Edit, Eye, EyeOff, FileWarning, Lock, MoreVertical, Plus, RefreshCw, Shield, Tags, Trash2} from 'lucide-react';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
@@ -10,6 +10,7 @@ import {deleteAgent, getTags, listAgentsByAdmin} from '@/api/agent.ts';
import type {Agent} from '@/types';
import {getErrorMessage} from '@/lib/utils';
import {PageHeader} from '@/components/PageHeader';
import {AdminDataTable} from '@/components/AdminDataTable';
import AgentEditModal from './AgentEditModal';
import BatchTagsModal from './BatchTagsModal';
import BatchTamperProtectionModal from './BatchTamperProtectionModal';
@@ -450,40 +451,59 @@ const AgentList = () => {
},
];
const batchMenuItems: MenuProps['items'] = [
{key: 'tags', icon: <Tags size={15}/>, label: '修改标签', onClick: handleBatchTags},
{key: 'visibility', icon: <EyeOff size={15}/>, label: '修改可见性', onClick: handleBatchVisibility},
{key: 'tamper', icon: <FileWarning size={15}/>, label: '配置防篡改保护', onClick: handleBatchTamperConfig},
{key: 'ssh', icon: <Lock size={15}/>, label: '配置 SSH 登录监控', onClick: handleBatchSSHConfig},
];
return (
<div className="space-y-6">
<PageHeader
title="探针管理"
description="管理和监控系统探针状态"
extra={(
<>
<Form
form={searchForm}
layout="inline"
onFinish={handleSearch}
className="min-w-0 max-sm:w-full"
style={{display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-end', gap: 8}}
>
<Form.Item name="keyword" style={{margin: 0}}>
<Input
allowClear
placeholder="搜索名称、主机名或 IP"
style={{width: 220}}
/>
</Form.Item>
<Form.Item name="status" style={{margin: 0}}>
<Select
placeholder="全部状态"
allowClear
style={{width: 112}}
options={[
{label: '在线', value: 'online'},
{label: '离线', value: 'offline'},
]}
/>
</Form.Item>
<Form.Item style={{margin: 0}}>
<Space size={8}>
<Button type="primary" htmlType="submit">查询</Button>
<Button onClick={handleReset}>重置</Button>
</Space>
</Form.Item>
</Form>
<Dropdown menu={{items: batchMenuItems}} trigger={['click']} disabled={selectedRowKeys.length === 0}>
<Button icon={<Tags size={16}/>} disabled={selectedRowKeys.length === 0}>
批量操作{selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ''}
</Button>
</Dropdown>
</>
)}
actions={[
{
key: 'batch-tags',
label: `批量操作标签${selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ''}`,
icon: <Tags size={16}/>,
onClick: handleBatchTags,
disabled: selectedRowKeys.length === 0,
},
{
key: 'batch-visibility',
label: `批量修改可见性${selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ''}`,
icon: <EyeOff size={16}/>,
onClick: handleBatchVisibility,
disabled: selectedRowKeys.length === 0,
},
{
key: 'batch-tamper',
label: `批量配置防篡改保护${selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ''}`,
icon: <FileWarning size={16}/>,
onClick: handleBatchTamperConfig,
disabled: selectedRowKeys.length === 0,
},
{
key: 'batch-ssh',
label: `批量配置 SSH 登录监控${selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ''}`,
icon: <Lock size={16}/>,
onClick: handleBatchSSHConfig,
disabled: selectedRowKeys.length === 0,
},
{
key: 'register',
label: '注册探针',
@@ -500,66 +520,36 @@ const AgentList = () => {
]}
/>
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-6">
<div className="flex flex-col gap-4">
<Form form={searchForm} layout="inline" onFinish={handleSearch} className="!flex-wrap gap-y-4">
<Form.Item label="关键字" name="keyword">
<Input placeholder="名称/主机名/通信地址/IPv4/IPv6" style={{width: 260}}/>
</Form.Item>
<Form.Item label="状态" name="status">
<Select
placeholder="请选择状态"
allowClear
style={{width: 140}}
options={[
{label: '在线', value: 'online'},
{label: '离线', value: 'offline'},
]}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
查询
</Button>
<Button onClick={handleReset}>
重置
</Button>
</Space>
</Form.Item>
</Form>
{tags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap pt-2 border-t border-gray-50 dark:border-white/5 mt-2">
<span className="text-sm text-gray-500 dark:text-gray-400">标签筛选:</span>
<Tag.CheckableTag
checked={selectedTags.length === 0}
onChange={() => setSelectedTags([])}
className="!rounded-full"
>
全部
</Tag.CheckableTag>
{tags.map((tag) => (
<Tag.CheckableTag
key={tag}
checked={selectedTags.includes(tag)}
onChange={(checked) => {
if (checked) {
setSelectedTags([...selectedTags, tag]);
} else {
setSelectedTags(selectedTags.filter(t => t !== tag));
}
}}
className="!rounded-full"
>
{tag}
</Tag.CheckableTag>
))}
</div>
)}
{tags.length > 0 && (
<div className="flex min-h-7 flex-wrap items-center gap-1.5">
<span className="text-xs text-[#646a73] dark:text-[#9ba1ab]">标签:</span>
<Tag.CheckableTag
checked={selectedTags.length === 0}
onChange={() => setSelectedTags([])}
style={{borderRadius: 6, margin: 0}}
>
全部
</Tag.CheckableTag>
{tags.map((tag) => (
<Tag.CheckableTag
key={tag}
checked={selectedTags.includes(tag)}
onChange={(checked) => {
if (checked) {
setSelectedTags([...selectedTags, tag]);
} else {
setSelectedTags(selectedTags.filter(t => t !== tag));
}
}}
style={{borderRadius: 6, margin: 0}}
>
{tag}
</Tag.CheckableTag>
))}
</div>
)}
<Table<Agent>
<AdminDataTable<Agent>
columns={columns}
dataSource={filteredAgents}
loading={isLoading || isFetching}
@@ -571,9 +561,8 @@ const AgentList = () => {
onChange: (keys) => setSelectedRowKeys(keys),
preserveSelectedRowKeys: true,
}}
pagination={false}
/>
</div>
pagination={false}
/>
<AgentEditModal
open={editModalVisible}
+18 -26
View File
@@ -1,6 +1,6 @@
import React, {useState} from 'react';
import {useSearchParams} from 'react-router-dom';
import {App, Divider, Select, Space, Table, Tag} from 'antd';
import {App, Divider, Select, Tag} from 'antd';
import type {ColumnsType, TablePaginationConfig} from 'antd/es/table';
import {Trash2} from 'lucide-react';
import {clearAlertRecords, getAlertRecords} from '@/api/alert.ts';
@@ -8,6 +8,7 @@ import type {AlertRecord} from '@/types';
import dayjs from 'dayjs';
import {getErrorMessage} from '@/lib/utils';
import {PageHeader} from '@/components/PageHeader';
import {AdminDataTable} from '@/components/AdminDataTable';
import {useQuery, useQueryClient} from '@tanstack/react-query';
import {listAgentsByAdmin} from "@/api/agent.ts";
@@ -260,7 +261,20 @@ const AlertRecordList = () => {
<div className={'space-y-6'}>
<PageHeader
title="告警记录"
description="查看和管理系统的告警记录"
extra={(
<Select
placeholder="选择探针"
allowClear
showSearch={{
filterOption: (inputValue, option) =>
(option?.label?.toString() ?? '').toLowerCase().includes(inputValue.toLowerCase()),
}}
style={{width: 200}}
value={selectedAgentId || undefined}
onChange={handleAgentChange}
options={agentOptions}
/>
)}
actions={[
{
key: 'clear',
@@ -273,32 +287,11 @@ const AlertRecordList = () => {
]}
/>
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<Space>
<Select
placeholder="选择探针"
allowClear
showSearch={{
filterOption: (inputValue, option) =>
(option?.label?.toString() ?? '')
.toLowerCase()
.includes(inputValue.toLowerCase()),
}}
style={{width: 200}}
value={selectedAgentId || undefined}
onChange={handleAgentChange}
options={agentOptions}
/>
</Space>
</div>
<Table<AlertRecord>
<AdminDataTable<AlertRecord>
columns={columns}
dataSource={alertPaging?.items || []}
loading={isLoading || isFetching}
rowKey="id"
size={'small'}
scroll={{x: 1700}}
tableLayout="fixed"
pagination={{
@@ -311,8 +304,7 @@ const AlertRecordList = () => {
showTotal: (total) => `共 ${total} 条`,
}}
onChange={handleTableChange}
/>
</div>
/>
</div>
);
};
+18 -23
View File
@@ -1,6 +1,6 @@
import {useEffect, useState} from 'react';
import {useSearchParams, useNavigate} from 'react-router-dom';
import {App, Button, Divider, Input, Popconfirm, Space, Table, Tag} from 'antd';
import {App, Button, Divider, Input, Popconfirm, Space, Tag} from 'antd';
import type {ColumnsType, TablePaginationConfig} from 'antd/es/table';
import {Copy, Edit, Loader2, Plus, RefreshCw, Terminal, Trash2} from 'lucide-react';
import {deleteApiKey, disableApiKey, enableApiKey, getApiKeyRaw, listApiKeys} from '@/api/apiKey.ts';
@@ -8,6 +8,7 @@ import type {ApiKey} from '@/types';
import dayjs from 'dayjs';
import {getErrorMessage} from '@/lib/utils';
import {PageHeader} from '@/components/PageHeader';
import {AdminDataTable} from '@/components/AdminDataTable';
import copy from 'copy-to-clipboard';
import ApiKeyModal from './ApiKeyModal';
import ShowApiKeyModal from './ShowApiKeyModal';
@@ -242,7 +243,20 @@ const ApiKeyList = () => {
<div className="space-y-6">
<PageHeader
title="通信密钥管理"
description="管理探针连接所需的通信密钥,用于验证探针注册与数据上报"
extra={(
<Input.Search
placeholder="按名称搜索"
allowClear
value={searchValue}
onChange={(event) => {
const nextValue = event.target.value;
setSearchValue(nextValue);
if (!nextValue) handleSearch('');
}}
onSearch={handleSearch}
style={{width: 240}}
/>
)}
actions={[
{
key: 'deploy',
@@ -266,25 +280,7 @@ const ApiKeyList = () => {
]}
/>
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<Input.Search
placeholder="按名称搜索"
allowClear
value={searchValue}
onChange={(event) => {
const nextValue = event.target.value;
setSearchValue(nextValue);
if (!nextValue) {
handleSearch('');
}
}}
onSearch={handleSearch}
className="w-full max-w-md"
/>
</div>
<Table<ApiKey>
<AdminDataTable<ApiKey>
columns={columns}
dataSource={apiKeyPaging?.items || []}
loading={isLoading || isFetching}
@@ -298,8 +294,7 @@ const ApiKeyList = () => {
showSizeChanger: true,
}}
onChange={handleTableChange}
/>
</div>
/>
<ApiKeyModal
open={isModalVisible}
+33 -27
View File
@@ -1,10 +1,11 @@
import {useEffect, useState} from 'react';
import {useSearchParams} from 'react-router-dom';
import {App, Button, Divider, Dropdown, Input, Space, Table, Tag, Tooltip} from 'antd';
import {App, Button, Divider, Dropdown, Input, Space, Tag, Tooltip} from 'antd';
import type {ColumnsType} from 'antd/es/table';
import type {MenuProps} from 'antd';
import {PageHeader} from '@/components/PageHeader';
import {Globe, MoreVertical, Plus, Settings} from 'lucide-react';
import {AdminDataTable} from '@/components/AdminDataTable';
import {Globe, MoreVertical, Plus, RefreshCw, Settings} from 'lucide-react';
import dayjs from 'dayjs';
import type {DDNSConfig} from '@/types';
import {deleteDDNSConfig, disableDDNSConfig, enableDDNSConfig, getDDNSConfigs, triggerDDNSUpdate,} from '@/api/ddns';
@@ -39,6 +40,7 @@ const DDNSPage = () => {
data: ddnsPaging,
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ['admin', 'ddns', pageIndex, pageSize, keyword],
queryFn: async () => {
@@ -269,20 +271,7 @@ const DDNSPage = () => {
<div className="space-y-6">
<PageHeader
title="DDNS 配置管理"
description="管理动态 DNS 配置,支持阿里云、腾讯云、Cloudflare、华为云等服务商,自动更新域名解析记录"
actions={[
{
key: 'provider',
label: 'DNS Provider',
icon: <Settings size={16}/>,
type: 'primary',
onClick: () => setProviderModalOpen(true),
},
]}
/>
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
extra={(
<Input.Search
placeholder="按配置名称搜索"
allowClear
@@ -290,19 +279,37 @@ const DDNSPage = () => {
onChange={(event) => {
const nextValue = event.target.value;
setSearchValue(nextValue);
if (!nextValue) {
handleSearch('');
}
if (!nextValue) handleSearch('');
}}
onSearch={handleSearch}
className="w-full max-w-md"
style={{width: 240}}
/>
<Button type="primary" icon={<Plus size={16}/>} onClick={handleCreate}>
新建配置
</Button>
</div>
)}
actions={[
{
key: 'provider',
label: 'DNS Provider',
icon: <Settings size={16}/>,
onClick: () => setProviderModalOpen(true),
},
{
key: 'create',
label: '新建配置',
icon: <Plus size={16}/>,
type: 'primary',
onClick: handleCreate,
},
{
key: 'refresh',
label: '刷新',
icon: <RefreshCw size={16}/>,
onClick: () => refetch(),
loading: isFetching,
},
]}
/>
<Table<DDNSConfig>
<AdminDataTable<DDNSConfig>
columns={columns}
dataSource={ddnsPaging?.items || []}
loading={isLoading || isFetching}
@@ -316,8 +323,7 @@ const DDNSPage = () => {
showSizeChanger: true,
}}
onChange={handleTableChange}
/>
</div>
/>
<DDNSModal
open={modalOpen}
@@ -1,6 +1,6 @@
import {useEffect, useState} from 'react';
import {useSearchParams} from 'react-router-dom';
import {App, Button, Input, Popconfirm, Space, Table, Tag} from 'antd';
import {App, Button, Input, Popconfirm, Space, Tag} from 'antd';
import type {ColumnsType, TablePaginationConfig} from 'antd/es/table';
import {Edit, Plus, RefreshCw, Trash2} from 'lucide-react';
import {deleteManageApiKey, disableManageApiKey, enableManageApiKey, listManageApiKeys} from '@/api/manageApiKey.ts';
@@ -8,6 +8,7 @@ import type {ApiKey} from '@/types';
import dayjs from 'dayjs';
import {getErrorMessage} from '@/lib/utils';
import {PageHeader} from '@/components/PageHeader';
import {AdminDataTable} from '@/components/AdminDataTable';
import ManageApiKeyModal from './ManageApiKeyModal';
import ShowManageApiKeyModal from './ShowManageApiKeyModal';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
@@ -214,7 +215,20 @@ const ManageApiKeyList = () => {
<div className="space-y-6">
<PageHeader
title="API密钥管理"
description="管理用于调用系统管理接口的API密钥,可作为JWT Token的替代认证方式"
extra={(
<Input.Search
placeholder="按名称搜索"
allowClear
value={searchValue}
onChange={(event) => {
const nextValue = event.target.value;
setSearchValue(nextValue);
if (!nextValue) handleSearch('');
}}
onSearch={handleSearch}
style={{width: 240}}
/>
)}
actions={[
{
key: 'create',
@@ -232,25 +246,7 @@ const ManageApiKeyList = () => {
]}
/>
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<Input.Search
placeholder="按名称搜索"
allowClear
value={searchValue}
onChange={(event) => {
const nextValue = event.target.value;
setSearchValue(nextValue);
if (!nextValue) {
handleSearch('');
}
}}
onSearch={handleSearch}
className="w-full max-w-md"
/>
</div>
<Table<ApiKey>
<AdminDataTable<ApiKey>
columns={columns}
dataSource={apiKeyPaging?.items || []}
loading={isLoading || isFetching}
@@ -264,8 +260,7 @@ const ManageApiKeyList = () => {
showSizeChanger: true,
}}
onChange={handleTableChange}
/>
</div>
/>
<ManageApiKeyModal
open={isModalVisible}
+18 -23
View File
@@ -1,6 +1,6 @@
import {useEffect, useState} from 'react';
import {useSearchParams} from 'react-router-dom';
import {App, Button, Divider, Input, Space, Table, Tag} from 'antd';
import {App, Button, Divider, Input, Space, Tag} from 'antd';
import type {ColumnsType, TablePaginationConfig} from 'antd/es/table';
import {Edit, Plus, RefreshCw, Trash2} from 'lucide-react';
import dayjs from 'dayjs';
@@ -9,6 +9,7 @@ import {deleteMonitor, listMonitors} from '@/api/monitor.ts';
import type {MonitorTask} from '@/types';
import {getErrorMessage} from '@/lib/utils';
import {PageHeader} from '@/components/PageHeader';
import {AdminDataTable} from '@/components/AdminDataTable';
import MonitorModal from './MonitorModal';
const MonitorList = () => {
@@ -232,7 +233,20 @@ const MonitorList = () => {
<div className="space-y-6">
<PageHeader
title="服务监控"
description="配置 HTTP/TCP/ICMP 服务可用性检测,集中管理监控策略与探针覆盖范围"
extra={(
<Input.Search
placeholder="按名称或目标搜索"
allowClear
value={searchValue}
onChange={(event) => {
const nextValue = event.target.value;
setSearchValue(nextValue);
if (!nextValue) handleKeywordSearch('');
}}
onSearch={(value) => handleKeywordSearch(value)}
style={{width: 260}}
/>
)}
actions={[
{
key: 'create',
@@ -250,25 +264,7 @@ const MonitorList = () => {
]}
/>
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<Input.Search
placeholder="按名称或目标搜索"
allowClear
value={searchValue}
onChange={(event) => {
const nextValue = event.target.value;
setSearchValue(nextValue);
if (!nextValue) {
handleKeywordSearch('');
}
}}
onSearch={(value) => handleKeywordSearch(value)}
className="w-full max-w-md"
/>
</div>
<Table<MonitorTask>
<AdminDataTable<MonitorTask>
columns={columns}
dataSource={dataSource}
loading={isLoading || isFetching}
@@ -283,8 +279,7 @@ const MonitorList = () => {
showTotal: (count) => `共 ${count} 条`,
}}
onChange={handleTableChange}
/>
</div>
/>
<MonitorModal
open={modalVisible}
+3 -3
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { App, Button, Card, Form, InputNumber, Space, Switch } from 'antd';
import { App, Button, Card, Form, InputNumber, Switch } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { AlertConfig } from '@/api/property';
import { getAlertConfig, saveAlertConfig } from '@/api/property';
@@ -43,7 +43,7 @@ const AlertSettings = () => {
return (
<div>
<Form form={form}>
<Space direction="vertical" className="w-full">
<div className="flex w-full min-w-0 flex-col gap-4">
<Card title="基本信息" type="inner">
<Form.Item label="启用告警" name="enabled" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
@@ -237,7 +237,7 @@ const AlertSettings = () => {
>
保存配置
</Button>
</Space>
</div>
</Form>
</div>
);
@@ -249,13 +249,11 @@ const NotificationChannels = () => {
return (
<div>
<div className="mb-4">
<h2 className="text-xl font-bold">通知渠道管理</h2>
<p className="text-gray-500 my-2">配置钉钉、企业微信、飞书和自定义Webhook通知渠道</p>
<Alert title="更改配置后,请点击下方的保存后再进行测试。" type="info" showIcon/>
</div>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Space orientation={'vertical'} className={'w-full'}>
<div className="flex w-full min-w-0 flex-col gap-4">
{/* 钉钉通知 */}
<Card
title={
@@ -269,7 +267,6 @@ const NotificationChannels = () => {
</div>
}
type="inner"
className="mb-4"
extra={
<Button
type="link"
@@ -333,7 +330,6 @@ const NotificationChannels = () => {
</div>
}
type="inner"
className="mb-4"
extra={
<Button
type="link"
@@ -386,7 +382,6 @@ const NotificationChannels = () => {
</div>
}
type="inner"
className="mb-4"
extra={
<Button
type="link"
@@ -475,7 +470,6 @@ const NotificationChannels = () => {
</div>
}
type="inner"
className="mb-4"
extra={
<Button
type="link"
@@ -539,7 +533,6 @@ const NotificationChannels = () => {
</div>
}
type="inner"
className="mb-4"
extra={
<Button
type="link"
@@ -602,7 +595,6 @@ const NotificationChannels = () => {
</div>
}
type="inner"
className="mb-4"
extra={
<Button
type="link"
@@ -692,7 +684,6 @@ const NotificationChannels = () => {
<Card
title="自定义 Webhook"
type="inner"
className="mb-4"
extra={
<Button
type="link"
@@ -845,7 +836,7 @@ const NotificationChannels = () => {
保存配置
</Button>
</Form.Item>
</Space>
</div>
</Form>
</div>
);
+5 -10
View File
@@ -135,14 +135,9 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
return (
<div>
<div className="mb-4">
<h2 className="text-xl font-bold">公网 IP 采集</h2>
<p className="text-gray-500 mt-2">通过 HTTP 接口采集探针公网 IPv4/IPv6 地址</p>
</div>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Space direction={'vertical'} className={'w-full'}>
<Card title="采集设置" type="inner" className="mb-4">
<div className="flex w-full min-w-0 flex-col gap-4">
<Card title="采集设置" type="inner">
<div className="flex flex-wrap items-center gap-6">
<Form.Item label="启用采集" name="enabled" valuePropName="checked">
<Switch/>
@@ -157,7 +152,7 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
</div>
</Card>
<Card title="IPv4 配置" type="inner" className="mb-4">
<Card title="IPv4 配置" type="inner">
<Form.Item label="启用 IPv4" name="ipv4Enabled" valuePropName="checked">
<Switch/>
</Form.Item>
@@ -200,7 +195,7 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
</Form.Item>
</Card>
<Card title="IPv6 配置" type="inner" className="mb-4">
<Card title="IPv6 配置" type="inner">
<Form.Item label="启用 IPv6" name="ipv6Enabled" valuePropName="checked">
<Switch/>
</Form.Item>
@@ -256,7 +251,7 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
</Button>
</Space>
</Form.Item>
</Space>
</div>
</Form>
</div>
);
+3 -11
View File
@@ -141,17 +141,11 @@ const SystemConfigComponent = () => {
return (
<div>
<div className="mb-4">
<h2 className="text-xl font-bold">系统配置</h2>
<p className="text-gray-500 mt-2">配置系统名称和 Logo,这些设置将在公共页面和管理后台显示</p>
</div>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Space direction={'vertical'} className={'w-full'}>
<div className="flex w-full min-w-0 flex-col gap-4">
<Card
title="系统基本信息"
type="inner"
className="mb-4"
>
<div className={'flex items-center gap-2'}>
<Form.Item
@@ -229,7 +223,7 @@ const SystemConfigComponent = () => {
label="系统 Logo"
tooltip="上传系统 Logo,建议使用正方形图片,尺寸为 256x256 或更大,文件大小不超过 500KB"
>
<Space direction="vertical" className="w-full">
<Space orientation="vertical" className="w-full">
<Upload
accept="image/*"
showUploadList={false}
@@ -247,7 +241,6 @@ const SystemConfigComponent = () => {
<Card
title="自定义代码"
type="inner"
className="mb-4"
>
<Form.Item
label="自定义 CSS"
@@ -275,7 +268,6 @@ const SystemConfigComponent = () => {
<Card
title="预览效果"
type="inner"
className="mb-4"
>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) => {
@@ -334,7 +326,7 @@ const SystemConfigComponent = () => {
</Button>
</Space>
</Form.Item>
</Space>
</div>
</Form>
</div>
);
+8 -16
View File
@@ -5,7 +5,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {activateTheme, deleteTheme, listThemes, type ThemeInfo, uploadTheme} from '@/api/theme';
import {getErrorMessage} from '@/lib/utils';
const {Text, Paragraph, Title} = Typography;
const {Text, Paragraph} = Typography;
const ThemePreview = ({theme}: {theme: ThemeInfo}) => {
const [source, setSource] = useState<string>();
@@ -122,14 +122,8 @@ const ThemeSettings = () => {
];
return (
<div className="min-w-0">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<Title level={3} style={{margin: 0}}>主题管理</Title>
<Paragraph type="secondary" style={{margin: '8px 0 0'}}>
安装和切换完整的公开页面主题;管理后台始终使用官方界面。
</Paragraph>
</div>
<div className="flex w-full min-w-0 flex-col gap-4">
<div className="flex justify-end">
<div className="shrink-0">
<Upload
accept=".zip,application/zip"
@@ -145,13 +139,11 @@ const ThemeSettings = () => {
</div>
</div>
<div className="mb-6">
<Alert
type="warning"
showIcon
title="只安装你信任的主题:第三方主题会在 Pika 同一域名下执行 JavaScript,安装前请确认来源可信。"
/>
</div>
<Alert
type="warning"
showIcon
title="只安装你信任的主题:第三方主题会在 Pika 同一域名下执行 JavaScript,安装前请确认来源可信。"
/>
{themesQuery.isLoading ? (
<div className="flex min-h-72 items-center justify-center">
+20 -15
View File
@@ -1,11 +1,9 @@
import {Tabs} from 'antd';
import {Bell, MessageSquare, Palette, Settings2, Wifi} from 'lucide-react';
import AlertSettings from './AlertSettings';
import NotificationChannels from './NotificationChannels';
import SystemConfig from './SystemConfig';
import PublicIPConfig from './PublicIPConfig';
import ThemeSettings from './ThemeSettings';
import {PageHeader} from "@/components/PageHeader";
import {useSearchParams} from "react-router-dom";
// 默认 IPv4 API 列表
@@ -28,7 +26,6 @@ export const defaultIPv6APIs = [
const Settings = () => {
const [searchParams, setSearchParams] = useSearchParams({tab: 'system'});
const items = [
{
key: 'system',
@@ -87,19 +84,27 @@ const Settings = () => {
},
];
const activeKey = searchParams.get('tab') || 'system';
const activeItem = items.find((item) => item.key === activeKey) || items[0];
return (
<div className={'space-y-6'}>
<PageHeader
title="系统设置"
description="CONFIGURATION"
/>
<Tabs tabPlacement={'start'}
items={items}
activeKey={searchParams.get('tab')}
onChange={(key) => {
setSearchParams({tab: key});
}}
/>
<div className="grid grid-cols-[208px_minmax(0,1fr)] items-start gap-4 max-md:block">
<nav className="flex flex-col gap-1 rounded-[10px] border border-[#e8ebf0] bg-white p-2 dark:border-[#272b33] dark:bg-[#171a21] max-md:mb-3.5 max-md:flex-row max-md:overflow-x-auto max-md:rounded-none max-md:border-0 max-md:bg-transparent max-md:p-0 max-md:pb-1.5 dark:max-md:bg-transparent" aria-label="设置分类">
{items.map((item) => (
<button
key={item.key}
type="button"
className={item.key === activeKey
? 'flex min-h-10 w-full cursor-pointer items-center rounded-[7px] border-0 bg-[#eaf2ff] px-2.5 py-2 text-left text-[13px] font-semibold text-[#145dcc] dark:bg-[#1677ff]/15 dark:text-[#75adff] max-md:w-auto max-md:shrink-0 max-md:whitespace-nowrap'
: 'flex min-h-10 w-full cursor-pointer items-center rounded-[7px] border-0 bg-transparent px-2.5 py-2 text-left text-[13px] text-[#646a73] hover:bg-[#f5f6f8] hover:text-[#1f2329] dark:text-[#9ba1ab] dark:hover:bg-[#20242c] dark:hover:text-[#e6e8ec] max-md:w-auto max-md:shrink-0 max-md:whitespace-nowrap'}
onClick={() => setSearchParams({tab: item.key})}
aria-current={item.key === activeKey ? 'page' : undefined}
>
{item.label}
</button>
))}
</nav>
<div className="min-w-0">{activeItem.children}</div>
</div>
);
};
+6 -1
View File
@@ -18,7 +18,12 @@ const DDNSPage = lazy(() => import('@/pages/DDNS'));
const AlertRecordListPage = lazy(() => import('@/pages/AlertRecords'));
const lazyLoad = (Component: LazyExoticComponent<ComponentType<any>>) => (
<Suspense fallback={<div className="flex h-[75vh] items-center justify-center text-gray-500">页面加载中...</div>}>
<Suspense fallback={
<div className="flex min-h-[60vh] items-center justify-center gap-2.5 text-[13px] text-slate-500" role="status" aria-live="polite">
<span className="size-[18px] animate-spin rounded-full border-2 border-[#dbe4ee] border-t-[#1677ff]"/>
<span>页面加载中</span>
</div>
}>
<Component/>
</Suspense>
);