feat: 修复仪表盘数据管线 — 补充应用发布种子数据 + 修复limit参数解析 + LDAP认证集成 + User类型完善

后端改进:
- 修复 dashboard/recent-alerts 的 limit 参数解析 (原代码错误地将数字解析为日期)
- 为 dashboard/recent-releases 添加 limit 参数解析支持
- 新增 generateAppReleases 种子数据生成函数 (10条应用发布记录)
- 修复 AlertEvent 的 rule_name/rule_id/severity 数据一致性
- 添加 go-ldap/ldap/v3 依赖并集成 LDAP 认证处理器
- 添加 seed.go 自动种子数据初始化 (admin用户+LDAP配置)
- User model 添加 AuthSource/LDAPConfig 字段

前端改进:
- 修复 User 类型定义以匹配后端 API (real_name/is_admin/auth_source等)
- 修复 Header.tsx 的 real_name 属性引用导致 TypeScript 编译错误
- 统一 API 服务层、暗色主题样式、组件联调优化

验证:
- go build ./... 编译通过
- npm run build 构建通过
- 仪表盘发布历史、告警消息等区域数据正常展示
- 健康检查 /health 正常
This commit is contained in:
attacker committed 2026-06-14 02:11:10 +08:00
1 parent 97c4207ff8
commit ff0b268984
39 files changed
+4283 -1878

No files matched your search

+131 -44
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -34,45 +34,106 @@ import {
Eye,
Edit,
GitBranch,
Loader2,
} from 'lucide-react';
import { approvalFlows, approvalInstances } from '@/mocks/data';
import type { ApprovalFlow as ApprovalFlowType, ApprovalInstance } from '@/types';
import { approvalApi } from '@/services/api';
function parseSteps(steps: any) {
if (Array.isArray(steps)) return steps;
if (typeof steps === 'string') {
try {
const parsed = JSON.parse(steps);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
}
export function ApprovalFlowManager() {
const [activeTab, setActiveTab] = useState('instances');
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedInstance, setSelectedInstance] = useState<ApprovalInstance | null>(null);
const [processes, setProcesses] = useState<any[]>([]);
const [templates, setTemplates] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedInstance, setSelectedInstance] = useState<any | null>(null);
const filteredInstances = approvalInstances.filter((instance) => {
const loadData = async () => {
try {
setLoading(true);
const [processesData, templatesData] = await Promise.all([
approvalApi.getProcesses(),
approvalApi.getTemplates(),
]);
setProcesses((processesData as any)?.list || (processesData as any) || []);
setTemplates(templatesData || []);
} catch (error) {
console.error('Failed to load approval data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredInstances = processes.filter((instance) => {
const matchesSearch =
instance.flowName.toLowerCase().includes(searchQuery.toLowerCase()) ||
instance.applicantName.toLowerCase().includes(searchQuery.toLowerCase());
(instance.title || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(instance.applicant_name || '').toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === 'all' || instance.status === statusFilter;
return matchesSearch && matchesStatus;
});
const getStatusBadge = (status: ApprovalInstance['status']) => {
const configs = {
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 },
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 },
};
const config = configs[status];
const config = configs[status] || configs.pending;
const Icon = config.icon;
return (
<Badge variant="outline" className={config.className}>
<config.icon className="h-3 w-3 mr-1" />
<Icon className="h-3 w-3 mr-1" />
{config.label}
</Badge>
);
};
const handleApprove = async (id: any) => {
try {
await approvalApi.approveProcess(id);
setSelectedInstance(null);
loadData();
} catch (error) {
console.error('Failed to approve process:', error);
}
};
const handleReject = async (id: any) => {
try {
await approvalApi.rejectProcess(id);
setSelectedInstance(null);
loadData();
} catch (error) {
console.error('Failed to reject process:', error);
}
};
// 模拟审批流程图
const renderFlowChart = (flow: ApprovalFlowType) => {
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="flex items-center gap-2 overflow-x-auto py-4">
{flow.nodes.map((node, index) => (
<div key={node.id} className="flex items-center">
{nodes.map((node: any, index: number) => (
<div key={node.id || index} className="flex items-center">
<div
className={cn(
'px-4 py-2 rounded-lg border min-w-[100px] text-center',
@@ -84,10 +145,12 @@ export function ApprovalFlowManager() {
>
<p className="text-sm font-medium">{node.name}</p>
{node.approvers && (
<p className="text-xs text-slate-500 mt-1">{node.approvers.join(', ')}</p>
<p className="text-xs text-slate-500 mt-1">
{Array.isArray(node.approvers) ? node.approvers.join(', ') : node.approvers}
</p>
)}
</div>
{index < flow.nodes.length - 1 && (
{index < nodes.length - 1 && (
<ArrowRight className="h-4 w-4 text-slate-600 mx-2" />
)}
</div>
@@ -124,7 +187,7 @@ export function ApprovalFlowManager() {
<div>
<p className="text-sm text-slate-500">待审批</p>
<p className="text-2xl font-bold text-amber-400">
{approvalInstances.filter((i) => i.status === 'pending').length}
{processes.filter((i) => i.status === 'pending').length}
</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
@@ -139,7 +202,7 @@ export function ApprovalFlowManager() {
<div>
<p className="text-sm text-slate-500">已通过</p>
<p className="text-2xl font-bold text-emerald-400">
{approvalInstances.filter((i) => i.status === 'approved').length}
{processes.filter((i) => i.status === 'approved').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
@@ -154,7 +217,7 @@ export function ApprovalFlowManager() {
<div>
<p className="text-sm text-slate-500">已拒绝</p>
<p className="text-2xl font-bold text-red-400">
{approvalInstances.filter((i) => i.status === 'rejected').length}
{processes.filter((i) => i.status === 'rejected').length}
</p>
</div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
@@ -168,7 +231,7 @@ export function ApprovalFlowManager() {
<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">{approvalFlows.length}</p>
<p className="text-2xl font-bold text-slate-100">{templates.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<GitBranch className="h-5 w-5" />
@@ -233,35 +296,42 @@ export function ApprovalFlowManager() {
</TableRow>
</TableHeader>
<TableBody>
{filteredInstances.map((instance) => (
{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">
<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.flowName}</span>
<span className="text-sm font-medium text-slate-200">{instance.title}</span>
</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" />
{instance.applicantName}
{instance.applicant_name || instance.applicant}
</div>
</TableCell>
<TableCell>{getStatusBadge(instance.status)}</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-700 text-slate-400">
{instance.currentNode}
{instance.current_step || '--'}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{new Date(instance.createdAt).toLocaleString()}
{instance.created_at ? new Date(instance.created_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{instance.completedAt
? new Date(instance.completedAt).toLocaleString()
{instance.completed_at
? new Date(instance.completed_at).toLocaleString()
: '--'}
</span>
</TableCell>
@@ -278,11 +348,21 @@ export function ApprovalFlowManager() {
</Button>
{instance.status === 'pending' && (
<>
<Button variant="ghost" size="sm" className="h-8 text-emerald-400 hover:text-emerald-400">
<Button
variant="ghost"
size="sm"
className="h-8 text-emerald-400 hover:text-emerald-400"
onClick={() => handleApprove(instance.id)}
>
<CheckCircle className="h-4 w-4 mr-1" />
通过
</Button>
<Button variant="ghost" size="sm" className="h-8 text-red-400 hover:text-red-400">
<Button
variant="ghost"
size="sm"
className="h-8 text-red-400 hover:text-red-400"
onClick={() => handleReject(instance.id)}
>
<XCircle className="h-4 w-4 mr-1" />
拒绝
</Button>
@@ -301,7 +381,7 @@ export function ApprovalFlowManager() {
<TabsContent value="flows" className="mt-4">
<div className="grid grid-cols-1 gap-4">
{approvalFlows.map((flow) => (
{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">
@@ -318,12 +398,12 @@ export function ApprovalFlowManager() {
<Badge
variant="outline"
className={
flow.enabled
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.enabled ? '已启用' : '已停用'}
{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" />
@@ -336,8 +416,8 @@ export function ApprovalFlowManager() {
{renderFlowChart(flow)}
</div>
<div className="flex items-center justify-between mt-4 text-sm text-slate-500">
<span>创建于 {new Date(flow.createdAt).toLocaleDateString()}</span>
<span>{flow.nodes.length} 个节点</span>
<span>创建于 {flow.created_at ? new Date(flow.created_at).toLocaleDateString() : '--'}</span>
<span>{parseSteps(flow.steps).length} 个节点</span>
</div>
</CardContent>
</Card>
@@ -364,11 +444,11 @@ export function ApprovalFlowManager() {
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<GitBranch className="h-5 w-5 text-blue-400" />
{selectedInstance?.flowName}
{selectedInstance?.title}
</DialogTitle>
<DialogDescription className="text-slate-500">
申请人: {selectedInstance?.applicantName} · 创建于{' '}
{selectedInstance && new Date(selectedInstance.createdAt).toLocaleString()}
申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant} · 创建于{' '}
{selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
</DialogDescription>
</DialogHeader>
@@ -388,10 +468,10 @@ export function ApprovalFlowManager() {
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-200">提交申请</span>
<span className="text-xs text-slate-500">
{selectedInstance && new Date(selectedInstance.createdAt).toLocaleString()}
{selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
</span>
</div>
<p className="text-sm text-slate-400 mt-1">申请人: {selectedInstance?.applicantName}</p>
<p className="text-sm text-slate-400 mt-1">申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant}</p>
</div>
</div>
@@ -418,10 +498,10 @@ 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?.currentNode}</span>
<span className="text-sm font-medium text-slate-200">{selectedInstance?.current_step || '审批节点'}</span>
<span className="text-xs text-slate-500">
{selectedInstance?.completedAt
? new Date(selectedInstance.completedAt).toLocaleString()
{selectedInstance?.completed_at
? new Date(selectedInstance.completed_at).toLocaleString()
: '进行中'}
</span>
</div>
@@ -441,11 +521,18 @@ export function ApprovalFlowManager() {
<DialogFooter className="gap-2">
{selectedInstance?.status === 'pending' && (
<>
<Button variant="outline" className="border-red-700 text-red-400 hover:bg-red-500/10">
<Button
variant="outline"
className="border-red-700 text-red-400 hover:bg-red-500/10"
onClick={() => handleReject(selectedInstance.id)}
>
<XCircle className="h-4 w-4 mr-2" />
拒绝
</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white">
<Button
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => handleApprove(selectedInstance.id)}
>
<CheckCircle className="h-4 w-4 mr-2" />
通过
</Button>
+86
View File
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useUserStore } from '@/store';
import { userApi } from '@/services/api';
export function Login() {
const navigate = useNavigate();
const login = useUserStore((s) => s.login);
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('123456');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await userApi.login(username, password);
login(result.user, result.token);
navigate('/', { replace: true });
} catch (err: any) {
setError(err.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>
</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}
</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
/>
</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
</p>
</div>
</div>
);
}
@@ -0,0 +1,10 @@
import { Navigate } from 'react-router-dom';
import { useUserStore } from '@/store';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useUserStore((s) => s.isAuthenticated);
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -41,30 +41,66 @@ import {
Edit,
Copy,
Download,
Loader2,
} from 'lucide-react';
import { cronJobs, scripts } from '@/mocks/data';
import type { CronJob, Script } from '@/types';
import { cronJobApi, scriptApi } from '@/services/api';
function parseParams(params: any) {
if (Array.isArray(params)) return params;
if (typeof params === 'string') {
try {
const parsed = JSON.parse(params);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
}
export function AutomationManager() {
const [activeTab, setActiveTab] = useState('cronjobs');
const [searchQuery, setSearchQuery] = useState('');
const [selectedScript, setSelectedScript] = useState<Script | null>(null);
const [cronJobs, setCronJobs] = useState<any[]>([]);
const [scripts, setScripts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedScript, setSelectedScript] = useState<any | null>(null);
const [isExecuteDialogOpen, setIsExecuteDialogOpen] = useState(false);
const loadData = async () => {
try {
setLoading(true);
const [jobsData, scriptsData] = await Promise.all([
cronJobApi.getJobs(),
scriptApi.getScripts(),
]);
setCronJobs(jobsData.list || []);
setScripts(scriptsData.list || []);
} catch (error) {
console.error('Failed to load automation data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredJobs = cronJobs.filter(
(job) =>
job.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
job.description.toLowerCase().includes(searchQuery.toLowerCase())
(job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(job.description || '').toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredScripts = scripts.filter(
(script) =>
script.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
script.description.toLowerCase().includes(searchQuery.toLowerCase())
(script.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(script.description || '').toLowerCase().includes(searchQuery.toLowerCase())
);
const getJobStatusBadge = (status: CronJob['status']) => {
return status === 'running' ? (
const getJobStatusBadge = (status: any) => {
return status === 1 ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<Play className="h-3 w-3 mr-1" />
运行中
@@ -77,19 +113,28 @@ export function AutomationManager() {
);
};
const getLanguageBadge = (language: Script['language']) => {
const colors = {
const getLanguageBadge = (language: any) => {
const colors: Record<string, string> = {
bash: 'border-green-500/30 text-green-400 bg-green-500/10',
python: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
powershell: 'border-cyan-500/30 text-cyan-400 bg-cyan-500/10',
};
return (
<Badge variant="outline" className={colors[language]}>
{language.toUpperCase()}
<Badge variant="outline" className={colors[language] || colors.bash}>
{(language || '').toUpperCase()}
</Badge>
);
};
const handleExecuteScript = async (id: any) => {
try {
await scriptApi.executeScript(id);
setIsExecuteDialogOpen(false);
} catch (error) {
console.error('Failed to execute script:', error);
}
};
return (
<div className="space-y-6">
{/* Page Header */}
@@ -131,7 +176,7 @@ export function AutomationManager() {
<div>
<p className="text-sm text-slate-500">运行中</p>
<p className="text-2xl font-bold text-emerald-400">
{cronJobs.filter((j) => j.status === 'running').length}
{cronJobs.filter((j) => j.status === 1).length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
@@ -157,8 +202,10 @@ export function AutomationManager() {
<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-blue-400">156</p>
<p className="text-sm text-slate-500">累计执行</p>
<p className="text-2xl font-bold text-blue-400">
{cronJobs.reduce((sum, j) => sum + (j.success_count || 0) + (j.fail_count || 0), 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
<RotateCw className="h-5 w-5" />
@@ -181,6 +228,12 @@ export function AutomationManager() {
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : null}
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
@@ -234,24 +287,29 @@ export function AutomationManager() {
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : '--'}
{job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{job.nextRunAt ? new Date(job.nextRunAt).toLocaleString() : '--'}
{job.next_run ? new Date(job.next_run).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<div className="text-sm">
<span className="text-emerald-400">{job.successCount}</span>
<span className="text-emerald-400">{job.success_count || 0}</span>
<span className="text-slate-500 mx-1">/</span>
<span className="text-red-400">{job.failCount}</span>
<span className="text-red-400">{job.fail_count || 0}</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
onClick={() => cronJobApi.executeJob(job.id)}
>
<Play className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
@@ -290,11 +348,11 @@ export function AutomationManager() {
</CardHeader>
<CardContent>
<div className="bg-slate-950 rounded-lg p-3 font-mono text-xs text-slate-400 overflow-x-auto">
<pre className="whitespace-pre-wrap">{script.content.slice(0, 200)}...</pre>
<pre className="whitespace-pre-wrap">{(script.content || '').slice(0, 200)}...</pre>
</div>
<div className="flex items-center justify-between mt-4">
<div className="flex gap-2">
{script.params.map((param) => (
{parseParams(script.params).map((param: any) => (
<Badge key={param.name} variant="outline" className="border-slate-700 text-slate-400 text-xs">
{param.name}
{param.required && <span className="text-red-400 ml-0.5">*</span>}
@@ -367,7 +425,7 @@ export function AutomationManager() {
<div className="flex items-center gap-2">
{selectedScript && getLanguageBadge(selectedScript.language)}
<span className="text-sm text-slate-500">
更新于 {selectedScript && new Date(selectedScript.updatedAt).toLocaleDateString()}
更新于 {selectedScript && selectedScript.updated_at ? new Date(selectedScript.updated_at).toLocaleDateString() : '--'}
</span>
</div>
@@ -375,11 +433,11 @@ export function AutomationManager() {
<pre className="whitespace-pre">{selectedScript?.content}</pre>
</div>
{selectedScript && selectedScript.params.length > 0 && (
{selectedScript && parseParams(selectedScript.params).length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">参数</h4>
<div className="space-y-2">
{selectedScript.params.map((param) => (
{parseParams(selectedScript.params).map((param: any) => (
<div key={param.name} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
<code className="text-sm text-slate-300">{param.name}</code>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
@@ -444,7 +502,7 @@ export function AutomationManager() {
</Select>
</div>
{selectedScript?.params.map((param) => (
{selectedScript && parseParams(selectedScript.params).map((param: any) => (
<div key={param.name}>
<label className="text-sm font-medium text-slate-300">
{param.name}
@@ -463,7 +521,7 @@ export function AutomationManager() {
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsExecuteDialogOpen(false)}>
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => selectedScript && handleExecuteScript(selectedScript.id)}>
<Play className="h-4 w-4 mr-2" />
开始执行
</Button>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -49,123 +49,46 @@ import {
Eye,
Copy,
} from 'lucide-react';
import { cronJobs } from '@/mocks/data';
interface CronJob {
id: string;
name: string;
description: string;
schedule: string;
command: string;
status: 'enabled' | 'disabled';
lastRun: string;
lastStatus: 'success' | 'failed' | 'running' | 'never';
nextRun: string;
targetServers: string[];
createdAt: string;
createdBy: string;
}
const mockCronJobs: CronJob[] = [
{
id: 'cron-001',
name: '数据库备份',
description: '每日凌晨2点备份数据库',
schedule: '0 2 * * *',
command: '/scripts/backup-db.sh --type=full',
status: 'enabled',
lastRun: '2025-04-14 02:00:00',
lastStatus: 'success',
nextRun: '2025-04-15 02:00:00',
targetServers: ['db-server-01', 'db-server-02'],
createdAt: '2025-01-15',
createdBy: 'admin',
},
{
id: 'cron-002',
name: '日志清理',
description: '每周日凌晨3点清理过期日志',
schedule: '0 3 * * 0',
command: '/scripts/clean-logs.sh --days=30',
status: 'enabled',
lastRun: '2025-04-13 03:00:00',
lastStatus: 'success',
nextRun: '2025-04-20 03:00:00',
targetServers: ['all'],
createdAt: '2025-01-20',
createdBy: 'admin',
},
{
id: 'cron-003',
name: '健康检查',
description: '每5分钟执行健康检查',
schedule: '*/5 * * * *',
command: '/scripts/health-check.sh',
status: 'enabled',
lastRun: '2025-04-14 11:55:00',
lastStatus: 'running',
nextRun: '2025-04-14 12:00:00',
targetServers: ['web-server-01', 'web-server-02', 'app-server-01'],
createdAt: '2025-02-01',
createdBy: 'ops01',
},
{
id: 'cron-004',
name: '数据同步',
description: '每小时同步数据到备份服务器',
schedule: '0 * * * *',
command: '/scripts/sync-data.sh --target=backup',
status: 'disabled',
lastRun: '2025-04-14 10:00:00',
lastStatus: 'failed',
nextRun: '-',
targetServers: ['app-server-01'],
createdAt: '2025-02-15',
createdBy: 'ops02',
},
{
id: 'cron-005',
name: '生成报表',
description: '每周一上午9点生成业务报表',
schedule: '0 9 * * 1',
command: '/scripts/generate-report.sh --type=weekly',
status: 'enabled',
lastRun: '2025-04-07 09:00:00',
lastStatus: 'success',
nextRun: '2025-04-21 09:00:00',
targetServers: ['report-server'],
createdAt: '2025-03-01',
createdBy: 'admin',
},
];
const executionLogs = [
{ id: 1, jobName: '数据库备份', startTime: '2025-04-14 02:00:00', endTime: '2025-04-14 02:15:30', duration: '15m 30s', status: 'success', output: '备份完成: 15.2GB' },
{ id: 2, jobName: '健康检查', startTime: '2025-04-14 11:55:00', endTime: '2025-04-14 11:55:05', duration: '5s', status: 'success', output: '所有服务正常' },
{ id: 3, jobName: '数据同步', startTime: '2025-04-14 10:00:00', endTime: '2025-04-14 10:05:23', duration: '5m 23s', status: 'failed', output: '连接超时' },
{ id: 4, jobName: '日志清理', startTime: '2025-04-13 03:00:00', endTime: '2025-04-13 03:02:15', duration: '2m 15s', status: 'success', output: '清理完成: 2.3GB' },
];
import { cronJobApi } from '@/services/api';
export function CronJobManager() {
const [searchQuery, setSearchQuery] = useState('');
const [jobs, setJobs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isLogDialogOpen, setIsLogDialogOpen] = useState(false);
const [selectedJob, setSelectedJob] = useState<CronJob | null>(null);
const [selectedJob, setSelectedJob] = useState<any | null>(null);
const [newJob, setNewJob] = useState({
name: '',
description: '',
schedule: '',
cron: '',
command: '',
targetServers: '',
});
const filteredJobs = mockCronJobs.filter(
const loadData = async () => {
try {
setLoading(true);
const data = await cronJobApi.getJobs();
setJobs(data.list || []);
} catch (error) {
console.error('Failed to load cron jobs:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredJobs = jobs.filter(
(job) =>
job.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
job.description.toLowerCase().includes(searchQuery.toLowerCase())
(job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(job.description || '').toLowerCase().includes(searchQuery.toLowerCase())
);
const getStatusBadge = (status: CronJob['lastStatus']) => {
const getStatusBadge = (status: any) => {
switch (status) {
case 'success':
return (
@@ -208,9 +131,47 @@ export function CronJobManager() {
return patterns[schedule] || schedule;
};
const handleCreateJob = () => {
setIsCreateDialogOpen(false);
setNewJob({ name: '', description: '', schedule: '', command: '', targetServers: '' });
const handleCreateJob = async () => {
try {
await cronJobApi.createJob({
name: newJob.name,
description: newJob.description,
cron: newJob.cron,
command: newJob.command,
});
setIsCreateDialogOpen(false);
setNewJob({ name: '', description: '', cron: '', command: '', targetServers: '' });
loadData();
} catch (error) {
console.error('Failed to create cron job:', error);
}
};
const handleToggleJob = async (id: any) => {
try {
await cronJobApi.toggleJob(id);
loadData();
} catch (error) {
console.error('Failed to toggle job:', error);
}
};
const handleExecuteJob = async (id: any) => {
try {
await cronJobApi.executeJob(id);
loadData();
} catch (error) {
console.error('Failed to execute job:', error);
}
};
const handleDeleteJob = async (id: any) => {
try {
await cronJobApi.deleteJob(id);
loadData();
} catch (error) {
console.error('Failed to delete job:', error);
}
};
return (
@@ -238,30 +199,28 @@ export function CronJobManager() {
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">任务总数</p>
<p className="text-2xl font-bold text-slate-100">{mockCronJobs.length}</p>
<p className="text-2xl font-bold text-slate-100">{jobs.length}</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">已启用</p>
<p className="text-2xl font-bold text-emerald-400">
{mockCronJobs.filter((j) => j.status === 'enabled').length}
{jobs.filter((j) => j.status === 1).length}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">执行中</p>
<p className="text-2xl font-bold text-blue-400">
{mockCronJobs.filter((j) => j.lastStatus === 'running').length}
</p>
<p className="text-2xl font-bold text-blue-400">--</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">今日失败</p>
<p className="text-slate-400 text-sm">累计失败</p>
<p className="text-2xl font-bold text-red-400">
{mockCronJobs.filter((j) => j.lastStatus === 'failed').length}
{jobs.reduce((sum, j) => sum + (j.fail_count || 0), 0)}
</p>
</CardContent>
</Card>
@@ -280,6 +239,12 @@ export function CronJobManager() {
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : null}
{/* Job List */}
<div className="space-y-3">
{filteredJobs.map((job) => (
@@ -292,20 +257,20 @@ export function CronJobManager() {
<Badge
variant="outline"
className={
job.status === 'enabled'
job.status === 1
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}
>
{job.status === 'enabled' ? '已启用' : '已禁用'}
{job.status === 1 ? '已启用' : '已禁用'}
</Badge>
{getStatusBadge(job.lastStatus)}
{getStatusBadge(job.last_status)}
</div>
<p className="text-sm text-slate-400 mt-1">{job.description}</p>
<div className="flex items-center gap-4 mt-2 text-sm">
<span className="flex items-center gap-1 text-slate-400">
<Clock className="h-3 w-3" />
{parseCron(job.schedule)}
{parseCron(job.cron)}
</span>
<span className="flex items-center gap-1 text-slate-400">
<Terminal className="h-3 w-3" />
@@ -313,17 +278,30 @@ export function CronJobManager() {
</span>
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>上次执行: {job.lastRun}</span>
<span>下次执行: {job.nextRun}</span>
<span>目标: {job.targetServers.join(', ')}</span>
<span>上次执行: {job.last_run ? new Date(job.last_run).toLocaleString() : '--'}</span>
<span>下次执行: {job.next_run ? new Date(job.next_run).toLocaleString() : '--'}</span>
<span>成功: {job.success_count || 0} / 失败: {job.fail_count || 0}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20">
<Button
variant="outline"
size="sm"
className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20"
onClick={() => handleExecuteJob(job.id)}
>
<Play className="h-4 w-4 mr-1" />
立即执行
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
onClick={() => handleToggleJob(job.id)}
>
{job.status === 1 ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
@@ -335,6 +313,7 @@ export function CronJobManager() {
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDeleteJob(job.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -375,12 +354,12 @@ export function CronJobManager() {
<Label className="text-slate-300">Cron 表达式</Label>
<div className="flex gap-2">
<Input
value={newJob.schedule}
onChange={(e) => setNewJob({ ...newJob, schedule: e.target.value })}
value={newJob.cron}
onChange={(e) => setNewJob({ ...newJob, cron: e.target.value })}
placeholder="0 2 * * *"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
<Select onValueChange={(v) => setNewJob({ ...newJob, schedule: v })}>
<Select onValueChange={(v) => setNewJob({ ...newJob, cron: v })}>
<SelectTrigger className="w-48 bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue placeholder="快速选择" />
</SelectTrigger>
@@ -433,39 +412,45 @@ export function CronJobManager() {
<DialogDescription className="text-slate-500">定时任务执行记录</DialogDescription>
</DialogHeader>
<div className="mt-4">
<Table>
<TableHeader>
<TableRow className="border-slate-700">
<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>
</TableRow>
</TableHeader>
<TableBody>
{executionLogs.map((log) => (
<TableRow key={log.id} className="border-slate-700">
<TableCell className="text-slate-200">{log.jobName}</TableCell>
<TableCell className="text-slate-400">{log.startTime}</TableCell>
<TableCell className="text-slate-400">{log.duration}</TableCell>
<TableCell>
<Badge
variant="outline"
className={
log.status === 'success'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-red-500/30 text-red-400 bg-red-500/10'
}
>
{log.status === 'success' ? '成功' : '失败'}
</Badge>
</TableCell>
<TableCell className="text-slate-400 text-sm">{log.output}</TableCell>
{jobs.length === 0 ? (
<div className="text-center text-slate-500 py-8">暂无执行记录</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-700">
<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>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{jobs.map((job) => (
<TableRow key={job.id} className="border-slate-700">
<TableCell className="text-slate-200">{job.name}</TableCell>
<TableCell className="text-slate-400">
{job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
</TableCell>
<TableCell className="text-emerald-400">{job.success_count || 0}</TableCell>
<TableCell className="text-red-400">{job.fail_count || 0}</TableCell>
<TableCell>
<Badge
variant="outline"
className={
(job.fail_count || 0) > 0
? 'border-red-500/30 text-red-400 bg-red-500/10'
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
}
>
{(job.fail_count || 0) > 0 ? '有失败' : '正常'}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
@@ -1,32 +1,20 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Plus,
Search,
Edit2,
Trash2,
import {
Plus,
Search,
Edit2,
Trash2,
Key,
Cloud,
CheckCircle2,
XCircle,
RefreshCw
} from 'lucide-react';
import { cloudAccounts } from '@/mocks/data';
interface CloudAccount {
id: string;
name: string;
provider: string;
accessKey: string;
status: 'active' | 'inactive' | 'error';
regionCount: number;
serverCount: number;
createdAt: string;
lastSync: string;
}
import { cloudApi } from '@/services/api';
const providerIcons: Record<string, string> = {
aws: 'AWS',
@@ -45,21 +33,50 @@ const providerColors: Record<string, string> = {
};
export function CloudAccountManager() {
const [accounts, setAccounts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedProvider, setSelectedProvider] = useState<string>('all');
const filteredAccounts = cloudAccounts.filter(account => {
const matchesSearch = account.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
account.accessKey.toLowerCase().includes(searchQuery.toLowerCase());
const loadData = async () => {
try {
setLoading(true);
const res: any = await cloudApi.getAccounts();
setAccounts(res?.list || []);
} catch (e) {
console.error('加载云账号列表失败:', e);
setAccounts([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleDelete = async (id: string) => {
try {
await cloudApi.deleteAccount(id);
loadData();
} catch (e) {
console.error('删除云账号失败:', e);
}
};
const filteredAccounts = accounts.filter(account => {
const accessKey = account.access_key || account.accessKey || '';
const matchesSearch = (account.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
accessKey.toLowerCase().includes(searchQuery.toLowerCase());
const matchesProvider = selectedProvider === 'all' || account.provider === selectedProvider;
return matchesSearch && matchesProvider;
});
const stats = {
total: cloudAccounts.length,
active: cloudAccounts.filter(a => a.status === 'active').length,
inactive: cloudAccounts.filter(a => a.status === 'inactive').length,
error: cloudAccounts.filter(a => a.status === 'error').length,
total: accounts.length,
active: accounts.filter(a => a.status === 'active').length,
inactive: accounts.filter(a => a.status === 'inactive').length,
error: accounts.filter(a => a.status === 'error').length,
};
return (
@@ -70,10 +87,16 @@ export function CloudAccountManager() {
<h1 className="text-2xl font-bold text-slate-100">云账号管理</h1>
<p className="text-slate-400 mt-1">管理多云平台的访问凭证和账号信息</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
添加账号
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadData} className="border-slate-700 text-slate-300">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
添加账号
</Button>
</div>
</div>
{/* Stats */}
@@ -158,74 +181,102 @@ export function CloudAccountManager() {
</div>
{/* Account List */}
<div className="grid grid-cols-2 gap-4">
{filteredAccounts.map((account) => (
<Card key={account.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg ${providerColors[account.provider]} flex items-center justify-center text-white font-bold text-xs`}>
{providerIcons[account.provider]?.slice(0, 2) || account.provider.slice(0, 2).toUpperCase()}
</div>
<div>
<h3 className="font-medium text-slate-100">{account.name}</h3>
<p className="text-sm text-slate-400 mt-0.5">{providerIcons[account.provider]}</p>
<div className="flex items-center gap-3 mt-2">
<Badge variant="secondary" className="bg-slate-700 text-slate-300">
<Key className="h-3 w-3 mr-1" />
{account.accessKey.slice(0, 8)}...
</Badge>
{account.status === 'active' && (
<Badge className="bg-green-500/20 text-green-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
正常
</Badge>
)}
{account.status === 'inactive' && (
<Badge className="bg-slate-500/20 text-slate-400 border-0">
<XCircle className="h-3 w-3 mr-1" />
已停用
</Badge>
)}
{account.status === 'error' && (
<Badge className="bg-red-500/20 text-red-400 border-0">
<Key className="h-3 w-3 mr-1" />
异常
</Badge>
)}
{loading ? (
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-12 text-center text-slate-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
加载中...
</CardContent>
</Card>
) : filteredAccounts.length === 0 ? (
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-12 text-center text-slate-500">暂无云账号数据</CardContent>
</Card>
) : (
<div className="grid grid-cols-2 gap-4">
{filteredAccounts.map((account) => {
const accessKey = account.access_key || account.accessKey || '';
const regionCount = account.region_count ?? account.regionCount ?? 0;
const serverCount = account.server_count ?? account.serverCount ?? 0;
const lastSync = account.last_sync || account.lastSync || account.created_at || '-';
return (
<Card key={account.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg ${providerColors[account.provider] || 'bg-blue-500'} flex items-center justify-center text-white font-bold text-xs`}>
{providerIcons[account.provider]?.slice(0, 2) || (account.provider || '').slice(0, 2).toUpperCase()}
</div>
<div>
<h3 className="font-medium text-slate-100">{account.name}</h3>
<p className="text-sm text-slate-400 mt-0.5">{providerIcons[account.provider]}</p>
<div className="flex items-center gap-3 mt-2">
{accessKey && (
<Badge variant="secondary" className="bg-slate-700 text-slate-300">
<Key className="h-3 w-3 mr-1" />
{accessKey.slice(0, 8)}...
</Badge>
)}
{account.status === 'active' && (
<Badge className="bg-green-500/20 text-green-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
正常
</Badge>
)}
{account.status === 'inactive' && (
<Badge className="bg-slate-500/20 text-slate-400 border-0">
<XCircle className="h-3 w-3 mr-1" />
已停用
</Badge>
)}
{account.status === 'error' && (
<Badge className="bg-red-500/20 text-red-400 border-0">
<Key className="h-3 w-3 mr-1" />
异常
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<RefreshCw className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDelete(account.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<RefreshCw className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
<div>
<p className="text-xs text-slate-400">区域数</p>
<p className="text-lg font-medium text-slate-100">{account.regionCount}</p>
</div>
<div>
<p className="text-xs text-slate-400">服务器数</p>
<p className="text-lg font-medium text-slate-100">{account.serverCount}</p>
</div>
<div>
<p className="text-xs text-slate-400">最后同步</p>
<p className="text-sm text-slate-300">{account.lastSync}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
<div>
<p className="text-xs text-slate-400">区域数</p>
<p className="text-lg font-medium text-slate-100">{regionCount}</p>
</div>
<div>
<p className="text-xs text-slate-400">服务器数</p>
<p className="text-lg font-medium text-slate-100">{serverCount}</p>
</div>
<div>
<p className="text-xs text-slate-400">最后同步</p>
<p className="text-sm text-slate-300">
{lastSync !== '-' ? new Date(lastSync).toLocaleString() : '-'}
</p>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}
+142 -94
View File
@@ -1,40 +1,57 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Plus,
Search,
Edit2,
Trash2,
import {
Plus,
Search,
Edit2,
Trash2,
Globe,
CheckCircle2,
XCircle,
Clock,
ExternalLink
ExternalLink,
RefreshCw
} from 'lucide-react';
import { domains } from '@/mocks/data';
interface Domain {
id: string;
name: string;
registrar: string;
status: 'active' | 'expired' | 'pending';
expireDate: string;
dnsProvider: string;
recordCount: number;
sslEnabled: boolean;
sslExpireDate?: string;
}
import { cloudApi } from '@/services/api';
export function DomainManager() {
const [domains, setDomains] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedStatus, setSelectedStatus] = useState<string>('all');
const loadData = async () => {
try {
setLoading(true);
const res: any = await cloudApi.getDomains();
setDomains(res?.list || []);
} catch (e) {
console.error('加载域名列表失败:', e);
setDomains([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleDelete = async (id: string) => {
try {
await cloudApi.deleteDomain(id);
loadData();
} catch (e) {
console.error('删除域名失败:', e);
}
};
const filteredDomains = domains.filter(domain => {
const matchesSearch = domain.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
domain.registrar.toLowerCase().includes(searchQuery.toLowerCase());
const matchesSearch = (domain.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(domain.registrar || '').toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = selectedStatus === 'all' || domain.status === selectedStatus;
return matchesSearch && matchesStatus;
});
@@ -44,10 +61,11 @@ export function DomainManager() {
active: domains.filter(d => d.status === 'active').length,
expired: domains.filter(d => d.status === 'expired').length,
pending: domains.filter(d => d.status === 'pending').length,
sslEnabled: domains.filter(d => d.sslEnabled).length,
sslEnabled: domains.filter(d => d.ssl_enabled || d.sslEnabled).length,
};
const getDaysUntilExpiry = (expireDate: string) => {
if (!expireDate) return Infinity;
const days = Math.ceil((new Date(expireDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return days;
};
@@ -60,10 +78,16 @@ export function DomainManager() {
<h1 className="text-2xl font-bold text-slate-100">域名管理</h1>
<p className="text-slate-400 mt-1">管理域名注册信息和DNS解析记录</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
添加域名
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadData} className="border-slate-700 text-slate-300">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
添加域名
</Button>
</div>
</div>
{/* Stats */}
@@ -159,77 +183,101 @@ export function DomainManager() {
</div>
{/* Domain List */}
<div className="space-y-3">
{filteredDomains.map((domain) => {
const daysUntilExpiry = getDaysUntilExpiry(domain.expireDate);
return (
<Card key={domain.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center">
<Globe className="h-5 w-5 text-white" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium text-slate-100 text-lg">{domain.name}</h3>
{domain.status === 'active' && (
<Badge className="bg-green-500/20 text-green-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
正常
</Badge>
)}
{domain.status === 'expired' && (
<Badge className="bg-red-500/20 text-red-400 border-0">
<XCircle className="h-3 w-3 mr-1" />
已过期
</Badge>
)}
{domain.status === 'pending' && (
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">
<Clock className="h-3 w-3 mr-1" />
待审核
</Badge>
)}
{domain.sslEnabled && (
<Badge className="bg-cyan-500/20 text-cyan-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
SSL
</Badge>
)}
{loading ? (
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-12 text-center text-slate-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
加载中...
</CardContent>
</Card>
) : filteredDomains.length === 0 ? (
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-12 text-center text-slate-500">暂无域名数据</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredDomains.map((domain) => {
const sslEnabled = domain.ssl_enabled || domain.sslEnabled;
const expireDate = domain.expire_date || domain.expireDate || '';
const sslExpireDate = domain.ssl_expire_date || domain.sslExpireDate || '';
const daysUntilExpiry = getDaysUntilExpiry(expireDate);
return (
<Card key={domain.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center">
<Globe className="h-5 w-5 text-white" />
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-slate-400">
<span>注册商: {domain.registrar}</span>
<span>DNS: {domain.dnsProvider}</span>
<span>解析记录: {domain.recordCount}条</span>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium text-slate-100 text-lg">{domain.name}</h3>
{domain.status === 'active' && (
<Badge className="bg-green-500/20 text-green-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
正常
</Badge>
)}
{domain.status === 'expired' && (
<Badge className="bg-red-500/20 text-red-400 border-0">
<XCircle className="h-3 w-3 mr-1" />
已过期
</Badge>
)}
{domain.status === 'pending' && (
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">
<Clock className="h-3 w-3 mr-1" />
待审核
</Badge>
)}
{sslEnabled && (
<Badge className="bg-cyan-500/20 text-cyan-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
SSL
</Badge>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-slate-400">
<span>注册商: {domain.registrar || '-'}</span>
<span>DNS: {domain.dns_provider || domain.dnsProvider || '-'}</span>
<span>解析记录: {domain.record_count ?? domain.recordCount ?? 0}条</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-slate-400">到期时间</p>
<p className={`text-sm font-medium ${daysUntilExpiry < 30 ? 'text-red-400' : 'text-slate-200'}`}>
{expireDate || '-'}{expireDate && ` (${daysUntilExpiry}天)`}
{sslEnabled && sslExpireDate && (
<span className="text-slate-500"> · SSL: {sslExpireDate}</span>
)}
</p>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDelete(domain.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-slate-400">到期时间</p>
<p className={`text-sm font-medium ${daysUntilExpiry < 30 ? 'text-red-400' : 'text-slate-200'}`}>
{domain.expireDate} ({daysUntilExpiry}天)
</p>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}
+243 -213
View File
@@ -3,7 +3,6 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
Server,
Cloud,
@@ -37,8 +36,37 @@ import {
BarChart,
Bar,
} from 'recharts';
import { dashboardData, servers, k8sClusters, alertEvents, appReleases, domains, generateMetricsData } from '@/mocks/data';
import type { DashboardData } from '@/types';
import { dashboardStatsApi, serverApi, k8sApi } from '@/services/api';
// 云平台颜色映射
const CLOUD_COLORS: Record<string, string> = {
'阿里云': '#ff6a00',
aliyun: '#ff6a00',
AWS: '#ff9900',
aws: '#ff9900',
'腾讯云': '#006eff',
tencent: '#006eff',
'华为云': '#ff0000',
huawei: '#ff0000',
Azure: '#0078d4',
azure: '#0078d4',
};
const FALLBACK_CLOUD_COLORS = ['#ff6a00', '#ff9900', '#006eff', '#ff0000', '#0078d4', '#10b981', '#a855f7', '#ec4899'];
// 根据平均值生成24小时趋势数据(无实时指标时使用)
function generateMetricsData(avgCpu: number, avgMemory: number, avgDisk: number, points = 24) {
const data = [];
for (let i = points - 1; i >= 0; i--) {
const d = new Date(Date.now() - i * 60 * 60 * 1000);
data.push({
time: `${d.getHours().toString().padStart(2, '0')}:00`,
cpu: Number(Math.max(0, avgCpu + (Math.random() - 0.5) * 15).toFixed(1)),
memory: Number(Math.max(0, avgMemory + (Math.random() - 0.5) * 10).toFixed(1)),
disk: Number(Math.max(0, avgDisk + (Math.random() - 0.5) * 5).toFixed(1)),
});
}
return data;
}
// 统计卡片组件
function StatCard({
@@ -109,24 +137,18 @@ function StatCard({
}
// 成本费用卡片
function CostOverviewCard() {
const costData = [
{ name: '阿里云', value: 28500, color: '#ff6a00' },
{ name: 'AWS', value: 42000, color: '#ff9900' },
{ name: '腾讯云', value: 15800, color: '#006eff' },
{ name: '华为云', value: 8900, color: '#ff0000' },
{ name: 'Azure', value: 12500, color: '#0078d4' },
];
function CostOverviewCard({ costStats }: { costStats: any }) {
const cloudCosts: any[] = costStats?.cloud_costs ?? [];
const monthTrend: any[] = costStats?.month_trend ?? [];
const monthlyData = [
{ month: '1月', cost: 85000 },
{ month: '2月', cost: 92000 },
{ month: '3月', cost: 88000 },
{ month: '4月', cost: 95000 },
{ month: '5月', cost: 107700 },
];
const totalCost = costData.reduce((sum, item) => sum + item.value, 0);
const costData = cloudCosts.map((c, i) => ({
name: c.cloud,
value: c.cost,
color: CLOUD_COLORS[c.cloud] || FALLBACK_CLOUD_COLORS[i % FALLBACK_CLOUD_COLORS.length],
}));
const monthlyData = monthTrend.map((m) => ({ month: m.month, cost: m.cost }));
const totalCost =
costStats?.total_cost ?? costData.reduce((sum, item) => sum + item.value, 0);
return (
<Card className="bg-slate-900 border-slate-800">
@@ -137,7 +159,7 @@ function CostOverviewCard() {
<CardDescription className="text-slate-500">本月各云平台费用支出</CardDescription>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-slate-100">¥{totalCost.toLocaleString()}</p>
<p className="text-2xl font-bold text-slate-100">¥{Number(totalCost).toLocaleString()}</p>
<p className="text-xs text-emerald-400">+12.5% 较上月</p>
</div>
</div>
@@ -205,7 +227,7 @@ function CostOverviewCard() {
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} />
<span className="text-sm text-slate-300">{item.name}</span>
</div>
<span className="text-sm font-medium text-slate-100">¥{item.value.toLocaleString()}</span>
<span className="text-sm font-medium text-slate-100">¥{Number(item.value).toLocaleString()}</span>
</div>
))}
</div>
@@ -215,8 +237,8 @@ function CostOverviewCard() {
}
// 告警消息卡片
function AlertMessagesCard() {
const recentAlerts = alertEvents.filter(a => a.status === 'firing').slice(0, 8);
function AlertMessagesCard({ alerts }: { alerts: any[] }) {
const recentAlerts = alerts.filter((a) => a.status === 'firing').slice(0, 8);
return (
<Card className="bg-slate-900 border-slate-800">
@@ -246,7 +268,7 @@ function AlertMessagesCard() {
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-200 truncate">{alert.ruleName}</span>
<span className="text-sm font-medium text-slate-200 truncate">{alert.rule_name}</span>
<Badge
variant="outline"
className={`text-xs ${
@@ -263,7 +285,9 @@ function AlertMessagesCard() {
<p className="text-xs text-slate-500 mt-1">{alert.target}</p>
<p className="text-xs text-slate-500">{alert.message}</p>
</div>
<span className="text-xs text-slate-500 whitespace-nowrap">{alert.startedAt}</span>
<span className="text-xs text-slate-500 whitespace-nowrap">
{alert.created_at ? new Date(alert.created_at).toLocaleString() : ''}
</span>
</div>
))}
</div>
@@ -274,18 +298,53 @@ function AlertMessagesCard() {
}
// Top5 资源使用卡片
function TopResourceUsageCard() {
const topCpuServers = [...servers]
.sort((a, b) => b.cpuUsage - a.cpuUsage)
.slice(0, 5);
function TopResourceUsageCard({ topUsage }: { topUsage: any }) {
const topCpuServers = (topUsage?.cpu ?? []).slice(0, 5);
const topMemoryServers = (topUsage?.memory ?? []).slice(0, 5);
const topDiskServers = (topUsage?.disk ?? []).slice(0, 5);
const topMemoryServers = [...servers]
.sort((a, b) => b.memoryUsage - a.memoryUsage)
.slice(0, 5);
const topDiskServers = [...servers]
.sort((a, b) => b.diskUsage - a.diskUsage)
.slice(0, 5);
const renderList = (
items: any[],
color: string,
BarColor: string,
icon: React.ReactNode,
label: string
) => (
<div>
<div className="flex items-center gap-2 mb-3">
{icon}
<span className="text-sm font-medium text-slate-300">{label}</span>
</div>
<div className="space-y-2">
{items.map((item, index) => {
const usage = Number(item.usage ?? 0);
return (
<div key={item.id ?? index} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{item.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
usage > 80 ? 'bg-red-400' : usage > 60 ? 'bg-amber-400' : BarColor
}`}
style={{ width: `${usage}%` }}
/>
</div>
<span className={`text-xs ${
usage > 80 ? 'text-red-400' : usage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{usage.toFixed(1)}%
</span>
</div>
</div>
</div>
);
})}
</div>
</div>
);
return (
<Card className="bg-slate-900 border-slate-800">
@@ -300,103 +359,11 @@ function TopResourceUsageCard() {
<CardContent>
<div className="grid grid-cols-3 gap-4">
{/* CPU Top5 */}
<div>
<div className="flex items-center gap-2 mb-3">
<Cpu className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-300">CPU</span>
</div>
<div className="space-y-2">
{topCpuServers.map((server, index) => (
<div key={server.id} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
server.cpuUsage > 80 ? 'bg-red-400' : server.cpuUsage > 60 ? 'bg-amber-400' : 'bg-blue-400'
}`}
style={{ width: `${server.cpuUsage}%` }}
/>
</div>
<span className={`text-xs ${
server.cpuUsage > 80 ? 'text-red-400' : server.cpuUsage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{server.cpuUsage.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
{renderList(topCpuServers, 'blue', 'bg-blue-400', <Cpu className="h-4 w-4 text-blue-400" />, 'CPU')}
{/* Memory Top5 */}
<div>
<div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-4 w-4 text-emerald-400" />
<span className="text-sm font-medium text-slate-300">内存</span>
</div>
<div className="space-y-2">
{topMemoryServers.map((server, index) => (
<div key={server.id} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
server.memoryUsage > 80 ? 'bg-red-400' : server.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
}`}
style={{ width: `${server.memoryUsage}%` }}
/>
</div>
<span className={`text-xs ${
server.memoryUsage > 80 ? 'text-red-400' : server.memoryUsage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{server.memoryUsage.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
{renderList(topMemoryServers, 'emerald', 'bg-emerald-400', <MemoryStick className="h-4 w-4 text-emerald-400" />, '内存')}
{/* Disk Top5 */}
<div>
<div className="flex items-center gap-2 mb-3">
<Database className="h-4 w-4 text-purple-400" />
<span className="text-sm font-medium text-slate-300">磁盘</span>
</div>
<div className="space-y-2">
{topDiskServers.map((server, index) => (
<div key={server.id} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
server.diskUsage > 80 ? 'bg-red-400' : server.diskUsage > 60 ? 'bg-amber-400' : 'bg-purple-400'
}`}
style={{ width: `${server.diskUsage}%` }}
/>
</div>
<span className={`text-xs ${
server.diskUsage > 80 ? 'text-red-400' : server.diskUsage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{server.diskUsage.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
{renderList(topDiskServers, 'purple', 'bg-purple-400', <Database className="h-4 w-4 text-purple-400" />, '磁盘')}
</div>
</CardContent>
</Card>
@@ -404,17 +371,16 @@ function TopResourceUsageCard() {
}
// 证书到期卡片
function CertificateExpiryCard() {
const expiringDomains = domains
.filter(d => d.sslEnabled)
.map(d => ({
function CertificateExpiryCard({ expCerts }: { expCerts: any[] }) {
const expiringDomains = (expCerts ?? [])
.map((d) => ({
...d,
daysUntilExpiry: Math.ceil((new Date(d.sslExpireDate!).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
daysUntilExpiry: d.days_until_expiry ?? Math.ceil((new Date(d.expire_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
}))
.sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry);
const criticalCount = expiringDomains.filter(d => d.daysUntilExpiry < 30).length;
const warningCount = expiringDomains.filter(d => d.daysUntilExpiry >= 30 && d.daysUntilExpiry < 60).length;
const criticalCount = expiringDomains.filter((d) => d.daysUntilExpiry < 30).length;
const warningCount = expiringDomains.filter((d) => d.daysUntilExpiry >= 30 && d.daysUntilExpiry < 60).length;
return (
<Card className="bg-slate-900 border-slate-800">
@@ -457,7 +423,7 @@ function CertificateExpiryCard() {
</div>
<div>
<p className="text-sm font-medium text-slate-200">{domain.name}</p>
<p className="text-xs text-slate-500">到期: {domain.sslExpireDate}</p>
<p className="text-xs text-slate-500">到期: {domain.expire_date}</p>
</div>
</div>
<Badge
@@ -482,8 +448,16 @@ function CertificateExpiryCard() {
}
// 资源使用率趋势卡片
function ResourceUsageCard() {
const data = generateMetricsData(24);
function ResourceUsageCard({
avgCpu,
avgMemory,
avgDisk,
}: {
avgCpu: number;
avgMemory: number;
avgDisk: number;
}) {
const data = generateMetricsData(avgCpu, avgMemory, avgDisk, 24);
return (
<Card className="bg-slate-900 border-slate-800">
@@ -574,7 +548,7 @@ function ResourceUsageCard() {
}
// 发布历史卡片
function ReleaseHistoryCard() {
function ReleaseHistoryCard({ releases }: { releases: any[] }) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
@@ -591,7 +565,7 @@ function ReleaseHistoryCard() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{appReleases.slice(0, 5).map((release) => (
{releases.slice(0, 5).map((release) => (
<div
key={release.id}
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-colors"
@@ -611,7 +585,7 @@ function ReleaseHistoryCard() {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-200">{release.appName}</span>
<span className="text-sm font-medium text-slate-200">{release.app_name}</span>
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
{release.version}
</Badge>
@@ -619,7 +593,7 @@ function ReleaseHistoryCard() {
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-slate-500">{release.environment}</span>
<span className="text-xs text-slate-500">·</span>
<span className="text-xs text-slate-500">{release.commitId}</span>
<span className="text-xs text-slate-500">{release.commit_id}</span>
</div>
</div>
<Badge
@@ -653,7 +627,7 @@ function ReleaseHistoryCard() {
}
// 服务器状态卡片
function ServerStatusCard() {
function ServerStatusCard({ servers }: { servers: any[] }) {
const runningServers = servers.filter((s) => s.status === 'running').length;
const stoppedServers = servers.filter((s) => s.status === 'stopped').length;
const maintenanceServers = servers.filter((s) => s.status === 'maintenance').length;
@@ -704,8 +678,8 @@ function ServerStatusCard() {
<p className="text-xs text-slate-500">{server.ip}</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-400">CPU {server.cpuUsage.toFixed(1)}%</p>
<p className="text-xs text-slate-400">内存 {server.memoryUsage.toFixed(1)}%</p>
<p className="text-xs text-slate-400">CPU {Number(server.cpu_usage ?? 0).toFixed(1)}%</p>
<p className="text-xs text-slate-400">内存 {Number(server.memory_usage ?? 0).toFixed(1)}%</p>
</div>
</div>
))}
@@ -716,7 +690,7 @@ function ServerStatusCard() {
}
// K8s集群状态卡片
function K8sClusterCard() {
function K8sClusterCard({ clusters }: { clusters: any[] }) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
@@ -733,46 +707,50 @@ function K8sClusterCard() {
</CardHeader>
<CardContent>
<div className="space-y-3">
{k8sClusters.map((cluster) => (
<div key={cluster.id} className="p-3 rounded-lg bg-slate-800/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Cloud className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">{cluster.name}</span>
{clusters.map((cluster) => {
const cpuUsage = Number(cluster.cpu_usage ?? 0);
const memoryUsage = Number(cluster.memory_usage ?? 0);
return (
<div key={cluster.id} className="p-3 rounded-lg bg-slate-800/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Cloud className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">{cluster.name}</span>
</div>
<Badge
variant="outline"
className={`text-xs ${
cluster.status === 'healthy'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: cluster.status === 'warning'
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
: 'border-red-500/30 text-red-400 bg-red-500/10'
}`}
>
{cluster.status === 'healthy' ? '健康' : cluster.status === 'warning' ? '警告' : '异常'}
</Badge>
</div>
<Badge
variant="outline"
className={`text-xs ${
cluster.status === 'healthy'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: cluster.status === 'warning'
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
: 'border-red-500/30 text-red-400 bg-red-500/10'
}`}
>
{cluster.status === 'healthy' ? '健康' : cluster.status === 'warning' ? '警告' : '异常'}
</Badge>
</div>
<div className="grid grid-cols-4 gap-2 text-center">
<div>
<div className="text-sm font-medium text-slate-200">{cluster.nodes}</div>
<div className="text-xs text-slate-500">节点</div>
</div>
<div>
<div className="text-sm font-medium text-slate-200">{cluster.pods}</div>
<div className="text-xs text-slate-500">Pod</div>
</div>
<div>
<div className="text-sm font-medium text-slate-200">{cluster.cpuUsage.toFixed(1)}%</div>
<div className="text-xs text-slate-500">CPU</div>
</div>
<div>
<div className="text-sm font-medium text-slate-200">{cluster.memoryUsage.toFixed(1)}%</div>
<div className="text-xs text-slate-500">内存</div>
<div className="grid grid-cols-4 gap-2 text-center">
<div>
<div className="text-sm font-medium text-slate-200">{cluster.node_count ?? cluster.nodes ?? 0}</div>
<div className="text-xs text-slate-500">节点</div>
</div>
<div>
<div className="text-sm font-medium text-slate-200">{cluster.pod_count ?? cluster.pods ?? 0}</div>
<div className="text-xs text-slate-500">Pod</div>
</div>
<div>
<div className="text-sm font-medium text-slate-200">{cpuUsage.toFixed(1)}%</div>
<div className="text-xs text-slate-500">CPU</div>
</div>
<div>
<div className="text-sm font-medium text-slate-200">{memoryUsage.toFixed(1)}%</div>
<div className="text-xs text-slate-500">内存</div>
</div>
</div>
</div>
</div>
))}
);
})}
</div>
</CardContent>
</Card>
@@ -780,21 +758,69 @@ function K8sClusterCard() {
}
export function Dashboard() {
const [data, setData] = useState<DashboardData>(dashboardData);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<any>(null);
const [servers, setServers] = useState<any[]>([]);
const [clusters, setClusters] = useState<any[]>([]);
const [alerts, setAlerts] = useState<any[]>([]);
const [releases, setReleases] = useState<any[]>([]);
const [expCerts, setExpCerts] = useState<any[]>([]);
// 模拟实时数据更新
useEffect(() => {
const interval = setInterval(() => {
setData((prev) => ({
...prev,
cpuUsage: 60 + Math.random() * 20,
memoryUsage: 70 + Math.random() * 15,
networkUsage: 30 + Math.random() * 30,
}));
}, 5000);
return () => clearInterval(interval);
const fetchData = async () => {
setLoading(true);
try {
const [statsRes, serversRes, clustersRes, alertsRes, releasesRes] = await Promise.all([
dashboardStatsApi.getStats().catch((e) => {
console.error('Failed to fetch dashboard stats:', e);
return null;
}),
serverApi.getServers({ page: 1, page_size: 100 }).catch((e) => {
console.error('Failed to fetch servers:', e);
return null;
}),
k8sApi.getClusters().catch((e) => {
console.error('Failed to fetch k8s clusters:', e);
return null;
}),
dashboardStatsApi.getAlerts(10).catch((e) => {
console.error('Failed to fetch dashboard alerts:', e);
return null;
}),
dashboardStatsApi.getReleases(5).catch((e) => {
console.error('Failed to fetch dashboard releases:', e);
return null;
}),
]);
setStats(statsRes);
setServers(serversRes?.list ?? []);
setClusters(clustersRes?.list ?? []);
setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
setReleases(Array.isArray(releasesRes) ? releasesRes : []);
setExpCerts(statsRes?.expiring_certs ?? []);
} catch (error) {
console.error('Failed to load dashboard data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="h-8 w-8 border-2 border-slate-700 border-t-blue-500 rounded-full animate-spin" />
</div>
);
}
const serverStats = stats?.server_stats;
const k8sStats = stats?.k8s_stats;
const alertStats = stats?.alert_stats;
const costStats = stats?.cost_stats;
return (
<div className="space-y-6">
{/* Page Header */}
@@ -819,8 +845,8 @@ export function Dashboard() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="服务器总数"
value={data.serverCount}
description={`${data.runningServers} 台运行中`}
value={serverStats?.total ?? 0}
description={`${serverStats?.running ?? 0} 台运行中`}
icon={Server}
trend="up"
trendValue="+3 本月"
@@ -828,8 +854,8 @@ export function Dashboard() {
/>
<StatCard
title="K8s集群"
value={data.k8sClusters}
description={`${data.healthyClusters} 个健康`}
value={k8sStats?.total ?? 0}
description={`${k8sStats?.healthy ?? 0} 个健康`}
icon={Cloud}
trend="neutral"
trendValue="稳定"
@@ -837,8 +863,8 @@ export function Dashboard() {
/>
<StatCard
title="待处理告警"
value={data.alertCount}
description={`${data.criticalAlerts} 个严重`}
value={alertStats?.firing ?? 0}
description={`${alertStats?.critical ?? 0} 个严重`}
icon={Bell}
trend="down"
trendValue="-5 本周"
@@ -846,7 +872,7 @@ export function Dashboard() {
/>
<StatCard
title="本月云成本"
value="¥107,700"
value={`¥${Number(costStats?.month_cost ?? 0).toLocaleString()}`}
description="较上月 +12.5%"
icon={DollarSign}
trend="up"
@@ -856,25 +882,29 @@ export function Dashboard() {
</div>
{/* Resource Usage Chart */}
<ResourceUsageCard />
<ResourceUsageCard
avgCpu={Number(serverStats?.avg_cpu ?? 0)}
avgMemory={Number(serverStats?.avg_memory ?? 0)}
avgDisk={Number(serverStats?.avg_disk ?? 0)}
/>
{/* Cost & Top Resource Usage */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CostOverviewCard />
<TopResourceUsageCard />
<CostOverviewCard costStats={costStats} />
<TopResourceUsageCard topUsage={stats?.top_resource_usage} />
</div>
{/* Alert Messages & Certificate Expiry */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<AlertMessagesCard />
<CertificateExpiryCard />
<AlertMessagesCard alerts={alerts} />
<CertificateExpiryCard expCerts={expCerts} />
</div>
{/* Bottom Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<ReleaseHistoryCard />
<ServerStatusCard />
<K8sClusterCard />
<ReleaseHistoryCard releases={releases} />
<ServerStatusCard servers={servers} />
<K8sClusterCard clusters={clusters} />
</div>
</div>
);
+71 -31
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -52,25 +52,42 @@ import {
ArrowRight,
Terminal,
} from 'lucide-react';
import { appReleases } from '@/mocks/data';
import type { AppRelease } from '@/types';
import { releaseApi } from '@/services/api';
export function AppReleaseManager() {
const [searchQuery, setSearchQuery] = useState('');
const [envFilter, setEnvFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedRelease, setSelectedRelease] = useState<AppRelease | null>(null);
const [releases, setReleases] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
const filteredReleases = appReleases.filter((release) => {
const loadData = async () => {
try {
setLoading(true);
const data = await releaseApi.getReleases();
setReleases(data.list || []);
} catch (error) {
console.error('Failed to load releases:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredReleases = releases.filter((release) => {
const matchesSearch =
release.appName.toLowerCase().includes(searchQuery.toLowerCase()) ||
release.version.includes(searchQuery);
(release.app_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(release.version || '').includes(searchQuery);
const matchesEnv = envFilter === 'all' || release.environment === envFilter;
const matchesStatus = statusFilter === 'all' || release.status === statusFilter;
return matchesSearch && matchesEnv && matchesStatus;
});
const getStatusBadge = (status: AppRelease['status']) => {
const getStatusBadge = (status: any) => {
switch (status) {
case 'success':
return (
@@ -119,26 +136,35 @@ export function AppReleaseManager() {
}
};
const getEnvBadge = (env: AppRelease['environment']) => {
const colors = {
const getEnvBadge = (env: any) => {
const colors: Record<string, string> = {
dev: 'border-slate-500/30 text-slate-400 bg-slate-500/10',
test: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
prod: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10',
};
const labels = {
const labels: Record<string, string> = {
dev: '开发环境',
test: '测试环境',
staging: '预发布',
prod: '生产环境',
};
return (
<Badge variant="outline" className={colors[env]}>
{labels[env]}
<Badge variant="outline" className={colors[env] || colors.dev}>
{labels[env] || env}
</Badge>
);
};
const handleRollback = async (id: any) => {
try {
await releaseApi.rollbackRelease(id);
loadData();
} catch (error) {
console.error('Failed to rollback release:', error);
}
};
// 模拟流水线步骤
const pipelineSteps = [
{ name: '代码检出', status: 'success', duration: '15s' },
@@ -178,7 +204,7 @@ export function AppReleaseManager() {
<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">12</p>
<p className="text-2xl font-bold text-slate-100">{releases.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Rocket className="h-5 w-5" />
@@ -191,7 +217,9 @@ export function AppReleaseManager() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">发布成功</p>
<p className="text-2xl font-bold text-emerald-400">10</p>
<p className="text-2xl font-bold text-emerald-400">
{releases.filter((r) => r.status === 'success').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
@@ -204,7 +232,9 @@ export function AppReleaseManager() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">发布失败</p>
<p className="text-2xl font-bold text-red-400">1</p>
<p className="text-2xl font-bold text-red-400">
{releases.filter((r) => r.status === 'failed').length}
</p>
</div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
<XCircle className="h-5 w-5" />
@@ -217,7 +247,11 @@ export function AppReleaseManager() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">成功率</p>
<p className="text-2xl font-bold text-blue-400">83.3%</p>
<p className="text-2xl font-bold text-blue-400">
{releases.length > 0
? `${Math.round((releases.filter((r) => r.status === 'success').length / releases.length) * 100)}%`
: '0%'}
</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<History className="h-5 w-5" />
@@ -278,17 +312,23 @@ export function AppReleaseManager() {
<TableHead className="text-slate-400">代码</TableHead>
<TableHead className="text-slate-400">发布人</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead>
<TableHead className="text-slate-400">耗时</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredReleases.map((release) => (
{loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={8} className="text-center text-slate-500 py-8">
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
加载中...
</TableCell>
</TableRow>
) : filteredReleases.map((release) => (
<TableRow key={release.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div className="flex items-center gap-2">
<Rocket className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium text-slate-200">{release.appName}</span>
<span className="text-sm font-medium text-slate-200">{release.app_name}</span>
</div>
</TableCell>
<TableCell>
@@ -304,7 +344,7 @@ export function AppReleaseManager() {
<span>{release.branch}</span>
<span className="mx-1">·</span>
<GitCommit className="h-3 w-3" />
<span>{release.commitId}</span>
<span>{release.commit_id}</span>
</div>
</TableCell>
<TableCell>
@@ -312,12 +352,7 @@ export function AppReleaseManager() {
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{new Date(release.startedAt).toLocaleString()}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{release.duration ? `${Math.floor(release.duration / 60)}m ${release.duration % 60}s` : '--'}
{release.created_at ? new Date(release.created_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell className="text-right">
@@ -338,7 +373,12 @@ export function AppReleaseManager() {
</Button>
)}
{release.status === 'success' && (
<Button variant="ghost" size="sm" className="h-8 text-slate-400 hover:text-amber-400">
<Button
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-amber-400"
onClick={() => handleRollback(release.id)}
>
<RotateCcw className="h-4 w-4 mr-1" />
回滚
</Button>
@@ -359,13 +399,13 @@ export function AppReleaseManager() {
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Rocket className="h-5 w-5 text-blue-400" />
{selectedRelease?.appName}
{selectedRelease?.app_name}
<Badge variant="outline" className="border-slate-700 text-slate-400">
{selectedRelease?.version}
</Badge>
</DialogTitle>
<DialogDescription className="text-slate-500">
流水线: {selectedRelease?.pipeline}
环境: {selectedRelease?.environment}
</DialogDescription>
</DialogHeader>
@@ -412,7 +452,7 @@ export function AppReleaseManager() {
<h4 className="text-sm font-medium text-slate-300">构建日志</h4>
<div className="bg-slate-950 rounded-lg p-4 font-mono text-xs text-slate-400 h-48 overflow-auto">
<div className="space-y-1">
<p>[10:00:00] Starting pipeline for {selectedRelease?.appName}...</p>
<p>[10:00:00] Starting pipeline for {selectedRelease?.app_name}...</p>
<p>[10:00:15] Checkout code from branch {selectedRelease?.branch}</p>
<p>[10:00:30] Installing dependencies...</p>
<p className="text-emerald-400">[10:01:15] Dependencies installed successfully</p>
@@ -1,4 +1,4 @@
import { useState } from 'react';
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';
@@ -56,7 +56,7 @@ import {
FileCode,
Workflow,
} from 'lucide-react';
import { pipelines, pipelineExecutions, releases } from '@/mocks/data';
import { pipelineApi } from '@/services/api';
interface PipelineStage {
id: string;
@@ -66,74 +66,6 @@ interface PipelineStage {
logs: string[];
}
interface Pipeline {
id: string;
name: string;
description: string;
repository: string;
branch: string;
stages: PipelineStage[];
lastRun: string;
lastStatus: 'success' | 'failed' | 'running' | 'never';
triggerType: 'manual' | 'auto';
createdAt: string;
}
const mockPipelines: Pipeline[] = [
{
id: 'pipe-001',
name: 'frontend-build-and-deploy',
description: '前端项目构建与部署',
repository: 'https://gitlab.company.com/frontend/app.git',
branch: 'main',
stages: [
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '15s', logs: ['检出代码...', '代码检出完成'] },
{ id: 'stage-2', name: '依赖安装', status: 'success', duration: '45s', logs: ['安装依赖...', '依赖安装完成'] },
{ id: 'stage-3', name: '代码检查', status: 'success', duration: '30s', logs: ['执行 ESLint...', '代码检查通过'] },
{ id: 'stage-4', name: '构建打包', status: 'success', duration: '2m 15s', logs: ['开始构建...', '构建完成'] },
{ id: 'stage-5', name: '部署到测试环境', status: 'success', duration: '30s', logs: ['部署中...', '部署完成'] },
],
lastRun: '2025-04-14 10:30:00',
lastStatus: 'success',
triggerType: 'auto',
createdAt: '2025-01-15',
},
{
id: 'pipe-002',
name: 'backend-api-deploy',
description: '后端API服务部署',
repository: 'https://gitlab.company.com/backend/api.git',
branch: 'develop',
stages: [
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '12s', logs: [] },
{ id: 'stage-2', name: '单元测试', status: 'success', duration: '1m 30s', logs: [] },
{ id: 'stage-3', name: '构建镜像', status: 'running', duration: '45s', logs: ['构建Docker镜像...'] },
{ id: 'stage-4', name: '推送镜像', status: 'pending', duration: '-', logs: [] },
{ id: 'stage-5', name: '部署到K8s', status: 'pending', duration: '-', logs: [] },
],
lastRun: '2025-04-14 11:15:00',
lastStatus: 'running',
triggerType: 'manual',
createdAt: '2025-02-01',
},
{
id: 'pipe-003',
name: 'database-migration',
description: '数据库迁移脚本执行',
repository: 'https://gitlab.company.com/database/migrations.git',
branch: 'main',
stages: [
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '10s', logs: [] },
{ id: 'stage-2', name: '数据库备份', status: 'success', duration: '2m 00s', logs: [] },
{ id: 'stage-3', name: '执行迁移', status: 'failed', duration: '15s', logs: ['迁移失败: 表已存在'] },
],
lastRun: '2025-04-14 09:00:00',
lastStatus: 'failed',
triggerType: 'manual',
createdAt: '2025-01-20',
},
];
const pipelineTemplates = [
{ id: 'template-1', name: 'Node.js 项目', description: '适用于 Node.js 前端项目', stages: ['代码检出', '依赖安装', '代码检查', '构建打包', '部署'] },
{ id: 'template-2', name: 'Go 项目', description: '适用于 Go 后端项目', stages: ['代码检出', '单元测试', '构建', '构建镜像', '部署'] },
@@ -141,9 +73,24 @@ const pipelineTemplates = [
{ id: 'template-4', name: 'Python 项目', description: '适用于 Python 项目', stages: ['代码检出', '依赖安装', '代码检查', '测试', '部署'] },
];
function parseStages(stages: any): PipelineStage[] {
if (Array.isArray(stages)) return stages;
if (typeof stages === 'string') {
try {
const parsed = JSON.parse(stages);
if (Array.isArray(parsed)) return parsed;
} catch {
return stages.split(',').map((s, i) => ({ id: `stage-${i + 1}`, name: s.trim(), status: 'success' as const, duration: '-', logs: [] }));
}
}
return [];
}
export function PipelineManager() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
const [pipelines, setPipelines] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPipeline, setSelectedPipeline] = useState<any | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
const [isRunDialogOpen, setIsRunDialogOpen] = useState(false);
@@ -156,13 +103,29 @@ export function PipelineManager() {
template: '',
});
const filteredPipelines = mockPipelines.filter(
const loadData = async () => {
try {
setLoading(true);
const data = await pipelineApi.getPipelines();
setPipelines(data.list || []);
} catch (error) {
console.error('Failed to load pipelines:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredPipelines = pipelines.filter(
(p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase())
(p.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(p.description || '').toLowerCase().includes(searchQuery.toLowerCase())
);
const getStatusBadge = (status: Pipeline['lastStatus']) => {
const getStatusBadge = (status: any) => {
switch (status) {
case 'success':
return (
@@ -209,14 +172,40 @@ export function PipelineManager() {
}
};
const handleCreatePipeline = () => {
// Mock create
setIsCreateDialogOpen(false);
setNewPipeline({ name: '', description: '', repository: '', branch: 'main', template: '' });
const handleCreatePipeline = async () => {
try {
await pipelineApi.createPipeline({
name: newPipeline.name,
description: newPipeline.description,
repository: newPipeline.repository,
branch: newPipeline.branch,
});
setIsCreateDialogOpen(false);
setNewPipeline({ name: '', description: '', repository: '', branch: 'main', template: '' });
loadData();
} catch (error) {
console.error('Failed to create pipeline:', error);
}
};
const handleRunPipeline = () => {
setIsRunDialogOpen(false);
const handleDeletePipeline = async (id: any) => {
try {
await pipelineApi.deletePipeline(id);
loadData();
} catch (error) {
console.error('Failed to delete pipeline:', error);
}
};
const handleRunPipeline = async () => {
if (!selectedPipeline) return;
try {
await pipelineApi.triggerPipeline(selectedPipeline.id);
setIsRunDialogOpen(false);
loadData();
} catch (error) {
console.error('Failed to trigger pipeline:', error);
}
};
return (
@@ -246,7 +235,7 @@ export function PipelineManager() {
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">流水线总数</p>
<p className="text-2xl font-bold text-slate-100">{mockPipelines.length}</p>
<p className="text-2xl font-bold text-slate-100">{pipelines.length}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Workflow className="h-5 w-5 text-blue-400" />
@@ -260,7 +249,7 @@ export function PipelineManager() {
<div>
<p className="text-slate-400 text-sm">运行中</p>
<p className="text-2xl font-bold text-blue-400">
{mockPipelines.filter((p) => p.lastStatus === 'running').length}
{pipelines.filter((p) => p.status === 'running').length}
</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
@@ -274,7 +263,9 @@ export function PipelineManager() {
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">今日成功</p>
<p className="text-2xl font-bold text-emerald-400">12</p>
<p className="text-2xl font-bold text-emerald-400">
{pipelines.filter((p) => p.status === 'success').length}
</p>
</div>
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-emerald-400" />
@@ -287,7 +278,9 @@ export function PipelineManager() {
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">今日失败</p>
<p className="text-2xl font-bold text-red-400">2</p>
<p className="text-2xl font-bold text-red-400">
{pipelines.filter((p) => p.status === 'failed').length}
</p>
</div>
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<XCircle className="h-5 w-5 text-red-400" />
@@ -310,6 +303,14 @@ export function PipelineManager() {
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : filteredPipelines.length === 0 ? (
<div className="text-center text-slate-500 py-12">暂无流水线数据</div>
) : null}
{/* Pipeline List */}
<div className="space-y-4">
{filteredPipelines.map((pipeline) => (
@@ -319,9 +320,9 @@ export function PipelineManager() {
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-medium text-slate-100">{pipeline.name}</h3>
{getStatusBadge(pipeline.lastStatus)}
{getStatusBadge(pipeline.status)}
<Badge variant="outline" className="border-slate-700 text-slate-400">
{pipeline.triggerType === 'auto' ? '自动触发' : '手动触发'}
{pipeline.trigger_type === 'auto' ? '自动触发' : '手动触发'}
</Badge>
</div>
<p className="text-sm text-slate-400 mt-1">{pipeline.description}</p>
@@ -332,15 +333,15 @@ export function PipelineManager() {
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
上次运行: {pipeline.lastRun}
上次运行: {pipeline.updated_at || '--'}
</span>
</div>
{/* Pipeline Stages */}
<div className="mt-4">
<div className="flex items-center gap-2">
{pipeline.stages.map((stage, index) => (
<div key={stage.id} className="flex items-center">
{parseStages(pipeline.stages).map((stage: PipelineStage, index: number) => (
<div key={stage.id || index} className="flex items-center">
<div
className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
stage.status === 'success'
@@ -355,7 +356,7 @@ export function PipelineManager() {
{getStageStatusIcon(stage.status)}
<span className="ml-1">{stage.name}</span>
</div>
{index < pipeline.stages.length - 1 && (
{index < parseStages(pipeline.stages).length - 1 && (
<ChevronRight className="h-4 w-4 text-slate-600 mx-1" />
)}
</div>
@@ -391,7 +392,7 @@ export function PipelineManager() {
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400" onClick={() => handleDeletePipeline(pipeline.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -513,9 +514,9 @@ export function PipelineManager() {
</TabsList>
<TabsContent value="stages" className="mt-4">
<div className="space-y-2">
{selectedPipeline.stages.map((stage, index) => (
{parseStages(selectedPipeline.stages).map((stage: PipelineStage, index: number) => (
<div
key={stage.id}
key={stage.id || index}
className={`p-3 rounded-lg border cursor-pointer transition-all ${
activeStage === stage.id
? 'border-blue-500 bg-blue-500/10'
@@ -562,7 +563,7 @@ export function PipelineManager() {
<div>
<Label className="text-slate-400">触发方式</Label>
<p className="text-slate-200">
{selectedPipeline.triggerType === 'auto' ? '自动触发' : '手动触发'}
{selectedPipeline.trigger_type === 'auto' ? '自动触发' : '手动触发'}
</p>
</div>
</div>
+111 -122
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
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';
@@ -54,95 +54,7 @@ import {
Check,
X,
} from 'lucide-react';
import { appReleases } from '@/mocks/data';
interface Release {
id: string;
appName: string;
version: string;
environment: 'production' | 'staging' | 'development';
status: 'pending' | 'approved' | 'deploying' | 'success' | 'failed' | 'rolled_back';
commitId: string;
branch: string;
applicant: string;
approver?: string;
description: string;
createdAt: string;
deployedAt?: string;
rollbackVersion?: string;
}
const mockReleases: Release[] = [
{
id: 'rel-001',
appName: '用户中心服务',
version: 'v2.3.1',
environment: 'production',
status: 'success',
commitId: 'a1b2c3d',
branch: 'main',
applicant: '张三',
approver: '李四',
description: '修复用户登录异常问题,优化性能',
createdAt: '2025-04-14 09:00:00',
deployedAt: '2025-04-14 10:30:00',
},
{
id: 'rel-002',
appName: '订单服务',
version: 'v1.5.0',
environment: 'production',
status: 'pending',
commitId: 'e4f5g6h',
branch: 'release/v1.5.0',
applicant: '王五',
description: '新增订单批量导出功能',
createdAt: '2025-04-14 11:00:00',
},
{
id: 'rel-003',
appName: '支付网关',
version: 'v3.0.2',
environment: 'staging',
status: 'deploying',
commitId: 'i7j8k9l',
branch: 'develop',
applicant: '赵六',
approver: '李四',
description: '集成新的支付渠道',
createdAt: '2025-04-14 10:00:00',
deployedAt: '2025-04-14 11:15:00',
},
{
id: 'rel-004',
appName: '消息推送服务',
version: 'v1.2.0',
environment: 'production',
status: 'failed',
commitId: 'm0n1o2p',
branch: 'main',
applicant: '张三',
approver: '李四',
description: '优化推送性能',
createdAt: '2025-04-14 08:00:00',
deployedAt: '2025-04-14 08:30:00',
},
{
id: 'rel-005',
appName: '数据分析平台',
version: 'v2.0.0',
environment: 'production',
status: 'rolled_back',
commitId: 'q3r4s5t',
branch: 'main',
applicant: '王五',
approver: '李四',
description: '新版数据分析引擎',
createdAt: '2025-04-13 14:00:00',
deployedAt: '2025-04-13 15:00:00',
rollbackVersion: 'v1.9.8',
},
];
import { releaseApi } from '@/services/api';
const applications = [
{ id: 'app-1', name: '用户中心服务', currentVersion: 'v2.3.0' },
@@ -156,7 +68,9 @@ export function ReleaseManager() {
const [searchQuery, setSearchQuery] = useState('');
const [envFilter, setEnvFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedRelease, setSelectedRelease] = useState<Release | null>(null);
const [releases, setReleases] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false);
@@ -169,16 +83,32 @@ export function ReleaseManager() {
description: '',
});
const filteredReleases = mockReleases.filter((release) => {
const loadData = async () => {
try {
setLoading(true);
const data = await releaseApi.getReleases();
setReleases(data.list || []);
} catch (error) {
console.error('Failed to load releases:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredReleases = releases.filter((release) => {
const matchesSearch =
release.appName.toLowerCase().includes(searchQuery.toLowerCase()) ||
release.version.includes(searchQuery);
(release.app_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(release.version || '').includes(searchQuery);
const matchesEnv = envFilter === 'all' || release.environment === envFilter;
const matchesStatus = statusFilter === 'all' || release.status === statusFilter;
return matchesSearch && matchesEnv && matchesStatus;
});
const getStatusBadge = (status: Release['status']) => {
const getStatusBadge = (status: any) => {
switch (status) {
case 'success':
return (
@@ -222,34 +152,78 @@ export function ReleaseManager() {
已回滚
</Badge>
);
default:
return null;
}
};
const getEnvironmentBadge = (env: Release['environment']) => {
const colors = {
const getEnvironmentBadge = (env: any) => {
const colors: Record<string, string> = {
production: 'border-red-500/30 text-red-400 bg-red-500/10',
staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
development: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
};
const labels = {
const labels: Record<string, string> = {
production: '生产环境',
staging: '预发环境',
development: '开发环境',
};
return (
<Badge variant="outline" className={colors[env]}>
{labels[env]}
<Badge variant="outline" className={colors[env] || colors.development}>
{labels[env] || env}
</Badge>
);
};
const handleCreateRelease = () => {
setIsCreateDialogOpen(false);
setNewRelease({ appId: '', version: '', environment: 'staging', branch: '', commitId: '', description: '' });
const handleCreateRelease = async () => {
try {
await releaseApi.createRelease({
app_name: applications.find((a) => a.id === newRelease.appId)?.name || newRelease.appId,
version: newRelease.version,
environment: newRelease.environment,
branch: newRelease.branch,
commit_id: newRelease.commitId,
description: newRelease.description,
});
setIsCreateDialogOpen(false);
setNewRelease({ appId: '', version: '', environment: 'staging', branch: '', commitId: '', description: '' });
loadData();
} catch (error) {
console.error('Failed to create release:', error);
}
};
const handleApprove = (approved: boolean) => {
setIsApproveDialogOpen(false);
const handleApprove = async (approved: boolean) => {
if (!selectedRelease) return;
try {
if (approved) {
await releaseApi.approveRelease(selectedRelease.id);
} else {
await releaseApi.rollbackRelease(selectedRelease.id);
}
setIsApproveDialogOpen(false);
loadData();
} catch (error) {
console.error('Failed to process approval:', error);
}
};
const handleDeploy = async (id: any) => {
try {
await releaseApi.deployRelease(id);
loadData();
} catch (error) {
console.error('Failed to deploy release:', error);
}
};
const handleRollback = async (id: any) => {
try {
await releaseApi.rollbackRelease(id);
loadData();
} catch (error) {
console.error('Failed to rollback release:', error);
}
};
return (
@@ -272,7 +246,7 @@ export function ReleaseManager() {
<CardContent className="p-4">
<p className="text-slate-400 text-sm">待审批</p>
<p className="text-2xl font-bold text-amber-400">
{mockReleases.filter((r) => r.status === 'pending').length}
{releases.filter((r) => r.status === 'pending').length}
</p>
</CardContent>
</Card>
@@ -280,7 +254,7 @@ export function ReleaseManager() {
<CardContent className="p-4">
<p className="text-slate-400 text-sm">发布中</p>
<p className="text-2xl font-bold text-blue-400">
{mockReleases.filter((r) => r.status === 'deploying').length}
{releases.filter((r) => r.status === 'deploying').length}
</p>
</CardContent>
</Card>
@@ -288,7 +262,7 @@ export function ReleaseManager() {
<CardContent className="p-4">
<p className="text-slate-400 text-sm">今日成功</p>
<p className="text-2xl font-bold text-emerald-400">
{mockReleases.filter((r) => r.status === 'success').length}
{releases.filter((r) => r.status === 'success').length}
</p>
</CardContent>
</Card>
@@ -296,7 +270,7 @@ export function ReleaseManager() {
<CardContent className="p-4">
<p className="text-slate-400 text-sm">今日失败</p>
<p className="text-2xl font-bold text-red-400">
{mockReleases.filter((r) => r.status === 'failed').length}
{releases.filter((r) => r.status === 'failed').length}
</p>
</CardContent>
</Card>
@@ -304,7 +278,7 @@ export function ReleaseManager() {
<CardContent className="p-4">
<p className="text-slate-400 text-sm">已回滚</p>
<p className="text-2xl font-bold text-slate-400">
{mockReleases.filter((r) => r.status === 'rolled_back').length}
{releases.filter((r) => r.status === 'rolled_back').length}
</p>
</CardContent>
</Card>
@@ -346,6 +320,12 @@ export function ReleaseManager() {
</select>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : null}
{/* Release List */}
<div className="space-y-3">
{filteredReleases.map((release) => (
@@ -354,7 +334,7 @@ export function ReleaseManager() {
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-medium text-slate-100">{release.appName}</h3>
<h3 className="font-medium text-slate-100">{release.app_name}</h3>
{getStatusBadge(release.status)}
{getEnvironmentBadge(release.environment)}
</div>
@@ -369,19 +349,18 @@ export function ReleaseManager() {
</span>
<span className="flex items-center gap-1">
<GitCommit className="h-3 w-3" />
{release.commitId}
{release.commit_id}
</span>
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
申请人: {release.applicant}
发布人: {release.deployer}
</span>
</div>
<p className="text-sm text-slate-500 mt-2">{release.description}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>创建: {release.createdAt}</span>
{release.deployedAt && <span>部署: {release.deployedAt}</span>}
{release.rollbackVersion && (
<span className="text-red-400">回滚至: {release.rollbackVersion}</span>
<span>创建: {release.created_at ? new Date(release.created_at).toLocaleString() : '--'}</span>
{release.updated_at && <span>部署: {new Date(release.updated_at).toLocaleString()}</span>}
{release.rollback_to && (
<span className="text-red-400">回滚至: {release.rollback_to}</span>
)}
</div>
</div>
@@ -402,13 +381,23 @@ export function ReleaseManager() {
</Button>
)}
{release.status === 'approved' && (
<Button variant="outline" size="sm" className="border-blue-600 text-blue-400 hover:bg-blue-600/20">
<Button
variant="outline"
size="sm"
className="border-blue-600 text-blue-400 hover:bg-blue-600/20"
onClick={() => handleDeploy(release.id)}
>
<Play className="h-4 w-4 mr-1" />
开始发布
</Button>
)}
{(release.status === 'success' || release.status === 'failed') && (
<Button variant="outline" size="sm" className="border-amber-600 text-amber-400 hover:bg-amber-600/20">
<Button
variant="outline"
size="sm"
className="border-amber-600 text-amber-400 hover:bg-amber-600/20"
onClick={() => handleRollback(release.id)}
>
<RotateCcw className="h-4 w-4 mr-1" />
回滚
</Button>
@@ -531,13 +520,13 @@ export function ReleaseManager() {
<DialogHeader>
<DialogTitle>审批发布</DialogTitle>
<DialogDescription className="text-slate-500">
{selectedRelease?.appName} - {selectedRelease?.version}
{selectedRelease?.app_name} - {selectedRelease?.version}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="p-4 bg-slate-800 rounded-lg">
<p className="text-sm text-slate-400">发布说明</p>
<p className="text-slate-200 mt-1">{selectedRelease?.description}</p>
<p className="text-slate-200 mt-1">{selectedRelease?.description || '无'}</p>
</div>
<div className="mt-4">
<Label className="text-slate-300">审批意见</Label>
+277 -162
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -43,7 +43,7 @@ import {
Search,
Filter,
} from 'lucide-react';
import { k8sClusters, k8sNodes, k8sPods, k8sDeployments, k8sServices, k8sIngresses } from '@/mocks/data';
import { k8sApi } from '@/services/api';
import type { K8sCluster } from '@/types';
import {
LineChart,
@@ -61,13 +61,79 @@ import {
} from 'recharts';
export function K8sClusters() {
const [clusters, setClusters] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const filteredClusters = k8sClusters.filter(
// 集群详情关联资源
const [nodes, setNodes] = useState<any[]>([]);
const [pods, setPods] = useState<any[]>([]);
const [deployments, setDeployments] = useState<any[]>([]);
const [services, setServices] = useState<any[]>([]);
const [ingresses, setIngresses] = useState<any[]>([]);
const loadData = async () => {
try {
setLoading(true);
const res: any = await k8sApi.getClusters();
setClusters(res?.list || []);
} catch (e) {
console.error('加载集群列表失败:', e);
setClusters([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const loadClusterDetail = async (clusterId: string) => {
try {
const [nodesRes, podsRes, depRes, svcRes, ingRes] = await Promise.all([
k8sApi.getNodes(clusterId).catch(() => ({ list: [] })),
k8sApi.getPods(clusterId).catch(() => ({ list: [] })),
k8sApi.getDeployments(clusterId).catch(() => ({ list: [] })),
k8sApi.getServices(clusterId).catch(() => ({ list: [] })),
k8sApi.getIngresses(clusterId).catch(() => ({ list: [] })),
]);
setNodes((nodesRes as any)?.list || []);
setPods((podsRes as any)?.list || []);
setDeployments((depRes as any)?.list || []);
setServices((svcRes as any)?.list || []);
setIngresses((ingRes as any)?.list || []);
} catch (e) {
console.error('加载集群详情失败:', e);
}
};
useEffect(() => {
if (selectedCluster) {
loadClusterDetail(selectedCluster);
} else {
setNodes([]);
setPods([]);
setDeployments([]);
setServices([]);
setIngresses([]);
}
}, [selectedCluster]);
const handleDelete = async (id: string) => {
try {
await k8sApi.deleteCluster(id);
loadData();
} catch (e) {
console.error('删除集群失败:', e);
}
};
const filteredClusters = clusters.filter(
(cluster) =>
cluster.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
cluster.version.includes(searchQuery)
(cluster.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(cluster.version || '').includes(searchQuery)
);
const getStatusBadge = (status: K8sCluster['status']) => {
@@ -98,15 +164,7 @@ export function K8sClusters() {
}
};
const clusterDetail = selectedCluster
? k8sClusters.find((c) => c.id === selectedCluster)
: null;
const clusterNodes = selectedCluster
? k8sNodes.filter((n) => n.clusterId === selectedCluster)
: [];
const clusterPods = selectedCluster
? k8sPods.filter((p) => p.clusterId === selectedCluster)
: [];
const clusterDetail = selectedCluster ? clusters.find((c) => c.id === selectedCluster) : null;
// 模拟资源使用趋势数据
const resourceData = [
@@ -128,8 +186,12 @@ export function K8sClusters() {
<p className="text-sm text-slate-500 mt-1">管理Kubernetes集群资源</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">
<RefreshCw className="h-4 w-4 mr-2" />
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={loadData}
>
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
刷新
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
@@ -146,7 +208,7 @@ export function K8sClusters() {
<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">{k8sClusters.length}</p>
<p className="text-2xl font-bold text-slate-100">{clusters.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Cloud className="h-5 w-5" />
@@ -160,7 +222,7 @@ export function K8sClusters() {
<div>
<p className="text-sm text-slate-500">健康集群</p>
<p className="text-2xl font-bold text-emerald-400">
{k8sClusters.filter((c) => c.status === 'healthy').length}
{clusters.filter((c) => c.status === 'healthy').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
@@ -175,7 +237,7 @@ export function K8sClusters() {
<div>
<p className="text-sm text-slate-500">总节点数</p>
<p className="text-2xl font-bold text-slate-100">
{k8sClusters.reduce((acc, c) => acc + c.nodes, 0)}
{clusters.reduce((acc, c) => acc + (c.node_count || 0), 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
@@ -190,7 +252,7 @@ export function K8sClusters() {
<div>
<p className="text-sm text-slate-500">总Pod数</p>
<p className="text-2xl font-bold text-slate-100">
{k8sClusters.reduce((acc, c) => acc + c.pods, 0)}
{clusters.reduce((acc, c) => acc + (c.pod_count || 0), 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
@@ -216,116 +278,139 @@ export function K8sClusters() {
{/* Clusters Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredClusters.map((cluster) => (
<Card
key={cluster.id}
className={cn(
'bg-slate-900 border-slate-800 cursor-pointer transition-all',
selectedCluster === cluster.id
? 'border-blue-500 ring-1 ring-blue-500/30'
: 'hover:border-slate-700'
)}
onClick={() => setSelectedCluster(cluster.id)}
>
<CardHeader className="pb-3">
<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">
<Cloud className="h-5 w-5 text-blue-400" />
</div>
<div>
<CardTitle className="text-base text-slate-100">{cluster.name}</CardTitle>
<CardDescription className="text-slate-500">{cluster.version}</CardDescription>
</div>
</div>
<div className="flex items-center gap-1">
{getStatusBadge(cluster.status)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Eye className="h-4 w-4 mr-2" />
查看详情
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Terminal className="h-4 w-4 mr-2" />
Kubectl
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Settings className="h-4 w-4 mr-2" />
配置
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.nodes}</div>
<div className="text-xs text-slate-500">节点</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.pods}</div>
<div className="text-xs text-slate-500">Pod</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.services}</div>
<div className="text-xs text-slate-500">Service</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.deployments}</div>
<div className="text-xs text-slate-500">Deployment</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-10">CPU</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
cluster.cpuUsage > 80 ? 'bg-red-400' : cluster.cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${cluster.cpuUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-10 text-right">{cluster.cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-10">内存</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
cluster.memoryUsage > 80 ? 'bg-red-400' : cluster.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${cluster.memoryUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-10 text-right">{cluster.memoryUsage.toFixed(1)}%</span>
</div>
</div>
{loading ? (
<Card className="bg-slate-900 border-slate-800 col-span-full">
<CardContent className="p-12 text-center text-slate-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
加载中...
</CardContent>
</Card>
))}
) : filteredClusters.length === 0 ? (
<Card className="bg-slate-900 border-slate-800 col-span-full">
<CardContent className="p-12 text-center text-slate-500">暂无集群数据</CardContent>
</Card>
) : (
filteredClusters.map((cluster) => {
const cpuUsage = Number(cluster.cpu_usage || 0);
const memoryUsage = Number(cluster.memory_usage || 0);
return (
<Card
key={cluster.id}
className={cn(
'bg-slate-900 border-slate-800 cursor-pointer transition-all',
selectedCluster === cluster.id
? 'border-blue-500 ring-1 ring-blue-500/30'
: 'hover:border-slate-700'
)}
onClick={() => setSelectedCluster(cluster.id)}
>
<CardHeader className="pb-3">
<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">
<Cloud className="h-5 w-5 text-blue-400" />
</div>
<div>
<CardTitle className="text-base text-slate-100">{cluster.name}</CardTitle>
<CardDescription className="text-slate-500">{cluster.version}</CardDescription>
</div>
</div>
<div className="flex items-center gap-1">
{getStatusBadge(cluster.status)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Eye className="h-4 w-4 mr-2" />
查看详情
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Terminal className="h-4 w-4 mr-2" />
Kubectl
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Settings className="h-4 w-4 mr-2" />
配置
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleDelete(cluster.id);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.node_count || 0}</div>
<div className="text-xs text-slate-500">节点</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.pod_count || 0}</div>
<div className="text-xs text-slate-500">Pod</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.service_count || 0}</div>
<div className="text-xs text-slate-500">Service</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.deployment_count || 0}</div>
<div className="text-xs text-slate-500">Deployment</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-10">CPU</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
cpuUsage > 80 ? 'bg-red-400' : cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${cpuUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-10 text-right">{cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-10">内存</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
memoryUsage > 80 ? 'bg-red-400' : memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${memoryUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-10 text-right">{memoryUsage.toFixed(1)}%</span>
</div>
</div>
</CardContent>
</Card>
);
})
)}
</div>
{/* Cluster Detail */}
@@ -338,8 +423,8 @@ export function K8sClusters() {
<div>
<CardTitle className="text-slate-100">{clusterDetail.name}</CardTitle>
<CardDescription className="text-slate-500">
{clusterDetail.version} · {clusterDetail.nodes} 节点 · 创建于{' '}
{new Date(clusterDetail.createdAt).toLocaleDateString()}
{clusterDetail.version} · {clusterDetail.node_count || 0} 节点 · 创建于{' '}
{clusterDetail.created_at ? new Date(clusterDetail.created_at).toLocaleDateString() : '-'}
</CardDescription>
</div>
</div>
@@ -361,19 +446,19 @@ export function K8sClusters() {
概览
</TabsTrigger>
<TabsTrigger value="nodes" className="data-[state=active]:bg-slate-700">
节点 ({clusterNodes.length})
节点 ({nodes.length})
</TabsTrigger>
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
Pod ({clusterPods.length})
Pod ({pods.length})
</TabsTrigger>
<TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700">
Deployment ({k8sDeployments.length})
Deployment ({deployments.length})
</TabsTrigger>
<TabsTrigger value="services" className="data-[state=active]:bg-slate-700">
Service ({k8sServices.length})
Service ({services.length})
</TabsTrigger>
<TabsTrigger value="ingresses" className="data-[state=active]:bg-slate-700">
Ingress ({k8sIngresses.length})
Ingress ({ingresses.length})
</TabsTrigger>
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
事件
@@ -440,19 +525,19 @@ export function K8sClusters() {
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-blue-400">{clusterDetail.pods}</div>
<div className="text-3xl font-bold text-blue-400">{clusterDetail.pod_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">Pod</div>
</div>
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-emerald-400">{clusterDetail.services}</div>
<div className="text-3xl font-bold text-emerald-400">{clusterDetail.service_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">Service</div>
</div>
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-purple-400">{clusterDetail.deployments}</div>
<div className="text-3xl font-bold text-purple-400">{clusterDetail.deployment_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">Deployment</div>
</div>
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-amber-400">{clusterDetail.nodes}</div>
<div className="text-3xl font-bold text-amber-400">{clusterDetail.node_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">节点</div>
</div>
</div>
@@ -476,8 +561,13 @@ export function K8sClusters() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{clusterNodes.map((node) => (
<tr key={node.id} className="hover:bg-slate-800/50">
{nodes.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">暂无节点数据</td>
</tr>
) : (
nodes.map((node) => (
<tr key={node.id || node.name} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{node.name}</td>
<td className="px-4 py-3">
<Badge
@@ -504,15 +594,16 @@ export function K8sClusters() {
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{node.cpuUsage.toFixed(1)}% / {node.cpu}核
{Number(node.cpu_usage || 0).toFixed(1)}% / {node.cpu}核
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{node.memoryUsage.toFixed(1)}% / {node.memory}GB
{Number(node.memory_usage || 0).toFixed(1)}% / {node.memory}GB
</td>
<td className="px-4 py-3 text-sm text-slate-300">{node.pods}</td>
<td className="px-4 py-3 text-sm text-slate-300">{node.pods ?? node.pod_count ?? 0}</td>
<td className="px-4 py-3 text-sm text-slate-400">{node.os}</td>
</tr>
))}
))
)}
</tbody>
</table>
</div>
@@ -533,8 +624,13 @@ export function K8sClusters() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{clusterPods.map((pod) => (
<tr key={pod.id} className="hover:bg-slate-800/50">
{pods.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">暂无Pod数据</td>
</tr>
) : (
pods.map((pod) => (
<tr key={pod.id || pod.name} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{pod.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400">
@@ -555,12 +651,13 @@ export function K8sClusters() {
{pod.status}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.restarts}</td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.restarts ?? 0}</td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.cpu}</td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.memory}</td>
<td className="px-4 py-3 text-sm text-slate-400">{pod.node}</td>
</tr>
))}
))
)}
</tbody>
</table>
</div>
@@ -580,8 +677,13 @@ export function K8sClusters() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{k8sDeployments.map((deployment) => (
<tr key={deployment.id} className="hover:bg-slate-800/50">
{deployments.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">暂无Deployment数据</td>
</tr>
) : (
deployments.map((deployment) => (
<tr key={deployment.id || deployment.name} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{deployment.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400">
@@ -590,14 +692,15 @@ export function K8sClusters() {
</td>
<td className="px-4 py-3 text-sm text-slate-300">{deployment.replicas}</td>
<td className="px-4 py-3">
<span className={`text-sm ${deployment.availableReplicas === deployment.replicas ? 'text-emerald-400' : 'text-amber-400'}`}>
{deployment.availableReplicas} / {deployment.replicas}
<span className={`text-sm ${(deployment.available_replicas ?? deployment.availableReplicas) === deployment.replicas ? 'text-emerald-400' : 'text-amber-400'}`}>
{deployment.available_replicas ?? deployment.availableReplicas} / {deployment.replicas}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-400 truncate max-w-[200px]">{deployment.image}</td>
<td className="px-4 py-3 text-sm text-slate-400">{deployment.createdAt}</td>
<td className="px-4 py-3 text-sm text-slate-400">{deployment.created_at || deployment.createdAt}</td>
</tr>
))}
))
)}
</tbody>
</table>
</div>
@@ -617,8 +720,13 @@ export function K8sClusters() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{k8sServices.map((service) => (
<tr key={service.id} className="hover:bg-slate-800/50">
{services.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">暂无Service数据</td>
</tr>
) : (
services.map((service) => (
<tr key={service.id || service.name} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{service.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400">
@@ -639,11 +747,12 @@ export function K8sClusters() {
{service.type}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">{service.clusterIP}</td>
<td className="px-4 py-3 text-sm text-slate-300">{service.cluster_ip ?? service.clusterIP}</td>
<td className="px-4 py-3 text-sm text-slate-300">{service.ports}</td>
<td className="px-4 py-3 text-sm text-slate-400">{service.selector}</td>
</tr>
))}
))
)}
</tbody>
</table>
</div>
@@ -662,8 +771,13 @@ export function K8sClusters() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{k8sIngresses.map((ingress) => (
<tr key={ingress.id} className="hover:bg-slate-800/50">
{ingresses.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">暂无Ingress数据</td>
</tr>
) : (
ingresses.map((ingress) => (
<tr key={ingress.id || ingress.name} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{ingress.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400">
@@ -685,7 +799,8 @@ export function K8sClusters() {
</Badge>
</td>
</tr>
))}
))
)}
</tbody>
</table>
</div>
+49 -4
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useThemeStore, useTabStore, useNotificationStore } from '@/store';
import { useThemeStore, useTabStore, useNotificationStore, useUserStore } from '@/store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -31,15 +31,27 @@ import {
Rocket,
Server,
Cloud,
Palette,
Check,
} from 'lucide-react';
import { formatDistanceToNow } from '@/lib/utils';
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
export function Header() {
const { sidebarCollapsed, toggleSidebar } = useThemeStore();
const { tabs, activeTab, removeTab, setActiveTab } = useTabStore();
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotificationStore();
const { user, logout } = useUserStore();
const { theme, setTheme } = useTheme();
const [searchValue, setSearchValue] = useState('');
const handleLogout = () => {
logout();
window.location.href = '/login';
};
const displayName = user?.real_name || user?.username || '管理员';
const getNotificationIcon = (type: string) => {
switch (type) {
case 'error':
@@ -101,6 +113,39 @@ export function Header() {
{/* Right: Actions */}
<div className="flex items-center gap-2">
{/* Theme Switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">主题切换</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
{THEME_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.id}
className={cn(
'flex items-center gap-2 cursor-pointer focus:bg-slate-700 focus:text-slate-100',
theme === option.id ? 'text-slate-100' : 'text-slate-300'
)}
onClick={() => setTheme(option.id)}
>
<span
className="w-3 h-3 rounded-full shrink-0 ring-2 ring-slate-900"
style={{ backgroundColor: option.primaryColor }}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{option.name}</span>
<span className="block text-xs text-slate-500 truncate">{option.description}</span>
</div>
{theme === option.id && <Check className="h-4 w-4 text-blue-400 shrink-0" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Create New */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -201,9 +246,9 @@ export function Header() {
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 gap-2 px-2 text-slate-300 hover:text-slate-100">
<div className="w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-medium">
管
{displayName.charAt(0)}
</div>
<span className="hidden md:inline text-sm">管理员</span>
<span className="hidden md:inline text-sm">{displayName}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
@@ -218,7 +263,7 @@ export function Header() {
账号设置
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400" onClick={handleLogout}>
<LogOut className="h-4 w-4 mr-2" />
退出登录
</DropdownMenuItem>
@@ -1,4 +1,4 @@
import { useState } from 'react';
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';
@@ -34,9 +34,9 @@ import {
MessageSquare,
Phone,
VolumeX,
Loader2,
} from 'lucide-react';
import { alertRules, alertEvents } from '@/mocks/data';
import type { AlertEvent } from '@/types';
import { alertApi } from '@/services/api';
import {
AreaChart,
Area,
@@ -47,22 +47,59 @@ import {
ResponsiveContainer,
} from 'recharts';
function hasChannel(notifyChannels: any, channel: string): boolean {
if (!notifyChannels) return false;
if (Array.isArray(notifyChannels)) return notifyChannels.includes(channel);
if (typeof notifyChannels === 'string') {
try {
const parsed = JSON.parse(notifyChannels);
return Array.isArray(parsed) ? parsed.includes(channel) : notifyChannels.includes(channel);
} catch {
return notifyChannels.includes(channel);
}
}
return false;
}
export function AlertManager() {
const [activeTab, setActiveTab] = useState('events');
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [alerts, setAlerts] = useState<any[]>([]);
const [rules, setRules] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const filteredEvents = alertEvents.filter((event) => {
const loadData = async () => {
try {
setLoading(true);
const [alertsData, rulesData] = await Promise.all([
alertApi.getAlerts(),
alertApi.getRules(),
]);
setAlerts((alertsData as any)?.list || []);
setRules(rulesData || []);
} catch (error) {
console.error('Failed to load alert data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredEvents = alerts.filter((event) => {
const matchesSearch =
event.ruleName.toLowerCase().includes(searchQuery.toLowerCase()) ||
event.target.toLowerCase().includes(searchQuery.toLowerCase());
((event.rule && event.rule.name) || event.message || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(event.target || '').toLowerCase().includes(searchQuery.toLowerCase());
const matchesSeverity = severityFilter === 'all' || event.severity === severityFilter;
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
return matchesSearch && matchesSeverity && matchesStatus;
});
const getSeverityBadge = (severity: AlertEvent['severity']) => {
const getSeverityBadge = (severity: any) => {
switch (severity) {
case 'critical':
return (
@@ -90,7 +127,7 @@ export function AlertManager() {
}
};
const getStatusBadge = (status: AlertEvent['status']) => {
const getStatusBadge = (status: any) => {
switch (status) {
case 'firing':
return (
@@ -109,6 +146,24 @@ export function AlertManager() {
}
};
const handleAcknowledge = async (id: any) => {
try {
await alertApi.acknowledge(id);
loadData();
} catch (error) {
console.error('Failed to acknowledge alert:', error);
}
};
const handleToggleRule = async (id: any) => {
try {
await alertApi.toggleRule(id);
loadData();
} catch (error) {
console.error('Failed to toggle rule:', error);
}
};
// 告警趋势数据
const alertTrendData = [
{ time: '00:00', critical: 2, warning: 5, info: 8 },
@@ -147,7 +202,7 @@ export function AlertManager() {
<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">{alertRules.length}</p>
<p className="text-2xl font-bold text-slate-100">{rules.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Bell className="h-5 w-5" />
@@ -161,7 +216,7 @@ export function AlertManager() {
<div>
<p className="text-sm text-slate-500">触发中</p>
<p className="text-2xl font-bold text-red-400">
{alertEvents.filter((e) => e.status === 'firing').length}
{alerts.filter((e) => e.status === 'firing').length}
</p>
</div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
@@ -175,7 +230,7 @@ export function AlertManager() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">今日告警</p>
<p className="text-2xl font-bold text-amber-400">{alertEvents.length}</p>
<p className="text-2xl font-bold text-amber-400">{alerts.length}</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Clock className="h-5 w-5" />
@@ -189,7 +244,7 @@ export function AlertManager() {
<div>
<p className="text-sm text-slate-500">已恢复</p>
<p className="text-2xl font-bold text-emerald-400">
{alertEvents.filter((e) => e.status === 'resolved').length}
{alerts.filter((e) => e.status === 'resolved').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
@@ -318,11 +373,20 @@ export function AlertManager() {
</TableRow>
</TableHeader>
<TableBody>
{filteredEvents.map((event) => (
{loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={8} className="text-center text-slate-500 py-8">
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
加载中...
</TableCell>
</TableRow>
) : filteredEvents.map((event) => (
<TableRow key={event.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>{getSeverityBadge(event.severity)}</TableCell>
<TableCell>
<span className="text-sm font-medium text-slate-200">{event.ruleName}</span>
<span className="text-sm font-medium text-slate-200">
{(event.rule && event.rule.name) || event.message}
</span>
</TableCell>
<TableCell>{getStatusBadge(event.status)}</TableCell>
<TableCell>
@@ -336,7 +400,7 @@ export function AlertManager() {
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{new Date(event.startedAt).toLocaleString()}
{event.created_at ? new Date(event.created_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell className="text-right">
@@ -346,6 +410,7 @@ export function AlertManager() {
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-emerald-400"
onClick={() => handleAcknowledge(event.id)}
>
<CheckCircle className="h-4 w-4 mr-1" />
确认
@@ -380,7 +445,7 @@ export function AlertManager() {
</TableRow>
</TableHeader>
<TableBody>
{alertRules.map((rule) => (
{rules.map((rule) => (
<TableRow key={rule.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div>
@@ -414,23 +479,27 @@ export function AlertManager() {
)}
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{rule.duration / 60}分钟</span>
<span className="text-sm text-slate-300">{(rule.duration || 0) / 60}分钟</span>
</TableCell>
<TableCell>
<div className="flex gap-1">
{rule.notifyChannels.includes('email') && (
{hasChannel(rule.notify_channels, 'email') && (
<Mail className="h-4 w-4 text-slate-400" />
)}
{rule.notifyChannels.includes('sms') && (
{hasChannel(rule.notify_channels, 'sms') && (
<Phone className="h-4 w-4 text-slate-400" />
)}
{rule.notifyChannels.includes('dingtalk') && (
{hasChannel(rule.notify_channels, 'dingtalk') && (
<MessageSquare className="h-4 w-4 text-slate-400" />
)}
</div>
</TableCell>
<TableCell>
<Switch checked={rule.enabled} className="data-[state=checked]:bg-blue-600" />
<Switch
checked={!!rule.enabled}
className="data-[state=checked]:bg-blue-600"
onCheckedChange={() => handleToggleRule(rule.id)}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
@@ -1,8 +1,9 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react';
import {
Search,
ExternalLink,
@@ -15,16 +16,16 @@ import {
Star,
Settings
} from 'lucide-react';
import { navigationGroups, navigationLinks } from '@/mocks/data';
import { navigationApi } from '@/services/api';
interface NavLink {
id: string;
name: string;
title: string;
url: string;
icon?: string;
description?: string;
groupId: string;
isFavorite?: boolean;
group_id: string;
is_favorite?: boolean;
tags?: string[];
}
@@ -33,7 +34,7 @@ interface NavGroup {
name: string;
icon?: string;
description?: string;
linkCount?: number;
sort_order?: number;
}
export function NavigationPage() {
@@ -41,28 +42,66 @@ export function NavigationPage() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [selectedGroup, setSelectedGroup] = useState<string>('all');
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [groups, setGroups] = useState<NavGroup[]>([]);
const [links, setLinks] = useState<NavLink[]>([]);
const [loading, setLoading] = useState(true);
const groups = navigationGroups;
const links = navigationLinks;
const loadData = async () => {
try {
setLoading(true);
const [groupsData, linksData] = await Promise.all([
navigationApi.getGroups(),
navigationApi.getLinks(),
]);
setGroups(groupsData || []);
setLinks(linksData || []);
} catch (error) {
console.error('Failed to load navigation data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleToggleFavorite = async (id: string) => {
try {
await navigationApi.toggleFavorite(id);
loadData();
} catch (error) {
console.error('Failed to toggle favorite:', error);
}
};
const handleDeleteLink = async (id: string) => {
try {
await navigationApi.deleteLink(id);
loadData();
} catch (error) {
console.error('Failed to delete link:', error);
}
};
const filteredLinks = links.filter(link => {
const matchesSearch = link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
link.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
link.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesGroup = selectedGroup === 'all' || link.groupId === selectedGroup;
const matchesFavorite = !showFavoritesOnly || link.isFavorite;
const matchesSearch = (link.title || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(link.description || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(link.tags || []).some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesGroup = selectedGroup === 'all' || link.group_id === selectedGroup;
const matchesFavorite = !showFavoritesOnly || link.is_favorite;
return matchesSearch && matchesGroup && matchesFavorite;
});
const groupedLinks = groups.map(group => ({
...group,
links: filteredLinks.filter(link => link.groupId === group.id)
links: filteredLinks.filter(link => link.group_id === group.id)
})).filter(group => group.links.length > 0);
const stats = {
totalLinks: links.length,
totalGroups: groups.length,
favorites: links.filter(l => l.isFavorite).length,
favorites: links.filter(l => l.is_favorite).length,
};
return (
@@ -178,6 +217,12 @@ export function NavigationPage() {
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : null}
{/* Navigation Content */}
<div className="space-y-6">
{selectedGroup === 'all' && !searchQuery && !showFavoritesOnly ? (
@@ -194,13 +239,13 @@ export function NavigationPage() {
{viewMode === 'grid' ? (
<div className="grid grid-cols-4 gap-4">
{group.links.map(link => (
<NavLinkCard key={link.id} link={link} />
<NavLinkCard key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
))}
</div>
) : (
<div className="space-y-2">
{group.links.map(link => (
<NavLinkRow key={link.id} link={link} />
<NavLinkRow key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
))}
</div>
)}
@@ -211,13 +256,13 @@ export function NavigationPage() {
viewMode === 'grid' ? (
<div className="grid grid-cols-4 gap-4">
{filteredLinks.map(link => (
<NavLinkCard key={link.id} link={link} />
<NavLinkCard key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
))}
</div>
) : (
<div className="space-y-2">
{filteredLinks.map(link => (
<NavLinkRow key={link.id} link={link} />
<NavLinkRow key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
))}
</div>
)
@@ -227,23 +272,23 @@ export function NavigationPage() {
);
}
function NavLinkCard({ link }: { link: NavLink }) {
function NavLinkCard({ link, onToggleFavorite, onDelete }: { link: NavLink; onToggleFavorite: (id: string) => void; onDelete: (id: string) => void }) {
return (
<Card className="bg-slate-800 border-slate-700 hover:border-blue-500/50 transition-colors group">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-lg">
{link.name.slice(0, 2)}
{(link.title || '').slice(0, 2)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-slate-100 truncate">{link.name}</h3>
<h3 className="font-medium text-slate-100 truncate">{link.title}</h3>
<p className="text-xs text-slate-400 mt-0.5 line-clamp-2">{link.description}</p>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400">
<Star className={`h-4 w-4 ${link.isFavorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400" onClick={() => onToggleFavorite(link.id)}>
<Star className={`h-4 w-4 ${link.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
@@ -252,7 +297,7 @@ function NavLinkCard({ link }: { link: NavLink }) {
</div>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-700">
<div className="flex gap-1">
{link.tags?.map(tag => (
{(link.tags || []).map(tag => (
<Badge key={tag} variant="secondary" className="bg-slate-700 text-slate-300 text-xs">
{tag}
</Badge>
@@ -270,31 +315,31 @@ function NavLinkCard({ link }: { link: NavLink }) {
);
}
function NavLinkRow({ link }: { link: NavLink }) {
function NavLinkRow({ link, onToggleFavorite, onDelete }: { link: NavLink; onToggleFavorite: (id: string) => void; onDelete: (id: string) => void }) {
return (
<Card className="bg-slate-800 border-slate-700 hover:border-blue-500/50 transition-colors">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-sm">
{link.name.slice(0, 2)}
{(link.title || '').slice(0, 2)}
</div>
<div>
<h3 className="font-medium text-slate-100">{link.name}</h3>
<h3 className="font-medium text-slate-100">{link.title}</h3>
<p className="text-xs text-slate-400">{link.description}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-1">
{link.tags?.map(tag => (
{(link.tags || []).map(tag => (
<Badge key={tag} variant="secondary" className="bg-slate-700 text-slate-300 text-xs">
{tag}
</Badge>
))}
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400">
<Star className={`h-4 w-4 ${link.isFavorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400" onClick={() => onToggleFavorite(link.id)}>
<Star className={`h-4 w-4 ${link.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@@ -20,7 +20,7 @@ import {
RefreshCw,
Check,
} from 'lucide-react';
import { cloudAccounts } from '@/mocks/data';
import { cloudApi } from '@/services/api';
interface CloudServerImportProps {
open: boolean;
@@ -45,12 +45,26 @@ const mockCloudServers = [
];
export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
const [cloudAccounts, setCloudAccounts] = useState<any[]>([]);
const [step, setStep] = useState(1);
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedAccount, setSelectedAccount] = useState<string>('');
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const loadAccounts = async () => {
try {
const res: any = await cloudApi.getAccounts();
setCloudAccounts(res?.list || []);
} catch (e) {
console.error('加载云账号失败:', e);
}
};
useEffect(() => {
loadAccounts();
}, []);
const filteredAccounts = cloudAccounts.filter(
acc => acc.provider === selectedProvider && acc.status === 'active'
);
@@ -196,14 +210,14 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
<Key className="h-5 w-5 text-blue-400" />
<div>
<p className="font-medium text-slate-200">{account.name}</p>
<p className="text-xs text-slate-500">{account.accessKey.slice(0, 20)}...</p>
<p className="text-xs text-slate-500">{(account.access_key || account.accessKey || '').slice(0, 20)}...</p>
</div>
</div>
<div className="text-right">
<Badge className="bg-emerald-500/20 text-emerald-400 border-0">
{account.regionCount} 区域
{account.region_count ?? account.regionCount ?? 0} 区域
</Badge>
<p className="text-xs text-slate-500 mt-1">{account.serverCount} 服务器</p>
<p className="text-xs text-slate-500 mt-1">{account.server_count ?? account.serverCount ?? 0} 服务器</p>
</div>
</CardContent>
</Card>
+228 -137
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -58,13 +58,16 @@ import {
Square,
Cloud,
FileSpreadsheet,
RefreshCw,
} from 'lucide-react';
import { servers } from '@/mocks/data';
import { serverApi } from '@/services/api';
import type { Server as ServerType } from '@/types';
import { CloudServerImport } from './CloudServerImport';
import { ExcelServerImport } from './ExcelServerImport';
export function ServerManager() {
const [servers, setServers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
@@ -72,12 +75,53 @@ export function ServerManager() {
const [cloudImportOpen, setCloudImportOpen] = useState(false);
const [excelImportOpen, setExcelImportOpen] = useState(false);
const loadData = async () => {
try {
setLoading(true);
const res: any = await serverApi.getServers();
setServers(res?.list || []);
} catch (e) {
console.error('加载服务器列表失败:', e);
setServers([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
// 解析标签:后端 tags 可能是 JSON 字符串、逗号分隔字符串或数组
const getTags = (server: any): string[] => {
const tags = server.tags;
if (!tags) return [];
if (Array.isArray(tags)) return tags;
try {
const parsed = JSON.parse(tags);
return Array.isArray(parsed) ? parsed : [String(tags)];
} catch {
return String(tags)
.split(',')
.map((t: string) => t.trim())
.filter(Boolean);
}
};
// 获取区域名称:后端 region 可能是对象 {name} 或字符串
const getRegionName = (server: any): string => {
if (!server.region) return '';
if (typeof server.region === 'string') return server.region;
return server.region.name || '';
};
const filteredServers = servers.filter((server) => {
const region = getRegionName(server);
const matchesSearch =
server.hostname.toLowerCase().includes(searchQuery.toLowerCase()) ||
server.ip.includes(searchQuery);
(server.hostname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(server.ip || '').includes(searchQuery);
const matchesStatus = statusFilter === 'all' || server.status === statusFilter;
const matchesRegion = regionFilter === 'all' || server.region === regionFilter;
const matchesRegion = regionFilter === 'all' || region === regionFilter;
return matchesSearch && matchesStatus && matchesRegion;
});
@@ -97,6 +141,15 @@ export function ServerManager() {
}
};
const handleDelete = async (id: string) => {
try {
await serverApi.deleteServer(id);
loadData();
} catch (e) {
console.error('删除服务器失败:', e);
}
};
const getStatusBadge = (status: ServerType['status']) => {
switch (status) {
case 'running':
@@ -130,7 +183,7 @@ export function ServerManager() {
}
};
const regions = Array.from(new Set(servers.map((s) => s.region)));
const regions = Array.from(new Set(servers.map((s) => getRegionName(s)).filter(Boolean)));
return (
<div className="space-y-6">
@@ -141,6 +194,14 @@ export function ServerManager() {
<p className="text-sm text-slate-500 mt-1">管理云平台服务器资产</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
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">
<Download className="h-4 w-4 mr-2" />
导出
@@ -155,7 +216,7 @@ export function ServerManager() {
<DropdownMenuContent className="bg-slate-800 border-slate-700 text-slate-100">
<DropdownMenuLabel>选择导入方式</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem
<DropdownMenuItem
className="hover:bg-slate-700 cursor-pointer"
onClick={() => setCloudImportOpen(true)}
>
@@ -163,7 +224,7 @@ export function ServerManager() {
云主机导入
<span className="ml-2 text-xs text-slate-500">从云平台同步</span>
</DropdownMenuItem>
<DropdownMenuItem
<DropdownMenuItem
className="hover:bg-slate-700 cursor-pointer"
onClick={() => setExcelImportOpen(true)}
>
@@ -322,131 +383,155 @@ export function ServerManager() {
</TableRow>
</TableHeader>
<TableBody>
{filteredServers.map((server) => (
<TableRow key={server.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<Checkbox
checked={selectedServers.includes(server.id)}
onCheckedChange={() => toggleSelect(server.id)}
className="border-slate-600"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-slate-500" />
<div>
<p className="font-medium text-slate-200">{server.hostname}</p>
<p className="text-xs text-slate-500">{server.os}</p>
</div>
</div>
</TableCell>
<TableCell>
<code className="text-sm text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
{server.ip}
</code>
</TableCell>
<TableCell>{getStatusBadge(server.status)}</TableCell>
<TableCell>
<div className="text-sm text-slate-300">
<span className="text-slate-500">{server.cpu}核</span>
<span className="mx-1 text-slate-600">/</span>
<span className="text-slate-500">{server.memory}GB</span>
<span className="mx-1 text-slate-600">/</span>
<span className="text-slate-500">{server.disk}GB</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-8">CPU</span>
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
server.cpuUsage > 80 ? 'bg-red-400' : server.cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${server.cpuUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400">{server.cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-8">内存</span>
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
server.memoryUsage > 80 ? 'bg-red-400' : server.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${server.memoryUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400">{server.memoryUsage.toFixed(1)}%</span>
</div>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{server.region}</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{server.tags.slice(0, 3).map((tag) => (
<Badge
key={tag}
variant="outline"
className="text-xs border-slate-700 text-slate-400 bg-slate-800/50"
>
{tag}
</Badge>
))}
{server.tags.length > 3 && (
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
+{server.tags.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Terminal className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Eye className="h-4 w-4 mr-2" />
查看详情
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Edit className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Terminal className="h-4 w-4 mr-2" />
WebShell
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<RotateCw className="h-4 w-4 mr-2" />
重启
</DropdownMenuItem>
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={9} className="text-center text-slate-500 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">
暂无服务器数据
</TableCell>
</TableRow>
) : (
filteredServers.map((server) => {
const cpuUsage = Number(server.cpu_usage || 0);
const memoryUsage = Number(server.memory_usage || 0);
const tags = getTags(server);
const region = getRegionName(server);
return (
<TableRow key={server.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<Checkbox
checked={selectedServers.includes(server.id)}
onCheckedChange={() => toggleSelect(server.id)}
className="border-slate-600"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-slate-500" />
<div>
<p className="font-medium text-slate-200">{server.hostname}</p>
<p className="text-xs text-slate-500">{server.os}</p>
</div>
</div>
</TableCell>
<TableCell>
<code className="text-sm text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
{server.ip}
</code>
</TableCell>
<TableCell>{getStatusBadge(server.status)}</TableCell>
<TableCell>
<div className="text-sm text-slate-300">
<span className="text-slate-500">{server.cpu}核</span>
<span className="mx-1 text-slate-600">/</span>
<span className="text-slate-500">{server.memory}GB</span>
<span className="mx-1 text-slate-600">/</span>
<span className="text-slate-500">{server.disk}GB</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-8">CPU</span>
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
cpuUsage > 80 ? 'bg-red-400' : cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${cpuUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400">{cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-8">内存</span>
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
memoryUsage > 80 ? 'bg-red-400' : memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${memoryUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400">{memoryUsage.toFixed(1)}%</span>
</div>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{region || '-'}</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag) => (
<Badge
key={tag}
variant="outline"
className="text-xs border-slate-700 text-slate-400 bg-slate-800/50"
>
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
+{tags.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Terminal className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Eye className="h-4 w-4 mr-2" />
查看详情
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Edit className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Terminal className="h-4 w-4 mr-2" />
WebShell
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<RotateCw className="h-4 w-4 mr-2" />
重启
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
onClick={() => handleDelete(server.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
@@ -454,13 +539,19 @@ export function ServerManager() {
</Card>
{/* Import Dialogs */}
<CloudServerImport
open={cloudImportOpen}
onClose={() => setCloudImportOpen(false)}
<CloudServerImport
open={cloudImportOpen}
onClose={() => {
setCloudImportOpen(false);
loadData();
}}
/>
<ExcelServerImport
open={excelImportOpen}
onClose={() => setExcelImportOpen(false)}
<ExcelServerImport
open={excelImportOpen}
onClose={() => {
setExcelImportOpen(false);
loadData();
}}
/>
</div>
);
+16 -1
View File
@@ -38,7 +38,7 @@ import {
Maximize2,
Minimize2,
} from 'lucide-react';
import { servers } from '@/mocks/data';
import { serverApi } from '@/services/api';
interface TerminalSession {
id: string;
@@ -50,6 +50,7 @@ interface TerminalSession {
}
export function WebShell() {
const [servers, setServers] = useState<any[]>([]);
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSession, setActiveSession] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<string>('');
@@ -57,6 +58,20 @@ export function WebShell() {
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null);
const loadServers = async () => {
try {
const res: any = await serverApi.getServers();
setServers(res?.list || []);
} catch (e) {
console.error('加载服务器列表失败:', e);
setServers([]);
}
};
useEffect(() => {
loadServers();
}, []);
const runningServers = servers.filter((s) => s.status === 'running');
const createSession = () => {
+319 -71
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -51,20 +51,68 @@ import {
LogOut,
RefreshCw,
} from 'lucide-react';
import { users, roles, operationLogs } from '@/mocks/data';
import type { User as UserType, Role, OperationLog } from '@/types';
import { roles, operationLogs } from '@/mocks/data';
import { userApi } from '@/services/api';
import type { Role, OperationLog } from '@/types';
// 后端用户数据转换为前端兼容格式
function mapUser(u: any) {
return {
...u,
status: u.status === 1 || u.status === 'active' ? ('active' as const) : ('inactive' as const),
roles: Array.isArray(u.roles) ? u.roles : u.role ? [u.role] : [],
lastLoginAt: u.last_login ?? u.lastLoginAt,
createdAt: u.created_at ?? u.createdAt,
};
}
const emptyForm = {
username: '',
nickname: '',
email: '',
phone: '',
role: '',
password: '',
status: 1 as number,
};
export function SystemManager() {
const [activeTab, setActiveTab] = useState('users');
const [searchQuery, setSearchQuery] = useState('');
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUser, setSelectedUser] = useState<any | null>(null);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
// 创建/编辑用户弹窗
const [dialogOpen, setDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<any | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ ...emptyForm });
const loadUsers = async () => {
setLoading(true);
try {
const res = await userApi.getUsers({ page: 1, page_size: 100 });
const rawUsers = res?.list ?? [];
setUsers(rawUsers.map(mapUser));
} catch (error) {
console.error('Failed to fetch users:', error);
setUsers([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
const filteredUsers = users.filter(
(u) =>
u.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.email.toLowerCase().includes(searchQuery.toLowerCase())
(u.username || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(u.nickname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(u.email || '').toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredLogs = operationLogs.filter(
@@ -74,7 +122,7 @@ export function SystemManager() {
log.action.toLowerCase().includes(searchQuery.toLowerCase())
);
const getUserStatusBadge = (status: UserType['status']) => {
const getUserStatusBadge = (status: 'active' | 'inactive') => {
return status === 'active' ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
@@ -100,6 +148,64 @@ export function SystemManager() {
);
};
const openCreate = () => {
setEditingUser(null);
setForm({ ...emptyForm });
setDialogOpen(true);
};
const openEdit = (user: any) => {
setEditingUser(user);
setForm({
username: user.username || '',
nickname: user.nickname || '',
email: user.email || '',
phone: user.phone || '',
role: Array.isArray(user.roles) ? user.roles[0] || '' : user.role || '',
password: '',
status: user.status === 'active' ? 1 : 0,
});
setDialogOpen(true);
};
const handleSubmit = async () => {
setSaving(true);
try {
const payload: any = {
username: form.username,
nickname: form.nickname,
email: form.email,
phone: form.phone,
role: form.role,
status: form.status,
};
if (form.password) {
payload.password = form.password;
}
if (editingUser) {
await userApi.updateUser(editingUser.id, payload);
} else {
await userApi.createUser(payload);
}
setDialogOpen(false);
await loadUsers();
} catch (error) {
console.error('Failed to save user:', error);
} finally {
setSaving(false);
}
};
const handleDelete = async (user: any) => {
if (!window.confirm(`确认删除用户「${user.nickname || user.username}」吗?`)) return;
try {
await userApi.deleteUser(user.id);
await loadUsers();
} catch (error) {
console.error('Failed to delete user:', error);
}
};
return (
<div className="space-y-6">
{/* Page Header */}
@@ -143,7 +249,7 @@ export function SystemManager() {
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
新建用户
</Button>
@@ -165,68 +271,94 @@ export function SystemManager() {
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
{user.nickname.charAt(0)}
</div>
<div>
<p className="text-sm font-medium text-slate-200">{user.nickname}</p>
<p className="text-xs text-slate-500">@{user.username}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-1 text-sm text-slate-400">
<Mail className="h-3.5 w-3.5" />
{user.email}
</div>
{user.phone && (
<div className="flex items-center gap-1 text-sm text-slate-400">
<Phone className="h-3.5 w-3.5" />
{user.phone}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.roles.map((role) => (
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
{role}
</Badge>
))}
</div>
</TableCell>
<TableCell>{getUserStatusBadge(user.status)}</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '--'}
</span>
</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"
onClick={() => setSelectedUser(user)}
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
<Trash2 className="h-4 w-4" />
</Button>
{loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={6} className="text-center py-12">
<div className="flex items-center justify-center">
<div className="h-6 w-6 border-2 border-slate-700 border-t-blue-500 rounded-full animate-spin" />
</div>
</TableCell>
</TableRow>
))}
) : filteredUsers.length === 0 ? (
<TableRow className="border-slate-800">
<TableCell colSpan={6} className="text-center py-12 text-sm text-slate-500">
暂无用户数据
</TableCell>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
{(user.nickname || user.username || '?').charAt(0)}
</div>
<div>
<p className="text-sm font-medium text-slate-200">{user.nickname || user.username}</p>
<p className="text-xs text-slate-500">@{user.username}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-1 text-sm text-slate-400">
<Mail className="h-3.5 w-3.5" />
{user.email || '--'}
</div>
{user.phone && (
<div className="flex items-center gap-1 text-sm text-slate-400">
<Phone className="h-3.5 w-3.5" />
{user.phone}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(user.roles || []).map((role: string) => (
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
{role}
</Badge>
))}
</div>
</TableCell>
<TableCell>{getUserStatusBadge(user.status)}</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '--'}
</span>
</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"
onClick={() => setSelectedUser(user)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => openEdit(user)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDelete(user)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
@@ -536,7 +668,7 @@ export function SystemManager() {
<div>
<p className="text-xs text-slate-500 mb-1">创建时间</p>
<p className="text-sm text-slate-200">
{selectedUser && new Date(selectedUser.createdAt).toLocaleDateString()}
{selectedUser && selectedUser.createdAt ? new Date(selectedUser.createdAt).toLocaleDateString() : '--'}
</p>
</div>
</div>
@@ -544,7 +676,7 @@ export function SystemManager() {
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">角色</h4>
<div className="flex flex-wrap gap-2">
{selectedUser?.roles.map((role) => (
{selectedUser?.roles.map((role: string) => (
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
{role}
</Badge>
@@ -558,13 +690,129 @@ export function SystemManager() {
<Key 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={() => {
if (selectedUser) {
setSelectedUser(null);
openEdit(selectedUser);
}
}}
>
<Edit className="h-4 w-4 mr-2" />
编辑
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create / Edit User Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<User className="h-5 w-5 text-blue-400" />
{editingUser ? '编辑用户' : '新建用户'}
</DialogTitle>
<DialogDescription className="text-slate-500">
{editingUser ? '修改用户信息' : '创建新的系统用户'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs text-slate-500">用户名</label>
<Input
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
disabled={!!editingUser}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
placeholder="登录用户名"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">昵称</label>
<Input
value={form.nickname}
onChange={(e) => setForm({ ...form, nickname: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
placeholder="显示昵称"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">邮箱</label>
<Input
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
placeholder="user@example.com"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">手机号</label>
<Input
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
placeholder="可选"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">角色</label>
<Select value={form.role} onValueChange={(v) => setForm({ ...form, role: v })}>
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="选择角色" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{roles.map((role) => (
<SelectItem key={role.id} value={role.name}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">状态</label>
<Select
value={String(form.status)}
onValueChange={(v) => setForm({ ...form, status: Number(v) })}
>
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="1">正常</SelectItem>
<SelectItem value="0">禁用</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">
密码{editingUser ? '(留空则不修改)' : ''}
</label>
<Input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
placeholder={editingUser ? '留空则不修改' : '设置初始密码'}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleSubmit} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}