Files
opsxsql/frontend/src/pages/Dashboard.tsx
T
logan 27989274f5 feat: Phase 2 — frontend-backend integration complete
- All 5 core pages now use real API instead of mock data
  - Dashboard: /api/v1/dashboard/stats
  - Tickets: list/create/detail/approve/execute/statistics
  - Users: list/create/toggle-active/delete
  - Instances: list/create/test-connection/delete
  - Queries: execute with result display + sensitive field masking
- Updated DashboardStats type to match backend schema
- Fixed InstancePage to work with InstanceResponse (no qps/cpu/metrics yet)
- Added create forms for Tickets, Users, Instances
- Added connection test in InstanceDetail
- Updated TDD_PLAN.md: Phase 1 ✅ (44 tests), Phase 2 ✅
- Updated ROADMAP.md: v0.2.0 ✅
- Updated PROJECT_EVALUATION.md: score 5.7 → 6.2
- Frontend build passes with zero TS errors
- 44 backend tests still passing
2026-05-22 12:39:48 +08:00

352 lines
18 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import {
ClipboardList, Clock, CheckCircle, AlertTriangle,
Database, ChevronRight,
X, AlertCircle, Info, Bell
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth';
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 }> = {
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: '一般' };
export default function Dashboard() {
const chartRef = useRef<HTMLDivElement>(null);
const { theme } = useTheme();
const { user } = useAuth();
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);
useEffect(() => {
dashboardApi.getStats()
.then(data => setStats(data))
.catch(() => {
// fallback: keep stats null, show skeleton
})
.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();
});
const dismiss = (id: string) => setDismissedIds(prev => new Set(prev).add(id));
const toggleExpand = (id: string) => setExpandedId(prev => prev === id ? null : id);
useEffect(() => {
if (!chartRef.current) return;
const chart = echarts.init(chartRef.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 = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis' as const,
backgroundColor: colors.bg,
borderColor: colors.border,
textStyle: { color: colors.text },
},
legend: {
data: ['提交工单', '执行成功', '执行失败'],
textStyle: { color: colors.muted },
bottom: 0,
itemWidth: 14,
itemHeight: 8,
},
grid: { left: '2%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
xAxis: {
type: 'category' as const,
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
axisLine: { lineStyle: { color: colors.border } },
axisLabel: { color: colors.muted, fontSize: 11 },
},
yAxis: {
type: 'value' as const,
axisLine: { show: false },
splitLine: { lineStyle: { color: colors.grid } },
axisLabel: { color: colors.muted, fontSize: 11 },
},
series: [
{
name: '提交工单', type: 'line', smooth: true,
data: [120, 132, 101, 134, 90, 230, 210],
itemStyle: { color: colors.blue },
lineStyle: { width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: isLight ? 'rgba(37, 99, 235, 0.15)' : 'rgba(88, 166, 255, 0.3)' },
{ offset: 1, color: 'transparent' },
]),
},
symbol: 'none',
},
{
name: '执行成功', type: 'line', smooth: true,
data: [110, 125, 95, 128, 85, 215, 200],
itemStyle: { color: colors.green },
lineStyle: { width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: isLight ? 'rgba(22, 163, 74, 0.15)' : 'rgba(63, 185, 80, 0.3)' },
{ offset: 1, color: 'transparent' },
]),
},
symbol: 'none',
},
{
name: '执行失败', type: 'line', smooth: true,
data: [5, 3, 4, 2, 3, 8, 5],
itemStyle: { color: colors.red },
lineStyle: { width: 2 },
symbol: 'none',
},
],
};
chart.setOption(option);
const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize);
return () => { window.removeEventListener('resize', handleResize); chart.dispose(); };
}, [theme]);
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);
// 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' },
] : [];
return (
<div className="space-y-6">
{/* 公告通知栏 */}
{visibleAnnouncements.length > 0 && (
<div className="space-y-3">
{visibleAnnouncements.slice(0, 3).map(ann => {
const style = priorityStyle[ann.priority] || priorityStyle.normal;
const PIcon = style.icon;
const isExpanded = expandedId === ann.id;
return (
<div
key={ann.id}
className={`dg-card border-l-[3px] ${style.border} ${style.bg} overflow-hidden transition-all duration-300`}
>
<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(' ')}`}>
<PIcon className="w-4 h-4" />
</div>
<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]}
</span>
<span className="text-sm font-semibold truncate">{ann.title}</span>
{ann.pinned && <span className="text-[10px] text-purple-400 flex-shrink-0">置顶</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 flex items-center gap-0.5 transition-colors"
>
{isExpanded ? '收起' : '展开全文'}
<ChevronRight className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<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"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
})}
{visibleAnnouncements.length > 3 && (
<div className="text-center">
<span className="text-xs text-[var(--dg-text-muted)]">
还有 {visibleAnnouncements.length - 3} 条公告,可在「公告管理」中查看
</span>
</div>
)}
</div>
)}
{/* 统计卡片 */}
<div className="grid 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>
))
) : (
statsCards.map((s, i) => (
<Card key={i} className="dg-card dg-card-hover">
<CardContent className="p-5">
<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`}>
<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} 用户
</span>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* 图表 + 实例状态 */}
<div className="grid grid-cols-3 gap-5">
{/* 工单趋势图 */}
<Card className="dg-card 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>
<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}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-5">
<div ref={chartRef} style={{ height: 300 }} />
</CardContent>
</Card>
{/* 实例状态 */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold">实例状态</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="p-4 border-b border-[var(--dg-border-light)]">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<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="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="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" />
</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>
{/* 待处理工单 */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold">待处理工单</CardTitle>
</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>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}