feat: 全面UI优化 — 5套主题系统 + 设计体系 + 菜单重组 + 页面优化
1. 主题系统扩展 (5套主题) - ocean-deep(深海蓝) / light-professional(明亮白) / midnight-purple(午夜紫) - pine-green(松林绿) / orange-vitality(活力橙) - useTheme hook 重写支持5主题 + 向后兼容 - AppearanceSettings 5主题预览卡片 2. 设计体系 (index.css) - 按钮系统: dg-btn-primary/secondary/ghost/danger + 尺寸变体 - 徽章系统: dg-badge-* (8色) - 卡片/表格: dg-card-section, dg-table-row, dg-table-header - 状态点: dg-status-dot-* (4色) - 动效关键帧: slideInLeft, scaleIn 3. 菜单重组 (menu.ts) - 5分组: 概览/数据源/工单/权限/系统 - 6个图标更新: Code2/FileEdit/ShieldCheck/FolderLock/ScrollText/Timer - 新增 permission 权限码字段 4. Layout 侧边栏增强 - 左侧渐变指示条 + 发光效果 - hover 微动画 + 图标变色 - 面包屑导航头部 - 主题切换循环5主题 5. 核心页面优化 - TicketsPage: 设计系统类 + 颜色对齐 - QueriesPage: 去Card依赖 + dg-table/dg-btn统一 - Breadcrumb组件 + useBreadcrumb hook
This commit is contained in:
1 parent
4e2df4d3d7
commit
746a000e8a
25 files changed
+7718
-178
No files matched your search
@@ -0,0 +1,139 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
import { menuItems, menuSections } from '@/constants/menu';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export default function Breadcrumb({ items }: BreadcrumbProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="flex items-center text-xs">
|
||||
<ol className="flex items-center gap-1">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-1">
|
||||
{index > 0 && (
|
||||
<ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
)}
|
||||
{isLast ? (
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
) : item.path ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(item.path!)}
|
||||
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors inline-flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
{index === 0 && <Home className="w-3 h-3" />}
|
||||
{item.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[var(--dg-text-secondary)]">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that builds breadcrumb items based on the current route.
|
||||
*
|
||||
* It matches the current pathname against menuItems, then constructs:
|
||||
* Home > Section label > Page label
|
||||
*
|
||||
* For detail/sub pages (e.g. /instances/:id, /tickets/:id, /tickets/create),
|
||||
* it prepends the parent breadcrumb entries.
|
||||
*/
|
||||
export function useBreadcrumb(): { items: BreadcrumbItem[] } {
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
|
||||
const items: BreadcrumbItem[] = [];
|
||||
|
||||
// Find a direct menu item match by id (exact route)
|
||||
const directMatch = menuItems.find(
|
||||
(item) => item.id === pathname.replace(/^\//, '')
|
||||
);
|
||||
|
||||
if (directMatch) {
|
||||
// Exact menu match: Home > Section > Page
|
||||
const section = menuSections.find((s) => s.id === directMatch.section);
|
||||
|
||||
items.push({ label: '首页', path: '/dashboard' });
|
||||
if (section && section.id !== 'overview') {
|
||||
items.push({ label: section.label });
|
||||
}
|
||||
items.push({ label: directMatch.label });
|
||||
return { items };
|
||||
}
|
||||
|
||||
// Parse the pathname for sub/detail pages
|
||||
const segments = pathname.split('/').filter(Boolean); // e.g. ['instances', '123'] or ['tickets', 'create']
|
||||
if (segments.length === 0) {
|
||||
return { items: [{ label: '首页', path: '/dashboard' }] };
|
||||
}
|
||||
|
||||
const rootSegment = segments[0];
|
||||
const parentMenuItem = menuItems.find((item) => item.id === rootSegment);
|
||||
|
||||
if (!parentMenuItem) {
|
||||
// Unknown route — just show Home
|
||||
return { items: [{ label: '首页', path: '/dashboard' }] };
|
||||
}
|
||||
|
||||
const section = menuSections.find((s) => s.id === parentMenuItem.section);
|
||||
|
||||
// Home
|
||||
items.push({ label: '首页', path: '/dashboard' });
|
||||
|
||||
// Section (skip for overview section like dashboard)
|
||||
if (section && section.id !== 'overview') {
|
||||
items.push({ label: section.label });
|
||||
}
|
||||
|
||||
// Parent page (clickable for detail pages)
|
||||
if (segments.length > 1) {
|
||||
items.push({ label: parentMenuItem.label, path: `/${rootSegment}` });
|
||||
}
|
||||
|
||||
// Sub-page label
|
||||
if (segments.length > 1) {
|
||||
const subSegment = segments[1];
|
||||
const isDetailPage = !isNaN(Number(subSegment));
|
||||
|
||||
if (isDetailPage) {
|
||||
// Numeric ID — use a generic "详情" label
|
||||
items.push({ label: '详情' });
|
||||
} else {
|
||||
// Named sub-pages like 'create'
|
||||
const subPageLabels: Record<string, string> = {
|
||||
create: '新建',
|
||||
edit: '编辑',
|
||||
};
|
||||
items.push({ label: subPageLabels[subSegment] || subSegment });
|
||||
}
|
||||
} else {
|
||||
// Single segment — this is the current page
|
||||
items.push({ label: parentMenuItem.label });
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } 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
|
||||
Code2, FileEdit, Users, ShieldCheck, FolderLock, Shield, GitBranch, Bell,
|
||||
LogOut, Settings, Timer, ScrollText, Megaphone, Sun, Moon, BookOpen,
|
||||
ChevronRight, Monitor
|
||||
} from 'lucide-react';
|
||||
import { menuSections } from '@/constants/menu';
|
||||
import { menuSections, menuItems } from '@/constants/menu';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTheme, THEME_OPTIONS } 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,
|
||||
Code2, FileEdit, Users, ShieldCheck, FolderLock, Shield, GitBranch, Bell,
|
||||
Settings, Timer, ScrollText, Megaphone, BookOpen,
|
||||
};
|
||||
|
||||
export default function Layout() {
|
||||
@@ -29,10 +30,25 @@ export default function Layout() {
|
||||
.catch(() => {});
|
||||
}, [location.pathname]);
|
||||
|
||||
const getPageTitle = () => {
|
||||
const item = visibleMenuItems.find(m => m.id === currentPath);
|
||||
return item?.label || '仪表盘';
|
||||
};
|
||||
// Build breadcrumb info from current path
|
||||
const breadcrumbInfo = useMemo(() => {
|
||||
const currentItem = menuItems.find(m => m.id === currentPath);
|
||||
if (!currentItem) {
|
||||
return { sectionLabel: '', pageLabel: '仪表盘' };
|
||||
}
|
||||
const section = menuSections.find(s => s.id === currentItem.section);
|
||||
return {
|
||||
sectionLabel: section?.label || '',
|
||||
pageLabel: currentItem.label,
|
||||
};
|
||||
}, [currentPath]);
|
||||
|
||||
const themeOption = useMemo(() => THEME_OPTIONS.find(o => o.id === theme), [theme]);
|
||||
const themeIcon = useMemo(() => {
|
||||
if (!themeOption) return <Monitor className="w-4 h-4" />;
|
||||
if (themeOption.isDark) return <Moon className="w-4 h-4" />;
|
||||
return <Sun className="w-4 h-4" />;
|
||||
}, [themeOption]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
|
||||
@@ -68,16 +84,33 @@ export default function Layout() {
|
||||
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] '
|
||||
'group flex items-center mx-2 px-3 py-2 rounded-lg cursor-pointer '
|
||||
+ 'transition-all duration-200 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)]')
|
||||
? 'bg-[var(--dg-blue)]/10 text-[var(--dg-blue)] font-medium'
|
||||
: 'text-[var(--dg-text-secondary)] '
|
||||
+ 'hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] '
|
||||
+ 'hover:ml-0.5')
|
||||
}
|
||||
>
|
||||
{/* Active indicator bar - gradient with glow */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-blue-400 rounded-r" />
|
||||
<div
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r animate-[slideInLeft_200ms_ease-out]"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, var(--dg-blue), var(--dg-purple, var(--dg-blue)))',
|
||||
boxShadow: '0 0 8px 1px var(--dg-blue), 0 0 16px 2px color-mix(in srgb, var(--dg-blue) 40%, transparent)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<Icon
|
||||
className={
|
||||
'w-4 h-4 flex-shrink-0 transition-colors duration-200 '
|
||||
+ (isActive
|
||||
? 'text-[var(--dg-blue)]'
|
||||
: 'group-hover:text-[var(--dg-blue)]')
|
||||
}
|
||||
/>
|
||||
<span className="ml-2.5">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -92,27 +125,44 @@ export default function Layout() {
|
||||
<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>
|
||||
{/* Breadcrumb area */}
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<span className="text-[var(--dg-text-muted)] font-medium">SQL-Platform</span>
|
||||
{breadcrumbInfo.sectionLabel && (
|
||||
<>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
||||
<span className="text-[var(--dg-text-muted)] opacity-70">{breadcrumbInfo.sectionLabel}</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">{breadcrumbInfo.pageLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme toggle with tooltip */}
|
||||
<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' ? '浅色模式' : '深色模式'}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-all duration-200"
|
||||
title={`当前主题: ${themeOption?.name || theme}\n点击切换下一个主题`}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
{themeIcon}
|
||||
</button>
|
||||
|
||||
{/* Notification bell */}
|
||||
<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">
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-all duration-200 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">
|
||||
<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 animate-pulse">
|
||||
{pendingCount > 99 ? '99+' : pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-[var(--dg-border)] mx-1" />
|
||||
|
||||
{/* User area */}
|
||||
<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}
|
||||
@@ -120,7 +170,7 @@ export default function Layout() {
|
||||
<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"
|
||||
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 duration-200"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
@@ -134,6 +184,22 @@ export default function Layout() {
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Keyframe animation for active indicator */}
|
||||
<style>{`
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scaleX(0);
|
||||
transform-origin: left;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scaleX(1);
|
||||
transform-origin: left;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
|
||||
export default function CenteredCard() {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isDark = THEME_OPTIONS.find(o => o.id === theme)?.isDark ?? true;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, position: 'relative' }}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
|
||||
export default function FullImage() {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isDark = THEME_OPTIONS.find(o => o.id === theme)?.isDark ?? true;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, position: 'relative', overflow: 'hidden' }}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
import { Sun, Moon, Database } from 'lucide-react';
|
||||
|
||||
export default function MinimalDark() {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isDark = THEME_OPTIONS.find(o => o.id === theme)?.isDark ?? true;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24, background: 'var(--dg-bg-primary)', color: 'var(--dg-text-primary)' }}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
import { Sun, Moon, Database } from 'lucide-react';
|
||||
|
||||
export default function SidePanel() {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isDark = THEME_OPTIONS.find(o => o.id === theme)?.isDark ?? true;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--dg-bg-primary)', color: 'var(--dg-text-primary)' }}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLogin } from '@/hooks/useLogin';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
import { Sun, Moon, Database, Code, GitBranch, Bell, History, UserCog } from 'lucide-react';
|
||||
|
||||
const features = [
|
||||
@@ -13,7 +13,8 @@ const features = [
|
||||
|
||||
export default function SplitView() {
|
||||
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
|
||||
const { theme, toggleTheme, isDark } = useTheme();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isDark = THEME_OPTIONS.find(o => o.id === theme)?.isDark ?? true;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--dg-bg-primary)', color: 'var(--dg-text-primary)' }}>
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
export const menuSections = [
|
||||
{ id: 'overview', label: '概览' },
|
||||
{ id: 'datasource', label: '数据源' },
|
||||
{ id: 'ticket', label: '工单管理' },
|
||||
{ id: 'permission', label: '权限管理' },
|
||||
{ id: 'system', label: '系统配置' },
|
||||
{ id: 'config', label: '配置平台' },
|
||||
{ id: 'data', label: '数据源' },
|
||||
{ id: 'ticket', label: '工单' },
|
||||
{ id: 'permission', label: '权限' },
|
||||
{ id: 'system', label: '系统' },
|
||||
];
|
||||
|
||||
export const menuItems = [
|
||||
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'auditor'] },
|
||||
{ id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
|
||||
{ id: 'instance-groups', label: '权限组', icon: 'Lock', section: 'permission', roles: ['admin'] },
|
||||
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer', 'auditor'] },
|
||||
{ id: 'queries', label: '查询数据', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
|
||||
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
||||
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
|
||||
{ id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] },
|
||||
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] },
|
||||
{ id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] },
|
||||
{ id: 'settings', label: '系统设置', icon: 'Settings', section: 'config', roles: ['admin'] },
|
||||
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'dashboard.view' },
|
||||
{ id: 'instances', label: '数据源', icon: 'Database', section: 'data', roles: ['admin', 'dba'], permission: 'instances.manage' },
|
||||
{ id: 'queries', label: '查询数据', icon: 'Code2', section: 'data', roles: ['admin', 'dba', 'developer'], permission: 'queries.execute' },
|
||||
{ id: 'tickets', label: '数据变更', icon: 'FileEdit', section: 'ticket', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'tickets.manage' },
|
||||
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'], permission: 'users.manage' },
|
||||
{ id: 'roles', label: '角色权限', icon: 'ShieldCheck', section: 'permission', roles: ['admin'], permission: 'roles.manage' },
|
||||
{ id: 'instance-groups', label: '权限组', icon: 'FolderLock', section: 'permission', roles: ['admin'], permission: 'instance-groups.manage' },
|
||||
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'], permission: 'workflow.manage' },
|
||||
{ id: 'audit-logs', label: '审计日志', icon: 'ScrollText', section: 'system', roles: ['admin', 'dba'], permission: 'audit-logs.view' },
|
||||
{ id: 'auto-task', label: '自动任务', icon: 'Timer', section: 'system', roles: ['admin'], permission: 'auto-task.manage' },
|
||||
{ id: 'settings', label: '系统设置', icon: 'Settings', section: 'system', roles: ['admin'], permission: 'settings.manage' },
|
||||
];
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
|
||||
import api from '@/api/request';
|
||||
|
||||
export interface LoginState {
|
||||
@@ -33,7 +33,14 @@ export function useLogin(): LoginState {
|
||||
// Apply user preferences from backend (cross-browser sync)
|
||||
try {
|
||||
const prefs = await api.get<{ theme?: string; login_style?: string }>('/user/preferences');
|
||||
if (prefs?.theme) setTheme(prefs.theme as 'dark' | 'light');
|
||||
if (prefs?.theme) {
|
||||
const t = prefs.theme;
|
||||
if (t === 'dark' || t === 'light') {
|
||||
setTheme(t === 'dark' ? 'ocean-deep' : 'light-professional');
|
||||
} else if (THEME_OPTIONS.some(o => o.id === t)) {
|
||||
setTheme(t as Theme);
|
||||
}
|
||||
}
|
||||
if (prefs?.login_style) localStorage.setItem('login-style', prefs.login_style);
|
||||
} catch { /* ignore */ }
|
||||
navigate('/dashboard');
|
||||
|
||||
@@ -1,22 +1,54 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
export type Theme = 'ocean-deep' | 'light-professional' | 'midnight-purple' | 'pine-green' | 'orange-vitality';
|
||||
|
||||
export interface ThemeOption {
|
||||
id: Theme;
|
||||
name: string;
|
||||
description: string;
|
||||
isDark: boolean;
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: 'ocean-deep', name: '深海蓝', description: '专业稳重的深蓝色调', isDark: true, primaryColor: '#3b82f6' },
|
||||
{ id: 'light-professional', name: '明亮白', description: '清爽干净的浅色主题', isDark: false, primaryColor: '#2563eb' },
|
||||
{ id: 'midnight-purple', name: '午夜紫', description: '神秘优雅的紫色调', isDark: true, primaryColor: '#a855f7' },
|
||||
{ id: 'pine-green', name: '松林绿', description: '自然清新的绿色主题', isDark: true, primaryColor: '#22c55e' },
|
||||
{ id: 'orange-vitality', name: '活力橙', description: '温暖活力的橙色主题', isDark: true, primaryColor: '#f97316' },
|
||||
];
|
||||
|
||||
const THEME_ORDER: Theme[] = THEME_OPTIONS.map(o => o.id);
|
||||
const DEFAULT_THEME: Theme = 'ocean-deep';
|
||||
|
||||
// Backward compatibility: map old theme names to new ones
|
||||
function migrateLegacyTheme(value: string | null): Theme | null {
|
||||
if (value === 'dark') return 'ocean-deep';
|
||||
if (value === 'light') return 'light-professional';
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
isDark: boolean;
|
||||
isLight: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
const stored = localStorage.getItem('dg-theme') as Theme | null;
|
||||
if (stored === 'dark' || stored === 'light') return stored;
|
||||
return 'dark';
|
||||
if (typeof window === 'undefined') return DEFAULT_THEME;
|
||||
const stored = localStorage.getItem('dg-theme');
|
||||
const migrated = migrateLegacyTheme(stored);
|
||||
if (migrated) {
|
||||
// Persist the migrated value so we don't migrate every time
|
||||
localStorage.setItem('dg-theme', migrated);
|
||||
return migrated;
|
||||
}
|
||||
if (stored && THEME_ORDER.includes(stored as Theme)) {
|
||||
return stored as Theme;
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
@@ -32,7 +64,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState(prev => {
|
||||
const next = prev === 'dark' ? 'light' : 'dark';
|
||||
const idx = THEME_ORDER.indexOf(prev);
|
||||
const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length];
|
||||
localStorage.setItem('dg-theme', next);
|
||||
return next;
|
||||
});
|
||||
@@ -47,8 +80,6 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
theme,
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
isDark: theme === 'dark',
|
||||
isLight: theme === 'light',
|
||||
}), [theme, toggleTheme, setTheme]);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
|
||||
+325
-6
@@ -3,8 +3,8 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
/* ===== Theme 1: Ocean Deep (深蓝专业) — Default ===== */
|
||||
:root {
|
||||
/* ===== Dark Theme (default) ===== */
|
||||
--background: 220 13% 5%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 220 13% 7%;
|
||||
@@ -33,7 +33,6 @@
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 220 13% 18%;
|
||||
--sidebar-ring: 217 91% 60%;
|
||||
|
||||
--dg-bg-primary: #0d1117;
|
||||
--dg-bg-secondary: #161b22;
|
||||
--dg-bg-tertiary: #21262d;
|
||||
@@ -50,7 +49,6 @@
|
||||
--dg-purple: #8957e5;
|
||||
--dg-cyan: #39c5cf;
|
||||
--dg-yellow: #d29922;
|
||||
|
||||
--dg-card-hover-border: rgba(88, 166, 255, 0.5);
|
||||
--dg-gradient-start: #2563eb;
|
||||
--dg-gradient-end: #9333ea;
|
||||
@@ -59,8 +57,62 @@
|
||||
--dg-input-focus-ring: rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="ocean-deep"] {
|
||||
--background: 220 13% 5%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 220 13% 7%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 220 13% 7%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 13% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 220 13% 14%;
|
||||
--muted-foreground: 215 20% 55%;
|
||||
--accent: 220 13% 14%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 13% 18%;
|
||||
--input: 220 13% 14%;
|
||||
--ring: 217 91% 60%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 220 13% 7%;
|
||||
--sidebar-foreground: 210 40% 90%;
|
||||
--sidebar-primary: 217 91% 60%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 220 13% 14%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 220 13% 18%;
|
||||
--sidebar-ring: 217 91% 60%;
|
||||
--dg-bg-primary: #0d1117;
|
||||
--dg-bg-secondary: #161b22;
|
||||
--dg-bg-tertiary: #21262d;
|
||||
--dg-bg-hover: #30363d;
|
||||
--dg-border: #30363d;
|
||||
--dg-border-light: #21262d;
|
||||
--dg-text-primary: #f0f6fc;
|
||||
--dg-text-secondary: #8b949e;
|
||||
--dg-text-muted: #6e7681;
|
||||
--dg-blue: #58a6ff;
|
||||
--dg-green: #3fb950;
|
||||
--dg-red: #f85149;
|
||||
--dg-orange: #f0883e;
|
||||
--dg-purple: #8957e5;
|
||||
--dg-cyan: #39c5cf;
|
||||
--dg-yellow: #d29922;
|
||||
--dg-card-hover-border: rgba(88, 166, 255, 0.5);
|
||||
--dg-gradient-start: #2563eb;
|
||||
--dg-gradient-end: #9333ea;
|
||||
--dg-gradient-text-start: #60a5fa;
|
||||
--dg-gradient-text-end: #c084fc;
|
||||
--dg-input-focus-ring: rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
/* ===== Theme 2: Light Professional (浅色商务) ===== */
|
||||
[data-theme="light-professional"],
|
||||
[data-theme="light"] {
|
||||
/* ===== Light Theme ===== */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 220 14% 10%;
|
||||
--card: 0 0% 100%;
|
||||
@@ -89,7 +141,6 @@
|
||||
--sidebar-accent-foreground: 220 14% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217 91% 60%;
|
||||
|
||||
--dg-bg-primary: #ffffff;
|
||||
--dg-bg-secondary: #f8f9fb;
|
||||
--dg-bg-tertiary: #f0f1f5;
|
||||
@@ -106,7 +157,6 @@
|
||||
--dg-purple: #7c3aed;
|
||||
--dg-cyan: #0891b2;
|
||||
--dg-yellow: #ca8a04;
|
||||
|
||||
--dg-card-hover-border: rgba(37, 99, 235, 0.3);
|
||||
--dg-gradient-start: #2563eb;
|
||||
--dg-gradient-end: #7c3aed;
|
||||
@@ -114,6 +164,168 @@
|
||||
--dg-gradient-text-end: #6d28d9;
|
||||
--dg-input-focus-ring: rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
/* ===== Theme 3: Midnight Purple (午夜紫韵) ===== */
|
||||
[data-theme="midnight-purple"] {
|
||||
--background: 264 56% 4%;
|
||||
--foreground: 280 100% 98%;
|
||||
--card: 264 48% 8%;
|
||||
--card-foreground: 280 100% 98%;
|
||||
--popover: 264 48% 8%;
|
||||
--popover-foreground: 280 100% 98%;
|
||||
--primary: 270 91% 65%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 264 40% 20%;
|
||||
--secondary-foreground: 280 100% 98%;
|
||||
--muted: 264 40% 20%;
|
||||
--muted-foreground: 280 30% 60%;
|
||||
--accent: 264 40% 20%;
|
||||
--accent-foreground: 280 100% 98%;
|
||||
--destructive: 348 100% 72%;
|
||||
--destructive-foreground: 264 56% 4%;
|
||||
--border: 264 40% 20%;
|
||||
--input: 264 40% 20%;
|
||||
--ring: 270 91% 65%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 264 48% 6%;
|
||||
--sidebar-foreground: 280 100% 92%;
|
||||
--sidebar-primary: 270 91% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 264 40% 20%;
|
||||
--sidebar-accent-foreground: 280 100% 98%;
|
||||
--sidebar-border: 264 40% 20%;
|
||||
--sidebar-ring: 270 91% 65%;
|
||||
--dg-bg-primary: #0a0612;
|
||||
--dg-bg-secondary: #130c21;
|
||||
--dg-bg-tertiary: #1c1330;
|
||||
--dg-bg-hover: #2d1f4a;
|
||||
--dg-border: #2d1f4a;
|
||||
--dg-border-light: #1c1330;
|
||||
--dg-text-primary: #faf5ff;
|
||||
--dg-text-secondary: #b794c7;
|
||||
--dg-text-muted: #7c5a8a;
|
||||
--dg-blue: #818cf8;
|
||||
--dg-green: #34d399;
|
||||
--dg-red: #fb7185;
|
||||
--dg-orange: #fb923c;
|
||||
--dg-purple: #c084fc;
|
||||
--dg-cyan: #22d3ee;
|
||||
--dg-yellow: #facc15;
|
||||
--dg-card-hover-border: rgba(192, 132, 252, 0.5);
|
||||
--dg-gradient-start: #9333ea;
|
||||
--dg-gradient-end: #c084fc;
|
||||
--dg-gradient-text-start: #a855f7;
|
||||
--dg-gradient-text-end: #c084fc;
|
||||
--dg-input-focus-ring: rgba(192, 132, 252, 0.2);
|
||||
}
|
||||
|
||||
/* ===== Theme 4: Pine Green (松绿自然) ===== */
|
||||
[data-theme="pine-green"] {
|
||||
--background: 150 25% 5%;
|
||||
--foreground: 140 60% 97%;
|
||||
--card: 150 25% 9%;
|
||||
--card-foreground: 140 60% 97%;
|
||||
--popover: 150 25% 9%;
|
||||
--popover-foreground: 140 60% 97%;
|
||||
--primary: 142 71% 45%;
|
||||
--primary-foreground: 150 25% 5%;
|
||||
--secondary: 150 20% 18%;
|
||||
--secondary-foreground: 140 60% 97%;
|
||||
--muted: 150 20% 18%;
|
||||
--muted-foreground: 150 20% 55%;
|
||||
--accent: 150 20% 18%;
|
||||
--accent-foreground: 140 60% 97%;
|
||||
--destructive: 0 90% 72%;
|
||||
--destructive-foreground: 150 25% 5%;
|
||||
--border: 150 20% 18%;
|
||||
--input: 150 20% 18%;
|
||||
--ring: 142 71% 45%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 150 25% 7%;
|
||||
--sidebar-foreground: 140 40% 88%;
|
||||
--sidebar-primary: 142 71% 45%;
|
||||
--sidebar-primary-foreground: 150 25% 5%;
|
||||
--sidebar-accent: 150 20% 18%;
|
||||
--sidebar-accent-foreground: 140 60% 97%;
|
||||
--sidebar-border: 150 20% 18%;
|
||||
--sidebar-ring: 142 71% 45%;
|
||||
--dg-bg-primary: #0a1210;
|
||||
--dg-bg-secondary: #111f1a;
|
||||
--dg-bg-tertiary: #192c24;
|
||||
--dg-bg-hover: #253d32;
|
||||
--dg-border: #253d32;
|
||||
--dg-border-light: #192c24;
|
||||
--dg-text-primary: #f0fdf4;
|
||||
--dg-text-secondary: #86b8a0;
|
||||
--dg-text-muted: #5a7c6b;
|
||||
--dg-blue: #38bdf8;
|
||||
--dg-green: #4ade80;
|
||||
--dg-red: #f87171;
|
||||
--dg-orange: #fb923c;
|
||||
--dg-purple: #c084fc;
|
||||
--dg-cyan: #2dd4bf;
|
||||
--dg-yellow: #facc15;
|
||||
--dg-card-hover-border: rgba(74, 222, 128, 0.5);
|
||||
--dg-gradient-start: #059669;
|
||||
--dg-gradient-end: #10b981;
|
||||
--dg-gradient-text-start: #059669;
|
||||
--dg-gradient-text-end: #34d399;
|
||||
--dg-input-focus-ring: rgba(74, 222, 128, 0.2);
|
||||
}
|
||||
|
||||
/* ===== Theme 5: Orange Vitality (橙光活力) ===== */
|
||||
[data-theme="orange-vitality"] {
|
||||
--background: 30 35% 5%;
|
||||
--foreground: 30 100% 97%;
|
||||
--card: 30 30% 9%;
|
||||
--card-foreground: 30 100% 97%;
|
||||
--popover: 30 30% 9%;
|
||||
--popover-foreground: 30 100% 97%;
|
||||
--primary: 25 95% 53%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 30 25% 18%;
|
||||
--secondary-foreground: 30 100% 97%;
|
||||
--muted: 30 25% 18%;
|
||||
--muted-foreground: 30 25% 55%;
|
||||
--accent: 30 25% 18%;
|
||||
--accent-foreground: 30 100% 97%;
|
||||
--destructive: 0 80% 70%;
|
||||
--destructive-foreground: 30 35% 5%;
|
||||
--border: 30 25% 18%;
|
||||
--input: 30 25% 18%;
|
||||
--ring: 25 95% 53%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 30 30% 7%;
|
||||
--sidebar-foreground: 30 80% 88%;
|
||||
--sidebar-primary: 25 95% 53%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 30 25% 18%;
|
||||
--sidebar-accent-foreground: 30 100% 97%;
|
||||
--sidebar-border: 30 25% 18%;
|
||||
--sidebar-ring: 25 95% 53%;
|
||||
--dg-bg-primary: #110d08;
|
||||
--dg-bg-secondary: #1d1610;
|
||||
--dg-bg-tertiary: #2b1f16;
|
||||
--dg-bg-hover: #3d2d20;
|
||||
--dg-border: #3d2d20;
|
||||
--dg-border-light: #2b1f16;
|
||||
--dg-text-primary: #fef7ed;
|
||||
--dg-text-secondary: #d4a574;
|
||||
--dg-text-muted: #9c7a54;
|
||||
--dg-blue: #60a5fa;
|
||||
--dg-green: #4ade80;
|
||||
--dg-red: #f87171;
|
||||
--dg-orange: #fb923c;
|
||||
--dg-purple: #a78bfa;
|
||||
--dg-cyan: #2dd4bf;
|
||||
--dg-yellow: #fcd34d;
|
||||
--dg-card-hover-border: rgba(251, 146, 60, 0.5);
|
||||
--dg-gradient-start: #ea580c;
|
||||
--dg-gradient-end: #f59e0b;
|
||||
--dg-gradient-text-start: #f97316;
|
||||
--dg-gradient-text-end: #fbbf24;
|
||||
--dg-input-focus-ring: rgba(251, 146, 60, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -179,12 +391,109 @@
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--dg-text-muted);
|
||||
}
|
||||
[data-theme="light-professional"] .scrollbar-thin::-webkit-scrollbar-thumb,
|
||||
[data-theme="light"] .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
}
|
||||
[data-theme="light-professional"] .scrollbar-thin::-webkit-scrollbar-thumb:hover,
|
||||
[data-theme="light"] .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* ===== Design System Utilities ===== */
|
||||
|
||||
/* Buttons */
|
||||
.dg-btn {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200;
|
||||
}
|
||||
.dg-btn-primary {
|
||||
@apply dg-btn dg-gradient-bg text-white hover:opacity-90 active:scale-[0.98];
|
||||
}
|
||||
.dg-btn-secondary {
|
||||
@apply dg-btn bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] active:scale-[0.98];
|
||||
}
|
||||
.dg-btn-ghost {
|
||||
@apply dg-btn text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)];
|
||||
}
|
||||
.dg-btn-danger {
|
||||
@apply dg-btn bg-red-500/10 text-red-400 hover:bg-red-500/20 active:scale-[0.98];
|
||||
}
|
||||
.dg-btn-sm {
|
||||
@apply px-3 py-1.5 text-xs;
|
||||
}
|
||||
.dg-btn-lg {
|
||||
@apply px-6 py-3 text-base;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.dg-card-section {
|
||||
@apply dg-card p-5;
|
||||
}
|
||||
.dg-card-header {
|
||||
@apply text-sm font-semibold text-[var(--dg-text-primary)] mb-3;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.dg-badge {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium;
|
||||
}
|
||||
.dg-badge-blue {
|
||||
@apply dg-badge bg-blue-500/10 text-blue-400;
|
||||
}
|
||||
.dg-badge-green {
|
||||
@apply dg-badge bg-green-500/10 text-green-400;
|
||||
}
|
||||
.dg-badge-red {
|
||||
@apply dg-badge bg-red-500/10 text-red-400;
|
||||
}
|
||||
.dg-badge-yellow {
|
||||
@apply dg-badge bg-yellow-500/10 text-yellow-400;
|
||||
}
|
||||
.dg-badge-purple {
|
||||
@apply dg-badge bg-purple-500/10 text-purple-400;
|
||||
}
|
||||
.dg-badge-orange {
|
||||
@apply dg-badge bg-orange-500/10 text-orange-400;
|
||||
}
|
||||
.dg-badge-cyan {
|
||||
@apply dg-badge bg-cyan-500/10 text-cyan-400;
|
||||
}
|
||||
.dg-badge-muted {
|
||||
@apply dg-badge bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)];
|
||||
}
|
||||
|
||||
/* Spacing helpers */
|
||||
.dg-gap-section {
|
||||
@apply gap-6;
|
||||
}
|
||||
.dg-gap-card {
|
||||
@apply gap-4;
|
||||
}
|
||||
|
||||
/* Table rows */
|
||||
.dg-table-row {
|
||||
@apply flex items-center px-4 py-3 border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] transition-colors duration-150;
|
||||
}
|
||||
.dg-table-header {
|
||||
@apply flex items-center px-4 py-2.5 text-xs font-semibold text-[var(--dg-text-muted)] uppercase tracking-wider border-b border-[var(--dg-border)];
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.dg-status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
.dg-status-dot-green {
|
||||
@apply dg-status-dot bg-green-400;
|
||||
}
|
||||
.dg-status-dot-red {
|
||||
@apply dg-status-dot bg-red-400;
|
||||
}
|
||||
.dg-status-dot-yellow {
|
||||
@apply dg-status-dot bg-yellow-400;
|
||||
}
|
||||
.dg-status-dot-blue {
|
||||
@apply dg-status-dot bg-blue-400;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
@@ -197,3 +506,13 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from { opacity: 0; transform: translateX(-8px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
|
||||
import { LOGIN_STYLE_OPTIONS, type LoginStyle } from '@/pages/LoginPage';
|
||||
import { Sun, Moon, Check, Save, Loader2 } from 'lucide-react';
|
||||
import { Check, Save, Loader2 } from 'lucide-react';
|
||||
import api from '@/api/request';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
@@ -16,7 +16,15 @@ export default function AppearanceSettings() {
|
||||
useEffect(() => {
|
||||
api.get<{ theme?: string; login_style?: string }>('/user/preferences')
|
||||
.then(prefs => {
|
||||
if (prefs?.theme) setTheme(prefs.theme as 'dark' | 'light');
|
||||
if (prefs?.theme) {
|
||||
// Backward compat: map old values
|
||||
const t = prefs.theme;
|
||||
if (t === 'dark' || t === 'light') {
|
||||
setTheme(t === 'dark' ? 'ocean-deep' : 'light-professional');
|
||||
} else if (THEME_OPTIONS.some(o => o.id === t)) {
|
||||
setTheme(t as Theme);
|
||||
}
|
||||
}
|
||||
if (prefs?.login_style) setLoginStyle(prefs.login_style as LoginStyle);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -27,7 +35,7 @@ export default function AppearanceSettings() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleThemeChange = (t: 'dark' | 'light') => {
|
||||
const handleThemeChange = (t: Theme) => {
|
||||
setTheme(t);
|
||||
setDirty(true);
|
||||
};
|
||||
@@ -66,24 +74,36 @@ export default function AppearanceSettings() {
|
||||
<div className="space-y-8">
|
||||
{/* Theme Mode */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">主题模式</h3>
|
||||
<div className="flex gap-3">
|
||||
{([
|
||||
{ id: 'dark' as const, icon: Moon, label: '深色模式' },
|
||||
{ id: 'light' as const, icon: Sun, label: '浅色模式' },
|
||||
]).map(opt => (
|
||||
<h3 className="text-sm font-semibold mb-1">主题模式</h3>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">选择界面配色方案,即时预览效果</p>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{THEME_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => handleThemeChange(opt.id)}
|
||||
className={`flex items-center gap-2 px-5 py-3 rounded-xl text-sm font-medium transition-all ${
|
||||
theme === opt.id
|
||||
? 'bg-blue-500/15 text-blue-400 ring-1 ring-blue-500/30'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
className={`group relative rounded-xl p-4 text-left transition-all bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] ${
|
||||
theme === opt.id ? 'ring-2 ring-blue-500/50' : ''
|
||||
}`}
|
||||
>
|
||||
<opt.icon className="w-4 h-4" />
|
||||
{opt.label}
|
||||
{theme === opt.id && <Check className="w-3.5 h-3.5 ml-1" />}
|
||||
{/* Color preview swatch */}
|
||||
<div
|
||||
className="w-full h-6 rounded-md mb-3"
|
||||
style={{
|
||||
background: opt.isDark
|
||||
? `linear-gradient(135deg, ${opt.primaryColor} 0%, color-mix(in srgb, ${opt.primaryColor} 40%, #1a1a2e) 50%, #0d1117 100%)`
|
||||
: `linear-gradient(135deg, ${opt.primaryColor} 0%, color-mix(in srgb, ${opt.primaryColor} 60%, #ffffff) 50%, #f8fafc 100%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Active check mark */}
|
||||
{theme === opt.id && (
|
||||
<div className="absolute top-2 right-2 w-5 h-5 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h4 className="text-sm font-medium mb-0.5">{opt.name}</h4>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] leading-snug">{opt.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
|
||||
History, PanelRightClose, PanelRightOpen,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
// Card components replaced by design system utility classes
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
import SchemaSidebar from '@/components/SchemaSidebar';
|
||||
@@ -321,28 +321,24 @@ export default function QueriesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
{instanceId && (
|
||||
<button onClick={() => setShowSidebar(!showSidebar)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border transition-all ${
|
||||
showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}>
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
{showSidebar ? '隐藏' : '导航'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowTemplates(!showTemplates)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] border border-[var(--dg-border)] rounded-lg transition-all">
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm">
|
||||
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
|
||||
模板 ({templates.length})
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<button onClick={() => setShowHistory(!showHistory)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border transition-all ${
|
||||
showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}>
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||
{showHistory ? <PanelRightClose className="w-3.5 h-3.5" /> : <PanelRightOpen className="w-3.5 h-3.5" />}
|
||||
历史 {history.length > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{history.length}</span>}
|
||||
</button>
|
||||
<button onClick={handleExecute} disabled={executing || !instanceId}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-40 transition-all">
|
||||
className="dg-btn dg-btn-primary">
|
||||
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{executing ? '执行中...' : '执行'}
|
||||
</button>
|
||||
@@ -351,37 +347,35 @@ export default function QueriesPage() {
|
||||
|
||||
{/* -- 模板选择器 -- */}
|
||||
{showTemplates && (
|
||||
<div className="p-3 bg-[var(--dg-bg-secondary)] rounded-lg border border-[var(--dg-border)]">
|
||||
<div className="dg-card p-3">
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{templateCategories.map(cat => (
|
||||
<button key={cat} onClick={() => setTemplateCategory(cat)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded-md transition-all font-medium ${
|
||||
templateCategory === cat ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] border border-transparent'
|
||||
}`}>
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${templateCategory === cat ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||
{catLabel[cat] || cat}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
|
||||
<input value={templateSearch} onChange={e => setTemplateSearch(e.target.value)}
|
||||
className="pl-7 pr-3 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none w-40"
|
||||
placeholder="搜索模板..." />
|
||||
</div>
|
||||
</div>
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无匹配模板</div>
|
||||
<div className="text-center py-4 text-xs dg-text-muted">无匹配模板</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
||||
{filteredTemplates.map(tpl => (
|
||||
<button key={tpl.id} onClick={() => handleApplyTemplate(tpl)}
|
||||
className="text-left p-2.5 rounded-lg bg-[var(--dg-bg-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors group border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]"
|
||||
className="dg-card text-left p-2.5 hover:bg-[var(--dg-bg-hover)] group"
|
||||
title={tpl.description}>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium">{tpl.category}</span>
|
||||
<span className="text-sm font-medium group-hover:text-blue-400 transition-colors truncate">{tpl.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] line-clamp-1">{tpl.description}</p>
|
||||
<p className="text-xs dg-text-muted line-clamp-1">{tpl.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -405,7 +399,7 @@ export default function QueriesPage() {
|
||||
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
|
||||
language={editorLang} height="240px" onExecute={handleExecute}
|
||||
schema={(dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql') ? editorSchema : undefined} />
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)]">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] dg-text-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
|
||||
{dbTypeLabel[dbType]} 模式 · {sql.length} 字符
|
||||
@@ -428,32 +422,32 @@ export default function QueriesPage() {
|
||||
|
||||
{/* -- 查询结果 -- */}
|
||||
{result && (
|
||||
<Card className="dg-card overflow-hidden">
|
||||
<CardHeader className="border-b border-[var(--dg-border)] py-2.5">
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="border-b border-[var(--dg-border)] py-2.5 px-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<span className="text-sm font-semibold flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
查询结果
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
|
||||
</span>
|
||||
<div className="flex items-center gap-3 text-xs dg-text-secondary">
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{result.execution_time_ms}ms</span>
|
||||
<span>{result.rows?.length || 0} 行</span>
|
||||
<span>{result.columns?.length || 0} 列</span>
|
||||
{result.rows && result.rows.length > 0 && (
|
||||
<button onClick={handleExportResult}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-green-500/15 text-green-400 border border-green-500/30 rounded-md hover:bg-green-500/25 transition-all">
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm">
|
||||
<Download className="w-3 h-3" />导出 CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
</div>
|
||||
<div className="p-0">
|
||||
{result.columns && result.columns.length > 0 ? (
|
||||
<div ref={resultContainerRef} onScroll={handleResultScroll} className="overflow-auto max-h-[350px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<tr className="dg-table-header">
|
||||
<th className="text-left px-3 py-2 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>
|
||||
{result.columns.map(col => (
|
||||
<th key={col} onClick={() => handleSort(col)}
|
||||
@@ -473,7 +467,7 @@ export default function QueriesPage() {
|
||||
{(sortedResult?.rows || []).slice(visibleRange.start, visibleRange.end).map((row, i) => {
|
||||
const ri = visibleRange.start + i;
|
||||
return (
|
||||
<tr key={ri} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors" style={{ height: rowHeight }}>
|
||||
<tr key={ri} className="dg-table-row" style={{ height: rowHeight }}>
|
||||
<td className="px-3 py-2 text-[10px] text-[var(--dg-text-muted)] font-mono">{ri + 1}</td>
|
||||
{result.columns.map(col => {
|
||||
const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k));
|
||||
@@ -492,14 +486,14 @@ export default function QueriesPage() {
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center text-[var(--dg-text-muted)] flex flex-col items-center gap-2">
|
||||
<div className="p-6 text-center dg-text-muted flex flex-col items-center gap-2">
|
||||
<Eye className="w-5 h-5" />
|
||||
<span className="text-sm">无结果数据</span>
|
||||
{result.affected_rows > 0 && <span className="text-xs">影响行数: {result.affected_rows}</span>}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -- 安全提示 -- */}
|
||||
@@ -516,9 +510,9 @@ export default function QueriesPage() {
|
||||
<span className="text-sm font-semibold flex items-center gap-2">
|
||||
<History className="w-4 h-4 text-blue-400" />
|
||||
查询历史
|
||||
{history.length > 0 && <span className="text-xs text-[var(--dg-text-muted)] font-normal">({history.length})</span>}
|
||||
{history.length > 0 && <span className="text-xs dg-text-muted font-normal">({history.length})</span>}
|
||||
</span>
|
||||
<button onClick={() => setShowHistory(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||
<button onClick={() => setShowHistory(false)} className="dg-btn dg-btn-ghost dg-btn-sm p-1">
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -527,22 +521,18 @@ export default function QueriesPage() {
|
||||
{history.length > 0 && (
|
||||
<div className="px-4 pb-2 space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
|
||||
<input className="w-full pl-8 pr-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
|
||||
placeholder="搜索历史..." value={searchHistory} onChange={e => setSearchHistory(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setFavFilter(!favFilter)}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-[11px] rounded-md transition-all ${
|
||||
favFilter ? 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/30' : 'text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] border border-transparent'
|
||||
}`}>
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${favFilter ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' : ''}`}>
|
||||
<Star className="w-3 h-3" />仅收藏
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button onClick={handleClearHistory}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-[11px] rounded-md transition-all ${
|
||||
clearConfirm ? 'text-red-400 bg-red-500/10' : 'text-[var(--dg-text-muted)] hover:text-red-400'
|
||||
}`}>
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${clearConfirm ? 'text-red-400 bg-red-500/10' : ''}`}>
|
||||
{clearConfirm ? <CheckCircle className="w-3 h-3" /> : <X className="w-3 h-3" />}
|
||||
{clearConfirm ? '确认清除' : '清除'}
|
||||
</button>
|
||||
@@ -553,29 +543,29 @@ export default function QueriesPage() {
|
||||
{/* 历史列表 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{history.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-[var(--dg-text-muted)]">暂无查询历史</div>
|
||||
<div className="p-6 text-center text-xs dg-text-muted">暂无查询历史</div>
|
||||
) : filteredHistory.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-[var(--dg-text-muted)]">没有匹配的记录</div>
|
||||
<div className="p-6 text-center text-xs dg-text-muted">没有匹配的记录</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border-light)]">
|
||||
<div className="space-y-1.5 px-3 py-2">
|
||||
{filteredHistory.map(h => (
|
||||
<div key={h.id} className="px-4 py-2.5 hover:bg-[var(--dg-bg-hover)] transition-colors group">
|
||||
<div key={h.id} className={`dg-card px-3 py-2.5 group ${h.favorite ? 'bg-yellow-500/5 border-yellow-500/20' : ''}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{h.db_type && dbTypeIcon[h.db_type] && <img src={dbTypeIcon[h.db_type]} alt="" className="w-3.5 h-3.5 object-contain" />}
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${h.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{h.execution_time_ms}ms
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">{new Date(h.created_at).toLocaleTimeString()}</span>
|
||||
<span className="text-[10px] dg-text-muted">{new Date(h.created_at).toLocaleTimeString()}</span>
|
||||
<div className="flex-1" />
|
||||
<button onClick={() => toggleFavorite(h.id)}
|
||||
className={`p-0.5 rounded transition-colors ${h.favorite ? 'text-yellow-400' : 'text-[var(--dg-text-muted)] opacity-0 group-hover:opacity-100 hover:text-yellow-400'}`}>
|
||||
className={`p-0.5 rounded transition-colors ${h.favorite ? 'text-yellow-400' : 'dg-text-muted opacity-0 group-hover:opacity-100 hover:text-yellow-400'}`}>
|
||||
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="flex-1 text-[11px] font-mono text-blue-400 truncate">{h.sql.substring(0, 80)}</code>
|
||||
<button onClick={() => setSql(h.sql)}
|
||||
className="p-1 rounded text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors opacity-0 group-hover:opacity-100"
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-0 group-hover:opacity-100"
|
||||
title="粘贴到编辑器">
|
||||
<ClipboardPaste className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
FileCode, Clock, Filter, X, Loader2, Users,
|
||||
ArrowUpDown, Ban, ChevronLeft, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants';
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
@@ -104,6 +103,28 @@ const displayStatusColorMap: Record<string, string> = {
|
||||
rollback: 'bg-orange-500/15 text-orange-400',
|
||||
};
|
||||
|
||||
/** Design-system badge class per status */
|
||||
const dgBadgeStatusMap: Record<string, string> = {
|
||||
pending: 'dg-badge-blue',
|
||||
reviewing: 'dg-badge-blue',
|
||||
approved: 'dg-badge-blue',
|
||||
rejected: 'dg-badge-red',
|
||||
executing: 'dg-badge-orange',
|
||||
success: 'dg-badge-green',
|
||||
failed: 'dg-badge-red',
|
||||
rollback: 'dg-badge-orange',
|
||||
};
|
||||
|
||||
/** Design-system badge class per db type */
|
||||
const dgBadgeDbMap: Record<string, string> = {
|
||||
mysql: 'dg-badge-blue',
|
||||
postgres: 'dg-badge-purple',
|
||||
postgresql: 'dg-badge-purple',
|
||||
redis: 'dg-badge-red',
|
||||
mongo: 'dg-badge-green',
|
||||
mongodb: 'dg-badge-green',
|
||||
};
|
||||
|
||||
const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success'];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -211,13 +232,12 @@ export default function TicketsPage() {
|
||||
return result;
|
||||
}, [tickets, sortAsc]);
|
||||
|
||||
/* ---- Stats Cards ---- */
|
||||
const statusCards = useMemo(() => [
|
||||
{ key: '', label: '全部', count: stats?.total || 0, color: 'text-[var(--dg-text-primary)]', bg: 'bg-[var(--dg-bg-tertiary)]' },
|
||||
{ key: 'pending', label: '待审批', count: (stats?.pending || 0) + (stats?.reviewing || 0) + (stats?.approved || 0), color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ key: 'executing', label: '执行中', count: stats?.executing || 0, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||
{ key: 'success', label: '完成', count: stats?.success || 0, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ key: 'failed', label: '失败', count: stats?.failed || 0, color: 'text-red-400', bg: 'bg-red-400/10' },
|
||||
/* ---- Status Summary Cards (design system) ---- */
|
||||
const statusSummaryCards = useMemo(() => [
|
||||
{ key: 'pending', label: '待审核', count: (stats?.pending || 0) + (stats?.reviewing || 0) + (stats?.approved || 0), dot: 'dg-status-dot-blue' },
|
||||
{ key: 'executing', label: '执行中', count: stats?.executing || 0, dot: 'dg-status-dot-orange' },
|
||||
{ key: 'success', label: '已完成', count: stats?.success || 0, dot: 'dg-status-dot-green' },
|
||||
{ key: 'failed', label: '失败/拒绝', count: (stats?.failed || 0) + (stats?.rejected || 0), dot: 'dg-status-dot-red' },
|
||||
], [stats]);
|
||||
|
||||
/* ---- Open detail dialog ---- */
|
||||
@@ -238,27 +258,31 @@ export default function TicketsPage() {
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
{/* Status Quick Filters */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* Status Summary Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{stats === null ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-9 w-24 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="dg-card-section animate-pulse">
|
||||
<div className="dg-card-header">
|
||||
<div className="h-8 w-12 rounded bg-[var(--dg-bg-tertiary)]" />
|
||||
<div className="h-3 w-16 rounded bg-[var(--dg-bg-tertiary)]" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
statusCards.map(card => (
|
||||
statusSummaryCards.map(card => (
|
||||
<button
|
||||
key={card.key}
|
||||
onClick={() => { setStatusFilter(card.key); setPage(1); }}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
statusFilter === card.key
|
||||
? `${card.bg} ${card.color} ring-1 ring-current/20`
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
onClick={() => { setStatusFilter(statusFilter === card.key ? '' : card.key); setPage(1); }}
|
||||
className={`dg-card-section text-left transition-all cursor-pointer hover:ring-1 hover:ring-[var(--dg-border)] ${
|
||||
statusFilter === card.key ? 'ring-1 ring-blue-500/30 bg-blue-500/5' : ''
|
||||
}`}
|
||||
>
|
||||
{card.label}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${card.key === '' ? 'bg-[var(--dg-bg-hover)]' : 'bg-black/20'}`}>
|
||||
{card.count}
|
||||
</span>
|
||||
<div className="dg-card-header flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{card.count}</span>
|
||||
<span className={`dg-status-dot ${card.dot}`} />
|
||||
</div>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)] mt-1 block">{card.label}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
@@ -269,7 +293,7 @@ export default function TicketsPage() {
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none transition-colors"
|
||||
className="dg-input w-full pl-10 pr-4 py-2.5"
|
||||
placeholder="搜索工单标题或SQL内容..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
@@ -283,13 +307,13 @@ export default function TicketsPage() {
|
||||
</div>
|
||||
|
||||
{/* Ticket Table */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="p-0">
|
||||
{loading && tickets.length === 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)]">
|
||||
<tr className="dg-table-header">
|
||||
<th className="text-left px-4 py-3 font-semibold">工单编号</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">实例名称</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">提交人</th>
|
||||
@@ -314,7 +338,7 @@ export default function TicketsPage() {
|
||||
<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)]">
|
||||
<tr className="dg-table-header">
|
||||
<th className="text-left px-4 py-3 font-semibold">工单编号</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">实例名称</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">提交人</th>
|
||||
@@ -332,7 +356,7 @@ export default function TicketsPage() {
|
||||
{sortedTickets.map(ticket => (
|
||||
<tr
|
||||
key={ticket.id}
|
||||
className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
className="dg-table-row"
|
||||
>
|
||||
{/* 工单编号 */}
|
||||
<td className="px-4 py-3.5">
|
||||
@@ -345,7 +369,7 @@ export default function TicketsPage() {
|
||||
{/* 实例名称 */}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium ${dbTypeColorMap[ticket.db_type] || ''}`}>
|
||||
<span className={`dg-badge ${dgBadgeDbMap[ticket.db_type] || 'dg-badge-blue'}`}>
|
||||
{ticket.db_type.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm">{ticket.instance_name || '-'}</span>
|
||||
@@ -380,8 +404,7 @@ export default function TicketsPage() {
|
||||
|
||||
{/* 状态 */}
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${displayStatusColorMap[ticket.status] || ''}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'executing' || ticket.status === 'reviewing' ? 'animate-pulse' : ''}`} />
|
||||
<span className={`dg-badge ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
|
||||
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
|
||||
</span>
|
||||
</td>
|
||||
@@ -396,7 +419,7 @@ export default function TicketsPage() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => openDetail(ticket.id)}
|
||||
className="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors"
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
{ticket.status === 'success' || ticket.status === 'failed' ? '详情' : '查看'}
|
||||
@@ -410,7 +433,7 @@ export default function TicketsPage() {
|
||||
fetchTickets(); fetchStats();
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="flex items-center gap-1 text-green-400 hover:text-green-300 text-xs font-medium transition-colors"
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-green-400 hover:text-green-300"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
通过
|
||||
@@ -425,7 +448,7 @@ export default function TicketsPage() {
|
||||
fetchTickets(); fetchStats();
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="flex items-center gap-1 text-red-400 hover:text-red-300 text-xs font-medium transition-colors"
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-red-400 hover:text-red-300"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
拒绝
|
||||
@@ -440,7 +463,7 @@ export default function TicketsPage() {
|
||||
fetchTickets(); fetchStats();
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="flex items-center gap-1 text-purple-400 hover:text-purple-300 text-xs font-medium transition-colors"
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-purple-400 hover:text-purple-300"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
执行
|
||||
@@ -455,7 +478,7 @@ export default function TicketsPage() {
|
||||
fetchTickets(); fetchStats();
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="flex items-center gap-1 text-amber-400 hover:text-amber-300 text-xs font-medium transition-colors"
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-amber-400 hover:text-amber-300"
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" />
|
||||
撤销
|
||||
@@ -478,7 +501,7 @@ export default function TicketsPage() {
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
className="px-3 py-1.5 rounded-md text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50 transition-colors"
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
@@ -497,8 +520,8 @@ export default function TicketsPage() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm transition-colors ${
|
||||
p === page ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
className={`dg-btn dg-btn-sm dg-btn-ghost ${
|
||||
p === page ? 'bg-blue-500 text-white hover:bg-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
@@ -508,15 +531,15 @@ export default function TicketsPage() {
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="px-3 py-1.5 rounded-md text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50 transition-colors"
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Dialog */}
|
||||
{detailTicket && (
|
||||
@@ -581,7 +604,7 @@ function TicketDetailDialog({ ticket, onClose, canApprove, canExecute, onActionD
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${displayStatusColorMap[ticket.status] || ''}`}>
|
||||
<span className={`dg-badge ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
|
||||
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -591,14 +614,14 @@ function TicketDetailDialog({ ticket, onClose, canApprove, canExecute, onActionD
|
||||
<button
|
||||
onClick={() => handleReview('approve')}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-green-500/15 text-green-400 border border-green-500/30 rounded-md hover:bg-green-500/25 disabled:opacity-50 transition-all"
|
||||
className="dg-btn dg-btn-sm bg-green-500/15 text-green-400 border border-green-500/30 hover:bg-green-500/25 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> 通过
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReview('reject')}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-500/15 text-red-400 border border-red-500/30 rounded-md hover:bg-red-500/25 disabled:opacity-50 transition-all"
|
||||
className="dg-btn dg-btn-sm bg-red-500/15 text-red-400 border border-red-500/30 hover:bg-red-500/25 disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" /> 拒绝
|
||||
</button>
|
||||
@@ -608,7 +631,7 @@ function TicketDetailDialog({ ticket, onClose, canApprove, canExecute, onActionD
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-purple-500/15 text-purple-400 border border-purple-500/30 rounded-md hover:bg-purple-500/25 disabled:opacity-50 transition-all"
|
||||
className="dg-btn dg-btn-sm bg-purple-500/15 text-purple-400 border border-purple-500/30 hover:bg-purple-500/25 disabled:opacity-50"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" /> 执行
|
||||
</button>
|
||||
|
||||
Reference in new issue
Block a user