feat: 完善告警规则配置页面 — 增删改查弹窗 + 修复白屏bug + UpdateRule notify_channels序列化修复
- AlertManager: 新建/编辑告警规则弹窗(规则名称/描述/类型/数据源/表达式/级别/持续时间/通知渠道Switch)
- AlertManager: 修复白屏bug — getRules返回{list,total}未解包导致rules.map崩溃(Frontend Pitfall #1)
- AlertManager: 修复规则列表字段显示 — 原引用不存在的rule.metric/operator/threshold字段,改为rule.source/expression
- AlertManager: 新增规则删除按钮(带确认)
- alert.go: 修复UpdateRule notify_channels序列化bug — 原直接赋值[]string到text列导致500,改用toJSON()
- 同步提交前序未提交改动(WebShell/K8s/Server/Login等组件改进)
This commit is contained in:
1 parent
170818c141
commit
00d5510366
13 files changed
+2170
-496
No files matched your search
@@ -3,6 +3,8 @@ import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
@@ -21,6 +23,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
CheckSquare,
|
||||
Plus,
|
||||
@@ -35,6 +44,7 @@ import {
|
||||
Edit,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { approvalApi } from '@/services/api';
|
||||
|
||||
@@ -51,15 +61,44 @@ function parseSteps(steps: any) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 审批类型字典(参考 sql-platform 审批流程)
|
||||
const APPROVAL_TYPE_OPTIONS = [
|
||||
{ value: 'deploy', label: '发布审批', color: 'var(--dg-blue)' },
|
||||
{ value: 'server', label: '服务器操作', color: 'var(--dg-cyan)' },
|
||||
{ value: 'permission', label: '权限申请', color: 'var(--dg-purple)' },
|
||||
];
|
||||
|
||||
const getApprovalTypeLabel = (value: string): string =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.label || value || '通用审批';
|
||||
|
||||
const getApprovalTypeColor = (value: string): string =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
|
||||
|
||||
// 状态筛选标签
|
||||
const STATUS_TABS = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'pending', label: '待审批' },
|
||||
{ value: 'approved', label: '已通过' },
|
||||
{ value: 'rejected', label: '已驳回' },
|
||||
];
|
||||
|
||||
export function ApprovalFlowManager() {
|
||||
const [activeTab, setActiveTab] = useState('instances');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [templateTypeFilter, setTemplateTypeFilter] = useState<string>('all');
|
||||
const [processes, setProcesses] = useState<any[]>([]);
|
||||
const [templates, setTemplates] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedInstance, setSelectedInstance] = useState<any | null>(null);
|
||||
|
||||
// 审批操作 Dialog 状态
|
||||
const [actionDialogOpen, setActionDialogOpen] = useState(false);
|
||||
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
|
||||
const [actionTarget, setActionTarget] = useState<any | null>(null);
|
||||
const [actionComment, setActionComment] = useState('');
|
||||
const [actionSubmitting, setActionSubmitting] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -68,7 +107,7 @@ export function ApprovalFlowManager() {
|
||||
approvalApi.getTemplates(),
|
||||
]);
|
||||
setProcesses((processesData as any)?.list || (processesData as any) || []);
|
||||
setTemplates(templatesData || []);
|
||||
setTemplates((templatesData as any)?.list || (templatesData as any) || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load approval data:', error);
|
||||
} finally {
|
||||
@@ -88,11 +127,17 @@ export function ApprovalFlowManager() {
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
// 按类型筛选的模板
|
||||
const filteredTemplates = templates.filter((template) => {
|
||||
if (templateTypeFilter === 'all') return true;
|
||||
return template.type === templateTypeFilter || template.category === templateTypeFilter;
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: any) => {
|
||||
const configs: Record<string, any> = {
|
||||
pending: { label: '审批中', className: 'border-amber-500/30 text-amber-400 bg-amber-500/10', icon: Clock },
|
||||
pending: { label: '待审批', className: 'border-amber-500/30 text-amber-400 bg-amber-500/10', icon: Clock },
|
||||
approved: { label: '已通过', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10', icon: CheckCircle },
|
||||
rejected: { label: '已拒绝', className: 'border-red-500/30 text-red-400 bg-red-500/10', icon: XCircle },
|
||||
rejected: { label: '已驳回', className: 'border-red-500/30 text-red-400 bg-red-500/10', icon: XCircle },
|
||||
};
|
||||
const config = configs[status] || configs.pending;
|
||||
const Icon = config.icon;
|
||||
@@ -104,31 +149,50 @@ export function ApprovalFlowManager() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleApprove = async (id: any) => {
|
||||
// 打开审批操作 Dialog
|
||||
const openActionDialog = (instance: any, type: 'approve' | 'reject') => {
|
||||
setActionTarget(instance);
|
||||
setActionType(type);
|
||||
setActionComment('');
|
||||
setActionDialogOpen(true);
|
||||
};
|
||||
|
||||
// 执行审批操作
|
||||
const handleConfirmAction = async () => {
|
||||
if (!actionTarget) return;
|
||||
try {
|
||||
await approvalApi.approveProcess(id);
|
||||
setActionSubmitting(true);
|
||||
if (actionType === 'approve') {
|
||||
await approvalApi.approveProcess(actionTarget.id, actionComment);
|
||||
} else {
|
||||
await approvalApi.rejectProcess(actionTarget.id, actionComment);
|
||||
}
|
||||
setActionDialogOpen(false);
|
||||
setSelectedInstance(null);
|
||||
setActionTarget(null);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to approve process:', error);
|
||||
console.error('Failed to process approval action:', error);
|
||||
} finally {
|
||||
setActionSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: any) => {
|
||||
try {
|
||||
await approvalApi.rejectProcess(id);
|
||||
setSelectedInstance(null);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to reject process:', error);
|
||||
}
|
||||
// 计算审批进度
|
||||
const getProgressInfo = (instance: any) => {
|
||||
const steps = parseSteps(instance.steps);
|
||||
const total = steps.length || instance.total_steps || 1;
|
||||
const current = instance.current_step_index ?? instance.current_step ?? 1;
|
||||
const currentNum = typeof current === 'number' ? current : 1;
|
||||
const percent = total > 0 ? Math.min(100, Math.round((currentNum / total) * 100)) : 0;
|
||||
return { current: currentNum, total, percent };
|
||||
};
|
||||
|
||||
// 模拟审批流程图
|
||||
const renderFlowChart = (flow: any) => {
|
||||
const nodes = parseSteps(flow.steps);
|
||||
if (nodes.length === 0) {
|
||||
return <div className="text-slate-500 text-center py-4">暂无流程节点</div>;
|
||||
return <div className="text-[var(--dg-text-muted)] text-center py-4">暂无流程节点</div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto py-4">
|
||||
@@ -138,20 +202,20 @@ export function ApprovalFlowManager() {
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg border min-w-[100px] text-center',
|
||||
node.type === 'start' && 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400',
|
||||
node.type === 'approval' && 'bg-blue-500/10 border-blue-500/30 text-blue-400',
|
||||
node.type === 'approval' && 'bg-[var(--dg-blue)]/10 border-[var(--dg-blue)]/30 text-[var(--dg-blue)]',
|
||||
node.type === 'condition' && 'bg-amber-500/10 border-amber-500/30 text-amber-400',
|
||||
node.type === 'end' && 'bg-slate-500/10 border-slate-500/30 text-slate-400'
|
||||
node.type === 'end' && 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)]'
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">{node.name}</p>
|
||||
{node.approvers && (
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">
|
||||
{Array.isArray(node.approvers) ? node.approvers.join(', ') : node.approvers}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{index < nodes.length - 1 && (
|
||||
<ArrowRight className="h-4 w-4 text-slate-600 mx-2" />
|
||||
<ArrowRight className="h-4 w-4 text-[var(--dg-text-muted)] mx-2" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -159,20 +223,48 @@ export function ApprovalFlowManager() {
|
||||
);
|
||||
};
|
||||
|
||||
// 审批步骤可视化(进度条)
|
||||
const renderStepProgress = (instance: any) => {
|
||||
const { current, total, percent } = getProgressInfo(instance);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--dg-text-secondary)]">
|
||||
当前步骤 <span className="font-medium text-[var(--dg-text-primary)]">{current}</span> / {total}
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-muted)]">{percent}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
instance.status === 'rejected'
|
||||
? 'bg-red-400'
|
||||
: instance.status === 'approved'
|
||||
? 'bg-emerald-400'
|
||||
: 'bg-[var(--dg-blue)]'
|
||||
)}
|
||||
style={{ width: `${instance.status === 'rejected' ? 100 : percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">审批流程</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] 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">
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
流程设计器
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建流程
|
||||
</Button>
|
||||
@@ -181,11 +273,11 @@ export function ApprovalFlowManager() {
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">待审批</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">待审批</p>
|
||||
<p className="text-2xl font-bold text-amber-400">
|
||||
{processes.filter((i) => i.status === 'pending').length}
|
||||
</p>
|
||||
@@ -196,11 +288,11 @@ export function ApprovalFlowManager() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">已通过</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">已通过</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{processes.filter((i) => i.status === 'approved').length}
|
||||
</p>
|
||||
@@ -211,11 +303,11 @@ export function ApprovalFlowManager() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">已拒绝</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">已驳回</p>
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{processes.filter((i) => i.status === 'rejected').length}
|
||||
</p>
|
||||
@@ -226,14 +318,14 @@ export function ApprovalFlowManager() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<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">{templates.length}</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">流程模板</p>
|
||||
<p className="text-2xl font-bold text-[var(--dg-text-primary)]">{templates.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-blue)]/10 text-[var(--dg-blue)]">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,104 +335,139 @@ export function ApprovalFlowManager() {
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
<TabsTrigger value="instances" className="data-[state=active]:bg-slate-700">
|
||||
<TabsList className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<TabsTrigger value="instances" className="data-[state=active]:bg-[var(--dg-bg-hover)] data-[state=active]:text-[var(--dg-text-primary)] text-[var(--dg-text-secondary)]">
|
||||
审批实例
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="flows" className="data-[state=active]:bg-slate-700">
|
||||
<TabsTrigger value="flows" className="data-[state=active]:bg-[var(--dg-bg-hover)] data-[state=active]:text-[var(--dg-text-primary)] text-[var(--dg-text-secondary)]">
|
||||
流程模板
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="my-approvals" className="data-[state=active]:bg-slate-700">
|
||||
<TabsTrigger value="my-approvals" className="data-[state=active]:bg-[var(--dg-bg-hover)] data-[state=active]:text-[var(--dg-text-primary)] text-[var(--dg-text-secondary)]">
|
||||
我的审批
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 审批实例 Tab */}
|
||||
<TabsContent value="instances" className="mt-4 space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* 状态筛选标签 + 搜索 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
{/* 状态筛选标签 */}
|
||||
<div className="flex items-center gap-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg p-1">
|
||||
{STATUS_TABS.map((tab) => {
|
||||
const count =
|
||||
tab.value === 'all'
|
||||
? processes.length
|
||||
: processes.filter((i) => i.status === tab.value).length;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setStatusFilter(tab.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
statusFilter === tab.value
|
||||
? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)] font-medium'
|
||||
: 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded-full',
|
||||
statusFilter === tab.value
|
||||
? 'bg-[var(--dg-blue)]/20 text-[var(--dg-blue)]'
|
||||
: 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]'
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Input
|
||||
placeholder="搜索流程名称或申请人..."
|
||||
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"
|
||||
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">审批中</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已拒绝</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Instances Table */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<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">完成时间</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">流程名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">申请人</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">审批进度</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">当前节点</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">创建时间</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={7} className="text-center text-[var(--dg-text-muted)] py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={7} className="text-center text-slate-500 py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredInstances.map((instance) => (
|
||||
<TableRow key={instance.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
) : filteredInstances.length === 0 ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={7} className="text-center text-[var(--dg-text-muted)] py-8">
|
||||
暂无审批实例
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredInstances.map((instance) => (
|
||||
<TableRow key={instance.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-200">{instance.title}</span>
|
||||
<GitBranch className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{instance.title}</span>
|
||||
{instance.type && (
|
||||
<p className="text-xs mt-0.5" style={{ color: getApprovalTypeColor(instance.type) }}>
|
||||
{getApprovalTypeLabel(instance.type)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-slate-300">
|
||||
<User className="h-3.5 w-3.5 text-slate-500" />
|
||||
<div className="flex items-center gap-1 text-sm text-[var(--dg-text-secondary)]">
|
||||
<User className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
|
||||
{instance.applicant_name || instance.applicant}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(instance.status)}</TableCell>
|
||||
{/* 审批进度可视化 */}
|
||||
<TableCell className="min-w-[160px]">
|
||||
{renderStepProgress(instance)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
{instance.current_step || '--'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">
|
||||
{instance.created_at ? new Date(instance.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{instance.completed_at
|
||||
? new Date(instance.completed_at).toLocaleString()
|
||||
: '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-slate-400 hover:text-slate-100"
|
||||
className="h-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => setSelectedInstance(instance)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
@@ -352,7 +479,7 @@ export function ApprovalFlowManager() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-emerald-400 hover:text-emerald-400"
|
||||
onClick={() => handleApprove(instance.id)}
|
||||
onClick={() => openActionDialog(instance, 'approve')}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
通过
|
||||
@@ -361,117 +488,191 @@ export function ApprovalFlowManager() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-400 hover:text-red-400"
|
||||
onClick={() => handleReject(instance.id)}
|
||||
onClick={() => openActionDialog(instance, 'reject')}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
拒绝
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="flows" className="mt-4">
|
||||
{/* 流程模板 Tab */}
|
||||
<TabsContent value="flows" className="mt-4 space-y-4">
|
||||
{/* 类型筛选 */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 text-sm text-[var(--dg-text-secondary)]">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>类型筛选:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setTemplateTypeFilter('all')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
templateTypeFilter === 'all'
|
||||
? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)] font-medium'
|
||||
: 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
)}
|
||||
>
|
||||
全部类型
|
||||
</button>
|
||||
{APPROVAL_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setTemplateTypeFilter(opt.value)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-sm transition-colors flex items-center gap-1.5',
|
||||
templateTypeFilter === opt.value
|
||||
? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)] font-medium'
|
||||
: 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
)}
|
||||
>
|
||||
<span style={{ color: opt.color }}>●</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{templates.map((flow) => (
|
||||
<Card key={flow.id} className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<GitBranch className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base text-slate-100">{flow.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">{flow.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
flow.status === 1 || flow.status === 'active' || flow.status === true
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
|
||||
}
|
||||
>
|
||||
{flow.status === 1 || flow.status === 'active' || flow.status === true ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-slate-950 rounded-lg p-4 overflow-x-auto">
|
||||
{renderFlowChart(flow)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4 text-sm text-slate-500">
|
||||
<span>创建于 {flow.created_at ? new Date(flow.created_at).toLocaleDateString() : '--'}</span>
|
||||
<span>{parseSteps(flow.steps).length} 个节点</span>
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center">
|
||||
<GitBranch className="h-12 w-12 mx-auto text-[var(--dg-text-muted)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--dg-text-secondary)] mb-2">暂无流程模板</h3>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">当前筛选条件下没有匹配的模板</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
) : (
|
||||
filteredTemplates.map((flow) => (
|
||||
<Card key={flow.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-blue)]/10 flex items-center justify-center">
|
||||
<GitBranch className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base text-[var(--dg-text-primary)]">{flow.name}</CardTitle>
|
||||
{flow.type && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-[var(--dg-border)]"
|
||||
style={{ color: getApprovalTypeColor(flow.type) }}
|
||||
>
|
||||
{getApprovalTypeLabel(flow.type)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-[var(--dg-text-muted)]">{flow.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
flow.status === 1 || flow.status === 'active' || flow.status === true
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-[var(--dg-border)] text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]'
|
||||
}
|
||||
>
|
||||
{flow.status === 1 || flow.status === 'active' || flow.status === true ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-4 overflow-x-auto">
|
||||
{renderFlowChart(flow)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4 text-sm text-[var(--dg-text-muted)]">
|
||||
<span>创建于 {flow.created_at ? new Date(flow.created_at).toLocaleDateString() : '--'}</span>
|
||||
<span>{parseSteps(flow.steps).length} 个节点</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 我的审批 Tab */}
|
||||
<TabsContent value="my-approvals" className="mt-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center">
|
||||
<CheckSquare className="h-12 w-12 mx-auto text-slate-500 mb-4" />
|
||||
<h3 className="text-lg font-medium text-slate-300 mb-2">暂无待审批事项</h3>
|
||||
<p className="text-sm text-slate-500">当前没有需要您审批的流程</p>
|
||||
<CheckSquare className="h-12 w-12 mx-auto text-[var(--dg-text-muted)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--dg-text-secondary)] mb-2">暂无待审批事项</h3>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">当前没有需要您审批的流程</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Instance Detail Dialog */}
|
||||
<Dialog open={!!selectedInstance} onOpenChange={() => setSelectedInstance(null)}>
|
||||
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800">
|
||||
{/* 审批实例详情 Dialog */}
|
||||
<Dialog open={!!selectedInstance} onOpenChange={(open) => !open && setSelectedInstance(null)}>
|
||||
<DialogContent className="max-w-2xl bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5 text-blue-400" />
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
{selectedInstance?.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant} · 创建于{' '}
|
||||
{selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 审批进度条可视化 */}
|
||||
{selectedInstance && (
|
||||
<div className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-secondary)]">审批进度</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
步骤 {getProgressInfo(selectedInstance).current} / {getProgressInfo(selectedInstance).total}
|
||||
</span>
|
||||
</div>
|
||||
{renderStepProgress(selectedInstance)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approval Timeline */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-slate-300">审批进度</h4>
|
||||
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)]">审批记录</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="w-0.5 h-full bg-slate-700 mt-2" />
|
||||
<div className="w-0.5 h-full bg-[var(--dg-border)] mt-2" />
|
||||
</div>
|
||||
<div className="flex-1 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-200">提交申请</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">提交申请</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
{selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-1">申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant}</p>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -498,19 +699,19 @@ export function ApprovalFlowManager() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-200">{selectedInstance?.current_step || '审批节点'}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{selectedInstance?.current_step || '审批节点'}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
{selectedInstance?.completed_at
|
||||
? new Date(selectedInstance.completed_at).toLocaleString()
|
||||
: '进行中'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
||||
{selectedInstance?.status === 'pending'
|
||||
? '等待审批人处理'
|
||||
: selectedInstance?.status === 'approved'
|
||||
? '审批已通过'
|
||||
: '审批已拒绝'}
|
||||
: '审批已驳回'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -523,15 +724,15 @@ export function ApprovalFlowManager() {
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-700 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => handleReject(selectedInstance.id)}
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => openActionDialog(selectedInstance, 'reject')}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
拒绝
|
||||
驳回
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => handleApprove(selectedInstance.id)}
|
||||
onClick={() => openActionDialog(selectedInstance, 'approve')}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
通过
|
||||
@@ -541,6 +742,89 @@ export function ApprovalFlowManager() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 审批操作 Dialog(含审批意见 textarea) */}
|
||||
<Dialog open={actionDialogOpen} onOpenChange={setActionDialogOpen}>
|
||||
<DialogContent className="max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
{actionType === 'approve' ? (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-emerald-400" />
|
||||
确认通过审批
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-red-400" />
|
||||
确认驳回审批
|
||||
</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
{actionTarget?.title ? `流程:${actionTarget.title}` : '请填写审批意见后确认操作'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">
|
||||
审批意见 <span className="text-[var(--dg-text-muted)] text-xs">(选填)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder={
|
||||
actionType === 'approve'
|
||||
? '请输入审批通过意见...'
|
||||
: '请输入驳回原因...'
|
||||
}
|
||||
value={actionComment}
|
||||
onChange={(e) => setActionComment(e.target.value)}
|
||||
rows={4}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] resize-none"
|
||||
/>
|
||||
</div>
|
||||
{actionType === 'reject' && !actionComment.trim() && (
|
||||
<p className="text-xs text-amber-400">建议填写驳回原因以便申请人了解</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setActionDialogOpen(false)}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
{actionType === 'approve' ? (
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={handleConfirmAction}
|
||||
disabled={actionSubmitting}
|
||||
>
|
||||
{actionSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
确认通过
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={handleConfirmAction}
|
||||
disabled={actionSubmitting}
|
||||
>
|
||||
{actionSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
确认驳回
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { User, Lock, Palette, Check, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { useUserStore } from '@/store';
|
||||
import { userApi } from '@/services/api';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
/* ---------- Floating background orbs (themed light spots) ---------- */
|
||||
const ORBS = [
|
||||
{
|
||||
top: '-10%',
|
||||
left: '-5%',
|
||||
size: 520,
|
||||
color: 'var(--dg-blue)',
|
||||
opacity: 0.22,
|
||||
blur: 90,
|
||||
animation: 'login-orb-1 18s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
bottom: '-15%',
|
||||
right: '-8%',
|
||||
size: 480,
|
||||
color: 'var(--dg-purple)',
|
||||
opacity: 0.2,
|
||||
blur: 100,
|
||||
animation: 'login-orb-2 22s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
top: '30%',
|
||||
right: '10%',
|
||||
size: 320,
|
||||
color: 'var(--dg-cyan)',
|
||||
opacity: 0.16,
|
||||
blur: 70,
|
||||
animation: 'login-orb-3 16s ease-in-out infinite',
|
||||
},
|
||||
{
|
||||
bottom: '10%',
|
||||
left: '8%',
|
||||
size: 380,
|
||||
color: 'var(--dg-gradient-start)',
|
||||
opacity: 0.18,
|
||||
blur: 80,
|
||||
animation: 'login-orb-4 20s ease-in-out infinite',
|
||||
},
|
||||
];
|
||||
|
||||
const ANIMATIONS = `
|
||||
@keyframes login-orb-1 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(40px, -50px) scale(1.12); }
|
||||
66% { transform: translate(-30px, 35px) scale(0.92); }
|
||||
}
|
||||
@keyframes login-orb-2 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-50px, 25px) scale(1.18); }
|
||||
}
|
||||
@keyframes login-orb-3 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(30px, 45px) scale(0.88); }
|
||||
}
|
||||
@keyframes login-orb-4 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(25px, -30px) scale(1.1); }
|
||||
}
|
||||
@keyframes login-card-enter {
|
||||
from { opacity: 0; transform: translateY(24px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes login-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`;
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const login = useUserStore((s) => s.login);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('123456');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -17,70 +97,278 @@ export function Login() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await userApi.login(username, password);
|
||||
login(result.user, result.token);
|
||||
useUserStore.getState().login(result.user, result.token);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: any) {
|
||||
setError(err.message || '登录失败');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '登录失败,请检查用户名和密码';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600 mb-4">
|
||||
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17v-2a4 4 0 014-4h2m4-2H5a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2V7a2 2 0 00-2-2z M7 7h.01M11 7h.01M15 7h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">AIOps 智能运维平台</h1>
|
||||
<p className="text-slate-400 mt-2 text-sm">EZOps Platform</p>
|
||||
<>
|
||||
<style>{ANIMATIONS}</style>
|
||||
|
||||
<div
|
||||
className="relative min-h-screen flex items-center justify-center px-4 overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, var(--dg-bg-primary) 0%, var(--dg-bg-secondary) 100%)',
|
||||
}}
|
||||
>
|
||||
{/* -------- Floating themed light spots -------- */}
|
||||
{ORBS.map((orb, i) => (
|
||||
<div
|
||||
key={`orb-${i}`}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
top: orb.top,
|
||||
left: orb.left,
|
||||
bottom: orb.bottom,
|
||||
right: orb.right,
|
||||
width: orb.size,
|
||||
height: orb.size,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${orb.color} 0%, transparent 70%)`,
|
||||
opacity: orb.opacity,
|
||||
filter: `blur(${orb.blur}px)`,
|
||||
animation: orb.animation,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* -------- Theme switcher (top-right) -------- */}
|
||||
<div
|
||||
className="fixed top-4 right-4 z-50"
|
||||
style={{ animation: 'login-fade-in 0.6s ease-out' }}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换主题"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center size-10 rounded-full',
|
||||
'border transition-all duration-200 hover:scale-105 active:scale-95'
|
||||
)}
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--dg-bg-secondary) 70%, transparent)',
|
||||
borderColor: 'var(--dg-border)',
|
||||
color: 'var(--dg-text-secondary)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
<Palette className="size-5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel>主题切换</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
className="flex items-center gap-2.5 cursor-pointer"
|
||||
onClick={() => setTheme(option.id)}
|
||||
>
|
||||
<span
|
||||
className="size-3.5 rounded-full shrink-0 ring-2"
|
||||
style={{
|
||||
backgroundColor: option.primaryColor,
|
||||
// @ts-expect-error css var in style string
|
||||
'--tw-ring-color': 'var(--dg-bg-primary)',
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium block">{option.name}</span>
|
||||
<span className="text-xs opacity-60 block truncate">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
{theme === option.id && <Check className="size-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="bg-slate-900/60 rounded-xl p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3">
|
||||
{error}
|
||||
{/* -------- Login card -------- */}
|
||||
<div
|
||||
className="relative z-10 w-full max-w-md"
|
||||
style={{
|
||||
background:
|
||||
'color-mix(in srgb, var(--dg-bg-secondary) 75%, transparent)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
border: '1px solid var(--dg-border)',
|
||||
borderRadius: 24,
|
||||
boxShadow:
|
||||
'0 8px 48px rgba(0,0,0,0.28), 0 0 0 1px rgba(255,255,255,0.02)',
|
||||
padding: '40px 36px',
|
||||
animation: 'login-card-enter 0.6s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="text-center mb-8">
|
||||
<div
|
||||
className="inline-flex items-center justify-center size-14 rounded-2xl mb-4 shadow-lg"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, var(--dg-gradient-start), var(--dg-gradient-end))',
|
||||
boxShadow: '0 8px 24px color-mix(in srgb, var(--dg-gradient-start) 35%, transparent)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="size-7 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 12h4l3-8 4 16 3-8h4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-slate-800 text-white rounded-lg px-4 py-2.5 border border-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-colors"
|
||||
placeholder="请输入用户名"
|
||||
autoFocus
|
||||
/>
|
||||
<h1 className="text-3xl font-bold dg-gradient-text tracking-tight">
|
||||
EZOps AIOps
|
||||
</h1>
|
||||
<p
|
||||
className="mt-2 text-sm"
|
||||
style={{ color: 'var(--dg-text-secondary)' }}
|
||||
>
|
||||
智能运维 · 全栈可观测平台
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-800 text-white rounded-lg px-4 py-2.5 border border-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-colors"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg px-4 py-2.5 transition-colors"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-slate-500 text-xs mt-6">
|
||||
默认账号 admin / 123456
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--dg-red) 12%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--dg-red) 30%, transparent)',
|
||||
color: 'var(--dg-red)',
|
||||
animation: 'login-fade-in 0.25s ease-out',
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="size-4 shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--dg-text-secondary)' }}
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User
|
||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 size-4 pointer-events-none"
|
||||
style={{ color: 'var(--dg-text-muted)' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="dg-input w-full rounded-lg py-2.5 pl-10 pr-4 text-sm transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--dg-text-secondary)' }}
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock
|
||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 size-4 pointer-events-none"
|
||||
style={{ color: 'var(--dg-text-muted)' }}
|
||||
/>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
className="dg-input w-full rounded-lg py-2.5 pl-10 pr-12 text-sm transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium transition-colors"
|
||||
style={{ color: 'var(--dg-text-muted)' }}
|
||||
>
|
||||
{showPassword ? '隐藏' : '显示'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className={cn(
|
||||
'dg-gradient-bg w-full rounded-lg py-3 mt-2 text-white font-semibold text-[15px]',
|
||||
'transition-all duration-200 active:scale-[0.98]',
|
||||
'disabled:cursor-not-allowed'
|
||||
)}
|
||||
style={{
|
||||
opacity: loading || !username || !password ? 0.6 : 1,
|
||||
boxShadow:
|
||||
'0 6px 24px color-mix(in srgb, var(--dg-gradient-start) 30%, transparent)',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
登录中…
|
||||
</span>
|
||||
) : (
|
||||
'登 录'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Hints */}
|
||||
<div
|
||||
className="mt-6 pt-5 text-center text-xs space-y-1"
|
||||
style={{
|
||||
borderTop: '1px solid var(--dg-border-light)',
|
||||
color: 'var(--dg-text-muted)',
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
默认管理员账号 <span className="font-medium" style={{ color: 'var(--dg-text-secondary)' }}>admin / 123456</span>
|
||||
</p>
|
||||
<p>LDAP 用户可直接使用域账号登录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p
|
||||
className="absolute bottom-4 left-0 right-0 text-center text-xs"
|
||||
style={{ color: 'var(--dg-text-muted)', opacity: 0.7 }}
|
||||
>
|
||||
© EZOps AIOps · 智能运维平台
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
@@ -66,6 +69,20 @@ export function K8sClusters() {
|
||||
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 创建/编辑集群表单
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingCluster, setEditingCluster] = useState<any>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
version: '',
|
||||
description: '',
|
||||
config_type: 1, // 0=Token, 1=Kubeconfig
|
||||
api_server: '',
|
||||
token: '',
|
||||
kube_config: '',
|
||||
});
|
||||
|
||||
// 集群详情关联资源
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
@@ -130,6 +147,81 @@ export function K8sClusters() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
version: '',
|
||||
description: '',
|
||||
config_type: 1,
|
||||
api_server: '',
|
||||
token: '',
|
||||
kube_config: '',
|
||||
});
|
||||
setEditingCluster(null);
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
resetForm();
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (cluster: any) => {
|
||||
setEditingCluster(cluster);
|
||||
setFormData({
|
||||
name: cluster.name || '',
|
||||
version: cluster.version || '',
|
||||
description: cluster.description || '',
|
||||
config_type: Number(cluster.config_type ?? 1),
|
||||
api_server: cluster.api_server || '',
|
||||
token: cluster.token || '',
|
||||
kube_config: cluster.kube_config || '',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 从 kubeconfig YAML 中解析 server 地址
|
||||
const parseApiServer = (yaml: string): string => {
|
||||
const match = yaml.match(/^\s*server:\s*(\S+)\s*$/m);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
|
||||
const handleKubeConfigChange = (value: string) => {
|
||||
const server = parseApiServer(value);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
kube_config: value,
|
||||
api_server: server || prev.api_server,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
version: formData.version,
|
||||
description: formData.description,
|
||||
config_type: formData.config_type,
|
||||
api_server: formData.api_server,
|
||||
token: formData.config_type === 0 ? formData.token : '',
|
||||
kube_config: formData.config_type === 1 ? formData.kube_config : '',
|
||||
};
|
||||
if (editingCluster) {
|
||||
await k8sApi.updateCluster(editingCluster.id, payload);
|
||||
} else {
|
||||
await k8sApi.createCluster(payload);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
resetForm();
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error('保存集群失败:', e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredClusters = clusters.filter(
|
||||
(cluster) =>
|
||||
(cluster.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -194,7 +286,7 @@ export function K8sClusters() {
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加集群
|
||||
</Button>
|
||||
@@ -339,7 +431,13 @@ export function K8sClusters() {
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
Kubectl
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<DropdownMenuItem
|
||||
className="text-slate-300 focus:bg-slate-700 focus:text-slate-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditDialog(cluster);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
配置
|
||||
</DropdownMenuItem>
|
||||
@@ -840,6 +938,123 @@ export function K8sClusters() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑集群 Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={(o) => { setDialogOpen(o); if (!o) resetForm(); }}>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">
|
||||
{editingCluster ? '编辑集群' : '添加集群'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
{editingCluster ? '修改集群接入配置' : '配置新的 Kubernetes 集群接入信息'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">集群名称</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="例如:prod-cluster"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">版本(可选)</Label>
|
||||
<Input
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
||||
placeholder="例如:1.28.0"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="集群用途描述"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">接入方式</Label>
|
||||
<RadioGroup
|
||||
value={String(formData.config_type)}
|
||||
onValueChange={(v) => setFormData({ ...formData, config_type: Number(v) })}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-hover)]">
|
||||
<RadioGroupItem value="0" id="ct-0" />
|
||||
<Label htmlFor="ct-0" className="cursor-pointer text-[var(--dg-text-primary)] font-normal">Token</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-hover)]">
|
||||
<RadioGroupItem value="1" id="ct-1" />
|
||||
<Label htmlFor="ct-1" className="cursor-pointer text-[var(--dg-text-primary)] font-normal">Kubeconfig</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">API Server</Label>
|
||||
<Input
|
||||
value={formData.api_server}
|
||||
onChange={(e) => setFormData({ ...formData, api_server: e.target.value })}
|
||||
placeholder="https://10.0.0.1:6443"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.config_type === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">Token</Label>
|
||||
<Textarea
|
||||
value={formData.token}
|
||||
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
|
||||
placeholder="粘贴 Service Account Token..."
|
||||
rows={4}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">Kubeconfig</Label>
|
||||
<Textarea
|
||||
value={formData.kube_config}
|
||||
onChange={(e) => handleKubeConfigChange(e.target.value)}
|
||||
placeholder={'apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://10.0.0.1:6443\n ...'}
|
||||
rows={10}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
粘贴 kubeconfig 内容后将自动解析 API Server 地址
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setDialogOpen(false); resetForm(); }}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !formData.name.trim()}
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
>
|
||||
{submitting ? '保存中...' : editingCluster ? '保存' : '添加'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
MessageSquare,
|
||||
Phone,
|
||||
VolumeX,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { alertApi } from '@/services/api';
|
||||
@@ -48,17 +51,23 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
function hasChannel(notifyChannels: any, channel: string): boolean {
|
||||
if (!notifyChannels) return false;
|
||||
if (Array.isArray(notifyChannels)) return notifyChannels.includes(channel);
|
||||
return parseChannels(notifyChannels).includes(channel);
|
||||
}
|
||||
|
||||
// 将 notify_channels (可能是数组/JSON字符串/逗号分隔) 解析为数组
|
||||
function parseChannels(notifyChannels: any): string[] {
|
||||
if (!notifyChannels) return [];
|
||||
if (Array.isArray(notifyChannels)) return notifyChannels;
|
||||
if (typeof notifyChannels === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(notifyChannels);
|
||||
return Array.isArray(parsed) ? parsed.includes(channel) : notifyChannels.includes(channel);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
return notifyChannels.includes(channel);
|
||||
// 非JSON,尝试逗号分隔
|
||||
}
|
||||
return notifyChannels.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return false;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function AlertManager() {
|
||||
@@ -70,6 +79,103 @@ export function AlertManager() {
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 告警规则弹窗状态
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<any>(null);
|
||||
const [ruleSaving, setRuleSaving] = useState(false);
|
||||
const [ruleForm, setRuleForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'metric',
|
||||
source: 'prometheus',
|
||||
expression: '',
|
||||
severity: 'warning',
|
||||
duration: 300,
|
||||
notify_channels: [] as string[],
|
||||
});
|
||||
|
||||
const resetRuleForm = () => {
|
||||
setRuleForm({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'metric',
|
||||
source: 'prometheus',
|
||||
expression: '',
|
||||
severity: 'warning',
|
||||
duration: 300,
|
||||
notify_channels: [],
|
||||
});
|
||||
setEditingRule(null);
|
||||
};
|
||||
|
||||
const openCreateRule = () => {
|
||||
resetRuleForm();
|
||||
setRuleDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditRule = (rule: any) => {
|
||||
setEditingRule(rule);
|
||||
setRuleForm({
|
||||
name: rule.name || '',
|
||||
description: rule.description || '',
|
||||
type: rule.type || 'metric',
|
||||
source: rule.source || 'prometheus',
|
||||
expression: rule.expression || '',
|
||||
severity: rule.severity || 'warning',
|
||||
duration: rule.duration || 300,
|
||||
notify_channels: parseChannels(rule.notify_channels),
|
||||
});
|
||||
setRuleDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (id: any) => {
|
||||
if (!window.confirm('确定删除此告警规则?')) return;
|
||||
try {
|
||||
await alertApi.deleteRule(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rule:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRule = async () => {
|
||||
if (!ruleForm.name.trim() || !ruleForm.expression.trim()) return;
|
||||
setRuleSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: ruleForm.name.trim(),
|
||||
description: ruleForm.description.trim(),
|
||||
type: ruleForm.type,
|
||||
source: ruleForm.source,
|
||||
expression: ruleForm.expression.trim(),
|
||||
severity: ruleForm.severity,
|
||||
duration: Number(ruleForm.duration) || 0,
|
||||
notify_channels: ruleForm.notify_channels,
|
||||
};
|
||||
if (editingRule) {
|
||||
await alertApi.updateRule(editingRule.id, payload);
|
||||
} else {
|
||||
await alertApi.createRule(payload);
|
||||
}
|
||||
setRuleDialogOpen(false);
|
||||
resetRuleForm();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to save rule:', error);
|
||||
} finally {
|
||||
setRuleSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNotifyChannel = (channel: string) => {
|
||||
setRuleForm((prev) => ({
|
||||
...prev,
|
||||
notify_channels: prev.notify_channels.includes(channel)
|
||||
? prev.notify_channels.filter((c) => c !== channel)
|
||||
: [...prev.notify_channels, channel],
|
||||
}));
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -78,7 +184,7 @@ export function AlertManager() {
|
||||
alertApi.getRules(),
|
||||
]);
|
||||
setAlerts((alertsData as any)?.list || []);
|
||||
setRules(rulesData || []);
|
||||
setRules((rulesData as any)?.list || (Array.isArray(rulesData) ? rulesData : []));
|
||||
} catch (error) {
|
||||
console.error('Failed to load alert data:', error);
|
||||
} finally {
|
||||
@@ -188,7 +294,7 @@ export function AlertManager() {
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
通知配置
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreateRule}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建规则
|
||||
</Button>
|
||||
@@ -435,7 +541,7 @@ export function AlertManager() {
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<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">持续时间</TableHead>
|
||||
@@ -450,18 +556,20 @@ export function AlertManager() {
|
||||
<TableCell>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-200">{rule.name}</span>
|
||||
<p className="text-xs text-slate-500">{rule.description}</p>
|
||||
{rule.description && (
|
||||
<p className="text-xs text-slate-500">{rule.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
|
||||
{rule.metric}
|
||||
{rule.source || '--'}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">
|
||||
{rule.operator} {rule.threshold}
|
||||
</span>
|
||||
<code className="text-xs text-blue-300">
|
||||
{rule.expression || '--'}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rule.severity === 'critical' ? (
|
||||
@@ -503,9 +611,22 @@ export function AlertManager() {
|
||||
</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-slate-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={() => openEditRule(rule)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -533,6 +654,147 @@ export function AlertManager() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 告警规则 创建/编辑弹窗 */}
|
||||
<Dialog open={ruleDialogOpen} onOpenChange={(open) => { setRuleDialogOpen(open); if (!open) resetRuleForm(); }}>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 text-slate-100 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">
|
||||
{editingRule ? '编辑告警规则' : '新建告警规则'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
{editingRule ? '修改告警规则的触发条件和通知方式' : '配置监控指标的告警触发条件'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">规则名称 <span className="text-red-400">*</span></Label>
|
||||
<Input
|
||||
value={ruleForm.name}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })}
|
||||
placeholder="如:CPU 使用率告警"
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">描述</Label>
|
||||
<Textarea
|
||||
value={ruleForm.description}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })}
|
||||
placeholder="规则说明"
|
||||
rows={2}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">规则类型</Label>
|
||||
<select
|
||||
value={ruleForm.type}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, type: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
|
||||
>
|
||||
<option value="metric">指标监控</option>
|
||||
<option value="log">日志告警</option>
|
||||
<option value="event">事件告警</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">数据源</Label>
|
||||
<select
|
||||
value={ruleForm.source}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, source: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
|
||||
>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
<option value="zabbix">Zabbix</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">触发表达式 <span className="text-red-400">*</span></Label>
|
||||
<Input
|
||||
value={ruleForm.expression}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, expression: e.target.value })}
|
||||
placeholder="如:cpu_usage > 80"
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-slate-600">PromQL 或指标表达式,例如 cpu_usage > 80</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">告警级别</Label>
|
||||
<select
|
||||
value={ruleForm.severity}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, severity: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
|
||||
>
|
||||
<option value="info">提示</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="critical">严重</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">持续时间(秒)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={ruleForm.duration}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, duration: Number(e.target.value) })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">通知渠道</Label>
|
||||
<div className="flex items-center gap-4 pt-1">
|
||||
{[
|
||||
{ key: 'email', label: '邮件', icon: Mail },
|
||||
{ key: 'sms', label: '短信', icon: Phone },
|
||||
{ key: 'dingtalk', label: '钉钉', icon: MessageSquare },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 cursor-pointer select-none text-sm text-slate-300"
|
||||
>
|
||||
<Switch
|
||||
checked={ruleForm.notify_channels.includes(key)}
|
||||
onCheckedChange={() => toggleNotifyChannel(key)}
|
||||
className="data-[state=checked]:bg-blue-600"
|
||||
/>
|
||||
<Icon className="h-4 w-4 text-slate-400" />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
onClick={() => { setRuleDialogOpen(false); resetRuleForm(); }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
disabled={!ruleForm.name.trim() || !ruleForm.expression.trim() || ruleSaving}
|
||||
onClick={handleSaveRule}
|
||||
>
|
||||
{ruleSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editingRule ? '保存修改' : '创建规则'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -28,7 +36,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
@@ -49,22 +56,75 @@ import {
|
||||
Eye,
|
||||
Download,
|
||||
Upload,
|
||||
Filter,
|
||||
Server,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Activity,
|
||||
CheckSquare,
|
||||
Square,
|
||||
Cloud,
|
||||
FileSpreadsheet,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { serverApi } from '@/services/api';
|
||||
import type { Server as ServerType } from '@/types';
|
||||
import { CloudServerImport } from './CloudServerImport';
|
||||
import { ExcelServerImport } from './ExcelServerImport';
|
||||
|
||||
// 资产类型字典(参考 CMDB 运维平台)
|
||||
const ASSET_TYPE_OPTIONS = [
|
||||
{ value: 'physical', label: '物理机', color: 'var(--dg-orange)' },
|
||||
{ value: 'virtual', label: '虚拟机', color: 'var(--dg-blue)' },
|
||||
{ value: 'cloud', label: '云主机', color: 'var(--dg-cyan)' },
|
||||
{ value: 'container', label: '容器', color: 'var(--dg-purple)' },
|
||||
];
|
||||
|
||||
const getAssetTypeLabel = (value: string): string =>
|
||||
ASSET_TYPE_OPTIONS.find((o) => o.value === value)?.label || value || '-';
|
||||
|
||||
const getAssetTypeColor = (value: string): string =>
|
||||
ASSET_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
|
||||
|
||||
const getConnectTypeLabel = (value: string): string => {
|
||||
if (value === 'public') return '公网';
|
||||
if (value === 'private') return '内网';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
// 表单数据结构
|
||||
interface ServerFormData {
|
||||
hostname: string;
|
||||
ip: string;
|
||||
os: string;
|
||||
cpu: string;
|
||||
memory: string;
|
||||
disk: string;
|
||||
status: string;
|
||||
region: string;
|
||||
asset_type: string;
|
||||
connect_type: string;
|
||||
floatingip: string;
|
||||
instance_id: string;
|
||||
expire_time: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: ServerFormData = {
|
||||
hostname: '',
|
||||
ip: '',
|
||||
os: '',
|
||||
cpu: '',
|
||||
memory: '',
|
||||
disk: '',
|
||||
status: 'running',
|
||||
region: '',
|
||||
asset_type: 'physical',
|
||||
connect_type: 'private',
|
||||
floatingip: '',
|
||||
instance_id: '',
|
||||
expire_time: '',
|
||||
remark: '',
|
||||
};
|
||||
|
||||
export function ServerManager() {
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -75,6 +135,13 @@ export function ServerManager() {
|
||||
const [cloudImportOpen, setCloudImportOpen] = useState(false);
|
||||
const [excelImportOpen, setExcelImportOpen] = useState(false);
|
||||
|
||||
// 创建/编辑表单状态
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<ServerFormData>(EMPTY_FORM);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -150,6 +217,74 @@ export function ServerManager() {
|
||||
}
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedServers.length === 0) return;
|
||||
try {
|
||||
await serverApi.batchDelete(selectedServers.map((id) => Number(id)));
|
||||
setSelectedServers([]);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error('批量删除失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开创建表单
|
||||
const openCreateForm = () => {
|
||||
setEditingId(null);
|
||||
setFormData(EMPTY_FORM);
|
||||
setDetailsOpen(false);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
// 打开编辑表单
|
||||
const openEditForm = (server: any) => {
|
||||
setEditingId(server.id);
|
||||
setFormData({
|
||||
hostname: server.hostname || '',
|
||||
ip: server.ip || '',
|
||||
os: server.os || '',
|
||||
cpu: String(server.cpu || ''),
|
||||
memory: String(server.memory || ''),
|
||||
disk: String(server.disk || ''),
|
||||
status: server.status || 'running',
|
||||
region: getRegionName(server),
|
||||
asset_type: server.asset_type || 'physical',
|
||||
connect_type: server.connect_type || 'private',
|
||||
floatingip: server.floatingip || '',
|
||||
instance_id: server.instance_id || '',
|
||||
expire_time: server.expire_time || '',
|
||||
remark: server.remark || '',
|
||||
});
|
||||
setDetailsOpen(false);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
// 保存表单
|
||||
const handleSave = async () => {
|
||||
if (!formData.hostname || !formData.ip) return;
|
||||
try {
|
||||
setFormSaving(true);
|
||||
const payload = {
|
||||
...formData,
|
||||
cpu: Number(formData.cpu) || 0,
|
||||
memory: Number(formData.memory) || 0,
|
||||
disk: Number(formData.disk) || 0,
|
||||
};
|
||||
if (editingId) {
|
||||
await serverApi.updateServer(editingId, payload);
|
||||
} else {
|
||||
await serverApi.createServer(payload);
|
||||
}
|
||||
setFormOpen(false);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error('保存服务器失败:', e);
|
||||
} finally {
|
||||
setFormSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ServerType['status']) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
@@ -161,7 +296,7 @@ export function ServerManager() {
|
||||
);
|
||||
case 'stopped':
|
||||
return (
|
||||
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] bg-[var(--dg-bg-tertiary)]">
|
||||
已停止
|
||||
</Badge>
|
||||
);
|
||||
@@ -185,56 +320,61 @@ export function ServerManager() {
|
||||
|
||||
const regions = Array.from(new Set(servers.map((s) => getRegionName(s)).filter(Boolean)));
|
||||
|
||||
const colSpanCount = 11;
|
||||
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">服务器管理</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] 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"
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
onClick={loadData}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<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">
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
批量导入
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-slate-800 border-slate-700 text-slate-100">
|
||||
<DropdownMenuContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DropdownMenuLabel>选择导入方式</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
<DropdownMenuItem
|
||||
className="hover:bg-slate-700 cursor-pointer"
|
||||
className="hover:bg-[var(--dg-bg-hover)] cursor-pointer focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]"
|
||||
onClick={() => setCloudImportOpen(true)}
|
||||
>
|
||||
<Cloud className="h-4 w-4 mr-2 text-blue-400" />
|
||||
<Cloud className="h-4 w-4 mr-2 text-[var(--dg-blue)]" />
|
||||
云主机导入
|
||||
<span className="ml-2 text-xs text-slate-500">从云平台同步</span>
|
||||
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">从云平台同步</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:bg-slate-700 cursor-pointer"
|
||||
className="hover:bg-[var(--dg-bg-hover)] cursor-pointer focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]"
|
||||
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>
|
||||
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">批量导入服务器</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Button
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
onClick={openCreateForm}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加服务器
|
||||
</Button>
|
||||
@@ -243,24 +383,24 @@ export function ServerManager() {
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<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>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">服务器总数</p>
|
||||
<p className="text-2xl font-bold text-[var(--dg-text-primary)]">{servers.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-blue)]/10 text-[var(--dg-blue)]">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">运行中</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">运行中</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{servers.filter((s) => s.status === 'running').length}
|
||||
</p>
|
||||
@@ -271,11 +411,11 @@ export function ServerManager() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">维护中</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">维护中</p>
|
||||
<p className="text-2xl font-bold text-amber-400">
|
||||
{servers.filter((s) => s.status === 'maintenance').length}
|
||||
</p>
|
||||
@@ -286,16 +426,16 @@ export function ServerManager() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<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">
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">已停止</p>
|
||||
<p className="text-2xl font-bold text-[var(--dg-text-muted)]">
|
||||
{servers.filter((s) => s.status === 'stopped').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-500/10 text-slate-400">
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]">
|
||||
<Power className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,24 +444,25 @@ export function ServerManager() {
|
||||
</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">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4 p-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" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<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"
|
||||
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="running">运行中</SelectItem>
|
||||
<SelectItem value="stopped">已停止</SelectItem>
|
||||
@@ -330,10 +471,10 @@ export function ServerManager() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={regionFilter} onValueChange={setRegionFilter}>
|
||||
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue placeholder="区域" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="all">全部区域</SelectItem>
|
||||
{regions.map((region) => (
|
||||
<SelectItem key={region} value={region}>
|
||||
@@ -345,54 +486,64 @@ export function ServerManager() {
|
||||
</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">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span>
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
批量命令
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300">
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
重启
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
|
||||
<div className="border-t border-[var(--dg-border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedServers.length === filteredServers.length && filteredServers.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-slate-600"
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</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>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">主机名</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">IP地址</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">资产类型</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">连接方式</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">配置</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">使用率</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">区域</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">标签</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={9} className="text-center text-slate-500 py-12">
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredServers.length === 0 ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={9} className="text-center text-slate-500 py-12">
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||
暂无服务器数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -403,43 +554,66 @@ export function ServerManager() {
|
||||
const tags = getTags(server);
|
||||
const region = getRegionName(server);
|
||||
return (
|
||||
<TableRow key={server.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableRow key={server.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedServers.includes(server.id)}
|
||||
onCheckedChange={() => toggleSelect(server.id)}
|
||||
className="border-slate-600"
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-slate-500" />
|
||||
<Server className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<div>
|
||||
<p className="font-medium text-slate-200">{server.hostname}</p>
|
||||
<p className="text-xs text-slate-500">{server.os}</p>
|
||||
<p className="font-medium text-[var(--dg-text-primary)]">{server.hostname}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{server.os}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
|
||||
<code className="text-sm text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">
|
||||
{server.ip}
|
||||
</code>
|
||||
</TableCell>
|
||||
{/* 资产类型 */}
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-[var(--dg-border)]"
|
||||
style={{ color: getAssetTypeColor(server.asset_type) }}
|
||||
>
|
||||
{getAssetTypeLabel(server.asset_type)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
{/* 连接方式 */}
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded',
|
||||
server.connect_type === 'public'
|
||||
? 'text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10'
|
||||
: 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]'
|
||||
)}
|
||||
>
|
||||
{getConnectTypeLabel(server.connect_type)}
|
||||
</span>
|
||||
</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 className="text-sm text-[var(--dg-text-secondary)]">
|
||||
<span className="text-[var(--dg-text-muted)]">{server.cpu}核</span>
|
||||
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
|
||||
<span className="text-[var(--dg-text-muted)]">{server.memory}GB</span>
|
||||
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
|
||||
<span className="text-[var(--dg-text-muted)]">{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">
|
||||
<span className="text-xs text-[var(--dg-text-muted)] w-8">CPU</span>
|
||||
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
@@ -448,11 +622,11 @@ export function ServerManager() {
|
||||
style={{ width: `${cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{cpuUsage.toFixed(1)}%</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{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">
|
||||
<span className="text-xs text-[var(--dg-text-muted)] w-8">内存</span>
|
||||
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
@@ -461,12 +635,12 @@ export function ServerManager() {
|
||||
style={{ width: `${memoryUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{memoryUsage.toFixed(1)}%</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{memoryUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{region || '-'}</span>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">{region || '-'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -474,13 +648,13 @@ export function ServerManager() {
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs border-slate-700 text-slate-400 bg-slate-800/50"
|
||||
className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]/50"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
|
||||
<Badge variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
+{tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -488,37 +662,40 @@ export function ServerManager() {
|
||||
</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">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
|
||||
<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">
|
||||
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => openEditForm(server)}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
|
||||
<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">
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
重启
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
|
||||
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
|
||||
onClick={() => handleDelete(server.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
@@ -538,6 +715,246 @@ export function ServerManager() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑服务器 Dialog */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="max-w-2xl bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
{editingId ? '编辑服务器' : '添加服务器'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
{editingId ? '修改服务器资产信息' : '录入新的服务器资产到 CMDB'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 基础信息 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">
|
||||
主机名 <span className="text-red-400">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="web-server-01"
|
||||
value={formData.hostname}
|
||||
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">
|
||||
IP地址 <span className="text-red-400">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="10.0.0.1"
|
||||
value={formData.ip}
|
||||
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CMDB 资产字段 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">资产类型</Label>
|
||||
<Select
|
||||
value={formData.asset_type}
|
||||
onValueChange={(v) => setFormData({ ...formData, asset_type: v })}
|
||||
>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue placeholder="选择资产类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
{ASSET_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<span style={{ color: opt.color }} className="mr-1">●</span>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">连接方式</Label>
|
||||
<RadioGroup
|
||||
value={formData.connect_type}
|
||||
onValueChange={(v) => setFormData({ ...formData, connect_type: v })}
|
||||
className="flex items-center gap-6 h-9"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="public" id="connect-public" />
|
||||
<Label htmlFor="connect-public" className="text-[var(--dg-text-secondary)] cursor-pointer font-normal">
|
||||
公网
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="private" id="connect-private" />
|
||||
<Label htmlFor="connect-private" className="text-[var(--dg-text-secondary)] cursor-pointer font-normal">
|
||||
内网
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 云主机扩展字段 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">弹性IP地址</Label>
|
||||
<Input
|
||||
placeholder="如 203.0.113.10"
|
||||
value={formData.floatingip}
|
||||
onChange={(e) => setFormData({ ...formData, floatingip: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">实例ID</Label>
|
||||
<Input
|
||||
placeholder="如 i-bp1abc123def"
|
||||
value={formData.instance_id}
|
||||
onChange={(e) => setFormData({ ...formData, instance_id: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">到期时间</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.expire_time}
|
||||
onChange={(e) => setFormData({ ...formData, expire_time: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">所在区域</Label>
|
||||
<Input
|
||||
placeholder="如 华东1"
|
||||
value={formData.region}
|
||||
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更多详情 - 可折叠区域 */}
|
||||
<Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
更多详情(OS版本 / CPU / 内存 / 磁盘 / 状态)
|
||||
</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', detailsOpen && 'rotate-180')} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">操作系统</Label>
|
||||
<Input
|
||||
placeholder="如 CentOS 7.9"
|
||||
value={formData.os}
|
||||
onChange={(e) => setFormData({ ...formData, os: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">运行状态</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => setFormData({ ...formData, status: v })}
|
||||
>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="running">运行中</SelectItem>
|
||||
<SelectItem value="stopped">已停止</SelectItem>
|
||||
<SelectItem value="maintenance">维护中</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">CPU核数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="4"
|
||||
value={formData.cpu}
|
||||
onChange={(e) => setFormData({ ...formData, cpu: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">内存 (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="8"
|
||||
value={formData.memory}
|
||||
onChange={(e) => setFormData({ ...formData, memory: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">磁盘 (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="100"
|
||||
value={formData.disk}
|
||||
onChange={(e) => setFormData({ ...formData, disk: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">备注</Label>
|
||||
<Textarea
|
||||
placeholder="补充说明..."
|
||||
value={formData.remark}
|
||||
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setFormOpen(false)}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!formData.hostname || !formData.ip || formSaving}
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
>
|
||||
{formSaving ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{editingId ? '保存修改' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Import Dialogs */}
|
||||
<CloudServerImport
|
||||
open={cloudImportOpen}
|
||||
|
||||
@@ -2,9 +2,6 @@ import { useState, useRef, useEffect } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,18 +23,12 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
Server,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Download,
|
||||
Upload,
|
||||
Copy,
|
||||
Settings,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { serverApi } from '@/services/api';
|
||||
|
||||
interface TerminalSession {
|
||||
@@ -46,7 +37,13 @@ interface TerminalSession {
|
||||
serverName: string;
|
||||
serverIp: string;
|
||||
connected: boolean;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
interface TermEntry {
|
||||
term: XTerm;
|
||||
ws: WebSocket;
|
||||
fit: FitAddon;
|
||||
opened: boolean;
|
||||
}
|
||||
|
||||
export function WebShell() {
|
||||
@@ -54,9 +51,13 @@ export function WebShell() {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<string | null>(null);
|
||||
const [selectedServer, setSelectedServer] = useState<string>('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
// 每个会话的终端实例:sessionId -> { term, ws, fit, opened }
|
||||
const termsRef = useRef<Map<string, TermEntry>>(new Map());
|
||||
// 每个会话的容器 DOM
|
||||
const containerRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
const loadServers = async () => {
|
||||
try {
|
||||
@@ -72,6 +73,21 @@ export function WebShell() {
|
||||
loadServers();
|
||||
}, []);
|
||||
|
||||
// 卸载时关闭所有连接
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
termsRef.current.forEach(({ ws, term }) => {
|
||||
try {
|
||||
ws.close();
|
||||
term.dispose();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
termsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const runningServers = servers.filter((s) => s.status === 'running');
|
||||
|
||||
const createSession = () => {
|
||||
@@ -79,26 +95,94 @@ export function WebShell() {
|
||||
const server = servers.find((s) => s.id === selectedServer);
|
||||
if (!server) return;
|
||||
|
||||
const sessionId = `session-${Date.now()}`;
|
||||
const cols = 80;
|
||||
const rows = 24;
|
||||
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#0a0e14',
|
||||
foreground: '#d6d6d6',
|
||||
cursor: '#d6d6d6',
|
||||
selectionBackground: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
|
||||
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${server.id}&cols=${cols}&rows=${rows}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
// 终端输入 -> WebSocket
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ status: 0, data }));
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket 输出 -> 终端
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.status === 0 && typeof msg.data === 'string') {
|
||||
term.write(msg.data);
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON,直接写入原始数据
|
||||
term.write(typeof event.data === 'string' ? event.data : '');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
term.write(`\x1b[32m正在连接 ${server.hostname} (${server.ip})...\x1b[0m\r\n`);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.id === sessionId ? { ...s, connected: false } : s))
|
||||
);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
termsRef.current.set(sessionId, { term, ws, fit, opened: false });
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
id: `session-${Date.now()}`,
|
||||
id: sessionId,
|
||||
serverId: server.id,
|
||||
serverName: server.hostname,
|
||||
serverIp: server.ip,
|
||||
connected: true,
|
||||
logs: [
|
||||
`Connecting to ${server.hostname} (${server.ip})...`,
|
||||
`Connection established.`,
|
||||
`Last login: ${new Date().toLocaleString()} from 192.168.1.100`,
|
||||
`[root@${server.hostname} ~]# `,
|
||||
],
|
||||
};
|
||||
|
||||
setSessions([...sessions, newSession]);
|
||||
setActiveSession(newSession.id);
|
||||
setSessions((prev) => [...prev, newSession]);
|
||||
setActiveSession(sessionId);
|
||||
setSelectedServer('');
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const closeSession = (sessionId: string) => {
|
||||
const entry = termsRef.current.get(sessionId);
|
||||
if (entry) {
|
||||
try {
|
||||
if (entry.ws.readyState === WebSocket.OPEN || entry.ws.readyState === WebSocket.CONNECTING) {
|
||||
entry.ws.close();
|
||||
}
|
||||
entry.term.dispose();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
termsRef.current.delete(sessionId);
|
||||
}
|
||||
containerRef.current.delete(sessionId);
|
||||
const newSessions = sessions.filter((s) => s.id !== sessionId);
|
||||
setSessions(newSessions);
|
||||
if (activeSession === sessionId) {
|
||||
@@ -106,93 +190,127 @@ export function WebShell() {
|
||||
}
|
||||
};
|
||||
|
||||
const executeCommand = () => {
|
||||
if (!command.trim() || !activeSession) return;
|
||||
|
||||
const session = sessions.find((s) => s.id === activeSession);
|
||||
if (!session) return;
|
||||
|
||||
const newLogs = [...session.logs, command];
|
||||
|
||||
// 模拟命令执行结果
|
||||
const mockResponses: Record<string, string> = {
|
||||
ls: 'total 32\ndrwxr-xr-x 5 root root 4096 Mar 30 10:00 .\ndrwxr-xr-x 18 root root 4096 Mar 30 09:00 ..\n-rw-r--r-- 1 root root 220 Mar 30 08:00 .bash_logout\n-rw-r--r-- 1 root root 3771 Mar 30 08:00 .bashrc\n-rw-r--r-- 1 root root 807 Mar 30 08:00 .profile\ndrwxr-xr-x 2 root root 4096 Mar 30 09:00 scripts\ndrwxr-xr-x 3 root root 4096 Mar 30 09:00 logs',
|
||||
pwd: '/root',
|
||||
'uname -a': 'Linux web-server-01 5.4.210-1.el7.elrepo.x86_64 #1 SMP x86_64 x86_64 x86_64 GNU/Linux',
|
||||
'df -h': 'Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 500G 180G 320G 36% /\n/dev/sdb1 1.0T 450G 550G 45% /data',
|
||||
'free -m': ' total used free shared buff/cache available\nMem: 65536 42000 8000 1024 15536 21000\nSwap: 8192 0 8192',
|
||||
'ps aux': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nroot 1 0.0 0.1 18532 3200 ? Ss Mar30 0:03 /sbin/init\nroot 512 0.1 0.5 45200 12800 ? Ss Mar30 2:15 nginx: master process\nroot 1024 0.5 2.0 256000 51200 ? Sl Mar30 15:30 java -jar app.jar',
|
||||
top: 'top - 10:30:00 up 30 days, 12:00, 1 user, load average: 0.52, 0.58, 0.59\nTasks: 156 total, 1 running, 155 sleeping, 0 stopped, 0 zombie\n%Cpu(s): 25.0 us, 5.0 sy, 0.0 ni, 68.0 id, 0.0 wa, 0.0 hi, 2.0 si, 0.0 st',
|
||||
};
|
||||
|
||||
const cmd = command.trim().split(' ')[0];
|
||||
const response = mockResponses[command.trim()] || mockResponses[cmd] || `${cmd}: command executed successfully`;
|
||||
|
||||
newLogs.push(response);
|
||||
newLogs.push(`[${session.serverName} ~]# `);
|
||||
|
||||
setSessions(
|
||||
sessions.map((s) => (s.id === activeSession ? { ...s, logs: newLogs } : s))
|
||||
);
|
||||
setCommand('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
executeCommand();
|
||||
}
|
||||
};
|
||||
|
||||
// 自动滚动到底部
|
||||
// 窗口 resize 时适配当前激活的终端
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
|
||||
const handleResize = () => {
|
||||
if (!activeSession) return;
|
||||
const entry = termsRef.current.get(activeSession);
|
||||
if (!entry || !entry.opened) return;
|
||||
try {
|
||||
entry.fit.fit();
|
||||
const { cols, rows } = entry.term;
|
||||
entry.term.resize(cols, rows);
|
||||
if (entry.ws.readyState === WebSocket.OPEN) {
|
||||
entry.ws.send(JSON.stringify({ status: 1, cols, rows }));
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [activeSession, isFullscreen]);
|
||||
|
||||
// 激活会话切换后重新 fit
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
const entry = termsRef.current.get(activeSession);
|
||||
if (entry && entry.opened) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
entry.fit.fit();
|
||||
entry.term.focus();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [sessions, activeSession]);
|
||||
}, [activeSession, isFullscreen]);
|
||||
|
||||
// 快捷命令:直接发送到当前激活的 WebSocket
|
||||
const sendCommand = (cmd: string) => {
|
||||
if (!activeSession) return;
|
||||
const entry = termsRef.current.get(activeSession);
|
||||
if (entry && entry.ws.readyState === WebSocket.OPEN) {
|
||||
entry.ws.send(JSON.stringify({ status: 0, data: cmd + '\n' }));
|
||||
entry.term.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 注册容器并首次挂载终端
|
||||
const registerContainer = (sessionId: string, el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
containerRef.current.set(sessionId, el);
|
||||
const entry = termsRef.current.get(sessionId);
|
||||
if (entry && !entry.opened) {
|
||||
try {
|
||||
entry.term.open(el);
|
||||
entry.opened = true;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
entry.fit.fit();
|
||||
entry.term.focus();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('打开终端失败:', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
containerRef.current.delete(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const activeSessionData = sessions.find((s) => s.id === activeSession);
|
||||
const terminalHeight = isFullscreen ? 'calc(100vh - 140px)' : '500px';
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', isFullscreen && 'fixed inset-0 z-50 bg-slate-950 p-4')}>
|
||||
<div className={cn('space-y-4', isFullscreen && 'fixed inset-0 z-50 p-4')} style={isFullscreen ? { background: 'var(--dg-bg-primary)' } : undefined}>
|
||||
{/* Page Header */}
|
||||
{!isFullscreen && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">WebShell</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">远程服务器终端管理</p>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">WebShell</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">远程服务器终端管理</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建连接
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-slate-900 border-slate-800">
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">新建终端连接</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">新建终端连接</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
选择要连接的服务器
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">选择服务器</label>
|
||||
<label className="text-sm font-medium text-[var(--dg-text-secondary)]">选择服务器</label>
|
||||
<Select value={selectedServer} onValueChange={setSelectedServer}>
|
||||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue placeholder="选择服务器" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{runningServers.map((server) => (
|
||||
<SelectItem key={server.id} value={server.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{server.hostname}</span>
|
||||
<span className="text-slate-500">({server.ip})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
{runningServers.length === 0 ? (
|
||||
<SelectItem value="__none" disabled>暂无运行中的服务器</SelectItem>
|
||||
) : (
|
||||
runningServers.map((server) => (
|
||||
<SelectItem key={server.id} value={server.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{server.hostname}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">({server.ip})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -200,8 +318,8 @@ export function WebShell() {
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={createSession}
|
||||
disabled={!selectedServer}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
disabled={!selectedServer || selectedServer === '__none'}
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
连接
|
||||
@@ -214,8 +332,8 @@ export function WebShell() {
|
||||
)}
|
||||
|
||||
{/* Terminal Container */}
|
||||
<Card className="bg-slate-900 border-slate-800 flex-1">
|
||||
<CardHeader className="pb-2 border-b border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] flex-1">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{sessions.length > 0 ? (
|
||||
@@ -227,8 +345,8 @@ export function WebShell() {
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||
activeSession === session.id
|
||||
? 'bg-slate-800 text-slate-100'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]'
|
||||
: 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -238,43 +356,32 @@ export function WebShell() {
|
||||
)}
|
||||
/>
|
||||
<span className="max-w-[120px] truncate">{session.serverName}</span>
|
||||
<button
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeSession(session.id);
|
||||
}}
|
||||
className="ml-1 p-0.5 rounded hover:bg-slate-700"
|
||||
className="ml-1 p-0.5 rounded hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-slate-500">暂无连接</span>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">暂无连接</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{activeSessionData && (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] mr-2">
|
||||
{activeSessionData.serverIp}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
@@ -283,65 +390,27 @@ export function WebShell() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{activeSessionData ? (
|
||||
<div className="flex flex-col" style={{ height: isFullscreen ? 'calc(100vh - 140px)' : '500px' }}>
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 p-4 font-mono text-sm bg-slate-950 overflow-auto"
|
||||
>
|
||||
{activeSessionData.logs.map((log, index) => (
|
||||
<div key={index} className="whitespace-pre-wrap text-slate-300 leading-relaxed">
|
||||
{log.startsWith('[') && log.endsWith(']# ') ? (
|
||||
<span>
|
||||
<span className="text-emerald-400">{log.split('#')[0]}#</span>
|
||||
<span className="text-slate-100">{log.split('#')[1]}</span>
|
||||
</span>
|
||||
) : log.startsWith('total') || log.startsWith('drwx') || log.startsWith('-rw') ? (
|
||||
<span className="text-slate-400">{log}</span>
|
||||
) : log.startsWith('Filesystem') || log.startsWith('/dev') ? (
|
||||
<span className="text-slate-400">{log}</span>
|
||||
) : log.startsWith('USER') || log.startsWith('root') ? (
|
||||
<span className="text-slate-400">{log}</span>
|
||||
) : log.startsWith('Connecting') || log.startsWith('Connection') || log.startsWith('Last login') ? (
|
||||
<span className="text-slate-500">{log}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">{log}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Command Input */}
|
||||
<div className="flex items-center gap-2 p-3 border-t border-slate-800 bg-slate-900">
|
||||
<span className="text-emerald-400 font-mono text-sm whitespace-nowrap">
|
||||
[{activeSessionData.serverName} ~]#
|
||||
</span>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入命令..."
|
||||
className="flex-1 bg-transparent border-0 text-slate-200 font-mono text-sm placeholder:text-slate-600 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
autoFocus
|
||||
{sessions.length > 0 ? (
|
||||
<div className="relative bg-[#0a0e14]" style={{ height: terminalHeight }}>
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
ref={(el) => registerContainer(session.id, el)}
|
||||
className={cn(
|
||||
'absolute inset-0 p-1',
|
||||
activeSession === session.id ? 'block' : 'hidden'
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={executeCommand}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-slate-500"
|
||||
style={{ height: isFullscreen ? 'calc(100vh - 140px)' : '500px' }}
|
||||
className="flex flex-col items-center justify-center text-[var(--dg-text-secondary)]"
|
||||
style={{ height: terminalHeight }}
|
||||
>
|
||||
<Terminal className="h-16 w-16 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">暂无活动连接</p>
|
||||
<p className="text-sm mt-1">点击"新建连接"开始远程会话</p>
|
||||
<p className="text-sm mt-1">点击“新建连接”开始远程会话</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -349,9 +418,9 @@ export function WebShell() {
|
||||
|
||||
{/* Quick Commands */}
|
||||
{activeSessionData && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-slate-400">快捷命令</CardTitle>
|
||||
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">快捷命令</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -361,11 +430,8 @@ export function WebShell() {
|
||||
key={cmd}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setCommand(cmd);
|
||||
setTimeout(executeCommand, 100);
|
||||
}}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
onClick={() => sendCommand(cmd)}
|
||||
>
|
||||
{cmd}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user