Files
opsxsql/frontend/src/components/Layout.tsx
T
logan 880dbfefa4 feat: Dashboard数据关联修复 + 通知模板优化 + 工单创建页布局
1. Dashboard: stats新增queryStats查询统计, trend API真实工单趋势数据
2. Layout铃铛: 显示真实待审批工单数量
3. 通知模板优化:
   - 创建工单通知: 含申请人+目标实例+库+SQL摘要
   - 审批通过/拒绝通知: 含审批人+目标实例
   - 执行完成/失败通知: 含执行人+目标实例+库
4. 飞书卡片颜色: 创建=turquoise, 审批=blue, 成功=green, 失败=red
5. 钉钉消息: 添加状态emoji标签(🆕✅❌⚠️)
6. TicketCreatePage: 底部操作栏(SQL检测+提交审批)
7. ticket_executor: 移除aiomysql不支持的read_timeout/write_timeout

Tests: 1057 passed, 17 pre-existing failures (notification 35/35 passed)
2026-05-25 01:29:24 +08:00

140 lines
6.3 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard, Database, Server, Zap, Leaf,
ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell,
LogOut, Settings, Clock, Megaphone, Sun, Moon, BookOpen
} from 'lucide-react';
import { menuSections } from '@/constants/menu';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
const iconMap: Record<string, React.ElementType> = {
LayoutDashboard, Database, Server, Zap, Leaf,
ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell,
Settings, Clock, Megaphone, BookOpen,
};
export default function Layout() {
const { user, logout, visibleMenuItems } = useAuth();
const { theme, toggleTheme } = useTheme();
const location = useLocation();
const navigate = useNavigate();
const currentPath = location.pathname.split('/')[1] || 'dashboard';
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
import('@/api/request').then(m => m.default.get('/dashboard/stats'))
.then((data: any) => setPendingCount(data?.ticket_pending ?? 0))
.catch(() => {});
}, [location.pathname]);
const getPageTitle = () => {
const item = visibleMenuItems.find(m => m.id === currentPath);
return item?.label || '仪表盘';
};
return (
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
{/* 侧边栏 */}
<aside className="flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
{/* Logo */}
<div className="h-14 flex items-center px-4">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg dg-gradient-br flex items-center justify-center flex-shrink-0">
<Shield className="w-4 h-4 text-white" />
</div>
<span className="text-base font-bold dg-gradient-text whitespace-nowrap">
SQL-Platform
</span>
</div>
</div>
{/* 菜单 */}
<nav className="flex-1 overflow-y-auto scrollbar-thin py-1">
{menuSections.map(section => {
const items = visibleMenuItems.filter(item => item.section === section.id);
if (items.length === 0) return null;
return (
<div key={section.id} className="mb-1">
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--dg-text-muted)]">
{section.label}
</div>
{items.map(item => {
const Icon = iconMap[item.icon] || Database;
const isActive = currentPath === item.id;
return (
<div
key={item.id}
onClick={() => navigate('/' + item.id)}
className={
'flex items-center mx-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 relative text-[13px] '
+ (isActive
? 'bg-blue-500/10 text-blue-400 font-medium'
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]')
}
>
{isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-blue-400 rounded-r" />
)}
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="ml-2.5">{item.label}</span>
</div>
);
})}
</div>
);
})}
</nav>
</aside>
{/* 主内容区 */}
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* 顶部栏 */}
<header className="h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-5 flex-shrink-0">
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--dg-text-muted)]">{getPageTitle()}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleTheme}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] transition-colors"
title={theme === 'dark' ? '浅色模式' : '深色模式'}
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
<button onClick={() => navigate('/tickets')}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] transition-colors relative">
<Bell className="w-4 h-4" />
{pendingCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 bg-red-500 rounded-full text-[10px] text-white font-bold flex items-center justify-center">
{pendingCount > 99 ? '99+' : pendingCount}
</span>
)}
</button>
<div className="w-px h-5 bg-[var(--dg-border)] mx-1" />
<div className="flex items-center gap-2 pl-1">
<div className="w-7 h-7 rounded-full dg-gradient-br flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
{user.avatar}
</div>
<span className="text-sm text-[var(--dg-text-secondary)] max-w-[80px] truncate">{user.username}</span>
<button
onClick={() => { logout(); navigate('/login'); }}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/10 hover:text-red-400 transition-colors"
title="退出登录"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</header>
{/* 页面内容 */}
<div className="flex-1 overflow-y-auto scrollbar-thin p-5">
<Outlet />
</div>
</main>
</div>
);
}