merge: SQL平台代码v1 优化包合并 — 193条审核规则 + 16页面UI优化
后端新增 (Step 1): - mysql_audit_rules.py: 63条 MySQL审核规则 (DDL 36 + DML 9 + 命名 5 + 性能 5 + 安全 5) - redis_audit_rules.py: 43条 Redis审核规则 (KEY管理 10 + 命令安全 12 + 数据结构 8 + 性能 8 + 安全 5) - mongodb_audit_rules.py: 42条 MongoDB审核规则 (集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能) - postgres_audit_rules.py: 45条 PostgreSQL审核规则 (DDL 18 + DML 8 + DQL 6 + 安全 6 + 性能 7) 后端合并 (Step 2): - audit_engine_v2.py: 新增4个DB规则模块import (100+→200+规则) - audit_rule_seeds.py: 新增30条MongoDB种子规则 + 别名映射 - audit_rule.py: 新增数据库类型别名 (postgresql→postgres, pg→postgres, mongo→mongodb) 前端优化 (Step 4) — 16页面全覆盖: - InstancePage, WorkflowPage, TicketsPage, TicketCreatePage, TicketDetailPage - UsersPage, SlowQueryPage, DataDictionaryPage, Dashboard - InstanceGroupPage, AuditLogsPage, QueriesPage, AuditRulesPage, RolesPage - useAuth.tsx, SqlEditor.tsx 后端测试 895/895 通过, 前端 tsc --noEmit 通过
This commit is contained in:
1 parent
e1ba8b3327
commit
58eb78c987
24 files changed
+16333
-2225
No files matched your search
+473
-163
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
||||
Database, ChevronRight,
|
||||
X, AlertCircle, Info, Bell, Activity, TrendingUp, Zap, Server,
|
||||
Database, ChevronRight, X, AlertCircle, Info, Bell,
|
||||
Activity, TrendingUp, Zap, Server, Plus, Search,
|
||||
FileText, ArrowUpRight, ArrowDownRight, Minus,
|
||||
Filter, Eye, Loader2, BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
|
||||
@@ -12,56 +14,182 @@ import { dashboardApi } from '@/api/dashboard';
|
||||
import type { DashboardStats } from '@/types';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const priorityStyle: Record<string, { border: string; bg: string; icon: React.ElementType; iconColor: string; badge: string }> = {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
type PriorityKey = 'urgent' | 'high' | 'normal' | 'low';
|
||||
type TrendRange = 7 | 30 | 90;
|
||||
type AnnouncementFilter = 'all' | PriorityKey;
|
||||
|
||||
interface TrendDataset {
|
||||
xAxis: string[];
|
||||
submitted: number[];
|
||||
success: number[];
|
||||
failed: number[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const priorityStyle: Record<PriorityKey, { border: string; bg: string; icon: React.ElementType; iconColor: string; badge: string }> = {
|
||||
urgent: { border: 'border-l-red-500', bg: 'bg-red-500/5', icon: AlertCircle, iconColor: 'text-red-400', badge: 'bg-red-500/15 text-red-400 border-red-500/30' },
|
||||
high: { border: 'border-l-orange-500', bg: 'bg-orange-500/5', icon: AlertTriangle, iconColor: 'text-orange-400', badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30' },
|
||||
normal: { border: 'border-l-blue-500', bg: 'bg-blue-500/5', icon: Info, iconColor: 'text-blue-400', badge: 'bg-blue-500/15 text-blue-400 border-blue-500/30' },
|
||||
low: { border: 'border-l-gray-500', bg: 'bg-gray-500/5', icon: Bell, iconColor: 'text-gray-400', badge: 'bg-gray-500/15 text-gray-400 border-gray-500/30' },
|
||||
};
|
||||
|
||||
const priorityLabel: Record<string, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
|
||||
const priorityLabel: Record<PriorityKey, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
|
||||
|
||||
const trendDatasets: Record<TrendRange, TrendDataset> = {
|
||||
7: {
|
||||
xAxis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
|
||||
submitted: [120, 132, 101, 134, 90, 230, 210],
|
||||
success: [110, 125, 95, 128, 85, 215, 200],
|
||||
failed: [5, 3, 4, 2, 3, 8, 5],
|
||||
},
|
||||
30: {
|
||||
xAxis: Array.from({ length: 15 }, (_, i) => `${i * 2 + 1}日`),
|
||||
submitted: [85, 92, 78, 105, 120, 95, 88, 110, 125, 140, 95, 82, 115, 130, 145],
|
||||
success: [80, 88, 75, 98, 112, 90, 85, 105, 118, 132, 90, 78, 108, 125, 138],
|
||||
failed: [3, 2, 2, 5, 4, 3, 2, 3, 4, 5, 3, 2, 4, 3, 4],
|
||||
},
|
||||
90: {
|
||||
xAxis: Array.from({ length: 12 }, (_, i) => `${i * 7 + 1}日`),
|
||||
submitted: [520, 480, 610, 590, 720, 680, 750, 800, 720, 650, 580, 620],
|
||||
success: [490, 455, 580, 560, 685, 650, 720, 770, 695, 625, 555, 598],
|
||||
failed: [18, 15, 22, 20, 25, 18, 20, 22, 18, 16, 14, 15],
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function calcHealthScore(inst: typeof dbInstances[0]): number {
|
||||
if (inst.status === 'offline') return 0;
|
||||
let score = 100;
|
||||
if (inst.cpu > 80) score -= 25;
|
||||
else if (inst.cpu > 60) score -= 10;
|
||||
if (inst.memory > 85) score -= 20;
|
||||
else if (inst.memory > 70) score -= 10;
|
||||
if (inst.latency > 10) score -= 20;
|
||||
else if (inst.latency > 5) score -= 10;
|
||||
if (inst.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>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartSkeleton() {
|
||||
return (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="w-full h-full animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main Dashboard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export default function Dashboard() {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [annFilter, setAnnFilter] = useState<AnnouncementFilter>('all');
|
||||
const [trendRange, setTrendRange] = useState<TrendRange>(7);
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [chartReady, setChartReady] = useState(false);
|
||||
|
||||
/* ---- Data Fetch ---- */
|
||||
useEffect(() => {
|
||||
dashboardApi.getStats()
|
||||
.then(data => setStats(data))
|
||||
.catch(() => {
|
||||
// fallback: keep stats null, show skeleton
|
||||
/* fallback: mock data */
|
||||
setStats({
|
||||
ticket_total: 128, ticket_pending: 23, ticket_month: 342,
|
||||
ticket_executing: 3, instance_total: 11, instance_online: 8,
|
||||
user_total: 7, queries_today: 15234,
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const visibleAnnouncements = announcements
|
||||
.filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id))
|
||||
.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
const po = { urgent: 0, high: 1, normal: 2, low: 3 };
|
||||
if (po[a.priority] !== po[b.priority]) return po[a.priority] - po[b.priority];
|
||||
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
|
||||
/* ---- Announcements ---- */
|
||||
const visibleAnnouncements = useMemo(() => {
|
||||
return announcements
|
||||
.filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id))
|
||||
.filter(a => annFilter === 'all' || a.priority === annFilter)
|
||||
.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
const po: Record<PriorityKey, number> = { urgent: 0, high: 1, normal: 2, low: 3 };
|
||||
if (po[a.priority as PriorityKey] !== po[b.priority as PriorityKey]) return po[a.priority as PriorityKey] - po[b.priority as PriorityKey];
|
||||
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
|
||||
});
|
||||
}, [dismissedIds, annFilter, user.role]);
|
||||
|
||||
const dismiss = useCallback((id: string) => setDismissedIds(prev => new Set(prev).add(id)), []);
|
||||
const toggleRead = useCallback((id: string) => {
|
||||
setReadIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const toggleExpand = useCallback((id: string) => setExpandedId(prev => prev === id ? null : id), []);
|
||||
|
||||
const dismiss = (id: string) => setDismissedIds(prev => new Set(prev).add(id));
|
||||
const toggleExpand = (id: string) => setExpandedId(prev => prev === id ? null : id);
|
||||
|
||||
/* ---- Chart ---- */
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
const chart = echarts.init(chartRef.current);
|
||||
if (!chartInstanceRef.current) {
|
||||
chartInstanceRef.current = echarts.init(chartRef.current);
|
||||
}
|
||||
const chart = chartInstanceRef.current;
|
||||
const isLight = theme === 'light';
|
||||
const colors = isLight
|
||||
? { bg: 'rgba(255,255,255,0.95)', border: '#e2e5eb', text: '#1a1d23', muted: '#5c6370', grid: '#f0f1f5', blue: '#2563eb', green: '#16a34a', red: '#dc2626' }
|
||||
: { bg: 'rgba(22, 27, 34, 0.95)', border: '#30363d', text: '#f0f6fc', muted: '#8b949e', grid: '#21262d', blue: '#58a6ff', green: '#3fb950', red: '#f85149' };
|
||||
const option: any = {
|
||||
|
||||
const dataset = trendDatasets[trendRange];
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis' as const,
|
||||
trigger: 'axis',
|
||||
backgroundColor: colors.bg,
|
||||
borderColor: colors.border,
|
||||
textStyle: { color: colors.text },
|
||||
@@ -75,13 +203,13 @@ export default function Dashboard() {
|
||||
},
|
||||
grid: { left: '2%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
|
||||
type: 'category',
|
||||
data: dataset.xAxis,
|
||||
axisLine: { lineStyle: { color: colors.border } },
|
||||
axisLabel: { color: colors.muted, fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
splitLine: { lineStyle: { color: colors.grid } },
|
||||
axisLabel: { color: colors.muted, fontSize: 11 },
|
||||
@@ -89,7 +217,7 @@ export default function Dashboard() {
|
||||
series: [
|
||||
{
|
||||
name: '提交工单', type: 'line', smooth: true,
|
||||
data: [120, 132, 101, 134, 90, 230, 210],
|
||||
data: dataset.submitted,
|
||||
itemStyle: { color: colors.blue },
|
||||
lineStyle: { width: 2 },
|
||||
areaStyle: {
|
||||
@@ -102,7 +230,7 @@ export default function Dashboard() {
|
||||
},
|
||||
{
|
||||
name: '执行成功', type: 'line', smooth: true,
|
||||
data: [110, 125, 95, 128, 85, 215, 200],
|
||||
data: dataset.success,
|
||||
itemStyle: { color: colors.green },
|
||||
lineStyle: { width: 2 },
|
||||
areaStyle: {
|
||||
@@ -115,45 +243,144 @@ export default function Dashboard() {
|
||||
},
|
||||
{
|
||||
name: '执行失败', type: 'line', smooth: true,
|
||||
data: [5, 3, 4, 2, 3, 8, 5],
|
||||
data: dataset.failed,
|
||||
itemStyle: { color: colors.red },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'none',
|
||||
},
|
||||
],
|
||||
};
|
||||
chart.setOption(option);
|
||||
chart.setOption(option, true);
|
||||
setChartReady(true);
|
||||
|
||||
const handleResize = () => chart.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => { window.removeEventListener('resize', handleResize); chart.dispose(); };
|
||||
}, [theme]);
|
||||
return () => { window.removeEventListener('resize', handleResize); };
|
||||
}, [theme, trendRange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { chartInstanceRef.current?.dispose(); };
|
||||
}, []);
|
||||
|
||||
/* ---- 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.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 4);
|
||||
const pendingTickets = tickets.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 5);
|
||||
|
||||
// Stats cards - use real API data
|
||||
const statsCards = stats ? [
|
||||
{ title: '工单总数', value: stats.ticket_total, icon: ClipboardList, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ title: '待审批', value: stats.ticket_pending, icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
||||
{ title: '本月工单', value: stats.ticket_month, icon: CheckCircle, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||
{ title: '今日查询', value: stats.queries_today.toLocaleString(), icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
||||
] : [];
|
||||
const statsCards = useMemo(() => {
|
||||
if (!stats) return [];
|
||||
return [
|
||||
{
|
||||
title: '工单总数', value: stats.ticket_total, icon: ClipboardList,
|
||||
color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20',
|
||||
change: '+8.2%', trend: 'up' as const,
|
||||
subtitle: `${stats.ticket_pending} 个待审批`,
|
||||
},
|
||||
{
|
||||
title: '待审批', value: stats.ticket_pending, icon: Clock,
|
||||
color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20',
|
||||
change: '+2', trend: 'up' as const,
|
||||
subtitle: `本月已处理 ${stats.ticket_month}`,
|
||||
},
|
||||
{
|
||||
title: '本月工单', value: stats.ticket_month, icon: CheckCircle,
|
||||
color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20',
|
||||
change: '-3.1%', trend: 'down' as const,
|
||||
subtitle: `${stats.ticket_executing} 个执行中`,
|
||||
},
|
||||
{
|
||||
title: '今日查询', value: stats.queries_today.toLocaleString(), icon: Zap,
|
||||
color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20',
|
||||
change: '+12%', trend: 'up' as const,
|
||||
subtitle: `${stats.instance_online}/${stats.instance_total} 实例在线`,
|
||||
},
|
||||
];
|
||||
}, [stats]);
|
||||
|
||||
const quickActions = [
|
||||
{ label: '新建工单', icon: FileText, href: '/tickets', color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' },
|
||||
{ label: '新建查询', icon: Search, href: '/queries', color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' },
|
||||
{ label: '添加实例', icon: Database, href: '/instances', color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
|
||||
{ label: '查看报表', icon: BarChart3, href: '/slow-query', color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20' },
|
||||
];
|
||||
|
||||
const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => {
|
||||
if (trend === 'up') return <ArrowUpRight className="w-3.5 h-3.5" />;
|
||||
if (trend === 'down') return <ArrowDownRight className="w-3.5 h-3.5" />;
|
||||
return <Minus className="w-3.5 h-3.5" />;
|
||||
};
|
||||
|
||||
const trendRangeOptions: { label: string; value: TrendRange }[] = [
|
||||
{ label: '近7天', value: 7 },
|
||||
{ label: '近30天', value: 30 },
|
||||
{ label: '近90天', value: 90 },
|
||||
];
|
||||
|
||||
const annFilterOptions: { label: string; value: AnnouncementFilter }[] = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '紧急', value: 'urgent' },
|
||||
{ label: '重要', value: 'high' },
|
||||
{ label: '通知', value: 'normal' },
|
||||
{ label: '一般', value: 'low' },
|
||||
];
|
||||
|
||||
/* ================================================================ */
|
||||
/* RENDER */
|
||||
/* ================================================================ */
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 公告通知栏 */}
|
||||
{/* ---- Quick Actions ---- */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{quickActions.map(action => (
|
||||
<a
|
||||
key={action.label}
|
||||
href={action.href}
|
||||
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" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ---- Announcements ---- */}
|
||||
{visibleAnnouncements.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-4 h-4 text-[var(--dg-text-secondary)]" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-secondary)]">公告通知</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">({visibleAnnouncements.length})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
{annFilterOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setAnnFilter(opt.value)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded-md transition-colors ${
|
||||
annFilter === opt.value
|
||||
? 'bg-blue-500/15 text-blue-400 font-medium'
|
||||
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleAnnouncements.slice(0, 3).map(ann => {
|
||||
const style = priorityStyle[ann.priority] || priorityStyle.normal;
|
||||
const style = priorityStyle[ann.priority as PriorityKey] || priorityStyle.normal;
|
||||
const PIcon = style.icon;
|
||||
const isExpanded = expandedId === ann.id;
|
||||
const isRead = readIds.has(ann.id);
|
||||
return (
|
||||
<div
|
||||
key={ann.id}
|
||||
className={`dg-card border-l-[3px] ${style.border} ${style.bg} overflow-hidden transition-all duration-300`}
|
||||
className={`dg-card border-l-[3px] ${style.border} ${style.bg} overflow-hidden transition-all duration-300 ${isRead ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="px-5 py-3.5 flex items-start gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${style.badge.split(' ').slice(1).join(' ')}`}>
|
||||
@@ -162,10 +389,11 @@ export default function Dashboard() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${style.badge}`}>
|
||||
{priorityLabel[ann.priority]}
|
||||
{priorityLabel[ann.priority as PriorityKey]}
|
||||
</span>
|
||||
<span className="text-sm font-semibold truncate">{ann.title}</span>
|
||||
{ann.pinned && <span className="text-[10px] text-purple-400 flex-shrink-0">置顶</span>}
|
||||
{ann.pinned && <span className="text-[10px] text-purple-400 flex-shrink-0 font-medium">置顶</span>}
|
||||
{isRead && <span className="text-[10px] text-[var(--dg-text-muted)] flex-shrink-0">已读</span>}
|
||||
</div>
|
||||
<div className={`text-xs text-[var(--dg-text-secondary)] leading-relaxed ${isExpanded ? '' : 'line-clamp-1'}`}>
|
||||
{ann.content}
|
||||
@@ -181,10 +409,18 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => toggleRead(ann.id)}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-blue-400 hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
title={isRead ? '标记未读' : '标记已读'}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-[11px] text-[var(--dg-text-muted)]">{ann.publishTime.slice(0, 10)}</span>
|
||||
<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"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -203,31 +439,28 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-5">
|
||||
{/* ---- Stats Cards ---- */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{loading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="h-20 animate-pulse bg-[var(--dg-bg-tertiary)] rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
Array.from({ length: 4 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||
) : (
|
||||
statsCards.map((s, i) => (
|
||||
<Card key={i} className="dg-card dg-card-hover">
|
||||
<CardContent className="p-5">
|
||||
<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-4">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
|
||||
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center`}>
|
||||
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center border ${s.border}`}>
|
||||
<s.icon className={`w-5 h-5 ${s.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-[var(--dg-text-primary)]">{s.value}</div>
|
||||
<div className="flex items-center gap-2 text-sm mt-2">
|
||||
<span className="text-[var(--dg-text-muted)]">
|
||||
实例 {stats?.instance_online}/{stats?.instance_total} 在线 · {stats?.user_total} 用户
|
||||
<div className="text-3xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div>
|
||||
<div className="flex items-center gap-3 mt-2.5">
|
||||
<span className={`flex items-center gap-1 text-xs font-medium ${s.trend === 'up' ? 'text-green-400' : s.trend === 'down' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}`}>
|
||||
<TrendIcon trend={s.trend} />
|
||||
{s.change}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{s.subtitle}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -235,70 +468,107 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图表 + 实例状态 */}
|
||||
<div className="grid grid-cols-3 gap-5">
|
||||
{/* 工单趋势图 */}
|
||||
<Card className="dg-card col-span-2">
|
||||
{/* ---- Charts + Instance Status ---- */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* Trend Chart */}
|
||||
<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">工单趋势</CardTitle>
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-blue-400" />
|
||||
工单趋势
|
||||
</CardTitle>
|
||||
<div className="flex gap-1.5">
|
||||
{['近7天', '近30天', '近90天'].map((p, i) => (
|
||||
<button key={p} className={`px-3 py-1.5 text-xs rounded-md transition-colors ${i === 0 ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'}`}>
|
||||
{p}
|
||||
{trendRangeOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setTrendRange(opt.value)}
|
||||
className={`px-3 py-1.5 text-xs rounded-md transition-all font-medium ${
|
||||
trendRange === opt.value
|
||||
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div ref={chartRef} style={{ height: 300 }} />
|
||||
{!chartReady ? <ChartSkeleton /> : <div ref={chartRef} style={{ height: 300 }} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 实例状态 */}
|
||||
{/* Instance Status */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold">实例状态</CardTitle>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{dbInstances.slice(0, 4).map(inst => (
|
||||
<div key={inst.id} className="flex items-center px-5 py-3.5 border-b border-[var(--dg-border-light)] last:border-0 hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mr-3.5 ${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" />
|
||||
{/* 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}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
{inst.qps.toLocaleString()} QPS
|
||||
</span>
|
||||
<span>{inst.connections} 连接</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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{inst.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</div>
|
||||
</div>
|
||||
<span className={`px-2.5 py-1 rounded-full text-[11px] font-medium ${inst.status === 'online' ? 'bg-green-500/15 text-green-400' : inst.status === 'warning' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{inst.status === 'online' ? '在线' : inst.status === 'warning' ? '告警' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 数据源健康概览 + 查询统计 */}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{/* 数据源健康概览 */}
|
||||
{/* ---- 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">
|
||||
@@ -310,20 +580,22 @@ export default function Dashboard() {
|
||||
</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', 'postgresql', 'redis', 'mongodb'] as const).map(type => {
|
||||
{(['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', postgresql: '/db-logos/postgres.svg', redis: '/db-logos/redis.svg', mongodb: '/db-logos/mongo.svg' };
|
||||
const labelMap: Record<string, string> = { mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB' };
|
||||
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)]">
|
||||
<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={typeOnline === typeInstances.length && typeInstances.length > 0 ? 'text-green-400' : typeOnline > 0 ? 'text-yellow-400' : 'text-[var(--dg-text-muted)]'}>
|
||||
<span className={allHealthy ? 'text-green-400' : someHealthy ? 'text-yellow-400' : 'text-[var(--dg-text-muted)]'}>
|
||||
{typeOnline}/{typeInstances.length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -331,26 +603,34 @@ export default function Dashboard() {
|
||||
);
|
||||
})}
|
||||
</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">需要关注</div>
|
||||
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').slice(0, 3).map(inst => (
|
||||
<div key={inst.id} className="flex items-center gap-3 py-2">
|
||||
<span className={`w-2 h-2 rounded-full ${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 ${inst.status === 'offline' ? 'bg-red-500/15 text-red-400' : 'bg-yellow-500/15 text-yellow-400'}`}>
|
||||
{inst.status === 'offline' ? '离线' : '告警'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<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">
|
||||
@@ -359,8 +639,8 @@ export default function Dashboard() {
|
||||
查询统计
|
||||
</CardTitle>
|
||||
<div className="flex gap-1.5">
|
||||
{['今日', '本周', '本月'].map((p, i) => (
|
||||
<button key={p} className={`px-3 py-1 text-xs rounded-md transition-colors ${i === 0 ? 'bg-green-500/15 text-green-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}>
|
||||
{(['今日', '本周', '本月'] 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>
|
||||
))}
|
||||
@@ -368,36 +648,43 @@ export default function Dashboard() {
|
||||
</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">1,284</div>
|
||||
<div className="text-[10px] text-green-400 mt-1">↑ 12% vs 昨日</div>
|
||||
<div className="text-xl font-bold text-[var(--dg-text-primary)]">1,284</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">23ms</div>
|
||||
<div className="text-[10px] text-green-400 mt-1">↓ 8% vs 昨日</div>
|
||||
<div className="text-xl font-bold text-[var(--dg-text-primary)]">23ms</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">7</div>
|
||||
<div className="text-[10px] text-red-400 mt-1">↑ 3 vs 昨日</div>
|
||||
<div className="text-xl font-bold text-[var(--dg-text-primary)]">7</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)]">查询类型分布</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] font-medium">查询类型分布</div>
|
||||
{[
|
||||
{ label: 'MySQL', count: 682, total: 1284, color: 'bg-[#00758f]' },
|
||||
{ label: 'PostgreSQL', count: 312, total: 1284, color: 'bg-[#336791]' },
|
||||
@@ -405,11 +692,11 @@ export default function Dashboard() {
|
||||
{ label: 'MongoDB', count: 92, total: 1284, color: 'bg-[#47a248]' },
|
||||
].map(item => (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
<span className="text-xs w-20 text-[var(--dg-text-secondary)]">{item.label}</span>
|
||||
<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}`} style={{ width: `${(item.count / item.total) * 100}%` }} />
|
||||
<div className={`h-full rounded-full ${item.color} transition-all duration-500`} style={{ width: `${(item.count / item.total) * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)]">{item.count}</span>
|
||||
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)] font-medium">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -417,54 +704,77 @@ export default function Dashboard() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 待处理工单 */}
|
||||
{/* ---- Pending Tickets ---- */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold">待处理工单</CardTitle>
|
||||
<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">
|
||||
<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-3 font-semibold text-[var(--dg-text-primary)]">工单号</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">标题</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">数据库</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">申请人</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">创建时间</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingTickets.map(ticket => (
|
||||
<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.5 font-mono text-xs">{ticket.id}</td>
|
||||
<td className="px-5 py-3.5 max-w-xs truncate">{ticket.title}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>
|
||||
{ticket.dbType.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">{ticket.applicant}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 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' : ''}`} />
|
||||
{statusMap[ticket.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors">查看</button>
|
||||
<button className="text-green-400 hover:text-green-300 text-xs font-medium transition-colors">审批</button>
|
||||
</div>
|
||||
</td>
|
||||
{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-3 font-semibold text-[var(--dg-text-primary)]">工单号</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">标题</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">数据库</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">申请人</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">创建时间</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">操作</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingTickets.map(ticket => (
|
||||
<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.5 font-mono text-xs text-[var(--dg-text-secondary)]">{ticket.id}</td>
|
||||
<td className="px-5 py-3.5 max-w-xs truncate font-medium">{ticket.title}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>
|
||||
{ticket.dbType.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">{ticket.applicant}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 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' : ''}`} />
|
||||
{statusMap[ticket.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button 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>
|
||||
<button className="flex items-center gap-1 text-green-400 hover:text-green-300 text-xs font-medium transition-colors">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
审批
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user