refactor: 标准化项目结构 frontend/ + backend/ + SQLite + run.sh
- 目录重构: app/ -> frontend/,与 backend/ 并列 - 数据库: 默认 SQLite(零配置),修复 DSN() bug - run.sh: 一键启动前后端,支持 start/stop/restart/status - Makefile: install/dev/build/lint/clean 快捷命令 - vite.config.ts: 配置 dev server 端口 5173 + API 代理到 8080 - config.yaml: SQLite 默认配置,开箱即用 - docs/: 完整开发文档(项目结构/API/数据库/规范/部署) - 修复: GORM foreign key 关联声明 - 修复: go get gorm.io/driver/sqlite 依赖
This commit is contained in:
1 parent
7d6674bddd
commit
97c4207ff8
132 files changed
+1572
-34
No files matched your search
@@ -0,0 +1,467 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Terminal,
|
||||
Power,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
Edit,
|
||||
Eye,
|
||||
Download,
|
||||
Upload,
|
||||
Filter,
|
||||
Server,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Activity,
|
||||
CheckSquare,
|
||||
Square,
|
||||
Cloud,
|
||||
FileSpreadsheet,
|
||||
} from 'lucide-react';
|
||||
import { servers } from '@/mocks/data';
|
||||
import type { Server as ServerType } from '@/types';
|
||||
import { CloudServerImport } from './CloudServerImport';
|
||||
import { ExcelServerImport } from './ExcelServerImport';
|
||||
|
||||
export function ServerManager() {
|
||||
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [regionFilter, setRegionFilter] = useState<string>('all');
|
||||
const [cloudImportOpen, setCloudImportOpen] = useState(false);
|
||||
const [excelImportOpen, setExcelImportOpen] = useState(false);
|
||||
|
||||
const filteredServers = servers.filter((server) => {
|
||||
const matchesSearch =
|
||||
server.hostname.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
server.ip.includes(searchQuery);
|
||||
const matchesStatus = statusFilter === 'all' || server.status === statusFilter;
|
||||
const matchesRegion = regionFilter === 'all' || server.region === regionFilter;
|
||||
return matchesSearch && matchesStatus && matchesRegion;
|
||||
});
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedServers.length === filteredServers.length) {
|
||||
setSelectedServers([]);
|
||||
} else {
|
||||
setSelectedServers(filteredServers.map((s) => s.id));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (selectedServers.includes(id)) {
|
||||
setSelectedServers(selectedServers.filter((s) => s !== id));
|
||||
} else {
|
||||
setSelectedServers([...selectedServers, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ServerType['status']) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
|
||||
运行中
|
||||
</Badge>
|
||||
);
|
||||
case 'stopped':
|
||||
return (
|
||||
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
|
||||
已停止
|
||||
</Badge>
|
||||
);
|
||||
case 'maintenance':
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 mr-1.5" />
|
||||
维护中
|
||||
</Badge>
|
||||
);
|
||||
case 'offline':
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
离线
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const regions = Array.from(new Set(servers.map((s) => s.region)));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">服务器管理</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">管理云平台服务器资产</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
批量导入
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-slate-800 border-slate-700 text-slate-100">
|
||||
<DropdownMenuLabel>选择导入方式</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem
|
||||
className="hover:bg-slate-700 cursor-pointer"
|
||||
onClick={() => setCloudImportOpen(true)}
|
||||
>
|
||||
<Cloud className="h-4 w-4 mr-2 text-blue-400" />
|
||||
云主机导入
|
||||
<span className="ml-2 text-xs text-slate-500">从云平台同步</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:bg-slate-700 cursor-pointer"
|
||||
onClick={() => setExcelImportOpen(true)}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-2 text-emerald-400" />
|
||||
Excel导入
|
||||
<span className="ml-2 text-xs text-slate-500">批量导入服务器</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加服务器
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">服务器总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{servers.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">运行中</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{servers.filter((s) => s.status === 'running').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<Activity className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">维护中</p>
|
||||
<p className="text-2xl font-bold text-amber-400">
|
||||
{servers.filter((s) => s.status === 'maintenance').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<RotateCw className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">已停止</p>
|
||||
<p className="text-2xl font-bold text-slate-400">
|
||||
{servers.filter((s) => s.status === 'stopped').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-500/10 text-slate-400">
|
||||
<Power className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters & Table */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索主机名或IP..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="running">运行中</SelectItem>
|
||||
<SelectItem value="stopped">已停止</SelectItem>
|
||||
<SelectItem value="maintenance">维护中</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={regionFilter} onValueChange={setRegionFilter}>
|
||||
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="区域" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all">全部区域</SelectItem>
|
||||
{regions.map((region) => (
|
||||
<SelectItem key={region} value={region}>
|
||||
{region}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedServers.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-400">已选择 {selectedServers.length} 项</span>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
批量命令
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300">
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
重启
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedServers.length === filteredServers.length && filteredServers.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-slate-600"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-400">主机名</TableHead>
|
||||
<TableHead className="text-slate-400">IP地址</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">配置</TableHead>
|
||||
<TableHead className="text-slate-400">使用率</TableHead>
|
||||
<TableHead className="text-slate-400">区域</TableHead>
|
||||
<TableHead className="text-slate-400">标签</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredServers.map((server) => (
|
||||
<TableRow key={server.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedServers.includes(server.id)}
|
||||
onCheckedChange={() => toggleSelect(server.id)}
|
||||
className="border-slate-600"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-slate-500" />
|
||||
<div>
|
||||
<p className="font-medium text-slate-200">{server.hostname}</p>
|
||||
<p className="text-xs text-slate-500">{server.os}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
|
||||
{server.ip}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(server.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-slate-300">
|
||||
<span className="text-slate-500">{server.cpu}核</span>
|
||||
<span className="mx-1 text-slate-600">/</span>
|
||||
<span className="text-slate-500">{server.memory}GB</span>
|
||||
<span className="mx-1 text-slate-600">/</span>
|
||||
<span className="text-slate-500">{server.disk}GB</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-8">CPU</span>
|
||||
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
server.cpuUsage > 80 ? 'bg-red-400' : server.cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${server.cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{server.cpuUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-8">内存</span>
|
||||
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
server.memoryUsage > 80 ? 'bg-red-400' : server.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${server.memoryUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{server.memoryUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{server.region}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{server.tags.slice(0, 3).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs border-slate-700 text-slate-400 bg-slate-800/50"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{server.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
|
||||
+{server.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
|
||||
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
WebShell
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
重启
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Import Dialogs */}
|
||||
<CloudServerImport
|
||||
open={cloudImportOpen}
|
||||
onClose={() => setCloudImportOpen(false)}
|
||||
/>
|
||||
<ExcelServerImport
|
||||
open={excelImportOpen}
|
||||
onClose={() => setExcelImportOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user