Files
opsxsql/frontend/src/pages/Dashboard.tsx
T
logan 43d1f3a234 feat: 工单权限过滤+仪表盘优化+LDAP登录+审批流程+待处理工单
后端:
- ticket_statistics/list_tickets 按用户身份过滤(admin全部, dba/auditor审批相关, developer自己的)
- 新增 /dashboard/my-tickets 接口(按角色返回待处理工单)
- auth.py 本地优先+LDAP fallback 登录认证
- ldap_config.py LDAPS 证书验证修复
- instance_detail.py developer/auditor 权限修复

前端:
- Dashboard 仪表盘: 3核心大数字指标+公告+我的待处理工单
- 删除 ECharts 趋势图(无真实数据)
- TicketDetailPage 审批流程时间线展示
- TicketsPage 工单统计按用户过滤+关联列(我提交的/待我审批)
- types 新增 TicketApproval 等类型定义
2026-05-25 20:47:15 +08:00

564 lines
29 KiB
TypeScript

import { useEffect, useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ClipboardList, Clock, CheckCircle, AlertTriangle,
Database, ChevronRight, X, Bell, Eye,
Activity, TrendingUp, Zap, Server, Search,
FileText, ArrowUpRight, ArrowDownRight,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants';
import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth';
import { dashboardApi } from '@/api/dashboard';
import type { DashboardStats } from '@/types';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface QuickAction {
label: string;
icon: any;
onClick: () => void;
color: string;
bg: string;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function calcHealthScore(inst: any): number {
if (inst.status === 'offline') return 0;
if (inst.healthScore != null) return inst.healthScore;
let score = 100;
const cpu = inst.cpu ?? 0, memory = inst.memory ?? 0, latency = inst.latency ?? 0, disk = inst.disk ?? 0;
if (cpu > 80) score -= 25; else if (cpu > 60) score -= 10;
if (memory > 85) score -= 20; else if (memory > 70) score -= 10;
if (latency > 10) score -= 20; else if (latency > 5) score -= 10;
if (disk > 80) score -= 15;
return Math.max(0, score);
}
function healthColor(score: number): string {
if (score >= 90) return 'text-green-400';
if (score >= 70) return 'text-yellow-400';
if (score >= 50) return 'text-orange-400';
return 'text-red-400';
}
function healthBg(score: number): string {
if (score >= 90) return 'bg-green-500/15';
if (score >= 70) return 'bg-yellow-500/15';
if (score >= 50) return 'bg-orange-500/15';
return 'bg-red-500/15';
}
/* ------------------------------------------------------------------ */
/* Skeleton Components */
/* ------------------------------------------------------------------ */
function StatCardSkeleton() {
return (
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
<div className="h-10 w-10 animate-pulse rounded-xl bg-[var(--dg-bg-tertiary)]" />
</div>
<div className="h-8 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)] mb-3" />
<div className="h-3 w-full animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
</CardContent>
</Card>
);
}
/* ------------------------------------------------------------------ */
/* Main Dashboard */
/* ------------------------------------------------------------------ */
export default function Dashboard() {
const theme = useTheme();
const { user } = useAuth();
const navigate = useNavigate();
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const [expandedId, setExpandedId] = useState<string | null>(null);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [announcements, setAnnouncements] = useState<any[]>([]);
const [dbInstances, setDbInstances] = useState<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]);
const [queryStats, setQueryStats] = useState<any>(null);
useEffect(() => {
import('@/api/request').then(m => m.default.get('/announcements')).then(res => {
const list = Array.isArray(res) ? res : [];
setAnnouncements(list.map((a: any) => ({
id: String(a.id),
title: a.title || '',
content: a.content || '',
priority: 'normal' as const,
pinned: false,
enabled: a.is_published ?? true,
publishTime: a.published_at || a.created_at || '',
targetRoles: ['admin', 'dba', 'developer'],
})));
}).catch(() => setAnnouncements([]));
}, []);
/* ---- Data Fetch ---- */
useEffect(() => {
dashboardApi.getStats()
.then(data => {
setStats(data);
// 关联查询统计
if (data.query_stats) setQueryStats(data.query_stats);
})
.catch(() => setStats(null))
.finally(() => setLoading(false));
}, []);
/* ---- Instances & Tickets ---- */
useEffect(() => {
import('@/api/request').then(m => {
m.default.get('/instances').then((res: any) => {
const list = Array.isArray(res) ? res : (res?.items || res?.data || []);
setDbInstances(list.map((i: any) => ({
id: i.id, name: i.name, dbType: i.db_type || i.dbType,
status: i.status || 'online',
healthScore: i.health_score ?? i.healthScore ?? 100,
host: i.host || '', port: i.port || '',
qps: i.qps ?? 0, connections: i.connections ?? 0,
})));
}).catch(() => setDbInstances([]));
m.default.get('/dashboard/my-tickets').then((res: any) => {
const list = Array.isArray(res) ? res : [];
setTickets(list);
}).catch(() => setTickets([]));
});
}, []);
/* ---- Announcements ---- */
const visibleAnnouncements = useMemo(() => {
return announcements
.filter(a => a.enabled && !dismissedIds.has(a.id))
.sort((a, b) => new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime());
}, [dismissedIds, announcements]);
const dismiss = useCallback((id: string) => setDismissedIds(prev => new Set(prev).add(id)), []);
const toggleExpand = useCallback((id: string) => setExpandedId(prev => prev === id ? null : id), []);
/* ---- Computed Data ---- */
const onlineInstances = dbInstances.filter(i => i.status === 'online').length;
const warningInstances = dbInstances.filter(i => i.status === 'warning').length;
const offlineInstances = dbInstances.filter(i => i.status === 'offline').length;
const pendingTickets = tickets;
const statsCards = useMemo(() => {
if (!stats) return [];
const queryTotal = stats.query_stats?.total ?? 0;
return [
{
title: '工单总数', value: stats.ticket_total.toLocaleString(), icon: ClipboardList,
color: 'text-blue-400', bg: 'bg-blue-400/10',
subtitle: `${stats.ticket_pending} 个待审批 · ${stats.ticket_executing} 个执行中`,
},
{
title: '查询总数', value: queryTotal.toLocaleString(), icon: Search,
color: 'text-green-400', bg: 'bg-green-400/10',
subtitle: `今日 ${stats.queries_today} 次 · 平均 ${stats.query_stats?.avg_time_ms ?? 0}ms`,
},
{
title: '数据源总数', value: stats.instance_total, icon: Database,
color: 'text-purple-400', bg: 'bg-purple-400/10',
subtitle: `${stats.instance_online} 个在线 · ${stats.user_total} 个用户`,
},
];
}, [stats]);
const quickActions = [
{ label: '新建工单', icon: FileText, onClick: () => navigate('/tickets/create'), color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' },
{ label: '新建查询', icon: Search, onClick: () => navigate('/queries'), color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' },
{ label: '添加实例', icon: Database, onClick: () => navigate('/instances'), color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
];
/* ================================================================ */
/* RENDER */
/* ================================================================ */
return (
<div className="space-y-6">
{/* ---- Quick Actions ---- */}
<div className="flex gap-2 flex-wrap">
{quickActions.map(action => (
<button
key={action.label}
onClick={action.onClick}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${action.bg} ${action.color} ${action.border} hover:opacity-80`}
>
<action.icon className="w-4 h-4" />
{action.label}
<ArrowUpRight className="w-3 h-3 opacity-50" />
</button>
))}
</div>
{/* ---- Announcements ---- */}
{visibleAnnouncements.length > 0 && (
<div className="space-y-2">
{visibleAnnouncements.slice(0, 3).map(ann => {
const isExpanded = expandedId === ann.id;
return (
<div
key={ann.id}
className="dg-card overflow-hidden"
>
<div className="px-5 py-3 flex items-start gap-3">
<div className="w-7 h-7 rounded-md bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Bell className="w-3.5 h-3.5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium truncate">{ann.title}</span>
<span className="text-[10px] text-[var(--dg-text-muted)] flex-shrink-0">{ann.publishTime.slice(0, 10)}</span>
</div>
<div className={`text-xs text-[var(--dg-text-secondary)] leading-relaxed ${isExpanded ? '' : 'line-clamp-1'}`}>
{ann.content}
</div>
{ann.content.includes('\n') && (
<button
onClick={() => toggleExpand(ann.id)}
className="text-[11px] text-blue-400 hover:text-blue-300 mt-1 transition-colors"
>
{isExpanded ? '收起' : '展开全文'}
</button>
)}
</div>
<button
onClick={() => dismiss(ann.id)}
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors flex-shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
{/* ---- Stats Cards ---- */}
<div className="grid grid-cols-3 gap-5">
{loading ? (
Array.from({ length: 3 }).map((_, i) => <StatCardSkeleton key={i} />)
) : (
statsCards.map((s, i) => (
<Card key={i} className="dg-card dg-card-hover relative overflow-hidden">
<div className={`absolute top-0 right-0 w-20 h-20 ${s.bg} rounded-bl-[40px] opacity-30`} />
<CardContent className="p-5 relative z-10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
<div className={`w-9 h-9 rounded-lg ${s.bg} flex items-center justify-center`}>
<s.icon className={`w-4.5 h-4.5 ${s.color}`} />
</div>
</div>
<div className="text-4xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-2">{s.subtitle}</div>
</CardContent>
</Card>
))
)}
</div>
{/* ---- My Pending Tickets + Instance Status ---- */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* My Pending Tickets */}
<Card className="dg-card col-span-1 lg:col-span-2">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-400" />
我的待处理
<span className="text-xs text-[var(--dg-text-muted)] font-normal">({pendingTickets.length})</span>
</CardTitle>
<a href="/tickets" className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 transition-colors">
查看全部 <ChevronRight className="w-3 h-3" />
</a>
</div>
</CardHeader>
<CardContent className="p-0">
{pendingTickets.length === 0 ? (
<div className="p-8 text-center text-[var(--dg-text-muted)] text-sm">
<CheckCircle className="w-8 h-8 mx-auto mb-2 text-green-400" />
暂无待处理工单
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">工单号</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">标题</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">数据库</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">状态</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">待处理</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">关联</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">时间</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">操作</th>
</tr>
</thead>
<tbody>
{pendingTickets.map((ticket: any) => (
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3 font-mono text-xs text-[var(--dg-text-secondary)]">TK-{ticket.id}</td>
<td className="px-5 py-3 max-w-[200px] truncate font-medium">{ticket.title}</td>
<td className="px-5 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.db_type] || ''}`}>
{(ticket.db_type || '').toUpperCase()}
</span>
</td>
<td className="px-5 py-3">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : 'bg-green-400'}`} />
{statusMap[ticket.status] || ticket.status}
</span>
</td>
<td className="px-5 py-3 text-xs text-[var(--dg-text-secondary)] max-w-[180px] truncate">{ticket.pending_info || '-'}</td>
<td className="px-5 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${ticket.relation === '我提交的' ? 'bg-blue-500/10 text-blue-400' : 'bg-orange-500/10 text-orange-400'}`}>
{ticket.relation}
</span>
</td>
<td className="px-5 py-3 text-xs text-[var(--dg-text-secondary)]">{ticket.create_time}</td>
<td className="px-5 py-3">
<button
onClick={() => navigate(`/tickets/${ticket.id}`)}
className="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors"
>
<Eye className="w-3.5 h-3.5" />
查看
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Instance Status */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Server className="w-4 h-4 text-blue-400" />
实例状态
</CardTitle>
<span className="text-xs text-[var(--dg-text-muted)]">{onlineInstances}/{dbInstances.length} 在线</span>
</div>
</CardHeader>
<CardContent className="p-0">
{/* Status Overview */}
<div className="p-4 border-b border-[var(--dg-border-light)]">
<div className="grid grid-cols-3 gap-3 text-center">
<div className="p-2 rounded-lg bg-green-500/5">
<div className="text-2xl font-bold text-green-400">{onlineInstances}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">在线</div>
</div>
<div className="p-2 rounded-lg bg-yellow-500/5">
<div className="text-2xl font-bold text-yellow-400">{warningInstances}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">告警</div>
</div>
<div className="p-2 rounded-lg bg-red-500/5">
<div className="text-2xl font-bold text-red-400">{offlineInstances}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">离线</div>
</div>
</div>
</div>
{/* Instance List */}
{dbInstances.slice(0, 5).map(inst => {
const health = calcHealthScore(inst);
return (
<div key={inst.id} className="flex items-center px-5 py-3 border-b border-[var(--dg-border-light)] last:border-0 hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer group">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mr-3.5 flex-shrink-0 ${
inst.dbType === 'mysql' ? 'bg-[rgba(0,117,143,0.2)] text-[#26a69a]' :
inst.dbType === 'redis' ? 'bg-[rgba(220,50,32,0.2)] text-[#ef5350]' :
inst.dbType === 'postgres' ? 'bg-[rgba(51,103,145,0.2)] text-[#42a5f5]' :
'bg-[rgba(71,162,72,0.2)] text-[#66bb6a]'
}`}>
<Database className="w-4.5 h-4.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{inst.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${healthBg(health)} ${healthColor(health)} font-medium`}>
{health}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span>{inst.host}{inst.port ? `:${inst.port}` : ''}</span>
<span className="flex items-center gap-1">
<Activity className="w-3 h-3" />
{(inst.qps ?? 0).toLocaleString()} QPS
</span>
<span>{inst.connections ?? 0} 连接</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
</div>
);
})}
</CardContent>
</Card>
</div>
{/* ---- DataSource Health + Query Stats ---- */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* DataSource Health */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Server className="w-4 h-4 text-blue-400" />
数据源健康概览
</CardTitle>
<span className="text-xs text-[var(--dg-text-muted)]">{onlineInstances}/{dbInstances.length} 在线</span>
</div>
</CardHeader>
<CardContent className="p-0">
{/* By Type */}
<div className="grid grid-cols-4 gap-2 p-4 border-b border-[var(--dg-border-light)]">
{(['mysql', 'postgres', 'redis', 'mongo'] as const).map(type => {
const typeInstances = dbInstances.filter(i => i.dbType === type);
const typeOnline = typeInstances.filter(i => i.status === 'online').length;
const iconMap: Record<string, string> = { mysql: '/db-logos/mysql.svg', postgres: '/db-logos/postgres.svg', redis: '/db-logos/redis.svg', mongo: '/db-logos/mongo.svg' };
const labelMap: Record<string, string> = { mysql: 'MySQL', postgres: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB' };
const allHealthy = typeOnline === typeInstances.length && typeInstances.length > 0;
const someHealthy = typeOnline > 0;
return (
<div key={type} className="text-center p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<img src={iconMap[type]} alt="" className="w-6 h-6 object-contain mx-auto mb-1.5" />
<div className="text-sm font-semibold">{typeInstances.length}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{labelMap[type]}</div>
<div className="text-[10px] mt-1">
<span className={allHealthy ? 'text-green-400' : someHealthy ? 'text-yellow-400' : 'text-[var(--dg-text-muted)]'}>
{typeOnline}/{typeInstances.length}
</span>
</div>
</div>
);
})}
</div>
{/* Attention List */}
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').length > 0 && (
<div className="p-4">
<div className="text-xs text-[var(--dg-text-muted)] mb-2 font-medium">需要关注</div>
<div className="space-y-2">
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').slice(0, 3).map(inst => {
const health = calcHealthScore(inst);
return (
<div key={inst.id} className="flex items-center gap-3 py-2 px-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${inst.status === 'offline' ? 'bg-red-400' : 'bg-yellow-400'}`} />
<span className="text-sm flex-1">{inst.name}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
<span className={`text-[10px] px-2 py-0.5 rounded-full ${healthBg(health)} ${healthColor(health)}`}>
{health}分
</span>
<span className={`text-[10px] px-2 py-0.5 rounded-full ${inst.status === 'offline' ? 'bg-red-500/15 text-red-400' : 'bg-yellow-500/15 text-yellow-400'}`}>
{inst.status === 'offline' ? '离线' : '告警'}
</span>
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
{/* Query Stats */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Activity className="w-4 h-4 text-green-400" />
查询统计
</CardTitle>
<div className="flex gap-1.5">
{(['今日', '本周', '本月'] as const).map((p, i) => (
<button key={p} className={`px-3 py-1 text-xs rounded-md transition-colors font-medium ${i === 0 ? 'bg-green-500/15 text-green-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}>
{p}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-4">
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2">
<Zap className="w-3.5 h-3.5 text-blue-400" />
<span className="text-xs text-[var(--dg-text-muted)]">总查询数</span>
</div>
<div className="text-xl font-bold text-[var(--dg-text-primary)]">{(queryStats?.total ?? 0).toLocaleString()}</div>
<div className="text-[10px] text-green-400 mt-1 flex items-center gap-1">
<ArrowUpRight className="w-3 h-3" />
12% vs 昨日
</div>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-3.5 h-3.5 text-orange-400" />
<span className="text-xs text-[var(--dg-text-muted)]">平均耗时</span>
</div>
<div className="text-xl font-bold text-[var(--dg-text-primary)]">{queryStats?.avg_time ?? 0}ms</div>
<div className="text-[10px] text-green-400 mt-1 flex items-center gap-1">
<ArrowDownRight className="w-3 h-3" />
8% vs 昨日
</div>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
<span className="text-xs text-[var(--dg-text-muted)]">慢查询</span>
</div>
<div className="text-xl font-bold text-[var(--dg-text-primary)]">{queryStats?.slow_count ?? 0}</div>
<div className="text-[10px] text-red-400 mt-1 flex items-center gap-1">
<ArrowUpRight className="w-3 h-3" />
3 vs 昨日
</div>
</div>
</div>
<div className="space-y-2.5">
<div className="text-xs text-[var(--dg-text-muted)] font-medium">查询类型分布</div>
{(queryStats?.type_distribution ?? [
{ label: 'MySQL', count: 0, color: 'bg-[#00758f]' },
{ label: 'PostgreSQL', count: 0, color: 'bg-[#336791]' },
{ label: 'Redis', count: 0, color: 'bg-[#dc382d]' },
{ label: 'MongoDB', count: 0, color: 'bg-[#47a248]' },
]).map((item: any) => {
const total = queryStats?.total || 1;
return (
<div key={item.label} className="flex items-center gap-3">
<span className="text-xs w-20 text-[var(--dg-text-secondary)] font-medium">{item.label}</span>
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
<div className={`h-full rounded-full ${item.color} transition-all duration-500`} style={{ width: `${(item.count / total) * 100}%` }} />
</div>
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)] font-medium">{item.count}</span>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
);
}