feat: 全站 PageShell 设计系统与专业 UI 统一

- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架
- index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩)
- 全站路由页接入 PageShell,云账单三 Tab 为标杆
- 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化
- 登录页/侧栏/顶栏去渐变光球,企业克制风
- 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
This commit is contained in:
Logan committed 2026-07-01 12:45:38 +08:00
1 parent 1acb0c49af
commit e363ded445
213 files changed
+7148 -6818

No files matched your search

@@ -0,0 +1,30 @@
// 列表/表格内容面板 — 统一卡片容器 + 可选工具栏槽 // Logan.Li
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface ContentPanelProps {
toolbar?: ReactNode;
children: ReactNode;
className?: string;
bodyClassName?: string;
noPadding?: boolean;
}
export function ContentPanel({
toolbar,
children,
className,
bodyClassName,
noPadding,
}: ContentPanelProps) {
return (
<div className={cn('dg-content-panel', className)}>
{toolbar && (
<div className="dg-content-panel-toolbar">{toolbar}</div>
)}
<div className={cn(!noPadding && 'dg-content-panel-body', bodyClassName)}>
{children}
</div>
</div>
);
}
@@ -65,7 +65,7 @@ export class ErrorBoundary extends Component<Props, State> {
<div className="dg-card max-w-lg w-full p-8 text-center space-y-6">
{/* Error Icon */}
<div className="mx-auto w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center">
<AlertTriangle className="h-8 w-8 text-red-400" />
<AlertTriangle className="h-8 w-8 dg-text-red" />
</div>
{/* Title */}
@@ -87,7 +87,7 @@ export class ErrorBoundary extends Component<Props, State> {
查看错误详情
</summary>
<div className="mt-2 p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] overflow-auto max-h-48">
<code className="text-xs text-red-400 font-mono block whitespace-pre-wrap">
<code className="text-xs dg-text-red font-mono block whitespace-pre-wrap">
{this.state.error.name}: {this.state.error.message}
</code>
{this.state.errorInfo && (
+14 -42
View File
@@ -28,7 +28,8 @@ import {
Languages,
} from 'lucide-react';
import { formatDistanceToNow } from '@/lib/utils';
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
import { useTheme } from '@/hooks/useTheme';
import { ThemePicker } from '@/components/layout/ThemePicker';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '@/i18n';
// Keyboard shortcut handler — Cmd/Ctrl+B 切换侧边栏
@@ -65,22 +66,23 @@ export function Header() {
const getNotificationIcon = useCallback((type: string) => {
switch (type) {
case 'error':
return <AlertTriangle className="h-4 w-4 text-red-500" />;
return <AlertTriangle className="h-4 w-4 dg-text-red" />;
case 'warning':
return <AlertTriangle className="h-4 w-4 text-amber-500" />;
return <AlertTriangle className="h-4 w-4 dg-text-yellow" />;
case 'success':
return <CheckCircle className="h-4 w-4 text-emerald-500" />;
return <CheckCircle className="h-4 w-4 dg-text-green" />;
default:
return <Info className="h-4 w-4 text-blue-500" />;
return <Info className="h-4 w-4 dg-text-blue" />;
}
}, []);
return (
<header
className={cn(
'fixed top-0 right-0 z-30 h-[var(--header-height)] bg-[var(--dg-bg-secondary)]/95 backdrop-blur-md',
'border-b border-[var(--dg-border)] transition-[margin] duration-300 ease-in-out',
'supports-[backdrop-filter]:bg-[var(--dg-bg-secondary)]/75',
'fixed top-0 right-0 z-30 h-[var(--layout-header-height)]',
'bg-[var(--surface-raised)]/95 backdrop-blur-md',
'border-b border-[var(--border-default)] transition-[margin] duration-300 ease-in-out',
'supports-[backdrop-filter]:bg-[var(--surface-raised)]/75',
)}
style={{ marginLeft: 'var(--sidebar-width)', left: 0 }}
>
@@ -118,38 +120,8 @@ export function Header() {
className="w-60 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
style={{ boxShadow: 'var(--shadow-dropdown)' }}
>
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">{t('header.themeSwitch')}</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
{THEME_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.id}
className={cn(
'flex items-center gap-2.5 cursor-pointer transition-colors',
'focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]',
theme === option.id
? 'text-[var(--dg-text-primary)]'
: 'text-[var(--dg-text-secondary)]',
)}
onClick={() => setTheme(option.id)}
>
<span
className="w-3.5 h-3.5 rounded-full shrink-0 ring-2"
style={{
backgroundColor: option.primaryColor,
boxShadow: '0 0 0 2px var(--dg-bg-primary)',
}}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{option.name}</span>
<span className="block text-xs text-[var(--dg-text-muted)] truncate">
{option.description}
</span>
</div>
{theme === option.id && (
<Check className="h-4 w-4 text-[var(--dg-blue)] shrink-0" />
)}
</DropdownMenuItem>
))}
<DropdownMenuLabel className="text-[var(--text-secondary)]">{t('header.themeSwitch')}</DropdownMenuLabel>
<ThemePicker value={theme} onChange={setTheme} />
</DropdownMenuContent>
</DropdownMenu>
@@ -304,7 +276,7 @@ export function Header() {
variant="ghost"
className="h-8 gap-2 px-2 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
>
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-[var(--dg-blue)] to-[var(--dg-purple)] flex items-center justify-center text-white text-xs font-bold">
<div className="w-7 h-7 rounded-full bg-[var(--brand)] flex items-center justify-center text-white text-xs font-bold">
{displayName.charAt(0).toUpperCase()}
</div>
<span className="hidden md:inline text-sm max-w-[100px] truncate">
@@ -344,7 +316,7 @@ export function Header() {
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="text-red-400 focus:bg-red-500/10 focus:text-red-400 cursor-pointer"
className="dg-text-red focus:bg-red-500/10 focus:dg-text-red cursor-pointer"
onClick={handleLogout}
>
<LogOut className="h-4 w-4 mr-2.5" />
+13 -27
View File
@@ -9,15 +9,8 @@ import { authApi } from '@/api';
import type { JsonData } from '@/types/common';
/**
* MainLayout - 优化后的主布局组件
*
* 优化点:
* 1. 使用 CSS 变量 + Tailwind 类替代硬编码的 style 属性
* 2. sidebar 宽度通过 CSS 变量管理,统一协调
* 3. 添加 Breadcrumb 面包屑导航
* 4. 布局过渡动画平滑
* 5. 支持响应式布局
* 6. F3: 页面刷新后自动拉取当前用户权限(RBAC 按钮级控制)
* MainLayout — 统一应用布局壳
* 侧栏 + 顶栏 + 面包屑 + 内容区,间距由 --layout-* 令牌驱动 // Logan.Li
*/
export function MainLayout() {
const { sidebarCollapsed } = useThemeStore();
@@ -25,8 +18,6 @@ export function MainLayout() {
const permissions = useUserStore((s) => s.permissions);
const setPermissions = useUserStore((s) => s.setPermissions);
// 页面刷新后 zustand persist 会恢复 token/user,但 permissions 可能丢失或过期
// 在 MainLayout 挂载时如果 permissions 为 null 则拉取一次
useEffect(() => {
if (!token || permissions !== null) return;
let cancelled = false;
@@ -37,7 +28,6 @@ export function MainLayout() {
setPermissions(Array.isArray(perms) ? perms : []);
})
.catch(() => {
// 拉取失败不阻塞页面,权限保持 null(usePermission 默认放行)
if (!cancelled) setPermissions([]);
});
return () => { cancelled = true; };
@@ -46,33 +36,29 @@ export function MainLayout() {
return (
<div
className="min-h-screen bg-[var(--dg-bg-primary)] transition-colors duration-300"
className="dg-app-shell"
style={{
'--sidebar-width': sidebarCollapsed ? '4rem' : '16rem',
'--header-height': '3.5rem',
'--sidebar-width': sidebarCollapsed
? 'var(--layout-sidebar-collapsed)'
: 'var(--layout-sidebar-expanded)',
} as React.CSSProperties}
>
<Sidebar />
<div
className={cn(
'flex flex-col min-h-screen transition-[margin] duration-300 ease-in-out',
)}
className={cn('flex flex-col min-h-screen transition-[margin] duration-300 ease-in-out')}
style={{ marginLeft: 'var(--sidebar-width)' }}
>
<Header />
<main className="flex-1 pt-[var(--header-height)]">
{/* Breadcrumb Navigation */}
<div className="px-6 pt-4 pb-0">
<main className="dg-app-main">
<div className="dg-app-breadcrumb">
<Breadcrumb />
</div>
{/* Page Content */}
<div className="p-6">
<div className="dg-app-content">
<Outlet />
</div>
</main>
{/* Footer */}
<footer className="px-6 py-4 border-t border-[var(--dg-border)] mt-auto">
<div className="flex items-center justify-between text-xs text-[var(--dg-text-muted)]">
<footer className="dg-app-footer">
<div className="flex items-center justify-between text-xs dg-text-muted">
<span>AIOps 智能运维平台</span>
<span>v1.0.0</span>
</div>
@@ -80,4 +66,4 @@ export function MainLayout() {
</div>
</div>
);
}
}
@@ -0,0 +1,28 @@
// 页面级空状态 // Logan.Li
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface PageEmptyProps {
icon?: ReactNode;
title?: string;
description?: string;
action?: ReactNode;
className?: string;
}
export function PageEmpty({
icon,
title = '暂无数据',
description,
action,
className,
}: PageEmptyProps) {
return (
<div className={cn('flex flex-col items-center justify-center py-16 text-center', className)}>
{icon && <div className="mb-3 opacity-40 text-[var(--text-tertiary)]">{icon}</div>}
<p className="text-sm font-medium text-[var(--text-primary)]">{title}</p>
{description && <p className="text-sm dg-text-muted mt-1 max-w-sm">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
@@ -0,0 +1,24 @@
// 页面级加载态 // Logan.Li
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface PageLoadingProps {
label?: string;
className?: string;
compact?: boolean;
}
export function PageLoading({ label = '加载中...', className, compact }: PageLoadingProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center dg-text-muted',
compact ? 'py-12' : 'py-20',
className,
)}
>
<Loader2 className="h-6 w-6 animate-spin mb-2" />
<span className="text-sm">{label}</span>
</div>
);
}
@@ -0,0 +1,106 @@
// 统一页面骨架 — 标题区 / 指标条 / 工具栏 / Tab / 内容区 // Logan.Li
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { PageTabs, type PageTabItem } from './PageTabs';
import { PageLoading } from './PageLoading';
export type PageShellVariant = 'default' | 'list' | 'config' | 'workspace';
const maxWidthClass: Record<string, string> = {
full: 'w-full',
lg: 'max-w-6xl mx-auto w-full',
xl: 'max-w-7xl mx-auto w-full',
'3xl': 'max-w-3xl mx-auto w-full',
};
export interface PageShellProps {
title: string;
subtitle?: ReactNode;
/** 标题左侧图标(建议单色 lucide,勿用渐变底) */
icon?: ReactNode;
actions?: ReactNode;
/** 标题下方指标条(StatStrip 等) */
metrics?: ReactNode;
/** 筛选 / 搜索工具栏,与标题操作区分开 */
toolbar?: ReactNode;
tabs?: PageTabItem[];
activeTab?: string;
onTabChange?: (id: string) => void;
variant?: PageShellVariant;
maxWidth?: keyof typeof maxWidthClass;
loading?: boolean;
loadingLabel?: string;
children: ReactNode;
className?: string;
contentClassName?: string;
}
export function PageShell({
title,
subtitle,
icon,
actions,
metrics,
toolbar,
tabs,
activeTab,
onTabChange,
variant = 'default',
maxWidth = 'full',
loading = false,
loadingLabel,
children,
className,
contentClassName,
}: PageShellProps) {
const width = maxWidthClass[maxWidth] ?? maxWidthClass.full;
const isConfig = variant === 'config' || maxWidth === '3xl';
return (
<div
className={cn(
'dg-page',
width,
variant === 'workspace' && 'flex flex-col min-h-0',
className,
)}
>
<header className="dg-page-header">
<div className="flex items-start gap-3 min-w-0">
{icon && (
<div className="dg-icon-box">{icon}</div>
)}
<div className="min-w-0">
<h1 className="dg-page-title truncate">{title}</h1>
{subtitle != null && subtitle !== '' && (
<p className="dg-page-subtitle">{subtitle}</p>
)}
</div>
</div>
{actions && <div className="dg-page-actions shrink-0">{actions}</div>}
</header>
{metrics && <section className="animate-fade-in-delay-1">{metrics}</section>}
{tabs && tabs.length > 0 && activeTab && onTabChange && (
<PageTabs items={tabs} value={activeTab} onChange={onTabChange} />
)}
{toolbar && (
<div className="dg-page-toolbar animate-fade-in-delay-1">{toolbar}</div>
)}
<section
className={cn(
'animate-fade-in-delay-2',
variant === 'workspace' && 'flex-1 min-h-0 flex flex-col',
isConfig && 'space-y-4',
variant === 'list' && 'space-y-0',
contentClassName,
)}
>
{loading ? <PageLoading label={loadingLabel} /> : children}
</section>
</div>
);
}
@@ -0,0 +1,48 @@
// 页面级 Tab 导航 — 与内容区筛选分离 // Logan.Li
import { cn } from '@/lib/utils';
export interface PageTabItem {
id: string;
label: string;
badge?: number | string;
}
interface PageTabsProps {
items: PageTabItem[];
value: string;
onChange: (id: string) => void;
className?: string;
}
export function PageTabs({ items, value, onChange, className }: PageTabsProps) {
return (
<nav
className={cn('dg-page-tabs flex gap-0.5 border-b border-[var(--border-default)]', className)}
role="tablist"
>
{items.map((tab) => {
const active = value === tab.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(tab.id)}
className={cn(
'dg-page-tab px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
active
? 'text-[var(--brand)] border-[var(--brand)]'
: 'dg-text-secondary border-transparent hover:text-[var(--text-primary)]',
)}
>
{tab.label}
{tab.badge != null && (
<span className="ml-1.5 text-xs dg-text-muted">({tab.badge})</span>
)}
</button>
);
})}
</nav>
);
}
+2 -2
View File
@@ -380,8 +380,8 @@ export function Sidebar() {
{/* Logo Area */}
<div className="flex items-center h-14 px-3 border-b border-[var(--dg-border)] flex-shrink-0">
<div className={cn('flex items-center gap-2.5 flex-1 min-w-0', sidebarCollapsed && 'justify-center')}>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[var(--dg-blue)] to-[var(--dg-cyan)] flex items-center justify-center flex-shrink-0 shadow-lg shadow-[var(--dg-blue)]/20">
<Cloud className="h-5 w-5 text-white" />
<div className="dg-icon-box w-8 h-8 rounded-lg flex-shrink-0">
<Cloud className="h-5 w-5" />
</div>
{!sidebarCollapsed && (
<span className="font-bold text-[var(--dg-text-primary)] text-sm tracking-tight truncate">
@@ -0,0 +1,61 @@
// 全局主题选择器 — Header / Login 共用,分组展示主流与开发者主题 // Logan.Li
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { THEME_GROUPS, type ThemePreference } from '@/utils/theme';
interface ThemePickerProps {
value: ThemePreference;
onChange: (theme: ThemePreference) => void;
}
export function ThemePicker({ value, onChange }: ThemePickerProps) {
return (
<>
{THEME_GROUPS.map((group, gi) => (
<div key={group.label}>
{gi > 0 && <DropdownMenuSeparator className="bg-[var(--border-default)]" />}
<DropdownMenuLabel className="text-xs dg-text-muted font-normal py-1">
{group.label}
</DropdownMenuLabel>
{group.options.map((option) => (
<DropdownMenuItem
key={option.id}
className={cn(
'flex items-center gap-2.5 cursor-pointer transition-colors',
'focus:bg-[var(--surface-hover)] focus:text-[var(--text-primary)]',
value === option.id
? 'text-[var(--text-primary)]'
: 'dg-text-secondary',
)}
onClick={() => onChange(option.id)}
>
<span
className="w-3.5 h-3.5 rounded-full shrink-0"
style={{
background: option.primaryColor.startsWith('linear')
? option.primaryColor
: option.primaryColor,
boxShadow: '0 0 0 2px var(--surface-canvas)',
}}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{option.name}</span>
<span className="block text-xs dg-text-muted truncate">
{option.description}
</span>
</div>
{value === option.id && (
<Check className="h-4 w-4 text-[var(--brand)] shrink-0" />
)}
</DropdownMenuItem>
))}
</div>
))}
</>
);
}