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
1137 lines
35 KiB
Markdown
1137 lines
35 KiB
Markdown
## 6. 菜单体系全面优化
|
||
|
||
菜单导航是SQL-Platform最核心的交互骨架,直接影响用户的工作流效率和平台整体体验。当前菜单体系存在分组逻辑不够清晰、图标体系不统一、权限控制粒度较粗、缺少面包屑导航等结构性问题。本章将从菜单结构重组、图标规范化、权限映射优化和面包屑导航四个维度进行系统性重构。
|
||
|
||
---
|
||
|
||
### 6.1 菜单结构重组方案
|
||
|
||
#### 6.1.1 重组设计思路
|
||
|
||
当前菜单的六个分组存在三个核心问题:其一,"配置平台"分组仅包含"系统设置"一项,空间利用率极低;其二,"自动任务"归类于"系统配置"不够合理,它与运维监控的关联度远高于系统配置;其三,"工单管理"的命名偏向内部管理视角,对实际执行数据操作的开发者而言不够直观。
|
||
|
||
重组方案遵循"以用户角色为中心、以任务流为导向"的原则,将原有6个分组精简为6个更具语义清晰度的分组。核心调整包括:将"概览"升级为"工作台"并增加快捷入口;将"工单管理"重命名为"数据操作";将"自动任务"移至"运维中心";将孤立的"系统设置"与新增"个人中心"组成"平台设置"。
|
||
|
||
#### 6.1.2 优化后的完整菜单定义
|
||
|
||
```typescript
|
||
// /frontend/src/constants/menu.ts
|
||
|
||
import type { LucideIcon } from 'lucide-react';
|
||
|
||
/** 权限码类型 —— 替代粗粒度的 roles 数组 */
|
||
export type PermissionCode =
|
||
| 'dashboard:view'
|
||
| 'todo:view'
|
||
| 'query:quick_access'
|
||
| 'ticket:view'
|
||
| 'ticket:create'
|
||
| 'query:view'
|
||
| 'sql_assistant:use'
|
||
| 'datasource:view'
|
||
| 'datasource:manage'
|
||
| 'data_dict:view'
|
||
| 'user:view'
|
||
| 'user:manage'
|
||
| 'role:view'
|
||
| 'role:manage'
|
||
| 'perm_group:view'
|
||
| 'perm_group:manage'
|
||
| 'workflow:view'
|
||
| 'workflow:manage'
|
||
| 'audit_log:view'
|
||
| 'auto_task:view'
|
||
| 'auto_task:manage'
|
||
| 'announcement:view'
|
||
| 'settings:system'
|
||
| 'settings:personal';
|
||
|
||
/** 菜单分组定义 */
|
||
export interface MenuSection {
|
||
id: string;
|
||
label: string;
|
||
icon?: LucideIcon;
|
||
order: number;
|
||
}
|
||
|
||
export const menuSections: MenuSection[] = [
|
||
{ id: 'workspace', label: '工作台', order: 10 },
|
||
{ id: 'dataops', label: '数据操作', order: 20 },
|
||
{ id: 'datasource', label: '数据源', order: 30 },
|
||
{ id: 'permission', label: '权限中心', order: 40 },
|
||
{ id: 'ops', label: '运维中心', order: 50 },
|
||
{ id: 'platform', label: '平台设置', order: 60 },
|
||
];
|
||
|
||
/** Badge 数据源类型 */
|
||
export type BadgeSource = 'pending_count' | 'unread_count' | 'task_fail_count' | 'announcement_count' | none;
|
||
|
||
/** 菜单项定义 */
|
||
export interface MenuItem {
|
||
id: string;
|
||
label: string;
|
||
icon: string; // lucide-react 图标名称
|
||
section: string; // 对应 menuSections.id
|
||
permissions: PermissionCode[]; // 细粒度权限码(满足任一即显示)
|
||
badge?: BadgeSource; // 动态角标数据源
|
||
shortcut?: string; // 快捷键(如 Ctrl+1)
|
||
order: number; // 组内排序
|
||
}
|
||
|
||
export const menuItems: MenuItem[] = [
|
||
// ─── 工作台 ───
|
||
{
|
||
id: 'dashboard',
|
||
label: '仪表盘',
|
||
icon: 'LayoutDashboard',
|
||
section: 'workspace',
|
||
permissions: ['dashboard:view'],
|
||
order: 10,
|
||
},
|
||
{
|
||
id: 'my-todo',
|
||
label: '我的待办',
|
||
icon: 'CheckCircle2',
|
||
section: 'workspace',
|
||
permissions: ['ticket:view', 'workflow:view'],
|
||
badge: 'pending_count',
|
||
order: 20,
|
||
},
|
||
{
|
||
id: 'quick-queries',
|
||
label: '快速查询',
|
||
icon: 'Zap',
|
||
section: 'workspace',
|
||
permissions: ['query:view', 'query:quick_access'],
|
||
order: 30,
|
||
},
|
||
|
||
// ─── 数据操作 ───
|
||
{
|
||
id: 'tickets',
|
||
label: '数据变更',
|
||
icon: 'ClipboardList',
|
||
section: 'dataops',
|
||
permissions: ['ticket:view'],
|
||
badge: 'pending_count',
|
||
order: 10,
|
||
},
|
||
{
|
||
id: 'queries',
|
||
label: '查询数据',
|
||
icon: 'Search',
|
||
section: 'dataops',
|
||
permissions: ['query:view'],
|
||
order: 20,
|
||
},
|
||
{
|
||
id: 'sql-assistant',
|
||
label: 'SQL助手',
|
||
icon: 'Wand2',
|
||
section: 'dataops',
|
||
permissions: ['sql_assistant:use', 'query:view'],
|
||
order: 30,
|
||
},
|
||
|
||
// ─── 数据源 ───
|
||
{
|
||
id: 'instances',
|
||
label: '数据源管理',
|
||
icon: 'Database',
|
||
section: 'datasource',
|
||
permissions: ['datasource:view'],
|
||
order: 10,
|
||
},
|
||
{
|
||
id: 'data-dict',
|
||
label: '数据字典',
|
||
icon: 'BookOpen',
|
||
section: 'datasource',
|
||
permissions: ['data_dict:view', 'datasource:view'],
|
||
order: 20,
|
||
},
|
||
|
||
// ─── 权限中心 ───
|
||
{
|
||
id: 'users',
|
||
label: '用户管理',
|
||
icon: 'Users',
|
||
section: 'permission',
|
||
permissions: ['user:view'],
|
||
order: 10,
|
||
},
|
||
{
|
||
id: 'roles',
|
||
label: '角色权限',
|
||
icon: 'UserCog',
|
||
section: 'permission',
|
||
permissions: ['role:view'],
|
||
order: 20,
|
||
},
|
||
{
|
||
id: 'instance-groups',
|
||
label: '权限组',
|
||
icon: 'Shield',
|
||
section: 'permission',
|
||
permissions: ['perm_group:view'],
|
||
order: 30,
|
||
},
|
||
{
|
||
id: 'workflow',
|
||
label: '审批流程',
|
||
icon: 'GitBranch',
|
||
section: 'permission',
|
||
permissions: ['workflow:view'],
|
||
order: 40,
|
||
},
|
||
|
||
// ─── 运维中心 ───
|
||
{
|
||
id: 'audit-logs',
|
||
label: '审计日志',
|
||
icon: 'FileText',
|
||
section: 'ops',
|
||
permissions: ['audit_log:view'],
|
||
order: 10,
|
||
},
|
||
{
|
||
id: 'auto-task',
|
||
label: '自动任务',
|
||
icon: 'Clock',
|
||
section: 'ops',
|
||
permissions: ['auto_task:view'],
|
||
badge: 'task_fail_count',
|
||
order: 20,
|
||
},
|
||
{
|
||
id: 'announcements',
|
||
label: '系统公告',
|
||
icon: 'Megaphone',
|
||
section: 'ops',
|
||
permissions: ['announcement:view'],
|
||
badge: 'announcement_count',
|
||
order: 30,
|
||
},
|
||
|
||
// ─── 平台设置 ───
|
||
{
|
||
id: 'settings',
|
||
label: '系统设置',
|
||
icon: 'Settings',
|
||
section: 'platform',
|
||
permissions: ['settings:system'],
|
||
order: 10,
|
||
},
|
||
{
|
||
id: 'profile',
|
||
label: '个人中心',
|
||
icon: 'UserCircle',
|
||
section: 'platform',
|
||
permissions: ['settings:personal'],
|
||
order: 20,
|
||
},
|
||
];
|
||
|
||
/** 根据分组 ID 获取菜单项 */
|
||
export const getMenuItemsBySection = (sectionId: string): MenuItem[] => {
|
||
return menuItems
|
||
.filter((item) => item.section === sectionId)
|
||
.sort((a, b) => a.order - b.order);
|
||
};
|
||
|
||
/** 根据权限码过滤可见菜单项 */
|
||
export const filterMenuByPermissions = (
|
||
items: MenuItem[],
|
||
userPermissions: PermissionCode[]
|
||
): MenuItem[] => {
|
||
return items.filter((item) =>
|
||
item.permissions.some((perm) => userPermissions.includes(perm))
|
||
);
|
||
};
|
||
```
|
||
|
||
#### 6.1.3 重组收益
|
||
|
||
| 维度 | 调整前 | 调整后 | 收益 |
|
||
|------|--------|--------|------|
|
||
| 分组数量 | 6个(含1个单项目分组) | 6个(每个分组2-4项) | 分布均匀,视觉平衡 |
|
||
| 语义清晰度 | "工单管理"偏管理视角 | "数据操作"以操作为导向 | 降低认知成本 |
|
||
| 功能关联性 | 自动任务与系统配置弱关联 | 自动任务归入运维中心 | 逻辑一致性提升 |
|
||
| 快捷入口 | 无 | 工作台聚合待办+快速查询 | 减少用户操作路径 |
|
||
| 可扩展性 | 新增功能无处安放 | 各分组预留扩展空间 | 支持未来功能迭代 |
|
||
|
||
---
|
||
|
||
### 6.2 图标体系规范化
|
||
|
||
#### 6.2.1 现状问题分析
|
||
|
||
当前系统存在两套图标体系并行的问题:侧边栏使用 `lucide-react` 矢量图标,而设置页面使用 emoji 图标(🎨⚙️🛡️🔔🔐)。这种不一致带来三个负面影响:视觉风格割裂,emoji 在不同操作系统渲染差异大;专业感下降,emoji 偏向社交/休闲场景;无障碍支持差,emoji 的屏幕阅读器朗读行为不可控。
|
||
|
||
#### 6.2.2 图标映射表
|
||
|
||
以下是重组后全部菜单项的图标映射,统一使用 `lucide-react` 图标库:
|
||
|
||
| 菜单项 ID | 菜单标签 | 图标名称 (Lucide) | 选用理由 |
|
||
|-----------|----------|-------------------|----------|
|
||
| `dashboard` | 仪表盘 | `LayoutDashboard` | 网格布局隐喻,dashboard 标准语义 |
|
||
| `my-todo` | 我的待办 | `CheckCircle2` | 待办完成状态的圆形勾选隐喻 |
|
||
| `quick-queries` | 快速查询 | `Zap` | 闪电符号表达"快速"语义 |
|
||
| `tickets` | 数据变更 | `ClipboardList` | 工单列表的标准图标,保持不变 |
|
||
| `queries` | 查询数据 | `Search` | 搜索查询的标准语义 |
|
||
| `sql-assistant` | SQL助手 | `Wand2` | 魔杖隐喻"智能辅助/魔法般的体验" |
|
||
| `instances` | 数据源管理 | `Database` | 数据库标准图标,保持不变 |
|
||
| `data-dict` | 数据字典 | `BookOpen` | 打开的书籍隐喻"字典/参考" |
|
||
| `users` | 用户管理 | `Users` | 用户群像标准图标,保持不变 |
|
||
| `roles` | 角色权限 | `UserCog` | 用户+齿轮表达"用户配置" |
|
||
| `instance-groups` | 权限组 | `Shield` | 盾牌隐喻"权限保护",替代原 Lock |
|
||
| `workflow` | 审批流程 | `GitBranch` | 分支流程图标,保持不变 |
|
||
| `audit-logs` | 审计日志 | `FileText` | 文件记录标准图标,保持不变 |
|
||
| `auto-task` | 自动任务 | `Clock` | 时钟隐喻"定时/自动化",保持不变 |
|
||
| `announcements` | 系统公告 | `Megaphone` | 喇叭隐喻"广播/公告" |
|
||
| `settings` | 系统设置 | `Settings` | 齿轮标准图标,保持不变 |
|
||
| `profile` | 个人中心 | `UserCircle` | 圆形用户头像隐喻"个人信息" |
|
||
|
||
#### 6.2.3 图标动态导入实现
|
||
|
||
为避免一次性导入全部图标导致的包体积膨胀,采用字符串名称 + 动态导入方案:
|
||
|
||
```typescript
|
||
// /frontend/src/constants/menu.ts
|
||
|
||
import * as Icons from 'lucide-react';
|
||
import type { LucideProps } from 'lucide-react';
|
||
import React from 'react';
|
||
|
||
/** 图标名称到组件的动态映射 */
|
||
export const getMenuIcon = (
|
||
iconName: string
|
||
): React.ComponentType<LucideProps> | null => {
|
||
const Icon = (Icons as Record<string, React.ComponentType<LucideProps>>)[iconName];
|
||
return Icon || null;
|
||
};
|
||
|
||
/** 在 SidebarMenuItem 组件中的使用方式 */
|
||
// const IconComponent = getMenuIcon(item.icon);
|
||
// {IconComponent && <IconComponent className={iconClassNames} size={18} />}
|
||
```
|
||
|
||
#### 6.2.4 图标颜色与尺寸规范
|
||
|
||
```css
|
||
/* /frontend/src/styles/menu-icons.css */
|
||
|
||
/* ─── 基础尺寸 ─── */
|
||
.menu-icon {
|
||
width: 18px;
|
||
height: 18px;
|
||
flex-shrink: 0;
|
||
transition: color 150ms ease, transform 150ms ease;
|
||
}
|
||
|
||
/* ─── 颜色状态 ─── */
|
||
.menu-icon--default {
|
||
color: var(--dg-text-secondary); /* #9ca3af */
|
||
}
|
||
|
||
.menu-item:hover .menu-icon--default {
|
||
color: var(--dg-text-primary); /* #e5e7eb */
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.menu-icon--active {
|
||
color: rgb(96 165 250); /* text-blue-400 */
|
||
}
|
||
|
||
/* ─── 分组标题图标(如使用)─── */
|
||
.menu-section-icon {
|
||
color: var(--dg-text-muted); /* #6b7280 */
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
/* ─── 角标容器 ─── */
|
||
.menu-badge {
|
||
min-width: 18px;
|
||
height: 18px;
|
||
padding: 0 5px;
|
||
border-radius: 9px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
background-color: rgb(239 68 68); /* red-500 */
|
||
color: white;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-left: auto;
|
||
animation: badge-pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes badge-pulse {
|
||
0%, 100% { transform: scale(1); }
|
||
50% { transform: scale(1.08); }
|
||
}
|
||
```
|
||
|
||
#### 6.2.5 设置页 emoji 替换方案
|
||
|
||
原设置页面中使用了以下 emoji 图标,需全部替换为 lucide-react 对应图标:
|
||
|
||
| 设置项 | 原 emoji | 替换为 Lucide 图标 | 图标名称 |
|
||
|--------|----------|-------------------|----------|
|
||
| 外观主题 | 🎨 | `Palette` | Palette |
|
||
| 平台设置 | ⚙️ | `SlidersHorizontal` | SlidersHorizontal |
|
||
| 安全设置 | 🛡️ | `ShieldCheck` | ShieldCheck |
|
||
| 通知设置 | 🔔 | `Bell` | Bell |
|
||
| 密码修改 | 🔐 | `KeyRound` | KeyRound |
|
||
|
||
替换后设置页面的图标导入:
|
||
|
||
```typescript
|
||
// /frontend/src/pages/settings/SettingsPage.tsx
|
||
|
||
import {
|
||
Palette,
|
||
SlidersHorizontal,
|
||
ShieldCheck,
|
||
Bell,
|
||
KeyRound,
|
||
} from 'lucide-react';
|
||
|
||
const settingsGroups = [
|
||
{ id: 'appearance', label: '外观主题', icon: Palette },
|
||
{ id: 'platform', label: '平台设置', icon: SlidersHorizontal },
|
||
{ id: 'security', label: '安全设置', icon: ShieldCheck },
|
||
{ id: 'notification', label: '通知设置', icon: Bell },
|
||
{ id: 'password', label: '密码修改', icon: KeyRound },
|
||
];
|
||
```
|
||
|
||
---
|
||
|
||
### 6.3 权限映射优化
|
||
|
||
#### 6.3.1 从角色到权限的范式迁移
|
||
|
||
原方案使用 `roles: ['admin', 'dba', 'developer', 'auditor']` 这种粗粒度的角色数组控制菜单可见性,存在三个结构性缺陷:角色名称硬编码,新增角色需修改多处代码;同一角色内不同用户的菜单可见性无法进一步细分;无法支持基于RBAC的动态权限变更。
|
||
|
||
新方案引入 `permissions: PermissionCode[]` 数组,每个菜单项声明所需的细粒度权限码。用户登录时后端返回该用户的权限码列表,前端通过权限交集运算判断菜单可见性。此设计遵循"后端控制权限,前端控制展示"的分层原则。
|
||
|
||
#### 6.3.2 权限码命名规范
|
||
|
||
权限码采用 `resource:action` 的标准格式:
|
||
|
||
```typescript
|
||
// /frontend/src/constants/permissions.ts
|
||
|
||
/** 权限码完整定义 */
|
||
export const PERMISSIONS = {
|
||
// 工作台
|
||
DASHBOARD_VIEW: 'dashboard:view' as const,
|
||
TODO_VIEW: 'todo:view' as const,
|
||
QUERY_QUICK_ACCESS:'query:quick_access' as const,
|
||
|
||
// 数据操作
|
||
TICKET_VIEW: 'ticket:view' as const,
|
||
TICKET_CREATE: 'ticket:create' as const,
|
||
TICKET_APPROVE: 'ticket:approve' as const,
|
||
QUERY_VIEW: 'query:view' as const,
|
||
QUERY_CREATE: 'query:create' as const,
|
||
SQL_ASSISTANT_USE: 'sql_assistant:use' as const,
|
||
|
||
// 数据源
|
||
DATASOURCE_VIEW: 'datasource:view' as const,
|
||
DATASOURCE_MANAGE: 'datasource:manage' as const,
|
||
DATA_DICT_VIEW: 'data_dict:view' as const,
|
||
|
||
// 权限中心
|
||
USER_VIEW: 'user:view' as const,
|
||
USER_MANAGE: 'user:manage' as const,
|
||
ROLE_VIEW: 'role:view' as const,
|
||
ROLE_MANAGE: 'role:manage' as const,
|
||
PERM_GROUP_VIEW: 'perm_group:view' as const,
|
||
PERM_GROUP_MANAGE: 'perm_group:manage' as const,
|
||
WORKFLOW_VIEW: 'workflow:view' as const,
|
||
WORKFLOW_MANAGE: 'workflow:manage' as const,
|
||
|
||
// 运维中心
|
||
AUDIT_LOG_VIEW: 'audit_log:view' as const,
|
||
AUTO_TASK_VIEW: 'auto_task:view' as const,
|
||
AUTO_TASK_MANAGE: 'auto_task:manage' as const,
|
||
ANNOUNCEMENT_VIEW: 'announcement:view' as const,
|
||
|
||
// 平台设置
|
||
SETTINGS_SYSTEM: 'settings:system' as const,
|
||
SETTINGS_PERSONAL: 'settings:personal' as const,
|
||
} as const;
|
||
|
||
/** 权限码联合类型 —— 从常量对象推导 */
|
||
export type PermissionCode =
|
||
typeof PERMISSIONS[keyof typeof PERMISSIONS];
|
||
|
||
/** 角色到权限的映射模板 —— 后端可动态调整 */
|
||
export const ROLE_PERMISSION_TEMPLATES: Record<string, PermissionCode[]> = {
|
||
admin: Object.values(PERMISSIONS),
|
||
dba: [
|
||
PERMISSIONS.DASHBOARD_VIEW,
|
||
PERMISSIONS.TODO_VIEW,
|
||
PERMISSIONS.TICKET_VIEW,
|
||
PERMISSIONS.TICKET_APPROVE,
|
||
PERMISSIONS.QUERY_VIEW,
|
||
PERMISSIONS.QUERY_CREATE,
|
||
PERMISSIONS.SQL_ASSISTANT_USE,
|
||
PERMISSIONS.DATASOURCE_VIEW,
|
||
PERMISSIONS.DATASOURCE_MANAGE,
|
||
PERMISSIONS.DATA_DICT_VIEW,
|
||
PERMISSIONS.WORKFLOW_VIEW,
|
||
PERMISSIONS.AUDIT_LOG_VIEW,
|
||
PERMISSIONS.AUTO_TASK_VIEW,
|
||
PERMISSIONS.ANNOUNCEMENT_VIEW,
|
||
PERMISSIONS.SETTINGS_PERSONAL,
|
||
],
|
||
developer: [
|
||
PERMISSIONS.DASHBOARD_VIEW,
|
||
PERMISSIONS.TICKET_VIEW,
|
||
PERMISSIONS.TICKET_CREATE,
|
||
PERMISSIONS.QUERY_VIEW,
|
||
PERMISSIONS.QUERY_CREATE,
|
||
PERMISSIONS.SQL_ASSISTANT_USE,
|
||
PERMISSIONS.DATASOURCE_VIEW,
|
||
PERMISSIONS.DATA_DICT_VIEW,
|
||
PERMISSIONS.WORKFLOW_VIEW,
|
||
PERMISSIONS.ANNOUNCEMENT_VIEW,
|
||
PERMISSIONS.SETTINGS_PERSONAL,
|
||
],
|
||
auditor: [
|
||
PERMISSIONS.DASHBOARD_VIEW,
|
||
PERMISSIONS.TICKET_VIEW,
|
||
PERMISSIONS.QUERY_VIEW,
|
||
PERMISSIONS.AUDIT_LOG_VIEW,
|
||
PERMISSIONS.ANNOUNCEMENT_VIEW,
|
||
PERMISSIONS.SETTINGS_PERSONAL,
|
||
],
|
||
};
|
||
```
|
||
|
||
#### 6.3.3 菜单可见性判断 Hook
|
||
|
||
```typescript
|
||
// /frontend/src/hooks/useMenuPermissions.ts
|
||
|
||
import { useMemo } from 'react';
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import {
|
||
menuSections,
|
||
menuItems,
|
||
filterMenuByPermissions,
|
||
getMenuItemsBySection,
|
||
} from '@/constants/menu';
|
||
import type { MenuItem, MenuSection } from '@/constants/menu';
|
||
|
||
export interface VisibleMenuGroup {
|
||
section: MenuSection;
|
||
items: MenuItem[];
|
||
}
|
||
|
||
/** 获取当前用户可见的菜单结构 */
|
||
export const useVisibleMenu = (): VisibleMenuGroup[] => {
|
||
const { userPermissions } = useAuth(); // 从 AuthContext 获取用户权限码数组
|
||
|
||
return useMemo(() => {
|
||
// 按 order 排序分组
|
||
const sortedSections = [...menuSections].sort((a, b) => a.order - b.order);
|
||
|
||
return sortedSections
|
||
.map((section) => {
|
||
const sectionItems = getMenuItemsBySection(section.id);
|
||
const visibleItems = filterMenuByPermissions(sectionItems, userPermissions);
|
||
return { section, items: visibleItems };
|
||
})
|
||
.filter((group) => group.items.length > 0); // 过滤空分组
|
||
}, [userPermissions]);
|
||
};
|
||
|
||
/** 判断用户是否拥有某个菜单项的访问权限 */
|
||
export const useHasMenuPermission = (menuItemId: string): boolean => {
|
||
const { userPermissions } = useAuth();
|
||
const item = menuItems.find((m) => m.id === menuItemId);
|
||
if (!item) return false;
|
||
return item.permissions.some((perm) => userPermissions.includes(perm));
|
||
};
|
||
|
||
/** 获取动态角标数据 */
|
||
export const useMenuBadges = (): Record<string, number> => {
|
||
const { badgeData } = useAuth(); // 从 AuthContext 获取角标数据
|
||
|
||
return useMemo(() => {
|
||
const result: Record<string, number> = {};
|
||
menuItems.forEach((item) => {
|
||
if (item.badge && badgeData[item.badge] !== undefined) {
|
||
result[item.id] = badgeData[item.badge];
|
||
}
|
||
});
|
||
return result;
|
||
}, [badgeData]);
|
||
};
|
||
```
|
||
|
||
#### 6.3.4 动态角标设计
|
||
|
||
菜单角标实时展示待处理事项数量,提醒用户关注。角标数据通过 WebSocket 或轮询接口从后端获取:
|
||
|
||
```typescript
|
||
// /frontend/src/types/badge.ts
|
||
|
||
/** 角标数据源类型 */
|
||
export interface BadgeData {
|
||
pending_count: number; // 待审批工单数
|
||
unread_count: number; // 未读消息数
|
||
task_fail_count: number; // 失败自动任务数
|
||
announcement_count: number; // 未读公告数
|
||
}
|
||
|
||
// /frontend/src/hooks/useBadgePolling.ts
|
||
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import type { BadgeData } from '@/types/badge';
|
||
|
||
const POLLING_INTERVAL = 60_000; // 60秒轮询
|
||
|
||
export const useBadgePolling = () => {
|
||
return useQuery<BadgeData>({
|
||
queryKey: ['menu-badges'],
|
||
queryFn: async () => {
|
||
const res = await fetch('/api/v1/badges');
|
||
if (!res.ok) throw new Error('Failed to fetch badges');
|
||
return res.json();
|
||
},
|
||
refetchInterval: POLLING_INTERVAL,
|
||
staleTime: 30_000,
|
||
});
|
||
};
|
||
```
|
||
|
||
角标在侧边栏的渲染逻辑:
|
||
|
||
```tsx
|
||
// 角标渲染(嵌入 SidebarMenuItem 组件中)
|
||
{badgeCount !== undefined && badgeCount > 0 && (
|
||
<span className={`
|
||
ml-auto inline-flex items-center justify-center
|
||
min-w-[18px] h-[18px] px-[5px] rounded-full
|
||
text-[11px] font-semibold
|
||
${badgeCount > 99 ? 'bg-red-600' : 'bg-red-500'}
|
||
text-white
|
||
animate-[badge-pulse_2s_ease-in-out_infinite]
|
||
`}>
|
||
{badgeCount > 99 ? '99+' : badgeCount}
|
||
</span>
|
||
)}
|
||
```
|
||
|
||
#### 6.3.5 权限迁移路径
|
||
|
||
| 阶段 | 操作 | 时间窗口 |
|
||
|------|------|----------|
|
||
| 第一阶段(并行期) | 同时保留 `roles` 和 `permissions` 字段,以 roles 为 fallback | 1-2 个迭代 |
|
||
| 第二阶段(切换期) | 后端全面返回 permissions,前端以 permissions 为主 | 1 个迭代 |
|
||
| 第三阶段(清理期) | 移除 roles 字段,权限判断完全基于 permissions | 1 个迭代 |
|
||
|
||
---
|
||
|
||
### 6.4 面包屑导航补充
|
||
|
||
#### 6.4.1 设计目标与定位
|
||
|
||
面包屑导航填补当前平台在"位置感知"维度的空白。当用户通过深层链接(如审批流程详情页)进入页面时,面包屑提供清晰的路径回溯能力。设计遵循三个原则:路径语义化(显示可读标签而非路由路径)、可交互(支持点击跳转)、与侧边栏激活状态联动。
|
||
|
||
#### 6.4.2 路径映射规则
|
||
|
||
面包屑的数据源不是路由配置本身,而是一个独立的路径映射表。这种解耦设计使面包屑标签与 URL 路径分离,便于国际化和文案调整。
|
||
|
||
```typescript
|
||
// /frontend/src/constants/breadcrumb.ts
|
||
|
||
/** 面包屑路径映射项 */
|
||
export interface BreadcrumbMapping {
|
||
path: string; // 路由路径(支持参数,如 /tickets/:id)
|
||
label: string; // 面包屑显示文本
|
||
parent?: string; // 父级路径(用于构建完整面包屑链)
|
||
icon?: string; // 可选的 lucide 图标名称
|
||
}
|
||
|
||
/** 面包屑路径映射表 —— 按深度从浅到深排列 */
|
||
export const breadcrumbMappings: BreadcrumbMapping[] = [
|
||
// 工作台
|
||
{ path: '/dashboard', label: '仪表盘', icon: 'LayoutDashboard' },
|
||
{ path: '/my-todo', label: '我的待办', icon: 'CheckCircle2' },
|
||
{ path: '/quick-queries',label: '快速查询', icon: 'Zap' },
|
||
|
||
// 数据操作
|
||
{ path: '/tickets', label: '数据变更', icon: 'ClipboardList' },
|
||
{ path: '/tickets/:id', label: '工单详情', parent: '/tickets' },
|
||
{ path: '/queries', label: '查询数据', icon: 'Search' },
|
||
{ path: '/queries/:id', label: '查询详情', parent: '/queries' },
|
||
{ path: '/sql-assistant',label: 'SQL助手', icon: 'Wand2' },
|
||
|
||
// 数据源
|
||
{ path: '/instances', label: '数据源管理', icon: 'Database' },
|
||
{ path: '/instances/:id',label: '数据源详情', parent: '/instances' },
|
||
{ path: '/data-dict', label: '数据字典', icon: 'BookOpen' },
|
||
|
||
// 权限中心
|
||
{ path: '/users', label: '用户管理', icon: 'Users' },
|
||
{ path: '/users/:id', label: '用户详情', parent: '/users' },
|
||
{ path: '/roles', label: '角色权限', icon: 'UserCog' },
|
||
{ path: '/instance-groups', label: '权限组', icon: 'Shield' },
|
||
{ path: '/workflow', label: '审批流程', icon: 'GitBranch' },
|
||
|
||
// 运维中心
|
||
{ path: '/audit-logs', label: '审计日志', icon: 'FileText' },
|
||
{ path: '/auto-task', label: '自动任务', icon: 'Clock' },
|
||
{ path: '/announcements',label: '系统公告', icon: 'Megaphone' },
|
||
|
||
// 平台设置
|
||
{ path: '/settings', label: '系统设置', icon: 'Settings' },
|
||
{ path: '/profile', label: '个人中心', icon: 'UserCircle' },
|
||
];
|
||
```
|
||
|
||
#### 6.4.3 面包屑构建工具函数
|
||
|
||
```typescript
|
||
// /frontend/src/utils/breadcrumb.ts
|
||
|
||
import { breadcrumbMappings } from '@/constants/breadcrumb';
|
||
import type { BreadcrumbMapping } from '@/constants/breadcrumb';
|
||
|
||
export interface BreadcrumbItem {
|
||
label: string;
|
||
path: string;
|
||
icon?: string;
|
||
isActive: boolean; // 是否为当前页(不可点击)
|
||
}
|
||
|
||
/** 将路径模板匹配到实际路径 */
|
||
const matchPath = (template: string, actualPath: string): boolean => {
|
||
const templateParts = template.split('/');
|
||
const actualParts = actualPath.split('/');
|
||
if (templateParts.length !== actualParts.length) return false;
|
||
|
||
return templateParts.every((part, i) => {
|
||
if (part.startsWith(':')) return true; // 参数段通配
|
||
return part === actualParts[i];
|
||
});
|
||
};
|
||
|
||
/** 根据当前路径构建面包屑链 */
|
||
export const buildBreadcrumbs = (currentPath: string): BreadcrumbItem[] => {
|
||
const chain: BreadcrumbItem[] = [];
|
||
const visited = new Set<string>();
|
||
|
||
// 查找当前路径的映射
|
||
let currentMapping = breadcrumbMappings.find((m) =>
|
||
matchPath(m.path, currentPath)
|
||
);
|
||
|
||
// 逐层向上追溯父级
|
||
while (currentMapping && !visited.has(currentMapping.path)) {
|
||
visited.add(currentMapping.path);
|
||
chain.unshift({
|
||
label: currentMapping.label,
|
||
path: currentPath, // 当前页使用实际路径
|
||
icon: currentMapping.icon,
|
||
isActive: chain.length === 0,
|
||
});
|
||
|
||
if (currentMapping.parent) {
|
||
currentMapping = breadcrumbMappings.find(
|
||
(m) => m.path === currentMapping!.parent
|
||
);
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return chain;
|
||
};
|
||
|
||
/** 获取面包屑项的目标路径(处理参数化路径) */
|
||
export const resolveBreadcrumbPath = (
|
||
mapping: BreadcrumbMapping,
|
||
currentPath: string
|
||
): string => {
|
||
if (!mapping.path.includes(':')) return mapping.path;
|
||
|
||
// 从当前路径提取参数值
|
||
const templateParts = mapping.path.split('/');
|
||
const currentParts = currentPath.split('/');
|
||
|
||
const resolvedParts = templateParts.map((part, i) => {
|
||
if (part.startsWith(':')) return currentParts[i] || part;
|
||
return part;
|
||
});
|
||
|
||
return resolvedParts.join('/');
|
||
};
|
||
```
|
||
|
||
#### 6.4.4 面包屑 React 组件
|
||
|
||
```tsx
|
||
// /frontend/src/components/layout/Breadcrumb.tsx
|
||
|
||
import React from 'react';
|
||
import { Link, useLocation } from 'react-router-dom';
|
||
import { ChevronRight, Home } from 'lucide-react';
|
||
import { buildBreadcrumbs, resolveBreadcrumbPath } from '@/utils/breadcrumb';
|
||
import { breadcrumbMappings } from '@/constants/breadcrumb';
|
||
import { getMenuIcon } from '@/constants/menu';
|
||
|
||
export const Breadcrumb: React.FC = () => {
|
||
const location = useLocation();
|
||
const items = buildBreadcrumbs(location.pathname);
|
||
|
||
if (items.length <= 1) {
|
||
// 顶层页面不显示面包屑
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<nav
|
||
aria-label="面包屑导航"
|
||
className="flex items-center gap-1 px-6 py-3 text-sm"
|
||
>
|
||
{/* 首页快捷入口 */}
|
||
<Link
|
||
to="/dashboard"
|
||
className="
|
||
flex items-center gap-1
|
||
text-[var(--dg-text-muted)]
|
||
hover:text-[var(--dg-text-primary)]
|
||
transition-colors duration-150
|
||
"
|
||
title="返回仪表盘"
|
||
>
|
||
<Home size={14} />
|
||
</Link>
|
||
|
||
{items.length > 0 && (
|
||
<ChevronRight
|
||
size={14}
|
||
className="text-[var(--dg-text-muted)] mx-0.5"
|
||
/>
|
||
)}
|
||
|
||
{/* 面包屑项 */}
|
||
{items.map((item, index) => {
|
||
const isLast = index === items.length - 1;
|
||
const IconComponent = item.icon ? getMenuIcon(item.icon) : null;
|
||
|
||
return (
|
||
<React.Fragment key={`${item.path}-${index}`}>
|
||
<div className="flex items-center gap-1.5">
|
||
{IconComponent && (
|
||
<IconComponent
|
||
size={14}
|
||
className={`
|
||
${isLast ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}
|
||
`}
|
||
/>
|
||
)}
|
||
{isLast ? (
|
||
// 当前页:不可点击,高亮显示
|
||
<span
|
||
className="font-medium text-[var(--dg-text-primary)]"
|
||
aria-current="page"
|
||
>
|
||
{item.label}
|
||
</span>
|
||
) : (
|
||
// 父级:可点击跳转
|
||
<Link
|
||
to={item.path}
|
||
className="
|
||
text-[var(--dg-text-secondary)]
|
||
hover:text-[var(--dg-text-primary)]
|
||
hover:underline underline-offset-2
|
||
transition-colors duration-150
|
||
"
|
||
>
|
||
{item.label}
|
||
</Link>
|
||
)}
|
||
</div>
|
||
|
||
{!isLast && (
|
||
<ChevronRight
|
||
size={14}
|
||
className="text-[var(--dg-text-muted)] mx-0.5"
|
||
/>
|
||
)}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</nav>
|
||
);
|
||
};
|
||
```
|
||
|
||
#### 6.4.5 面包屑与布局的集成
|
||
|
||
```tsx
|
||
// /frontend/src/components/layout/AppLayout.tsx —— 集成面包屑后的布局
|
||
|
||
import React from 'react';
|
||
import { Sidebar } from './Sidebar';
|
||
import { Breadcrumb } from './Breadcrumb';
|
||
import { TopBar } from './TopBar';
|
||
|
||
export const AppLayout: React.FC<{ children: React.ReactNode }> = ({
|
||
children,
|
||
}) => {
|
||
return (
|
||
<div className="flex h-screen bg-[var(--dg-bg-primary)]">
|
||
{/* 侧边栏 */}
|
||
<aside className="w-[220px] flex-shrink-0 border-r border-[var(--dg-border)]">
|
||
<Sidebar />
|
||
</aside>
|
||
|
||
{/* 主内容区 */}
|
||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||
{/* 顶部栏 */}
|
||
<TopBar />
|
||
|
||
{/* 面包屑导航 */}
|
||
<Breadcrumb />
|
||
|
||
{/* 页面内容 */}
|
||
<div
|
||
className="
|
||
flex-1 overflow-auto
|
||
px-6 py-4
|
||
scroll-smooth
|
||
"
|
||
>
|
||
{children}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
#### 6.4.6 面包屑设计规范
|
||
|
||
| 设计要素 | 规范值 | 说明 |
|
||
|----------|--------|------|
|
||
| 字体大小 | `text-sm` (14px) | 小于页面标题,大于辅助文本 |
|
||
| 字体颜色(默认) | `var(--dg-text-secondary)` | 非当前页 |
|
||
| 字体颜色(当前页) | `var(--dg-text-primary)` | 加粗高亮 |
|
||
| 图标尺寸 | 14px | 小于侧边栏图标,弱化层级 |
|
||
| 分隔符 | `ChevronRight` 14px | 统一右箭头 |
|
||
| 垂直间距 | `py-3` (12px) | 紧凑布局 |
|
||
| 水平间距 | `px-6` (24px) | 与页面内容对齐 |
|
||
| 最大显示层级 | 4 级 | 超过则折叠中间层级 |
|
||
| 悬停交互 | 下划线 + 颜色过渡 | `transition-colors duration-150` |
|
||
|
||
面包屑的显示逻辑遵循"至少两级才展示"原则——当用户位于仪表盘等顶层页面时面包屑自动隐藏,避免冗余信息干扰。对于超过四级的深层路径,中间层级折叠为"..."下拉菜单,保持导航栏的简洁可读性。
|
||
|
||
---
|
||
|
||
### 6.5 菜单 Hover/Active 动效设计
|
||
|
||
#### 6.5.1 动画参数规范
|
||
|
||
```css
|
||
/* /frontend/src/styles/menu-animations.css */
|
||
|
||
/* ─── 菜单项基础 ─── */
|
||
.menu-item {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 9px 14px;
|
||
margin: 2px 8px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 450;
|
||
color: var(--dg-text-secondary);
|
||
cursor: pointer;
|
||
transition:
|
||
background-color 180ms cubic-bezier(0.4, 0, 0.2, 1),
|
||
color 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||
transform 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ─── Hover 状态 ─── */
|
||
.menu-item:hover {
|
||
background-color: rgba(255, 255, 255, 0.04);
|
||
color: var(--dg-text-primary);
|
||
transform: translateX(2px);
|
||
}
|
||
|
||
/* ─── Active 状态 ─── */
|
||
.menu-item--active {
|
||
background-color: rgb(59 130 246 / 0.10); /* blue-500/10 */
|
||
color: rgb(96 165 250); /* blue-400 */
|
||
font-weight: 550;
|
||
}
|
||
|
||
/* 左侧指示条 */
|
||
.menu-item--active::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 3px;
|
||
height: 18px;
|
||
background-color: rgb(96 165 250); /* blue-400 */
|
||
border-radius: 0 2px 2px 0;
|
||
animation: indicator-slide-in 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
@keyframes indicator-slide-in {
|
||
from {
|
||
height: 0;
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
height: 18px;
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* ─── 点击反馈 ─── */
|
||
.menu-item:active {
|
||
transform: scale(0.98);
|
||
transition-duration: 80ms;
|
||
}
|
||
|
||
/* ─── 菜单分组标题 ─── */
|
||
.menu-section-title {
|
||
padding: 16px 14px 6px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
color: var(--dg-text-muted);
|
||
user-select: none;
|
||
}
|
||
```
|
||
|
||
#### 6.5.2 动画设计决策说明
|
||
|
||
| 动画 | 时长 | 缓动函数 | 设计意图 |
|
||
|------|------|----------|----------|
|
||
| Hover 背景色 | 180ms | `cubic-bezier(0.4, 0, 0.2, 1)` | Material Design 标准缓动,自然流畅 |
|
||
| Hover 位移 | 120ms | 同上 | 轻微右移引导视觉向右,暗示可点击 |
|
||
| 文字颜色 | 150ms | 同上 | 略快于背景色,形成层次 |
|
||
| 指示条展开 | 200ms | 同上 | 从0到18px高度,带有生长感 |
|
||
| 点击缩放 | 80ms | 线性 | 快速反馈,确认操作被接收 |
|
||
|
||
所有动画均采用 GPU 加速属性(`transform`、`opacity`、`background-color`),确保在 60fps 下流畅运行,不会造成布局抖动。
|
||
|
||
---
|
||
|
||
### 6.6 收藏/快捷访问功能
|
||
|
||
#### 6.6.1 功能设计
|
||
|
||
"快速查询"菜单项(位于工作台分组)承载收藏/快捷访问功能。用户可在任何页面点击顶部栏的星形图标将当前页面加入收藏,收藏项会出现在快速查询的下拉面板中。
|
||
|
||
#### 6.6.2 数据模型与存储
|
||
|
||
```typescript
|
||
// /frontend/src/types/favorite.ts
|
||
|
||
export interface FavoriteItem {
|
||
id: string; // 唯一标识
|
||
label: string; // 显示名称(用户可编辑)
|
||
path: string; // 路由路径
|
||
icon: string; // lucide 图标名称
|
||
createdAt: string; // ISO 时间戳
|
||
order: number; // 自定义排序
|
||
}
|
||
|
||
/** 收藏数据持久化 —— localStorage + 云端同步 */
|
||
const FAVORITES_STORAGE_KEY = 'sql_platform_favorites';
|
||
|
||
export const favoriteStorage = {
|
||
getAll(): FavoriteItem[] {
|
||
try {
|
||
const raw = localStorage.getItem(FAVORITES_STORAGE_KEY);
|
||
return raw ? JSON.parse(raw) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
},
|
||
|
||
save(items: FavoriteItem[]): void {
|
||
localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(items));
|
||
},
|
||
|
||
add(item: Omit<FavoriteItem, 'order'>): FavoriteItem[] {
|
||
const items = this.getAll();
|
||
if (items.some((f) => f.path === item.path)) return items; // 去重
|
||
const newItem: FavoriteItem = {
|
||
...item,
|
||
order: items.length,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
const updated = [...items, newItem];
|
||
this.save(updated);
|
||
return updated;
|
||
},
|
||
|
||
remove(id: string): FavoriteItem[] {
|
||
const items = this.getAll().filter((f) => f.id !== id);
|
||
this.save(items);
|
||
return items;
|
||
},
|
||
|
||
reorder(id: string, newOrder: number): FavoriteItem[] {
|
||
const items = this.getAll();
|
||
const item = items.find((f) => f.id === id);
|
||
if (!item) return items;
|
||
item.order = newOrder;
|
||
this.save(items.sort((a, b) => a.order - b.order));
|
||
return items;
|
||
},
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
### 6.7 实施优先级与验收标准
|
||
|
||
#### 6.7.1 实施优先级
|
||
|
||
| 优先级 | 模块 | 预估工时 | 依赖项 |
|
||
|--------|------|----------|--------|
|
||
| P0 | 6.1 菜单结构重组 | 4h | 无 |
|
||
| P0 | 6.2 图标体系规范化 | 3h | 6.1 |
|
||
| P1 | 6.3 权限映射优化 | 6h | 后端接口配合 |
|
||
| P1 | 6.4 面包屑导航 | 4h | 6.1 |
|
||
| P2 | 6.5 菜单动效 | 2h | 6.1, 6.2 |
|
||
| P2 | 6.6 收藏功能 | 4h | 无 |
|
||
|
||
#### 6.7.2 验收标准
|
||
|
||
- [ ] 侧边栏展示重组后的6个分组,无空分组显示
|
||
- [ ] 全部菜单项使用 lucide-react 图标,系统无 emoji 图标残留
|
||
- [ ] 菜单可见性基于 permissions 数组判断,roles 字段已移除
|
||
- [ ] 面包屑导航在二级及以上页面正确显示路径链
|
||
- [ ] 菜单项 Hover/Active 动效流畅,无掉帧
|
||
- [ ] 动态角标正确显示待处理数量,超过99显示"99+"
|
||
- [ ] 设置页面 emoji 已全部替换为 lucide 图标
|
||
|
||
---
|
||
|
||
### 6.8 本章小结
|
||
|
||
本章从菜单结构重组、图标规范化、权限映射优化和面包屑导航四个维度对 SQL-Platform 的导航体系进行了系统性重构。核心改进包括:将原有6个分组重组为语义更清晰的工作台/数据操作/数据源/权限中心/运维中心/平台设置分组;建立了统一的 lucide-react 图标体系,彻底移除了 emoji 图标;引入 `PermissionCode` 细粒度权限码替代粗粒度的角色数组;补充了面包屑导航完善位置感知能力。这些改进使导航体系从"可用"迈向"好用",为平台的长期功能扩展奠定了坚实的交互基础。
|