增加团队管理页面

This commit is contained in:
dengqichen 2025-10-30 18:20:39 +08:00
parent 97e6b30e65
commit 68bc9757e4
2 changed files with 479 additions and 205 deletions

View File

@ -0,0 +1,195 @@
import React from 'react';
import {
TestTube,
Pencil,
Trash2,
Loader2,
Activity,
AlertCircle,
HelpCircle,
} from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { ServerResponse } from '../types';
import { ServerStatusLabels, OsTypeLabels } from '../types';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
interface ServerTableProps {
servers: ServerResponse[];
loading: boolean;
onTest: (server: ServerResponse) => void;
onEdit: (server: ServerResponse) => void;
onDelete: (server: ServerResponse) => void;
isTesting?: (serverId: number) => boolean;
getOsIcon: (osType?: string) => React.ReactNode;
}
export const ServerTable: React.FC<ServerTableProps> = ({
servers,
loading,
onTest,
onEdit,
onDelete,
isTesting,
getOsIcon,
}) => {
const formatTime = (time?: string) => {
if (!time) return '-';
return dayjs(time).fromNow();
};
const getStatusIcon = (status?: string) => {
switch (status) {
case 'ONLINE':
return <Activity className="h-4 w-4 text-green-600 dark:text-green-400" />;
case 'OFFLINE':
return <AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400" />;
default:
return <HelpCircle className="h-4 w-4 text-gray-600 dark:text-gray-400" />;
}
};
return (
<Table minWidth="1200px">
<TableHeader>
<TableRow>
<TableHead width="180px"></TableHead>
<TableHead width="150px">IP地址</TableHead>
<TableHead width="120px"></TableHead>
<TableHead width="100px"></TableHead>
<TableHead width="80px">CPU核心</TableHead>
<TableHead width="100px">(GB)</TableHead>
<TableHead width="100px">(GB)</TableHead>
<TableHead width="120px">SSH认证</TableHead>
<TableHead width="140px"></TableHead>
<TableHead width="150px"></TableHead>
<TableHead sticky width="140px" className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{servers.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="text-center py-8">
<span className="text-muted-foreground"></span>
</TableCell>
</TableRow>
) : (
servers.map((server) => (
<TableRow key={server.id}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{server.serverName}</span>
</div>
</TableCell>
<TableCell>
<span className="font-mono text-sm">{server.hostIp}</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getStatusIcon(server.status)}
<Badge className={
server.status === 'ONLINE' ? 'bg-green-500/10 text-green-700 border-green-500/30 dark:text-green-400' :
server.status === 'OFFLINE' ? 'bg-red-500/10 text-red-700 border-red-500/30 dark:text-red-400' :
'bg-gray-500/10 text-gray-700 border-gray-500/30 dark:text-gray-400'
}>
{ServerStatusLabels[server.status]?.label || server.status}
</Badge>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getOsIcon(server.osType)}
<span className="text-sm">{OsTypeLabels[server.osType]?.label || server.osType}</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm">{server.cpuCores || '-'}</span>
</TableCell>
<TableCell>
<span className="text-sm">{server.memorySize || '-'}</span>
</TableCell>
<TableCell>
<span className="text-sm">{server.diskSize || '-'}</span>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{server.authType === 'PASSWORD' ? '密码' : '密钥'}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{server.categoryName || '未分类'}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">{formatTime(server.lastConnectTime)}</span>
</TableCell>
<TableCell sticky className="text-right">
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => onTest(server)}
disabled={isTesting?.(server.id)}
className="h-8 w-8 p-0"
>
{isTesting?.(server.id) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(server)}
className="h-8 w-8 p-0"
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(server)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
);
};

View File

@ -11,6 +11,8 @@ import {
Filter, Filter,
Search, Search,
RotateCcw, RotateCcw,
Grid3x3,
List,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@ -39,16 +41,25 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { DataTablePagination } from '@/components/ui/pagination'; import { DataTablePagination } from '@/components/ui/pagination';
import { Input } from '@/components/ui/input';
import type { ServerResponse, ServerCategoryResponse, ServerStatus, OsType } from './types'; import type { ServerResponse, ServerCategoryResponse, ServerStatus, OsType } from './types';
import { ServerStatusLabels, OsTypeLabels } from './types'; import { ServerStatusLabels, OsTypeLabels } from './types';
import { getServers, getServerCategories, deleteServer, testServerConnection } from './service'; import { getServers, getServerCategories, deleteServer, testServerConnection } from './service';
import { CategoryManageDialog } from './components/CategoryManageDialog'; import { CategoryManageDialog } from './components/CategoryManageDialog';
import { ServerEditDialog } from './components/ServerEditDialog'; import { ServerEditDialog } from './components/ServerEditDialog';
import { ServerCard } from './components/ServerCard'; import { ServerCard } from './components/ServerCard';
import { ServerTable } from './components/ServerTable';
const ServerList: React.FC = () => { const ServerList: React.FC = () => {
const { toast } = useToast(); const { toast } = useToast();
// 视图模式
type ViewMode = 'grid' | 'table';
const [viewMode, setViewMode] = useState<ViewMode>('grid');
// 搜索关键字
const [searchKeyword, setSearchKeyword] = useState<string>('');
// 状态管理 // 状态管理
const [categories, setCategories] = useState<ServerCategoryResponse[]>([]); const [categories, setCategories] = useState<ServerCategoryResponse[]>([]);
const [servers, setServers] = useState<ServerResponse[]>([]); const [servers, setServers] = useState<ServerResponse[]>([]);
@ -253,9 +264,21 @@ const ServerList: React.FC = () => {
setSelectedCategoryId(undefined); setSelectedCategoryId(undefined);
setSelectedStatus(undefined); setSelectedStatus(undefined);
setSelectedOsType(undefined); setSelectedOsType(undefined);
setSearchKeyword('');
setPageIndex(0); // 重置到第一页 setPageIndex(0); // 重置到第一页
}; };
// 客户端搜索过滤(在加载数据后进行)
const filteredServers = servers.filter(server => {
if (!searchKeyword.trim()) return true;
const keyword = searchKeyword.toLowerCase();
return (
server.serverName.toLowerCase().includes(keyword) ||
server.hostIp.includes(keyword) ||
(server.hostname && server.hostname.toLowerCase().includes(keyword))
);
});
// 监听筛选条件和分页变化 // 监听筛选条件和分页变化
useEffect(() => { useEffect(() => {
loadServers(); loadServers();
@ -266,64 +289,14 @@ const ServerList: React.FC = () => {
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="h-screen flex flex-col overflow-hidden"> <div className="h-screen flex flex-col overflow-hidden bg-background">
<div className="flex-shrink-0 space-y-6 p-6"> {/* 顶部区域 - 统计和标题 */}
{/* 统计卡片 */} <div className="flex-shrink-0 space-y-4 p-6">
<div className="grid gap-4 md:grid-cols-4"> {/* 标题栏 */}
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 border-blue-200 dark:border-blue-900">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Server className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200 dark:border-green-900">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">线</CardTitle>
<Activity className="h-4 w-4 text-green-600 dark:text-green-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.online}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-red-50 to-rose-50 dark:from-red-950/20 dark:to-rose-950/20 border-red-200 dark:border-red-900">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">线</CardTitle>
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.offline}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-gray-50 to-slate-50 dark:from-gray-950/20 dark:to-slate-950/20 border-gray-200 dark:border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<HelpCircle className="h-4 w-4 text-gray-600 dark:text-gray-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600 dark:text-gray-400">{stats.unknown}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
</div>
</div>
{/* 服务器管理卡片 - 可滚动区域 */}
<div className="flex-1 overflow-hidden px-6 pb-6">
<Card className="h-full flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle className="text-2xl"></CardTitle> <h1 className="text-3xl font-bold"></h1>
<CardDescription className="mt-2"> <p className="text-muted-foreground mt-1">SSH连接和分类管理</p>
SSH连接和分类管理
</CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setCategoryDialogOpen(true)}> <Button variant="outline" onClick={() => setCategoryDialogOpen(true)}>
@ -342,12 +315,94 @@ const ServerList: React.FC = () => {
</Button> </Button>
</div> </div>
</div> </div>
</CardHeader>
<Separator className="flex-shrink-0" /> {/* 统计卡片 - 更紧凑的布局 */}
<ScrollArea className="flex-1"> <div className="grid gap-3 md:grid-cols-4">
<CardContent className="pt-6"> <Card className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 border-blue-200 dark:border-blue-900">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium"></p>
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">{stats.total}</p>
</div>
<Server className="h-8 w-8 text-blue-600 dark:text-blue-400 opacity-50" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200 dark:border-green-900">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium">线</p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400 mt-1">{stats.online}</p>
</div>
<Activity className="h-8 w-8 text-green-600 dark:text-green-400 opacity-50" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-red-50 to-rose-50 dark:from-red-950/20 dark:to-rose-950/20 border-red-200 dark:border-red-900">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium">线</p>
<p className="text-2xl font-bold text-red-600 dark:text-red-400 mt-1">{stats.offline}</p>
</div>
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400 opacity-50" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-gray-50 to-slate-50 dark:from-gray-950/20 dark:to-slate-950/20 border-gray-200 dark:border-gray-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-medium"></p>
<p className="text-2xl font-bold text-gray-600 dark:text-gray-400 mt-1">{stats.unknown}</p>
</div>
<HelpCircle className="h-8 w-8 text-gray-600 dark:text-gray-400 opacity-50" />
</div>
</CardContent>
</Card>
</div>
</div>
{/* 内容区域 - 可滚动 */}
<div className="flex-1 overflow-hidden px-6 pb-6">
<Card className="h-full flex flex-col">
{/* 搜索和视图切换栏 */}
<CardHeader className="flex-shrink-0">
<div className="space-y-4">
{/* 搜索框 */}
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
placeholder="搜索服务器名称、IP或主机名..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="h-10"
/>
</div>
<div className="flex items-center gap-1 bg-muted/50 rounded-lg p-1">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('grid')}
className="h-8 px-3"
>
<Grid3x3 className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'table' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('table')}
className="h-8 px-3"
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
{/* 筛选栏 */} {/* 筛选栏 */}
<div className="flex items-center gap-3 mb-6 p-4 bg-muted/30 rounded-lg"> <div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" /> <Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"></span> <span className="text-sm font-medium"></span>
@ -428,8 +483,14 @@ const ServerList: React.FC = () => {
</Button> </Button>
</div> </div>
</div> </div>
</div>
</CardHeader>
{/* 服务器卡片网格 */} <Separator className="flex-shrink-0" />
{/* 内容区域 */}
<ScrollArea className="flex-1">
<CardContent className="pt-6">
<div className="space-y-4"> <div className="space-y-4">
{/* 加载状态 - 骨架屏 */} {/* 加载状态 - 骨架屏 */}
{loading && servers.length === 0 && ( {loading && servers.length === 0 && (
@ -459,18 +520,20 @@ const ServerList: React.FC = () => {
)} )}
{/* 空状态 */} {/* 空状态 */}
{!loading && servers.length === 0 && ( {!loading && filteredServers.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<Server className="h-16 w-16 mb-4 opacity-20" /> <Server className="h-16 w-16 mb-4 opacity-20" />
<p className="text-lg font-medium"></p> <p className="text-lg font-medium"></p>
<p className="text-sm mt-2">"新增服务器"</p> <p className="text-sm mt-2">
{servers.length === 0 ? '点击上方"新增服务器"按钮添加服务器' : '没有匹配搜索条件的服务器'}
</p>
</div> </div>
)} )}
{/* 服务器列表 */} {/* 网格视图 */}
{!loading && servers.length > 0 && ( {!loading && filteredServers.length > 0 && viewMode === 'grid' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{servers.map((server) => ( {filteredServers.map((server) => (
<ServerCard <ServerCard
key={server.id} key={server.id}
server={server} server={server}
@ -484,11 +547,27 @@ const ServerList: React.FC = () => {
</div> </div>
)} )}
{/* 表格视图 */}
{!loading && filteredServers.length > 0 && viewMode === 'table' && (
<ServerTable
servers={filteredServers}
loading={loading}
onTest={handleTestConnection}
onEdit={handleEdit}
onDelete={handleDelete}
isTesting={(serverId) => testingServerId === serverId}
getOsIcon={getOsIcon}
/>
)}
{/* 分页 */} {/* 分页 */}
{!loading && servers.length > 0 && ( {!loading && servers.length > 0 && (
<div className="mt-6 flex items-center justify-between"> <div className="mt-6 flex items-center justify-between">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{totalElements} {pageIndex + 1} / {Math.ceil(totalElements / pageSize)} {totalElements} {pageIndex + 1} / {Math.ceil(totalElements / pageSize)}
{searchKeyword && filteredServers.length > 0 && (
<span> {filteredServers.length} </span>
)}
</div> </div>
<DataTablePagination <DataTablePagination
pageIndex={pageIndex} pageIndex={pageIndex}