优化重构管理页面
This commit is contained in:
1 parent
ef34036bc0
commit
435245d639
24 files changed
+579
-573
No files matched your search
@@ -15,25 +15,25 @@ 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) => (
|
||||
{(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
|
||||
key={action.key}
|
||||
type={action.type || 'default'}
|
||||
icon={action.icon}
|
||||
onClick={action.onClick}
|
||||
@@ -43,6 +43,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({title, description, actio
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+60
-123
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -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 className="h-4 w-4" strokeWidth={2}/>}
|
||||
icon={<Eye size={16} 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"
|
||||
style={{color: appliedTheme === 'dark' ? '#cbd5e1' : '#646a73', fontSize: 12}}
|
||||
>
|
||||
公共页面
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<Tooltip title={appliedTheme === 'dark' ? '切换到浅色模式' : '切换到暗黑模式'}>
|
||||
<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' ? '切换到浅色模式' : '切换到暗黑模式'}
|
||||
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 className="h-4 w-4" strokeWidth={2}/> : <Moon className="h-4 w-4" strokeWidth={2}/>}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
<>
|
||||
<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 isActive = location.pathname.startsWith(item.path);
|
||||
const active = location.pathname === item.path || location.pathname.startsWith(`${item.path}/`);
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => navigate(item.path)}
|
||||
onClick={() => goTo(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',
|
||||
'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(
|
||||
'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 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
<nav className="flex-1 overflow-y-auto px-3 pb-6 space-y-1 thin-scrollbar">
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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)`}}
|
||||
<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)`}}
|
||||
>
|
||||
<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">
|
||||
<div className={isWideCompactPage ? 'w-full' : 'mx-auto w-full max-w-[1320px]'}>
|
||||
<Outlet/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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;
|
||||
@@ -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"/>
|
||||
<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>
|
||||
<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>
|
||||
<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 内容 */}
|
||||
<PagePanel>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
/>
|
||||
</PagePanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,29 +158,44 @@ curl -L "${backendServerUrl}${config.downloadUrl}?key=${selectedApiKey}" -o ${AG
|
||||
setSelectedApiKeyId(apiKey.id);
|
||||
}}
|
||||
/>
|
||||
<Tabs
|
||||
activeKey={selectedOS}
|
||||
onChange={(key) => setSelectedOS(key as OSType)}
|
||||
<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]'}
|
||||
>
|
||||
{Object.entries(osConfigs).map(([key, config]) => (
|
||||
<TabPane
|
||||
tab={
|
||||
<div className="flex items-center gap-2">
|
||||
{config.icon}
|
||||
<span>{config.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
key={key}
|
||||
</Card>
|
||||
|
||||
<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)]"
|
||||
>
|
||||
<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">
|
||||
<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
|
||||
@@ -186,22 +203,21 @@ curl -L "${backendServerUrl}${config.downloadUrl}?key=${selectedApiKey}" -o ${AG
|
||||
onClick={() => void copyToClipboard(step.command)}
|
||||
icon={<CopyIcon className="h-4 w-4"/>}
|
||||
size="small"
|
||||
style={{ margin: 0, padding: 0 }}
|
||||
style={{margin: '8px 0 0', padding: 0}}
|
||||
disabled={!selectedApiKey}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ServiceHelper os={key} />
|
||||
<div className="grid items-start gap-4 xl:grid-cols-2">
|
||||
<ServiceHelper os={selectedOS}/>
|
||||
<ConfigHelper/>
|
||||
</Space>
|
||||
</TabPane>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Space>
|
||||
</AgentInstallLayout>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<Card title="运行安装命令">
|
||||
<div className="mb-4 space-y-2">
|
||||
<Alert description="支持 Linux 和 macOS,脚本会自动识别系统与架构。" type="info" showIcon/>
|
||||
{!effectiveServerUrl && (
|
||||
<Alert
|
||||
description="请先配置服务端地址后再生成安装命令。"
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mt-2"
|
||||
/>
|
||||
<Alert description="请先配置服务端地址后再生成安装命令。" type="warning" showIcon/>
|
||||
)}
|
||||
<Card type="inner" title="一键安装">
|
||||
</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>
|
||||
|
||||
<div className="grid items-start gap-4 xl:grid-cols-2">
|
||||
<ServiceHelper os={AGENT_NAME}/>
|
||||
<ConfigHelper/>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApiKeyModal
|
||||
open={isCreateModalVisible}
|
||||
|
||||
@@ -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>
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="探针部署指南"
|
||||
actions={[{
|
||||
key: 'back',
|
||||
label: '返回探针列表',
|
||||
icon: <ArrowLeft size={16}/>,
|
||||
onClick: () => navigate('/admin/agents'),
|
||||
}]}
|
||||
/>
|
||||
|
||||
<Space size={8}>
|
||||
<Button
|
||||
type={activeKey === 'one-click' ? 'primary' : 'default'}
|
||||
<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={activeKey === 'manual' ? 'primary' : 'default'}
|
||||
</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>
|
||||
</Space>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
|
||||
@@ -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,42 +520,13 @@ 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>
|
||||
<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([])}
|
||||
className="!rounded-full"
|
||||
style={{borderRadius: 6, margin: 0}}
|
||||
>
|
||||
全部
|
||||
</Tag.CheckableTag>
|
||||
@@ -550,16 +541,15 @@ const AgentList = () => {
|
||||
setSelectedTags(selectedTags.filter(t => t !== tag));
|
||||
}
|
||||
}}
|
||||
className="!rounded-full"
|
||||
style={{borderRadius: 6, margin: 0}}
|
||||
>
|
||||
{tag}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table<Agent>
|
||||
<AdminDataTable<Agent>
|
||||
columns={columns}
|
||||
dataSource={filteredAgents}
|
||||
loading={isLoading || isFetching}
|
||||
@@ -573,7 +563,6 @@ const AgentList = () => {
|
||||
}}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AgentEditModal
|
||||
open={editModalVisible}
|
||||
|
||||
@@ -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={{
|
||||
@@ -313,7 +306,6 @@ const AlertRecordList = () => {
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -299,7 +295,6 @@ const ApiKeyList = () => {
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ApiKeyModal
|
||||
open={isModalVisible}
|
||||
|
||||
@@ -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}}
|
||||
/>
|
||||
)}
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" icon={<Plus size={16}/>} onClick={handleCreate}>
|
||||
新建配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table<DDNSConfig>
|
||||
<AdminDataTable<DDNSConfig>
|
||||
columns={columns}
|
||||
dataSource={ddnsPaging?.items || []}
|
||||
loading={isLoading || isFetching}
|
||||
@@ -317,7 +324,6 @@ const DDNSPage = () => {
|
||||
}}
|
||||
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}
|
||||
@@ -265,7 +261,6 @@ const ManageApiKeyList = () => {
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ManageApiKeyModal
|
||||
open={isModalVisible}
|
||||
|
||||
@@ -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}
|
||||
@@ -284,7 +280,6 @@ const MonitorList = () => {
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MonitorModal
|
||||
open={modalVisible}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
{themesQuery.isLoading ? (
|
||||
<div className="flex min-h-72 items-center justify-center">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user