refactor: 全局代码模块化重构 — 所有文件<700行

后端 handler 拆分:
- automation.go(1067→4文件), server.go(1011→5文件), ldap.go(815→3文件), user.go(704→2文件)

前端组件拆分:
- SystemManager(1782→569), AlertManager(1133→220), K8sClusters(1062→4文件)
- Dashboard(941→4文件), ServerManager(905→640), ApprovalFlow(830→548)
- AlertCenter(775→693), AIPredictiveAnalytics(760→670)
- CloudBilling(728→663), GrafanaDashboard(717→643), sidebar(726→689)

Mock 数据模块化:
- mocks/data.ts(2552行→barrel+11个domain模块)

新增 CLAUDE.md 项目全局开发规范

修复: AlertCenter/LogManager ListResponse.data→.list 类型不匹配
验证: go build ✅ + tsc --noEmit ✅ + npm run build ✅
This commit is contained in:
attacker committed 2026-06-15 01:32:15 +08:00
1 parent e8dbe65955
commit 73b5a48df9
37 files changed
+5148 -5237

No files matched your search

@@ -32,6 +32,7 @@ import {
Loader2,
} from 'lucide-react';
import { dashboardStatsApi, serverApi, alertApi } from '@/services/api';
import { PredictiveCapacityTab } from './PredictiveCapacityTab';
interface PredictionMetric {
name: string;
@@ -43,7 +44,7 @@ interface PredictionMetric {
timeRange: string;
}
interface CapacityForecast {
export interface CapacityForecast {
resource: string;
metricKey: string;
currentUsage: number;
@@ -554,98 +555,7 @@ export function AIPredictiveAnalytics() {
</TabsContent>
<TabsContent value="capacity" className="space-y-4">
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-slate-100 flex items-center gap-2">
<HardDrive className="h-5 w-5 text-amber-400" />
容量规划预警
</CardTitle>
<span className="text-xs text-slate-500">基于日均增长率推算阈值触及时间</span>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载容量数据...
</div>
) : capacityForecasts.length === 0 ? (
<div className="text-center py-12 text-slate-500">暂无容量数据</div>
) : (
<div className="space-y-4">
{capacityForecasts.map((forecast, idx) => (
<div key={idx} className="p-4 rounded-lg border border-slate-700 bg-slate-800/50">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-blue-400" />
<span className="font-medium text-slate-200">{forecast.resource}</span>
</div>
<Badge
variant="outline"
className={
forecast.daysUntilThreshold <= 7
? 'border-red-500/30 text-red-400 bg-red-500/10'
: forecast.daysUntilThreshold <= 14
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
}
>
{forecast.daysUntilThreshold <= 7
? '紧急'
: forecast.daysUntilThreshold <= 14
? '警告'
: '正常'}
</Badge>
</div>
<div className="mb-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-400">7天使用率预测</span>
<span className="text-slate-400">
{forecast.daysUntilThreshold >= 999 ? (
'增长平缓,暂无触顶风险'
) : (
<>
预计{' '}
<span className="text-amber-400 font-medium">{forecast.daysUntilThreshold}天</span>{' '}
后达到阈值
</>
)}
</span>
</div>
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
<div className="flex h-full">
<div className="bg-blue-500" style={{ width: `${forecast.currentUsage}%` }} />
<div
className="bg-gradient-to-r from-blue-500/50 to-amber-500"
style={{
width: `${Math.max(0, forecast.predictedUsage - forecast.currentUsage)}%`,
}}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs mt-1">
<span className="text-slate-500">当前 {forecast.currentUsage}%</span>
<span className="text-amber-400">预测 {forecast.predictedUsage}%</span>
<span className="text-red-400">阈值 {forecast.threshold}%</span>
</div>
</div>
<div className="p-3 bg-slate-900 rounded-lg border border-slate-700">
<div className="flex items-start gap-2">
<Zap className="h-4 w-4 text-emerald-400 mt-0.5" />
<div>
<span className="text-sm text-slate-400">AI建议:</span>
<p className="text-sm text-slate-300 mt-1">{forecast.recommendation}</p>
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<PredictiveCapacityTab forecasts={capacityForecasts} loading={loading} />
</TabsContent>
<TabsContent value="anomaly" className="space-y-4">
@@ -0,0 +1,107 @@
import { HardDrive, Database, Zap, Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { CapacityForecast } from './AIPredictiveAnalytics';
export function PredictiveCapacityTab({
forecasts,
loading,
}: {
forecasts: CapacityForecast[];
loading: boolean;
}) {
return (
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-slate-100 flex items-center gap-2">
<HardDrive className="h-5 w-5 text-amber-400" />
容量规划预警
</CardTitle>
<span className="text-xs text-slate-500">基于日均增长率推算阈值触及时间</span>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载容量数据...
</div>
) : forecasts.length === 0 ? (
<div className="text-center py-12 text-slate-500">暂无容量数据</div>
) : (
<div className="space-y-4">
{forecasts.map((forecast, idx) => (
<div key={idx} className="p-4 rounded-lg border border-slate-700 bg-slate-800/50">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-blue-400" />
<span className="font-medium text-slate-200">{forecast.resource}</span>
</div>
<Badge
variant="outline"
className={
forecast.daysUntilThreshold <= 7
? 'border-red-500/30 text-red-400 bg-red-500/10'
: forecast.daysUntilThreshold <= 14
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
}
>
{forecast.daysUntilThreshold <= 7
? '紧急'
: forecast.daysUntilThreshold <= 14
? '警告'
: '正常'}
</Badge>
</div>
<div className="mb-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-400">7天使用率预测</span>
<span className="text-slate-400">
{forecast.daysUntilThreshold >= 999 ? (
'增长平缓,暂无触顶风险'
) : (
<>
预计{' '}
<span className="text-amber-400 font-medium">{forecast.daysUntilThreshold}天</span>{' '}
后达到阈值
</>
)}
</span>
</div>
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
<div className="flex h-full">
<div className="bg-blue-500" style={{ width: `${forecast.currentUsage}%` }} />
<div
className="bg-gradient-to-r from-blue-500/50 to-amber-500"
style={{
width: `${Math.max(0, forecast.predictedUsage - forecast.currentUsage)}%`,
}}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs mt-1">
<span className="text-slate-500">当前 {forecast.currentUsage}%</span>
<span className="text-amber-400">预测 {forecast.predictedUsage}%</span>
<span className="text-red-400">阈值 {forecast.threshold}%</span>
</div>
</div>
<div className="p-3 bg-slate-900 rounded-lg border border-slate-700">
<div className="flex items-start gap-2">
<Zap className="h-4 w-4 text-emerald-400 mt-0.5" />
<div>
<span className="text-sm text-slate-400">AI建议:</span>
<p className="text-sm text-slate-300 mt-1">{forecast.recommendation}</p>
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+1 -66
View File
@@ -36,6 +36,7 @@ import {
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { billingApi } from '@/services/api';
import { formatCurrency, StatCard, TrendChart } from './CloudBillingHelpers';
const providerLabels: Record<string, string> = {
aliyun: '阿里云',
@@ -53,10 +54,6 @@ const providerColors: Record<string, string> = {
azure: 'bg-sky-500',
};
function formatCurrency(n: number) {
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function CloudBilling() {
const confirm = useConfirm();
@@ -664,65 +661,3 @@ export function CloudBilling() {
);
}
function StatCard({ icon, label, value, trend, trendLabel, alert }: {
icon: React.ReactNode;
label: string;
value: string;
trend?: number;
trendLabel?: string;
alert?: boolean;
}) {
return (
<div className={`rounded-lg border p-4 ${alert ? 'border-amber-500/30 bg-amber-500/5' : 'border-slate-800 bg-slate-900/50'}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-slate-400 text-sm">{label}</span>
<span className={alert ? 'text-amber-400' : 'text-slate-500'}>{icon}</span>
</div>
<div className={`text-2xl font-bold ${alert ? 'text-amber-300' : 'text-slate-100'}`}>{value}</div>
{trendLabel && (
<div className="flex items-center gap-1 mt-1 text-xs">
{trend !== undefined && trend > 0 && <TrendingUp className="h-3 w-3 text-red-400" />}
{trend !== undefined && trend < 0 && <TrendingDown className="h-3 w-3 text-green-400" />}
<span className="text-slate-500">{trendLabel}</span>
</div>
)}
</div>
);
}
function TrendChart({ data }: { data: { date: string; cost: number }[] }) {
if (data.length === 0) {
return <p className="text-sm text-slate-500 py-8 text-center">暂无数据</p>;
}
const max = Math.max(...data.map((d) => d.cost), 1);
const recent = data.slice(-30);
return (
<div>
<div className="flex items-end gap-0.5 h-40">
{recent.map((d, i) => {
const h = Math.max((d.cost / max) * 100, 2);
return (
<div
key={i}
className="flex-1 group relative"
style={{ height: '100%' }}
>
<div
className="absolute bottom-0 w-full rounded-t-sm bg-gradient-to-t from-blue-600/60 to-blue-400/80 group-hover:from-blue-500 group-hover:to-blue-300 transition-colors"
style={{ height: `${h}%` }}
/>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block whitespace-nowrap text-xs bg-slate-800 text-slate-200 px-2 py-1 rounded border border-slate-700 z-10">
{d.date}: {formatCurrency(d.cost)}
</div>
</div>
);
})}
</div>
<div className="flex justify-between mt-2 text-xs text-slate-500">
<span>{recent[0]?.date}</span>
<span>{recent[recent.length - 1]?.date}</span>
</div>
</div>
);
}
@@ -0,0 +1,69 @@
import { type ReactNode } from 'react';
import { TrendingUp, TrendingDown } from 'lucide-react';
export function formatCurrency(n: number) {
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function StatCard({ icon, label, value, trend, trendLabel, alert }: {
icon: ReactNode;
label: string;
value: string;
trend?: number;
trendLabel?: string;
alert?: boolean;
}) {
return (
<div className={`rounded-lg border p-4 ${alert ? 'border-amber-500/30 bg-amber-500/5' : 'border-slate-800 bg-slate-900/50'}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-slate-400 text-sm">{label}</span>
<span className={alert ? 'text-amber-400' : 'text-slate-500'}>{icon}</span>
</div>
<div className={`text-2xl font-bold ${alert ? 'text-amber-300' : 'text-slate-100'}`}>{value}</div>
{trendLabel && (
<div className="flex items-center gap-1 mt-1 text-xs">
{trend !== undefined && trend > 0 && <TrendingUp className="h-3 w-3 text-red-400" />}
{trend !== undefined && trend < 0 && <TrendingDown className="h-3 w-3 text-green-400" />}
<span className="text-slate-500">{trendLabel}</span>
</div>
)}
</div>
);
}
export function TrendChart({ data }: { data: { date: string; cost: number }[] }) {
if (data.length === 0) {
return <p className="text-sm text-slate-500 py-8 text-center">暂无数据</p>;
}
const max = Math.max(...data.map((d) => d.cost), 1);
const recent = data.slice(-30);
return (
<div>
<div className="flex items-end gap-0.5 h-40">
{recent.map((d, i) => {
const h = Math.max((d.cost / max) * 100, 2);
return (
<div
key={i}
className="flex-1 group relative"
style={{ height: '100%' }}
>
<div
className="absolute bottom-0 w-full rounded-t-sm bg-gradient-to-t from-blue-600/60 to-blue-400/80 group-hover:from-blue-500 group-hover:to-blue-300 transition-colors"
style={{ height: `${h}%` }}
/>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block whitespace-nowrap text-xs bg-slate-800 text-slate-200 px-2 py-1 rounded border border-slate-700 z-10">
{d.date}: {formatCurrency(d.cost)}
</div>
</div>
);
})}
</div>
<div className="flex justify-between mt-2 text-xs text-slate-500">
<span>{recent[0]?.date}</span>
<span>{recent[recent.length - 1]?.date}</span>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -24,8 +24,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { alertCenterApi, alertApi } from '@/services/api';
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
interface Alert {
export interface Alert {
id: string;
title: string;
description: string;
@@ -102,7 +103,7 @@ const AlertCenter: React.FC = () => {
setLoading(true);
try {
const res = await alertCenterApi.getAlerts();
const data = res.data || [];
const data = res.list || [];
setAlerts(data);
setStats({
firing: data.filter((a: Alert) => a.status === 'firing').length,
@@ -169,7 +170,7 @@ const AlertCenter: React.FC = () => {
const toggleRule = async (ruleId: string, enabled: boolean) => {
try {
await alertCenterApi.toggleRule(ruleId, enabled);
await alertCenterApi.toggleRule(ruleId);
setRules(rules.map(r => r.id === ruleId ? { ...r, enabled } : r));
toast.success('规则状态已更新');
} catch (error) {
@@ -616,101 +617,18 @@ const AlertCenter: React.FC = () => {
</TabsContent>
</Tabs>
{/* 告警详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedAlert?.title}
{selectedAlert && getSeverityBadge(selectedAlert.severity)}
</DialogTitle>
</DialogHeader>
{selectedAlert && (
<div className="space-y-4">
<div>
<Label className="text-muted-foreground">描述</Label>
<p className="mt-1">{selectedAlert.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">来源</Label>
<p className="font-medium">{selectedAlert.source}</p>
</div>
<div>
<Label className="text-muted-foreground">服务</Label>
<p className="font-medium">{selectedAlert.service}</p>
</div>
<div>
<Label className="text-muted-foreground">指标</Label>
<p className="font-medium">{selectedAlert.metric}</p>
</div>
<div>
<Label className="text-muted-foreground">当前值 / 阈值</Label>
<p className="font-medium">{selectedAlert.value} / {selectedAlert.threshold}</p>
</div>
<div>
<Label className="text-muted-foreground">开始时间</Label>
<p className="font-medium">{selectedAlert.startedAt}</p>
</div>
{selectedAlert.resolvedAt && (
<div>
<Label className="text-muted-foreground">解决时间</Label>
<p className="font-medium">{selectedAlert.resolvedAt}</p>
</div>
)}
</div>
<div>
<Label className="text-muted-foreground">标签</Label>
<div className="flex flex-wrap gap-2 mt-1">
{Object.entries(selectedAlert.labels).map(([k, v]) => (
<Badge key={k} variant="outline">{k}={v}</Badge>
))}
</div>
</div>
{selectedAlert.runbookUrl && (
<div>
<Label className="text-muted-foreground">处理手册</Label>
<a href={selectedAlert.runbookUrl} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline block mt-1">
查看处理手册 →
</a>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDetailOpen(false)}>关闭</Button>
{selectedAlert?.status === 'firing' && (
<Button onClick={() => { handleAcknowledge(selectedAlert.id); setDetailOpen(false); }}>
<Check className="w-4 h-4 mr-2" />
确认告警
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* 静默对话框 */}
<Dialog open={silenceOpen} onOpenChange={setSilenceOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>静默告警</DialogTitle>
<DialogDescription>选择静默持续时间</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{['1h', '4h', '8h', '24h', '7d'].map((duration) => (
<Button
key={duration}
variant="outline"
className="w-full justify-between"
onClick={() => selectedAlert && handleSilence(selectedAlert.id, duration)}
>
<span>静默 {duration}</span>
<ChevronRight className="w-4 h-4" />
</Button>
))}
</div>
</DialogContent>
</Dialog>
<AlertDetailDialog
alert={selectedAlert}
open={detailOpen}
onOpenChange={setDetailOpen}
onAcknowledge={handleAcknowledge}
/>
<AlertSilenceDialog
alert={selectedAlert}
open={silenceOpen}
onOpenChange={setSilenceOpen}
onSilence={handleSilence}
/>
{/* 通知渠道创建/编辑对话框 */}
<Dialog open={channelDialogOpen} onOpenChange={setChannelDialogOpen}>
@@ -0,0 +1,145 @@
import {
Check, ChevronRight, AlertCircle, AlertTriangle, Info,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import type { Alert } from './AlertCenter';
function getSeverityBadge(severity: string) {
switch (severity) {
case 'critical':
return <Badge variant="destructive" className="gap-1"><AlertCircle className="w-3 h-3" />严重</Badge>;
case 'warning':
return <Badge variant="default" className="bg-orange-500 gap-1"><AlertTriangle className="w-3 h-3" />警告</Badge>;
case 'info':
return <Badge variant="default" className="bg-blue-500 gap-1"><Info className="w-3 h-3" />信息</Badge>;
default:
return <Badge variant="secondary">未知</Badge>;
}
}
export function AlertDetailDialog({
alert,
open,
onOpenChange,
onAcknowledge,
}: {
alert: Alert | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onAcknowledge: (id: string) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{alert?.title}
{alert && getSeverityBadge(alert.severity)}
</DialogTitle>
</DialogHeader>
{alert && (
<div className="space-y-4">
<div>
<Label className="text-muted-foreground">描述</Label>
<p className="mt-1">{alert.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">来源</Label>
<p className="font-medium">{alert.source}</p>
</div>
<div>
<Label className="text-muted-foreground">服务</Label>
<p className="font-medium">{alert.service}</p>
</div>
<div>
<Label className="text-muted-foreground">指标</Label>
<p className="font-medium">{alert.metric}</p>
</div>
<div>
<Label className="text-muted-foreground">当前值 / 阈值</Label>
<p className="font-medium">{alert.value} / {alert.threshold}</p>
</div>
<div>
<Label className="text-muted-foreground">开始时间</Label>
<p className="font-medium">{alert.startedAt}</p>
</div>
{alert.resolvedAt && (
<div>
<Label className="text-muted-foreground">解决时间</Label>
<p className="font-medium">{alert.resolvedAt}</p>
</div>
)}
</div>
<div>
<Label className="text-muted-foreground">标签</Label>
<div className="flex flex-wrap gap-2 mt-1">
{Object.entries(alert.labels).map(([k, v]) => (
<Badge key={k} variant="outline">{k}={v}</Badge>
))}
</div>
</div>
{alert.runbookUrl && (
<div>
<Label className="text-muted-foreground">处理手册</Label>
<a href={alert.runbookUrl} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline block mt-1">
查看处理手册 →
</a>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
{alert?.status === 'firing' && (
<Button onClick={() => { onAcknowledge(alert.id); onOpenChange(false); }}>
<Check className="w-4 h-4 mr-2" />
确认告警
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function AlertSilenceDialog({
alert,
open,
onOpenChange,
onSilence,
}: {
alert: Alert | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSilence: (id: string, duration: string) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>静默告警</DialogTitle>
<DialogDescription>选择静默持续时间</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{['1h', '4h', '8h', '24h', '7d'].map((duration) => (
<Button
key={duration}
variant="outline"
className="w-full justify-between"
onClick={() => alert && onSilence(alert.id, duration)}
>
<span>静默 {duration}</span>
<ChevronRight className="w-4 h-4" />
</Button>
))}
</div>
</DialogContent>
</Dialog>
);
}
@@ -22,6 +22,7 @@ import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { dashboardApi } from '@/services/api';
import { GrafanaPanelContent } from './GrafanaPanelContent';
interface Dashboard {
id: string;
@@ -36,7 +37,7 @@ interface Dashboard {
isDefault: boolean;
}
interface Panel {
export interface Panel {
id: string;
title: string;
type: 'graph' | 'stat' | 'gauge' | 'table' | 'pie' | 'heatmap' | 'text';
@@ -250,81 +251,6 @@ const GrafanaDashboard: React.FC = () => {
}
};
// 模拟图表数据
const generateChartData = (panel: Panel) => {
const dataPoints = 24;
const data = [];
for (let i = 0; i < dataPoints; i++) {
data.push({
time: new Date(Date.now() - (dataPoints - i) * 3600000).toLocaleTimeString(),
value: Math.random() * 100
});
}
return data;
};
// 渲染面板内容
const renderPanelContent = (panel: Panel) => {
const data = generateChartData(panel);
switch (panel.type) {
case 'stat':
return (
<div className="flex flex-col items-center justify-center h-full">
<span className="text-4xl font-bold text-blue-500">{Math.floor(Math.random() * 1000)}</span>
<span className="text-sm text-muted-foreground mt-2">{panel.options?.unit || 'requests/s'}</span>
<div className="flex items-center gap-1 mt-2 text-green-500 text-sm">
<TrendingUp className="w-4 h-4" />
<span>+12.5%</span>
</div>
</div>
);
case 'gauge':
return (
<div className="flex flex-col items-center justify-center h-full">
<div className="relative w-24 h-24">
<svg className="w-full h-full" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="#e5e7eb" strokeWidth="10" />
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="#3b82f6"
strokeWidth="10"
strokeDasharray={`${Math.random() * 251} 251`}
transform="rotate(-90 50 50)"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold">{Math.floor(Math.random() * 100)}%</span>
</div>
</div>
<span className="text-sm text-muted-foreground mt-2">CPU使用率</span>
</div>
);
case 'graph':
default:
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex items-end gap-1 px-2">
{data.map((d, i) => (
<div
key={i}
className="flex-1 bg-blue-500 rounded-t"
style={{ height: `${d.value}%`, opacity: 0.3 + (i / data.length) * 0.7 }}
/>
))}
</div>
<div className="flex justify-between text-xs text-muted-foreground px-2 pt-2">
<span>{data[0].time}</span>
<span>{data[data.length - 1].time}</span>
</div>
</div>
);
}
};
return (
<div className={`space-y-4 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4' : ''}`}>
{/* 顶部工具栏 */}
@@ -448,7 +374,7 @@ const GrafanaDashboard: React.FC = () => {
</div>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
{renderPanelContent(panel)}
<GrafanaPanelContent panel={panel} />
</CardContent>
</Card>
))}
@@ -0,0 +1,77 @@
import { TrendingUp } from 'lucide-react';
import type { Panel } from './GrafanaDashboard';
/** 模拟图表数据 */
function generateChartData(panel: Panel) {
const dataPoints = 24;
const data = [];
for (let i = 0; i < dataPoints; i++) {
data.push({
time: new Date(Date.now() - (dataPoints - i) * 3600000).toLocaleTimeString(),
value: Math.random() * 100
});
}
return data;
}
/** 渲染面板内容 */
export function GrafanaPanelContent({ panel }: { panel: Panel }) {
const data = generateChartData(panel);
switch (panel.type) {
case 'stat':
return (
<div className="flex flex-col items-center justify-center h-full">
<span className="text-4xl font-bold text-blue-500">{Math.floor(Math.random() * 1000)}</span>
<span className="text-sm text-muted-foreground mt-2">{panel.options?.unit || 'requests/s'}</span>
<div className="flex items-center gap-1 mt-2 text-green-500 text-sm">
<TrendingUp className="w-4 h-4" />
<span>+12.5%</span>
</div>
</div>
);
case 'gauge':
return (
<div className="flex flex-col items-center justify-center h-full">
<div className="relative w-24 h-24">
<svg className="w-full h-full" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="#e5e7eb" strokeWidth="10" />
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="#3b82f6"
strokeWidth="10"
strokeDasharray={`${Math.random() * 251} 251`}
transform="rotate(-90 50 50)"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold">{Math.floor(Math.random() * 100)}%</span>
</div>
</div>
<span className="text-sm text-muted-foreground mt-2">CPU使用率</span>
</div>
);
case 'graph':
default:
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex items-end gap-1 px-2">
{data.map((d, i) => (
<div
key={i}
className="flex-1 bg-blue-500 rounded-t"
style={{ height: `${d.value}%`, opacity: 0.3 + (i / data.length) * 0.7 }}
/>
))}
</div>
<div className="flex justify-between text-xs text-muted-foreground px-2 pt-2">
<span>{data[0].time}</span>
<span>{data[data.length - 1].time}</span>
</div>
</div>
);
}
}
@@ -0,0 +1,462 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
ChevronDown,
Users,
Key,
RefreshCw,
Plus,
UserPlus,
Download,
Edit,
Trash2,
Loader2,
} from 'lucide-react';
interface LdapManagementSectionProps {
ldapConfig: any;
ldapMgmtOpen: boolean;
setLdapMgmtOpen: (v: boolean) => void;
ldapOUs: any[];
selectedOUDN: string;
setSelectedOUDN: (v: string) => void;
ldapUsers: any[];
selectedLDAPUsers: string[];
setSelectedLDAPUsers: (v: string[]) => void;
loadLdapOUs: () => void;
loadLdapOUUsers: (ouDn?: string) => void;
handleLdapImport: () => void;
importing: boolean;
importFeedback: string | null;
importResultOpen: boolean;
setImportResultOpen: (v: boolean) => void;
// Create OU
createOUOpen: boolean;
setCreateOUOpen: (v: boolean) => void;
ouForm: { ou_name: string; parent_dn: string; description: string };
setOuForm: React.Dispatch<React.SetStateAction<{ ou_name: string; parent_dn: string; description: string }>>;
handleCreateOU: () => void;
ldapSaving: boolean;
// Create LDAP user
createUserOpen: boolean;
setCreateUserOpen: (v: boolean) => void;
newUser: { username: string; password: string; display_name: string; email: string; ou_dn: string };
setNewUser: React.Dispatch<React.SetStateAction<{ username: string; password: string; display_name: string; email: string; ou_dn: string }>>;
handleCreateLDAPUser: () => void;
// LDAP user test
ldapUserTestOpen: boolean;
setLdapUserTestOpen: (v: boolean) => void;
ldapTestUser: string;
setLdapTestUser: (v: string) => void;
ldapTestPass: string;
setLdapTestPass: (v: string) => void;
ldapUserTesting: boolean;
ldapUserTestResult: any;
handleLdapUserTest: () => void;
// Edit LDAP user
editLdapUser: any | null;
setEditLdapUser: (u: any | null) => void;
editLdapForm: { cn: string; mail: string; phone: string; title: string };
setEditLdapForm: React.Dispatch<React.SetStateAction<{ cn: string; mail: string; phone: string; title: string }>>;
handleUpdateLdapUser: () => void;
handleDeleteLDAPEntry: (dn: string, silent?: boolean) => void;
openEditLdapUser: (user: any) => void;
}
export function LdapManagementSection(props: LdapManagementSectionProps) {
const {
ldapConfig,
ldapMgmtOpen,
setLdapMgmtOpen,
ldapOUs,
selectedOUDN,
setSelectedOUDN,
ldapUsers,
selectedLDAPUsers,
setSelectedLDAPUsers,
loadLdapOUs,
loadLdapOUUsers,
handleLdapImport,
importing,
importFeedback,
importResultOpen,
setImportResultOpen,
createOUOpen,
setCreateOUOpen,
ouForm,
setOuForm,
handleCreateOU,
ldapSaving,
createUserOpen,
setCreateUserOpen,
newUser,
setNewUser,
handleCreateLDAPUser,
ldapUserTestOpen,
setLdapUserTestOpen,
ldapTestUser,
setLdapTestUser,
ldapTestPass,
setLdapTestPass,
ldapUserTesting,
ldapUserTestResult,
handleLdapUserTest,
editLdapUser,
setEditLdapUser,
editLdapForm,
setEditLdapForm,
handleUpdateLdapUser,
handleDeleteLDAPEntry,
openEditLdapUser,
} = props;
return (
<>
{/* 折叠/展开按钮 */}
<button
onClick={() => {
const open = !ldapMgmtOpen;
setLdapMgmtOpen(open);
if (open && ldapOUs.length === 0) loadLdapOUs();
}}
className="w-full flex items-center gap-2 px-4 py-2.5 bg-slate-900/50 hover:bg-slate-800/50 text-slate-400 text-sm transition-colors"
>
<ChevronDown className={`h-4 w-4 transition-transform ${ldapMgmtOpen ? '' : '-rotate-90'}`} />
<Users className="h-4 w-4 text-amber-400" />
<span className="text-slate-300">LDAP 账号管理</span>
</button>
{ldapMgmtOpen && (
<div className="bg-slate-900/30 border-t border-slate-800/50 px-4 py-4 space-y-4">
<div className="flex items-center gap-3 flex-wrap">
{/* 账号验证测试 */}
<Dialog open={ldapUserTestOpen} onOpenChange={setLdapUserTestOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-200">
<Key className="h-3.5 w-3.5 mr-1" />
账号验证
</Button>
</DialogTrigger>
<DialogContent className="max-w-md bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100">LDAP 账号验证测试</DialogTitle>
<DialogDescription className="text-slate-500">输入 LDAP 用户凭据进行认证测试</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="text-xs text-slate-500 mb-1 block">用户名</label>
<Input
value={ldapTestUser}
onChange={(e) => setLdapTestUser(e.target.value)}
placeholder="zhangsan"
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">密码</label>
<Input
type="password"
value={ldapTestPass}
onChange={(e) => setLdapTestPass(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLdapUserTest()}
placeholder="••••••"
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
/>
</div>
{ldapUserTestResult && (
<div className={`p-3 rounded-lg text-sm ${ldapUserTestResult.success ? 'bg-emerald-500/10 text-emerald-300' : 'bg-red-500/10 text-red-300'}`}>
{ldapUserTestResult.message}
{ldapUserTestResult.user_info && (
<pre className="mt-2 text-xs whitespace-pre-wrap">{JSON.stringify(ldapUserTestResult.user_info, null, 2)}</pre>
)}
</div>
)}
<Button
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
onClick={handleLdapUserTest}
disabled={ldapUserTesting || !ldapTestUser}
>
{ldapUserTesting ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : null}
验证
</Button>
</div>
</DialogContent>
</Dialog>
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-200" onClick={loadLdapOUs}>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
刷新
</Button>
{/* OU 选择 */}
<Select value={selectedOUDN} onValueChange={(v) => { setSelectedOUDN(v); loadLdapOUUsers(v); }}>
<SelectTrigger className="w-60 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs">
<SelectValue placeholder="选择组织单元" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value={ldapConfig.base_dn}>根 ({ldapConfig.base_dn})</SelectItem>
{ldapOUs.map((ou: any) => (
<SelectItem key={ou.dn} value={ou.dn}>
{ou.description ? `${ou.name} (${ou.description})` : ou.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 新建 OU */}
<Dialog open={createOUOpen} onOpenChange={setCreateOUOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-200">
<Plus className="h-3.5 w-3.5 mr-1" />
新建 OU
</Button>
</DialogTrigger>
<DialogContent className="max-w-md bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100">新建组织单元</DialogTitle>
<DialogDescription className="text-slate-500">在 LDAP 目录中创建新的组织单元 (OU)</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="text-xs text-slate-500 mb-1 block">OU 名称 *</label>
<Input value={ouForm.ou_name} onChange={(e) => setOuForm({ ...ouForm, ou_name: e.target.value })} placeholder="dev" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">父级 DN</label>
<Input value={ouForm.parent_dn} onChange={(e) => setOuForm({ ...ouForm, parent_dn: e.target.value })} placeholder={ldapConfig.base_dn} className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">描述</label>
<Input value={ouForm.description} onChange={(e) => setOuForm({ ...ouForm, description: e.target.value })} placeholder="开发团队" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<Button className="w-full bg-blue-600 hover:bg-blue-700 text-white" onClick={handleCreateOU} disabled={!ouForm.ou_name || ldapSaving}>
{ldapSaving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : null}
创建
</Button>
</div>
</DialogContent>
</Dialog>
{/* 添加用户 */}
<Dialog open={createUserOpen} onOpenChange={setCreateUserOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-200">
<UserPlus className="h-3.5 w-3.5 mr-1" />
添加用户
</Button>
</DialogTrigger>
<DialogContent className="max-w-md bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100">添加 LDAP 用户</DialogTitle>
<DialogDescription className="text-slate-500">在 LDAP 目录中创建新的用户账号</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="text-xs text-slate-500 mb-1 block">用户名 *</label>
<Input value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} placeholder="zhangsan" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">密码 *</label>
<Input type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} placeholder="••••••" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">显示名称</label>
<Input value={newUser.display_name} onChange={(e) => setNewUser({ ...newUser, display_name: e.target.value })} placeholder="张三" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">邮箱</label>
<Input value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} placeholder="zs@example.com" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">目标 OU</label>
<Input value={newUser.ou_dn || selectedOUDN || ldapConfig.base_dn} onChange={(e) => setNewUser({ ...newUser, ou_dn: e.target.value })} className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<Button className="w-full bg-blue-600 hover:bg-blue-700 text-white" onClick={handleCreateLDAPUser} disabled={!newUser.username || !newUser.password || ldapSaving}>
{ldapSaving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : null}
创建
</Button>
</div>
</DialogContent>
</Dialog>
{/* 导入到本地 */}
<Button
variant="outline"
size="sm"
className="border-blue-600/50 text-blue-400 hover:bg-blue-600/10 ml-auto"
onClick={handleLdapImport}
disabled={ldapUsers.length === 0 || importing}
>
{importing ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Download className="h-3.5 w-3.5 mr-1" />}
{selectedLDAPUsers.length > 0 ? `导入 (${selectedLDAPUsers.length})` : `导入全部 (${ldapUsers.length})`}
</Button>
</div>
{importFeedback && (
<Dialog open={importResultOpen} onOpenChange={setImportResultOpen}>
<DialogContent className="max-w-sm bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100">导入结果</DialogTitle>
<DialogDescription className="text-slate-500">LDAP 用户导入到本地用户库的执行结果</DialogDescription>
</DialogHeader>
<div className="py-3 text-sm text-slate-300">
{importFeedback}
</div>
<Button className="w-full bg-blue-600 hover:bg-blue-700 text-white" onClick={() => setImportResultOpen(false)}>
确定
</Button>
</DialogContent>
</Dialog>
)}
{/* 用户表格 */}
<div className="border border-slate-800 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400 w-8">
<input
type="checkbox"
className="rounded bg-slate-800 border-slate-600"
checked={selectedLDAPUsers.length > 0 && selectedLDAPUsers.length === ldapUsers.length}
onChange={(e) => setSelectedLDAPUsers(e.target.checked ? ldapUsers.map((u: any) => u.dn) : [])}
/>
</TableHead>
<TableHead className="text-slate-400">用户名</TableHead>
<TableHead className="text-slate-400">显示名称</TableHead>
<TableHead className="text-slate-400">邮箱</TableHead>
<TableHead className="text-slate-400">DN</TableHead>
<TableHead className="text-slate-400 w-12">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ldapUsers.length === 0 ? (
<TableRow className="border-slate-800">
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
点击「刷新」加载 LDAP 用户
</TableCell>
</TableRow>
) : (
ldapUsers.map((user: any) => (
<TableRow key={user.dn} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<input
type="checkbox"
className="rounded bg-slate-800 border-slate-600"
checked={selectedLDAPUsers.includes(user.dn)}
onChange={(e) => {
if (e.target.checked) setSelectedLDAPUsers([...selectedLDAPUsers, user.dn]);
else setSelectedLDAPUsers(selectedLDAPUsers.filter((d: string) => d !== user.dn));
}}
/>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200 font-medium">{user.uid}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{user.cn}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{user.mail || '-'}</span>
</TableCell>
<TableCell>
<code className="text-xs text-slate-500">{user.dn}</code>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-blue-400 hover:bg-blue-500/10"
onClick={() => openEditLdapUser(user)}
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleDeleteLDAPEntry(user.dn)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
)}
{/* 编辑 LDAP 用户 Dialog */}
<Dialog open={!!editLdapUser} onOpenChange={(v) => !v && setEditLdapUser(null)}>
<DialogContent className="max-w-md bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Edit className="h-5 w-5 text-blue-400" />
编辑 LDAP 用户
</DialogTitle>
<DialogDescription className="text-slate-500">
修改 {editLdapUser?.uid} 的属性
</DialogDescription>
</DialogHeader>
{editLdapUser && (
<div className="space-y-3 py-2">
<div>
<label className="text-xs text-slate-500 mb-1 block">用户名(只读)</label>
<Input value={editLdapUser.uid || ''} disabled className="bg-slate-800/50 border-slate-700 text-slate-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">显示名称 (cn)</label>
<Input value={editLdapForm.cn} onChange={(e) => setEditLdapForm({ ...editLdapForm, cn: e.target.value })} className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">邮箱</label>
<Input value={editLdapForm.mail} onChange={(e) => setEditLdapForm({ ...editLdapForm, mail: e.target.value })} placeholder="user@example.com" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">电话</label>
<Input value={editLdapForm.phone} onChange={(e) => setEditLdapForm({ ...editLdapForm, phone: e.target.value })} placeholder="13800138000" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">职位/头衔</label>
<Input value={editLdapForm.title} onChange={(e) => setEditLdapForm({ ...editLdapForm, title: e.target.value })} placeholder="运维工程师" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setEditLdapUser(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleUpdateLdapUser}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Plus, Shield, Edit, Eye } from 'lucide-react';
import { roles } from '@/mocks/data';
import { userApi } from '@/services/api';
import type { Role } from '@/types';
interface SystemRolesTabProps {
@@ -10,7 +11,43 @@ interface SystemRolesTabProps {
setSelectedRole: (role: Role | null) => void;
}
// 将后端角色数据规范化为前端 Role 类型 // Logan.Li
function normalizeRole(raw: any): Role {
return {
id: String(raw.id ?? ''),
name: raw.name ?? '',
code: raw.code ?? '',
description: raw.description ?? '',
// 后端可能不返回 permissions/createdAt,给予默认值以避免渲染崩溃
permissions: Array.isArray(raw.permissions) ? raw.permissions : [],
createdAt: raw.createdAt ?? raw.created_at ?? '',
};
}
export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTabProps) {
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const resp = await userApi.getRoles();
if (cancelled) return;
const list = resp?.list ?? [];
setRoles(list.map(normalizeRole));
} catch (err) {
if (!cancelled) setRoles([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<>
<div className="flex items-center justify-end">
@@ -20,53 +57,64 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{roles.map((role) => (
<Card key={role.id} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
<Shield className="h-5 w-5 text-purple-400" />
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : roles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<Shield className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm">暂无角色数据</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{roles.map((role) => (
<Card key={role.id} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
<Shield className="h-5 w-5 text-purple-400" />
</div>
<div>
<CardTitle className="text-base text-slate-100">{role.name}</CardTitle>
<CardDescription className="text-slate-500">{role.code}</CardDescription>
</div>
</div>
<div>
<CardTitle className="text-base text-slate-100">{role.name}</CardTitle>
<CardDescription className="text-slate-500">{role.code}</CardDescription>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedRole(role)}
>
<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>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedRole(role)}
>
<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>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-400 mb-3">{role.description}</p>
<div className="flex flex-wrap gap-1">
{role.permissions.slice(0, 5).map((permission) => (
<Badge key={permission} variant="outline" className="border-slate-700 text-slate-400 text-xs">
{permission}
</Badge>
))}
{role.permissions.length > 5 && (
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
+{role.permissions.length - 5}
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-400 mb-3">{role.description}</p>
<div className="flex flex-wrap gap-1">
{role.permissions.slice(0, 5).map((permission) => (
<Badge key={permission} variant="outline" className="border-slate-700 text-slate-400 text-xs">
{permission}
</Badge>
))}
{role.permissions.length > 5 && (
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
+{role.permissions.length - 5}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
);
}
@@ -0,0 +1,340 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Lock, History, Key, Settings, Network, RefreshCw, Loader2 } from 'lucide-react';
import { LdapManagementSection } from './LdapManagementSection';
export interface LdapFormData {
ldap_host: string;
ldap_port: number;
use_ldaps: boolean;
admin_dn: string;
admin_password: string;
base_dn: string;
search_filter: string;
}
interface SystemSettingsTabProps {
// LDAP config
ldapConfig: any;
ldapForm: LdapFormData;
setLdapForm: React.Dispatch<React.SetStateAction<LdapFormData>>;
ldapSaving: boolean;
ldapTesting: boolean;
ldapTestResult: string | null;
handleLdapSave: () => void;
handleLdapTest: () => void;
// LDAP management - pass-through to LdapManagementSection
ldapMgmtOpen: boolean;
setLdapMgmtOpen: (v: boolean) => void;
ldapOUs: any[];
selectedOUDN: string;
setSelectedOUDN: (v: string) => void;
ldapUsers: any[];
selectedLDAPUsers: string[];
setSelectedLDAPUsers: (v: string[]) => void;
loadLdapOUs: () => void;
loadLdapOUUsers: (ouDn?: string) => void;
handleLdapImport: () => void;
importing: boolean;
importFeedback: string | null;
importResultOpen: boolean;
setImportResultOpen: (v: boolean) => void;
createOUOpen: boolean;
setCreateOUOpen: (v: boolean) => void;
ouForm: { ou_name: string; parent_dn: string; description: string };
setOuForm: React.Dispatch<React.SetStateAction<{ ou_name: string; parent_dn: string; description: string }>>;
handleCreateOU: () => void;
createUserOpen: boolean;
setCreateUserOpen: (v: boolean) => void;
newUser: { username: string; password: string; display_name: string; email: string; ou_dn: string };
setNewUser: React.Dispatch<React.SetStateAction<{ username: string; password: string; display_name: string; email: string; ou_dn: string }>>;
handleCreateLDAPUser: () => void;
ldapUserTestOpen: boolean;
setLdapUserTestOpen: (v: boolean) => void;
ldapTestUser: string;
setLdapTestUser: (v: string) => void;
ldapTestPass: string;
setLdapTestPass: (v: string) => void;
ldapUserTesting: boolean;
ldapUserTestResult: any;
handleLdapUserTest: () => void;
editLdapUser: any | null;
setEditLdapUser: (u: any | null) => void;
editLdapForm: { cn: string; mail: string; phone: string; title: string };
setEditLdapForm: React.Dispatch<React.SetStateAction<{ cn: string; mail: string; phone: string; title: string }>>;
handleUpdateLdapUser: () => void;
handleDeleteLDAPEntry: (dn: string, silent?: boolean) => void;
openEditLdapUser: (user: any) => void;
}
export function SystemSettingsTab(props: SystemSettingsTabProps) {
const {
ldapConfig,
ldapForm,
setLdapForm,
ldapSaving,
ldapTesting,
ldapTestResult,
handleLdapSave,
handleLdapTest,
} = props;
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<Lock className="h-5 w-5 text-blue-400" />
安全设置
</CardTitle>
<CardDescription className="text-slate-500">密码策略与登录安全</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">强制密码复杂度</p>
<p className="text-xs text-slate-500">要求密码包含大小写字母、数字和特殊字符</p>
</div>
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">登录失败锁定</p>
<p className="text-xs text-slate-500">连续5次登录失败锁定账户30分钟</p>
</div>
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">双因素认证</p>
<p className="text-xs text-slate-500">启用后需要手机验证码登录</p>
</div>
<Switch className="data-[state=checked]:bg-blue-600" />
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<History className="h-5 w-5 text-emerald-400" />
审计设置
</CardTitle>
<CardDescription className="text-slate-500">操作日志与审计策略</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">记录所有操作</p>
<p className="text-xs text-slate-500">记录用户的所有操作行为</p>
</div>
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">日志保留时间</p>
<p className="text-xs text-slate-500">操作日志保留180天</p>
</div>
<Select defaultValue="180">
<SelectTrigger className="w-32 h-8 bg-slate-800 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="30">30天</SelectItem>
<SelectItem value="90">90天</SelectItem>
<SelectItem value="180">180天</SelectItem>
<SelectItem value="365">1年</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<Key className="h-5 w-5 text-amber-400" />
会话设置
</CardTitle>
<CardDescription className="text-slate-500">会话超时与Token配置</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">会话超时时间</p>
<p className="text-xs text-slate-500">用户无操作30分钟后自动登出</p>
</div>
<Select defaultValue="30">
<SelectTrigger className="w-32 h-8 bg-slate-800 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="15">15分钟</SelectItem>
<SelectItem value="30">30分钟</SelectItem>
<SelectItem value="60">1小时</SelectItem>
<SelectItem value="120">2小时</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">单点登录</p>
<p className="text-xs text-slate-500">同一账号只能在一个设备登录</p>
</div>
<Switch className="data-[state=checked]:bg-blue-600" />
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<Settings className="h-5 w-5 text-purple-400" />
系统信息
</CardTitle>
<CardDescription className="text-slate-500">版本与系统状态</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-400">系统版本</span>
<span className="text-sm text-slate-200">v2.5.1</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-400">运行时间</span>
<span className="text-sm text-slate-200">30天 12小时</span>
</div>
</CardContent>
</Card>
</div>
{/* LDAP 配置 */}
<Card className="bg-slate-900 border-slate-800 mt-4">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100 flex items-center gap-2">
<Network className="h-5 w-5 text-cyan-400" />
LDAP / Active Directory
</CardTitle>
<CardDescription className="text-slate-500 mt-1">配置 LDAP 服务器,实现统一身份认证</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs text-slate-500">LDAP 服务器地址</label>
<Input
value={ldapForm.ldap_host}
onChange={(e) => setLdapForm({ ...ldapForm, ldap_host: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
placeholder="ldap.example.com"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-slate-500">端口</label>
<Input
type="number"
value={ldapForm.ldap_port}
onChange={(e) => setLdapForm({ ...ldapForm, ldap_port: Number(e.target.value) })}
className="bg-slate-800/50 border-slate-700 text-slate-200"
placeholder="389"
/>
</div>
<div className="space-y-1.5 col-span-2">
<label className="text-xs text-slate-500">Base DN(用户搜索基础)</label>
<Input
value={ldapForm.base_dn}
onChange={(e) => setLdapForm({ ...ldapForm, base_dn: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
placeholder="ou=users,dc=example,dc=com"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-slate-500">管理员 Bind DN</label>
<Input
value={ldapForm.admin_dn}
onChange={(e) => setLdapForm({ ...ldapForm, admin_dn: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
placeholder="cn=admin,dc=example,dc=com"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-slate-500">管理员密码 {ldapConfig && '(留空不修改)'}</label>
<Input
type="password"
value={ldapForm.admin_password}
onChange={(e) => setLdapForm({ ...ldapForm, admin_password: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
placeholder="******"
/>
</div>
<div className="space-y-1.5 col-span-2">
<label className="text-xs text-slate-500">搜索过滤器(可选,用 %s 代表用户名)</label>
<Input
value={ldapForm.search_filter}
onChange={(e) => setLdapForm({ ...ldapForm, search_filter: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
placeholder="(uid=%s)"
/>
</div>
<div className="flex items-center gap-2 col-span-2">
<Switch
checked={ldapForm.use_ldaps}
onCheckedChange={(v) => setLdapForm({ ...ldapForm, use_ldaps: v })}
className="data-[state=checked]:bg-blue-600"
/>
<span className="text-sm text-slate-300">启用 LDAPS(SSL 加密)</span>
</div>
</div>
{/* 测试/保存按钮 */}
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-slate-800">
{ldapTestResult && (
<span className={`text-xs ${ldapTestResult.includes('成功') ? 'text-emerald-400' : 'text-red-400'}`}>
{ldapTestResult}
</span>
)}
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="sm"
className="border-slate-700 text-slate-300"
onClick={handleLdapTest}
disabled={ldapTesting}
>
{ldapTesting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1" />}
测试连接
</Button>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={handleLdapSave}
disabled={ldapSaving}
>
{ldapSaving ? '保存中...' : '保存配置'}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* LDAP 账号管理 - 可折叠 */}
{ldapConfig && (
<div className="mt-4">
<LdapManagementSection {...props} />
</div>
)}
</>
);
}
@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -16,7 +17,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { User } from 'lucide-react';
import { roles } from '@/mocks/data';
import { userApi } from '@/services/api';
export interface UserFormData {
username: string;
@@ -47,6 +48,32 @@ export function UserFormDialog({
saving,
onSubmit,
}: UserFormDialogProps) {
const [roles, setRoles] = useState<{ id: string; name: string }[]>([]);
// 对话框打开时拉取角色列表 // Logan.Li
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
try {
const resp = await userApi.getRoles();
if (cancelled) return;
const list = resp?.list ?? [];
setRoles(
list.map((r: any) => ({
id: String(r.id ?? ''),
name: r.name ?? r.code ?? '',
}))
);
} catch {
if (!cancelled) setRoles([]);
}
})();
return () => {
cancelled = true;
};
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-slate-900 border-slate-800">
@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { cva } from "class-variance-authority"
// Sidebar constants
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
export const SIDEBAR_WIDTH = "16rem"
export const SIDEBAR_WIDTH_MOBILE = "18rem"
export const SIDEBAR_WIDTH_ICON = "3rem"
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
// Sidebar context type
export const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
export const SidebarContext = React.createContext<SidebarContextProps | null>(null)
export function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
+13 -50
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
@@ -25,33 +25,18 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
import {
SIDEBAR_COOKIE_NAME,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_MOBILE,
SIDEBAR_WIDTH_ICON,
SIDEBAR_KEYBOARD_SHORTCUT,
SidebarContext,
useSidebar,
sidebarMenuButtonVariants,
type SidebarContextProps,
} from "./sidebar-context"
function SidebarProvider({
defaultOpen = true,
@@ -473,28 +458,6 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,