feat: 霓虹都市→白日梦境 + 渐变网格→云石纹理 — 两个白色浅色主题登录页重写

This commit is contained in:
logan committed 2026-05-27 21:53:27 +08:00
1 parent b5739ba955
commit 5a61babd33
241 files changed
+58974 -7370

No files matched your search

-23
View File
@@ -1,23 +0,0 @@
# SQL Platform 全面评估与优化方案 - 执行计划
## 阶段 1: 代码库克隆与结构分析
- 克隆代码库 `https://gitee.com/attacker/sql-platform.git`
- 分析项目结构(前后端技术栈、目录组织)
- 阅读 docs 目录下所有文档
## 阶段 2: 前端代码深入分析
- 分析5个登录页面的实现和现有设计
- 分析登录后页面主题系统
- 分析菜单结构和路由配置
- 重点分析核心功能:审批变更、查询数据、系统设置的实现
## 阶段 3: 问题评估与方案设计
- 加载 deep-research-swarm 技能,进行UI/UX最佳实践调研
- 加载 report-writing 技能,撰写评估报告
- 设计5个专业登录页风格方案
- 设计5个登录后主题风格方案
- 设计核心功能页面优化方案
## 阶段 4: 输出优化方案文档
- 输出完整的评估报告(.docx格式)
- 包含:现状分析、问题清单、5套登录页设计方案、5套主题设计方案、核心功能优化方案、实施路线图
File diff suppressed because it is too large. Load diff
@@ -1,78 +0,0 @@
# SQL-Platform 全面评估与UI/UX优化方案
## 1. 项目概况与技术架构评估
- 1.1 项目定位与核心功能
- 1.2 技术栈分析(React 19 + Go/Gin)
- 1.3 前端架构现状评估
- 1.4 现有UI设计体系梳理
## 2. 现状问题诊断
- 2.1 登录页面五大问题分析
- 2.1.1 视觉层次薄弱
- 2.1.2 交互体验不足
- 2.1.3 品牌感缺失
- 2.1.4 组件化程度低
- 2.1.5 动效与反馈匮乏
- 2.2 主题系统局限性
- 2.3 核心功能页面问题
- 2.3.1 审批变更工单系统
- 2.3.2 查询数据页面
- 2.3.3 系统设置页面
- 2.4 菜单体系问题
- 2.5 设计体系不一致性
## 3. 五套专业登录页设计方案
- 3.1 方案一「极光科技」— 动态粒子科技风
- 3.2 方案二「企业金融」— 银行级安全可信风
- 3.3 方案三「云端数据」— 云计算SaaS风
- 3.4 方案四「极简矩阵」— 开发者极客风
- 3.5 方案五「自然流动」— 有机渐变自然风
- 3.6 各方案组件设计要点
- 3.7 各方案动效设计规范
## 4. 五套登录后主题设计方案
- 4.1 主题一「深蓝专业」— 默认深色增强版
- 4.2 主题二「浅色商务」— 企业办公风
- 4.3 主题三「午夜紫韵」— 高端紫色调
- 4.4 主题四「松绿自然」— 护眼绿调
- 4.5 主题五「橙光活力」— 活力暖色调
- 4.6 各主题CSS变量规范
- 4.7 主题切换实现方案
## 5. 核心功能页面优化方案
- 5.1 审批变更工单系统优化
- 5.1.1 工单列表页优化
- 5.1.2 工单创建页优化
- 5.1.3 工单详情页优化
- 5.1.4 审批流程可视化优化
- 5.2 查询数据页面优化
- 5.2.1 SQL编辑器优化
- 5.2.2 结果展示优化
- 5.2.3 侧边栏导航优化
- 5.2.4 历史记录优化
- 5.3 系统设置页面优化
- 5.3.1 设置分类重组
- 5.3.2 表单交互优化
- 5.3.3 安全设置增强
- 5.3.4 外观设置增强
## 6. 菜单体系全面优化
- 6.1 菜单结构重组方案
- 6.2 图标体系规范化
- 6.3 权限映射优化
- 6.4 面包屑导航补充
## 7. 设计体系统一方案
- 7.1 色彩体系规范
- 7.2 字体排版规范
- 7.3 间距体系规范
- 7.4 组件交互规范
- 7.5 动效体系规范
## 8. 实施路线图
- 8.1 优先级划分
- 8.2 阶段一:登录页改造(2周)
- 8.3 阶段二:主题系统扩展(1周)
- 8.4 阶段三:核心功能优化(3周)
- 8.5 阶段四:菜单与体系统一(1周)
- 8.6 预期效果
@@ -1,237 +0,0 @@
# 1. 项目概况与技术架构评估
## 1.1 项目定位与核心功能
### 1.1.1 平台定位
SQL-Platform 是一款面向企业内部数据操作的**自助查询与数据变更审批流平台**,其核心定位是为开发、运维、数据分析等角色提供安全可控的数据库操作入口。平台覆盖 MySQL、PostgreSQL、Redis、MongoDB 四种主流数据库类型,通过工单驱动的审批机制,在操作便捷性与数据安全性之间建立平衡。
该平台在生态位上直接对标 **Yearning**(Go + Vue 技术栈,侧重 MySQL 审计)与 **Archery**(Python + Vue 技术栈,支持多数据库),同时吸收了 **Bytebase** 在工单审批流程设计上的先进理念。相较于竞品,本项目采用 React 19 + Go/Gin 的现代化全栈组合,在前后端解耦程度、UI 可定制性以及开发者体验(DX)方面具备差异化竞争力。
### 1.1.2 核心功能模块
平台功能围绕"**数据操作全生命周期管理**"展开,可划分为五大核心域:
| 功能域 | 核心能力 | 对应菜单 |
|--------|----------|----------|
| 数据操作 | SQL 自助查询(支持 CodeMirror 6 编辑器)、数据变更提交 | 查询数据、数据变更 |
| 审批工作流 | 工单创建 → 审批节点流转 → 执行/驳回 → 归档 | 工单管理 |
| 数据源治理 | 多类型数据库实例注册、连接池管理、Schema 浏览 | 数据源管理 |
| 权限管控 | RBAC 角色权限模型、权限组划分、用户生命周期管理 | 权限组、角色权限、用户管理 |
| 审计与合规 | 操作日志全量记录、审批流程自定义、自动任务调度 | 审计日志、审批流程、自动任务 |
### 1.1.3 对标产品分析
| 维度 | SQL-Platform | Yearning | Archery | Bytebase |
|------|-------------|----------|---------|----------|
| 后端技术 | Go/Gin | Go/Beego | Python/Django | Go |
| 前端技术 | React 19 + TS | Vue 2 | Vue 2 | Vue 3 |
| 支持数据库 | MySQL/PgSQL/Redis/MongoDB | MySQL | 多数据库 | 全品类 |
| UI 组件体系 | shadcn/ui + Tailwind | Element UI | Element UI | 自研组件 |
| 编辑器 | CodeMirror 6 | ACE Editor | ACE Editor | 自研 Monaco |
| 代码开源度 | 全开源 | 开源核心 | 全开源 | 开源核心 |
相较 Yearning,本项目在多数据库支持广度与前端技术栈现代化程度上具有明显优势;相较 Archery,Go 后端的性能表现与部署便捷性更为突出。当前主要差距集中在**工单审批流程的可视化设计**与**数据库变更的 Schema 版本管理(VCS 集成)**方面,这两项可作为后续版本的重点演进方向。
---
## 1.2 技术栈分析
### 1.2.1 前后端技术选型评估
**后端架构**
后端采用 **Go 1.26 + Gin v1.10.1** 的组合,Gin 作为目前 Go 生态中 Stars 数最高(>80k)的 Web 框架,其高性能路由与极简的 Middleware 设计非常适合 API 密集型企业应用。ORM 层选用 **GORM v1.26.0**,借助其 AutoMigrate、关联预加载与插件扩展能力,可显著降低数据库操作层的样板代码量。元数据库采用 **SQLite**(通过 `gorm.io/driver/sqlite` 驱动),这一设计决策使得平台在部署时**零依赖外部数据库**,极大降低了私有化部署的门槛——这对于中小团队或对运维复杂度敏感的企业极具吸引力。
认证层采用 **JWT HS256 + bcrypt** 方案,Token 默认 24h 过期,在安全性与用户体验之间取得合理平衡。RBAC 权限中间件的独立设计体现了安全架构的前置思维,建议后续版本考虑引入 Refresh Token 机制以降低长期会话的泄露风险。
**前端架构**
前端基于 **React 19 + TypeScript**,构建工具采用 **Vite 7**,这一组合代表了 2024-2025 年 React 生态的技术前沿。React 19 的 Server Components 支持(尽管当前项目以 CSR 为主)、Compiler 自动优化以及改进的 Suspense 边界处理,为后续性能演进预留了充足空间。Vite 7 相较于 Webpack 在冷启动速度(提升约 10-20 倍)与 HMR 响应速度上具备代际优势,显著改善开发体验。
UI 层采用 **shadcn/ui + Tailwind CSS 4 + Radix UI** 的三层架构:Radix UI 提供无样式、无障碍(A11y)完备的底层原语;Tailwind CSS 4 提供原子化样式能力;shadcn/ui 则在此基础上封装了一套设计一致、可高度定制的高阶组件。这种"无头组件 + 原子样式 + 组件封装"的分层模式,是当前前端 UI 架构的最佳实践之一。
代码编辑器选用 **CodeMirror 6**(`@codemirror/lang-sql` 语言包),相较于 Monaco Editor,CodeMirror 6 在包体积(Tree-shakeable 后约 100KB vs Monaco 的 1MB+)、移动端适配以及主题定制灵活性上表现更优,非常适合本项目这种以 SQL 编辑为核心但非纯 IDE 场景的应用。
图表层采用 **Recharts**,作为 React 生态中最成熟的 D3 封装库,其与 React 组件模型的深度整合使得在仪表盘页面中构建数据可视化变得直观高效。
### 1.2.2 技术栈优势分析
1. **部署零依赖**:SQLite 作为元数据库 + Go 单二进制部署,使得整个平台可以编译为单一可执行文件分发,运维成本极低。
2. **类型安全贯穿全栈**:TypeScript(前端)+ Go(后端)的双重静态类型保障,配合 GORM 的模型定义与前端接口类型的共享(可通过 OpenAPI/Swagger 代码生成进一步强化),可显著降低因类型不匹配导致的运行时错误。
3. **组件体系可扩展性强**:shadcn/ui 的组件代码直接存在于项目源码中(非 npm 黑盒),团队可根据业务需求任意修改组件内部实现而不受上游版本锁定。
4. **性能基线优秀**:Go 的 goroutine 并发模型 + Gin 的高性能路由 + Vite 的快速构建 + React.lazy 代码分割,从后端到前端形成了一条完整的性能优化链路。
### 1.2.3 潜在技术债务
| 风险点 | 影响等级 | 说明与建议 |
|--------|----------|-----------|
| GORM v1.26.0 版本锁定 | 中 | GORM v2 与 v1 存在 API 不兼容变更,长期停留在 v1 会阻碍安全补丁获取。建议规划迁移至 GORM v2。 |
| SQLite 并发写入瓶颈 | 中 | SQLite 在 WAL 模式下可支持多读单写,但在高并发工单场景下可能成为瓶颈。建议评估是否需要支持 PostgreSQL/MySQL 作为可选元数据库。 |
| React 19 生态适配 | 低-中 | React 19 尚属较新版本,部分第三方库可能存在兼容性问题。建议建立依赖版本锁定策略,并持续跟踪关键依赖的 React 19 适配进度。 |
| JWT 无 Refresh Token | 中 | 24h 固定过期时间导致用户需要频繁重新登录,影响体验。建议引入 Refresh Token + Access Token 双 Token 机制。 |
| 缺少 API 文档自动化 | 低 | 后端 120+ 路由缺乏自动化文档输出。建议集成 Swagger/OpenAPI 自动生成,降低前后端协作成本。 |
---
## 1.3 前端架构现状评估
### 1.3.1 页面结构与路由设计
项目当前包含 **24+ 页面**,覆盖平台五大功能域,页面层级设计遵循"功能域 → 功能页 → 操作面板"的三层信息架构原则:
```
概览
└── 仪表盘(Dashboard)— 运营指标总览
数据源
└── 数据源管理(Instances)— CRUD + 连接测试
工单管理
├── 数据变更(Tickets/Change)— 变更工单列表 + 审批流
└── 查询数据(Tickets/Query)— 查询工单列表 + SQL 执行
权限管理
├── 权限组(PermissionGroups)— 权限集合管理
├── 角色权限(Roles)— RBAC 角色定义
└── 用户管理(Users)— 用户生命周期管理
系统配置
├── 审计日志(AuditLogs)— 操作记录检索
├── 审批流程(ApprovalFlows)— 审批节点自定义
└── 自动任务(ScheduledTasks)— 定时任务管理
配置平台
└── 系统设置(Settings)— 全局参数配置
```
路由设计层面,项目已采用 **React.lazy 进行代码分割**,这是一个重要的性能优化信号——通过将各功能域拆分为独立的异步 Chunk,可显著降低首屏加载时间。建议进一步结合 React Router v6 的 `loader` 与 `action` API 实现路由级的数据预取,减少页面切换时的感知延迟。
平台提供 **5 种登录页主题**(SplitView / CenteredCard / SidePanel / FullImage / MinimalDark),这一设计体现了对部署场景多样性的考虑——不同企业可根据自身品牌调性选择最适合的登录体验。从维护性角度,建议将这 5 种主题抽象为统一的 "LoginLayout" 组件,通过配置对象驱动渲染,减少重复逻辑。
### 1.3.2 组件化程度分析
项目组件体系可分为三个层级,整体呈现良好的分层架构:
| 层级 | 组件数量 | 代表组件 | 职责说明 |
|------|----------|----------|----------|
| 基础组件 | 50+ | Button, Input, Dialog, Table, Dropdown | shadcn/ui 封装,平台无关,可复用性最高 |
| 业务组件 | 9 | Layout, SqlEditor, SchemaSidebar, EditInstanceDialog | 领域特定,封装业务逻辑与交互模式 |
| 页面组件 | 24+ | Dashboard, Instances, Tickets, Settings | 路由级入口,组合业务组件完成特定功能 |
**值得肯定的方面**:
- 自定义组件的命名遵循 PascalCase + 语义化约定(如 `SchemaSidebar`、`EditInstanceDialog`),可读性良好。
- `SqlEditor` 组件作为核心交互组件被独立封装,基于 CodeMirror 6 提供了 SQL 语法高亮、关键词补全等能力,是可复用性最高的业务资产之一。
- `Layout` 组件的统一设计确保了全站导航结构的一致性。
**建议改进的方向**:
- 业务组件层当前仅有 9 个,相对于 24+ 页面的规模略显单薄,存在页面间逻辑重复的可能性。建议对高频交互模式(如表单创建/编辑、列表筛选/分页、审批操作确认等)进行进一步抽象。
- 建议建立组件文档体系(如 Storybook),记录各组件的 Props 接口、使用示例与视觉规格,降低团队协作成本。
### 1.3.3 状态管理方案
基于代码架构分析,项目当前的状态管理呈现 **"React 内置状态 + 局部上下文"** 的轻量级模式。对于 SQL-Platform 这类以表单操作和列表管理为核心的应用,这种状态管理策略是合理且务实的:
- **服务端状态**(工单列表、实例配置、用户数据等):通过 API 调用获取,建议引入 **TanStack Query (React Query)** 进行封装,可自动处理缓存、去重、重试、乐观更新等复杂逻辑。
- **客户端状态**(UI 展开/折叠、编辑器内容、筛选条件):以 `useState` / `useReducer` 管理,复杂度可控。
- **全局共享状态**(用户认证信息、主题偏好、权限节点):当前可能通过 React Context 或 Props Drilling 传递,建议评估是否需要引入轻量级全局状态库(如 **Zustand**)以避免深层嵌套组件的 Props 传递开销。
### 1.3.4 性能优化措施
目前已实施的优化措施:
| 优化手段 | 实施状态 | 效果评估 |
|----------|----------|----------|
| React.lazy 代码分割 | 已实施 | 降低首屏 JS 体积,提升 FCP/LCP |
| Vite 7 快速构建 | 已实施 | 开发阶段 HMR < 50ms,构建产出高效 |
| Tailwind CSS 原子样式 | 已实施 | 无运行时开销,产物体积小 |
建议补充的优化方向:
1. **虚拟列表**:工单列表与审计日志在数据量较大时可能出现渲染性能瓶颈,建议对长列表引入 `@tanstack/react-virtual` 或类似方案。
2. **编辑器按需加载**:CodeMirror 6 及其语言包体积不可忽视,建议确保编辑器组件仅在 SQL 相关路由中被异步加载。
3. **Tree Shaking 审查**:定期使用 `rollup-plugin-visualizer` 或类似工具分析产物体积,剔除未使用的依赖代码。
4. **图片资源优化**:如有图表截图、操作指南配图等静态资源,建议采用 WebP/AVIF 格式并配置懒加载。
---
## 1.4 现有 UI 设计体系梳理
### 1.4.1 CSS 变量体系评估
项目已建立一套以 `--dg-*` 为前缀的 **CSS 自定义属性体系**,覆盖了 Dark / Light 两种主题模式以及共享的渐变色系统。这一设计决策体现了良好的工程化思维——通过将颜色值抽象为语义化变量,可实现全站一键换肤并降低维护成本。
**Dark 主题色板**:
| 变量名 | 色值 | 语义角色 | 评估 |
|--------|------|----------|------|
| `--dg-bg-primary` | `#0d1117` | 页面主背景 | GitHub Dark 同款基底,视觉深邃,适合长时间操作 |
| `--dg-bg-secondary` | `#161b22` | 卡片/面板背景 | 与主背景形成 7% 亮度差,层级感清晰 |
| `--dg-bg-tertiary` | `#21262d` | 输入框/悬停态背景 | 足够的对比度确保交互反馈可感知 |
**Light 主题色板**:
| 变量名 | 色值 | 语义角色 | 评估 |
|--------|------|----------|------|
| `--dg-bg-primary` | `#ffffff` | 页面主背景 | 纯白基底,经典稳妥 |
| `--dg-bg-secondary` | `#f8f9fb` | 卡片/面板背景 | 微冷灰调,避免纯白页面的单调感 |
**共享渐变系统**:
| 变量名 | 色值 | 使用场景 |
|--------|------|----------|
| `--dg-gradient-start` | `#2563eb`(蓝色) | 品牌主色 → 用于 Logo、主按钮、活跃状态 |
| `--dg-gradient-end` | `#9333ea`(紫色) | 品牌辅色 → 用于渐变过渡、装饰性元素 |
蓝紫渐变在视觉上传递了**科技感与专业性**,与数据库管理工具的定位高度契合。该渐变目前被封装为 `.dg-gradient-text` 与 `.dg-gradient-bg` 两个工具类,使用便捷。
**语义化工具类**:
项目还定义了 `.dg-card`、`.dg-input` 等高级工具类,将颜色变量、圆角、阴影、边框等属性聚合为语义化的样式单元。这种"变量 + 工具类"的双层体系兼顾了灵活性与开发效率,是可维护性设计的体现。
### 1.4.2 shadcn/ui 组件库使用情况
项目使用了 **50+ shadcn/ui 基础组件**,覆盖了表单控件(Input、Select、Checkbox、Radio)、数据展示(Table、Card、Badge、Tooltip)、反馈(Dialog、Toast、Alert、Skeleton)、导航(Tabs、Breadcrumb、Sidebar)等完整类别。
shadcn/ui 的核心优势在于**源码级可定制性**——组件代码直接存在于项目的 `components/ui/` 目录中,团队可以:
- 自由修改组件的默认样式、动画参数、无障碍属性
- 根据品牌规范调整颜色、圆角、间距等设计 Token
- 添加业务特有的 Props 扩展(如在 Button 上增加 `isLoading` 的统一处理)
当前需要关注的点:50+ 组件的规模意味着维护成本不可忽视,建议在升级 shadcn/ui 版本时建立变更日志审查机制,确保上游的 Bug 修复与安全更新能够及时同步。
### 1.4.3 主题系统设计
平台当前的主题系统具备以下特征:
1. **双主题覆盖**:Dark / Light 两种模式,通过 CSS 变量切换实现,切换成本极低(仅需切换顶层 class)。
2. **渐变色作为品牌标识**:蓝紫渐变(`#2563eb` → `#9333ea`)贯穿两种主题,形成了强烈的品牌视觉记忆点。
3. **CSS 变量驱动**:所有主题相关属性均通过 `--dg-*` 变量管理,无硬编码色值散落在组件代码中。
**建议完善的方面**:
- 当前主题切换可能依赖 CSS 的 `prefers-color-scheme` 媒体查询或手动切换按钮,建议增加**主题偏好持久化**(localStorage)与**系统主题自动跟随**的双重机制。
- 建议补充 **High Contrast(高对比度)** 模式,满足无障碍合规要求(WCAG 2.1 AA 级)。
- 考虑引入 **主题 Token 文档**,明确定义每种语义色值的使用场景(如 `--dg-bg-primary` 仅用于页面级背景,不可用于卡片),防止变量滥用导致视觉不一致。
### 1.4.4 当前设计体系的优势与不足
**优势**:
| 维度 | 表现 |
|------|------|
| 工程化成熟度 | CSS 变量 + 工具类的双层体系,shadcn/ui 的源码级可控性 |
| 品牌一致性 | 蓝紫渐变标识贯穿全站,Dark/Light 双主题覆盖完整 |
| 开发效率 | Tailwind 原子类 + 语义化工具类,编写样式快速直观 |
| 扩展能力 | 新增主题仅需定义一组 CSS 变量,无需修改组件代码 |
**不足**:
| 维度 | 问题描述 | 改进优先级 |
|------|----------|-----------|
| 设计 Token 粒度 | 当前变量主要集中在颜色维度,缺少间距、字体、阴影、动效的 Token 化定义 | 高 |
| 动效体系缺失 | 页面转场、组件出现/消失、状态切换等交互缺乏统一的动效规范 | 高 |
| 无障碍支持 | 缺少 ARIA 属性系统化检查、键盘导航测试、色彩对比度合规验证 | 中 |
| 响应式设计 | 平台以桌面端操作为主,但缺少不同分辨率下的布局适配策略 | 中 |
| 图标体系 | 未明确图标库选型(如 Lucide、Radix Icons)与使用规范 | 低 |
综上,SQL-Platform 的 UI 设计体系已具备良好的基础骨架,特别是在 CSS 变量体系与组件化封装方面展现了专业水准。后续优化的核心方向应聚焦于**设计 Token 的维度扩展**(补充间距、动效、阴影 Token)、**交互动效体系的建立**以及**无障碍合规性的系统性提升**,这些内容将在后续章节中展开详细方案。
@@ -1,171 +0,0 @@
## 2. 现状问题诊断
经过对 SQL-Platform 前端代码库的全面审阅与走查,我们共识别出 5 大维度、23 项具体问题。这些问题贯穿登录认证、主题系统、核心功能页面、菜单导航以及整体设计体系,严重程度从"视觉瑕疵"到"架构性债务"不等。以下按模块逐项展开诊断。
---
### 2.1 登录页面五大问题分析
平台提供了 5 种登录页面布局(`SplitView`、`CenteredCard`、`SidePanel`、`FullImage`、`MinimalDark`),位于 `/frontend/src/components/login/` 目录下。尽管数量丰富,但 5 个组件共享大量共性问题,且各布局存在特有的设计缺陷。以下从视觉层次、交互体验、品牌感知、组件化程度和动效反馈五个维度进行诊断。
#### 2.1.1 视觉层次薄弱
**共性问题**:5 个登录页均缺乏清晰的视觉焦点层级,背景、装饰元素与核心表单区域之间的对比关系处理随意。
| 布局组件 | 具体视觉问题 | 严重程度 |
|---|---|---|
| `SplitView.tsx` | 左侧特性列表采用简单 icon+文字网格排列,无卡片包裹、无分割线,各 feature 项视觉权重完全一致,用户难以快速扫描核心卖点 | 中 |
| `CenteredCard.tsx` | 渐变背景仅为单色线性过渡,无纹理或光晕层次;卡片阴影硬编码为 `0 8px 32px rgba(0,0,0,0.3)`,缺乏环境光感,呈现"浮空贴图"效果 | 中 |
| `SidePanel.tsx` | 装饰性圆圈使用 `borderRadius: 50%` 的 div 硬编码,无渐变填充、无模糊处理,视觉上如同"开发占位符";品牌区域仅放置 `Database` 图标,无品牌图形标识 | 高 |
| `FullImage.tsx` | 电路板网格背景使用 CSS `repeating-linear-gradient` 实现,线条粗细一致、无辉光效果,整体呈现"半成品 sketch"观感;毛玻璃效果 `backdrop-filter: blur(16px)` 未配合饱和度降低(` saturate`)与边缘高光,通透感不足 | 高 |
| `MinimalDark.tsx` | 几乎无任何装饰性设计元素,纯黑背景+白字表单的极简走向极端,缺乏辅助视觉引导线或光晕点缀,整体显得空旷冷清 | 中 |
**根因分析**:各登录页的开发重心放在"布局差异化"上,却忽视了"视觉深度营造"。装饰元素的实现手段停留在 CSS 基础属性层面,未利用渐变、噪点纹理、多层阴影、边缘发光等现代视觉技法来建立前景/中景/背景的三层空间关系。
#### 2.1.2 交互体验不足
登录作为用户进入平台的第一触点,其交互体验直接影响用户对平台专业度的初始判断。当前 5 个登录组件在表单交互方面存在系统性缺陷:
1. **表单验证视觉反馈缺失**:全部使用原生 `<input>` 元素,未采用 shadcn/ui 的 `<Input>` 组件。输入框在 focus、error、success 三种状态下均无边框颜色变化、无图标提示、无 shake 动画。用户提交错误表单后,仅能看到简单的红色文字提示,错误字段本身没有任何视觉变化。
2. **"记住我"功能缺位**:5 个登录页均未提供"记住账号/保持登录"选项。对于企业级工具而言,频繁的会话过期与重复登录是显著的效率损耗点。
3. **"忘记密码"入口缺失**:没有提供密码找回或重置的链接入口,用户一旦遗忘密码将无从操作。
4. **安全提示文字缺失**:未展示"建议使用强密码"、"登录即表示同意服务条款"等合规性文案,降低了平台的安全可信度。
5. **版本号显示缺失**:登录页未展示当前平台版本号(如 `v2.4.1`),不利于运维排查与用户反馈时的版本确认。
#### 2.1.3 品牌感缺失
品牌名在所有 5 个登录页中统一硬编码为"SQL审核平台",名称过于直白、缺乏品牌辨识度与情感连接。对比业界标杆产品(如 Bytebase 的科技感命名、Archery 的品牌化 Logo),当前的命名策略停留在"功能描述"层面,未形成品牌记忆点。
更关键的是,5 个登录页均**未展示品牌标识**(Logo、品牌图形、吉祥物或标志性配色)。`SidePanel` 仅用 `Database` 通用图标代替品牌图形,`CenteredCard` 完全没有任何品牌标识。登录页作为品牌视觉锤的核心触点,未能承担品牌认知建设的职责。
#### 2.1.4 组件化程度低
从代码实现层面审视,5 个登录组件存在显著的工程化债务:
- **未使用 shadcn/ui `<Input>` 组件**:全部直接使用原生 `<input>`,导致表单样式无法继承主题系统的 CSS 变量,切换主题时可能出现颜色断层。
- **内联样式泛滥**:表单样式大量使用内联 `style` 属性(如 `style={{ padding: '24px', borderRadius: '8px' }}`),而非 Tailwind CSS 工具类。这破坏了项目的样式一致性,也增加了主题切换的维护成本。
- **按钮样式不统一**:有的登录按钮使用渐变背景,有的使用纯色,有的使用 outline 样式,5 个组件之间没有共享按钮规范。
- **登录按钮 loading 状态简陋**:loading 状态仅通过文字变化(如"登录中...")体现,无 spinner 图标、无按钮宽度保持、无禁用状态视觉变化,用户可能因按钮"变形"而产生疑惑。
**改进方向**:应将登录表单抽象为可复用的 `<LoginForm>` 组件,统一使用 shadcn/ui 的 `Input`、`Button`、`Checkbox`、`Label` 等原子组件,并通过 Tailwind class 管理所有样式。
#### 2.1.5 动效与反馈匮乏
当前登录页的动效设计几乎处于空白状态:
| 交互场景 | 当前表现 | 期望表现 |
|---|---|---|
| 登录成功 | 直接跳转,无过渡动画 | 显示成功 checkmark 动画,延迟 400ms 后优雅跳转 |
| 登录失败 | 简单红色文字提示 | 表单 shake 动画 + 错误字段高亮 + 带图标的错误消息 |
| 输入框 focus | 无特殊效果 | 边框颜色过渡 + 柔和外发光(`ring`) |
| 按钮 hover/click | 无反馈或仅有透明度变化 | 微缩放 + 阴影加深 + 涟漪效果 |
| 页面加载 | 静态呈现 | 表单区域的 fade-in + translateY 入场动画 |
**根因分析**:开发过程中未引入动画库(如 `framer-motion`),所有交互反馈停留在浏览器默认行为层面。登录页作为情感化设计的最佳载体,动效的缺失使得整体体验显得"机械"与"冰冷"。
---
### 2.2 主题系统局限性
当前主题系统仅支持 `dark` 与 `light` 两种模式,通过 `data-theme` HTML 属性进行切换,主题选择入口位于 `AppearanceSettings` 页面,以简单的按钮组形式呈现。
**核心局限点**:
1. **色彩方案单一**:缺乏紫色系、绿色系、暖色系、高对比度等多元色彩方案。现代 SaaS 平台(如 Vercel、Notion、Linear)通常提供 4-8 种预设主题,以满足不同用户的审美偏好与无障碍需求。
2. **主题预览简陋**:预览区域仅以 4:3 比例的缩略图展示,用户无法直观感受主题在真实数据密度下的表现。建议提供可交互的实时预览(如展示表格、按钮、标签等组件的组合效果)。
3. **主题切换过渡生硬**:切换主题时无过渡动画,页面瞬间"闪切",视觉体验断裂。应加入 200-300ms 的颜色过渡动画(`transition: color, background-color`)。
4. **自定义能力缺失**:高级用户无法调整主色调、圆角大小、字体密度等参数。建议提供"自定义主题"模式,允许用户通过色盘选择器定义个性化的品牌色。
5. **CSS 变量覆盖范围不足**:部分组件仍使用硬编码颜色值,未完全纳入 CSS 变量体系,导致主题切换时存在"漏色"现象。
---
### 2.3 核心功能页面问题
#### 2.3.1 审批变更工单系统
工单系统包含 3 个核心页面,总计 2742 行代码,存在显著的 UX 债务:
**审批变更页(`TicketsPage.tsx`,742 行)**:
- 表格使用原生 `<table>` 元素,未使用 shadcn/ui 的 `Table` 组件,导致列头排序、列宽调整、行悬停效果等功能全部缺失。
- 状态标签样式极其简单(如 `bg-blue-500/15 text-blue-400`),仅为色块+文字,无圆角规范、无图标辅助、无二态(solid/soft)区分。
- 操作按钮采用简单文字链接形式("查看""编辑""删除"),无图标按钮、无操作菜单(dropdown menu),在窄屏下易折行。
- **缺少批量操作功能**:用户无法对多条工单进行批量审批或批量关闭,效率低下。
- 搜索框为原生 `<input>`,无搜索图标、无清除按钮、无搜索建议。
- 分页组件为手写实现,未使用 shadcn/ui `Pagination`,样式与平台其他分页区域不统一。
**工单创建页(`TicketCreatePage.tsx`,1192 行)**:
- 布局采用上下堆叠的线性结构,左侧 SQL 编辑器与右侧库表浏览面板的空间配比固定,未利用响应式网格最大化编辑区域。
- SQL 编辑器下方的信息条(行数、字符数、语法状态)样式过于简单,无图标、无色彩状态区分。
- 审核结果展示为扁平列表,缺乏视觉层次:通过/失败的规则未使用不同颜色或图标区分,高亮行与错误行的视觉权重相近。
- 库表浏览面板的 tab 切换样式为基础样式,无活跃 tab 的指示器动画。
- **无自动保存草稿功能**:用户误刷新页面后,已编写的 SQL 将全部丢失,这是企业级工具的高风险体验缺口。
**工单详情页(`TicketDetailPage.tsx`,808 行)**:
- 审批流程可视化使用简单圆形+直线实现,无节点状态图标(如 `Check`、`Clock`、`X`)、无连线动画、无进度百分比指示。
- 信息网格采用 2 列 flex 布局直接裸露呈现,无卡片包裹、无分割线、无标签-值的对齐规范。
- 执行结果直接展示原始 JSON 字符串,无格式化、无语法高亮、无可折叠区域,运维人员阅读极为困难。
- 关联工单区域仅为简单的工单编号链接列表,无状态徽章、无优先级标识。
#### 2.3.2 查询数据页面
`QueriesPage.tsx`(896 行)作为数据查询的核心工作区,存在以下 UX 问题:
- 查询结果表格同样使用原生 `<table>`,列宽不可调整(无拖拽调整功能),对于宽表(20+ 列)的浏览体验极差。
- **缺少查询收藏夹功能**:用户无法保存常用的查询语句,每次需重新输入或从历史中翻找。
- 历史记录面板为简单的文字列表,无查询耗时显示、无行数结果、无收藏/删除操作。
- SQL 编辑器与结果表格之间的区域无明确的分割线或可调整拖拽条(resizer),用户无法按需求分配编辑区与结果区的屏幕空间。
#### 2.3.3 系统设置页面
`SettingsPage.tsx`(56 行)作为设置入口,问题尤为突出:
- **Tab 图标使用 Emoji**(`🎨` 外观、`⚙️` 平台、`🛡️` 安全、`🔔` 通知、`🔐` 密码),与平台其他页面统一使用的 `lucide-react` 图标体系严重冲突,视觉风格割裂。Emoji 在不同操作系统下的渲染差异也会导致界面不统一。
- Tab 切换无动画过渡,内容区域的切换为瞬间跳转,体验生硬。
- 各设置子页面的表单设计不一致:有的使用 shadcn/ui 组件,有的(如 `PlatformConfigPage`)直接使用原生 `<input>`,标签对齐方式、间距、按钮位置均不统一。
- **缺少设置变更确认/撤销机制**:用户在修改敏感配置(如数据库连接参数)后,没有二次确认对话框;也没有"重置为默认值"或"撤销本次更改"的功能,误操作风险高。
---
### 2.4 菜单体系问题
平台的侧边栏导航菜单存在以下结构性问题:
1. **图标体系不统一**:`Settings` 页面使用 Emoji 图标,而其他所有页面使用 `lucide-react` 图标库。这种混用破坏了导航的视觉一致性,也降低了专业感。
2. **菜单分组合理性不足**:当前菜单分为 6 个 section,但部分分类逻辑存在问题。例如"自动任务"被归类在"系统配置"分组下,而从用户心智模型来看,自动任务属于"运维操作"范畴,与"系统配置"(偏静态参数)的认知类别不同。建议重新审视信息架构,采用"按用户角色分组"或"按操作频率分组"的策略。
3. **面包屑导航缺失**:页面层级超过 2 层后(如 设置 > 平台配置 > 数据库参数),用户无法通过面包屑快速定位当前位置或返回上级,只能依赖浏览器后退按钮。
4. **Hover/Active 状态过渡动画缺失**:菜单项的悬停和激活状态切换为瞬间变化,无 150-200ms 的颜色/背景过渡动画,整体感觉"卡顿"而非"响应"。
5. **当前页面指示不够醒目**:激活菜单项仅通过轻微的颜色变化区分,缺乏左侧边框高亮(`border-left` accent)或背景色块等强指示手段。
---
### 2.5 设计体系不一致性
从代码层面审视,平台在整体 design system 的贯彻执行上存在严重的碎片化现象:
**卡片组件混用**:部分页面使用 shadcn/ui 的 `<Card>` 组件(如 Dashboard),部分页面使用 `<div>` 手写卡片样式(如工单详情页的信息网格)。手写卡片的圆角、阴影、边框、背景色与 shadcn/ui Card 的默认参数存在细微差异,积累后形成显著的视觉噪音。
**按钮样式分裂**:平台内同时存在 3 种按钮风格——渐变按钮(部分登录页)、纯色填充按钮(shadcn/ui 默认)、outline 描边按钮(部分操作栏)。三种风格的使用没有明确的场景规范,同一页面内也可能混用,破坏了操作隐喻的一致性。
**表单输入框样式差异**:有的页面使用 shadcn/ui `<Input>`(带统一的 focus ring、圆角、高度),有的页面使用原生 `<input>`(样式依赖浏览器默认),有的页面使用内联样式自定义的输入框。三种实现的视觉高度、边框粗细、焦点反馈均不相同。
**颜色管理混乱**:部分组件使用硬编码颜色值(如 `rgba(0,0,0,0.3)`、`#3b82f6`),部分组件使用 CSS 变量(如 `hsl(var(--primary))`),部分组件使用 Tailwind 的语义化 class(如 `bg-primary`)。当主题切换或品牌色调整时,硬编码的颜色值不会跟随变化,导致"漏色"与视觉断层。
**间距系统缺失**:各页面的 `padding` 和 `margin` 取值随意,无统一的 spacing scale。例如有的卡片内边距为 `16px`,有的为 `20px`,有的为 `24px`;页面级的外边距也无统一规范(`pagePadding` token 缺失)。这种不一致使得页面之间的"呼吸感"差异明显,整体缺乏精致感。
**根因总结**:以上不一致性的根本原因在于缺乏一个被严格执行的 Design Token 体系与组件使用规范。开发人员在实现新页面时,没有明确的"应该使用哪个组件、哪个 token"的指引,导致各页面"各自为政"。建议引入一套完整的 Design Token(颜色、间距、字体、阴影、圆角)并配合 ESLint 规则强制在组件层面统一规范。
---
> **本章小结**:SQL-Platform 的现状问题可归纳为"功能可用但体验粗糙"。登录页面作为品牌门面,在视觉层次、交互反馈、品牌感知上均有显著短板;核心功能页面(工单系统、查询页面、设置页面)在组件选型、布局效率、信息架构上存在系统性债务;主题系统与菜单体系的局限性则反映了设计体系在规划层面的不足。这些问题并非孤立存在,而是相互交织:设计体系的碎片化导致了各页面实现的不一致,组件化程度低导致了交互体验的简陋,主题系统的局限又加剧了视觉风格的单一。后续章节将针对这些问题提出具体的优化方案。
@@ -1,393 +0,0 @@
## 3. 五套专业登录页设计方案
针对现有五个登录页组件(`SplitView.tsx`、`CenteredCard.tsx`、`SidePanel.tsx`、`FullImage.tsx`、`MinimalDark.tsx`)"不专业、太 low"的用户反馈,本章提供五套完整的专业级登录页重设计方案。每套方案均基于 React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui + Lucide Icons 技术栈,保留现有的 `useLogin` 与 `useTheme` hook 架构,在视觉表现、交互体验与动效层次上实现全面升级。
五套方案分别对应五种截然不同的企业气质:科技互联、金融安全、云端 SaaS、极客代码、创意自然。设计方案遵循"一次一屏"(Single Screen)原则,确保登录流程简洁高效,同时在第一眼传递出强烈的品牌专业感。
---
### 3.1 方案一「极光科技」— 动态粒子科技风
#### 设计概念与灵感来源
「极光科技」方案的灵感源自现代科技公司的数据可视化美学——深空般的背景中,粒子如数据节点般流动、连接,暗示着数据库节点之间的网络拓扑关系。设计参考了 Vercel、PlanetScale 等现代 DevTools 产品的暗黑登录页风格,将"数据流动"这一抽象概念转化为可感知的视觉语言。深色背景配合冷色调粒子动画,营造出深邃、专注、充满科技张力的第一印象。
#### 适用场景
适合科技互联网公司、数据库工具产品、DevOps 平台、技术驱动型 SaaS 企业。这类企业的用户群体(开发者、DBA、技术人员)对科技感视觉有天然的好感度与信任预期。
#### 视觉规范
| 元素 | 规范值 | 说明 |
|------|--------|------|
| 背景渐变 | `#0a0e27` → `#1a1f4b` | 深邃的深蓝到暗蓝,比纯黑更有空间感 |
| 粒子数量 | 80 个 | 足够形成视觉密度,不过度抢占注意力 |
| 粒子颜色 | `#3b82f680`(半透蓝) | 与品牌蓝色系一致,带透明度避免喧宾夺主 |
| 连线距离 | 120px | 控制连线密度,确保画面不过于繁杂 |
| 卡片背景 | `rgba(255,255,255,0.03)` | 极微弱的白色,配合毛玻璃效果 |
| 卡片毛玻璃 | `backdrop-blur: 20px` | iOS 风格毛玻璃,现代感强烈 |
| 卡片边框 | `1px solid rgba(255,255,255,0.08)` | 微妙的发光边框勾勒卡片轮廓 |
| 卡片圆角 | `16px` | 现代感的较大圆角 |
| 卡片内边距 | `40px 32px` | 宽裕的内部空间 |
| Logo 底圆 | `56px`,渐变 `from #2563eb to #9333ea` | 渐变圆形底托提升品牌质感 |
| Logo 图标 | `Shield`(Lucide),白色,`24px` | 安全感的盾形图标 |
| 品牌名 | "DataGuard",`20px`,白色,字重 600 | 清晰的品牌露出 |
| 输入框高度 | `44px` | 符合现代表单的最佳点击高度 |
| 输入框圆角 | `10px` | 与卡片圆角协调 |
| 输入框聚焦 | 边框 `#3b82f6`,外发光 `0 0 0 3px rgba(59,130,246,0.15)` | 蓝色光晕聚焦指示 |
| 按钮渐变 | `from #2563eb to #7c3aed` | 蓝紫渐变,与现有品牌渐变一致 |
| 按钮高度 | `44px` | 与输入框等高,视觉对齐 |
| 按钮圆角 | `10px` | 与输入框统一 |
| 辅助链接色 | `#8b949e`,hover `#58a6ff` | GitHub 风格的灰色链接 |
#### 动效规范
**粒子系统动画**:使用 Canvas 2D 实现 60fps 粒子系统。每个粒子以随机速度在画布内移动(速度范围 0.3~0.8px/frame),碰到边界反弹。当两个粒子距离小于 120px 时,绘制连线(线条透明度随距离衰减:`opacity = 1 - distance/120 * 0.7`)。鼠标位置额外产生一个"引力点",靠近鼠标的粒子连线距离阈值提升至 180px,形成交互反馈。
**卡片入场动画**:
```css
@keyframes cardEnter {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* duration: 600ms, easing: cubic-bezier(0.16, 1, 0.3, 1) */
```
使用 `cubic-bezier(0.16, 1, 0.3, 1)` 缓动函数——初始阶段快速响应,结尾柔和减速,营造"丝滑吸附"的质感。
**输入框聚焦**:边框颜色过渡 `200ms ease`,外发光同步渐显,给用户明确的焦点反馈。
**按钮 Hover**:`transform: translateY(-1px)` 微上移,`box-shadow` 从 `0 4px 12px rgba(37,99,235,0.25)` 增强为 `0 8px 24px rgba(37,99,235,0.35)`,过渡 `200ms ease`。
**错误状态 Shake 动画**:
```css
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
50% { transform: translateX(5px); }
75% { transform: translateX(-5px); }
}
/* duration: 300ms, 执行 1 次,同时边框变为 #ef4444 */
```
#### 代码实现要点
1. **粒子系统**:使用 `useRef<HTMLCanvasElement>` 获取 Canvas 上下文,在 `useEffect` 中初始化粒子数组并启动 `requestAnimationFrame` 动画循环。粒子系统封装为独立的 `ParticleBackground.tsx` 组件,通过 props 接收 `particleCount`、`connectionDistance`、`color` 等参数。
2. **卡片结构**:使用 `Card`(shadcn/ui)作为容器,外层包裹 `div` 设置 `backdrop-blur-xl bg-white/[0.03] border border-white/[0.08]` 实现毛玻璃效果。
3. **表单组件**:`Input` 和 `Button` 直接使用 shadcn/ui 组件,通过 Tailwind 的 `className` 覆盖默认样式。输入框聚焦外发光使用 `focus:ring-[3px] focus:ring-blue-500/15 focus:border-blue-500`。
4. **状态管理**:继续使用现有的 `useLogin` hook(`username` / `password` / `loading` / `error`),错误触发时通过条件类名绑定 shake 动画。
---
### 3.2 方案二「企业金融」— 银行级安全可信风
#### 设计概念与灵感来源
金融类产品的登录页第一要务是传递"安全感"。「企业金融」方案借鉴了招商银行企业网银、Stripe Dashboard 以及 Bloomberg Terminal 的视觉语言——通过深沉的蓝色调、严谨的版式分割、明确的信任标识,在瞬间建立起用户对系统的信任。左右分屏的布局本身就是一种力量感的表达:左侧沉稳的品牌展示,右侧聚焦的表单操作,信息层级清晰,符合金融用户"高效、安全、专业"的心理预期。
#### 适用场景
金融、银行、保险、证券、支付等强监管行业企业。用户群体包括金融从业者、企业财务人员、风控人员,他们对安全标识(SSL、加密、审计)高度敏感。
#### 视觉规范
| 元素 | 规范值 | 说明 |
|------|--------|------|
| 左侧背景 | `#0c1a3c` → `#1a365d` | 深蓝渐变,传递权威与稳重 |
| 左侧宽度 | 60% | 大面积品牌展示区 |
| 右侧背景 | `#f8fafc` | 极浅灰白,与左侧形成清晰分割 |
| 右侧宽度 | 40% | 聚焦的表单操作区 |
| 品牌 Logo | 白色 Shield 图标 + "DataGuard" | 居中偏上布局 |
| Slogan | "安全的数据库变更管理",`18px`,`#94a3b8` | 简洁定位标语 |
| 信任指标 | 3 个:SSL 加密、权限隔离、审计追踪 | 带 CheckCircle 图标的列表 |
| 信任指标图标 | `CheckCircle2`(Lucide),`#22c55e` | 绿色对勾传递"已验证" |
| 信任指标文字 | `#cbd5e1`,`14px` | 柔和的说明文字 |
| 右侧卡片 | 白色背景,圆角 `16px` | 干净的表单容器 |
| 卡片阴影 | `0 4px 24px rgba(0,0,0,0.08)` | 微妙的浮起感 |
| 卡片内边距 | `48px 40px` | 金融级宽裕间距 |
| 登录标题 | "欢迎回来",`24px`,`#0f172a`,字重 700 | 亲切的问候 |
| 输入框样式 | 底部边框(Underline Input)| 类似 Material Design 的底线输入 |
| 输入框高度 | `48px` | 稍大以凸显重要感 |
| 输入框边框 | 默认 `border-b-2 border-slate-200`,聚焦 `border-[#1a365d]` | 底线聚焦变深蓝 |
| 按钮背景 | `#1a365d` | 沉稳深蓝,呼应左侧 |
| 按钮高度 | `48px` | 与输入框等高 |
| 按钮圆角 | `8px` | 相对克制的圆角,体现严谨 |
| 安全提示 | "🔒 您的连接已加密保护",`12px`,`#64748b` | 明确的安全信号 |
#### 动效规范
**左右面板载入**:页面加载时,左侧面板从左侧滑入(`translateX(-40px) → 0`,`400ms ease-out`),右侧面板从右侧同步滑入(`translateX(40px) → 0`,`400ms ease-out`,延迟 `50ms`)。两侧同时汇聚中间,隐喻"安全通道的建立"。
**信任指标依次淡入**:3 个信任指标在左侧面板就位后依次显示,每个间隔 `150ms`:
```css
.trust-item:nth-child(1) { animation-delay: 200ms; }
.trust-item:nth-child(2) { animation-delay: 350ms; }
.trust-item:nth-child(3) { animation-delay: 500ms; }
/* 统一动画: opacity 0→1, translateX(-10px)→0, 300ms ease-out */
```
**按钮 Hover**:背景色从 `#1a365d` 平滑过渡为 `#122a4a`(加深 10%),同时增加 `box-shadow: 0 4px 16px rgba(12,26,60,0.2)`,过渡 `200ms ease`。
**输入框聚焦**:底部边框从 `#e2e8f0` 变为 `#1a365d`,宽度有微妙的"延展"效果(使用 `scaleX` 从 0→1 配合 `transform-origin: left`),`200ms ease`。
#### 代码实现要点
1. **分屏布局**:使用 CSS Grid `grid-cols-[60%_40%]` 或 Flex `flex-[60]` / `flex-[40]` 实现左右分割。移动端(`< 1024px`)切换为上下布局,左侧折叠为顶部横幅。
2. **底线输入框**:不使用 shadcn/ui 默认的边框输入框,而是自定义 `div` 包裹 `<input>`,通过 `border-b-2` + `bg-transparent` 实现底线样式,聚焦时切换 `border-color`。
3. **信任指标列表**:封装为 `TrustIndicators` 子组件,接收指标数组渲染,配合 `setTimeout` 或 CSS `animation-delay` 实现依次淡入。
4. **安全提示**:底部固定位置,使用 `flex items-center gap-2` 布局,锁图标使用 Lucide `Lock` 组件。
---
### 3.3 方案三「云端数据」— 云计算 SaaS 风
#### 设计概念与灵感来源
云计算和 SaaS 产品的登录页追求的是"轻盈、开放、无门槛"。「云端数据」方案汲取了 Snowflake、Figma、Notion 等现代 SaaS 产品的登录体验——明亮的浅色调、友好的圆角语言、丰富的 SSO 集成选项,向用户传递"即开即用"的产品气质。右上角的装饰性云朵图案是对"云端"概念的直接呼应,但处理为低对比度的纹理,避免过度具象化。
#### 适用场景
云服务商、SaaS 产品、互联网协作工具、中台系统、B2B 软件公司。用户群体包括产品经理、技术团队、普通企业用户,他们期望登录流程简单快捷,SSO 支持是加分项。
#### 视觉规范
| 元素 | 规范值 | 说明 |
|------|--------|------|
| 背景渐变 | `#f0f4f8` → `#e2e8f0` | 柔和的天空感浅灰蓝渐变 |
| 装饰云朵 | SVG 路径,右上角,`opacity: 0.3` | 微妙的云端意象,不干扰主体 |
| 卡片背景 | `#ffffff` 纯白 | 干净、明亮的中心焦点 |
| 卡片圆角 | `20px` | SaaS 产品偏爱的友好大圆角 |
| 卡片阴影 | `0 8px 32px rgba(148,163,184,0.15)` | 柔和的悬浮阴影 |
| 卡片宽度 | `420px` | 适中宽度,聚焦表单 |
| 卡片内边距 | `48px 40px` | 宽裕的内部空间 |
| 顶部品牌区 | `40px` 圆角矩形图标 + "SQL-Platform" | 清晰的身份标识 |
| 品牌图标背景 | `from #0ea5e9 to #2563eb` | 天蓝到蓝的渐变,呼应云端主题 |
| 副标题 | "数据库变更管理平台",`14px`,`#64748b` | 产品定位说明 |
| 输入框背景 | `#f8fafc` | 极浅灰底,区别于卡片白 |
| 输入框圆角 | `12px` | 圆角输入,友好感 |
| 输入框边框 | 默认无边框,聚焦 `1px solid #3b82f6` | 最小化视觉噪音 |
| 输入框高度 | `46px` | 略大于标准,易于点击 |
| 按钮渐变 | `from #0ea5e9 to #2563eb` | 天空蓝到标准蓝 |
| 按钮圆角 | `12px` | 与输入框统一 |
| 按钮高度 | `46px` | 与输入框等高对齐 |
| 分隔线 | "或使用以下方式登录",`13px`,`#94a3b8` | 经典的 SSO 分隔 |
| SSO 图标 | 企业微信/钉钉/飞书,`40px` 圆形按钮 | 图标化登录选项 |
| SSO 按钮背景 | `#f1f5f9`,hover `#e2e8f0` | 灰色底,不与主按钮竞争 |
#### 动效规范
**卡片入场**:从上方轻柔落下,同时伴随微妙的缩放——`opacity 0→1`、`translateY(-16px)→0`、`scale(0.98)→1`,`500ms cubic-bezier(0.34, 1.56, 0.64, 1)`。使用弹性缓动函数(overshoot easing),营造 SaaS 产品特有的"活泼弹动感"。
**输入框聚焦**:无边框输入框在聚焦时,背景色从 `#f8fafc` 微妙提亮至 `#ffffff`,同时蓝色边框从 `opacity 0` 渐显至 `opacity 1`,过渡 `150ms ease`。
**按钮 Hover**:`translateY(-2px)` 上浮,`box-shadow` 增强,同时渐变产生微妙的位置偏移(使用 `background-position` 动画),`200ms ease`。
**SSO 图标 Hover**:圆形背景从 `#f1f5f9` 变为 `#e2e8f0`,图标 `scale(1.1)`,`150ms ease`。
**装饰云朵**:缓慢的水平飘移动画(`translateX(0) → translateX(20px) → translateX(0)`,`20s ease-in-out infinite`),赋予页面呼吸感。
#### 代码实现要点
1. **卡片弹性动画**:使用 Tailwind 的 `animate-in` 配合自定义 CSS keyframe 实现弹性入场。或使用 Framer Motion(如项目已引入)的 `initial/animate` props 配合 `type: "spring", stiffness: 200, damping: 15` 实现物理弹性效果。
2. **无边框输入框**:shadcn/ui 的 `Input` 组件默认有边框,需通过 `border-0 bg-slate-50 focus:bg-white focus:ring-1 focus:ring-blue-500` 覆盖样式。
3. **SSO 按钮组**:使用 Flex 布局 `flex gap-3 justify-center`,每个 SSO 按钮为 `button` 元素包裹品牌 SVG 图标。考虑到不同企业的 SSO 需求,SSO 区域应封装为 `SSOProviders` 组件,通过配置数组动态渲染。
4. **云朵装饰**:将云朵 SVG 设为 `position: absolute; top: -50px; right: -30px; pointer-events: none;`,确保不影响交互。
---
### 3.4 方案四「极简矩阵」— 开发者极客风
#### 设计概念与灵感来源
对于面向开发者的工具平台,过于" polished "的设计反而可能显得不够硬核。「极简矩阵」方案致敬了《黑客帝国》的数字雨(Matrix Rain)以及终端命令行的纯粹美学,将登录页转化为一个"进入系统"的仪式。纯黑背景上绿色字符如雨般落下,输入框如同终端命令行,每一个交互细节都在告诉用户:"这是一个为技术人员打造的严肃工具。"
#### 适用场景
技术团队、开发者工具、DevOps 平台、开源项目、CLI/GUI 混合产品。用户群体是开发者、SRE、运维工程师,他们对终端界面有亲切感,极客风格的 UI 反而能唤起"同道中人"的认同感。
#### 视觉规范
| 元素 | 规范值 | 说明 |
|------|--------|------|
| 背景色 | `#000000` 纯黑 | 终端/命令行的经典底色 |
| 字符雨颜色 | `#22c55e`(绿色),`#16a34a`(深绿) | 双色调绿色增加层次 |
| 字符集 | 大小写字母 + 数字 + 日文片假名 | Matrix 经典字符集 |
| 字符密度 | 比原版 Matrix 减少 40% | 不过度干扰登录表单的可读性 |
| 字符下落速度 | 2~6 px/frame 随机 | 自然的不规则雨效 |
| 卡片背景 | `rgba(0,0,0,0.85)` | 高透明度黑底,字符隐约可见 |
| 卡片边框 | `1px solid rgba(34,197,94,0.3)` | 绿色发光边框,终端窗口感 |
| 卡片圆角 | `4px` | 极小圆角,锐利的技术感 |
| 标题字体 | `JetBrains Mono`,`22px`,`#22c55e` | 等宽字体是终端的灵魂 |
| 标题文字 | `> sql-platform login` | 命令行风格的标题 |
| 副标题 | "请输入您的凭据以继续",`13px`,`#4ade80` | 浅绿说明文字 |
| 输入框背景 | `#0a0a0a` | 深黑,区别于卡片 |
| 输入框边框 | `1px solid rgba(34,197,94,0.25)` | 微弱绿边 |
| 输入框圆角 | `4px` | 锐利 |
| 输入框字体 | `JetBrains Mono`,`14px` | 打字机般的输入体验 |
| 输入框文字色 | `#22c55e` | 绿色输入文字 |
| Placeholder | `#374151`(深灰)| 低调提示 |
| 输入框聚焦 | 边框变为 `rgba(34,197,94,0.6)` | 明亮的绿色边框 |
| 按钮背景 | `#22c55e` | 醒目绿色 |
| 按钮文字 | `#000000`,`JetBrains Mono`,字重 600 | 黑底绿字,高对比 |
| 按钮圆角 | `4px` | 锐利风格统一 |
| 光标 | 自定义绿色闪烁光标 | `2px` 宽竖线,每 `530ms` 闪烁 |
| 底部版本号 | "v2.4.1 — build 20240815",`11px`,`#374151` | 开发者熟悉的版本信息 |
#### 动效规范
**字符雨动画**:使用 Canvas 2D 实现。初始化多列字符,每列以随机速度向下滚动。每个字符有随机的生命周期——新字符高亮(`#4ade80`),旧字符渐暗(`#15803d` → `#166534`),超出画布底部后重置到顶部。字符雨在登录卡片后方运行,使用 `z-index` 分层。
**卡片入场**:模拟终端启动——先显示 `> sql-platform login_`(带闪烁光标),停顿 `300ms` 后表单元素逐行"打印"出现。每个元素使用 `opacity 0→1`,间隔 `80ms`,模拟打字机效果。
**输入框聚焦**:边框绿色亮度提升(`opacity 0.25 → 0.6`),同时产生微弱的绿色外发光 `0 0 8px rgba(34,197,94,0.15)`,`150ms ease`。
**按钮 Hover**:背景色从 `#22c55e` 变为 `#4ade80`(更亮),同时按钮产生 `box-shadow: 0 0 16px rgba(34,197,94,0.3)` 绿光晕染,`150ms ease`。
**光标闪烁**:
```css
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
/* animation: blink 1.06s step-end infinite */
```
使用 `step-end` 时序函数实现硬切闪烁,更接近真实终端光标。
**错误状态**:终端风格的错误提示——输入框边框变为 `#ef4444`,下方显示红色文字 `[ERROR] 用户名或密码不正确`,字体 `JetBrains Mono`,`12px`。无 shake 动画,用冷静的错误信息替代。
#### 代码实现要点
1. **字符雨 Canvas**:独立的 `MatrixRain.tsx` 组件。核心逻辑:创建 `columns` 数组,每列跟踪当前字符和 y 坐标。在 `requestAnimationFrame` 循环中,以半透明黑色 (`rgba(0,0,0,0.05)`) 覆盖画布而非完全清除,形成字符拖尾效果。字符从预定义字符集中随机选取。
2. **等宽字体**:通过 Google Fonts 或本地引入 JetBrains Mono。Tailwind 配置中添加 `fontFamily: { mono: ['JetBrains Mono', 'monospace'] }`。
3. **打字机入场**:使用自定义 hook `useTypewriterEffect`,控制子元素依次显示。或直接使用 `setTimeout` 链实现简单的延迟序列。
4. **终端光标**:使用一个 `span` 元素(`w-[2px] h-[1.2em] bg-green-500 inline-block`)配合 CSS 动画实现闪烁。可封装为 `TerminalCursor` 小组件。
5. **密码输入**:密码框仍使用 `type="password"`,但自定义样式使圆点/星号也呈现绿色(通过 `color: #22c55e` 和 `caret-color: #22c55e`)。
---
### 3.5 方案五「自然流动」— 有机渐变自然风
#### 设计概念与灵感来源
并非所有企业都喜欢冷峻的科技风。「自然流动」方案面向创意公司、设计团队和文化企业,从自然界的极光、晨曦、水波中汲取配色灵感。三个巨大的模糊色块在背景中缓慢漂移、交叠,产生不断变化的有机渐变——这种"活着的"背景打破了登录页的单调,将等待时间转化为一种视觉享受。设计参考了 Linear、Arc Browser 等产品的有机渐变美学,以及 Blobs 设计趋势。
#### 适用场景
创意设计公司、数字营销机构、文化传媒企业、生活方式品牌、内部创意工具。用户群体包括设计师、创意总监、市场人员,他们对视觉美感有较高要求,偏好温暖、有情感的界面。
#### 视觉规范
| 元素 | 规范值 | 说明 |
|------|--------|------|
| 背景底色 | `#0f0e1a` | 深紫黑,衬托前景彩色光晕 |
| 渐变圆 1 | `#f472b6`(粉色),直径 `600px`,blur `120px` | 左上区域 |
| 渐变圆 2 | `#a78bfa`(紫色),直径 `500px`,blur `120px` | 右侧中部 |
| 渐变圆 3 | `#60a5fa`(蓝色),直径 `550px`,blur `120px` | 底部中央 |
| 模糊方式 | CSS `filter: blur(120px)` 或 SVG `feGaussianBlur` | 产生柔和的色块融合 |
| 卡片背景 | `rgba(255,255,255,0.1)` | 半透明白 |
| 卡片毛玻璃 | `backdrop-blur: 24px` | 更强的模糊,突出背后的色彩流动 |
| 卡片圆角 | `24px` | 有机的大圆角 |
| 卡片边框 | `1px solid rgba(255,255,255,0.15)` | 半透明白色边框 |
| 卡片内边距 | `48px 40px` | 宽裕的空间 |
| 品牌图标 | `64px` 大圆角矩形,`border-radius: 20px` | 渐变背景 `from #f472b6 to #a78bfa` |
| 品牌名 | "DataGuard",`22px`,白色,字重 600 | 居中展示 |
| 品牌标语 | "数据之美,流动不息",`14px`,`rgba(255,255,255,0.6)` | 诗意化标语 |
| 输入框背景 | `rgba(255,255,255,0.08)` | 毛玻璃输入 |
| 输入框边框 | `1px solid rgba(255,255,255,0.12)` | 微妙的边框 |
| 输入框圆角 | `14px` | 圆润 |
| 输入框高度 | `46px` | 舒适点击高度 |
| 输入框文字 | 白色,`rgba(255,255,255,0.9)` | 高对比度 |
| 输入框聚焦 | 边框变为 `rgba(255,255,255,0.4)` | 白色高亮 |
| 按钮背景 | `#ffffff` | 白色主按钮 |
| 按钮文字 | `#0f0e1a`,字重 600 | 深色文字 |
| 按钮圆角 | `12px` | 现代圆角 |
| 按钮高度 | `46px` | 与输入框等高 |
| 卡片外光晕 | `box-shadow: 0 0 80px rgba(167,139,250,0.15)` | 微妙的紫色光晕包裹卡片 |
#### 动效规范
**渐变圆漂移**:三个彩色模糊圆以不同的速度和轨迹缓慢移动,形成不断变化的背景:
- 粉圆:`translate` 在 `(0,0) → (30px,-20px) → (-20px,30px) → (0,0)` 之间循环,`15s ease-in-out infinite`
- 紫圆:`translate` 在 `(0,0) → (-25px,25px) → (20px,-15px) → (0,0)`,`18s ease-in-out infinite`
- 蓝圆:`translate` 在 `(0,0) → (20px,20px) → (-30px,-10px) → (0,0)`,`12s ease-in-out infinite`
三个圆的运动周期互质(15s/18s/12s),确保组合图案永不重复。
**卡片入场**:`opacity 0→1`、`translateY(24px)→0`、`scale(0.96)→1`,`700ms cubic-bezier(0.22, 1, 0.36, 1)`。较长的时间和柔和的缓动,配合背景的流动感,营造"浮出水面"的诗意体验。
**输入框聚焦**:边框透明度提升,同时产生微弱的白色外发光 `0 0 0 3px rgba(255,255,255,0.08)`,`200ms ease`。
**按钮 Hover**:`scale(1.02)` 微放大(而非上移,更柔和),`box-shadow` 增强为 `0 8px 24px rgba(255,255,255,0.2)`,过渡 `200ms ease`。白色按钮在暗色背景下产生"发光"效果。
**光晕脉动**:卡片外层的紫色光晕以 `4s ease-in-out infinite` 的节奏在 `opacity 0.1 ↔ 0.2` 之间呼吸,赋予页面生命力。
#### 代码实现要点
1. **SVG 渐变网格**:使用绝对定位的 `div` 元素包裹三个 `div`(每个设置 `border-radius: 50%`、`filter: blur(120px)`、对应背景色),通过 CSS `@keyframes` 分别控制 `transform` 动画。或使用 SVG `<circle>` 配合 `<filter><feGaussianBlur>` 实现同样的模糊色块效果。
2. **强毛玻璃卡片**:`backdrop-blur-2xl bg-white/10 border border-white/15 rounded-3xl shadow-[0_0_80px_rgba(167,139,250,0.15)]`。注意 `backdrop-blur` 需要浏览器支持,可考虑添加 `-webkit-backdrop-blur` 前缀。
3. **白色主按钮**:在深色背景下,白色按钮天然具有高对比度。Hover 效果避免使用 `translateY`(上移动效偏科技感),改用 `scale(1.02)`(更柔和、有机)。
4. **性能优化**:`blur(120px)` 大模糊是 GPU 密集型操作。建议使用 `will-change: transform` 提示浏览器优化,同时确保动画元素使用 `transform` 而非 `top/left` 属性。在低性能设备上可通过 `@media (prefers-reduced-motion: reduce)` 关闭背景动画。
---
### 3.6 各方案组件设计要点(对比表)
| 设计维度 | 方案一「极光科技」 | 方案二「企业金融」 | 方案三「云端数据」 | 方案四「极简矩阵」 | 方案五「自然流动」 |
|:---------|:------------------|:------------------|:------------------|:------------------|:------------------|
| **布局结构** | 居中卡片 + 全屏粒子背景 | 左右分屏 60:40 | 居中卡片 + 浅色背景 | 居中卡片 + 全屏字符雨 | 居中卡片 + 动态渐变背景 |
| **背景实现** | Canvas 2D 粒子系统 | CSS 渐变 + 纯色分割 | CSS 渐变 + SVG 装饰 | Canvas 2D Matrix Rain | SVG/CSS 模糊渐变圆 |
| **卡片材质** | 毛玻璃 `backdrop-blur: 20px` | 纯白实色 | 纯白实色 | 半透明黑 `rgba(0,0,0,0.85)` | 强毛玻璃 `backdrop-blur: 24px` |
| **卡片圆角** | `16px` | `16px` | `20px` | `4px`(锐利) | `24px`(最大) |
| **卡片边框** | `1px solid rgba(255,255,255,0.08)` | 无 | 无 | `1px solid rgba(34,197,94,0.3)` | `1px solid rgba(255,255,255,0.15)` |
| **主色调** | 蓝 `#2563eb` + 紫 `#7c3aed` | 深蓝 `#1a365d` | 天蓝 `#0ea5e9` | 绿 `#22c55e` | 粉 `#f472b6` + 紫 `#a78bfa` + 蓝 `#60a5fa` |
| **背景色** | `#0a0e27` → `#1a1f4b` | 左 `#0c1a3c` 右 `#f8fafc` | `#f0f4f8` → `#e2e8f0` | `#000000` | `#0f0e1a` |
| **输入框样式** | 标准边框式 | 底部底线式 | 无边框 + 灰底 | 等宽字体 + 绿边框 | 毛玻璃半透明 |
| **按钮样式** | 渐变蓝紫 | 纯色深蓝 | 渐变天蓝 | 纯色绿 | 纯白 |
| **按钮圆角** | `10px` | `8px` | `12px` | `4px` | `12px` |
| **字体选择** | 系统默认 sans-serif | 系统默认 sans-serif | 系统默认 sans-serif | JetBrains Mono 等宽 | 系统默认 sans-serif |
| **SSO 支持** | 无 | 无 | 有(企业微信/钉钉/飞书) | 无 | 无 |
| **安全标识** | Shield 图标暗示 | SSL/权限/审计 明确列出 | 无 | 版本号信息 | 无 |
| **品牌展示** | 渐变圆 + Shield 图标 | 左侧大面积品牌区 | 顶部品牌图标 | 终端命令行风格 | 大圆角渐变图标 |
| **移动端适配** | 卡片全宽 + 边距 | 上下布局折叠 | 卡片全宽 | 卡片全宽 | 卡片全宽 |
### 3.7 各方案动效设计规范(汇总表)
| 动效维度 | 方案一「极光科技」 | 方案二「企业金融」 | 方案三「云端数据」 | 方案四「极简矩阵」 | 方案五「自然流动」 |
|:---------|:------------------|:------------------|:------------------|:------------------|:------------------|
| **背景动画** | 粒子连线 60fps | 无 | 云朵缓慢水平漂移 | 字符雨下落 | 三圆渐变漂移 |
| **入场动画** | 卡片淡入+上移 `600ms` | 左右滑入 `400ms` | 弹性下落 `500ms` | 打字机逐行 `80ms/行` | 柔和浮现 `700ms` |
| **入场缓动** | `cubic-bezier(0.16,1,0.3,1)` | `ease-out` | `cubic-bezier(0.34,1.56,0.64,1)` | `step-end` 风格 | `cubic-bezier(0.22,1,0.36,1)` |
| **输入框聚焦** | 蓝边框+光晕 `200ms` | 底线延展 `200ms` | 背景提亮+边框渐显 `150ms` | 绿边框亮度提升 `150ms` | 白边框透明度提升 `200ms` |
| **按钮 Hover** | 上移 `1px` + 阴影增强 | 背景加深 + 阴影 | 上移 `2px` + 渐变偏移 | 绿光晕染 + 背景变亮 | 缩放 `1.02` + 白影 |
| **错误反馈** | Shake 抖动 `300ms` | 边框变红 + 提示文字 | 边框变红 + 弹性抖动 | 红色终端错误信息 | 边框变红 + 光晕转红 |
| **特殊交互** | 鼠标粒子引力 | 信任指标依次淡入 | SSO 图标放大 | 光标闪烁 | 光晕呼吸脉动 |
| **动画性能** | `requestAnimationFrame` | CSS 过渡 | CSS 过渡/Framer Motion | `requestAnimationFrame` | CSS `transform` + `will-change` |
| **减弱动效** | 保留静态粒子 | 无需处理 | 保留静态布局 | 保留静态字符背景 | 保留静态模糊色块 |
---
#### 方案选型建议
五套方案并非"五选一"的关系,而是为不同的部署场景提供针对性选择:
- **对外技术产品**(如数据库管理工具、开发者平台)→ 方案一「极光科技」或方案四「极简矩阵」
- **金融/政企客户私有化部署** → 方案二「企业金融」
- **SaaS 订阅产品/云端服务** → 方案三「云端数据」
- **创意团队/设计公司内部系统** → 方案五「自然流动」
建议在系统设置中提供登录页主题切换功能,允许管理员根据企业 VI 选择合适的方案,或在不同的子域名/入口自动匹配不同风格(如 `dev.dataguard.com` 使用极客风,`finance.dataguard.com` 使用金融风)。所有方案共享 `useLogin` 和 `useTheme` hook,切换主题仅涉及 UI 层组件的替换,业务逻辑零改动。
@@ -1,741 +0,0 @@
## 4. 五套登录后主题设计方案
为满足不同用户群体在不同使用场景下的视觉偏好,本章为 SQL-Platform 设计五套完整的登录后主题方案。每套主题均包含完整的 CSS 变量规范、色彩心理学依据及典型应用场景说明。五套主题涵盖从专业沉稳到活力温暖的全方位视觉谱系,确保平台在各类企业环境中都能提供最佳的用户体验。
---
### 4.1 主题一「深蓝专业」— 默认深色增强版
**设计定位**:深蓝专业主题是在现有深色主题基础上的全面增强版本,定位为平台的默认主题。设计目标是在保持深色模式优势(降低眼部疲劳、突出内容)的同时,通过更纯正深邃的蓝色调提升整体品质感,并强化视觉层次与可读性。
**色彩设计理念**:该主题摒弃了原主题中偏灰的杂色倾向,将背景色收敛到更纯正的深蓝谱系。主背景色 `#070d19` 接近纯黑但带有微弱的蓝色相,营造出深邃而稳定的基底。次背景色 `#0f172a` 参考了 Slate 色板中最深沉的蓝色调,既保证了与主背景的层次区分,又维持了蓝色谱系的纯粹性。文字色彩采用 `#f8fafc`(极亮蓝白)作为主文字色,相比纯白色更具温润感,长时间阅读不易产生刺眼感。次文字色 `#94a3b8` 提供了充分的对比度,确保辅助信息的可读性。
**强调色体系**:蓝色系强调色选用 `#3b82f6`(Tailwind Blue 500),这是一个饱和度适中、辨识度高的蓝色,在深色背景上呈现明亮但不刺眼的视觉效果。状态色(成功、错误、警告)均经过深色背景适配,确保各色彩在深蓝底上都有足够的明度对比。
**适用场景**:作为默认主题,适用于绝大多数使用场景,特别适合技术团队日常操作、数据库管理、权限配置等需要长时间专注的工作界面。
```css
/* === 主题一:深蓝专业 ocean-deep === */
[data-theme="ocean-deep"] {
/* shadcn/ui 基础变量 */
--background: 222 47% 4%;
--foreground: 210 40% 98%;
--card: 222 47% 7%;
--card-foreground: 210 40% 98%;
--popover: 222 47% 7%;
--popover-foreground: 210 40% 98%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 217 91% 60%;
--radius: 0.5rem;
/* DataGuard 自定义变量 */
--dg-bg-primary: #070d19;
--dg-bg-secondary: #0f172a;
--dg-bg-tertiary: #172033;
--dg-bg-hover: #233049;
--dg-border: #1e293b;
--dg-border-light: #172033;
--dg-text-primary: #f8fafc;
--dg-text-secondary: #94a3b8;
--dg-text-muted: #64748b;
--dg-blue: #3b82f6;
--dg-green: #22c55e;
--dg-red: #ef4444;
--dg-orange: #f97316;
--dg-purple: #8b5cf6;
--dg-cyan: #06b6d4;
--dg-yellow: #eab308;
--dg-gradient-start: #3b82f6;
--dg-gradient-end: #2563eb;
}
```
---
### 4.2 主题二「浅色商务」— 企业办公风
**设计定位**:浅色商务主题面向白天办公环境优化,采用温暖的灰白色调替代常见的冷白背景,营造舒适、专业且不刺眼的工作界面。该主题适合不习惯深色模式的用户,或在明亮办公室环境中使用。
**色彩设计理念**:背景色摒弃了纯白的冷峻感,主背景色 `#fafaf9` 选用带有微弱暖色相的" zinc white",在显示器上呈现柔和的纸质感。次背景色 `#f5f5f4` 和三级背景色 `#e7e5e4` 构成自然的层次递进,避免了传统浅色主题中背景层次过于平淡的问题。边框色 `#d6d3d1` 同样带有暖色调,使界面分割更加自然和谐。文字色彩方面,主文字色 `#1c1917` 是带有微弱棕色相的深黑色,相比纯黑在暖白背景上对比度更舒适,避免了"黑字白纸"的刺眼感。
**强调色体系**:强调色选用稳重的 `#2563eb`(Tailwind Blue 600),这是一个偏深的标准蓝色,传达专业、可信赖的企业形象。状态色均调整为适合浅色背景的版本,成功绿 `#16a34a`、错误红 `#dc2626`、警告橙 `#ca8a04` 在浅色背景上均保持良好的可读性。
**适用场景**:适合白天办公环境、管理人员日常审批查看、客户演示投屏等场景。浅色界面在投影设备上的显示效果通常优于深色界面,因此该主题也是会议室演示的首选。
```css
/* === 主题二:浅色商务 light-professional === */
[data-theme="light-professional"] {
/* shadcn/ui 基础变量 */
--background: 40 9% 98%;
--foreground: 24 10% 10%;
--card: 40 9% 96%;
--card-foreground: 24 10% 10%;
--popover: 40 9% 96%;
--popover-foreground: 24 10% 10%;
--primary: 221 83% 53%;
--primary-foreground: 0 0% 100%;
--secondary: 40 6% 90%;
--secondary-foreground: 24 10% 10%;
--muted: 40 6% 90%;
--muted-foreground: 25 5% 45%;
--accent: 40 6% 90%;
--accent-foreground: 24 10% 10%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 24 6% 83%;
--input: 24 6% 83%;
--ring: 221 83% 53%;
--radius: 0.5rem;
/* DataGuard 自定义变量 */
--dg-bg-primary: #fafaf9;
--dg-bg-secondary: #f5f5f4;
--dg-bg-tertiary: #e7e5e4;
--dg-bg-hover: #d6d3d1;
--dg-border: #d6d3d1;
--dg-border-light: #e7e5e4;
--dg-text-primary: #1c1917;
--dg-text-secondary: #78716c;
--dg-text-muted: #a8a29e;
--dg-blue: #2563eb;
--dg-green: #16a34a;
--dg-red: #dc2626;
--dg-orange: #ea580c;
--dg-purple: #7c3aed;
--dg-cyan: #0891b2;
--dg-yellow: #ca8a04;
--dg-gradient-start: #2563eb;
--dg-gradient-end: #4f46e5;
}
```
---
### 4.3 主题三「午夜紫韵」— 高端紫色调
**设计定位**:午夜紫韵主题以深紫黑色为基底,搭配明亮的紫色渐变强调色,营造高端、神秘且富有科技感的视觉氛围。该主题适合作为给客户演示时的展示主题,能够传达产品的技术领先性和品质感。
**色彩设计理念**:主背景色 `#0a0612` 是带有明显紫色相的极深色,接近"午夜黑"的概念,为整个界面奠定了神秘而高贵的基调。次背景色 `#130c21` 和三级背景色 `#1c1330` 沿紫色谱系逐步提亮,形成清晰的层次结构。悬停背景色 `#2d1f4a` 是一个中等深度的紫色,在交互时能够产生明显的视觉反馈,同时保持整体色调的一致性。文字色彩方面,主文字色 `#faf5ff` 带有微弱的紫色调,使文字在深色背景上更加柔和雅致。
**强调色体系**:该主题的核心亮点在于紫色渐变强调体系。渐变起点 `#9333ea`(Tailwind Purple 600)到渐变终点 `#c084fc`(Tailwind Purple 400)构成了一段明亮且富有活力的紫色渐变,用于品牌标识、主要按钮和重要数据高亮。蓝色调整为偏紫的靛蓝色 `#818cf8`,绿色调整为翠绿色 `#34d399`,红色调整为玫红色 `#fb7185`,所有状态色都经过紫色调环境的协调适配。
**适用场景**:产品发布会演示、客户汇报展示、高端企业部署、技术峰会展台等需要彰显产品品质的正式场合。
```css
/* === 主题三:午夜紫韵 midnight-purple === */
[data-theme="midnight-purple"] {
/* shadcn/ui 基础变量 */
--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.5rem;
/* DataGuard 自定义变量 */
--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-gradient-start: #9333ea;
--dg-gradient-end: #c084fc;
}
```
---
### 4.4 主题四「松绿自然」— 护眼绿调
**设计定位**:松绿自然主题面向长时间使用平台的开发团队设计,以墨绿色为基调,利用绿色光谱对人眼最为友好的特性,最大程度降低长时间注视屏幕带来的视觉疲劳。
**色彩设计理念**:该主题的核心设计原则是"护眼优先"。主背景色 `#0a1210` 是带有明显绿色相的极深色,类似深夜松林深处的颜色,极低明度确保了基础视觉负荷的最小化。次背景色 `#111f1a`(深墨绿)和三级背景色 `#192c24`(墨绿灰)沿绿色谱系递进,在保持低明度的同时构建清晰的视觉层次。悬停背景色 `#253d32` 选用了中等深度的松绿色,交互时呈现自然、柔和的状态变化。边框色 `#253d32` 与悬停色共用,确保交互反馈与界面分割的视觉统一。主文字色 `#f0fdf4` 是极淡的薄荷白,在墨绿背景上明亮而不刺眼,具有极佳的阅读舒适度。
**强调色体系**:绿色系强调色选用 `#4ade80`(Tailwind Green 400),这是一个明亮、清新的绿色,在墨绿暗底上如同松针上的晨露般醒目。信息色 `#2dd4bf` 偏向青绿色,与主色调完美协调。蓝色调整为天蓝色 `#38bdf8`,在绿调环境中起到色彩平衡的作用,避免界面过于单调。
**适用场景**:开发团队日常长时间使用、DBA 持续监控数据库状态、运维人员驻屏值班等需要长时间面对屏幕的工作场景。
```css
/* === 主题四:松绿自然 pine-green === */
[data-theme="pine-green"] {
/* shadcn/ui 基础变量 */
--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.5rem;
/* DataGuard 自定义变量 */
--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-gradient-start: #059669;
--dg-gradient-end: #10b981;
}
```
---
### 4.5 主题五「橙光活力」— 活力暖色调
**设计定位**:橙光活力主题以深棕暗橙为基底,搭配明亮的橙色渐变,传递温暖、积极、富有活力的品牌情绪。该主题面向年轻化团队和创新型企业,打破传统数据安全平台冰冷刻板的印象。
**色彩设计理念**:主背景色 `#110d08` 是带有明显棕色相的极深色,类似深烘焙咖啡豆的颜色,温暖而沉稳。次背景色 `#1d1610` 和三级背景色 `#2b1f16` 沿棕色谱系递进,营造出如同暖色调咖啡馆般的舒适感。悬停背景色 `#3d2d20` 是一个中等深度的暖棕色,交互反馈如同木材被温暖光线照亮。文字色彩方面,主文字色 `#fef7ed` 是极淡的暖白色,如同烛光下的纸张,在暗棕底上既明亮又温暖。
**强调色体系**:核心渐变从 `#ea580c`(Tailwind Orange 600)到 `#f59e0b`(Tailwind Amber 500),这是一段充满能量感的橙黄渐变,用于重要按钮、品牌标识和数据高亮时极具视觉冲击力。蓝色调整为柔和的 `#60a5fa`,在暖色调环境中起到中和平衡的作用。紫色调整为偏暖的 `#a78bfa`,与整体暖调和谐共存。
**适用场景**:互联网创业公司、年轻化的技术团队、创意型企业、产品内部分享会等追求活力与创新氛围的使用场景。
```css
/* === 主题五:橙光活力 orange-vitality === */
[data-theme="orange-vitality"] {
/* shadcn/ui 基础变量 */
--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.5rem;
/* DataGuard 自定义变量 */
--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-gradient-start: #ea580c;
--dg-gradient-end: #f59e0b;
}
```
---
### 4.6 各主题 CSS 变量规范对比表
下表汇总了五套主题的全部 DataGuard 自定义 CSS 变量值,便于开发实现时快速查阅与对比。
| CSS 变量 | 深蓝专业 | 浅色商务 | 午夜紫韵 | 松绿自然 | 橙光活力 |
|:---|:---|:---|:---|:---|:---|
| `--dg-bg-primary` | `#070d19` | `#fafaf9` | `#0a0612` | `#0a1210` | `#110d08` |
| `--dg-bg-secondary` | `#0f172a` | `#f5f5f4` | `#130c21` | `#111f1a` | `#1d1610` |
| `--dg-bg-tertiary` | `#172033` | `#e7e5e4` | `#1c1330` | `#192c24` | `#2b1f16` |
| `--dg-bg-hover` | `#233049` | `#d6d3d1` | `#2d1f4a` | `#253d32` | `#3d2d20` |
| `--dg-border` | `#1e293b` | `#d6d3d1` | `#2d1f4a` | `#253d32` | `#3d2d20` |
| `--dg-border-light` | `#172033` | `#e7e5e4` | `#1c1330` | `#192c24` | `#2b1f16` |
| `--dg-text-primary` | `#f8fafc` | `#1c1917` | `#faf5ff` | `#f0fdf4` | `#fef7ed` |
| `--dg-text-secondary` | `#94a3b8` | `#78716c` | `#b794c7` | `#86b8a0` | `#d4a574` |
| `--dg-text-muted` | `#64748b` | `#a8a29e` | `#7c5a8a` | `#5a7c6b` | `#9c7a54` |
| `--dg-blue` | `#3b82f6` | `#2563eb` | `#818cf8` | `#38bdf8` | `#60a5fa` |
| `--dg-green` | `#22c55e` | `#16a34a` | `#34d399` | `#4ade80` | `#4ade80` |
| `--dg-red` | `#ef4444` | `#dc2626` | `#fb7185` | `#f87171` | `#f87171` |
| `--dg-orange` | `#f97316` | `#ea580c` | `#fb923c` | `#fb923c` | `#fb923c` |
| `--dg-purple` | `#8b5cf6` | `#7c3aed` | `#c084fc` | `#c084fc` | `#a78bfa` |
| `--dg-cyan` | `#06b6d4` | `#0891b2` | `#22d3ee` | `#2dd4bf` | `#2dd4bf` |
| `--dg-yellow` | `#eab308` | `#ca8a04` | `#facc15` | `#facc15` | `#fcd34d` |
| `--dg-gradient-start` | `#3b82f6` | `#2563eb` | `#9333ea` | `#059669` | `#ea580c` |
| `--dg-gradient-end` | `#2563eb` | `#4f46e5` | `#c084fc` | `#10b981` | `#f59e0b` |
**shadcn/ui 基础变量对比表**
| HSL 变量 | 深蓝专业 | 浅色商务 | 午夜紫韵 | 松绿自然 | 橙光活力 |
|:---|:---|:---|:---|:---|:---|
| `--background` | `222 47% 4%` | `40 9% 98%` | `264 56% 4%` | `150 25% 5%` | `30 35% 5%` |
| `--foreground` | `210 40% 98%` | `24 10% 10%` | `280 100% 98%` | `140 60% 97%` | `30 100% 97%` |
| `--card` | `222 47% 7%` | `40 9% 96%` | `264 48% 8%` | `150 25% 9%` | `30 30% 9%` |
| `--primary` | `217 91% 60%` | `221 83% 53%` | `270 91% 65%` | `142 71% 45%` | `25 95% 53%` |
| `--secondary` | `217 33% 17%` | `40 6% 90%` | `264 40% 20%` | `150 20% 18%` | `30 25% 18%` |
| `--muted` | `217 33% 17%` | `40 6% 90%` | `264 40% 20%` | `150 20% 18%` | `30 25% 18%` |
| `--muted-foreground` | `215 20% 65%` | `25 5% 45%` | `280 30% 60%` | `150 20% 55%` | `30 25% 55%` |
| `--destructive` | `0 84% 60%` | `0 72% 51%` | `348 100% 72%` | `0 90% 72%` | `0 80% 70%` |
| `--border` | `217 33% 17%` | `24 6% 83%` | `264 40% 20%` | `150 20% 18%` | `30 25% 18%` |
| `--ring` | `217 91% 60%` | `221 83% 53%` | `270 91% 65%` | `142 71% 45%` | `25 95% 53%` |
---
### 4.7 主题切换实现方案
#### 4.7.1 CSS 变量组织方式
五套主题的 CSS 变量采用 **"分散定义、统一命名空间"** 的组织策略。每套主题的所有变量定义在一个独立的 `data-theme` 属性选择器下,避免变量冲突和覆盖混乱。
**文件结构建议**:
```
frontend/src/styles/
├── themes/
│ ├── index.css /* 主题入口文件,导入所有主题 */
│ ├── ocean-deep.css /* 主题一:深蓝专业 */
│ ├── light-professional.css /* 主题二:浅色商务 */
│ ├── midnight-purple.css /* 主题三:午夜紫韵 */
│ ├── pine-green.css /* 主题四:松绿自然 */
│ └── orange-vitality.css /* 主题五:橙光活力 */
└── index.css /* 现有主样式文件 */
```
**入口文件 `themes/index.css` 内容**:
```css
/* ===== SQL-Platform 主题系统入口 ===== */
/* 按加载顺序:默认主题(深蓝专业)无需 data-theme 属性 */
@import "./ocean-deep.css";
@import "./light-professional.css";
@import "./midnight-purple.css";
@import "./pine-green.css";
@import "./orange-vitality.css";
```
> **实现说明**:`ocean-deep.css` 同时提供 `:root` 级别的默认变量(确保无 `data-theme` 属性时回退到默认主题)和 `[data-theme="ocean-deep"]` 选择器下的显式定义,保证其他主题切换回来时变量能正确覆盖。
#### 4.7.2 useTheme Hook 增强实现
现有的 `useTheme` hook 需要从双值切换(dark/light)扩展为多主题选择。以下是增强后的实现方案:
```typescript
// frontend/src/hooks/useTheme.ts
import { useState, useEffect, useCallback } from 'react';
/** 支持的主题标识列表 */
export const THEMES = [
{ id: 'ocean-deep', label: '深蓝专业', description: '默认深色增强版' },
{ id: 'light-professional', label: '浅色商务', description: '企业办公风' },
{ id: 'midnight-purple', label: '午夜紫韵', description: '高端紫色调' },
{ id: 'pine-green', label: '松绿自然', description: '护眼绿调' },
{ id: 'orange-vitality', label: '橙光活力', description: '活力暖色调' },
] as const;
export type ThemeId = typeof THEMES[number]['id'];
const STORAGE_KEY = 'sqlp-theme-id';
const DEFAULT_THEME: ThemeId = 'ocean-deep';
/**
* 从 localStorage 读取保存的主题
*/
function getSavedTheme(): ThemeId {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved && THEMES.some(t => t.id === saved)) {
return saved as ThemeId;
}
} catch {
// localStorage 不可用时忽略
}
return DEFAULT_THEME;
}
/**
* 检测系统色彩偏好
*/
function getSystemPreference(): 'dark' | 'light' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* 根据系统偏好映射默认主题
*/
function getDefaultBySystem(): ThemeId {
return getSystemPreference() === 'dark' ? 'ocean-deep' : 'light-professional';
}
export function useTheme() {
const [theme, setThemeState] = useState<ThemeId>(() => {
const saved = getSavedTheme();
return saved || getDefaultBySystem();
});
/**
* 切换主题的核心方法
*/
const setTheme = useCallback((newTheme: ThemeId) => {
setThemeState(newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
try {
localStorage.setItem(STORAGE_KEY, newTheme);
} catch {
// localStorage 不可用时静默处理
}
}, []);
/**
* 初始化主题:首次加载时应用
*/
useEffect(() => {
const saved = getSavedTheme();
const initial = saved || getDefaultBySystem();
document.documentElement.setAttribute('data-theme', initial);
setThemeState(initial);
}, []);
/**
* 监听系统主题变化,仅在用户未手动选择主题时自动切换
*/
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
// 检查用户是否已手动设置过主题
const hasUserPreference = localStorage.getItem(STORAGE_KEY) !== null;
if (!hasUserPreference) {
const newTheme = e.matches ? 'ocean-deep' : 'light-professional';
setTheme(newTheme);
}
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [setTheme]);
return { theme, setTheme, themes: THEMES };
}
```
#### 4.7.3 主题预览组件设计
AppearanceSettings 页面需新增主题选择区域,包含五张主题预览卡片。每张卡片展示主题的核心色彩样本和简要描述。
```typescript
// frontend/src/components/settings/ThemePreviewCard.tsx
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ThemeId } from '@/hooks/useTheme';
interface ThemePreviewCardProps {
themeId: ThemeId;
label: string;
description: string;
colors: {
bg: string;
secondary: string;
accent: string;
text: string;
};
isActive: boolean;
onClick: () => void;
}
export function ThemePreviewCard({
label,
description,
colors,
isActive,
onClick,
}: ThemePreviewCardProps) {
return (
<button
onClick={onClick}
className={cn(
"relative flex flex-col gap-2 rounded-lg border-2 p-3 transition-all duration-200",
"hover:shadow-md hover:-translate-y-0.5",
isActive
? "border-dg-blue ring-2 ring-dg-blue/20"
: "border-dg-border hover:border-dg-border-light"
)}
aria-pressed={isActive}
>
{/* 预览色块区域 */}
<div
className="h-16 w-full rounded-md flex items-end justify-end p-1.5 gap-1"
style={{ backgroundColor: colors.bg }}
>
<div
className="w-4 h-4 rounded-sm"
style={{ backgroundColor: colors.secondary }}
/>
<div
className="w-4 h-4 rounded-sm"
style={{ backgroundColor: colors.accent }}
/>
<div
className="w-4 h-4 rounded-sm"
style={{ backgroundColor: colors.text }}
/>
</div>
{/* 文字信息 */}
<div className="text-left">
<p className="text-sm font-medium text-dg-text-primary">{label}</p>
<p className="text-xs text-dg-text-muted">{description}</p>
</div>
{/* 选中标记 */}
{isActive && (
<div className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-dg-blue rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
)}
</button>
);
}
```
**AppearanceSettings 页面中的主题选择区域布局**:
```tsx
// 在 AppearanceSettings 页面中使用
import { useTheme, THEMES } from '@/hooks/useTheme';
import { ThemePreviewCard } from '@/components/settings/ThemePreviewCard';
// 各主题预览色值配置
const THEME_PREVIEW_COLORS: Record<string, { bg: string; secondary: string; accent: string; text: string }> = {
'ocean-deep': { bg: '#070d19', secondary: '#0f172a', accent: '#3b82f6', text: '#f8fafc' },
'light-professional': { bg: '#fafaf9', secondary: '#f5f5f4', accent: '#2563eb', text: '#1c1917' },
'midnight-purple': { bg: '#0a0612', secondary: '#130c21', accent: '#c084fc', text: '#faf5ff' },
'pine-green': { bg: '#0a1210', secondary: '#111f1a', accent: '#4ade80', text: '#f0fdf4' },
'orange-vitality': { bg: '#110d08', secondary: '#1d1610', accent: '#fb923c', text: '#fef7ed' },
};
function ThemeSelector() {
const { theme: activeTheme, setTheme } = useTheme();
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-dg-text-primary">界面主题</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{THEMES.map((t) => (
<ThemePreviewCard
key={t.id}
themeId={t.id}
label={t.label}
description={t.description}
colors={THEME_PREVIEW_COLORS[t.id]}
isActive={activeTheme === t.id}
onClick={() => setTheme(t.id)}
/>
))}
</div>
</div>
);
}
```
#### 4.7.4 localStorage 持久化方案
主题持久化遵循以下策略:
| 策略项 | 说明 |
|:---|:---|
| **存储键名** | `sqlp-theme-id`,与现有 `theme` 键分离,避免升级冲突 |
| **存储值** | 主题标识字符串,如 `ocean-deep`、`light-professional` |
| **回退逻辑** | 读取不到有效值时,检测 `prefers-color-scheme` 系统偏好,暗色默认 `ocean-deep`,亮色默认 `light-professional` |
| **版本兼容** | 首次升级时,若检测到旧版 `theme: "dark"` 键,自动迁移至 `ocean-deep`;`theme: "light"` 迁移至 `light-professional`,随后删除旧键 |
| **异常处理** | localStorage 不可用时(隐私模式、存储已满),降级为仅内存状态,不抛出错误 |
| **跨设备同步** | 通过 `storage` 事件监听同源其他标签页的主题变更,实现多标签页同步: |
```typescript
/**
* 跨标签页主题同步
*/
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === STORAGE_KEY && e.newValue) {
const newTheme = e.newValue as ThemeId;
if (THEMES.some(t => t.id === newTheme)) {
setThemeState(newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
}
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, []);
```
#### 4.7.5 系统主题偏好检测
平台需响应操作系统的 `prefers-color-scheme` 媒体查询,提供智能化的默认主题推荐。
**检测逻辑**:
```typescript
// 检测系统暗色/浅色偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
```
**偏好映射规则**:
| 系统偏好 | 默认推荐主题 | 推荐逻辑 |
|:---|:---|:---|
| `prefers-color-scheme: dark` | **深蓝专业** (`ocean-deep`) | 暗色系统偏好用户通常偏好深色界面 |
| `prefers-color-scheme: light` | **浅色商务** (`light-professional`) | 亮色系统偏好用户通常偏好浅色界面 |
| 未指定(no-preference) | **深蓝专业** (`ocean-deep`) | 默认采用平台默认主题 |
**动态响应机制**:
```typescript
/**
* 系统主题偏好动态响应
* 仅在用户未手动设置主题时自动跟随系统
*/
useEffect(() => {
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
const lightQuery = window.matchMedia('(prefers-color-scheme: light)');
const handleChange = () => {
const hasUserPreference = localStorage.getItem(STORAGE_KEY) !== null;
if (hasUserPreference) return; // 用户已手动选择,不自动切换
if (darkQuery.matches) {
setTheme('ocean-deep');
} else if (lightQuery.matches) {
setTheme('light-professional');
}
};
darkQuery.addEventListener('change', handleChange);
lightQuery.addEventListener('change', handleChange);
return () => {
darkQuery.removeEventListener('change', handleChange);
lightQuery.removeEventListener('change', handleChange);
};
}, [setTheme]);
```
**重要原则**:系统偏好仅影响**首次访问**或**清除 localStorage 后**的默认主题选择。一旦用户手动选择了主题,该选择将被持久化并优先于系统偏好,避免在用户主动选择后因系统时间变化(如 macOS 自动切换暗色模式)导致界面意外变更。
#### 4.7.6 切换过渡动效
主题切换时应提供平滑的视觉过渡,避免色彩突变带来的不适感。
```css
/* 全局主题过渡效果 —— 添加到 index.css */
html {
/* 对所有 CSS 变量变化应用 200ms 缓动过渡 */
transition: background-color 200ms ease, color 200ms ease;
}
html *, html *::before, html *::after {
/* 谨慎使用:仅对关键属性过渡,避免性能问题 */
transition: background-color 200ms ease,
border-color 200ms ease,
color 150ms ease;
}
/* 可选:切换瞬间的淡入效果 */
[data-theme] {
animation: themeFadeIn 200ms ease;
}
@keyframes themeFadeIn {
from { opacity: 0.92; }
to { opacity: 1; }
}
```
**性能优化建议**:全局 `*` 过渡规则可能影响大型列表渲染性能。生产环境中建议仅对需要过渡的元素添加过渡规则,或通过 JavaScript 在切换主题时临时添加过渡 class,切换完成后再移除:
```typescript
/**
* 带过渡动画的主题切换
*/
const setThemeWithTransition = useCallback((newTheme: ThemeId) => {
const root = document.documentElement;
root.classList.add('theme-transitioning');
setTheme(newTheme);
// 300ms 后移除过渡 class
setTimeout(() => {
root.classList.remove('theme-transitioning');
}, 300);
}, [setTheme]);
```
```css
/* 仅在切换时启用过渡 */
html.theme-transitioning,
html.theme-transitioning * {
transition: background-color 200ms ease,
border-color 200ms ease,
color 150ms ease !important;
}
```
以上实现方案确保五套主题能够稳定、高效、优雅地在平台中运行,为用户提供丰富的个性化选择。
@@ -1,533 +0,0 @@
## 5. 核心功能页面优化方案
本章针对 SQL-Platform 三个核心功能模块——审批变更(工单系统)、查询数据、系统设置——进行逐页拆解分析,提出可落地的 UI/UX 优化方案。每个方案均包含具体的组件选型、布局参数、交互细节与视觉规范,确保开发团队能够直接转化为实现任务。
---
### 5.1 审批变更工单系统优化
审批变更是平台的核心业务流,涉及工单列表、工单创建、工单详情三个主要页面。当前实现存在布局松散、信息层级不清、操作流程割裂等问题。以下按页面逐一给出优化方案。
#### 5.1.1 工单列表页优化
**组件选型与布局重构**
将原生 `<table>` 全面替换为 `@tanstack/react-table` + `shadcn/ui DataTable` 组合。DataTable 内置列排序、列过滤、行选择、分页等能力,可显著减少 742 行中的手写逻辑。表格容器采用 `rounded-xl border border-border/60 bg-card` 样式,阴影参数为 `shadow-sm`。
**状态卡片升级**
原状态卡片(全部/待审批/执行中/完成/失败)改为 Stats Cards。每张卡片包含:状态图标(`Lucide`)、工单数量(`text-2xl font-bold`)、环比趋势箭头(绿色上升/红色下降)、环比百分比(`text-xs text-muted-foreground`)。卡片使用 `cn()` 动态绑定激活状态的边框色——激活时边框为 `ring-2 ring-primary/40 border-primary`,未激活为 `border-transparent hover:border-border/80`。卡片网格采用 `grid grid-cols-5 gap-4`,在 < 1024px 下退化为 `grid-cols-3` + `grid-cols-2`。
**高级筛选栏**
在状态卡片下方新增折叠式筛选栏,包含以下控件:
| 筛选项 | 组件 | 说明 |
|---------|------|------|
| 实例 | `Select`(多选) | 从实例列表异步加载 |
| 数据库类型 | `Select`(多选) | MySQL/PostgreSQL/MongoDB 等 |
| 提交人 | `Input` + `Avatar` | 支持用户名/邮箱搜索 |
| 时间范围 | `DateRangePicker` | shadcn/ui 官方组件 |
| 优先级 | `ToggleGroup` | 高/中/低三态切换 |
筛选栏默认折叠,点击"高级筛选"按钮展开,展开高度动画 `duration-300 ease-in-out`。所有筛选条件通过 URL query params 同步,支持刷新后状态保持。
**表格交互增强**
- **优先级标识**:每行左侧增加 4px 宽色条,高=`bg-red-500`、中=`bg-amber-500`、低=`bg-slate-400`,使用绝对定位贴左边框。
- **行 Hover 效果**:`hover:bg-muted/60`,同时左侧出现 `border-l-2 border-primary` 指示条,过渡 `transition-colors duration-150`。
- **操作列优化**:将分散的查看/通过/拒绝/执行/撤销按钮收缩为图标按钮组(`Eye`, `CheckCircle`, `XCircle`, `Play`, `RotateCcw`)。当操作超过 3 个时,溢出部分收入 `DropdownMenu`。按钮使用 `variant="ghost" size="icon"`。
- **批量操作**:顶部新增批量操作栏,选中行后出现(`AnimatePresence` 滑入),包含"批量审批"、"批量拒绝"、"导出选中"三个主操作,危险操作使用 `variant="destructive"`。
- **列自定义**:列头右键菜单支持"隐藏此列"、"重置列宽",列宽拖拽使用 `react-resizable` 实现,拖拽手柄 4px 宽,hover 时显示 `bg-primary/30`。
**搜索与分页**
搜索框使用 `Input` + `Search` 图标,支持 `Cmd+K` 快捷键聚焦。分页组件采用 `shadcn/ui Pagination`,页码按钮尺寸 `h-9 w-9`,当前页 `bg-primary text-primary-foreground`。每页条数可选 10/25/50/100。
---
#### 5.1.2 工单创建页优化
**布局重构:左右分栏**
将当前 1192 行的单栏布局改为左右分栏:左侧编辑器区占 `w-3/5`(60%),右侧辅助信息面板占 `w-2/5`(40%),中间分隔线 `border-r border-border/60`。两栏均独立滚动,避免编辑器滚动时右侧信息丢失。分栏在 < 1280px 下退化为上下堆叠布局。
**步骤指示器(Step Indicator)**
在分栏顶部增加横向步骤条,共三步:
```
① 填写信息 ──→ ② SQL 审核 ──→ ③ 提交审批
```
步骤条使用 `shadcn/ui` 风格的自定义组件。已完成步骤圆圈 `bg-primary text-primary-foreground`,当前步骤 `bg-primary ring-4 ring-primary/20`,未到达步骤 `bg-muted text-muted-foreground`。连线使用 `h-0.5 w-16`,已完成段 `bg-primary`,未完成段 `bg-border`。步骤切换允许回退(已填写内容保留),但前进需通过当前步骤的验证(如 SQL 审核必须通过才能到第三步)。
**左侧编辑器区**
- **顶部工具栏**:实例选择 + 数据库选择使用 `Popover` + `Command` 组合(支持搜索筛选),定时执行使用 `Switch` + `DateTimePicker`,格式化按钮(`AlignLeft` 图标)使用 `variant="outline" size="sm"`。工具栏使用 `flex items-center gap-3 p-3 border-b border-border/60 bg-muted/30`。
- **SQL 编辑器**:保留 CodeMirror 6,新增配置:行号显示(`lineNumbers()`)、迷你地图(`@codemirror/view showMinimap` 或自定义 SVG 缩略图)、当前行高亮(`highlightActiveLineGutter()`)。编辑器高度 `h-[480px]`,边框 `border-0`(融入容器)。
- **SQL 模板快捷插入**:编辑器左下角增加"模板"按钮,点击弹出 `Popover`,内含分类模板列表(DDL/DML/查询),点击后 `editor.dispatch()` 插入模板 SQL。
**右侧辅助面板**
- **审核结果卡片**:使用三个独立卡片分别展示 Error(`bg-red-50 border-red-200`)、Warning(`bg-amber-50 border-amber-200`)、Info(`bg-blue-50 border-blue-200`)。每张卡片包含:数量 badge(`rounded-full px-2 py-0.5 text-xs font-medium`)、发现项列表(每项包含行号、规则名、描述)、展开/折叠按钮。Error 卡片默认展开,其他折叠。
- **变更原因输入**:将 `<textarea>` 替换为轻量级 Markdown 编辑器(`react-simplemde-editor` 或自定义实现),支持粗体、斜体、代码块、列表快捷按钮。高度 `min-h-[120px]`,占位符文本"请描述本次变更的业务原因和影响范围..."
- **工单预览**:在提交按钮左侧增加"预览"按钮,点击弹出 `Dialog`(宽 `max-w-3xl`),展示完整工单信息的只读视图,包括 SQL 摘要、变更原因、元数据。
- **自动保存草稿**:每 30 秒将表单状态序列化到 `localStorage`,键名为 `ticket_draft_${userId}`。页面加载时检测草稿,若有则提示"检测到未提交的草稿,是否恢复?"(`Toast` + 操作按钮)。提交成功后清除草稿。
---
#### 5.1.3 工单详情页优化
**头部 Hero Section**
将原头部改为 Hero Section 风格:
- 背景:`bg-gradient-to-r from-primary/5 via-primary/3 to-transparent`,底部边框 `border-b border-border/60`。
- 标题:`text-3xl font-bold tracking-tight`,状态 badge 紧跟标题右侧,badge 尺寸加大(`text-sm px-3 py-1`)。
- 元信息行:`flex gap-6 text-sm text-muted-foreground`,包含提交人(Avatar + 用户名)、提交时间(`Calendar` 图标)、实例名(`Database` 图标)、数据库(`Table` 图标)。
- 操作按钮组:头部右侧放置主操作按钮,审批通过用 `variant="default"`(`CheckCircle` 图标),拒绝用 `variant="destructive"`(`XCircle` 图标),执行用 `variant="outline"`(`Play` 图标)。操作按钮根据当前状态条件渲染(如已完成的工单不显示"通过"按钮)。
**审批流程 Timeline**
将原圆形+连线流程改为横向 Timeline:
```
[创建]──→[待审批]──→[已通过]──→[执行中]──→[已完成]
张三 李四 王五 系统 系统
10:23 14:05 16:30 16:31 16:33
```
- 节点形状:操作步骤使用圆形(`w-10 h-10 rounded-full`),判断步骤(如"审核通过?")使用菱形(CSS `rotate(45deg)` 实现,内部图标反向旋转)。
- 当前节点:`ring-4 ring-primary/30 bg-primary text-white`,带脉冲动画(`animate-pulse` 或自定义 CSS keyframes `scale 1→1.05→1`)。
- 已完成连线:`h-0.5 bg-primary`。
- 未完成连线:`h-0.5 bg-border border-dashed`。
- 鼠标悬停节点时,弹出 `Tooltip` 显示步骤详情(操作人、时间、备注)。
**内容区域**
- **SQL 内容区**:只读编辑器上方增加工具栏,包含"复制"(`Copy` 图标,点击后图标变为 `Check` 持续 2 秒)、"下载 SQL"(`Download` 图标)、"全屏查看"(`Maximize` 图标)。
- **执行结果 Dashboard**:将原本平铺的执行结果改为 Dashboard 风格,使用 `grid grid-cols-4 gap-4` 布局:
- 执行状态:大图标 + 状态文字,成功 `text-green-600`、失败 `text-red-600`。
- 影响行数:`text-3xl font-bold` + "行" 单位。
- 执行耗时:`text-3xl font-bold` + "秒" 单位。
- 警告数:如有则显示 badge,无则显示 "-" 并置灰。
- **回滚 SQL**:使用 `Accordion` 包裹,默认折叠,类型为 `type="single" collapsible`。展开后显示只读编辑器 + 复制按钮。
- **审批意见输入**:在审批流程下方新增意见输入区,使用 `Textarea` + 提交按钮。支持 `@` 提及功能(输入 `@` 后弹出用户列表 `Popover`,使用 `Command` 组件筛选)。已提交的审批意见以评论卡片形式展示,包含 Avatar、用户名、时间、意见内容。
- **关联工单 Carousel**:将关联工单列表改为横向滚动卡片组,每张卡片显示工单标题、状态 badge、最后更新时间。使用 `scroll-snap-type: x mandatory` 实现平滑滚动,左右箭头按钮控制滚动。
- **操作日志时间轴**:左侧竖线 `w-px bg-border absolute left-4 top-0 bottom-0`,每个日志节点包含:圆点(操作类型颜色编码)、时间(`text-xs text-muted-foreground`)、操作描述、操作人 Avatar。
---
#### 5.1.4 审批流程可视化优化
**流程图实现方案**
推荐采用自定义 SVG 方案(相比 Mermaid.js 更轻量、更易定制样式)。流程图容器使用 `aspect-[16/9] max-h-[360px]`,SVG 内部使用 `<defs>` 定义箭头标记和渐变色。
**节点设计**
| 节点类型 | 形状 | 尺寸 | 颜色 |
|---------|------|------|------|
| 开始/结束 | 圆角矩形(rx=20) | 100×48px | `fill: hsl(var(--primary))` |
| 操作步骤 | 圆角矩形(rx=8) | 140×56px | `fill: hsl(var(--card)), stroke: hsl(var(--border))` |
| 判断节点 | 菱形(polygon) | 112×80px | `fill: hsl(var(--amber-50)), stroke: hsl(var(--amber-400))` |
| 当前步骤 | 任意形状 + 脉冲 | 基础尺寸 | `ring` + `animate-pulse` |
节点文本居中显示,使用 `text-sm font-medium fill-foreground`。当前步骤节点的边框使用 `stroke: hsl(var(--primary))`,线宽 `stroke-width: 2.5`,并附加 SVG `<animate>` 脉冲效果(`opacity 0.6→1→0.6`,周期 2s)。
**连线设计**
- 正常路径:`stroke: hsl(var(--primary))`,`stroke-width: 2`,带箭头标记。
- 回退路径(如"拒绝"分支):`stroke: hsl(var(--destructive))`,`stroke-width: 2`,`stroke-dasharray: 6 3`,箭头标记颜色同步。
- 条件分支标签:连线中点放置文本标签,如"通过"/"拒绝",使用 `text-xs fill-muted-foreground`,背景白色矩形 `fill: hsl(var(--background))` 防止线穿过文字。
- 鼠标悬停连线时,线宽动画增至 `stroke-width: 3`,同时显示 `Tooltip` 说明该路径的触发条件。
**交互增强**
- 点击任意节点,右侧(或下方)弹出详情面板,显示该步骤的完整信息(操作人、操作时间、耗时、备注、相关规则)。
- 流程图支持缩放(鼠标滚轮,范围 0.5x-2x)和平移(拖拽),使用 `transform` 属性实现。
- 提供"重置视图"按钮,点击后平滑动画回归初始缩放和位置。
- 当前正在进行的步骤,节点右上角显示旋转的 `Loader2` 图标(`animate-spin`)。
---
### 5.2 查询数据页面优化
查询数据页面(QueriesPage.tsx,896 行)是开发人员最高频使用的功能模块。当前页面在编辑器体验、结果展示、导航效率方面均有较大提升空间。
#### 5.2.1 SQL 编辑器优化
**主题同步**
CodeMirror 6 编辑器主题通过 `Compartment` 动态切换,监听 `theme` 状态变化:
```typescript
const themeCompartment = new Compartment();
// 深色主题使用 oneDark,浅色使用 lightTheme
const themeExtension = theme === 'dark' ? oneDark : lightTheme;
view.dispatch({ effects: themeCompartment.reconfigure(themeExtension) });
```
编辑器背景色使用 `bg-card`,确保与页面主题无缝融合。光标颜色使用 `caret-color: hsl(var(--primary))`。
**全屏编辑模式**
监听 `F11` 按键(`preventDefault` 阻止浏览器默认全屏),触发自定义全屏:编辑器容器使用 `fixed inset-0 z-50 bg-background` 定位,动画 `animate-in zoom-in-95 duration-200`。全屏模式下隐藏侧边栏,工具栏收缩为单行。退出方式:再次按 `F11` 或点击右上角 `Minimize` 图标按钮。使用 `useHotkeys` hook 管理快捷键。
**代码折叠**
启用 CodeMirror 6 内置的 `@codemirror/language foldGutter` 扩展,折叠指示器使用 `&bull;` 或自定义图标(`ChevronDown`/`ChevronRight`)。折叠区域背景色使用 `bg-muted/30` 以视觉区分。
**多标签页支持**
在编辑器顶部增加标签页栏,高度 `h-10`,样式类似 VS Code:
- 活动标签:`bg-card border-t-2 border-primary text-foreground`。
- 非活动标签:`bg-muted/50 border-t-2 border-transparent text-muted-foreground hover:bg-muted`。
- 标签内容:查询前 20 个字符 + 未保存指示点(`bg-primary rounded-full w-2 h-2`)。
- 关闭按钮:标签 hover 时显示 `X` 图标,`size="sm" variant="ghost"`。
- 新建标签:右侧 `+` 按钮,点击后创建空查询标签。
- 标签状态存储在 `zustand` store 中,切换标签时通过 `editor.dispatch` 替换文档内容。
**查询参数化**
支持 `:paramName` 语法,编辑器通过自定义 `MatchDecorator` 高亮参数(`background: hsl(var(--primary)/0.15), border-radius: 3px`)。编辑器下方增加参数输入区:自动解析 SQL 中的参数,为每个参数生成 `Input` 字段(标签 = 参数名,占位符 = 数据类型推断)。参数值随查询一起提交到后端。
---
#### 5.2.2 结果展示优化
**DataTable 替换**
将原生结果表格替换为 `shadcn/ui DataTable`(`@tanstack/react-table`)。关键配置:
- `enableColumnResizing: true`:列宽可拖拽调整,拖拽手柄在列头右侧边缘,hover 时显示 `w-1 h-full bg-primary/30`。
- `enableSorting: true`:点击列头排序,排序状态循环:无 → 升序 → 降序 → 无。排序图标使用 `ArrowUpDown`,激活时根据方向切换为 `ArrowUp` 或 `ArrowDown`。
- `enableRowSelection: true`:支持行选择,批量导出。
**列头固定(Sticky Header)**
结果表格容器设置 `max-h-[480px] overflow-auto`,表头使用 `sticky top-0 z-10 bg-card border-b` 固定,确保纵向滚动时列头始终可见。
**大数据量虚拟滚动**
当结果行数超过 1000 行时,启用 `@tanstack/react-virtual` 虚拟滚动。虚拟滚动配置:
- `estimateSize: () => 40`:每行预估高度 40px。
- `overscan: 10`:上下各预渲染 10 行。
- 行元素使用绝对定位 `position: absolute; top: 0; transform: translateY(${virtualRow.start}px)`。
**单元格内容预览**
- 文本/数字列:超出列宽时显示 `truncate`,hover 时弹出 `Popover` 展示完整内容,Popover 最大宽度 `max-w-sm`,支持文本选择。
- JSON 列:使用 `lucide-react` 的 `Braces` 图标标识,内容折叠显示 `{...}` 或 `[...]`(包含元素个数 badge)。点击展开后使用 `react-json-view` 或自定义树形组件格式化展示,支持层级折叠。
- NULL 值:特殊样式 `text-muted-foreground italic` 显示 "NULL"。
**结果导出**
工具栏增加导出 `DropdownMenu`,包含:
| 格式 | 说明 |
|------|------|
| CSV | 逗号分隔,包含 BOM 解决中文乱码 |
| JSON | 对象数组格式,字段名作为 key |
| Excel | 通过 `xlsx` 库生成 `.xlsx` 文件 |
导出范围支持:当前页 / 全部结果 / 选中行。大数据量导出(>10,000 行)时显示进度条 `Dialog`。
**结果图表视图**
在结果表格上方增加视图切换 `ToggleGroup`:`Table` / `BarChart` / `LineChart`。
- 柱状图:使用 `recharts` 的 `<BarChart>`,选择两列(X 轴 + Y 轴)后渲染。X 轴标签旋转 45° 防止重叠。
- 折线图:使用 `<LineChart>`,适合时间序列数据。支持多系列(选择多个数值列)。
- 图表容器高度 `h-[360px]`,空状态显示 `BarChart3` 图标 + "请选择数据列生成图表" 提示。
- 图表配置面板:X 轴选择、Y 轴选择(可多选)、图表标题输入。
---
#### 5.2.3 侧边栏导航优化
**Schema 树搜索过滤**
Schema 侧边栏顶部增加搜索输入框(`Input size="sm"` + `Search` 图标),实时过滤数据库/表/字段名。过滤逻辑:
- 匹配数据库名:展示该数据库下所有表。
- 匹配表名:展示该表及其所在数据库(其他数据库折叠)。
- 匹配字段名:展示该字段及其所属表和数据库。
使用 `fuse.js` 实现模糊搜索,配置 `threshold: 0.3, keys: ['name']`。
**表节点增强**
每个表节点左侧显示 `Table2` 图标(`w-4 h-4 text-muted-foreground`),右侧显示行数 badge(`rounded-full bg-muted px-1.5 py-0 text-[10px] text-muted-foreground`)。行数从 `INFORMATION_SCHEMA` 或后端缓存获取,显示 "~" 前缀表示近似值。
**收藏表功能**
表节点 hover 时显示星标按钮(`Star` 图标,`size="sm" variant="ghost"`),点击切换收藏状态。已收藏的表显示实心 `Star` 图标(`fill: hsl(var(--amber-400))`)。收藏表固定在侧边栏顶部"收藏"分组下,分组标题使用 `text-xs font-semibold text-muted-foreground uppercase tracking-wider`。
**最近访问表**
在"收藏"分组下方增加"最近访问"分组,最多展示 10 个最近点击过的表,按访问时间倒序。每个表项显示访问时间相对文本(`2分钟前`)。使用 `lru-cache` 或简单的 `Map` 实现客户端缓存,持久化到 `localStorage`。
**拖拽插入 SQL**
实现从 Schema 树拖拽表名/字段名到编辑器:
- 拖拽开始:设置 `dataTransfer.setData('text/plain', tableName)`,拖拽图像为半透明标签显示表名。
- 拖拽经过编辑器:显示插入位置指示线。
- 拖拽释放:根据拖拽对象类型插入不同内容:
- 表名:插入 `` `tableName` ``
- 字段名:插入 `` `tableName`.`columnName` ``
- 数据库名:插入 `` `databaseName` ``
使用 CodeMirror 6 的 `dropCursor` 扩展显示插入位置。
---
#### 5.2.4 历史记录优化
**分类标签页**
将历史记录抽屉中的列表改为 `shadcn/ui Tabs` 分类:
- 全部(`History` 图标):所有查询记录。
- 成功(`CheckCircle` 图标,绿色):执行成功的查询。
- 失败(`XCircle` 图标,红色):执行失败的查询。
- 收藏(`Star` 图标,琥珀色):用户收藏的查询。
每个标签显示记录数量 badge。标签切换使用 `AnimatePresence` 实现内容淡入淡出。
**记录项增强**
每条历史记录显示:
- 左侧:状态圆点(成功 `bg-green-500`、失败 `bg-red-500`),直径 8px。
- 标题行:查询前 40 个字符 + 执行时间 badge(`rounded-md bg-muted px-1.5 py-0.5 text-xs`,如 "23ms"、"1.2s")。
- 展开/折叠:点击记录行展开 SQL 预览(`ChevronDown` / `ChevronRight` 图标指示),展开的 SQL 使用只读 CodeMirror 实例(高度 `max-h-[120px]`)或 `<pre>` 标签 + 语法高亮。
- 操作按钮组(hover 显示):"运行"(`Play` 图标,将 SQL 插入编辑器并执行)、"插入"(`CornerDownLeft` 图标,仅插入编辑器)、"收藏"(`Star` 图标)、"删除"(`Trash2` 图标,`variant="ghost"` 且 hover 变红)。
**一键对比功能**
在历史记录操作菜单中增加"对比"选项。选择两条记录后,弹出对比 `Dialog`(宽 `max-w-5xl`),使用左右分栏展示两条 SQL 的差异。差异高亮使用 `diff-match-patch` 库或自定义实现:
- 相同行:正常显示。
- 删除行:背景 `bg-red-50`,左侧红色竖线标记。
- 新增行:背景 `bg-green-50`,左侧绿色竖线标记。
- 修改行:字符级差异高亮,删除字符 `bg-red-200 line-through`,新增字符 `bg-green-200`。
**文件夹/标签组织**
允许用户创建自定义文件夹("日常巡检"、"业务报表"等)来组织历史查询。文件夹使用侧边栏树形结构展示,支持:
- 新建文件夹:输入名称,使用 `Dialog` + `Input`。
- 拖拽排序:文件夹和历史记录均支持拖拽重新排序。
- 颜色标签:每个文件夹可选颜色标签(8 种预设色),用于快速视觉识别。
- 搜索:文件夹和历史记录支持全文搜索(SQL 内容 + 备注)。
---
### 5.3 系统设置页面优化
当前设置页面(SettingsPage.tsx 仅 56 行)结构过于简单,PlatformConfigPage.tsx 和 AppearanceSettings.tsx 也存在分类不清晰、交互不一致的问题。
#### 5.3.1 设置分类重组
**新分类结构**
将原有分类重新组织为 7 个一级分类,每个分类分配独立路由(`/settings/:category`):
| 分类 | 路由 | 包含内容 | 图标 |
|------|------|----------|------|
| 外观设置 | `/settings/appearance` | 主题/语言/密度/字体/圆角/自定义CSS | `Palette` |
| 安全设置 | `/settings/security` | 密码策略/登录限制/2FA/IP白名单 | `Shield` |
| 通知设置 | `/settings/notifications` | 渠道配置/模板管理/订阅规则 | `Bell` |
| 执行设置 | `/settings/execution` | 超时/LIMIT/并发/执行器配置 | `PlayCircle` |
| 审计设置 | `/settings/audit` | 保留策略/敏感词/审计级别 | `ClipboardList` |
| 集成设置 | `/settings/integrations` | LDAP/Webhook/API密钥 | `Plug` |
| 高级设置 | `/settings/advanced` | 清理/备份/日志/导入导出 | `Cog` |
**导航布局**
设置页面采用左右布局:左侧导航栏宽 `w-64`(固定),右侧内容区 `flex-1`。左侧导航使用 `shadcn/ui` 的 `ScrollArea` 包裹,每个导航项包含图标(`w-4 h-4`)+ 分类名 + 设置项数量 badge(如"12 项")。当前激活项使用 `bg-primary/10 text-primary border-r-2 border-primary`。
导航栏底部放置"危险区域"入口(`AlertTriangle` 图标,红色文字),包含恢复默认设置、清除所有数据等操作。
---
#### 5.3.2 表单交互优化
**统一表单架构**
所有设置表单统一使用 `shadcn/ui Form` + `react-hook-form` + `zod` 验证。每个设置项包裹在卡片组件中:
```
┌─────────────────────────────────────┐
│ 设置项标题 │ ← text-base font-medium
│ 设置项描述,说明此配置的作用和影响范围 │ ← text-sm text-muted-foreground
│ │
│ [输入控件] [重置] │ ← 控件右对齐,重置按钮变灰
└─────────────────────────────────────┘
```
卡片样式:`rounded-xl border border-border/60 bg-card p-5 shadow-sm`,hover 时 `shadow-md transition-shadow duration-200`。卡片间距 `space-y-4`。
**设置项搜索**
页面顶部增加全局搜索栏(`Input` + `Search` 图标 + `Command+K` 快捷键),支持跨分类搜索设置项。搜索范围包括:设置项标题、描述、关键词标签。搜索结果按分类分组展示,点击后滚动定位到对应设置项并高亮(`bg-primary/10` 闪烁 2 秒后消退)。使用 `fuse.js` 实现,`threshold: 0.25`。
**变更指示与保存**
- 变更指示:每个设置卡片在值发生变化时,底部显示 "已修改" 文字(`text-xs text-amber-600`)+ "撤销" 链接,同时卡片顶部边框变为 `border-t-2 border-amber-400`。
- 保存策略:每个分类页右下角固定悬浮保存栏(`sticky bottom-6`),包含"保存更改"(`variant="default"`)和"重置"(`variant="outline"`)按钮。保存栏使用 `bg-card border shadow-lg rounded-xl px-6 py-3`。
- 自动保存:可选开启,每 5 秒自动保存已变更的设置(防抖处理)。
- 保存成功:顶部显示 `Toast`("设置已保存",`CheckCircle` 图标),保存按钮短暂变为成功状态(文字 "已保存" + `Check` 图标,2 秒后恢复)。
**变更历史**
在设置页面右上角增加"变更历史"按钮(`History` 图标),点击后弹出 `Sheet`(右侧滑出,宽 `w-[480px]`)。历史记录展示最近 50 条设置变更,每条包含:
- 时间(相对格式,如"3 分钟前")
- 分类名
- 设置项标题
- 旧值 → 新值(差异高亮)
- 操作人 Avatar + 用户名
历史记录数据由后端提供,前端使用 `useSWR` 轮询获取(间隔 30 秒)。
**危险设置二次确认**
对于高风险设置项(如"允许删除生产环境数据"、"关闭审计日志"),使用以下交互设计:
- 卡片边框:`border-red-200`。
- 卡片顶部:`bg-red-50 px-5 py-2 -mx-5 -mt-5 mb-3 rounded-t-xl border-b border-red-100`,内含 `AlertTriangle` 图标 + "危险设置" 警告文字。
- 开关/输入控件旁增加确认 `Dialog`,用户点击变更时弹出,要求输入操作密码或二次确认文字(如"请输入 DELETE 确认")。
**设置导入/导出**
在高级设置分类中增加导入/导出功能:
- 导出:将所有设置序列化为 JSON 文件下载,包含元数据(导出时间、版本、操作人)。
- 导入:拖拽或选择 JSON 文件,预览变更内容(使用 diff 视图展示与当前设置的差异),确认后应用。导入前自动创建备份点。
---
#### 5.3.3 安全设置增强
**密码策略配置**
新增密码策略卡片,包含以下设置项:
| 设置项 | 控件 | 默认值 | 验证规则 |
|--------|------|--------|----------|
| 最小密码长度 | `Slider` (6-32) + `Input` | 8 | zod `min(6).max(32)` |
| 必须包含大写字母 | `Switch` | true | — |
| 必须包含小写字母 | `Switch` | true | — |
| 必须包含数字 | `Switch` | true | — |
| 必须包含特殊字符 | `Switch` | false | — |
| 密码过期时间(天) | `Input` (数字) | 90 | `min(1).max(365)` |
| 历史密码禁止 reuse 次数 | `Slider` (0-10) | 3 | `min(0).max(10)` |
密码强度实时预览:下方显示密码示例框,输入测试密码后动态显示强度条(红/黄/绿三段)和文字评价("弱"/"中"/"强")。
**登录失败锁定策略**
- 启用开关:`Switch`,默认开启。
- 最大尝试次数:`Slider` (3-10),默认 5 次。
- 锁定时间:`Select`(5分钟/15分钟/30分钟/1小时/手动解锁),默认 15 分钟。
- 通知管理员:`Switch`,锁定事件发生时通知管理员。
**IP 白名单配置**
使用 `Textarea` 输入,每行一个 IP 或 CIDR 段(如 `192.168.1.0/24`)。实时验证输入格式,非法格式行显示红色下划线 + `Tooltip` 说明错误原因。支持从文件导入(`.txt` 或 `.csv` 格式)。
白名单列表以标签云形式展示,每个 IP 标签可单独删除(`X` 图标)。空状态时显示 `Globe` 图标 + "未配置 IP 白名单,允许所有 IP 访问" 提示。
**Session 超时设置**
- 超时时间:`Select`(15分钟/30分钟/1小时/2小时/4小时/8小时/永不),默认 30 分钟。
- 超时前警告:`Switch`,在超时前 2 分钟弹出警告 `Dialog`,提供"保持登录"按钮。
- 多设备登录限制:`Select`(不限制/1台/2台/3台),默认不限制。
**操作审计级别配置**
使用 `RadioGroup` 三选一:
- 基础:仅记录登录/登出和审批操作。
- 标准(默认):额外记录查询执行和设置变更。
- 完整:记录所有操作包括 Schema 浏览和导出行为。
每个级别旁显示预估日志存储量(基于当前使用量计算)。
---
#### 5.3.4 外观设置增强
**主题实时预览**
主题模式切换(深色/浅色/跟随系统)改为实时预览:用户点击选项后立即应用主题(`document.documentElement.classList`),但此时不保存到后端。页面显示"主题已临时切换,保存以永久生效"的提示条(`bg-primary/10 border border-primary/20 rounded-lg px-4 py-2 mb-4`),包含"保存"和"恢复"按钮。用户刷新页面后未保存的主题恢复为已保存值。
**界面密度选择**
新增三档密度选择,使用 `ToggleGroup` 展示:
| 密度 | 效果 | 适用场景 |
|------|------|----------|
| 紧凑 (`compact`) | 表格行高 32px,卡片内边距 16px,字体 13px | 数据密集型用户 |
| 默认 (`default`) | 表格行高 40px,卡片内边距 20px,字体 14px | 平衡 |
| 舒适 (`comfortable`) | 表格行高 48px,卡片内边距 24px,字体 14px | 触控设备或视力辅助 |
选择后立即生效,通过 CSS 变量控制:`--density-factor: 0.8 | 1.0 | 1.2`,各组件使用 `calc()` 基于该变量计算尺寸。
**侧边栏宽度可调**
在"布局"子分类中增加侧边栏宽度设置:
- 控件:`Slider` (200px-400px),默认 280px。
- 实时预览:拖动滑块时侧边栏宽度实时变化。
- 重置按钮:点击恢复默认 280px。
**字体大小设置**
- 基础字体大小:`Select`(12px/13px/14px/15px/16px),默认 14px。
- 代码字体大小:`Select`(12px/13px/14px/15px/16px),默认 14px。
- 代码字体族:`Select`('JetBrains Mono', 'Fira Code', 'Source Code Pro', 'monospace'),默认 'JetBrains Mono'。
字体族选择时,预览文字实时展示该字体的渲染效果。
**圆角风格设置**
使用三个可视化选项卡片:
| 风格 | CSS 变量 | 视觉描述 |
|------|----------|----------|
| 锐利 (`sharp`) | `--radius: 0px` | 直角,无圆角 |
| 默认 (`default`) | `--radius: 0.5rem` | 中等圆角,卡片 8px |
| 圆润 (`rounded`) | `--radius: 0.75rem` | 大圆角,卡片 12px |
每个选项卡片展示缩略图:一个矩形 + 一个按钮 + 一个 badge,使用对应圆角值渲染。选中状态 `ring-2 ring-primary`。
**自定义 CSS(高级用户)**
在"高级"子分类中增加自定义 CSS 输入区:
- 使用 CodeMirror 6 编辑器(只开启基础高亮,无语法检查),高度 `min-h-[200px]`。
- 占位符:"/* 在此输入自定义 CSS,将覆盖默认样式 */\n\n.custom-sidebar {\n /* your styles */\n}"。
- 实时预览开关:`Switch`,开启时 CSS 立即应用到页面。
- 安全提示:输入区上方显示 `Info` 图标 + "自定义 CSS 仅影响当前用户,错误样式可能导致页面显示异常" 警告。
- CSS 作用域:通过 Shadow DOM 或 `style` 标签 + 特定 class 前缀隔离,避免影响全局样式。
**登录页风格选择**
将原有的 5 个缩略图卡片优化为:
- 卡片尺寸:`aspect-[16/10] rounded-lg overflow-hidden border-2`。
- 选中状态:`border-primary ring-2 ring-primary/20`。
- 缩略图内容:使用实际登录页的静态截图或实时 iframe 预览。
- 卡片底部:风格名称 + 简短描述。
- 新增"上传自定义背景"按钮:支持上传图片(JPG/PNG,最大 5MB),上传后显示预览和"删除"按钮。
---
以上优化方案覆盖了审批变更、查询数据、系统设置三大核心模块的全部关键页面。每个方案均提供了具体的组件选型、布局参数、交互细节和视觉规范,可直接转化为前端开发任务。建议按照"查询数据(高频使用)→ 审批变更(核心流程)→ 系统设置(低频但影响全局)"的优先级顺序逐步实施。
File diff suppressed because it is too large. Load diff
@@ -1,209 +0,0 @@
## 7. 设计体系统一方案
### 7.1 色彩体系规范
**主色板定义**
| 角色 | 色值 | 用途 |
|------|------|------|
| Primary | `#58a6ff` (--dg-blue) | 主要操作、链接、高亮 |
| Secondary | `#8957e5` (--dg-purple) | 次级强调、标签、分类 |
| Accent | `#39c5cf` (--dg-cyan) | 提示点、新功能标记、数据高亮 |
**功能色定义**
| 角色 | 色值 | 使用场景 |
|------|------|----------|
| Success | `#3fb950` | 成功状态、通过标识、正向数据 |
| Warning | `#f0883e` | 警告提示、需谨慎操作 |
| Error | `#f85149` | 错误状态、删除操作、阻断性提示 |
| Info | `#58a6ff` | 信息提示、帮助引导 |
**中性色(Gray Scale)**
以现有变量为锚点扩展完整灰阶:`#f0f6fc` (50) → `#c9d1d9` (200) → `#8b949e` (400) → `#6e7681` (500) → `#484f58` (700) → `#30363d` (800) → `#21262d` (850) → `#161b22` (900) → `#0d1117` (950)。分别对应主文本、次级文本、弱化文本、悬浮背景、边框、深边框、卡片背景、次级背景、主背景。
**渐变规范**
- 品牌渐变(`.dg-gradient-bg`):`linear-gradient(135deg, #2563eb, #9333ea)`,用于Hero区域、登录页背景、Banner
- 文字渐变(`.dg-gradient-text`):`linear-gradient(90deg, #60a5fa, #c084fc)`,仅用于大标题强调
- 边框渐变(`.dg-gradient-br`):`linear-gradient(135deg, #2563eb, #9333ea)` 作为 border-image,用于特色卡片边框
- 使用规则:同一页面渐变不超过2种,文字渐变仅限H1/H2级别
**Tailwind配置扩展**
```js
colors: {
dg: {
bg: { primary: '#0d1117', secondary: '#161b22', tertiary: '#21262d', hover: '#30363d' },
border: { DEFAULT: '#30363d', light: '#21262d' },
text: { primary: '#f0f6fc', secondary: '#8b949e', muted: '#6e7681' },
blue: '#58a6ff', green: '#3fb950', red: '#f85149',
orange: '#f0883e', purple: '#8957e5', cyan: '#39c5cf', yellow: '#d29922',
}
}
```
### 7.2 字体排版规范
**字体栈**:`font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;` 系统字体优先,中文回退依次为Noto Sans SC、PingFang SC、Microsoft YaHei。
**字号Scale**(基于1.25倍率):
| Token | 尺寸 | 行高 | 用途 |
|-------|------|------|------|
| text-xs | 12px | 18px | 辅助文字、时间戳、徽章内文字 |
| text-sm | 14px | 20px | 表单标签、表格内容、按钮文字 |
| text-base | 16px | 24px | 正文、菜单项、对话框内容 |
| text-lg | 18px | 28px | 卡片标题、区块小标题 |
| text-xl | 20px | 30px | 页面次级标题 |
| text-2xl | 24px | 32px | 页面标题 |
| text-3xl | 30px | 38px | 模块大标题 |
| text-4xl | 36px | 44px | Hero标题、登录页品牌名 |
**字重规范**:normal (400) 用于正文,medium (500) 用于菜单和按钮,semibold (600) 用于标题,bold (700) 用于强调数据。H1-H6统一semibold,避免过粗导致的中文笔画粘连。
**等宽字体**:代码块与数据展示使用 `font-family: "JetBrains Mono", "Fira Code", "SF Mono", monospace;`,字号14px,行高1.6。
### 7.3 间距体系规范
**基于4px的Spacing Scale**:0(0px)、1(4px)、2(8px)、3(12px)、4(16px)、5(20px)、6(24px)、8(32px)、10(40px)、12(48px)、16(64px)、20(80px)、24(96px)。
**页面内边距**:内容区水平 `px-6`(24px),最大宽度1440px居中;垂直方向页面级 `py-8`(32px),模块级 `py-6`(24px)。
**卡片内边距**:标准卡片 `p-6`(24px),紧凑卡片 `p-4`(16px),数据卡片 `px-5 py-4`(20px/16px)。
**表单间距**:标签与输入框间距 `mb-1.5`(6px),表单项之间 `mb-5`(20px),表单组之间 `mb-8`(32px)。
**表格间距**:表头内边距 `px-4 py-3`(16px/12px),行内边距 `px-4 py-3`(16px/12px),行间距0(以边框分隔)。
**常见布局模板**:页面标题区(mb-8)→ 统计卡片区(grid gap-4, mb-8)→ 主内容卡片区(gap-6)。
### 7.4 组件交互规范
**按钮体系**
| 变体 | 背景 | 文字 | 圆角 | 内边距 | 使用场景 |
|------|------|------|------|--------|----------|
| Primary | `#58a6ff` | `#0d1117` | 6px | px-4 py-2 | 主要提交、确认操作 |
| Secondary | `#21262d` | `#f0f6fc` | 6px | px-4 py-2 | 取消、返回操作 |
| Ghost | transparent | `#8b949e` | 6px | px-4 py-2 | 次要操作、工具栏按钮 |
| Danger | `#f85149` | `#fff` | 6px | px-4 py-2 | 删除、阻断性操作 |
| Link | transparent | `#58a6ff` | 0 | px-2 py-1 | 导航跳转、文字链接 |
尺寸:sm (h-8, px-3, text-xs),md (h-10, px-4, text-sm),lg (h-12, px-6, text-base)。圆角统一6px,hover时亮度提升10%,transition 150ms。
**输入框体系**:默认边框`#30363d`,focus边框`#58a6ff`并附加`ring-2 ring-blue-500/20`,error边框`#f85149`带红色ring,disabled背景`#161b22`文字`#6e7681`。统一圆角6px,内边距px-3 py-2,高度40px。
**卡片体系**:默认卡片(bg `#161b22` border `#30363d` 圆角8px),可悬浮卡片(hover时border-color过渡为`#484f58`、translateY(-1px)、shadow-lg),可选中卡片(选中态border `#58a6ff`、bg `#58a6ff/5%`)。
**表格体系**:表头bg `#161b22` 文字`#8b949e` font-semibold,行hover bg `#21262d`,操作列按钮间距gap-2。边框1px `#21262d`。
**弹窗体系**:确认弹窗(宽400px),表单弹窗(宽560px),抽屉(宽480px/640px两档)。统一圆角12px,阴影`shadow-2xl`,入场动画scaleIn 200ms。
**徽章体系**:状态徽章(圆角全角,px-2 py-0.5,text-xs),标签徽章(圆角4px,带边框),计数徽章(圆形,min-w-5,h-5)。
### 7.5 动效体系规范
**缓动函数定义**
| 名称 | 值 | 用途 |
|------|-----|------|
| ease-default | cubic-bezier(0.4, 0, 0.2, 1) | 常规过渡 |
| ease-in | cubic-bezier(0.4, 0, 1, 1) | 元素退出 |
| ease-out | cubic-bezier(0, 0, 0.2, 1) | 元素入场 |
| ease-spring | cubic-bezier(0.34, 1.56, 0.64, 1) | 弹性交互 |
**时长Scale**:fast 150ms(hover、focus环、颜色过渡),normal 250ms(弹窗、展开/收起),slow 350ms(页面转场、大型组件)。
**入场动画**:fadeIn(opacity 0→1, 200ms ease-out)、slideUp(translateY 8px→0 + opacity, 250ms ease-out)、scaleIn(scale 0.95→1 + opacity, 200ms ease-out)。弹窗统一scaleIn,列表项使用slideUp带stagger 30ms。
**交互动画**:hover过渡统一150ms,按钮active时scale 0.98,卡片hover时translateY(-1px) + border-color过渡,输入框focus时ring 150ms。所有交互元素必须有hover反馈。
**Loading动画**:旋转器(border-2 border-t-blue-500,animate-spin,20px),骨架屏(shimmer动画,bg-gradient横向扫光,1.5s linear infinite)。
**CSS Keyframes**
```css
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes slideUp { from { opacity: 0; transform: translateY(8px) } to { opacity: 1; transform: translateY(0) } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }
```
---
## 8. 实施路线图
### 8.1 优先级划分
| 优先级 | 范围 | 目标 |
|--------|------|------|
| P0 关键 | 登录页改造、设计体系统一 | 解决当前最大用户体验短板,建立可复用的设计基础 |
| P1 重要 | 主题系统扩展、核心功能优化 | 提升产品专业感,优化高频使用路径 |
| P2 优化 | 菜单体系、动效体系 | 增强导航效率与交互质感 |
| P3 增强 | 高级功能 | 数据可视化、个性化等增值能力 |
### 8.2 阶段一:登录页改造(2周)
**Week 1:组件重构**
- Day 1-2:开发粒子动画背景组件(Canvas实现,可配置粒子密度与连线颜色)
- Day 3:品牌展示区(渐变文字、系统名称、副标题、版本号)
- Day 4:登录表单重构(统一输入框规范、密码可见性切换、记住登录态)
- Day 5:安全提示区块(SSL标识、多因子认证引导、安全公告轮播)
**Week 2:动效与联调**
- Day 1-2:入场动效串联(粒子→标题→表单依次fadeIn+slideUp,stagger 150ms)
- Day 3:表单验证增强(实时校验、错误动画抖动、聚焦引导)
- Day 4-5:联调测试、响应式适配、浏览器兼容性验证
交付物:全新登录页、可复用粒子背景组件、登录表单组件。验收标准:Lighthouse性能分≥90,首屏加载<1.5s,动画帧率≥55fps。
### 8.3 阶段二:主题系统扩展(1周)
- Day 1-2:CSS变量体系扩展(将7.1节色彩规范完整植入,新增主题变量层 `--theme-*`)
- Day 3-4:主题切换UI(设置面板主题选择器、实时预览、本地持久化)
- Day 5:测试优化(变量回退机制、主题切换无闪烁、50+ shadcn/ui组件全覆盖)
交付物:扩展后的CSS变量体系、主题切换组件、Light/Dark/High-Contrast三主题。验收标准:主题切换<100ms无闪烁,所有组件颜色100%变量驱动。
### 8.4 阶段三:核心功能优化(3周)
**Week 1:审批变更工单系统**
- 工单列表卡片化重构(统一.dg-card规范)
- 审批流可视化(状态徽章体系、进度条组件)
- 批量操作工具栏(按钮体系统一)
**Week 2:查询数据页面**
- SQL编辑器区域优化(等宽字体规范、行号样式)
- 结果表格重构(表格体系统一、列宽记忆)
- 查询历史面板(间距体系应用、空状态设计)
**Week 3:系统设置页面**
- 设置项卡片分组(间距模板应用)
- 表单交互优化(输入框规范、保存反馈动效)
- 权限矩阵表格重构(表格规范全面落地)
交付物:三个核心页面重构完成、对应组件更新。验收标准:无硬编码颜色,间距100%符合scale,交互动效完整。
### 8.5 阶段四:菜单与体系统一(1周)
- Day 1-2:菜单结构重组(按用户角色分组、图标体系统一、激活态样式)
- Day 3-4:全局设计体系统一(替换所有硬编码颜色为CSS变量,按钮/卡片/输入框标准化)
- Day 5:动效体系实现(全局加载动画、页面转场、交互反馈动效)
交付物:重组后的菜单组件、全局样式清理报告、动效配置文件。验收标准:代码库零硬编码色值(除渐变色定义外),全局交互动效一致性100%。
### 8.6 预期效果
**定性效果**:登录页从功能型升级为品牌展示窗口;核心操作流程视觉层级清晰,用户认知负担降低;全产品交互反馈一致,专业感显著提升。
**定量效果**:
| 指标 | 当前预估 | 目标 |
|------|----------|------|
| 首屏加载时间 | ~2.5s | <1.5s |
| Lighthouse性能分 | ~70 | ≥90 |
| 全局样式不一致点 | 35+处 | 0处 |
| CSS变量覆盖率 | ~60% | 100% |
**风险控制**:每阶段独立分支开发,P0/P1阶段完成后可独立上线;所有样式变更基于现有变量扩展,保持向后兼容;关键页面保留回滚开关,确保24小时内可回退至原版本。
@@ -25,13 +25,13 @@ const KEYFRAMES = `
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(25px, 30px); }
}
@keyframes card-glow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
@keyframes mesh-drift-6 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-15px, 25px); }
}
@keyframes input-glow-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
@keyframes mesh-drift-7 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20px, -15px); }
}
`;
@@ -45,53 +45,67 @@ export default function GradientMeshView(): React.JSX.Element {
const isReady = username.trim().length > 0 && password.trim().length > 0;
/* ---- gradient mesh blobs ---- */
/* ---- marble / terrazzo blobs (light tones) ---- */
const blobs: React.CSSProperties[] = [
{
position: 'fixed', top: '-100px', left: '-100px',
width: '600px', height: '600px',
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.3), transparent 70%)',
filter: 'blur(80px)', pointerEvents: 'none',
animation: 'mesh-drift-1 12s ease-in-out infinite',
},
{
position: 'fixed', bottom: '-80px', right: '-80px',
width: '500px', height: '500px',
background: 'radial-gradient(circle, rgba(244, 63, 94, 0.2), transparent 70%)',
position: 'fixed', top: '-120px', left: '-80px',
width: '650px', height: '650px',
background: 'radial-gradient(circle, rgba(99,102,241,0.06), transparent 70%)',
filter: 'blur(90px)', pointerEvents: 'none',
animation: 'mesh-drift-2 10s ease-in-out infinite',
animation: 'mesh-drift-1 14s ease-in-out infinite',
},
{
position: 'fixed', top: '40%', right: '10%',
width: '400px', height: '400px',
background: 'radial-gradient(circle, rgba(16, 185, 129, 0.15), transparent 70%)',
filter: 'blur(100px)', pointerEvents: 'none',
animation: 'mesh-drift-3 14s ease-in-out infinite',
},
{
position: 'fixed', bottom: '5%', left: '5%',
width: '450px', height: '450px',
background: 'radial-gradient(circle, rgba(245, 158, 11, 0.1), transparent 70%)',
filter: 'blur(85px)', pointerEvents: 'none',
animation: 'mesh-drift-4 11s ease-in-out infinite',
},
{
position: 'fixed', top: '-50px', right: '20%',
position: 'fixed', bottom: '-100px', right: '-60px',
width: '550px', height: '550px',
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.2), transparent 70%)',
background: 'radial-gradient(circle, rgba(244,63,94,0.04), transparent 70%)',
filter: 'blur(100px)', pointerEvents: 'none',
animation: 'mesh-drift-2 12s ease-in-out infinite',
},
{
position: 'fixed', top: '35%', right: '8%',
width: '450px', height: '450px',
background: 'radial-gradient(circle, rgba(16,185,129,0.05), transparent 70%)',
filter: 'blur(110px)', pointerEvents: 'none',
animation: 'mesh-drift-3 16s ease-in-out infinite',
},
{
position: 'fixed', bottom: '10%', left: '8%',
width: '500px', height: '500px',
background: 'radial-gradient(circle, rgba(139,92,246,0.04), transparent 70%)',
filter: 'blur(95px)', pointerEvents: 'none',
animation: 'mesh-drift-5 13s ease-in-out infinite',
animation: 'mesh-drift-4 13s ease-in-out infinite',
},
{
position: 'fixed', top: '-60px', right: '25%',
width: '580px', height: '580px',
background: 'radial-gradient(circle, rgba(99,102,241,0.04), transparent 70%)',
filter: 'blur(100px)', pointerEvents: 'none',
animation: 'mesh-drift-5 15s ease-in-out infinite',
},
{
position: 'fixed', top: '60%', left: '30%',
width: '400px', height: '400px',
background: 'radial-gradient(circle, rgba(244,63,94,0.03), transparent 70%)',
filter: 'blur(105px)', pointerEvents: 'none',
animation: 'mesh-drift-6 11s ease-in-out infinite',
},
{
position: 'fixed', top: '15%', left: '50%',
width: '480px', height: '480px',
background: 'radial-gradient(circle, rgba(16,185,129,0.04), transparent 70%)',
filter: 'blur(95px)', pointerEvents: 'none',
animation: 'mesh-drift-7 17s ease-in-out infinite',
},
];
/* ---- input base style ---- */
const inputBase: React.CSSProperties = {
width: '100%',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
background: '#fff',
border: '1px solid #e5e7eb',
borderRadius: 12,
padding: '14px 18px',
color: '#fff',
color: '#111827',
fontSize: 15,
outline: 'none',
transition: 'all 0.25s ease',
@@ -100,32 +114,12 @@ export default function GradientMeshView(): React.JSX.Element {
const getInputStyle = (field: string): React.CSSProperties => ({
...inputBase,
borderColor: focusedField === field ? 'rgba(99,102,241,0.5)' : 'rgba(255,255,255,0.06)',
background: focusedField === field ? 'rgba(255,255,255,0.07)' : 'rgba(255,255,255,0.04)',
borderColor: focusedField === field ? '#8b5cf6' : '#e5e7eb',
boxShadow: focusedField === field
? '0 0 0 3px rgba(99,102,241,0.1)'
: 'none',
});
/* ---- card top accent line ---- */
const topAccent: React.CSSProperties = {
position: 'absolute',
top: 0, left: 24, right: 24,
height: 2,
borderRadius: 1,
background: 'linear-gradient(90deg, #6366f1, #f43f5e, #10b981)',
opacity: 0.6,
};
/* ---- subtle glow inside card bottom ---- */
const innerGlow: React.CSSProperties = {
position: 'absolute',
bottom: -60, left: '50%',
transform: 'translateX(-50%)',
width: '70%', height: 120,
background: 'radial-gradient(ellipse, rgba(99,102,241,0.08), transparent 70%)',
filter: 'blur(40px)',
pointerEvents: 'none',
animation: 'card-glow 8s ease-in-out infinite',
};
return (
<>
<style>{KEYFRAMES}</style>
@@ -140,24 +134,25 @@ export default function GradientMeshView(): React.JSX.Element {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0a0a0f',
background: '#fafaf8',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}}
>
{/* ---- gradient mesh blobs ---- */}
{/* ---- marble / terrazzo texture blobs ---- */}
{blobs.map((b, i) => (
<div key={i} style={b} />
))}
{/* ---- subtle dot-grid texture ---- */}
{/* ---- subtle dot-grid texture (light version) ---- */}
<div
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
opacity: 0.03,
opacity: 0.28,
backgroundImage:
'radial-gradient(circle, rgba(255,255,255,0.8) 1px, transparent 1px)',
'radial-gradient(circle, rgba(0,0,0,0.08) 1px, transparent 1px)',
backgroundSize: '24px 24px',
}}
/>
@@ -169,22 +164,17 @@ export default function GradientMeshView(): React.JSX.Element {
width: '100%',
maxWidth: 400,
margin: '0 20px',
background: 'rgba(255, 255, 255, 0.03)',
background: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
border: '1px solid rgba(255, 255, 255, 0.08)',
border: 'none',
borderRadius: 24,
padding: '48px 36px',
boxShadow:
'0 0 0 1px rgba(255,255,255,0.04), 0 25px 50px -12px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)',
'0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.06), 0 12px 40px rgba(0,0,0,0.04)',
zIndex: 10,
}}
>
{/* top accent line */}
<div style={topAccent} />
{/* inner glow */}
<div style={innerGlow} />
{/* ---- brand ---- */}
<div style={{ textAlign: 'center', marginBottom: 36 }}>
@@ -199,7 +189,7 @@ export default function GradientMeshView(): React.JSX.Element {
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
boxShadow: '0 6px 20px rgba(99,102,241,0.2)',
}}
>
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L20 6V11C20 16 16 19.5 12 22C8 19.5 4 16 4 11V6L12 2Z" /><path d="M9 12L11 14L15 10" /></svg>
@@ -207,7 +197,7 @@ export default function GradientMeshView(): React.JSX.Element {
<h1
style={{
color: '#fff',
color: '#111827',
fontSize: 24,
fontWeight: 700,
letterSpacing: '-0.5px',
@@ -218,7 +208,7 @@ export default function GradientMeshView(): React.JSX.Element {
</h1>
<p
style={{
color: 'rgba(255,255,255,0.35)',
color: '#6b7280',
fontSize: 14,
letterSpacing: '0.3px',
margin: 0,
@@ -234,65 +224,35 @@ export default function GradientMeshView(): React.JSX.Element {
style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
>
{/* username */}
<div style={{ position: 'relative' }}>
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
onFocus={() => setFocusedField('username')}
onBlur={() => setFocusedField(null)}
style={getInputStyle('username')}
autoComplete="username"
/>
{focusedField === 'username' && (
<div
style={{
position: 'absolute',
inset: -1,
borderRadius: 13,
background: 'radial-gradient(ellipse at center, rgba(99,102,241,0.12), transparent 70%)',
filter: 'blur(8px)',
pointerEvents: 'none',
animation: 'input-glow-pulse 3s ease-in-out infinite',
}}
/>
)}
</div>
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
onFocus={() => setFocusedField('username')}
onBlur={() => setFocusedField(null)}
style={getInputStyle('username')}
autoComplete="username"
/>
{/* password */}
<div style={{ position: 'relative' }}>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
onFocus={() => setFocusedField('password')}
onBlur={() => setFocusedField(null)}
style={getInputStyle('password')}
autoComplete="current-password"
/>
{focusedField === 'password' && (
<div
style={{
position: 'absolute',
inset: -1,
borderRadius: 13,
background: 'radial-gradient(ellipse at center, rgba(99,102,241,0.12), transparent 70%)',
filter: 'blur(8px)',
pointerEvents: 'none',
animation: 'input-glow-pulse 3s ease-in-out infinite',
}}
/>
)}
</div>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
onFocus={() => setFocusedField('password')}
onBlur={() => setFocusedField(null)}
style={getInputStyle('password')}
autoComplete="current-password"
/>
{/* error */}
{error && (
<div
style={{
background: 'rgba(239,68,68,0.08)',
color: '#f87171',
background: 'rgba(239,68,68,0.06)',
color: '#ef4444',
borderRadius: 10,
padding: '10px 14px',
fontSize: 13,
@@ -319,10 +279,10 @@ export default function GradientMeshView(): React.JSX.Element {
color: '#fff',
cursor: isReady && !loading ? 'pointer' : 'not-allowed',
opacity: isReady ? 1 : 0.5,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%)',
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
boxShadow: hoverBtn && isReady && !loading
? '0 8px 32px rgba(99,102,241,0.45)'
: '0 4px 20px rgba(99,102,241,0.3)',
? '0 8px 28px rgba(99,102,241,0.35)'
: '0 4px 16px rgba(99,102,241,0.2)',
transform: hoverBtn && isReady && !loading ? 'translateY(-1px)' : 'translateY(0)',
transition: 'all 0.25s ease',
}}
@@ -335,7 +295,7 @@ export default function GradientMeshView(): React.JSX.Element {
<p
style={{
textAlign: 'center',
color: 'rgba(255,255,255,0.2)',
color: '#9ca3af',
fontSize: 12,
margin: '28px 0 0 0',
}}
+92 -268
View File
@@ -2,103 +2,41 @@ import React from 'react';
import { useLogin } from '@/hooks/useLogin';
const KEYFRAMES = `
@keyframes neon-pulse {
0%, 100% { opacity: 0.6; box-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 40px currentColor; }
50% { opacity: 1; box-shadow: 0 0 20px currentColor, 0 0 40px currentColor, 0 0 80px currentColor; }
@keyframes float-slow {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(20px, -30px) scale(1.03); }
50% { transform: translate(-10px, -50px) scale(1.01); }
75% { transform: translate(15px, -20px) scale(1.04); }
}
@keyframes neon-flicker {
0%, 100% { opacity: 1; }
3% { opacity: 0.4; }
6% { opacity: 1; }
7% { opacity: 0.2; }
8% { opacity: 1; }
92% { opacity: 1; }
93% { opacity: 0.3; }
94% { opacity: 1; }
@keyframes float-medium {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(-25px, -40px) scale(1.02); }
50% { transform: translate(20px, -60px) scale(0.98); }
75% { transform: translate(-15px, -25px) scale(1.03); }
}
@keyframes rain-fall {
0% { transform: translateY(-100vh) translateX(0); opacity: 0; }
10% { opacity: 0.6; }
90% { opacity: 0.6; }
100% { transform: translateY(100vh) translateX(20px); opacity: 0; }
}
@keyframes grid-move {
0% { transform: perspective(500px) rotateX(60deg) translateY(0); }
100% { transform: perspective(500px) rotateX(60deg) translateY(40px); }
}
@keyframes city-breathe {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(1.02); }
}
@keyframes neon-glow-text {
0%, 100% { text-shadow: 0 0 10px rgba(236, 72, 153, 0.5), 0 0 20px rgba(236, 72, 153, 0.3); }
50% { text-shadow: 0 0 20px rgba(236, 72, 153, 0.8), 0 0 40px rgba(236, 72, 153, 0.5); }
@keyframes float-fast {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(15px, -20px) scale(1.01); }
50% { transform: translate(-20px, -35px) scale(1.02); }
75% { transform: translate(10px, -15px) scale(0.99); }
}
`;
// Building data: [width, heightPercent, leftPercent, glowColor]
const BUILDINGS: [number, number, number, string][] = [
[40, 18, 0, 'rgba(236, 72, 153, 0.4)'],
[55, 28, 4, 'rgba(168, 85, 247, 0.35)'],
[35, 15, 9, 'rgba(6, 182, 212, 0.3)'],
[70, 35, 12, 'rgba(236, 72, 153, 0.45)'],
[45, 22, 19, 'rgba(168, 85, 247, 0.4)'],
[50, 30, 23, 'rgba(6, 182, 212, 0.35)'],
[30, 16, 28, 'rgba(236, 72, 153, 0.3)'],
[80, 38, 31, 'rgba(168, 85, 247, 0.5)'],
[42, 20, 39, 'rgba(6, 182, 212, 0.4)'],
[60, 32, 43, 'rgba(236, 72, 153, 0.45)'],
[38, 17, 49, 'rgba(168, 85, 247, 0.3)'],
[75, 36, 52, 'rgba(6, 182, 212, 0.5)'],
[48, 25, 60, 'rgba(236, 72, 153, 0.4)'],
[55, 30, 65, 'rgba(168, 85, 247, 0.45)'],
[33, 14, 70, 'rgba(6, 182, 212, 0.3)'],
[85, 40, 73, 'rgba(236, 72, 153, 0.5)'],
[44, 21, 82, 'rgba(168, 85, 247, 0.4)'],
[58, 33, 86, 'rgba(6, 182, 212, 0.45)'],
[36, 16, 92, 'rgba(236, 72, 153, 0.35)'],
[50, 27, 95, 'rgba(168, 85, 247, 0.4)'],
];
// Neon tube data: [topPercent, color, animationDelay, animationDuration]
const NEON_TUBES: [number, string, string, string][] = [
[22, '#ec4899', '0s', '3s'],
[38, '#06b6d4', '1s', '4s'],
[52, '#a855f7', '2s', '3.5s'],
];
// Rain drop data: [leftPercent, delay, duration]
const RAIN_DROPS: [number, string, string][] = [
[5, '0s', '1.5s'],
[15, '0.3s', '1.8s'],
[25, '0.7s', '1.3s'],
[35, '0.2s', '1.6s'],
[45, '0.9s', '1.4s'],
[55, '0.5s', '1.7s'],
[65, '0.1s', '1.5s'],
[75, '0.8s', '1.9s'],
[85, '0.4s', '1.3s'],
[95, '0.6s', '1.6s'],
[10, '1.1s', '1.4s'],
[30, '1.3s', '1.7s'],
[50, '1.0s', '1.5s'],
[70, '1.5s', '1.8s'],
[90, '1.2s', '1.4s'],
[20, '0.15s', '2.0s'],
[40, '0.85s', '1.35s'],
[60, '1.25s', '1.65s'],
[80, '0.45s', '1.75s'],
[8, '0.95s', '1.55s'],
// Floating shapes: [width, height, top, left, color, opacity, animation, duration]
const FLOATING_SHAPES: [number, number, string, string, string, number, string, string][] = [
[340, 280, '8%', '5%', 'linear-gradient(135deg, #e0f2fe 0%, #f3e8ff 100%)', 0.5, 'float-slow', '20s'],
[240, 200, '55%', '2%', 'linear-gradient(135deg, #f3e8ff 0%, #ede9fe 100%)', 0.4, 'float-medium', '25s'],
[180, 160, '70%', '15%', 'linear-gradient(135deg, #dbeafe 0%, #e0f2fe 100%)', 0.35, 'float-fast', '18s'],
[280, 240, '20%', '18%', 'linear-gradient(135deg, #ede9fe 0%, #e0f2fe 100%)', 0.3, 'float-slow', '22s'],
];
export default function NeonCityView(): JSX.Element {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
const isDisabled = !username || !password || loading;
return (
<>
<style>{KEYFRAMES}</style>
@@ -110,124 +48,30 @@ export default function NeonCityView(): JSX.Element {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(180deg, #0a0418 0%, #1a0b2e 40%, #0d0718 100%)',
background: 'linear-gradient(180deg, #fafbfc 0%, #f0f2f5 100%)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}}
>
{/* Grid floor background */}
<div
style={{
position: 'absolute',
bottom: '-60%',
left: '-50%',
width: '200%',
height: '100%',
backgroundImage:
'linear-gradient(rgba(236, 72, 153, 0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(6, 182, 212, 0.04) 1px, transparent 1px)',
backgroundSize: '60px 60px',
animation: 'grid-move 8s linear infinite',
pointerEvents: 'none',
}}
/>
{/* Neon tubes */}
{NEON_TUBES.map(([top, color, delay, duration], i) => (
{/* Floating soft geometric shapes */}
{FLOATING_SHAPES.map(([w, h, top, left, bg, opacity, anim, dur], i) => (
<div
key={`neon-${i}`}
key={`shape-${i}`}
style={{
position: 'absolute',
top: `${top}%`,
left: '5%',
right: '5%',
height: '2px',
background: color,
color: color,
boxShadow: `0 0 10px ${color}, 0 0 20px ${color}, 0 0 40px ${color}`,
animation: `neon-pulse ${duration} ease-in-out infinite ${delay}, neon-flicker ${duration} ease-in-out infinite ${delay}`,
borderRadius: '1px',
top,
left,
width: w,
height: h,
background: bg,
opacity,
borderRadius: '50%',
filter: 'blur(40px)',
animation: `${anim} ${dur} ease-in-out infinite`,
pointerEvents: 'none',
zIndex: 1,
}}
/>
))}
{/* City skyline buildings */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '40%',
animation: 'city-breathe 6s ease-in-out infinite',
pointerEvents: 'none',
zIndex: 2,
}}
>
{BUILDINGS.map(([width, heightPct, leftPct, glowColor], i) => (
<div
key={`building-${i}`}
style={{
position: 'absolute',
bottom: 0,
left: `${leftPct}%`,
width: `${width}px`,
height: `${heightPct}%`,
background: 'rgba(5, 3, 15, 0.9)',
borderRadius: '2px 2px 0 0',
boxShadow: `0 -2px 10px ${glowColor}`,
}}
/>
))}
</div>
{/* Rain drops */}
{RAIN_DROPS.map(([left, delay, duration], i) => (
<div
key={`rain-${i}`}
style={{
position: 'absolute',
left: `${left}%`,
top: 0,
width: '1px',
height: '25px',
background: 'linear-gradient(180deg, transparent, rgba(6, 182, 212, 0.3))',
animation: `rain-fall ${duration} linear infinite ${delay}`,
pointerEvents: 'none',
zIndex: 3,
}}
/>
))}
{/* Fog layer */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '35%',
background: 'linear-gradient(180deg, transparent 0%, rgba(10, 4, 24, 0.6) 50%, rgba(10, 4, 24, 0.85) 100%)',
pointerEvents: 'none',
zIndex: 4,
}}
/>
{/* Top glow */}
<div
style={{
position: 'absolute',
top: '-10%',
left: '50%',
transform: 'translateX(-50%)',
width: '600px',
height: '300px',
background: 'radial-gradient(ellipse, rgba(168, 85, 247, 0.12) 0%, transparent 70%)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
{/* Login card */}
<div
style={{
@@ -236,58 +80,41 @@ export default function NeonCityView(): JSX.Element {
width: '100%',
maxWidth: 400,
margin: '0 20px',
background: 'rgba(10, 5, 25, 0.8)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(236, 72, 153, 0.2)',
borderRadius: 16,
padding: '44px 36px',
boxShadow: '0 0 20px rgba(236, 72, 153, 0.1), 0 8px 40px rgba(0,0,0,0.5)',
overflow: 'hidden',
background: 'rgba(255, 255, 255, 0.72)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
borderRadius: 20,
padding: '48px 40px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.06)',
}}
>
{/* Top neon line decoration */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '2px',
background: 'linear-gradient(90deg, #ec4899, #06b6d4, #a855f7, #ec4899)',
backgroundSize: '200% 100%',
animation: 'neon-pulse 3s ease-in-out infinite',
}}
/>
{/* Brand area */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ textAlign: 'center', marginBottom: 36 }}>
{/* Logo */}
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
width: 48,
height: 48,
borderRadius: 14,
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
boxShadow: '0 0 15px rgba(236, 72, 153, 0.5), 0 0 30px rgba(6, 182, 212, 0.3)',
boxShadow: '0 4px 16px rgba(59, 130, 246, 0.25)',
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L20 6V11C20 16 16 19.5 12 22C8 19.5 4 16 4 11V6L12 2Z" /><path d="M9 12L11 14L15 10" /></svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L20 6V11C20 16 16 19.5 12 22C8 19.5 4 16 4 11V6L12 2Z" /><path d="M9 12L11 14L15 10" /></svg>
</div>
{/* Title */}
<h1
style={{
fontSize: 24,
fontSize: 26,
fontWeight: 700,
color: '#fff',
color: '#1a1a2e',
margin: '0 0 8px',
letterSpacing: '0.5px',
animation: 'neon-glow-text 3s ease-in-out infinite',
letterSpacing: '0.3px',
}}
>
OpsxSQL
@@ -297,9 +124,9 @@ export default function NeonCityView(): JSX.Element {
<p
style={{
fontSize: 14,
color: 'rgba(6, 182, 212, 0.8)',
color: '#64748b',
margin: 0,
letterSpacing: '0.3px',
letterSpacing: '0.2px',
}}
>
企业级数据库审核与合规管理
@@ -309,16 +136,16 @@ export default function NeonCityView(): JSX.Element {
{/* Login form */}
<form onSubmit={handleLogin}>
{/* Username input */}
<div style={{ marginBottom: 18 }}>
<div style={{ marginBottom: 20 }}>
<label
htmlFor="username"
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
color: 'rgba(255,255,255,0.7)',
color: '#64748b',
marginBottom: 8,
letterSpacing: '0.3px',
letterSpacing: '0.2px',
}}
>
用户名
@@ -335,36 +162,36 @@ export default function NeonCityView(): JSX.Element {
height: 46,
padding: '0 16px',
fontSize: 14,
color: '#fff',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#1a1a2e',
background: '#f5f5f5',
border: '1.5px solid transparent',
borderRadius: 10,
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
transition: 'border-color 0.25s ease, background 0.25s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(236, 72, 153, 0.6)';
e.currentTarget.style.boxShadow = '0 0 10px rgba(236, 72, 153, 0.2)';
e.currentTarget.style.borderColor = '#93c5fd';
e.currentTarget.style.background = '#fff';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = 'transparent';
e.currentTarget.style.background = '#f5f5f5';
}}
/>
</div>
{/* Password input */}
<div style={{ marginBottom: 24 }}>
<div style={{ marginBottom: 28 }}>
<label
htmlFor="password"
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
color: 'rgba(255,255,255,0.7)',
color: '#64748b',
marginBottom: 8,
letterSpacing: '0.3px',
letterSpacing: '0.2px',
}}
>
密码
@@ -381,21 +208,21 @@ export default function NeonCityView(): JSX.Element {
height: 46,
padding: '0 16px',
fontSize: 14,
color: '#fff',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#1a1a2e',
background: '#f5f5f5',
border: '1.5px solid transparent',
borderRadius: 10,
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
transition: 'border-color 0.25s ease, background 0.25s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(6, 182, 212, 0.6)';
e.currentTarget.style.boxShadow = '0 0 10px rgba(6, 182, 212, 0.2)';
e.currentTarget.style.borderColor = '#93c5fd';
e.currentTarget.style.background = '#fff';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = 'transparent';
e.currentTarget.style.background = '#f5f5f5';
}}
/>
</div>
@@ -406,13 +233,10 @@ export default function NeonCityView(): JSX.Element {
style={{
marginBottom: 16,
padding: '10px 14px',
borderRadius: 8,
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
color: '#f87171',
borderRadius: 10,
background: '#fef2f2',
color: '#dc2626',
fontSize: 13,
textShadow: '0 0 8px rgba(239, 68, 68, 0.4)',
animation: 'neon-flicker 0.5s ease-in-out',
}}
>
{error}
@@ -422,36 +246,36 @@ export default function NeonCityView(): JSX.Element {
{/* Login button */}
<button
type="submit"
disabled={!username || !password || loading}
disabled={isDisabled}
style={{
width: '100%',
height: 48,
fontSize: 15,
fontWeight: 600,
color: '#fff',
background: 'linear-gradient(135deg, #ec4899, #06b6d4)',
background: isDisabled
? '#cbd5e1'
: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
border: 'none',
borderRadius: 10,
cursor: !username || !password || loading ? 'not-allowed' : 'pointer',
opacity: !username || !password ? 0.6 : 1,
boxShadow: '0 0 15px rgba(236, 72, 153, 0.4)',
transition: 'all 0.3s ease',
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'all 0.25s ease',
letterSpacing: '0.5px',
}}
onMouseEnter={(e) => {
if (username && password && !loading) {
e.currentTarget.style.boxShadow = '0 0 25px rgba(236, 72, 153, 0.6), 0 0 40px rgba(6, 182, 212, 0.3)';
e.currentTarget.style.transform = 'translateY(-1px)';
if (!isDisabled) {
e.currentTarget.style.transform = 'scale(1.02)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(59, 130, 246, 0.3)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '0 0 15px rgba(236, 72, 153, 0.4)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{loading ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L20 6V11C20 16 16 19.5 12 22C8 19.5 4 16 4 11V6L12 2Z" /><path d="M9 12L11 14L15 10" /></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" /><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite" /></svg>
登录中...
</span>
) : (
@@ -465,8 +289,8 @@ export default function NeonCityView(): JSX.Element {
style={{
textAlign: 'center',
fontSize: 12,
color: 'rgba(255,255,255,0.25)',
marginTop: 28,
color: '#94a3b8',
marginTop: 32,
marginBottom: 0,
}}
>
+24 -25
View File
@@ -15,8 +15,8 @@ const LOGIN_PREVIEW_COLORS: Record<LoginStyle, { bg: string; accent: string; lab
aurora: { bg: 'linear-gradient(180deg, #050810 0%, #0a0e1a 30%, #0d1425 60%, #080c14 100%)', accent: '#10b981', label: '极光之境' },
cosmos: { bg: '#030508', accent: '#8b5cf6', label: '星际漫游' },
inkwash: { bg: 'linear-gradient(180deg, #f5f0e8 0%, #ebe4d6 100%)', accent: '#c9a96e', label: '水墨山水' },
neoncity: { bg: 'linear-gradient(180deg, #0a0418 0%, #1a0b2e 40%, #0d0718 100%)', accent: '#ec4899', label: '霓虹都市' },
gradientmesh: { bg: '#0a0a0f', accent: '#6366f1', label: '渐变网格' },
neoncity: { bg: 'linear-gradient(180deg, #fafbfc 0%, #f0f2f5 100%)', accent: '#3b82f6', label: '白日梦境' },
gradientmesh: { bg: '#fafaf8', accent: '#8b5cf6', label: '云石纹理' },
};
export default function AppearanceSettings() {
@@ -293,36 +293,35 @@ export default function AppearanceSettings() {
</div>
)}
{opt.id === 'neoncity' && (
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, #0a0418 0%, #1a0b2e 40%, #0d0718 100%)' }}>
<div className="absolute" style={{ top: '25%', left: 0, right: 0, height: 1, background: 'linear-gradient(90deg, transparent, rgba(236,72,153,0.4), transparent)', boxShadow: '0 0 4px rgba(236,72,153,0.3)' }} />
<div className="absolute" style={{ top: '50%', left: 0, right: 0, height: 1, background: 'linear-gradient(90deg, transparent, rgba(6,182,212,0.3), transparent)', boxShadow: '0 0 4px rgba(6,182,212,0.2)' }} />
{[10, 25, 40, 55, 70, 85].map((l, i) => (
<div key={i} className="absolute bottom-0" style={{ left: `${l}%`, width: `${8 + i * 3}px`, height: `${20 + (i % 3) * 12}%`, background: 'rgba(5,3,15,0.9)' }} />
))}
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, #fafbfc 0%, #f0f2f5 100%)' }}>
<div className="absolute" style={{ top: '10%', left: '5%', width: '45%', height: '40%', background: 'radial-gradient(ellipse, rgba(147,197,253,0.25), transparent 70%)', filter: 'blur(12px)' }} />
<div className="absolute" style={{ bottom: '10%', right: '5%', width: '50%', height: '45%', background: 'radial-gradient(ellipse, rgba(196,181,253,0.2), transparent 70%)', filter: 'blur(12px)' }} />
<div className="absolute" style={{ top: '40%', left: '50%', width: '30%', height: '30%', background: 'radial-gradient(ellipse, rgba(147,197,253,0.15), transparent 70%)', filter: 'blur(10px)' }} />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-3/4 rounded-md p-1.5 flex flex-col items-center gap-0.5" style={{ background: 'rgba(10,5,25,0.8)', border: '1px solid rgba(236,72,153,0.2)', backdropFilter: 'blur(4px)' }}>
<div className="w-3 h-1 rounded-sm" style={{ background: 'rgba(236,72,153,0.5)' }} />
<div className="w-5 h-0.5 rounded-sm" style={{ background: 'rgba(6,182,212,0.3)' }} />
<div className="w-full h-0.5 rounded-sm" style={{ background: 'rgba(255,255,255,0.06)' }} />
<div className="w-full h-0.5 rounded-sm" style={{ background: 'rgba(255,255,255,0.06)' }} />
<div className="w-full h-1 rounded-sm mt-0.5" style={{ background: 'linear-gradient(90deg, #ec4899, #06b6d4)', opacity: 0.5 }} />
<div className="w-3/4 rounded-lg p-1.5 flex flex-col items-center gap-0.5" style={{ background: 'rgba(255,255,255,0.72)', backdropFilter: 'blur(8px)' }}>
<div className="w-3 h-1 rounded-sm" style={{ background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)' }} />
<div className="w-5 h-0.5 rounded-sm" style={{ background: '#94a3b8' }} />
<div className="w-full h-0.5 rounded-sm" style={{ background: '#f5f5f5' }} />
<div className="w-full h-0.5 rounded-sm" style={{ background: '#f5f5f5' }} />
<div className="w-full h-1 rounded-sm mt-0.5" style={{ background: 'linear-gradient(90deg, #3b82f6, #8b5cf6)', opacity: 0.8 }} />
</div>
</div>
</div>
)}
{opt.id === 'gradientmesh' && (
<div className="absolute inset-0" style={{ background: '#0a0a0f' }}>
<div className="absolute" style={{ top: '-20%', left: '-15%', width: '60%', height: '60%', background: 'radial-gradient(circle, rgba(99,102,241,0.2), transparent 70%)', filter: 'blur(10px)' }} />
<div className="absolute" style={{ bottom: '-15%', right: '-10%', width: '55%', height: '55%', background: 'radial-gradient(circle, rgba(244,63,94,0.15), transparent 70%)', filter: 'blur(10px)' }} />
<div className="absolute" style={{ top: '30%', right: '10%', width: '35%', height: '35%', background: 'radial-gradient(circle, rgba(16,185,129,0.1), transparent 70%)', filter: 'blur(8px)' }} />
<div className="absolute inset-0" style={{ backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.04) 0.5px, transparent 0.5px)', backgroundSize: '14px 14px' }} />
<div className="absolute inset-0" style={{ background: '#fafaf8' }}>
<div className="absolute" style={{ top: '-20%', left: '-15%', width: '60%', height: '60%', background: 'radial-gradient(circle, rgba(99,102,241,0.06), transparent 70%)', filter: 'blur(10px)' }} />
<div className="absolute" style={{ bottom: '-15%', right: '-10%', width: '55%', height: '55%', background: 'radial-gradient(circle, rgba(244,63,94,0.04), transparent 70%)', filter: 'blur(10px)' }} />
<div className="absolute" style={{ top: '30%', right: '10%', width: '35%', height: '35%', background: 'radial-gradient(circle, rgba(16,185,129,0.05), transparent 70%)', filter: 'blur(8px)' }} />
<div className="absolute" style={{ top: '5%', right: '25%', width: '40%', height: '40%', background: 'radial-gradient(circle, rgba(139,92,246,0.05), transparent 70%)', filter: 'blur(12px)' }} />
<div className="absolute inset-0" style={{ backgroundImage: 'radial-gradient(circle, rgba(0,0,0,0.06) 0.5px, transparent 0.5px)', backgroundSize: '14px 14px' }} />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-3/4 rounded-md p-1.5 flex flex-col items-center gap-0.5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)' }}>
<div className="w-3 h-1 rounded-sm" style={{ background: 'rgba(99,102,241,0.5)' }} />
<div className="w-5 h-0.5 rounded-sm" style={{ background: 'rgba(255,255,255,0.12)' }} />
<div className="w-full h-0.5 rounded-sm" style={{ background: 'rgba(255,255,255,0.06)' }} />
<div className="w-full h-0.5 rounded-sm" style={{ background: 'rgba(255,255,255,0.06)' }} />
<div className="w-full h-1 rounded-sm mt-0.5" style={{ background: 'linear-gradient(90deg, #6366f1, #ec4899)', opacity: 0.5 }} />
<div className="w-3/4 rounded-lg p-1.5 flex flex-col items-center gap-0.5" style={{ background: 'rgba(255,255,255,0.85)', boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}>
<div className="w-3 h-1 rounded-sm" style={{ background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)' }} />
<div className="w-5 h-0.5 rounded-sm" style={{ background: '#9ca3af' }} />
<div className="w-full h-0.5 rounded-sm" style={{ background: '#fff', border: '1px solid #e5e7eb' }} />
<div className="w-full h-0.5 rounded-sm" style={{ background: '#fff', border: '1px solid #e5e7eb' }} />
<div className="w-full h-1 rounded-sm mt-0.5" style={{ background: 'linear-gradient(90deg, #3b82f6, #8b5cf6)', opacity: 0.8 }} />
</div>
</div>
</div>
+2 -2
View File
@@ -34,8 +34,8 @@ export const LOGIN_STYLE_OPTIONS: { id: LoginStyle; name: string; desc: string }
{ id: 'aurora', name: '极光之境', desc: '北极光动态渐变,紫绿流光' },
{ id: 'cosmos', name: '星际漫游', desc: '深邃星空星云,银白粒子' },
{ id: 'inkwash', name: '水墨山水', desc: '东方水墨意境,动态墨痕' },
{ id: 'neoncity', name: '霓虹都市', desc: '赛博朋克城市,彩色霓虹' },
{ id: 'gradientmesh', name: '渐变网格', desc: '柔和渐变网格,现代呼吸感' },
{ id: 'neoncity', name: '白日梦境', desc: '纯白柔和几何,轻盈漂浮' },
{ id: 'gradientmesh', name: '云石纹理', desc: '暖白大理石纹理,优雅质感' },
];
export default function LoginPage() {
+133
View File
@@ -0,0 +1,133 @@
# SQL审核平台 — 登录页主题扩展交付文档
## 变更概要
将系统原有的 **5个登录页主题** 扩展至 **10个高级登录主题**,涵盖科技感、自然风、东方美学、赛博朋克、现代极简等多种视觉风格。
---
## 10个登录页主题一览
| # | 风格ID | 名称 | 视觉风格 | 状态 |
|---|--------|------|----------|------|
| 1 | `split` | 经典分屏 | 发光网格晶格,Tron科技感 | 原有 |
| 2 | `centered` | 居中卡片 | 黎明光柱,金色晨曦氛围 | 原有 |
| 3 | `sidepanel` | 侧边面板 | 深海波浪,生物发光粒子 | 原有 |
| 4 | `fullimage` | 全屏背景 | 星空+毛玻璃卡片 | 原有 |
| 5 | `minimal` | 深蓝紫曲线 | 优雅暗黑,粉紫流动曲线 | 原有 |
| 6 | `aurora` | **极光之境** | 北极光动态渐变,紫绿流光 | **新增** |
| 7 | `cosmos` | **星际漫游** | 深邃星空星云,银白粒子 | **新增** |
| 8 | `inkwash` | **水墨山水** | 东方水墨意境,动态墨痕 | **新增** |
| 9 | `neoncity` | **霓虹都市** | 赛博朋克城市,彩色霓虹 | **新增** |
| 10 | `gradientmesh` | **渐变网格** | 柔和渐变网格,现代呼吸感 | **新增** |
---
## 新增文件(5个)
```
frontend/src/components/login/
├── AuroraView.tsx # 极光之境 — 4条极光光带+星光+毛玻璃卡片
├── CosmosView.tsx # 星际漫游 — 星云+45颗星星+流星+行星
├── InkWashView.tsx # 水墨山水 — 三层远山+水墨晕染+金色点缀
├── NeonCityView.tsx # 霓虹都市 — 城市剪影+霓虹灯管+雨滴效果
└── GradientMeshView.tsx # 渐变网格 — 5个渐变光晕+点阵纹理+呼吸动画
```
---
## 修改文件(2个)
### 1. frontend/src/pages/LoginPage.tsx
- 新增5个组件导入
- 扩展 `LoginStyle` union type: 添加 `'aurora' | 'cosmos' | 'inkwash' | 'neoncity' | 'gradientmesh'`
- 扩展 `LOGIN_STYLES` 映射: 注册5个新组件
- 扩展 `LOGIN_STYLE_OPTIONS` 数组: 添加5个新选项(名称+描述)
### 2. frontend/src/pages/AppearanceSettings.tsx
- 扩展 `LOGIN_PREVIEW_COLORS`: 添加5个新风格的预览背景色、强调色、标签
- 添加5个新风格的迷你预览JSX(在设置页面的预览卡片中显示)
---
## 后端兼容性
**后端无需任何修改。** 现有API已完全兼容:
- `GET /api/v1/public/login-style` — 返回 `{ login_style: string }`,前端已支持10种值
- `GET/PUT /api/v1/user/preferences` — 用户偏好读写,login_style 字段支持任意字符串
- `SystemSetting` 表 — key=login_style 的存储逻辑无需变更
---
## 部署步骤
### 方式一:直接复制文件(推荐)
```bash
# 1. 将5个新组件复制到项目中
cp frontend/src/components/login/AuroraView.tsx <你的项目>/frontend/src/components/login/
cp frontend/src/components/login/CosmosView.tsx <你的项目>/frontend/src/components/login/
cp frontend/src/components/login/InkWashView.tsx <你的项目>/frontend/src/components/login/
cp frontend/src/components/login/NeonCityView.tsx <你的项目>/frontend/src/components/login/
cp frontend/src/components/login/GradientMeshView.tsx <你的项目>/frontend/src/components/login/
# 2. 用提供的文件替换修改的文件
cp frontend/src/pages/LoginPage.tsx <你的项目>/frontend/src/pages/
cp frontend/src/pages/AppearanceSettings.tsx <你的项目>/frontend/src/pages/
# 3. 启动开发服务器验证
cd <你的项目>/frontend && npm run dev
```
### 方式二:Git 合并
```bash
cd <你的项目>
git remote add design-patch /mnt/agents/output/sql-platform
git fetch design-patch
git cherry-pick <commit-hash> # 或手动合并文件
```
---
## 设计亮点
### 极光之境 (AuroraView)
- 4条不同颜色的极光光带(紫罗兰、翠绿、青蓝、紫绿混合)
- 每条光带有独特的 @keyframes 飘动动画(18-25秒周期)
- 15颗闪烁星光点缀,卡片带呼吸辉光
### 星际漫游 (CosmosView)
- 3个大型星云色块(蓝/紫/粉),带缓慢漂移动画
- 45颗星星(不同大小、颜色、闪烁节奏)
- 2条流星拖尾效果 + 带光环行星装饰
### 水墨山水 (InkWashView)
- 三层远山轮廓(不同透明度渐变+曲线borderRadius)
- 水墨晕染效果(模糊径向渐变模拟墨迹扩散)
- 金色点缀线 + 竖排"SQL/AUDIT"背景装饰
- **亮色主题**表单设计,与其他暗色主题形成对比
### 霓虹都市 (NeonCityView)
- 15-20栋城市建筑剪影,顶部带彩色霓虹glow
- 3条水平霓虹灯管(粉/青/紫),带脉动+闪烁动画
- 雨滴效果 + 透视网格地面
- 赛博朋克风格表单,粉色霓虹边框
### 渐变网格 (GradientMeshView)
- 5个大型彩色光晕圆形(靛蓝/玫瑰/翠绿/琥珀/紫),带呼吸漂移
- 淡点阵纹理覆盖层
- 高质量毛玻璃卡片(blur(40px) saturate(180%))
- 三色彩渐变登录按钮
---
## 技术规范
- **框架**: React + TypeScript
- **样式**: 全内联 style 属性,无 CSS 文件依赖
- **动画**: CSS @keyframes 通过 `<style>` 标签注入
- **Hook**: 统一使用 `useLogin` hook 处理登录逻辑
- **文案**: 全中文化
- **类型**: TypeScript 类型安全,默认导出组件
@@ -0,0 +1,32 @@
# Backend Python
__pycache__/
*.pyc
venv/
*.db
.env
*.sqlite3
*.db-journal
.pytest_cache/
apscheduler.db
# Backend Python (preserved but not tracked)
backend-python/
# Backend Go
backend/sql-platform
backend/go.sum
# Frontend
node_modules/
dist/
# OS
.DS_Store
Thumbs.db
# Runtime
.pids/
logs/
# Docs (large/generated)
OPTIMIZATION_GUIDE*.md
@@ -0,0 +1,97 @@
stages:
- test
- build
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
# Cache
cache:
key: "${CI_COMMIT_REF_SLUG}"
paths:
- backend/venv/
- frontend/node_modules/
- .cache/pip/
# ===== Test Stage =====
backend-test:
stage: test
image: python:3.11-slim
before_script:
- cd backend
- python -m venv venv
- source venv/bin/activate
- pip install -r requirements.txt -q
script:
- source venv/bin/activate
- python -m pytest tests/ -v --tb=short --junitxml=report.xml
artifacts:
reports:
junit: backend/report.xml
when: always
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "master"'
frontend-build:
stage: test
image: node:20-alpine
before_script:
- cd frontend
- npm ci
script:
- npm run build
artifacts:
paths:
- frontend/dist/
expire_in: 1 day
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "master"'
# ===== Build Stage =====
docker-build:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY" 2>/dev/null || true
script:
- docker build -t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHORT_SHA ./backend
- docker build -t $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHORT_SHA ./frontend
- docker push $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHORT_SHA 2>/dev/null || true
- docker push $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHORT_SHA 2>/dev/null || true
- echo "Build complete: $CI_COMMIT_SHORT_SHA"
rules:
- if: '$CI_COMMIT_BRANCH == "master"'
when: manual
tags:
- docker
# ===== Deploy Stage =====
deploy:
stage: deploy
image: alpine:3.19
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$DEPLOY_SSH_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- echo "$DEPLOY_SSH_HOST_KEY" > ~/.ssh/known_hosts 2>/dev/null || true
script:
- ssh $DEPLOY_USER@$DEPLOY_HOST "cd $DEPLOY_PATH && docker compose pull && docker compose up -d && echo 'Deploy done'"
environment:
name: production
url: http://$DEPLOY_HOST
rules:
- if: '$CI_COMMIT_BRANCH == "master"'
when: manual
tags:
- deploy
@@ -0,0 +1,198 @@
# SQL平台代码v1 优化包对比报告
> 分析日期: 2026-05-23
> 分析对象: ~/Downloads/SQL平台代码v1/ (optimized + final)
> 当前项目: ~/devops/sql-platform/
---
## 一、优化包概览
优化包包含两个版本:
- **sql-platform-optimized**: 中间优化版本(前 3 个 Stage 的产出)
- **sql-platform-final**: 最终版本(在 optimized 基础上新增数据库审核规则拆分)
两者基于 4 阶段优化计划(plan.md):
1. **Stage 1** — 后端关键缺陷修复
2. **Stage 2** — 前端界面全方位优化(4 组并行)
3. **Stage 3** — 权限逻辑 & 安全增强
4. **Stage 4** — 构建验证 & 最终集成
---
## 二、项目评估摘要(来自 PROJECT_EVALUATION.md)
| 维度 | 评分 | 说明 |
|------|------|------|
| 功能完成度 | 8.5/10 | 核心功能全覆盖:工单全生命周期、SQL审核引擎、规则CRUD、审计日志 |
| 代码质量 | 8.5/10 | TDD驱动、128测试、模块化清晰 |
| 安全性 | 9.0/10 | AES加密、bcrypt、JWT、AST防注入、权限矩阵 |
| 测试覆盖 | 8.5/10 | 128 tests 覆盖11模块 |
| 生产就绪度 | 8.0/10 | Docker部署、Alembic迁移、GitLab CI/CD |
| 用户体验 | 7.5/10 | 缺国际化、深色模式 |
| **综合评分** | **8.1/10** | 目标 9.0+ |
---
## 三、路线图优化项(来自 ROADMAP.md)
### 已完成的优化
| 分类 | 优化项 | 规模 |
|------|--------|------|
| **P0 数据库全景支持** | 统一查询服务 (MySQL/PG/Redis/MongoDB) | query_service.py 完整4种Handler |
| | Redis 只读命令白名单 | 40+ 只读命令 + 子命令验证 |
| | MongoDB 只读操作白名单 | find/findOne/countDocuments/distinct/aggregate |
| | 工单执行多数据库分发 | ticket_executor.py 多DB执行 |
| | MongoDB 连接器 + 前端管理页面 | mongo_connector.py 780行 |
| **P1 审计闭环** | 查询历史服务 | query_history_service.py |
| | SQL 助手 | sql_assistant.py (格式化/风险高亮/EXPLAIN) |
| | 审计规则种子 | MySQL 18规则 + PG 12规则 + Redis 8规则 |
| | 影响分析 | impact_analyzer.py |
| | 回滚生成 | rollback_generator.py |
| | Schema 漂移检测 | schema_drift_detector.py |
| **P2 高级功能** | 数据脱敏 | data_masker.py + masking_rule CRUD |
| | 通知服务 | 钉钉/飞书/邮件 SMTP |
| | 备份管理 | backup_executor.py |
| | 工作流引擎 | workflow_engine.py + workflow_seeds.py |
| | 慢日志收集 | slow_log_collector.py |
| | 连接池 | connection_pool.py |
### 待完成项
- [ ] 执行前后数据快照对比可视化(前端)
- [ ] 快捷查询模板(10个预置 + 自定义共享)
- [ ] SQL 格式化前端集成
- [ ] 查询结果格式化展示优化
- [ ] SQL 语法高亮编辑器
- [ ] Slow query 前端集成
- [ ] Dashboard 数据源健康概览
---
## 四、版本差异分析
### 4.1 optimized vs final 差异
**final 在 optimized 基础上新增了 4 个数据库专用审核规则文件:**
| 新增文件 | 行数 | 规则数量 | 覆盖范围 |
|----------|------|----------|----------|
| `mysql_audit_rules.py` | 2,101 行 | 63条 | DDL 36条 + DML 9条 + 命名 5条 + 性能 5条 + 安全 5条 |
| `redis_audit_rules.py` | 1,863 行 | 43条 | KEY管理10条 + 命令安全12条 + 数据结构8条 + 性能8条 + 安全5条 |
| `mongodb_audit_rules.py` | 1,638 行 | 42条 | 集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能 |
| `postgres_audit_rules.py` | 1,289 行 | 45条 | DDL 18条 + DML 8条 + DQL 6条 + 安全6条 + 性能7条 |
**final 还修改了 5 个关联文件:**
- `audit_engine_v2.py` — 新增 4 个 DB 规则模块的 import,规则数量从 100+ 升级到 200+
- `audit_rule_seeds.py` — 新增 MongoDB 种子规则 + 别名映射
- `audit_rule.py` (endpoint) — 新增数据库类型别名支持(postgresql→postgres, mongo→mongodb)
- `AuditRulesPage.tsx` — 前端新增 MongoDB/Redis 相关关键词识别
### 4.2 optimized vs 当前项目差异
**文件结构:完全一致**(无新增/缺失文件,仅 `__pycache__` 差异)
**内容差异:共 25 个文件有改动**
#### 后端 (9 个文件)
| 文件 | 改动行数 | 改动性质 |
|------|----------|----------|
| `services/workflow_engine.py` | 1,284 | 工作流引擎大幅扩展 |
| `services/query_service.py` | 720 | 查询服务优化/增强 |
| `services/audit_engine.py` | 858 | 审核引擎 V1 优化 |
| `services/data_masker.py` | 668 | 数据脱敏模块增强 |
| `services/ticket_executor.py` | 530 | 工单执行器改进 |
| `models/workflow.py` | 331 | 工作流模型扩展 |
| `models/user.py` | 278 | 用户模型扩展 |
| `endpoints/ticket.py` | 540 | 工单 API 改进 |
| `endpoints/query.py` | 256 | 查询 API 改进 |
#### 前端 (16 个文件)
| 文件 | 改动行数 | 改动性质 |
|------|----------|----------|
| `pages/InstancePage.tsx` | 1,372 | 实例管理页大幅优化 |
| `pages/WorkflowPage.tsx` | 971 | 工作流页面优化 |
| `pages/TicketsPage.tsx` | 960 | 工单列表页优化 |
| `pages/TicketCreatePage.tsx` | 868 | 工单创建页优化 |
| `pages/UsersPage.tsx` | 804 | 用户管理页优化 |
| `pages/SlowQueryPage.tsx` | 703 | 慢查询页优化 |
| `pages/TicketDetailPage.tsx` | 719 | 工单详情页优化 |
| `pages/DataDictionaryPage.tsx` | 650 | 数据字典页优化 |
| `pages/Dashboard.tsx` | 636 | 仪表盘优化 |
| `pages/InstanceGroupPage.tsx` | 631 | 实例组页优化 |
| `pages/AuditLogsPage.tsx` | 600 | 审计日志页优化 |
| `pages/QueriesPage.tsx` | 601 | 查询页优化 |
| `pages/AuditRulesPage.tsx` | 584 | 审核规则页优化 |
| `pages/RolesPage.tsx` | 519 | 角色管理页优化 |
| `hooks/useAuth.tsx` | 391 | 认证 hook 优化 |
| `components/SqlEditor.tsx` | 3 | SQL编辑器微调 |
---
## 五、对我们当前项目有价值的改动
### 🔴 高价值(建议立即采纳)
1. **数据库专用审核规则(final 独有)** — 193条数据库专用规则
- `mysql_audit_rules.py` (63条) — 生产级 MySQL 审核覆盖
- `redis_audit_rules.py` (43条) — Redis 命令安全检测
- `mongodb_audit_rules.py` (42条) — MongoDB 操作审核
- `postgres_audit_rules.py` (45条) — PostgreSQL 特有语法检查
- 关联修改:`audit_engine_v2.py`, `audit_rule_seeds.py`, `audit_rule.py`
2. **前端页面全面优化** — 16 个页面的 UI/UX 改进
- 所有核心页面均有数百行改动,估计是界面交互、组件化、错误处理方面的增强
3. **后端核心服务优化** — 9 个关键文件
- `workflow_engine.py` (1,284 行改动) — 工作流引擎重大升级
- `query_service.py` (720 行改动) — 查询服务改进
- `ticket_executor.py` (530 行改动) — 工单执行器改进
### 🟡 中等价值
4. **数据脱敏增强** — `data_masker.py` 668 行改动
5. **用户/工作流模型扩展** — `user.py` + `workflow.py` 模型字段扩展
6. **认证 hook 优化** — `useAuth.tsx` 391 行改动
### 🟢 待评估
7. **TDD 测试计划** — 当前 128 tests,Phase 4 安全测试未完成
8. **路线图待完成项** — 快照对比、查询模板、前端集成等
---
## 六、建议操作
### 立即可做
```bash
# 将 final 版本的4个新审核规则文件复制到我们的项目
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/mysql_audit_rules.py ~/devops/sql-platform/backend/app/services/
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/redis_audit_rules.py ~/devops/sql-platform/backend/app/services/
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/mongodb_audit_rules.py ~/devops/sql-platform/backend/app/services/
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/postgres_audit_rules.py ~/devops/sql-platform/backend/app/services/
```
### 需要逐步验证
- 逐个对比 25 个差异文件的改动内容,评估每项改动的兼容性
- 运行现有测试确保不引入回归
- 前端改动需逐一构建验证
### 建议策略
1. 先合并 `final` 版本新增的 4 个审核规则文件 + 关联修改(低风险,纯新增)
2. 逐页面合并前端优化(每合并一个页面后构建验证)
3. 最后处理后端核心服务改动(需配合测试)
---
## 七、总结
| 项目 | 数据 |
|------|------|
| 优化包总改动文件数 (vs 我们) | **29 个** (25差异 + 4新增) |
| 优化包总新增代码量 | ~6,900 行 (4个新规则文件) + 大量行级改动 |
| 文件结构差异 | **无** (完全一致的目录结构) |
| 最有价值的改动 | **193条数据库专用审核规则** (final 独有) |
| 风险评估 | 中等 — 文件结构一致但内容改动量大 |
@@ -0,0 +1,179 @@
# SQL-Platform-Pro 优化清单
> 基于当前实现状态(commit 39ffc14)的差距分析,按优先级 P0/P1/P2 分级。
> 状态标记:🟢已有基础 🟡部分实现 🔴未实现
---
## 一、假阳性/假阴性治理
### 现状
- MySQL 117 条审核规则(DDL-036 + DML 等),已持久化到 DB + 前端可调 severity/enabled
- 已做一轮降级(9 条 ERROR→WARNING),但缺少「为什么误报」的反馈闭环
- PostgreSQL / Redis / MongoDB 审核规则薄弱(PG 少量、Redis 仅 FLUSHDB 拦截、MongoDB 无专用规则)
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 1.1 | **P0** | **审核结果反馈机制** — 审批人在审批时可标记某条 finding 为「误报」,记录到 `audit_feedback` 表(rule_code, ticket_id, feedback_type, reason),月度统计误报率 TOP10 规则 | 🔴 | 2d |
| 1.2 | **P0** | **上下文感知规则** — `ALTER TABLE ADD INDEX` 在小表(<1万行)应 WARNING 而非 ERROR;连接目标库查 `table_rows` 动态调整 severity | 🔴 | 3d |
| 1.3 | **P1** | **规则分组白名单** — 支持实例级别禁用/调整规则(如测试环境放宽所有 DDL 规则),`instance_rule_override` 表 | 🔴 | 2d |
| 1.4 | **P1** | **PG 审核规则补全** — 参照 MySQL 规则体系,补齐 PG 特有规则(SERIAL 滥用、JSONB 无索引、VACUUM 建议、大对象等) | 🟡 | 3d |
| 1.5 | **P2** | **MongoDB 审核规则** — Pipeline 复杂度限制、`$where`/`eval` 禁止、`dropDatabase` 拦截、索引建议 | 🔴 | 3d |
| 1.6 | **P2** | **规则测试基准** — 每条规则至少 3 个 case(正常通过/应拦截/边界),CI 自动验证规则回归 | 🔴 | 3d |
---
## 二、大 SQL / 复杂语句处理
### 现状
- `_split_statements()` 用 sqlparse 切割多条 SQL
- 无 SQL 长度/复杂度上限(有 `max_query_length` 但仅查长度)
- MongoDB Pipeline 无复杂度检查
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 2.1 | **P1** | **SQL 复杂度评分** — 解析 JOIN 数量、子查询嵌套层数、UNION 数量,超过阈值(可配置)提升 risk_level | 🔴 | 2d |
| 2.2 | **P1** | **审核超时保护** — 单条 SQL 审核超过 5s 自动跳过(标记 timeout),避免正则/解析卡死 | 🔴 | 1d |
| 2.3 | **P1** | **审核缓存** — 相同 SQL 文本(去空格后 hash)短期不重复审核,LRU 缓存 1000 条 | 🔴 | 1d |
| 2.4 | **P2** | **MongoDB Pipeline 复杂度** — 限制 stage 数量(默认 20)、`$lookup` 嵌套层数、`$group` 内存风险 | 🔴 | 2d |
| 2.5 | **P2** | **SQL 格式化展示** — 审核详情中高亮问题位置(行号+列号),用 sqlparse 格式化 | 🔴 | 1d |
---
## 三、事务一致性(多条 SQL 原子性)
### 现状
- MySQL 执行有 `conn.rollback()` 失败回滚
- PG 执行有 `transaction()` 包裹
- 但 **跨库没有事务**,同库多条 SQL 部分成功部分失败时缺少明确的补偿机制
- Redis/MongoDB 多命令无事务概念(Redis 有 MULTI/EXEC 但未使用)
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 3.1 | **P0** | **执行进度透明** — 多条 SQL 逐条执行,每条记录 status( pending/running/success/failed ),前端实时展示 | 🟡 | 3d |
| 3.2 | **P0** | **部分失败处理策略** — 可选:`stop_on_error`(默认,遇错停止)/ `continue_on_error`(跳过失败继续)/ `transaction`(整体回滚) | 🔴 | 2d |
| 3.3 | **P1** | **自动回滚 SQL 生成** — INSERT→DELETE、UPDATE→反向 UPDATE、DDL→DROP/ALTER REVERSE,记录到 `rollback_sql` 字段 | 🔴 | 5d |
| 3.4 | **P1** | **Redis MULTI/EXEC** — 工单中多条 Redis 命令用 pipeline 批量执行,保证原子性 | 🔴 | 1d |
| 3.5 | **P2** | **跨库事务标记** — 跨库工单标注「无原子性保障」警告,审批人二次确认 | 🔴 | 1d |
---
## 四、锁表与业务影响分析
### 现状
- 有工单执行锁(防并发执行同一工单)
- **无 EXPLAIN 预估**、无影响行数预估、无锁表风险提示
- 无大表 ALTER 的 pt-osc / gh-ost 建议
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 4.1 | **P0** | **EXPLAIN 预检** — 审核阶段对 DML 执行 EXPLAIN(MySQL)/ EXPLAIN ANALYZE(PG),提取 `rows` 预估扫描行数,超过阈值(可配置,默认 10 万)标 ERROR | 🔴 | 3d |
| 4.2 | **P0** | **大表 ALTER 检测** — 查 `information_schema.tables` 获取表行数,大表(>10万行)ALTER 时提示「建议使用 pt-online-schema-change / gh-ost」,risk_level=high | 🔴 | 2d |
| 4.3 | **P1** | **影响行数预估** — DML 工单执行前用 `SELECT COUNT(*) FROM ... WHERE <condition>` 预估影响行数,超过阈值(默认 1 万)强制高等级审批 | 🔴 | 2d |
| 4.4 | **P1** | **执行时间预估** — 基于 EXPLAIN 的 rows × 历史执行平均耗时,给出粗略预估(快<1s / 中<30s / 慢>30s / 极慢>5min) | 🔴 | 2d |
| 4.5 | **P2** | **执行超时强杀** — MySQL `max_execution_time` / PG `statement_timeout` 硬超时,超时自动 KILL 并标记 failed | 🟡 | 1d |
| 4.6 | **P2** | **维护窗口控制** — 可配置允许执行 DDL 的时间窗口(如凌晨 2-5 点),窗口外提交自动拒绝 | 🔴 | 1d |
---
## 五、敏感操作审核强化
### 现状
- DROP TABLE → 审核规则 ERROR(DDL-004)
- Redis FLUSHDB/FLUSHALL → 仅限 admin
- **但审批只有一级**(任意 admin/dba 可批),无多级审批
- 无风险等级区分(所有工单走同一审批流)
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 5.1 | **P0** | **工单风险等级** — 根据 SQL 类型 + 操作对象自动计算 risk_level(low/medium/high/critical):DROP/TRUNCATE/FLUSHDB=critical,大表 ALTER=high,普通 DML=medium,SELECT=low | 🔴 | 2d |
| 5.2 | **P0** | **多级审批流** — risk_level=high 需要 2 人审批(DBA+管理员),critical 需要 3 人审批(DBA+管理员+CTO 或指定人),利用现有 WorkflowStep 模型 | 🟡 | 3d |
| 5.3 | **P1** | **高危操作二次确认** — critical 级别工单执行前,执行人必须输入工单编号确认(防误操作) | 🔴 | 1d |
| 5.4 | **P1** | **敏感表保护** — 配置「保护表清单」(如 user/password/order/pay),对这些表的任何 DML/DDL 自动提升 risk_level,额外审批 | 🔴 | 2d |
| 5.5 | **P2** | **执行前备份** — critical 工单执行前自动备份受影响表(`CREATE TABLE xxx_backup AS SELECT *`),备份保留可配置天数 | 🔴 | 3d |
| 5.6 | **P2** | **MongoDB 危险操作** — `dropDatabase`、`dropCollection`、`remove({})` 拦截 + 多级审批 | 🔴 | 1d |
---
## 六、数据脱敏与审计
### 现状
- `MaskingRule` 模型已有(full/partial/hash/truncate/regex 5 种类型)
- 查询接口有 `DataMasker` 应用脱敏
- 审计日志 `AuditLog` 记录操作
- **但审核详情中 SQL 可能看到生产敏感数据**、通知中可能泄露信息
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 6.1 | **P0** | **通知脱敏** — 通知消息中的 SQL 内容截断(>200 字符只显示前 100 + ...),隐藏 WHERE 中的值(如 `phone='138****1234'`) | 🔴 | 1d |
| 6.2 | **P1** | **审核日志不可篡改** — AuditLog 表增加 `checksum` 字段(SHA256 of content),定期校验完整性;或写入只追加日志文件 | 🔴 | 2d |
| 6.3 | **P1** | **操作导出** — 审计日志支持 CSV/Excel 导出(按时间/用户/操作类型过滤),满足合规审计需求 | 🔴 | 2d |
| 6.4 | **P1** | **审核详情脱敏** — 审批人看到的工单 SQL 中,对配置了脱敏的字段值做 mask(如 INSERT INTO users (phone) VALUES ('138****1234')) | 🔴 | 2d |
| 6.5 | **P2** | **会话级脱敏策略** — 不同角色看到不同级别的脱敏(admin 看完整、dba 看部分脱敏、developer 看完全脱敏) | 🔴 | 2d |
| 6.6 | **P2** | **SQL 注入检测** — 审核阶段检测疑似 SQL 注入模式(如连续单引号、UNION SELECT 注入、hex 编码绕过) | 🔴 | 2d |
---
## 优先级排序(建议实施路线)
### 第一阶段(2 周)— 安全基线
```
5.1 工单风险等级 → 后续所有功能的基础
1.2 上下文感知规则 → 减少误报最有效的手段
4.1 EXPLAIN 预检 → DML 执行安全核心
3.1 执行进度透明 → 多条 SQL 可观测性
6.1 通知脱敏 → 快速见效
```
### 第二阶段(2 周)— 体验优化
```
5.2 多级审批流 → 基于风险等级的审批
3.2 部分失败处理策略 → 多条 SQL 容错
4.2 大表 ALTER 检测 → DBA 最关心的场景
4.3 影响行数预估 → 审批人最需要的信息
1.1 审核结果反馈机制 → 持续优化规则质量
2.2 审核超时保护 → 稳定性兜底
```
### 第三阶段(2 周)— 高级特性
```
3.3 自动回滚 SQL 生成 → 最高价值但最复杂
4.4 执行时间预估 → 配合维护窗口
5.3 高危操作二次确认 → 防呆机制
5.4 敏感表保护 → 细粒度管控
1.4 PG 审核规则补全 → 多 DB 支持
6.2 审计日志不可篡改 → 合规需求
```
### 第四阶段(持续)— 生态完善
```
1.5 MongoDB 审核规则
1.6 规则测试基准
2.1 SQL 复杂度评分
5.5 执行前备份
6.3 操作导出
6.6 SQL 注入检测
```
---
## 预估总工作量
| 阶段 | 工作量 | 核心目标 |
|------|--------|----------|
| P0 合计 | ~15 人天 | 安全基线 + 防误报 |
| P1 合计 | ~23 人天 | 审批流 + 影响分析 |
| P2 合计 | ~18 人天 | 高级特性 + 合规 |
| **总计** | **~56 人天** | — |
@@ -0,0 +1,149 @@
# 优化清单可行性评估
> 对 OPTIMIZATION_PLAN.md 逐项评审,结论:**可行但有水分,需要砍掉/合并部分项**
>
> 标记:✅可直接做 ⚠️需要架构调整 ❌过度设计建议砍掉
---
## 总体结论
| 维度 | 评估 |
|------|------|
| **方向正确性** | ✅ 6 个方向都是真实的运维痛点 |
| **工作量估算** | ⚠️ 偏乐观,56 人天实际可能 80+,因为很多项需要改 audit 方法签名(目前不接受 DB 连接) |
| **架构瓶颈** | ⚠️ 审核引擎是纯静态分析(只看 SQL 文本),要加 EXPLAIN/影响分析需要大改 |
| **过度设计项** | ❌ 约 8 项(见下文),属于「有了更好但不是刚需」 |
| **实际建议** | 砍到 **18 项**,分 3 阶段,约 **35 人天** |
---
## 逐项评审
### 一、假阳性/假阴性治理
| # | 评估 | 理由 |
|---|------|------|
| 1.1 审核结果反馈机制 | ⚠️ **可做但优先级降低** | 需要新表 + 前端改动,但价值在「长期优化」,前期没有足够数据量来统计误报率。建议后期做 |
| 1.2 上下文感知规则 | ✅ **核心价值** | 同一条 ALTER TABLE,小表 WARNING 大表 ERROR,这才是减少假阳性的关键。但实现难点:**审核引擎目前没有 DB 连接**,需要改 `audit()` 方法签名,加 `instance_id` 参数 → 查表行数 |
| 1.3 规则分组白名单 | ⚠️ **合理但可简化** | 不需要 `instance_rule_override` 表,直接在现有 `audit_rules` 表加 `scope` 字段(instance_id NULL=全局,非 NULL=实例级覆盖) |
| 1.4 PG 审核规则补全 | ⚠️ **可以做** | 但 PG 用户量通常远小于 MySQL,优先级可以降低。先补 10 条核心规则就够 |
| 1.5 MongoDB 审核规则 | ❌ **砍掉或极简** | MongoDB 工单场景极少(大部分是 Redis + MySQL),先只做 `dropDatabase` 拦截一条就够 |
| 1.6 规则测试基准 | ⚠️ **有价值但量大** | 117 条 × 3 case = 351 个测试。建议只给 ERROR 级别规则写测试(约 20 条 × 3 = 60 个) |
### 二、大 SQL / 复杂语句处理
| # | 评估 | 理由 |
|---|------|------|
| 2.1 SQL 复杂度评分 | ⚠️ **有一定价值** | 但 JOIN 数/子查询层数对 DBA 审批帮助有限。简化版:只算 SQL 长度 + 语句条数就够了 |
| 2.2 审核超时保护 | ✅ **必须做** | 正则/解析卡死会拖垮整个审核。`asyncio.wait_for(audit(), timeout=5)` 即可,半天工作量 |
| 2.3 审核缓存 | ❌ **砍掉** | 同一 SQL 重复审核的场景极少(工单每次 SQL 都不同),LRU 缓存是过度优化 |
| 2.4 MongoDB Pipeline 复杂度 | ❌ **砍掉** | 同 1.5,MongoDB 工单场景少 |
| 2.5 SQL 格式化展示 | ⚠️ **可做但不是 P0** | 前端已有 CodeMirror,高亮问题位置需要额外 parser 工作。放 P2 |
### 三、事务一致性
| # | 评估 | 理由 |
|---|------|------|
| 3.1 执行进度透明 | ✅ **核心价值** | 多条 SQL 逐条记录状态,审批人能看清哪条成功哪条失败。现有 `_split_statements` 已有框架,改动不大 |
| 3.2 部分失败处理策略 | ✅ **必须做** | 3 种模式(遇错停止/跳过继续/整体回滚)覆盖所有场景。MySQL 已有 transaction,PG 已有 transaction(),加上策略枚举即可 |
| 3.3 自动回滚 SQL 生成 | ❌ **砍掉** | INSERT→反向 DELETE 看似简单,但 UPDATE 的反向需要 SELECT 旧值,DDL 不可逆。业界 Yearning/Archery 也只做了 INSERT 回滚,做了半吊子反而危险。不如做成「执行前手动填写回滚 SQL」 |
| 3.4 Redis MULTI/EXEC | ⚠️ **可做但简化** | Redis Pipeline(非事务)已经能批量执行,`MULTI/EXEC` 在跨 slot 时会失败。用 pipeline 就够 |
| 3.5 跨库事务标记 | ✅ **简单有效** | 前端/通知加一个警告标签,半天工作量 |
### 四、锁表与业务影响分析
| # | 评估 | 理由 |
|---|------|------|
| 4.1 EXPLAIN 预检 | ⚠️ **核心价值但实现复杂** | 最大难点:**审核引擎是纯静态分析**,`audit(sql, db_type)` 没有 DB 连接。要跑 EXPLAIN 需要:(1) 改 audit 方法签名加 instance_id (2) 审核时建临时连接 (3) 执行 EXPLAIN (4) 解析结果。涉及连接池管理、超时处理、权限控制,实际工作量 5d 起步 |
| 4.2 大表 ALTER 检测 | ⚠️ **同 4.1 的前置问题** | 需要查 `information_schema`,同样需要 DB 连接。但可以做成「创建工单时」而非「审核时」查询,利用 ticket endpoint 已有的 db session |
| 4.3 影响行数预估 | ⚠️ **与 4.1 合并** | `SELECT COUNT(*) WHERE <condition>` 就是 EXPLAIN 的简化版,不需要分开做 |
| 4.4 执行时间预估 | ❌ **砍掉** | EXPLAIN 的 rows × 历史耗时 = 极不准,给用户错误预期反而有害。不如只给「快/慢」二分 |
| 4.5 执行超时强杀 | ✅ **已部分实现** | `asyncio.wait_for(cur.execute(stmt), timeout=xxx)` 已有。补充 MySQL 的 `max_execution_time` hint 和 PG 的 `statement_timeout` SET 即可 |
| 4.6 维护窗口控制 | ⚠️ **简单有效** | 配置项 + 中间件检查,1d 工作量 |
### 五、敏感操作审核强化
| # | 评估 | 理由 |
|---|------|------|
| 5.1 工单风险等级 | ✅ **基础能力,必须先做** | Ticket 模型已有 `priority` 字段,直接复用或加 `risk_level`。根据 SQL 类型 + 操作对象自动计算,是后续多级审批的基础 |
| 5.2 多级审批流 | ⚠️ **架构已有但逻辑未通** | WorkflowStep 已有 `step_type=serial/parallel/any_one/condition`,但 ticket 创建时**没有关联 workflow_id**。需要补上:ticket 绑定 workflow → 按 risk_level 匹配不同审批流 |
| 5.3 高危操作二次确认 | ✅ **简单有效** | 前端弹框要求输入工单编号,半天工作量 |
| 5.4 敏感表保护 | ⚠️ **可以做但配置繁琐** | 需要维护「保护表清单」,DBA 愿意配置才行。先做成全局配置(`protected_tables: ["user","password","order","pay"]`) |
| 5.5 执行前备份 | ❌ **砍掉** | `CREATE TABLE xxx_backup AS SELECT *` 对大表会锁表 + 占空间,与要解决的问题矛盾。改成建议提示「请手动备份」更实际 |
| 5.6 MongoDB 危险操作 | ❌ **合并到 5.1** | dropDatabase 在风险等级计算里处理即可,不需要单独做 |
### 六、数据脱敏与审计
| # | 评估 | 理由 |
|---|------|------|
| 6.1 通知脱敏 | ✅ **简单有效** | 模板里 `{sql_preview}` 已经是截断的(取前 100 字符),但需要确认 `error` 字段有没有泄露数据。1d 工作量 |
| 6.2 审计日志不可篡改 | ❌ **砍掉** | checksum 方案在更新/删除场景下无法自洽,需要区块链/WORM 才能真正防篡改。对内部系统来说,DB 权限控制 + 操作审计就够了 |
| 6.3 操作导出 | ✅ **实用** | AuditLog + QueryHistory CSV 导出,API 加个 endpoint 即可 |
| 6.4 审核详情脱敏 | ⚠️ **理想但实现困难** | 对 SQL 中的字面值做脱敏需要 SQL parser 精确定位值位置,正则方案容易误伤。简化版:对审批人隐藏 `WHERE phone='13812341234'` 中的值 |
| 6.5 会话级脱敏策略 | ❌ **砍掉** | 过度设计,现有 MaskingRule 已够用 |
| 6.6 SQL 注入检测 | ⚠️ **有价值但非 P0** | 与审核引擎结合,检测 `UNION SELECT`、`OR 1=1`、hex 编码等模式。放 P2 |
---
## 修订后实施计划(18 项 / ~35 人天)
### 第一阶段:安全基线(~10d)
| # | 项目 | 工作量 | 核心改动 |
|---|------|--------|----------|
| 1 | **5.1 工单风险等级** | 2d | Ticket 加 `risk_level` 字段 + 自动计算逻辑(后端) |
| 2 | **5.2 多级审批流打通** | 3d | Ticket 绑定 workflow_id → 按 risk_level 匹配 → 多步审批 |
| 3 | **2.2 审核超时保护** | 0.5d | `wait_for(audit(), timeout=5)` |
| 4 | **3.1 执行进度透明** | 2d | `ticket_sql_items` 表 + 逐条执行 + 前端实时状态 |
| 5 | **6.1 通知脱敏** | 1d | 模板中截断 SQL + 隐藏 WHERE 值 |
| 6 | **5.3 高危二次确认** | 0.5d | 前端输入工单编号确认框 |
| 7 | **4.6 维护窗口控制** | 1d | system_settings 配置 + 中间件检查 |
### 第二阶段:影响分析(~12d)
| # | 项目 | 工作量 | 核心改动 |
|---|------|--------|----------|
| 8 | **4.1+4.3 EXPLAIN + 影响行数** | 5d | 改 audit 签名 + DB 连接 + EXPLAIN 解析 + 前端展示 |
| 9 | **4.2 大表 ALTER 检测** | 2d | 创建工单时查 information_schema + 风险提示 |
| 10 | **3.2 部分失败策略** | 2d | stop_on_error / continue / transaction 三模式 |
| 11 | **1.2 上下文感知规则** | 3d | ALTER 规则查表行数动态调 severity |
### 第三阶段:体验完善(~13d)
| # | 项目 | 工作量 | 核心改动 |
|---|------|--------|----------|
| 12 | **1.4 PG 审核规则** | 3d | 补 10 条核心规则 |
| 13 | **1.3 规则白名单(简化版)** | 2d | audit_rules 加 scope 字段 |
| 14 | **4.5 执行超时强杀** | 1d | MySQL SET max_execution_time + PG SET statement_timeout |
| 15 | **6.3 审计日志导出** | 2d | CSV/Excel 导出 API |
| 16 | **3.5 跨库事务标记** | 0.5d | 前端/通知警告标签 |
| 17 | **5.4 敏感表保护** | 2d | 全局 protected_tables 配置 |
| 18 | **2.1 SQL 复杂度(简化版)** | 1d | 只算长度 + 语句条数 |
### 砍掉的 12 项
| # | 砍掉原因 |
|---|----------|
| 1.1 审核结果反馈 | 数据量不够,后期做 |
| 1.5 MongoDB 审核规则 | 场景少,dropDatabase 合并到风险等级 |
| 1.6 规则测试基准 | 工作量大,改为只给 ERROR 规则写测试 |
| 2.3 审核缓存 | 同 SQL 重复审核场景极少 |
| 2.4 MongoDB Pipeline 复杂度 | 场景少 |
| 2.5 SQL 格式化展示 | 前端 CodeMirror 够用 |
| 3.3 自动回滚 SQL 生成 | 做半吊子反而危险 |
| 4.4 执行时间预估 | 不准,给错误预期 |
| 5.5 执行前备份 | 大表备份本身锁表 |
| 5.6 MongoDB 危险操作 | 合并到风险等级 |
| 6.2 审计日志不可篡改 | 内部系统不需要区块链 |
| 6.5 会话级脱敏策略 | 现有 MaskingRule 够用 |
---
## 关键架构决策(实施前需确认)
1. **审核引擎签名改造** — `audit(sql, db_type)` → `audit(sql, db_type, instance_id=None, db_session=None)`,加了 DB 连接才能跑 EXPLAIN。这是一个破坏性变更,需要评估影响面
2. **多级审批路由** — ticket 创建时如何匹配 workflow?方案:(A) 按 instance 关联的 workflow_id (B) 按 risk_level 查默认 workflow。建议用 (A),instance 已有 `workflow_id` 字段
3. **执行进度存储** — 是新建 `ticket_sql_items` 表还是复用 `execute_result` JSON?建议新建表,便于查询和展示
@@ -0,0 +1,126 @@
# SQL-Platform 数据审核平台 - 项目评估
> 评估日期: 2025-05-22
> 评估版本: v0.5.0 (Phase 1-5 全部完成)
---
## 一、项目概况
| 项目 | 说明 |
|------|------|
| 项目名 | SQL-Platform 数据审核平台 (sql-platform) |
| 定位 | SQL 审核与工单管理平台(对标 Yearning/ArcSQL) |
| 技术栈 | FastAPI + SQLite + React + TypeScript + Vite |
| 代码仓库 | ~/devops/sql-platform |
| Git 提交 | 8 commits |
| 测试 | 128 tests passing |
| 部署 | Docker (docker-compose),SQLite 轻量存储 |
---
## 二、功能完成度
### 后端 API (FastAPI)
| 模块 | 端点数 | 状态 |
|------|--------|------|
| 认证 (auth) | 4 | ✅ 登录/登出/获取用户/验证token |
| 用户管理 (user) | 5 | ✅ CRUD + 状态切换 |
| 数据源 (instance) | 6 | ✅ CRUD + 连接测试 + AES加密 |
| 工单 (ticket) | 8 | ✅ 创建/审批/拒绝/执行/统计/分页 |
| 在线查询 (query) | 1 | ✅ AST安全验证 + 自动LIMIT |
| 仪表盘 (dashboard) | 1 | ✅ 统计数据 |
| 审核引擎 (audit) | 3 | ✅ 规则引擎10条 + 自动评分 + 集成 |
| 审核规则 (audit-rule) | 7 | ✅ CRUD + toggle + stats + filter |
| 审计日志 (audit) | 2 | ✅ 列表/过滤/分页 |
### 前端页面 (React)
| 页面 | 路由 | 状态 |
|------|------|------|
| 登录 | /login | ✅ JWT认证 |
| 仪表盘 | /dashboard | ✅ 统计图表 |
| 数据源管理 | /mysql, /postgres 等 | ✅ CRUD + 连接测试 |
| 工单管理 | /tickets | ✅ 创建/审批/执行/统计 |
| 在线查询 | /queries | ✅ SQL执行 + 结果表格 |
| 用户管理 | /users | ✅ CRUD + 状态切换 |
| 审核规则 | /audit-rules | ✅ CRUD + toggle + stats |
| 审计日志 | /audit-logs | ✅ 分页 + 过滤 |
---
## 三、安全特性
| 特性 | 实现 |
|------|------|
| 密码存储 | ✅ bcrypt hash |
| JWT Token | ✅ 30分钟过期 |
| 数据源密码 | ✅ AES-256-GCM 加密 |
| SQL 防注入 | ✅ sqlparse AST 解析 |
| 操作审计 | ✅ 全操作记录 |
| 权限控制 | ✅ admin/dba/developer/guest |
---
## 四、测试覆盖
| 测试文件 | 数量 | 覆盖范围 |
|----------|------|----------|
| test_auth | 8 | 登录/登出/token验证 |
| test_users | 8 | CRUD/权限/自删 |
| test_instances | 8 | CRUD/加密/连接测试 |
| test_tickets | 11 | 全生命周期/统计/分页 |
| test_query | 4 | SELECT/DROP/DELETE/guest |
| test_dashboard | 2 | 统计/未登录 |
| test_crypto | 9 | AES加密/解密/兼容 |
| test_audit_engine | 23 | 10条规则/集成测试 |
| test_audit_logs | 9 | API/CRUD审计/分页 |
| test_sql_analyzer | 34 | 类型检测/风险/安全检查 |
| test_audit_rules | 14 | CRUD/权限/toggle/stats |
| **合计** | **128** | **11 模块** |
---
## 五、DevOps
| 项目 | 状态 |
|------|------|
| Dockerfile (backend) | ✅ Python 3.11 + auto migrate |
| Dockerfile (frontend) | ✅ Node 20 build + nginx |
| docker-compose | ✅ 双容器 + volume |
| Alembic 迁移 | ✅ 初始 schema |
| requirements.txt | ✅ 12 依赖 |
---
## 六、评分
| 维度 | 分数 (1-10) | 说明 |
|------|-------------|------|
| **功能完成度** | **8.5** | 核心功能全覆盖:工单全生命周期、SQL审核引擎、规则CRUD、审计日志 |
| **代码质量** | **8.5** | TDD驱动、128测试、模块化清晰、类型注解完整 |
| **安全性** | **9.0** | AES加密、bcrypt、JWT、AST防注入、权限矩阵、审计日志 |
| **测试覆盖** | **8.5** | 128 tests 覆盖11模块,含单元+集成+安全测试 |
| **生产就绪度** | **8.0** | Docker部署、Alembic迁移、SQLite轻量、真实DB连接、GitLab CI/CD |
| **用户体验** | **7.5** | 前端响应式、过滤分页、toast提示;缺国际化、深色模式 |
| **文档** | **8.0** | ROADMAP + TDD_PLAN + EVALUATION 完整 |
| **综合评分** | **8.1** | |
---
## 七、下一步优化建议
### 高优先级 (P0)
1. **慢查询分析** — EXPLAIN 执行计划展示 + 慢查询日志
2. **邮件/Webhook 通知** — 工单状态变更通知
3. **数据源探针** — 实例健康检查 + 连接池
### 中优先级 (P1)
4. **权限矩阵细化** — 数据源级权限、SQL 类型权限
5. **SQL 格式化** — sqlparse 格式化展示
6. **操作回滚** — SQL 执行前自动备份
### 低优先级 (P2)
7. **国际化 (i18n)** — 中英文切换
8. **深色模式** — Tailwind dark mode
@@ -0,0 +1,40 @@
# SQL-Platform-Pro v0.1.0 Release Notes
## 🎯 首个里程碑版本
核心定位:**自助查询 + 数据变更审批流**,支持 MySQL / PostgreSQL / Redis / MongoDB 多数据库。
## ✨ 核心功能
### 数据源管理
- 多数据库实例注册(MySQL / PostgreSQL / Redis / MongoDB)
- 连接测试(异步驱动 + 品牌图标)
- 独立编辑弹框 + 4列卡片布局
### SQL 审核引擎
- MySQL: 67条规则 (DDL 36 + DML 9 + 安全 9 + 性能 5 + 命名 5 + OSC 3)
- PostgreSQL: 31条规则
- Redis: 25条规则
- MongoDB: 31条规则
- 6分组前端展示: DDL / DML / 安全 / DCL / DQL / 性能
- 结构化审核结果: 分数 + 通过/阻断 + findings 逐条
### 数据变更审批流
- 标准审批流(DBA审批 → 管理员终审)
- 简单审批流 / 紧急审批流
- 工单全生命周期: 创建 → 审核 → 审批 → 执行 → 结果
- 执行结果结构化展示(状态/影响行数/耗时/错误/警告)
### 权限体系
- RBAC 角色权限(admin / dba / developer)
- 数据源级别权限控制
- 完整审计日志
## 🧪 测试
- **1040** 测试全部通过
- 前端 TypeScript 零错误
- TDD 开发流程
## 🏗️ 技术栈
- 后端: Python / FastAPI / SQLAlchemy async / SQLite
- 前端: React / Vite / TypeScript / shadcn/ui / Tailwind / CodeMirror
@@ -0,0 +1,64 @@
# SQL Audit Platform Roadmap
## 当前版本: v0.8.1
## 已完成
### Phase 1-7 (v0.1.0 ~ v0.7.0)
- 基线版本 + 权限组 + 审批流 + 多数据库查询/执行引擎
- 后端测试: 808/808 全部通过
- 前端: 27 个页面, 构建零错误
### v0.8.1 — 技术债务清理
- ✅ Pydantic V2 完整迁移: 13个schema + config.py, 消除14个deprecation警告
- ✅ 前端TypeScript编译修复: SqlEditor SQLDialect, AuditLogsPage类型转换, QueriesPage未用import
- ✅ 后端测试: 843/843 通过, 0 warnings
- ✅ 前端构建: 零错误
### P0 — 数据库全景支持 ✅
- ✅ 统一查询服务: POST /query 自动路由 MySQL/PostgreSQL/Redis/MongoDB
- ✅ query_service.py: 完整 4 种数据库 Handler (MySQL/PostgreSQL/Redis/MongoDB)
- ✅ Redis 只读命令白名单: 40+ 只读命令 + 子命令验证 (CONFIG GET/MEMORY USAGE/OBJECT)
- ✅ MongoDB 只读操作白名单: find/findOne/countDocuments/distinct/aggregate + $out/$merge 防护
- ✅ 工单执行多数据库分发: ticket_executor.py 支持 MySQL/PostgreSQL/Redis/MongoDB 执行
- ✅ MongoDB 连接器: mongo_connector.py 780 行, 完整 CRUD + 统计 + 安全验证
- ✅ MongoDB 前端管理页面: MongoDBDetailPage.tsx (集合管理/文档浏览/查询控制台/统计信息)
- ✅ mongo API 模块: frontend/src/api/mongo.ts
- ✅ /mongo/:id 路由集成 + InstancePage 管理控制台跳转按钮
- ✅ 数据源品牌图标: MySQL/PostgreSQL/Redis/MongoDB SVG logos
### P1 — 审计闭环 (部分完成)
- ✅ 查询历史服务: query_history_service.py (记录/收藏/快捷查询)
- ✅ SQL 助手: sql_assistant.py (格式化/风险高亮/EXPLAIN 解析)
- ✅ 审计规则种子: MySQL 18规则 + PostgreSQL 12规则 + Redis 8规则
- ✅ 审计日志全操作覆盖
- ✅ 影响分析: impact_analyzer.py
- ✅ 回滚生成: rollback_generator.py
- ✅ Schema 漂移检测: schema_drift_detector.py
### P2 — 高级功能 (部分完成)
- ✅ 数据脱敏: data_masker.py + masking_rule CRUD
- ✅ 通知服务: 钉钉/飞书/邮件 SMTP
- ✅ 备份管理: backup_executor.py
- ✅ 工作流引擎: workflow_engine.py + workflow_seeds.py
- ✅ 慢日志收集: slow_log_collector.py
- ✅ 连接池: connection_pool.py
## 待完成
### P1 — 审计闭环
- [ ] 执行前后数据快照对比可视化 (前端)
- [ ] 快捷查询模板 (10个预置 + 自定义共享)
- [ ] SQL 格式化前端集成 (CodeMirror / Monaco editor)
- [ ] 查询结果格式化展示优化
### P2 — 人性化设计
- [ ] SQL 语法高亮编辑器
- [ ] 数据库类型说明 API
- [ ] Slow query analysis 前端集成
- [ ] Dashboard 数据源健康概览
### 运维
- [ ] 远程 Git 仓库配置
- [ ] CI/CD 完整流水线测试
- [ ] 生产环境部署文档
@@ -0,0 +1,247 @@
# SQL-Platform 数据审核平台 - TDD 开发计划
> 创建时间: 2025-05-22
> 测试框架: pytest (后端) + vitest (前端)
> 目标覆盖率: > 60%
---
## 测试环境配置
### 后端 (pytest)
```
pip install pytest pytest-asyncio httpx aiomysql
```
**conftest.py 基础配置:**
- 使用 SQLite in-memory 数据库
- FastAPI TestClient + async 测试
- 自动创建/销毁表
- 测试用户 fixture
### 前端 (vitest)
```
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
```
---
## 测试计划
### Phase 1: 后端 API 测试 (P0 — 阻塞)
> 目标:确保所有现有 API 行为正确,为前后端对接打基础
#### 1.1 认证模块 `tests/test_auth.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
|| 1 | 正确密码登录 → 200 + token + user | P0 | ✅ |
|| 2 | 错误密码登录 → 401 | P0 | ✅ |
|| 3 | 用户不存在 → 401 | P0 | ✅ |
|| 4 | 已禁用用户登录 → 403 | P0 | ✅ |
|| 5 | GET /me 无 token → 401 | P0 | ✅ |
|| 6 | GET /me 有效 token → 200 + user info | P0 | ✅ |
|| 7 | token 过期 → 401 | P1 | ✅ |
|| 8 | POST /logout → 200 | P1 | ✅ |
#### 1.2 用户管理 `tests/test_users.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
| 1 | admin 列出用户 → 200 | P0 | ⬜ |
| 2 | non-admin 列出用户 → 403 | P0 | ⬜ |
| 3 | 创建用户(正常) → 201 | P0 | ⬜ |
| 4 | 创建用户(重复用户名) → 400 | P0 | ⬜ |
| 5 | 获取单个用户 → 200 | P1 | ⬜ |
| 6 | 更新用户信息 → 200 | P1 | ⬜ |
| 7 | 删除用户 → 200 | P1 | ⬜ |
| 8 | 删除自己 → 400 | P1 | ⬜ |
#### 1.3 数据源管理 `tests/test_instances.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
|| 1 | 列出实例 → 200 | P0 | ✅ |
|| 2 | 创建实例 → 201 | P0 | ✅ |
|| 3 | 创建实例(non-admin) → 403 | P0 | ✅ |
|| 4 | 获取实例详情 → 200 | P1 | ✅ |
|| 5 | 更新实例 → 200 | P1 | ✅ |
|| 6 | 删除实例 → 200 | P1 | ✅ |
|| 7 | 测试连接(MySQL mock) → 测试结果 | P1 | ✅ |
|| 8 | 列出数据库 → 列表 | P2 | ⬜ |
|| 9 | 列出表 → 列表 | P2 | ⬜ |
#### 1.4 工单管理 `tests/test_tickets.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
|| 1 | 列出工单(developer 只看自己的) → 过滤正确 | P0 | ✅ |
|| 2 | 列出工单(admin 看全部) → 全部 | P0 | ✅ |
|| 3 | 创建工单 → 201 | P0 | ✅ |
|| 4 | 创建工单(实例不存在) → 400 | P0 | ✅ |
|| 5 | 获取工单详情 → 200 | P1 | ✅ |
|| 6 | 获取别人工单(developer) → 403 | P1 | ✅ |
|| 7 | 审批工单(approve) → approved | P0 | ✅ |
|| 8 | 审批工单(reject) → rejected | P0 | ✅ |
|| 9 | 审批工单(non-dba) → 403 | P0 | ✅ |
|| 10 | 审批已执行工单 → 400 | P1 | ✅ |
|| 11 | 执行工单(已审批) → success | P0 | ✅ |
|| 12 | 执行工单(未审批) → 400 | P0 | ✅ |
|| 13 | 执行工单(执行失败) → failed | P1 | ⬜ |
|| 14 | 工单统计 → 正确数字 | P1 | ✅ |
|| 15 | 分页查询 → 正确分页 | P1 | ✅ |
#### 1.5 查询模块 `tests/test_query.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
|| 1 | 执行 SELECT 查询 → 结果 | P0 | ✅ |
|| 2 | 执行 DROP → 拒绝 | P0 | ✅ |
|| 3 | 执行 DELETE → 拒绝 | P0 | ✅ |
|| 4 | 无 LIMIT 自动追加 → 限制行数 | P1 | ⬜ |
|| 5 | guest 无权查询 → 403 | P1 | ✅ |
|| 6 | 查询超时 → 错误 | P2 | ⬜ |
|| 7 | 审计日志记录 → 有记录 | P1 | ⬜ |
#### 1.6 仪表盘 `tests/test_dashboard.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
| 1 | 获取统计数据 → 正确结构 | P1 | ⬜ |
| 2 | 未登录 → 401 | P1 | ⬜ |
---
### Phase 2: 前端对接 (P0 — v0.2.0) ✅ 已完成
> 前后端对接已完成,所有页面已替换 mock 数据为真实 API 调用
#### 2.1 API 集成测试
| # | 测试场景 | 优先级 | 状态 |
|---|---------|--------|------|
|| 1 | 登录流程:输入→请求→token存储→跳转 | P0 | ✅ |
|| 2 | Dashboard 加载:请求stats→渲染卡片 | P0 | ✅ |
|| 3 | 工单列表:请求→筛选→分页 | P0 | ✅ |
|| 4 | 新建工单:表单→提交→刷新列表 | P0 | ✅ |
|| 5 | 实例管理:CRUD→列表刷新 | P1 | ✅ |
|| 6 | 401 自动跳转登录页 | P0 | ✅ |
#### 2.2 组件测试
| # | 测试场景 | 优先级 | 状态 |
|---|---------|--------|------|
| 1 | LoginPage 渲染 + 表单验证 | P1 | ⬜ |
| 2 | Dashboard 统计卡片渲染 | P1 | ⬜ |
| 3 | TicketsPage 筛选功能 | P1 | ⬜ |
| 4 | RouteGuard 权限拦截 | P0 | ⬜ |
---
### Phase 3: 新功能 TDD (P1 — v0.3.0)
> 先写测试,再写代码
#### 3.1 Instance 密码加密 ✅ 已完成
| # | TDD 步骤 | 状态 |
|---|---------|------|
| 1 | 🔴 写测试:创建实例时密码加密存储 | ✅ test_crypto.py |
| 2 | 🔴 写测试:读取实例时密码解密返回 | ✅ test_crypto.py |
| 3 | 🟢 实现 AES-256-GCM 加密/解密工具 | ✅ app/core/crypto.py |
| 4 | 🟢 集成到 instance endpoint (create/update/decrypt) | ✅ |
| 5 | ✅ 9 个加密相关测试全部通过 | ✅ |
#### 3.2 SQL 审核规则引擎 ✅ 已完成
| # | TDD 步骤 | 状态 |
|---|---------|------|
| 1 | 🔴 写测试:SELECT * 规则触发 info | ✅ |
| 2 | 🔴 写测试:无 WHERE 的 UPDATE/DELETE 规则触发 error | ✅ |
| 3 | 🔴 写测试:DROP TABLE/TRUNCATE 规则 | ✅ |
| 4 | 🔴 写测试:生产环境额外规则(ALTER TABLE 等) | ✅ |
| 5 | 🟢 实现 10 条规则引擎 | ✅ app/services/audit_engine.py |
| 6 | 🟢 集成到工单创建流程(自动审核+评分) | ✅ |
| 7 | ✅ 23 个审核引擎测试全部通过 | ✅ |
#### 3.3 操作审计日志 ✅ 已完成
| # | 测试用例 | 状态 |
|---|---------|------|
| 1 | 审计日志列表 API (admin/dba) | ✅ |
| 2 | 审计日志列表 API (developer 禁止) | ✅ |
| 3 | 创建实例 → 审计日志 | ✅ |
| 4 | 创建用户 → 审计日志 | ✅ |
| 5 | 创建工单 → 审计日志(含审核评分) | ✅ |
| 6 | 审批工单 → 审计日志 | ✅ |
| 7 | 审计日志分页 | ✅ |
| 8 | 审计日志过滤 | ✅ |
| 9 | ✅ 9 个审计日志测试全部通过 | ✅ |
---
### Phase 4: 安全测试 (P1)
| # | 测试场景 | 状态 |
|---|---------|------|
| 1 | SQL 注入:`DROP TABLE users; --` 被拦截 | ⬜ |
| 2 | SQL 注入:`UNION SELECT * FROM users` 被拦截 | ⬜ |
| 3 | 越权:developer 不能审批工单 | ⬜ |
| 4 | 越权:developer 不能访问 admin API | ⬜ |
| 5 | Token 伪造 → 401 | ⬜ |
| 6 | 并发创建工单不会重复 | ⬜ |
---
## 测试目录结构
```
backend/
├── tests/
│ ├── conftest.py # fixtures: db, client, users, token, test_instance
│ ├── test_auth.py # 认证测试 (8)
│ ├── test_users.py # 用户管理测试 (8)
│ ├── test_instances.py # 数据源测试 (8)
│ ├── test_tickets.py # 工单测试 (11)
│ ├── test_query.py # 查询测试 (4)
│ ├── test_dashboard.py # 仪表盘测试 (2)
│ ├── test_crypto.py # 加密测试 (9)
│ ├── test_audit_engine.py # SQL审核引擎测试 (23)
│ ├── test_audit_logs.py # 审计日志测试 (9)
│ ├── test_sql_analyzer.py # SQL AST测试 (34)
│ └── test_audit_rules.py # 审核规则CRUD测试 (14)
frontend/
├── src/
│ ├── __tests__/
│ │ ├── setup.ts # vitest setup
│ │ ├── LoginPage.test.tsx
│ │ ├── Dashboard.test.tsx
│ │ ├── TicketsPage.test.tsx
│ │ └── RouteGuard.test.tsx
```
---
## 覆盖率目标
| 阶段 | 目标后端覆盖率 | 目标前端覆盖率 |
|------|--------------|--------------|
| Phase 1 完成 | 40% | 0% |
| Phase 2 完成 | 50% | 30% |
| Phase 3 完成 | 60% | 40% |
| Phase 4 完成 | 70% | 50% |
---
## 执行优先级
1. ~~**立刻** — 创建 conftest.py + test_auth.py~~ ✅
2. ~~**v0.2.0** — Phase 1 全部完成~~ ✅
3. ~~**v0.2.1** — Phase 2 前后端对接~~ ✅
4. ~~**v0.3.0** — Phase 3 密码加密 + SQL 审核引擎~~ ✅ (85 tests)
4. ~~**v0.4.0** — Phase 4 SQL AST + 规则管理 + Alembic + Docker~~ ✅ (128 tests)
5. **v0.5.0** — Phase 5 运维工具 + 真实DB连接 + CI/CD ✅
@@ -0,0 +1,30 @@
version: '3.8'
services:
backend:
build: ./backend
container_name: sql-audit-backend
restart: unless-stopped
ports:
- "9000:9000"
volumes:
- backend-data:/app/data
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/sqlite3.db
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
- ENV=production
- DEBUG=false
frontend:
build: ./frontend
container_name: sql-audit-frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
- backend
volumes:
backend-data:
@@ -0,0 +1,3 @@
node_modules/
dist/
.git/
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1,547 @@
# DataGuard - 数据库变更审核平台 开发文档
> 版本: v2.0
> 更新日期: 2026-05-20
> 技术栈: React 19 + TypeScript + Vite + Tailwind CSS + shadcn/ui
---
## 一、项目概述
DataGuard 是一款企业级数据库变更审核平台,对标 Bytebase / Yearning / Archery,支持多数据库实例管理、SQL 变更工单审批、查询审计、自动任务、权限控制等核心功能。
### 核心设计理念
- **安全优先**: 所有数据变更通过工单审批流程,SQL 自动审核 + 人工审批双重保障
- **多数据库支持**: MySQL、PostgreSQL、Redis、MongoDB、ClickHouse
- **审计可追溯**: 完整的查询审计日志,敏感数据自动脱敏
- **回滚可控**: 基于 Binlog ROW 格式的自动回滚 SQL 生成
- **角色分级**: admin / dba / developer / guest 四级权限控制
---
## 二、技术栈
| 类别 | 技术 | 版本 |
|------|------|------|
| 框架 | React | 19 |
| 语言 | TypeScript | 5.x |
| 构建 | Vite | 7.x |
| 样式 | Tailwind CSS | 3.4 |
| UI 库 | shadcn/ui + Radix UI | latest |
| 路由 | react-router-dom | 7.x |
| 图标 | lucide-react | latest |
| 图表 | recharts | 2.x |
### 目录结构
```
src/
├── components/ # 全局组件
│ ├── ui/ # shadcn/ui 组件(Button, Card, Dialog, Tabs, Switch, Select 等)
│ ├── Layout.tsx # 主布局(侧边栏 + 顶部栏 + 内容区)
│ ├── PageHeader.tsx # 统一页面头部(标题 + 描述 + 操作按钮)
│ ├── EmptyState.tsx # 空状态组件
│ └── SkeletonCard.tsx # Loading 骨架屏
├── pages/ # 页面组件(按路由划分)
│ ├── Dashboard.tsx # 仪表盘
│ ├── InstancePage.tsx # 数据库实例管理
│ ├── TicketsPage.tsx # 数据变更工单
│ ├── QueriesPage.tsx # 查询审计
│ ├── RolesPage.tsx # 角色管理
│ ├── UsersPage.tsx # 用户管理
│ ├── DataPermissionPage.tsx # 数据权限
│ ├── AuditRulesPage.tsx # 审核规则
│ ├── WorkflowPage.tsx # 审批流程配置
│ ├── AutoTaskPage.tsx # 自动任务
│ ├── AnnouncementPage.tsx # 公告管理
│ ├── ConfigPlatformPage.tsx # 配置平台
│ └── LoginPage.tsx # 登录
├── hooks/ # 自定义 Hooks
│ └── useAuth.tsx # 权限上下文 + useAuth + useActionPermission
├── lib/ # 工具库
│ ├── mock.ts # 全量 Mock 数据
│ └── utils.ts # 工具函数
├── types/ # TypeScript 类型定义
│ └── index.ts # 全局类型
├── App.tsx # 根组件(路由 + 路由守卫)
├── index.css # 全局样式 + 深色主题变量
└── main.tsx # 入口文件
```
---
## 三、核心功能模块
### 3.1 仪表盘 (Dashboard)
**路由**: `/dashboard`
**权限**: 全部角色
**功能说明**:
- 工单统计卡片(总工单 / 待审批 / 本月 / 进行中)
- 审核规则统计卡片
- 查询统计卡片
- 工单趋势折线图(30天)
- 状态分布饼图
- 工单表格列表(含详情弹窗)
**关键代码**:
```tsx
// pages/Dashboard.tsx
// 使用 recharts 绘制图表,工单表格复用 tickets mock 数据
```
---
### 3.2 数据库实例管理 (InstancePage)
**路由**: `/mysql`, `/postgres`, `/redis`, `/mongo`, `/clickhouse`
**权限**: admin, dba
**功能说明**:
- 卡片网格展示实例(连接状态、版本、环境、主机、端口)
- 实时性能指标(QPS、连接数、磁盘、CPU、内存)
- 实例详情页(监控图表、连接信息、慢查询 TOP5、基本信息)
- 支持批量添加实例
**关键数据结构**:
```typescript
interface DBInstance {
id: number;
name: string;
host: string;
port: number;
type: 'mysql' | 'postgres' | 'redis' | 'mongo' | 'clickhouse';
env: 'production' | 'staging' | 'development';
status: 'connected' | 'disconnected' | 'error';
version: string;
databases: number;
tables: number;
size: string;
connections: number;
maxConnections: number;
qps: number;
latency: number;
cpu: number;
memory: number;
disk: number;
uptime: string;
lastBackup: string;
}
```
---
### 3.3 数据变更工单 (TicketsPage)
**路由**: `/tickets`
**权限**: admin, dba, developer(developer 仅可查看自己工单)
**功能说明**:
- 工单列表(状态快捷筛选、搜索、多维度过滤)
- 新建工单(SQL 编辑器、审核预览、回滚 SQL)
- 工单详情(审批流程可视化、SQL 内容、回滚 SQL、审批记录)
- 操作权限:审批/拒绝(dba+admin)、执行(dba+admin)
**工单状态流转**:
```
pending(待审批) -> reviewing(审批中) -> approved(已通过) -> executing(执行中) -> success(成功)
-> rejected(已拒绝)
-> failed(失败)
```
**回滚机制**:
- 基于 MySQL Binlog ROW 格式解析
- AST 语法分析提取变更前后的数据
- 执行前自动备份受影响数据
- 72 小时内可一键回滚
**关键数据结构**:
```typescript
interface Ticket {
id: string;
title: string;
dbType: string;
instance: string;
database: string;
sql: string;
rollbackSql?: string;
status: 'pending' | 'reviewing' | 'approved' | 'rejected' | 'executing' | 'success' | 'failed';
priority: 'high' | 'medium' | 'low';
applicant: string;
approver: string;
createTime: string;
executeTime: string;
reason: string;
workflowSteps?: { name: string; status: string; time?: string }[];
approvalRecords?: { approver: string; action: string; comment: string; time: string }[];
}
```
---
### 3.4 查询审计 (QueriesPage)
**路由**: `/queries`
**权限**: 全部角色(guest 不可新建查询)
**功能说明**:
- 查询审计日志列表(SQL 语句、执行时长、返回行数、敏感数据标记)
- 查询结果集展示(敏感字段自动脱敏)
- 新建查询(SQL 编辑器 + 数据库选择)
- 查询结果导出
**敏感数据脱敏规则**:
| 字段类型 | 脱敏方式 | 示例 |
|---------|---------|------|
| 邮箱 | 前缀掩码 | `abc***@gmail.com` |
| 手机号 | 中间掩码 | `138****8888` |
| 身份证号 | 中间掩码 | `110**********0011` |
| 银行卡 | 后四位保留 | `**** **** **** 1234` |
---
### 3.5 角色管理 (RolesPage)
**路由**: `/roles`
**权限**: admin
**功能说明**:
- 角色列表展示
- 权限矩阵(页面权限 + 操作权限 + 数据权限)
- 角色创建/编辑/删除
**系统内置角色**:
| 角色 | 权限范围 |
|------|---------|
| 超级管理员(admin) | 全部功能 |
| 数据库管理员(dba) | 数据源 + 工单审批 + 查询 + 权限 + 规则 + 流程 |
| 开发人员(developer) | 工单提交 + 查询 |
| 访客(guest) | 仅查询审计查看 |
---
### 3.6 用户管理 (UsersPage)
**路由**: `/users`
**权限**: admin
**功能说明**:
- 用户列表(用户名、角色、状态、最近登录)
- 新建/编辑/禁用用户
- 角色分配
---
### 3.7 审批流程配置 (WorkflowPage)
**路由**: `/workflow`
**权限**: admin, dba
**功能说明**:
- 审批流程模板管理
- 流程步骤配置:提交阶段 -> 审核阶段 -> 执行阶段
- 支持多级审批(一级审批、二级审批)
- 审批模式:或签(任一人通过)/ 会签(全部人通过)
- 流程可视化展示
- 适用数据库类型配置
**典型流程示例**:
```
提交阶段(提交人) -> 一级审批(部门负责人, 或签) -> 二级审批(DBA团队, 或签) -> 执行阶段(DBA执行人)
```
---
### 3.8 自动任务 (AutoTaskPage)
**路由**: `/auto-task`
**权限**: admin
**功能说明**:
- 定时 SQL 任务管理(Cron 表达式)
- 支持频率:一次性 / 每小时 / 每天 / 每周 / 每月
- 任务启停控制
- 执行日志和通知
**典型场景**:
- 每日数据归档
- 周/月度统计报表生成
- 索引健康检查
- 过期数据清理
---
### 3.9 配置平台 (ConfigPlatformPage)
**路由**: `/config-platform`
**权限**: admin
**包含 5 个配置模块**:
#### 3.9.1 消息推送
- 渠道:飞书、钉钉、企业微信、Telegram、邮件
- 每个渠道独立开关 + Webhook 配置
- 通知事件:工单创建、审批通过、审批拒绝、执行完成、执行失败
- 连接测试功能
#### 3.9.2 LDAP 设置
- LDAP/AD 域账号登录集成
- 服务器地址、Bind DN、Base DN、字段映射
- 自动同步用户(可配置同步间隔)
#### 3.9.3 查询限制
- 查询审核审批开关(不开启时允许查询授权资源)
- 最大 Limit 限制(滑块控制 100-100,000)
- 查询超时时间
- 结果导出行数限制
- 写入操作开关
- 禁止的关键词列表
#### 3.9.4 工单提交规则
| 规则 | 说明 |
|------|------|
| SQL 语法检查 | 提交前自动语法校验 |
| 影响行数限制 | 单次操作最大影响行数 |
| 工单标题必填 | 最小 5 字符 |
| 变更原因必填 | 最小 10 字符 |
| 回滚 SQL 必填 | UPDATE/DELETE 必须提供回滚 |
| 禁止提交时段 | 配置高峰时段禁止提交 |
| 同一表并发限制 | 同一表只能有一个进行中的变更 |
| 敏感操作二次确认 | 影响行数超阈值需二次确认 |
#### 3.9.5 DDL 拦截规则
| 规则 | 动作 |
|------|------|
| DROP TABLE / DATABASE | 拦截 |
| TRUNCATE TABLE | 拦截 |
| ALTER TABLE DROP/MODIFY/CHANGE COLUMN | 需审批 |
| RENAME TABLE | 需审批 |
| CREATE TABLE / ADD COLUMN / CREATE INDEX | 警告 |
---
## 四、权限系统
### 4.1 权限架构
```
路由守卫 (RouteGuard)
-> 菜单过滤 (visibleMenuItems)
-> 页面级权限 (PageHeader action/secondaryAction)
-> 操作级权限 (useActionPermission)
-> 数据级权限 (列表过滤)
```
### 4.2 权限 Hook
```typescript
// 获取用户信息和权限方法
const { user, isAdmin, isDBA, isDeveloper, isGuest, hasPermission } = useAuth();
// 检查操作权限
const canApprove = useActionPermission('ticket.approve'); // true/false
```
### 4.3 权限配置表
路由权限:`useAuth.tsx` 中 `routePermissionMap`
操作权限:`useAuth.tsx` 中 `actionPermissionMap`
菜单权限:`lib/mock.ts` 中 `menuItems[*].roles`
### 4.4 角色切换演示
侧边栏底部提供角色切换按钮(仅演示用途),切换后:
1. 自动过滤菜单项
2. 检查当前页面是否有权限,无权限则跳转到仪表盘
3. 页面内按钮按新角色重新渲染
---
## 五、Mock 数据层
所有数据存储在 `src/lib/mock.ts`,后端接口就绪后替换为 API 调用即可。
**主要数据集合**:
| 数据 | 变量名 | 用途 |
|------|--------|------|
| 数据库实例 | `dbInstances` | 实例管理 |
| 工单 | `tickets` | 工单管理 |
| 查询审计 | `queryAudits` | 查询审计 |
| 审核规则 | `auditRules` | 审核规则 |
| 用户 | `users` | 用户管理 |
| 角色 | `roles` | 角色管理 |
| 审批流程 | `workflows` | 流程配置 |
| 菜单 | `menuItems` | 侧边栏菜单 |
| 当前用户 | `currentUser` | 登录用户 |
---
## 六、开发规范
### 6.1 新增页面步骤
1. 在 `src/pages/` 创建页面组件
2. 在 `src/App.tsx` 添加路由(配置 `routePermissionMap` 权限)
3. 在 `src/hooks/useAuth.tsx` 的 `routePermissionMap` 中添加路由权限
4. 在 `src/lib/mock.ts` 的 `menuItems` 中添加菜单项
5. (如需要)在 `actionPermissionMap` 中添加页面操作权限
### 6.2 新增操作权限
1. 在 `useAuth.tsx` 的 `actionPermissionMap` 中定义权限
2. 在页面中使用 `useActionPermission('action.key')` 获取权限
3. 使用条件渲染控制按钮/操作的显示
```tsx
const canDelete = useActionPermission('ticket.delete');
{canDelete && (
<button onClick={handleDelete}>删除</button>
)}
```
### 6.3 使用 PageHeader 组件
```tsx
import PageHeader from '@/components/PageHeader';
<PageHeader
title="页面标题"
description="页面描述"
action={{ label: '新建', icon: Plus, onClick: () => {}, permission: 'action.key' }}
secondaryAction={{ label: '导出', icon: Download, onClick: () => {} }}
/>
```
---
## 七、主题与样式
### 7.1 CSS 变量
深色主题变量定义在 `src/index.css`:
```css
:root {
--dg-bg-primary: #0d1117; /* 主背景 */
--dg-bg-secondary: #161b22; /* 卡片背景 */
--dg-bg-tertiary: #21262d; /* 输入框/标签背景 */
--dg-bg-hover: #1c2128; /* hover 状态 */
--dg-border: #30363d; /* 边框 */
--dg-border-light: #21262d; /* 浅色边框 */
--dg-text-primary: #e6edf3; /* 主要文字 */
--dg-text-secondary: #8b949e; /* 次要文字 */
--dg-text-muted: #484f58; /* 弱化文字 */
}
```
### 7.2 公共样式类
```css
.dg-card /* 统一卡片样式 */
.dg-btn-primary /* 主按钮 */
.dg-btn-secondary /* 次要按钮 */
.dg-input /* 输入框样式 */
.dg-label /* 标签样式 */
```
---
## 八、后续优化建议
### 8.1 后端集成(高优先级)
| 模块 | 接口需求 |
|------|----------|
| 用户认证 | `/api/auth/login`, `/api/auth/logout` |
| 仪表盘 | `/api/dashboard/stats`, `/api/dashboard/trends` |
| 实例管理 | `/api/instances/*` CRUD |
| 工单 | `/api/tickets/*` CRUD + 审批/执行/回滚 |
| 查询审计 | `/api/queries/*` 执行 + 日志 |
| 用户管理 | `/api/users/*` CRUD |
| 角色管理 | `/api/roles/*` CRUD |
| 配置平台 | `/api/config/*` 读写 |
### 8.2 功能优化
- [ ] **SQL 编辑器升级**: 集成 Monaco Editor 替代 textarea,支持语法高亮、自动补全、错误提示
- [ ] **实时通知**: WebSocket 推送工单状态变更、审批提醒
- [ ] **数据同步**: LDAP 用户自动同步定时任务
- [ ] **报表导出**: PDF/Excel 格式的审计报告生成
- [ ] **SQL 变更影响分析**: 自动计算影响行数、预估执行时间
- [ ] **数据库对比**: 结构对比、数据对比功能
- [ ] **备份管理**: 定时备份、备份恢复、备份策略配置
- [ ] **操作审计**: 管理员操作日志,防止越权
### 8.3 性能优化
- [ ] **虚拟滚动**: 大数据量表格使用虚拟滚动
- [ ] **懒加载**: 路由级 Code Splitting
- [ ] **数据缓存**: React Query / SWR 管理服务端状态
- [ ] **图表优化**: 大数据量图表采样渲染
### 8.4 安全加固
- [ ] **前端加密**: 敏感配置字段前端加密传输
- [ ] **防 XSS**: 所有用户输入转义输出
- [ ] **防 CSRF**: 请求头携带 Token
- [ ] **会话管理**: JWT Token 自动刷新、过期提醒
---
## 九、API 接口规范(建议)
### 9.1 统一响应格式
```json
{
"code": 200,
"message": "success",
"data": {},
"timestamp": 1705315200000
}
```
### 9.2 分页参数
```
GET /api/tickets?page=1&size=10&status=pending&sort=-createTime
```
### 9.3 权限相关 Header
```
Authorization: Bearer <JWT_TOKEN>
X-User-Role: admin
```
---
## 十、部署说明
### 10.1 构建
```bash
cd /mnt/agents/output/app
npm run build
```
输出目录: `dist/`
### 10.2 环境变量
```env
# .env.production
VITE_API_BASE_URL=https://api.dataguard.company.com
VITE_APP_TITLE=DataGuard
VITE_VERSION=2.0.0
```
---
*文档维护: 开发团队*
*如有问题请联系 DBA 团队或提交 Issue*
@@ -0,0 +1,13 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "postcss.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SQL-Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,31 @@
Using Node.js 20, Tailwind CSS v3.4.19, and Vite v7.2.4
Tailwind CSS has been set up with the shadcn theme
Setup complete: /mnt/agents/output/app
Components (40+):
accordion, alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb,
button-group, button, calendar, card, carousel, chart, checkbox, collapsible,
command, context-menu, dialog, drawer, dropdown-menu, empty, field, form,
hover-card, input-group, input-otp, input, item, kbd, label, menubar,
navigation-menu, pagination, popover, progress, radio-group, resizable,
scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner,
spinner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
Usage:
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
Structure:
src/sections/ Page sections
src/hooks/ Custom hooks
src/types/ Type definitions
src/App.css Styles specific to the Webapp
src/App.tsx Root React component
src/index.css Global styles
src/main.tsx Entry point for rendering the Webapp
index.html Entry point for the Webapp
tailwind.config.js Configures Tailwind's theme, plugins, etc.
vite.config.ts Main build and dev server settings for Vite
postcss.config.js Config file for CSS post-processing tools
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,94 @@
{
"name": "my-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.6",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/echarts": "^4.9.22",
"axios": "^1.16.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"echarts": "^6.0.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-resizable-panels": "^4.2.2",
"react-router": "^7.6.1",
"react-router-dom": "^7.15.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"kimi-plugin-inspect-react": "^1.0.3",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" clip-rule="evenodd" fill="#439934" d="M88.038 42.812c1.605 4.643 2.761 9.383 3.141 14.296.472 6.095.256 12.147-1.029 18.142-.035.165-.109.32-.164.48-.403.001-.814-.049-1.208.012-3.329.523-6.655 1.065-9.981 1.604-3.438.557-6.881 1.092-10.313 1.687-1.216.21-2.721-.041-3.212 1.641-.014.046-.154.054-.235.08l.166-10.051-.169-24.252 1.602-.275c2.62-.429 5.24-.864 7.862-1.281 3.129-.497 6.261-.98 9.392-1.465 1.381-.215 2.764-.412 4.148-.618z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#45A538" d="M61.729 110.054c-1.69-1.453-3.439-2.842-5.059-4.37-8.717-8.222-15.093-17.899-18.233-29.566-.865-3.211-1.442-6.474-1.627-9.792-.13-2.322-.318-4.665-.154-6.975.437-6.144 1.325-12.229 3.127-18.147l.099-.138c.175.233.427.439.516.702 1.759 5.18 3.505 10.364 5.242 15.551 5.458 16.3 10.909 32.604 16.376 48.9.107.318.384.579.583.866l-.87 2.969z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#46A037" d="M88.038 42.812c-1.384.206-2.768.403-4.149.616-3.131.485-6.263.968-9.392 1.465-2.622.417-5.242.852-7.862 1.281l-1.602.275-.012-1.045c-.053-.859-.144-1.717-.154-2.576-.069-5.478-.112-10.956-.18-16.434-.042-3.429-.105-6.857-.175-10.285-.043-2.13-.089-4.261-.185-6.388-.052-1.143-.236-2.28-.311-3.423-.042-.657.016-1.319.029-1.979.817 1.583 1.616 3.178 2.456 4.749 1.327 2.484 3.441 4.314 5.344 6.311 7.523 7.892 12.864 17.068 16.193 27.433z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#409433" d="M65.036 80.753c.081-.026.222-.034.235-.08.491-1.682 1.996-1.431 3.212-1.641 3.432-.594 6.875-1.13 10.313-1.687 3.326-.539 6.652-1.081 9.981-1.604.394-.062.805-.011 1.208-.012-.622 2.22-1.112 4.488-1.901 6.647-.896 2.449-1.98 4.839-3.131 7.182a49.142 49.142 0 01-6.353 9.763c-1.919 2.308-4.058 4.441-6.202 6.548-1.185 1.165-2.582 2.114-3.882 3.161l-.337-.23-1.214-1.038-1.256-2.753a41.402 41.402 0 01-1.394-9.838l.023-.561.171-2.426c.057-.828.133-1.655.168-2.485.129-2.982.241-5.964.359-8.946z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#4FAA41" d="M65.036 80.753c-.118 2.982-.23 5.964-.357 8.947-.035.83-.111 1.657-.168 2.485l-.765.289c-1.699-5.002-3.399-9.951-5.062-14.913-2.75-8.209-5.467-16.431-8.213-24.642a4498.887 4498.887 0 00-6.7-19.867c-.105-.31-.407-.552-.617-.826l4.896-9.002c.168.292.39.565.496.879a6167.476 6167.476 0 016.768 20.118c2.916 8.73 5.814 17.467 8.728 26.198.116.349.308.671.491 1.062l.67-.78-.167 10.052z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#4AA73C" d="M43.155 32.227c.21.274.511.516.617.826a4498.887 4498.887 0 016.7 19.867c2.746 8.211 5.463 16.433 8.213 24.642 1.662 4.961 3.362 9.911 5.062 14.913l.765-.289-.171 2.426-.155.559c-.266 2.656-.49 5.318-.814 7.968-.163 1.328-.509 2.632-.772 3.947-.198-.287-.476-.548-.583-.866-5.467-16.297-10.918-32.6-16.376-48.9a3888.972 3888.972 0 00-5.242-15.551c-.089-.263-.34-.469-.516-.702l3.272-8.84z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#57AE47" d="M65.202 70.702l-.67.78c-.183-.391-.375-.714-.491-1.062-2.913-8.731-5.812-17.468-8.728-26.198a6167.476 6167.476 0 00-6.768-20.118c-.105-.314-.327-.588-.496-.879l6.055-7.965c.191.255.463.482.562.769 1.681 4.921 3.347 9.848 5.003 14.778 1.547 4.604 3.071 9.215 4.636 13.813.105.308.47.526.714.786l.012 1.045c.058 8.082.115 16.167.171 24.251z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#60B24F" d="M65.021 45.404c-.244-.26-.609-.478-.714-.786-1.565-4.598-3.089-9.209-4.636-13.813-1.656-4.93-3.322-9.856-5.003-14.778-.099-.287-.371-.514-.562-.769 1.969-1.928 3.877-3.925 5.925-5.764 1.821-1.634 3.285-3.386 3.352-5.968.003-.107.059-.214.145-.514l.519 1.306c-.013.661-.072 1.322-.029 1.979.075 1.143.259 2.28.311 3.423.096 2.127.142 4.258.185 6.388.069 3.428.132 6.856.175 10.285.067 5.478.111 10.956.18 16.434.008.861.098 1.718.152 2.577z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#A9AA88" d="M62.598 107.085c.263-1.315.609-2.62.772-3.947.325-2.649.548-5.312.814-7.968l.066-.01.066.011a41.402 41.402 0 001.394 9.838c-.176.232-.425.439-.518.701-.727 2.05-1.412 4.116-2.143 6.166-.1.28-.378.498-.574.744l-.747-2.566.87-2.969z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#B6B598" d="M62.476 112.621c.196-.246.475-.464.574-.744.731-2.05 1.417-4.115 2.143-6.166.093-.262.341-.469.518-.701l1.255 2.754c-.248.352-.59.669-.728 1.061l-2.404 7.059c-.099.283-.437.483-.663.722l-.695-3.985z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#C2C1A7" d="M63.171 116.605c.227-.238.564-.439.663-.722l2.404-7.059c.137-.391.48-.709.728-1.061l1.215 1.037c-.587.58-.913 1.25-.717 2.097l-.369 1.208c-.168.207-.411.387-.494.624-.839 2.403-1.64 4.819-2.485 7.222-.107.305-.404.544-.614.812-.109-1.387-.22-2.771-.331-4.158z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#CECDB7" d="M63.503 120.763c.209-.269.506-.508.614-.812.845-2.402 1.646-4.818 2.485-7.222.083-.236.325-.417.494-.624l-.509 5.545c-.136.157-.333.294-.398.477-.575 1.614-1.117 3.24-1.694 4.854-.119.333-.347.627-.525.938-.158-.207-.441-.407-.454-.623-.Line truncated

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#00618A" d="M116.948 97.807c-6.863-.187-12.104.452-16.585 2.341-1.273.537-3.305.552-3.513 2.147.7.733.809 1.829 1.365 2.731 1.07 1.73 2.876 4.052 4.488 5.268 1.762 1.33 3.577 2.751 5.465 3.902 3.358 2.047 7.107 3.217 10.34 5.268 1.906 1.21 3.799 2.733 5.658 4.097.92.675 1.537 1.724 2.732 2.147v-.194c-.628-.8-.79-1.898-1.366-2.733l-2.537-2.537c-2.48-3.292-5.629-6.184-8.976-8.585-2.669-1.916-8.642-4.504-9.755-7.609l-.195-.195c1.892-.214 4.107-.898 5.854-1.367 2.934-.786 5.556-.583 8.585-1.365l4.097-1.171v-.78c-1.531-1.571-2.623-3.651-4.292-5.073-4.37-3.72-9.138-7.437-14.048-10.537-2.724-1.718-6.089-2.835-8.976-4.292-.971-.491-2.677-.746-3.318-1.562-1.517-1.932-2.342-4.382-3.511-6.633-2.449-4.717-4.854-9.868-7.024-14.831-1.48-3.384-2.447-6.72-4.293-9.756-8.86-14.567-18.396-23.358-33.169-32-3.144-1.838-6.929-2.563-10.929-3.513-2.145-.129-4.292-.26-6.438-.391-1.311-.546-2.673-2.149-3.902-2.927C17.811 4.565 5.257-2.16 1.633 6.682c-2.289 5.581 3.421 11.025 5.462 13.854 1.434 1.982 3.269 4.207 4.293 6.438.674 1.467.79 2.938 1.367 4.489 1.417 3.822 2.652 7.98 4.487 11.511.927 1.788 1.949 3.67 3.122 5.268.718.981 1.951 1.413 2.145 2.927-1.204 1.686-1.273 4.304-1.95 6.44-3.05 9.615-1.899 21.567 2.537 28.683 1.36 2.186 4.567 6.871 8.975 5.073 3.856-1.57 2.995-6.438 4.098-10.732.249-.973.096-1.689.585-2.341v.195l3.513 7.024c2.6 4.187 7.212 8.562 11.122 11.514 2.027 1.531 3.623 4.177 6.244 5.073v-.196h-.195c-.508-.791-1.303-1.119-1.951-1.755-1.527-1.497-3.225-3.358-4.487-5.073-3.556-4.827-6.698-10.11-9.561-15.609-1.368-2.627-2.557-5.523-3.709-8.196-.444-1.03-.438-2.589-1.364-3.122-1.263 1.958-3.122 3.542-4.098 5.854-1.561 3.696-1.762 8.204-2.341 12.878-.342.122-.19.038-.391.194-2.718-.655-3.672-3.452-4.683-5.853-2.554-6.07-3.029-15.842-.781-22.829.582-1.809 3.21-7.501 2.146-9.172-.508-1.666-2.184-2.63-3.121-3.903-1.161-1.574-2.319-3.646-3.124-5.464-2.09-4.731-3.066-10.044-5.267-14.828-1.053-2.287-2.832-4.602-4.293-6.634-1.617-2.253-3.429-3.912-4.683-6.635-.446-.968-1.051-2.518-.391-3.513.21-.671.508-.951 1.171-1.17 1.132-.873 4.284.29 5.462.779 3.129 1.3 5.741 2.538 8.392 4.294 1.271.844 2.559 2.475 4.097 2.927h1.756c2.747.631 5.824.195 8.391.975 4.536 1.378 8.601 3.523 12.292 5.854 11.246 7.102 20.442 17.21 26.732 29.269 1.012 1.942 1.45 3.794 2.341 5.854 1.798 4.153 4.063 8.426 5.852 12.488 1.786 4.052 3.526 8.141 6.05 11.513 1.327 1.772 6.451 2.723 8.781 3.708 1.632.689 4.307 1.409 5.854 2.34 2.953 1.782 5.815 3.903 8.586 5.855 1.383.975 5.64 3.116 5.852 4.879zM29.729 23.466c-1.431-.027-2.443.156-3.513.389v.195h.195c.683 1.402 1.888 2.306 2.731 3.513.65 1.367 1.301 2.732 1.952 4.097l.194-.193c1.209-.853 1.762-2.214 1.755-4.294-.484-.509-.555-1.147-.975-1.755-.556-.811-1.635-1.272-2.339-1.952z"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path d="M93.809 92.112c.785-6.533.55-7.492 5.416-6.433l1.235.108c3.742.17 8.637-.602 11.513-1.938 6.191-2.873 9.861-7.668 3.758-6.409-13.924 2.873-14.881-1.842-14.881-1.842 14.703-21.815 20.849-49.508 15.543-56.287-14.47-18.489-39.517-9.746-39.936-9.52l-.134.025c-2.751-.571-5.83-.912-9.289-.968-6.301-.104-11.082 1.652-14.709 4.402 0 0-44.683-18.409-42.604 23.151.442 8.841 12.672 66.898 27.26 49.362 5.332-6.412 10.484-11.834 10.484-11.834 2.558 1.699 5.622 2.567 8.834 2.255l.249-.212c-.078.796-.044 1.575.099 2.497-3.757 4.199-2.653 4.936-10.166 6.482-7.602 1.566-3.136 4.355-.221 5.084 3.535.884 11.712 2.136 17.238-5.598l-.22.882c1.474 1.18 1.375 8.477 1.583 13.69.209 5.214.558 10.079 1.621 12.948 1.063 2.868 2.317 10.256 12.191 8.14 8.252-1.764 14.561-4.309 15.136-27.985"/><path d="M75.458 125.256c-4.367 0-7.211-1.689-8.938-3.32-2.607-2.46-3.641-5.629-4.259-7.522l-.267-.79c-1.244-3.358-1.666-8.193-1.916-14.419-.038-.935-.064-1.898-.093-2.919-.021-.747-.047-1.684-.085-2.664a18.8 18.8 0 01-4.962 1.568c-3.079.526-6.389.356-9.84-.507-2.435-.609-4.965-1.871-6.407-3.82-4.203 3.681-8.212 3.182-10.396 2.453-3.853-1.285-7.301-4.896-10.542-11.037-2.309-4.375-4.542-10.075-6.638-16.943-3.65-11.96-5.969-24.557-6.175-28.693C4.292 23.698 7.777 14.44 15.296 9.129 27.157.751 45.128 5.678 51.68 7.915c4.402-2.653 9.581-3.944 15.433-3.851 3.143.051 6.136.327 8.916.823 2.9-.912 8.628-2.221 15.185-2.139 12.081.144 22.092 4.852 28.949 13.615 4.894 6.252 2.474 19.381.597 26.651-2.642 10.226-7.271 21.102-12.957 30.57 1.544.011 3.781-.174 6.961-.831 6.274-1.295 8.109 2.069 8.607 3.575 1.995 6.042-6.677 10.608-9.382 11.864-3.466 1.609-9.117 2.589-13.745 2.377l-.202-.013-1.216-.107-.12 1.014-.116.991c-.311 11.999-2.025 19.598-5.552 24.619-3.697 5.264-8.835 6.739-13.361 7.709-1.544.33-2.947.474-4.219.474zm-9.19-43.671c2.819 2.256 3.066 6.501 3.287 14.434.028.99.054 1.927.089 2.802.106 2.65.355 8.855 1.327 11.477.137.371.26.747.39 1.146 1.083 3.316 1.626 4.979 6.309 3.978 3.931-.843 5.952-1.599 7.534-3.851 2.299-3.274 3.585-9.86 3.821-19.575l4.783.116-4.75-.57.14-1.186c.455-3.91.783-6.734 3.396-8.602 2.097-1.498 4.486-1.353 6.389-1.01-2.091-1.58-2.669-3.433-2.823-4.193l-.399-1.965 1.121-1.663c6.457-9.58 11.781-21.354 14.609-32.304 2.906-11.251 2.02-17.226 1.134-18.356-11.729-14.987-32.068-8.799-34.192-8.097l-.359.194-1.8.335-.922-.191c-2.542-.528-5.366-.82-8.393-.869-4.756-.08-8.593 1.044-11.739 3.431l-2.183 1.655-2.533-1.043c-5.412-2.213-21.308-6.662-29.696-.721-4.656 3.298-6.777 9.76-6.305 19.207.156 3.119 2.275 14.926 5.771 26.377 4.831 15.825 9.221 21.082 11.054 21.693.32.108 1.15-.537 1.976-1.529a270.708 270.708 0 0110.694-12.07l2.77-2.915 3.349 2.225c1.35.897 2.839 1.406 4.368 1.502l7.987-6.812-1.157 11.808c-.026.265-.039.626.065 1.296l.348 2.238-1.51 1.688-.174.196 4.388 2.025 1.836-2.301z"/><path fill="#336791" d="M115.731 77.44c-13.925 2.873-14.882-1.842-14.882-1.842 14.703-21.816 20.849-49.51 15.545-56.287C101.924.823 76.875 9.566 76.457 9.793l-.135.024c-2.751-.571-5.83-.911-9.291-.967-6.301-.103-11.08 1.652-14.707 4.402 0 0-44.684-18.408-42.606 23.151.442 8.842 12.672 66.899 27.26 49.363 5.332-6.412 10.483-11.834 10.483-11.834 2.559 1.699 5.622 2.567 8.833 2.255l.25-.212c-.078.796-.042 1.575.1 2.497-3.758 4.199-2.654 4.936-10.167 6.482-7.602 1.566-3.136 4.355-.22 5.084 3.534.884 11.712 2.136 17.237-5.598l-.221.882c1.473 1.18 2.507 7.672 2.334 13.557-.174 5.885-.29 9.926.871 13.082 1.16 3.156 2.316 10.256 12.192 8.14 8.252-1.768 12.528-6.351 13.124-13.995.422-5.435 1.377-4.631 1.438-9.49l.767-2.3c.884-7.367.14-9.743 5.225-8.638l1.235.108c3.742.17 8.639-.602 11.514-1.938 6.19-2.871 9.861-7.667 3.758-6.408z"/><path fill="#fff" d="M75.957 122.307c-8.232 0-10.84-6.519-11.907-9.185-1.562-3.907-1.899-19.069-1.551-31.503a1.59 1.59 0 011.64-1.55 1.594 1.594 0 011.55 1.639c-.401 14.341.168 27.337 1.324 30.229 1.804 4.509 4.54 8.453 12.275 6.796 7.343-1.575 10.093-4.359 11.318-11.46.94-5.449 2.799-20.951 3.028-24.01a1.593 1.593 0 011.71-1.472 1.597 1.597 0 011.472 1.71c-.239 3.185-2.089 18.657-3.065 24.315-1.446 8.387-5.185 12.191-13.794 14.037-1.463.313-2.792.453-4 .454zM31.321 90.466a6.71 6.71 0 01-2.116-.35c-5.347-1.784-10.44-10.492-15.138-25.885-3.576-11.717-5.842-23.947-6.041-27.922-.589-11.784 2.445-20.121 9.02-24.778 13.007-9.216 34.888-.44 35.813-.062a1.596 1.596 0 01-1.207 2.955c-.211-.086-21.193-8.492-32.768-.285-5.622 3.986-8.203 11.392-7.672 22.011.167 3.349 2.284 15.285 5.906 27.149 4.194 13.742 8.967 22.413 13.096 23.79.648.216 2.62.873 5.439-2.517A245.272 245.272 0 0145.88 73.046a1.596 1.596 0 012.304 2.208c-.048.05-4.847 5.067-10.077 11.359-2.477 2.979-4.851 3.853-6.786 3.853zm69.429-13.445a1.596 1.596 0 01-1.322-2.487c14.863-22.055 20.08-48.704 15.612-54.414-5.624-7.186-13.565-10.939-23.604-11.156-7.433-.16-13.341 1.738-14.307 2.069l-.243.099c-.971.305-1.716-.227-1.997-.849a1.6 1.6 0 01.631-2.025c.046-.027.192-.089.429-.176l-.021.006.021-.007c1.641-.601 7.63Line truncated

After

Width:  |  Height:  |  Size: 9.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#A41E11" d="M121.8 93.1c-6.7 3.5-41.4 17.7-48.8 21.6-7.4 3.9-11.5 3.8-17.3 1S13 98.1 6.3 94.9c-3.3-1.6-5-2.9-5-4.2V78s48-10.5 55.8-13.2c7.8-2.8 10.4-2.9 17-.5s46.1 9.5 52.6 11.9v12.5c0 1.3-1.5 2.7-4.9 4.4z"/><path fill="#D82C20" d="M121.8 80.5C115.1 84 80.4 98.2 73 102.1c-7.4 3.9-11.5 3.8-17.3 1-5.8-2.8-42.7-17.7-49.4-20.9C-.3 79-.5 76.8 6 74.3c6.5-2.6 43.2-17 51-19.7 7.8-2.8 10.4-2.9 17-.5s41.1 16.1 47.6 18.5c6.7 2.4 6.9 4.4.2 7.9z"/><path fill="#A41E11" d="M121.8 72.5C115.1 76 80.4 90.2 73 94.1c-7.4 3.8-11.5 3.8-17.3 1C49.9 92.3 13 77.4 6.3 74.2c-3.3-1.6-5-2.9-5-4.2V57.3s48-10.5 55.8-13.2c7.8-2.8 10.4-2.9 17-.5s46.1 9.5 52.6 11.9V68c0 1.3-1.5 2.7-4.9 4.5z"/><path fill="#D82C20" d="M121.8 59.8c-6.7 3.5-41.4 17.7-48.8 21.6-7.4 3.8-11.5 3.8-17.3 1C49.9 79.6 13 64.7 6.3 61.5s-6.8-5.4-.3-7.9c6.5-2.6 43.2-17 51-19.7 7.8-2.8 10.4-2.9 17-.5s41.1 16.1 47.6 18.5c6.7 2.4 6.9 4.4.2 7.9z"/><path fill="#A41E11" d="M121.8 51c-6.7 3.5-41.4 17.7-48.8 21.6-7.4 3.8-11.5 3.8-17.3 1C49.9 70.9 13 56 6.3 52.8c-3.3-1.6-5.1-2.9-5.1-4.2V35.9s48-10.5 55.8-13.2c7.8-2.8 10.4-2.9 17-.5s46.1 9.5 52.6 11.9v12.5c.1 1.3-1.4 2.6-4.8 4.4z"/><path fill="#D82C20" d="M121.8 38.3C115.1 41.8 80.4 56 73 59.9c-7.4 3.8-11.5 3.8-17.3 1S13 43.3 6.3 40.1s-6.8-5.4-.3-7.9c6.5-2.6 43.2-17 51-19.7 7.8-2.8 10.4-2.9 17-.5s41.1 16.1 47.6 18.5c6.7 2.4 6.9 4.4.2 7.8z"/><path fill="#fff" d="M80.4 26.1l-10.8 1.2-2.5 5.8-3.9-6.5-12.5-1.1 9.3-3.4-2.8-5.2 8.8 3.4 8.2-2.7L72 23zM66.5 54.5l-20.3-8.4 29.1-4.4z"/><ellipse fill="#fff" cx="38.4" cy="35.4" rx="15.5" ry="6"/><path fill="#7A0C00" d="M93.3 27.7l17.2 6.8-17.2 6.8z"/><path fill="#AD2115" d="M74.3 35.3l19-7.6v13.6l-1.9.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
@@ -0,0 +1,97 @@
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { AuthProvider, useAuth } from '@/hooks/useAuth';
import { ThemeProvider } from '@/hooks/useTheme';
import Layout from '@/components/Layout';
import LoginPage from '@/pages/LoginPage';
import Dashboard from '@/pages/Dashboard';
import InstancePage from '@/pages/InstancePage';
import TicketsPage from '@/pages/TicketsPage';
import QueriesPage from '@/pages/QueriesPage';
import RolesPage from '@/pages/RolesPage';
import UsersPage from '@/pages/UsersPage';
import AuditRulesPage from '@/pages/AuditRulesPage';
import WorkflowPage from '@/pages/WorkflowPage';
import AutoTaskPage from '@/pages/AutoTaskPage';
import SettingsPage from '@/pages/SettingsPage';
import AuditLogsPage from '@/pages/AuditLogsPage';
import DataDictionaryPage from '@/pages/DataDictionaryPage';
import InstanceDetailPage from '@/pages/InstanceDetailPage';
import RedisDetailPage from '@/pages/RedisDetailPage';
import MongoDBDetailPage from '@/pages/MongoDBDetailPage';
import TicketCreatePage from '@/pages/TicketCreatePage';
import TicketDetailPage from '@/pages/TicketDetailPage';
import InstanceGroupPage from '@/pages/InstanceGroupPage';
import { ToastContainer } from '@/components/ToastContainer';
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
function RouteGuard() {
const { isLoggedIn, hasPermission } = useAuth();
const path = location.pathname.split('/')[1] || 'dashboard';
if (!isLoggedIn) {
return <Navigate to="/login" replace />;
}
// 检查当前路径是否有权限访问
if (path !== 'dashboard' && !hasPermission(path)) {
return <Navigate to="/dashboard" replace />;
}
return <Outlet />;
}
// 登录页守卫:已登录用户自动跳转到仪表盘
function LoginGuard() {
const { isLoggedIn } = useAuth();
if (isLoggedIn) {
return <Navigate to="/dashboard" replace />;
}
return <LoginPage />;
}
function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<LoginGuard />} />
<Route element={<RouteGuard />}>
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="instances" element={<InstancePage />} />
<Route path="instance-groups" element={<InstanceGroupPage />} />
<Route path="instances/:id" element={<InstanceDetailPage />} />
<Route path="redis/:id" element={<RedisDetailPage />} />
<Route path="mongo/:id" element={<MongoDBDetailPage />} />
<Route path="data-dictionary" element={<DataDictionaryPage />} />
<Route path="tickets" element={<TicketsPage />} />
<Route path="tickets/create" element={<TicketCreatePage />} />
<Route path="tickets/:id" element={<TicketDetailPage />} />
<Route path="queries" element={<QueriesPage />} />
<Route path="roles" element={<RolesPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="audit-rules" element={<AuditRulesPage />} />
<Route path="audit-logs" element={<AuditLogsPage />} />
<Route path="workflow" element={<WorkflowPage />} />
<Route path="auto-task" element={<AutoTaskPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
</Route>
</Routes>
);
}
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<AppRoutes />
<ToastContainer />
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
);
}
export default App;
@@ -0,0 +1,34 @@
import request from './request';
export interface LoginResponse {
access_token: string;
token_type: string;
user: {
id: number;
username: string;
display_name: string;
role: string;
email: string | null;
data_source_permissions?: string;
custom_permissions?: string;
group_ids?: number[];
};
}
export interface MeResponse {
id: number;
username: string;
display_name: string;
role: string;
email: string | null;
data_source_permissions?: string;
custom_permissions?: string;
group_ids?: number[];
}
export const authApi = {
login: (username: string, password: string) =>
request.post<any, LoginResponse>('/auth/login', { username, password }),
getMe: () => request.get<any, MeResponse>('/auth/me'),
logout: () => request.post('/auth/logout'),
};
@@ -0,0 +1,5 @@
import request from './request';
export const dashboardApi = {
getStats: () => request.get<any, any>('/dashboard/stats'),
};
@@ -0,0 +1,51 @@
import request from './request';
export interface DBTypeOverview {
type: string;
name: string;
icon: string;
description: string;
category: string;
version: string;
supported_operations: number;
security_rules_count: number;
}
export interface DBTypeQueryType {
type: string;
description: string;
example: string;
}
export interface DBTypeDetail {
type: string;
name: string;
icon: string;
description: string;
version: string;
category: string;
supported_query_types: DBTypeQueryType[];
security_rules: string[];
tips: string[];
common_commands: Record<string, string>;
limitations: string[];
}
export interface DBTypeCommand {
name: string;
command: string;
}
export const dbTypesApi = {
list: () =>
request.get<any, { items: DBTypeOverview[]; total: number }>('/db-types'),
getDetail: (dbType: string) =>
request.get<any, DBTypeDetail>('/db-types/' + dbType),
getCommands: (dbType: string) =>
request.get<any, { items: DBTypeCommand[]; total: number }>('/db-types/' + dbType + '/commands'),
getTips: (dbType: string) =>
request.get<any, { items: string[]; total: number }>('/db-types/' + dbType + '/tips'),
};
@@ -0,0 +1,22 @@
import request from './request';
export var dictionaryApi = {
listDatabases: function (instanceId: number) {
return request.get('/dictionary/instances/' + instanceId + '/databases');
},
listTables: function (instanceId: number, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/tables', {
params: { database: database },
});
},
getTableDetail: function (instanceId: number, tableName: string, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/tables/' + tableName, {
params: { database: database },
});
},
exportDictionary: function (instanceId: number, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/export', {
params: { database: database },
});
},
};
@@ -0,0 +1,14 @@
import request from './request';
export const instanceApi = {
list: (params?: any) => request.get<any, any[]>('/instances', { params }),
accessible: (accessType: 'readonly' | 'write') => request.get<any, any[]>('/instances/accessible', { params: { access_type: accessType } }),
get: (id: number) => request.get<any, any>(`/instances/${id}`),
create: (data: any) => request.post<any, any>('/instances', data),
update: (id: number, data: any) => request.put<any, any>(`/instances/${id}`, data),
delete: (id: number) => request.delete(`/instances/${id}`),
testConnection: (id: number) => request.post<any, any>(`/instances/${id}/test-connection`),
testNewConnection: (data: any) => request.post<any, any>('/instances/test-connection', data),
listDatabases: (id: number) => request.get<any, string[]>(`/instances/${id}/databases`),
listTables: (id: number, db: string) => request.get<any, any[]>(`/instances/${id}/databases/${db}/tables`),
};
@@ -0,0 +1,89 @@
import request from './request';
/** 实例详情 API — 数据库/表/字段/索引/Redis Key */
export const instanceDetailApi = {
/** 获取 MySQL 数据库列表 */
listDatabases: (instanceId: number) =>
request.get<any, string[]>(`/instances/${instanceId}/detail/databases`),
/** 获取表列表(含引擎、行数等) */
listTables: (instanceId: number, database: string) =>
request.get<any, TableMeta[]>(`/instances/${instanceId}/detail/tables`, {
params: { database },
}),
/** 获取整库表+列结构(用于 SQL 自动补全) */
getDbSchema: (instanceId: number, database: string) =>
request.get<any, Record<string, string[]>>(`/instances/${instanceId}/detail/db-schema`, {
params: { database },
}),
/** 获取表详情(DDL + 字段 + 索引) */
getTableDetail: (instanceId: number, database: string, table: string) =>
request.get<any, TableDetail>(`/instances/${instanceId}/detail/table-detail`, {
params: { database, table },
}),
/** SHOW PROCESSLIST */
getProcesslist: (instanceId: number) =>
request.get<any, any[]>(`/instances/${instanceId}/detail/processlist`),
/** SHOW GLOBAL STATUS */
getStatus: (instanceId: number) =>
request.get<any, { key_metrics: Record<string, string>; all_status: any[] }>(
`/instances/${instanceId}/detail/status`
),
/** 终止进程 */
killProcess: (instanceId: number, pid: number) =>
request.post<any, any>(`/instances/${instanceId}/detail/processlist/${pid}/kill`),
/** 获取数据库用户列表 */
listUsers: (instanceId: number) =>
request.get<any, any[]>(`/instances/${instanceId}/detail/users`),
};
/** 表元信息 */
export interface TableMeta {
name: string;
engine: string;
rows: number;
data_length: number;
index_length: number;
comment: string;
collation: string;
create_time: string;
update_time: string;
}
/** 表详情 */
export interface TableDetail {
ddl: string;
columns: ColumnInfo[];
indexes: IndexInfo[];
}
export interface ColumnInfo {
Field: string;
Type: string;
Null: string;
Key: string;
Default: string | null;
Extra: string;
}
export interface IndexInfo {
Table: string;
Non_unique: number;
Key_name: string;
Seq_in_index: number;
Column_name: string;
Collation: string;
Cardinality: number;
Sub_part: string | null;
Packed: string | null;
Null: string;
Index_type: string;
Comment: string;
Index_comment: string;
}
@@ -0,0 +1,38 @@
import request from './request';
export interface InstanceGroup {
id: number;
name: string;
description?: string;
db_type: string;
permissions?: string[];
allowed_databases?: string[];
is_active: boolean;
created_at?: string;
updated_at?: string;
readonly_instance_ids?: number[];
write_instance_ids?: number[];
readonly_instance_count: number;
write_instance_count: number;
user_count: number;
readonly_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
write_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
users?: { id: number; username: string; display_name: string; role: string }[];
}
export const instanceGroupApi = {
list: (params?: any) => request.get<any, InstanceGroup[]>('/instance-groups', { params }),
get: (id: number) => request.get<any, InstanceGroup>(`/instance-groups/${id}`),
create: (data: any) => request.post<any, InstanceGroup>('/instance-groups', data),
update: (id: number, data: any) => request.put<any, InstanceGroup>(`/instance-groups/${id}`, data),
delete: (id: number) => request.delete(`/instance-groups/${id}`),
assignInstances: (groupId: number, instanceIds: number[], accessType: string = 'readonly') =>
request.post(`/instance-groups/${groupId}/instances`, { instance_ids: instanceIds, access_type: accessType }),
removeInstance: (groupId: number, instanceId: number, accessType?: string) =>
request.delete(`/instance-groups/${groupId}/instances/${instanceId}`, { params: accessType ? { access_type: accessType } : undefined }),
listUsers: (groupId: number) => request.get<any, any[]>(`/instance-groups/${groupId}/users`),
assignUsers: (groupId: number, userIds: number[], permissions?: string[]) =>
request.post(`/instance-groups/${groupId}/users`, { user_ids: userIds, permissions }),
updateUserGroups: (userId: number, groupIds: number[]) =>
request.put(`/instance-groups/users/${userId}/groups`, { group_ids: groupIds }),
};
@@ -0,0 +1,24 @@
import request from './request';
export var mongoApi = {
getDatabases: function (id: number) {
return request.get('/mongo/' + id + '/databases');
},
getCollections: function (id: number, database: string) {
return request.get('/mongo/' + id + '/databases/' + encodeURIComponent(database) + '/collections');
},
getCollectionStats: function (id: number, collection: string, database?: string) {
var params: Record<string, any> = {};
if (database) { params.database = database; }
return request.get('/mongo/' + id + '/collections/' + encodeURIComponent(collection) + '/stats', { params: params });
},
query: function (id: number, data: Record<string, any>) {
return request.post('/mongo/' + id + '/query', data);
},
execute: function (id: number, data: Record<string, any>) {
return request.post('/mongo/' + id + '/execute', data);
},
testConnection: function (id: number) {
return request.post('/mongo/' + id + '/test-connection');
},
};
@@ -0,0 +1,9 @@
import request from './request';
export const notificationApi = {
list: () => request.get<any, any[]>('/notifications'),
create: (data: any) => request.post<any, any>('/notifications', data),
update: (id: number, data: any) => request.put<any, any>(`/notifications/${id}`, data),
delete: (id: number) => request.delete(`/notifications/${id}`),
test: (id: number) => request.post<any, any>(`/notifications/${id}/test`),
};
@@ -0,0 +1,15 @@
import request from './request';
export const queryApi = {
execute: (data: { instance_id: number; database?: string; sql: string }) =>
request.post<any, any>('/query', data),
formatSQL: (sql: string) =>
request.post<any, any>('/sql-assistant/format', { sql }),
analyzeSQL: (sql: string, db_type: string) =>
request.post<any, any>('/sql-assistant/analyze', { sql, db_type }),
getDBTypeDetail: (dbType: string) =>
request.get<any, any>('/db-types/' + dbType),
};
@@ -0,0 +1,43 @@
import request from './request';
export const redisApi = {
// Redis 信息概览
getInfo: (instanceId: number) =>
request.get<any, any>('/redis/' + instanceId + '/info'),
// DBSIZE
getDbsize: (instanceId: number, db?: number) =>
request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }),
// 键列表
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) =>
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
// 键详情
getKeyDetail: (instanceId: number, key: string, db?: number) =>
request.get<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
// 慢日志
getSlowlog: (instanceId: number, count?: number) =>
request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }),
// 客户端列表
getClients: (instanceId: number) =>
request.get<any, any>('/redis/' + instanceId + '/clients'),
// 执行命令
executeCommand: (instanceId: number, data: { db: number; command: string }) =>
request.post<any, any>('/redis/' + instanceId + '/command', data),
// 获取键列表(兼容旧接口)
getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) =>
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
// 创建键
createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) =>
request.post<any, any>('/redis/' + instanceId + '/keys', data),
// 删除键
deleteKey: (instanceId: number, key: string, db?: number) =>
request.delete<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
};
@@ -0,0 +1,50 @@
import axios from 'axios';
import { toast } from '@/hooks/useToast';
const request = axios.create({
baseURL: '/api/v1',
timeout: 30000,
});
request.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
request.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
const status = error.response?.status;
const detail = error.response?.data?.detail;
const msg = detail || error.message || '未知错误';
if (status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
} else if (status === 429) {
toast('warning', msg);
} else if (status === 422) {
// 验证错误 — 取第一个错误信息
const errors = error.response?.data?.detail;
if (Array.isArray(errors)) {
const first = errors[0];
toast('error', first?.msg || '请求参数错误');
} else {
toast('error', typeof errors === 'string' ? errors : '请求参数错误');
}
} else if (status && status >= 400) {
toast('error', msg);
} else {
toast('error', '网络连接失败');
}
return Promise.reject(error);
}
);
export default request;
@@ -0,0 +1,37 @@
import request from './request';
export var ticketApi = {
list: function (params?: any) {
return request.get('/tickets', { params: params });
},
get: function (id: number) {
return request.get('/tickets/' + id);
},
create: function (data: any) {
return request.post('/tickets', data);
},
review: function (id: number, data: { action: 'approve' | 'reject'; comment: string }) {
return request.post('/tickets/' + id + '/review', data);
},
execute: function (id: number) {
return request.post('/tickets/' + id + '/execute');
},
statistics: function () {
return request.get('/tickets/statistics');
},
timeline: function (id: number) {
return request.get('/tickets/' + id + '/timeline');
},
approvals: function (id: number) {
return request.get('/tickets/' + id + '/approvals');
},
cancel: function (id: number) {
return request.post('/tickets/' + id + '/cancel');
},
rollback: function (id: number) {
return request.post('/tickets/' + id + '/rollback');
},
audit: function (data: { sql_content: string; instance_id: number; database: string }) {
return request.post('/tickets/audit', data);
},
};
@@ -0,0 +1,25 @@
import request from './request';
export const userApi = {
list: (params?: any) => request.get<any, any>('/users', { params }),
get: (id: number) => request.get<any, any>(`/users/${id}`),
create: (data: any) => request.post<any, any>('/users', data),
update: (id: number, data: any) => request.put<any, any>(`/users/${id}`, data),
delete: (id: number) => request.delete(`/users/${id}`),
resetPassword: (id: number, data: { new_password: string }) =>
request.post(`/users/${id}/reset-password`, data),
batchImport: (data: any) => request.post('/users/batch-import', data),
batchImportLdap: (data: any) => request.post('/users/batch-import-ldap', data),
downloadTemplate: () => request.get('/users/import-template', { responseType: 'blob' }),
};
export const ldapApi = {
getConfig: () => request.get('/ldap-config'),
updateConfig: (data: any) => request.put('/ldap-config', data),
testConnection: (data: any) => request.post('/ldap-config/test', data),
listOus: () => request.post('/ldap-config/ou-list'),
listUsers: (data?: any) => request.post('/ldap-config/user-list', data || {}),
createOu: (data: any) => request.post('/ldap-config/create-ou', data),
createUser: (data: any) => request.post('/ldap-config/create-user', data),
batchImportToLdap: (data: any) => request.post('/ldap-config/batch-import-ldap', data),
};
@@ -0,0 +1,11 @@
import request from './request';
export const workflowApi = {
list: () => request.get<any, any[]>('/workflows'),
get: (id: number) => request.get<any, any>(`/workflows/${id}`),
create: (data: any) => request.post<any, any>('/workflows', data),
update: (id: number, data: any) => request.put<any, any>(`/workflows/${id}`, data),
delete: (id: number) => request.delete(`/workflows/${id}`),
setDefault: (id: number) => request.post<any, any>(`/workflows/${id}/set-default`),
clone: (id: number) => request.post<any, any>(`/workflows/${id}/clone`),
};
@@ -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 };
}
@@ -0,0 +1,59 @@
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface Props {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
title?: string;
description?: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'default';
}
export function ConfirmDialog({
open,
onConfirm,
onCancel,
title = '确认操作',
description,
confirmText = '确认',
cancelText = '取消',
variant = 'default',
}: Props) {
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onCancel(); }}>
<DialogContent
showCloseButton={false}
className="border-0 sm:max-w-[400px]"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogTitle className="text-base">{title}</DialogTitle>
{description && (
<DialogDescription className="text-sm text-[var(--dg-text-secondary)]">
{description}
</DialogDescription>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={onCancel}>
{cancelText}
</Button>
<Button
size="sm"
variant={variant === 'danger' ? 'destructive' : 'default'}
onClick={onConfirm}
>
{confirmText}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,276 @@
import { useState, useCallback, useEffect } from 'react';
import { X, Loader2, Plus, Trash2 } from 'lucide-react';
import { instanceApi } from '@/api/instance';
import { workflowApi } from '@/api/workflow';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface InstanceData {
id: number;
name: string;
db_type: string;
host: string;
port: number;
username: string;
database: string;
env: string;
access_mode: string;
workflow_id?: number | null;
workflow_name?: string | null;
excluded_databases?: string[] | null;
mask_fields?: { table: string; field: string; rule: string }[] | null;
group_id?: number | null;
}
interface WorkflowItem {
id: number;
name: string;
is_active?: boolean;
}
interface Props {
instance: InstanceData;
open: boolean;
onClose: () => void;
onSaved: () => void;
mode?: 'edit' | 'clone';
}
/* ------------------------------------------------------------------ */
/* Reusable Edit Instance Dialog */
/* ------------------------------------------------------------------ */
export default function EditInstanceDialog({ instance, open, onClose, onSaved, mode = 'edit' }: Props) {
const [name, setName] = useState(instance.name);
const [host, setHost] = useState(instance.host);
const [port, setPort] = useState(String(instance.port));
const [username, setUsername] = useState(instance.username);
const [password, setPassword] = useState('');
const [database, setDatabase] = useState(instance.database || '');
const [env, setEnv] = useState(instance.env || 'development');
const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite');
const [workflowId, setWorkflowId] = useState<number | null>(instance.workflow_id || null);
const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
// excluded_databases: comma-separated input
const [excludedDbs, setExcludedDbs] = useState((instance.excluded_databases || []).join(', '));
// mask_fields
const [maskFields, setMaskFields] = useState<{ table: string; field: string; rule: string }[]>(
instance.mask_fields && instance.mask_fields.length > 0 ? instance.mask_fields : []
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
/* Sync when instance changes */
useEffect(() => {
setName(instance.name);
setHost(instance.host);
setPort(String(instance.port));
setUsername(instance.username);
setPassword('');
setDatabase(instance.database || '');
setEnv(instance.env || 'development');
setAccessMode(instance.access_mode || 'readwrite');
setWorkflowId(instance.workflow_id || null);
setExcludedDbs((instance.excluded_databases || []).join(', '));
setMaskFields(instance.mask_fields && instance.mask_fields.length > 0 ? instance.mask_fields : []);
setError('');
}, [instance]);
/* Load workflows when modal opens */
useEffect(() => {
if (open) {
workflowApi.list().then((data: any) => {
const items = Array.isArray(data) ? data : (data?.items || []);
setWorkflows(items.filter((w: WorkflowItem) => w.is_active !== false));
}).catch(() => setWorkflows([]));
}
}, [open]);
const addMaskField = () => setMaskFields([...maskFields, { table: '', field: '', rule: 'mask' }]);
const removeMaskField = (idx: number) => setMaskFields(maskFields.filter((_, i) => i !== idx));
const updateMaskField = (idx: number, key: string, value: string) => {
const next = [...maskFields];
next[idx] = { ...next[idx], [key]: value };
setMaskFields(next);
};
const handleSubmit = useCallback(async () => {
if (!name.trim()) { setError('请填写名称'); return; }
if (!host.trim()) { setError('请填写地址'); return; }
setSubmitting(true); setError('');
try {
const parsedExcluded = excludedDbs.trim()
? excludedDbs.split(',').map(s => s.trim()).filter(Boolean)
: undefined;
const parsedMask = maskFields.some(m => m.table && m.field)
? maskFields.filter(m => m.table && m.field)
: undefined;
const payload: Record<string, unknown> = {
name: name.trim(),
host: host.trim(),
port: Number(port),
username: username.trim() || undefined,
database: database.trim() || undefined,
env,
access_mode: accessMode,
workflow_id: workflowId,
excluded_databases: parsedExcluded,
mask_fields: parsedMask,
};
if (mode === 'clone') {
payload.db_type = instance.db_type;
if (!password) {
setError('克隆数据源需要重新输入密码');
setSubmitting(false);
return;
}
payload.password = password;
await instanceApi.create(payload);
} else {
payload.password = password || undefined;
await instanceApi.update(instance.id, payload);
}
onSaved();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败';
setError(msg);
} finally {
setSubmitting(false);
}
}, [name, host, port, username, password, database, env, accessMode, workflowId, excludedDbs, maskFields, instance.id, onSaved]);
if (!open) return null;
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors';
const labelCls = 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<h2 className="text-base font-semibold">{mode === 'clone' ? '克隆数据源' : '编辑数据源'}</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Body */}
<div className="px-5 pb-5 space-y-4">
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
<div>
<label className={labelCls}>名称</label>
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} placeholder="实例名称" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className={labelCls}>地址</label>
<input className={inputCls} value={host} onChange={e => setHost(e.target.value)} placeholder="127.0.0.1" />
</div>
<div>
<label className={labelCls}>端口</label>
<input className={inputCls} value={port} onChange={e => setPort(e.target.value)} placeholder="3306" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>用户名</label>
<input className={inputCls} value={username} onChange={e => setUsername(e.target.value)} />
</div>
<div>
<label className={labelCls}>{mode === 'clone' ? '密码' : '密码(留空不修改)'}</label>
<input className={inputCls} type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••" />
</div>
</div>
<div>
<label className={labelCls}>默认数据库</label>
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder="可选" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>环境</label>
<select className={inputCls} value={env} onChange={e => setEnv(e.target.value)}>
<option value="development">开发</option>
<option value="testing">测试</option>
<option value="staging">预发</option>
<option value="production">生产</option>
<option value="uat">UAT</option>
</select>
</div>
<div>
<label className={labelCls}>访问模式</label>
<select className={inputCls} value={accessMode} onChange={e => setAccessMode(e.target.value)}>
<option value="readwrite">读写</option>
<option value="read">只读</option>
<option value="write">只写</option>
</select>
</div>
</div>
{/* 审批流程 */}
<div>
<label className={labelCls}>审批流程</label>
<select className={inputCls} value={workflowId ?? ''} onChange={e => setWorkflowId(e.target.value ? Number(e.target.value) : null)}>
<option value="">不关联审批流程</option>
{workflows.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
{workflows.length === 0 && <p className="text-xs text-[var(--dg-text-muted)] mt-1">暂无可用审批流程</p>}
</div>
{/* 排除数据库 */}
<div>
<label className={labelCls}>排除数据库 <span className="font-normal text-[var(--dg-text-muted)]">(逗号分隔,如 sys, mysql)</span></label>
<input className={inputCls} value={excludedDbs} onChange={e => setExcludedDbs(e.target.value)} placeholder="sys, information_schema, mysql" />
</div>
{/* 脱敏字段 */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={labelCls}>脱敏字段</label>
<button onClick={addMaskField} className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1">
<Plus className="w-3 h-3" /> 添加
</button>
</div>
{maskFields.length === 0 && (
<div className="text-xs text-[var(--dg-text-muted)] py-2">未配置脱敏规则</div>
)}
<div className="space-y-2">
{maskFields.map((mf, idx) => (
<div key={idx} className="flex items-center gap-2">
<input className="flex-1 px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none" value={mf.table} onChange={e => updateMaskField(idx, 'table', e.target.value)} placeholder="表名" />
<input className="flex-1 px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none" value={mf.field} onChange={e => updateMaskField(idx, 'field', e.target.value)} placeholder="字段名" />
<select className="w-20 px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none" value={mf.rule} onChange={e => updateMaskField(idx, 'rule', e.target.value)}>
<option value="mask">遮盖</option>
<option value="hide">隐藏</option>
<option value="hash">哈希</option>
</select>
<button onClick={() => removeMaskField(idx)} className="p-1 text-[var(--dg-text-muted)] hover:text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4">
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">取消</button>
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors">
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
{mode === 'clone' ? '克隆' : '保存'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import { SearchX, Inbox, FileQuestion } from 'lucide-react';
interface EmptyStateProps {
type?: 'search' | 'data' | 'error';
title?: string;
description?: string;
}
const config = {
search: { icon: SearchX, title: '没有找到匹配的结果', desc: '请尝试调整搜索关键词或筛选条件' },
data: { icon: Inbox, title: '暂无数据', desc: '当前没有可展示的数据' },
error: { icon: FileQuestion, title: '出错了', desc: '请稍后重试' },
};
export default function EmptyState({ type = 'data', title, description }: EmptyStateProps) {
const c = config[type];
const Icon = c.icon;
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-14 h-14 rounded-2xl bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center mb-4">
<Icon className="w-7 h-7 text-[var(--dg-text-muted)]" />
</div>
<p className="text-sm font-medium text-[var(--dg-text-secondary)]">{title || c.title}</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{description || c.desc}</p>
</div>
);
}
@@ -0,0 +1,53 @@
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { XIcon } from 'lucide-react';
import type { ReactNode } from 'react';
interface Props {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
width?: string;
footer?: ReactNode;
}
export function FormDialog({
open,
onClose,
title,
children,
width = 'max-w-lg',
footer,
}: Props) {
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
showCloseButton={false}
className={`border-0 ${width} flex max-h-[85vh] flex-col`}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
{/* Header */}
<div className="flex items-center justify-between">
<DialogTitle className="text-base font-semibold">{title}</DialogTitle>
<button
onClick={onClose}
className="rounded-xs p-1 text-[var(--dg-text-muted)] transition-opacity hover:opacity-70"
>
<XIcon className="h-4 w-4" />
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto pr-1">{children}</div>
{/* Optional footer */}
{footer && <div className="flex justify-end gap-2 pt-2">{footer}</div>}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,205 @@
import { useState, useEffect, useMemo } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard, Database, Server, Zap, Leaf,
Code2, FileEdit, Users, ShieldCheck, FolderLock, Shield, GitBranch, Bell,
LogOut, Settings, Timer, ScrollText, Megaphone, Sun, Moon, BookOpen,
ChevronRight, Monitor
} from 'lucide-react';
import { menuSections, menuItems } from '@/constants/menu';
import { useAuth } from '@/hooks/useAuth';
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
const iconMap: Record<string, React.ElementType> = {
LayoutDashboard, Database, Server, Zap, Leaf,
Code2, FileEdit, Users, ShieldCheck, FolderLock, Shield, GitBranch, Bell,
Settings, Timer, ScrollText, Megaphone, BookOpen,
};
export default function Layout() {
const { user, logout, visibleMenuItems } = useAuth();
const { theme, toggleTheme } = useTheme();
const location = useLocation();
const navigate = useNavigate();
const currentPath = location.pathname.split('/')[1] || 'dashboard';
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
import('@/api/request').then(m => m.default.get('/dashboard/stats'))
.then((data: any) => setPendingCount(data?.ticket_pending ?? 0))
.catch(() => {});
}, [location.pathname]);
// 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">
{/* 侧边栏 */}
<aside className="flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
{/* Logo */}
<div className="h-14 flex items-center px-4">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg dg-gradient-br flex items-center justify-center flex-shrink-0">
<Shield className="w-4 h-4 text-white" />
</div>
<span className="text-base font-bold dg-gradient-text whitespace-nowrap">
SQL-Platform
</span>
</div>
</div>
{/* 菜单 */}
<nav className="flex-1 overflow-y-auto scrollbar-thin py-1">
{menuSections.map(section => {
const items = visibleMenuItems.filter(item => item.section === section.id);
if (items.length === 0) return null;
return (
<div key={section.id} className="mb-1">
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--dg-text-muted)]">
{section.label}
</div>
{items.map(item => {
const Icon = iconMap[item.icon] || Database;
const isActive = currentPath === item.id;
return (
<div
key={item.id}
onClick={() => navigate('/' + item.id)}
className={
'group flex items-center mx-2 px-3 py-2 rounded-lg cursor-pointer '
+ 'transition-all duration-200 relative text-[13px] '
+ (isActive
? '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-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 transition-colors duration-200 '
+ (isActive
? 'text-[var(--dg-blue)]'
: 'group-hover:text-[var(--dg-blue)]')
}
/>
<span className="ml-2.5">{item.label}</span>
</div>
);
})}
</div>
);
})}
</nav>
</aside>
{/* 主内容区 */}
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* 顶部栏 */}
<header className="h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-5 flex-shrink-0">
{/* 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)] hover:text-[var(--dg-text-primary)] transition-all duration-200"
title={`当前主题: ${themeOption?.name || theme}\n点击切换下一个主题`}
>
{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)] 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 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}
</div>
<span className="text-sm text-[var(--dg-text-secondary)] max-w-[80px] truncate">{user.username}</span>
<button
onClick={() => { logout(); navigate('/login'); }}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/10 hover:text-red-400 transition-colors duration-200"
title="退出登录"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</header>
{/* 页面内容 */}
<div className="flex-1 overflow-y-auto scrollbar-thin p-5">
<Outlet />
</div>
</main>
{/* 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>
);
}
@@ -0,0 +1,55 @@
import { useAuth, actionPermissionMap } from '@/hooks/useAuth';
import type { ReactNode } from 'react';
interface ActionConfig {
label: string;
icon: React.ElementType;
onClick: () => void;
permission?: string;
}
interface PageHeaderProps {
title: string;
description?: string;
action?: ActionConfig;
secondaryAction?: ActionConfig;
children?: ReactNode;
}
export default function PageHeader({ title, description, action, secondaryAction, children }: PageHeaderProps) {
const { user, hasPermission } = useAuth();
const canShowAction = !action?.permission
|| hasPermission(action.permission)
|| (actionPermissionMap[action.permission] || []).includes(user.role);
return (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">{title}</h1>
{description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{description}</p>}
{children}
</div>
<div className="flex gap-3">
{secondaryAction && (
<button
onClick={secondaryAction.onClick}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all"
>
{(() => { const Icon = secondaryAction.icon; return <Icon className="w-4 h-4" />; })()}
{secondaryAction.label}
</button>
)}
{canShowAction && action && (
<button
onClick={action.onClick}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
{(() => { const Icon = action.icon; return <Icon className="w-4 h-4" />; })()}
{action.label}
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,614 @@
import { useState, useEffect, useCallback } from 'react';
import {
ChevronDown, ChevronRight, Database, Table2, Columns3,
KeyRound, Search, Loader2, FolderOpen, Server,
} from 'lucide-react';
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
import { redisApi } from '@/api/redis';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface Props {
instanceId: string;
database: string;
dbType: string;
onInsert: (text: string) => void;
}
interface RedisKeyItem {
key: string;
type: string;
ttl: number;
}
/** Redis key 树节点 */
interface RedisTreeNode {
name: string;
fullPrefix: string;
isLeaf: boolean;
keyItem?: RedisKeyItem; // 叶子节点才有
children: Map<string, RedisTreeNode>;
}
/** 把 flat keys 按 ':' 构建成树 */
function buildRedisTree(keys: RedisKeyItem[]): RedisTreeNode {
const root: RedisTreeNode = { name: '', fullPrefix: '', isLeaf: false, children: new Map() };
for (const item of keys) {
const parts = item.key.split(':');
let node = root;
let prefix = '';
for (let i = 0; i < parts.length; i++) {
prefix += (i === 0 ? '' : ':') + parts[i];
if (!node.children.has(parts[i])) {
node.children.set(parts[i], {
name: parts[i],
fullPrefix: prefix,
isLeaf: i === parts.length - 1,
keyItem: i === parts.length - 1 ? item : undefined,
children: new Map(),
});
}
node = node.children.get(parts[i])!;
}
}
return root;
}
/** Redis 树形渲染组件 */
/** 根据 key type 生成查询命令 */
function redisCommand(key: string, type: string): string {
switch (type) {
case 'hash': return `HGETALL ${key}`;
case 'list': return `LRANGE ${key} 0 -1`;
case 'set': return `SMEMBERS ${key}`;
case 'zset': return `ZRANGE ${key} 0 -1 WITHSCORES`;
default: return `GET ${key}`;
}
}
function RedisTree({ tree, expanded, setExpanded, selectedKey, onSelectKey, keyDetail, loadingDetail, onInsert }: {
tree: RedisTreeNode;
expanded: Set<string>;
setExpanded: React.Dispatch<React.SetStateAction<Set<string>>>;
selectedKey: string | null;
onSelectKey: (key: string) => void;
keyDetail: any;
loadingDetail: boolean;
onInsert?: (cmd: string) => void;
}) {
const toggle = (prefix: string) => {
setExpanded(prev => {
const next = new Set(prev);
next.has(prefix) ? next.delete(prefix) : next.add(prefix);
return next;
});
};
const renderNode = (node: RedisTreeNode, depth: number) => {
const sorted = [...node.children.values()].sort((a, b) => {
// 文件夹优先,叶子在后
if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1;
return a.name.localeCompare(b.name);
});
return sorted.map(child => {
const indent = depth * 12;
if (child.isLeaf && child.keyItem) {
// 叶子节点:实际 Redis key
return (
<div key={child.fullPrefix}>
<button
onClick={() => {
onSelectKey(child.keyItem!.key);
if (onInsert) onInsert(redisCommand(child.keyItem!.key, child.keyItem!.type));
}}
className={`flex items-center gap-1.5 w-full px-2 py-1 text-xs transition-colors ${
selectedKey === child.keyItem!.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
}`}
style={{ paddingLeft: 8 + indent }}
>
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
<span className="font-mono truncate flex-1 text-left">{child.name}</span>
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] flex-shrink-0">{child.keyItem.type}</span>
</button>
{selectedKey === child.keyItem.key && (
<div className="px-2 pb-2" style={{ paddingLeft: 8 + indent }}>
{loadingDetail ? (
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]">
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
</div>
) : keyDetail ? (
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{keyDetail.type}</span>
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
</div>
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
{typeof keyDetail.value === 'object'
? JSON.stringify(keyDetail.value, null, 2)
: String(keyDetail.value)}
</pre>
</div>
) : null}
</div>
)}
</div>
);
}
// 文件夹节点
const isOpen = expanded.has(child.fullPrefix);
const childCount = child.children.size;
return (
<div key={child.fullPrefix}>
<button
onClick={() => toggle(child.fullPrefix)}
className="flex items-center gap-1.5 w-full px-2 py-1 text-xs text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
style={{ paddingLeft: 8 + indent }}
>
{isOpen
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
: <ChevronRight className="w-3 h-3 flex-shrink-0" />
}
<FolderOpen className="w-3 h-3 text-amber-400/70 flex-shrink-0" />
<span className="truncate flex-1 text-left">{child.name}</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">{childCount}</span>
</button>
{isOpen && renderNode(child, depth + 1)}
</div>
);
});
};
return <div>{renderNode(tree, 0)}</div>;
}
/** 每个数据库节点缓存的表列表 */
interface DbNode {
name: string;
tables: TableMeta[];
loading: boolean;
loaded: boolean;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatTTL(ttl: number): string {
if (ttl < 0) return '永不过期';
if (ttl < 60) return `${ttl}s`;
if (ttl < 3600) return `${Math.floor(ttl / 60)}m`;
if (ttl < 86400) return `${Math.floor(ttl / 3600)}h`;
return `${Math.floor(ttl / 86400)}d`;
}
/* ================================================================== */
/* COMPONENT */
/* ================================================================== */
export default function SchemaSidebar({ instanceId, database, dbType, onInsert }: Props) {
const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql';
const isRedis = dbType === 'redis';
// -- MySQL: DB → Table → Column 三级 --
const [databases, setDatabases] = useState<string[]>([]);
const [loadingDbs, setLoadingDbs] = useState(false);
const [expandedDb, setExpandedDb] = useState<string | null>(null);
const [dbNodes, setDbNodes] = useState<Record<string, DbNode>>({});
const [expandedTable, setExpandedTable] = useState<string | null>(null);
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [search, setSearch] = useState('');
// -- Redis State --
const [redisDb, setRedisDb] = useState(0);
const [redisPattern, setRedisPattern] = useState('*');
const [redisKeys, setRedisKeys] = useState<RedisKeyItem[]>([]);
const [loadingRedis, setLoadingRedis] = useState(false);
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyDetail, setKeyDetail] = useState<any>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
// -- Tab --
const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables');
/* ---------------------------------------------------------------- */
/* Fetch databases (level 1) */
/* ---------------------------------------------------------------- */
useEffect(() => {
if (!instanceId || !isMySQL) { setDatabases([]); return; }
setLoadingDbs(true);
instanceDetailApi.listDatabases(Number(instanceId))
.then((data) => {
const list = Array.isArray(data) ? data : [];
setDatabases(list);
// 如果当前已选了 database,自动展开
if (database && list.includes(database)) {
setExpandedDb(database);
}
})
.catch(() => setDatabases([]))
.finally(() => setLoadingDbs(false));
}, [instanceId, isMySQL]);
// 当 database prop 变化时自动展开
useEffect(() => {
if (database && databases.includes(database) && expandedDb !== database) {
setExpandedDb(database);
}
}, [database, databases]);
/* ---------------------------------------------------------------- */
/* Expand database → load tables (level 2) */
/* ---------------------------------------------------------------- */
const handleExpandDb = useCallback(async (dbName: string) => {
if (expandedDb === dbName) {
setExpandedDb(null);
return;
}
setExpandedDb(dbName);
setExpandedTable(null);
// 已加载过则跳过
if (dbNodes[dbName]?.loaded) return;
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: true, loaded: false } }));
try {
const data = await instanceDetailApi.listTables(Number(instanceId), dbName);
const tables = Array.isArray(data) ? data : [];
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables, loading: false, loaded: true } }));
} catch {
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: false, loaded: true } }));
}
}, [expandedDb, instanceId, dbNodes]);
/* ---------------------------------------------------------------- */
/* Expand table → load columns (level 3) */
/* ---------------------------------------------------------------- */
const handleExpandTable = useCallback(async (dbName: string, tableName: string) => {
const key = `${dbName}.${tableName}`;
if (expandedTable === key) { setExpandedTable(null); return; }
setExpandedTable(key);
setTableColumns([]);
setLoadingColumns(true);
try {
const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName);
setTableColumns(detail?.columns || []);
} catch { /* ignore */ }
finally { setLoadingColumns(false); }
}, [expandedTable, instanceId]);
/* ---------------------------------------------------------------- */
/* Fetch Redis keys */
/* ---------------------------------------------------------------- */
const fetchRedisKeys = useCallback(async () => {
if (!instanceId) return;
setLoadingRedis(true);
try {
const res = await redisApi.scanKeys(Number(instanceId), {
db: redisDb, pattern: redisPattern || '*', count: 100,
});
const keys = (res?.keys || []).map((k: any) => ({
key: typeof k === 'string' ? k : k.key,
type: typeof k === 'string' ? 'string' : (k.type || 'string'),
ttl: k.ttl ?? -1,
}));
setRedisKeys(keys);
} catch { setRedisKeys([]); }
finally { setLoadingRedis(false); }
}, [instanceId, redisDb, redisPattern]);
useEffect(() => {
if (isRedis && instanceId) fetchRedisKeys();
}, [isRedis, instanceId, fetchRedisKeys]);
/* ---------------------------------------------------------------- */
/* Fetch Redis key detail */
/* ---------------------------------------------------------------- */
const handleSelectKey = useCallback(async (key: string) => {
setSelectedKey(key);
setLoadingDetail(true);
try {
const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb);
setKeyDetail(detail);
} catch { setKeyDetail(null); }
finally { setLoadingDetail(false); }
}, [instanceId, redisDb]);
/* ---------------------------------------------------------------- */
/* Filtered databases (search matches db name or table name) */
/* ---------------------------------------------------------------- */
const filteredDatabases = databases.filter(dbName => {
// 如果已选目标数据库,只显示该库
if (database && dbName !== database) return false;
if (!search) return true;
const kw = search.toLowerCase();
// 库名匹配
if (dbName.toLowerCase().includes(kw)) return true;
// 该库下的表名匹配
const node = dbNodes[dbName];
if (node?.loaded) {
return node.tables.some(t => t.name.toLowerCase().includes(kw));
}
return false;
});
const getFilteredTables = (dbName: string) => {
const node = dbNodes[dbName];
if (!node?.loaded) return [];
if (!search) return node.tables;
const kw = search.toLowerCase();
return node.tables.filter(t => t.name.toLowerCase().includes(kw));
};
/* ================================================================== */
/* RENDER */
/* ================================================================== */
if (!instanceId) {
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<FolderOpen className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
<span className="text-xs font-medium text-[var(--dg-text-muted)]">数据库导航</span>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-xs text-[var(--dg-text-muted)] text-center px-4">请先选择实例</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-[var(--dg-bg-secondary)]">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<Server className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
<span className="text-xs font-medium truncate flex-1">{dbType.toUpperCase()}</span>
</div>
{/* Tabs */}
{(isMySQL || isRedis) && (
<div className="flex border-b border-[var(--dg-border)]">
{isMySQL && (
<button
onClick={() => setActiveTab('tables')}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
activeTab === 'tables'
? 'text-blue-400 border-blue-400 bg-blue-500/5'
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
}`}
>
<Database className="w-3 h-3" /> 库表
</button>
)}
{isRedis && (
<button
onClick={() => setActiveTab('redis')}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
activeTab === 'redis'
? 'text-blue-400 border-blue-400 bg-blue-500/5'
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
}`}
>
<KeyRound className="w-3 h-3" /> Keys
</button>
)}
</div>
)}
{/* Content */}
<div className="flex-1 overflow-hidden">
{activeTab === 'tables' && isMySQL && (
<div className="h-full flex flex-col">
{/* Search */}
<div className="px-2 py-2 border-b border-[var(--dg-border-light)]">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-7 pr-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
placeholder="搜索库/表..."
/>
</div>
</div>
{/* DB → Table → Column tree */}
<div className="flex-1 overflow-y-auto">
{loadingDbs ? (
<div className="space-y-1 p-2">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<div className="w-3 h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
<div className="h-3.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
</div>
))}
</div>
) : filteredDatabases.length === 0 ? (
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无数据库</div>
) : (
<div>
{filteredDatabases.map(dbName => {
const isExpanded = expandedDb === dbName;
const node = dbNodes[dbName];
const tables = getFilteredTables(dbName);
const isActive = dbName === database;
return (
<div key={dbName}>
{/* Level 1: Database */}
<button
onClick={() => handleExpandDb(dbName)}
className={`flex items-center gap-1.5 w-full px-2 py-1.5 text-xs hover:bg-[var(--dg-bg-hover)] transition-colors ${
isActive ? 'text-blue-400' : ''
}`}
>
{isExpanded
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
}
<Database className="w-3 h-3 text-amber-400 flex-shrink-0" />
<span className="font-mono truncate flex-1 text-left">{dbName}</span>
{isActive && (
<span className="text-[8px] px-1 bg-blue-500/10 text-blue-400 rounded flex-shrink-0">当前</span>
)}
</button>
{/* Level 2: Tables */}
{isExpanded && (
<div className="ml-3 border-l border-[var(--dg-border-light)]">
{node?.loading ? (
<div className="space-y-1 px-2 py-1">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="flex items-center gap-2 px-1 py-1">
<div className="w-2.5 h-2.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
<div className="h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
</div>
))}
</div>
) : tables.length === 0 ? (
<div className="px-3 py-1 text-[10px] text-[var(--dg-text-muted)]">无表</div>
) : (
tables.map(t => {
const tableKey = `${dbName}.${t.name}`;
const isTableExpanded = expandedTable === tableKey;
return (
<div key={t.name}>
<button
onClick={() => handleExpandTable(dbName, t.name)}
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group"
>
{isTableExpanded
? <ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
: <ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
}
<Table2 className="w-2.5 h-2.5 text-blue-400 flex-shrink-0" />
<span
className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors"
onClick={(e) => { e.stopPropagation(); onInsert(t.name); }}
title="点击插入表名"
>
{t.name}
</span>
{t.rows !== undefined && (
<span className="text-[8px] text-[var(--dg-text-muted)] flex-shrink-0">{t.rows.toLocaleString()}</span>
)}
</button>
{/* Level 3: Columns */}
{isTableExpanded && (
<div className="ml-4 border-l border-[var(--dg-border-light)]">
{loadingColumns ? (
<div className="flex items-center gap-1 p-1.5 text-[10px] text-[var(--dg-text-muted)]">
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
</div>
) : (
tableColumns.map(col => (
<button
key={col.Field}
onClick={() => onInsert(col.Field)}
className="flex items-center gap-1.5 w-full px-2 py-0.5 text-[10px] hover:bg-[var(--dg-bg-hover)] transition-colors"
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
>
<Columns3 className="w-2 h-2 text-[var(--dg-text-muted)] flex-shrink-0" />
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 truncate">{col.Field}</span>
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">{col.Type}</span>
{col.Key === 'PRI' && <span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">PK</span>}
</button>
))
)}
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
{activeTab === 'redis' && isRedis && (
<div className="h-full flex flex-col">
{/* DB + Search */}
<div className="px-2 py-2 border-b border-[var(--dg-border-light)] space-y-1.5">
<select
value={redisDb}
onChange={(e) => { setRedisDb(Number(e.target.value)); }}
className="w-full px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
>
{Array.from({ length: 16 }, (_, i) => (
<option key={i} value={i}>DB{i}</option>
))}
</select>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
<input
value={redisPattern}
onChange={(e) => setRedisPattern(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()}
className="w-full pl-7 pr-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
placeholder="*"
/>
</div>
<button
onClick={fetchRedisKeys}
disabled={loadingRedis}
className="w-full px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50"
>
{loadingRedis ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '扫描'}
</button>
</div>
{/* Key Tree + Detail */}
<div className="flex-1 overflow-y-auto">
{loadingRedis ? (
<div className="space-y-1 p-2">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<div className="w-3 h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
<div className="h-3.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
<div className="h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-10" />
</div>
))}
</div>
) : redisKeys.length === 0 ? (
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无 Keys</div>
) : (
<RedisTree
tree={buildRedisTree(redisKeys)}
expanded={redisExpanded}
setExpanded={setRedisExpanded}
selectedKey={selectedKey}
onSelectKey={handleSelectKey}
keyDetail={keyDetail}
loadingDetail={loadingDetail}
onInsert={onInsert}
/>
)}
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,65 @@
export function SkeletonCard({ count = 4 }: { count?: number }) {
return (
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="dg-card p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)]" />
<div className="flex-1 space-y-2">
<div className="h-6 w-12 bg-[var(--dg-bg-tertiary)] rounded" />
<div className="h-3 w-16 bg-[var(--dg-bg-tertiary)] rounded" />
</div>
</div>
</div>
))}
</div>
);
}
export function SkeletonTable({ rows = 5, cols = 6 }: { rows?: number; cols?: number }) {
return (
<div className="dg-card animate-pulse">
<div className="p-0">
<div className="flex gap-4 px-5 py-3 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
{Array.from({ length: cols }).map((_, i) => (
<div key={i} className="h-4 flex-1 bg-[var(--dg-bg-primary)] rounded max-w-[120px]" />
))}
</div>
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="flex gap-4 px-5 py-3.5 border-b border-[var(--dg-border-light)]">
{Array.from({ length: cols }).map((_, c) => (
<div key={c} className="h-4 flex-1 bg-[var(--dg-bg-tertiary)] rounded max-w-[120px]" />
))}
</div>
))}
</div>
</div>
);
}
export function SkeletonDetail() {
return (
<div className="space-y-5 animate-pulse max-w-5xl">
<div className="flex items-center gap-4">
<div className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)]" />
<div className="space-y-2">
<div className="h-7 w-48 bg-[var(--dg-bg-tertiary)] rounded" />
<div className="h-4 w-32 bg-[var(--dg-bg-tertiary)] rounded" />
</div>
</div>
<div className="grid grid-cols-2 gap-5">
<div className="dg-card p-5 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex justify-between py-2">
<div className="h-4 w-24 bg-[var(--dg-bg-tertiary)] rounded" />
<div className="h-4 w-32 bg-[var(--dg-bg-tertiary)] rounded" />
</div>
))}
</div>
<div className="dg-card p-5">
<div className="h-48 bg-[var(--dg-bg-tertiary)] rounded" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,432 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { EditorState, Compartment } from '@codemirror/state';
import type { Extension } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view';
import { sql, SQLDialect } from '@codemirror/lang-sql';
import { oneDark } from '@codemirror/theme-one-dark';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import {
syntaxHighlighting,
defaultHighlightStyle,
bracketMatching,
} from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap, autocompletion } from '@codemirror/autocomplete';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SqlEditorProps {
value: string;
onChange: (val: string) => void;
placeholder?: string;
language?: 'sql' | 'redis' | 'mongodb';
height?: string;
readOnly?: boolean;
onExecute?: () => void;
/** SQL schema: { [dbName]: { [tableName]: string[] (columns) } } or { [tableName]: string[] } */
schema?: Record<string, Record<string, string[]> | string[]>;
}
// ---------------------------------------------------------------------------
// Language configs
// ---------------------------------------------------------------------------
const REDIS_KEYWORDS = [
'GET', 'SET', 'KEYS', 'HGET', 'HSET', 'HGETALL', 'HDEL', 'HMSET',
'ZADD', 'ZRANGE', 'ZREM', 'ZRANK', 'ZSCORE', 'ZCARD',
'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LRANGE', 'LLEN',
'SADD', 'SMEMBERS', 'SREM', 'SCARD', 'SISMEMBER',
'INFO', 'CLIENT', 'CONFIG', 'DEL', 'EXISTS', 'EXPIRE', 'TTL',
'TYPE', 'RENAME', 'DBSIZE', 'FLUSHDB', 'FLUSHALL',
'PING', 'ECHO', 'SELECT', 'MOVE', 'OBJECT', 'SORT',
'MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH',
'PUBLISH', 'SUBSCRIBE', 'UNSUBSCRIBE', 'PSUBSCRIBE',
'SCAN', 'HSCAN', 'SSCAN', 'ZSCAN',
'INCR', 'DECR', 'INCRBY', 'DECRBY', 'INCRBYFLOAT',
'APPEND', 'STRLEN', 'MGET', 'MSET', 'SETNX', 'SETEX',
'AUTH', 'QUIT', 'BGSAVE', 'SAVE', 'LASTSAVE',
'SLOWLOG', 'MONITOR', 'DEBUG', 'SHUTDOWN', 'SLAVEOF', 'REPLICAOF',
];
const MONGODB_KEYWORDS = [
// CRUD
'find', 'findOne', 'insertOne', 'insertMany', 'updateOne', 'updateMany',
'deleteOne', 'deleteMany', 'replaceOne', 'findOneAndUpdate', 'findOneAndDelete',
'findOneAndReplace', 'bulkWrite', 'aggregate', 'countDocuments', 'distinct',
// Aggregation stages
'match', 'group', 'project', 'sort', 'limit', 'skip', 'unwind', 'lookup',
'addFields', 'set', 'unset', 'facet', 'bucket', 'bucketAuto', 'sortByCount',
'graphLookup', 'out', 'merge', 'replaceRoot', 'sample', 'count',
// Operators
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'and', 'or', 'not',
'nor', 'exists', 'type', 'regex', 'expr', 'cond', 'switch', 'if', 'then',
'else', 'let', 'map', 'filter', 'reduce', 'range', 'concatArrays',
'avg', 'sum', 'min', 'max', 'push', 'addToSet', 'first', 'last',
// Index / admin
'createIndex', 'dropIndex', 'getIndexes', 'explain', 'hint',
// Collection / DB
'use', 'show', 'db', 'collection', 'collections', 'databases',
// JS keywords commonly used
'function', 'return', 'var', 'let', 'const', 'if', 'else', 'for', 'while',
'true', 'false', 'null', 'undefined', 'new', 'this', 'typeof', 'instanceof',
'ObjectId', 'Date', 'ISODate', 'NumberLong', 'NumberInt', 'NumberDecimal',
'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef',
];
function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEditorProps['schema']): Extension {
const lang = language ?? 'sql';
if (lang === 'redis') {
const dialect = SQLDialect.define({
keywords: REDIS_KEYWORDS.join(' '),
});
return sql({ dialect });
}
if (lang === 'mongodb') {
const dialect = SQLDialect.define({
keywords: MONGODB_KEYWORDS.join(' '),
});
return sql({ dialect });
}
// MySQL/PG — pass schema for autocomplete (tables + columns)
if (schema && Object.keys(schema).length > 0) {
// Flatten: if first value is an array, it's { table: columns[] }
// if first value is an object, it's { db: { table: columns[] } }
const sampleValue = Object.values(schema)[0];
let tables: Record<string, readonly string[]> = {};
if (Array.isArray(sampleValue)) {
// Direct: { table: [columns] }
tables = Object.fromEntries(
Object.entries(schema).map(([k, v]) => [k, v as string[]])
);
} else {
// Nested: { db: { table: [columns] } }
// Register: db → table names (so "db." shows tables)
// Register: table → columns (so "table." shows columns)
for (const [db, tableMap] of Object.entries(schema)) {
const entries = tableMap as Record<string, string[]>;
const tableNames = Object.keys(entries);
// "shop_db" → shows table names as completions after "."
tables[db] = tableNames;
for (const [table, cols] of Object.entries(entries)) {
// "goods" → shows column names after "."
if (cols.length > 0) {
tables[table] = cols;
}
}
}
}
const dialect = SQLDialect.define({
keywords: 'select from where order by group having limit offset join inner left right full outer cross on and or not in is null like between as asc desc distinct all case when then else end exists union intersect except insert into values update set delete create alter drop table index view primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze',
schema: tables,
});
return sql({ dialect });
}
return sql();
}
// ---------------------------------------------------------------------------
// Custom theme to match app CSS variables
// ---------------------------------------------------------------------------
const appTheme = EditorView.theme({
'&': {
fontSize: '13px',
backgroundColor: 'var(--dg-bg-primary)',
color: 'var(--dg-text-primary)',
border: '1px solid var(--dg-border)',
borderRadius: '6px',
},
'.cm-content': {
caretColor: 'var(--dg-text-primary)',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
},
'.cm-cursor': {
borderLeftColor: 'var(--dg-text-primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'rgba(66, 133, 244, 0.3) !important',
},
'.cm-gutters': {
backgroundColor: 'var(--dg-bg-tertiary)',
color: 'var(--dg-text-muted)',
border: 'none',
borderRight: '1px solid var(--dg-border)',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--dg-bg-tertiary)',
},
'.cm-activeLine': {
backgroundColor: 'rgba(255, 255, 255, 0.04)',
},
'.cm-placeholder': {
color: 'var(--dg-text-muted)',
fontStyle: 'italic',
},
'.cm-scroller': {
overflow: 'auto',
},
});
// ---------------------------------------------------------------------------
// formatSQL helper
// ---------------------------------------------------------------------------
const SQL_BREAK_KEYWORDS = [
'SELECT',
'FROM',
'WHERE',
'ORDER BY',
'GROUP BY',
'HAVING',
'LIMIT',
'OFFSET',
'JOIN',
'INNER JOIN',
'LEFT JOIN',
'RIGHT JOIN',
'FULL JOIN',
'CROSS JOIN',
'LEFT OUTER JOIN',
'RIGHT OUTER JOIN',
'FULL OUTER JOIN',
'ON',
'UNION',
'UNION ALL',
'INTERSECT',
'EXCEPT',
'INSERT INTO',
'VALUES',
'UPDATE',
'SET',
'DELETE FROM',
'CREATE TABLE',
'ALTER TABLE',
'DROP TABLE',
];
const SQL_KEYWORDS_UPPER = new Set([
'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET',
'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS', 'ON',
'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'AS',
'ASC', 'DESC', 'DISTINCT', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
'EXISTS', 'UNION', 'INTERSECT', 'EXCEPT',
'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'ALTER',
'DROP', 'TABLE', 'INDEX', 'VIEW', 'TRIGGER', 'PROCEDURE', 'FUNCTION',
'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'UNIQUE', 'CHECK', 'DEFAULT',
'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION',
'IF', 'ELSE', 'WHILE', 'FOR', 'RETURN',
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
'TRUE', 'FALSE', 'BOOLEAN', 'INTEGER', 'BIGINT', 'FLOAT', 'DOUBLE',
'VARCHAR', 'TEXT', 'DATE', 'TIMESTAMP', 'BLOB', 'JSON',
'TRUNCATE', 'EXPLAIN', 'ANALYZE', 'SHOW', 'DESCRIBE', 'USE', 'DATABASE',
]);
export function formatSQL(input: string): string {
// Normalise whitespace
let formatted = input.replace(/\s+/g, ' ').trim();
// Uppercase SQL keywords (word-boundary aware)
formatted = formatted.replace(/\b([A-Za-z_]+)\b/g, (match) => {
const upper = match.toUpperCase();
return SQL_KEYWORDS_UPPER.has(upper) ? upper : match;
});
// Insert newlines before major clauses
// We iterate in the order they might appear; use a single pass regex approach.
for (const kw of SQL_BREAK_KEYWORDS) {
// Only break on keyword that is not already at start of a line
const escaped = kw.replace(/\s+/g, '\\s+');
const re = new RegExp(`(?<!^)(?<!\\n)\\b(${escaped})\\b`, 'gi');
formatted = formatted.replace(re, '\n$1');
}
// Clean up any double newlines
formatted = formatted.replace(/\n{2,}/g, '\n');
return formatted.trim();
}
// ---------------------------------------------------------------------------
// SqlEditor component
// ---------------------------------------------------------------------------
const SqlEditor: React.FC<SqlEditorProps> = ({
value,
onChange,
placeholder = 'Enter SQL query…',
language = 'sql',
height = '200px',
readOnly = false,
onExecute,
schema,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const langCompartmentRef = useRef(new Compartment());
const readOnlyCompartmentRef = useRef(new Compartment());
const placeholderCompartmentRef = useRef(new Compartment());
const isExternalUpdateRef = useRef(false);
// Keep onChange in a stable ref so callbacks never go stale
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Ctrl+Enter handler
const runKeymap = useCallback(
(): Extension[] => [
keymap.of([
{
key: 'Mod-Enter',
run: () => {
if (onExecute) {
onExecute();
} else {
window.dispatchEvent(new CustomEvent('sql-editor:execute'));
}
return true;
},
},
{
key: 'Mod-Shift-f',
run: (view: EditorView) => {
const current = view.state.doc.toString();
const formatted = formatSQL(current);
if (formatted !== current) {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: formatted,
},
});
}
return true;
},
},
]),
],
[onExecute],
);
// Create editor once
useEffect(() => {
if (!containerRef.current) return;
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged && !isExternalUpdateRef.current) {
onChangeRef.current(update.state.doc.toString());
}
});
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
history(),
bracketMatching(),
closeBrackets(),
autocompletion({
activateOnTyping: true,
icons: false,
}),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
appTheme,
oneDark,
langCompartmentRef.current.of(getSQLExtension(language, schema)),
readOnlyCompartmentRef.current.of(
EditorState.readOnly.of(readOnly),
),
placeholderCompartmentRef.current.of(cmPlaceholder(placeholder)),
updateListener,
...runKeymap(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
]),
EditorView.lineWrapping,
],
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// We intentionally only run this once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync external value changes into the editor (avoid loops)
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const currentContent = view.state.doc.toString();
if (currentContent !== value) {
isExternalUpdateRef.current = true;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
isExternalUpdateRef.current = false;
}
}, [value]);
// Update language/schema when they change
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)),
});
}, [language, schema]);
// Update readOnly when it changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: readOnlyCompartmentRef.current.reconfigure(
EditorState.readOnly.of(readOnly),
),
});
}, [readOnly]);
// Update placeholder when it changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: placeholderCompartmentRef.current.reconfigure(
cmPlaceholder(placeholder ?? ''),
),
});
}, [placeholder]);
return (
<div
ref={containerRef}
style={{ height, minHeight: height }}
className="sql-editor-wrapper"
/>
);
};
export default SqlEditor;
@@ -0,0 +1,42 @@
import { useToast } from '@/hooks/useToast';
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
const ICONS = {
success: CheckCircle2,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
};
const COLORS = {
success: 'bg-green-500/10 border-green-500/30 text-green-400',
error: 'bg-red-500/10 border-red-500/30 text-red-400',
warning: 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400',
info: 'bg-blue-500/10 border-blue-500/30 text-blue-400',
};
export function ToastContainer() {
const { toasts, remove } = useToast();
if (toasts.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
{toasts.map(t => {
const Icon = ICONS[t.type];
return (
<div
key={t.id}
className={`flex items-center gap-3 px-4 py-3 rounded-lg border backdrop-blur-sm animate-in slide-in-from-right fade-in duration-200 ${COLORS[t.type]}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="text-sm flex-1">{t.message}</span>
<button onClick={() => remove(t.id)} className="shrink-0 opacity-50 hover:opacity-100">
<X className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,429 @@
import React from 'react';
import { useLogin } from '@/hooks/useLogin';
/* ──────────────── 极光之境 · 登录组件 ──────────────── */
const KEYFRAMES = `
@keyframes aurora-drift-1 {
0% { transform: translate(-30%, -10%) rotate(-12deg) scaleX(1.2); opacity: 0.35; }
25% { transform: translate(-10%, 5%) rotate(-8deg) scaleX(1.5); opacity: 0.5; }
50% { transform: translate(10%, -5%) rotate(-14deg) scaleX(1.3); opacity: 0.4; }
75% { transform: translate(-5%, 10%) rotate(-10deg) scaleX(1.6); opacity: 0.55; }
100% { transform: translate(-30%, -10%) rotate(-12deg) scaleX(1.2); opacity: 0.35; }
}
@keyframes aurora-drift-2 {
0% { transform: translate(20%, 15%) rotate(8deg) scaleX(1.3); opacity: 0.25; }
30% { transform: translate(-10%, -5%) rotate(12deg) scaleX(1.6); opacity: 0.45; }
60% { transform: translate(5%, 10%) rotate(6deg) scaleX(1.4); opacity: 0.35; }
100% { transform: translate(20%, 15%) rotate(8deg) scaleX(1.3); opacity: 0.25; }
}
@keyframes aurora-drift-3 {
0% { transform: translate(-15%, 25%) rotate(-5deg) scaleX(1.5); opacity: 0.3; }
33% { transform: translate(15%, 5%) rotate(-9deg) scaleX(1.8); opacity: 0.5; }
66% { transform: translate(0%, 20%) rotate(-3deg) scaleX(1.4); opacity: 0.4; }
100% { transform: translate(-15%, 25%) rotate(-5deg) scaleX(1.5); opacity: 0.3; }
}
@keyframes aurora-drift-4 {
0% { transform: translate(10%, -15%) rotate(15deg) scaleX(1.4); opacity: 0.2; }
40% { transform: translate(-20%, 0%) rotate(10deg) scaleX(1.7); opacity: 0.4; }
80% { transform: translate(0%, 8%) rotate(18deg) scaleX(1.5); opacity: 0.3; }
100% { transform: translate(10%, -15%) rotate(15deg) scaleX(1.4); opacity: 0.2; }
}
@keyframes twinkle {
0%, 100% { opacity: 0.15; transform: scale(0.8); }
50% { opacity: 0.9; transform: scale(1.2); }
}
@keyframes float-card {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(124, 58, 237, 0.15), 0 0 60px rgba(5, 150, 105, 0.08); }
50% { box-shadow: 0 0 30px rgba(124, 58, 237, 0.25), 0 0 80px rgba(5, 150, 105, 0.15); }
}
`;
/* ──────────────── 星星数据 ──────────────── */
const STARS = [
{ left: '8%', top: '5%', size: 2, delay: 0, duration: 3 },
{ left: '18%', top: '15%', size: 1.5, delay: 0.7, duration: 4 },
{ left: '5%', top: '35%', size: 1, delay: 1.2, duration: 2.5 },
{ left: '25%', top: '8%', size: 2.5, delay: 0.3, duration: 3.5 },
{ left: '75%', top: '6%', size: 1.5, delay: 1.5, duration: 4.5 },
{ left: '88%', top: '12%', size: 2, delay: 0.5, duration: 3 },
{ left: '92%', top: '30%', size: 1, delay: 2, duration: 2.8 },
{ left: '82%', top: '45%', size: 1.5, delay: 0.9, duration: 3.8 },
{ left: '68%', top: '18%', size: 1, delay: 1.8, duration: 2.2 },
{ left: '38%', top: '3%', size: 2, delay: 0.2, duration: 4 },
{ left: '55%', top: '10%', size: 1.5, delay: 1.1, duration: 3.2 },
{ left: '45%', top: '22%', size: 1, delay: 0.6, duration: 2.6 },
{ left: '12%', top: '55%', size: 1, delay: 1.6, duration: 3.6 },
{ left: '95%', top: '55%', size: 1.5, delay: 0.4, duration: 2.4 },
{ left: '60%', top: '30%', size: 1, delay: 2.2, duration: 4.2 },
];
export default function AuroraView(): JSX.Element {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
return (
<>
<style>{KEYFRAMES}</style>
{/* ═══════════════ 全屏背景容器 ═══════════════ */}
<div
style={{
position: 'relative',
minHeight: '100vh',
width: '100%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
background: 'linear-gradient(180deg, #050810 0%, #0a0e1a 30%, #0d1425 60%, #080c14 100%)',
}}
>
{/* ────────── 极光光带 1:紫罗兰色 ────────── */}
<div
style={{
position: 'absolute',
top: '15%',
left: '10%',
width: '80%',
height: '35%',
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.6) 0%, rgba(124, 58, 237, 0.3) 40%, transparent 80%)',
filter: 'blur(60px)',
borderRadius: '50%',
animation: 'aurora-drift-1 18s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
{/* ────────── 极光光带 2:翠绿色 ────────── */}
<div
style={{
position: 'absolute',
top: '25%',
left: '20%',
width: '75%',
height: '30%',
background: 'linear-gradient(120deg, rgba(16, 185, 129, 0.5) 0%, rgba(5, 150, 105, 0.25) 50%, transparent 90%)',
filter: 'blur(70px)',
borderRadius: '50%',
animation: 'aurora-drift-2 22s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
{/* ────────── 极光光带 3:青蓝色 ────────── */}
<div
style={{
position: 'absolute',
top: '35%',
left: '5%',
width: '70%',
height: '28%',
background: 'linear-gradient(145deg, rgba(6, 182, 212, 0.4) 0%, rgba(6, 182, 212, 0.15) 45%, transparent 85%)',
filter: 'blur(55px)',
borderRadius: '50%',
animation: 'aurora-drift-3 20s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
{/* ────────── 极光光带 4:紫绿混合 ────────── */}
<div
style={{
position: 'absolute',
top: '10%',
left: '30%',
width: '65%',
height: '40%',
background: 'linear-gradient(100deg, rgba(124, 58, 237, 0.35) 0%, rgba(16, 185, 129, 0.2) 50%, rgba(6, 182, 212, 0.15) 100%)',
filter: 'blur(75px)',
borderRadius: '50%',
animation: 'aurora-drift-4 25s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
{/* ────────── 星光点缀 ────────── */}
{STARS.map((star, i) => (
<div
key={i}
style={{
position: 'absolute',
left: star.left,
top: star.top,
width: star.size,
height: star.size,
borderRadius: '50%',
background: '#fff',
boxShadow: `0 0 ${star.size * 2}px rgba(255,255,255,0.5)`,
animation: `twinkle ${star.duration}s ease-in-out ${star.delay}s infinite`,
pointerEvents: 'none',
}}
/>
))}
{/* ═══════════════ 登录卡片 ═══════════════ */}
<div
style={{
position: 'relative',
zIndex: 10,
width: '100%',
maxWidth: 400,
margin: '0 20px',
padding: '44px 36px',
borderRadius: 20,
background: 'rgba(8, 12, 25, 0.65)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
border: '1px solid rgba(255,255,255,0.06)',
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 20px 60px rgba(0,0,0,0.4)',
animation: 'float-card 8s ease-in-out infinite, pulse-glow 6s ease-in-out infinite',
}}
>
{/* ──────── 品牌区域 ──────── */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
{/* Logo */}
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: 'linear-gradient(135deg, #7c3aed 0%, #059669 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 16px',
boxShadow: '0 4px 20px rgba(124, 58, 237, 0.3), 0 2px 10px rgba(5, 150, 105, 0.2)',
}}
>
<svg
width={22}
height={22}
viewBox="0 0 24 24"
fill="none"
stroke="#fff"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
</div>
{/* 标题 */}
<h1
style={{
fontSize: 22,
fontWeight: 600,
color: '#fff',
margin: '0 0 6px',
letterSpacing: 0.5,
}}
>
SQL审核平台
</h1>
{/* 副标题 */}
<p
style={{
fontSize: 14,
color: 'rgba(255,255,255,0.4)',
margin: 0,
letterSpacing: 0.3,
}}
>
企业级数据库审核与合规管理
</p>
</div>
{/* ──────── 表单区域 ──────── */}
<form onSubmit={handleLogin} noValidate>
{/* 错误提示 */}
{error && (
<div
style={{
marginBottom: 16,
padding: '10px 14px',
borderRadius: 10,
background: 'rgba(239, 68, 68, 0.12)',
border: '1px solid rgba(239, 68, 68, 0.2)',
color: '#f87171',
fontSize: 13,
textAlign: 'center',
}}
>
{error}
</div>
)}
{/* 用户名输入框 */}
<div style={{ marginBottom: 14 }}>
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
autoComplete="username"
style={{
width: '100%',
height: 46,
padding: '0 16px',
borderRadius: 10,
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#fff',
fontSize: 14,
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.25s ease, box-shadow 0.25s ease',
fontFamily: 'inherit',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.4)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(124, 58, 237, 0.08)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
{/* 密码输入框 */}
<div style={{ marginBottom: 20 }}>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
autoComplete="current-password"
style={{
width: '100%',
height: 46,
padding: '0 16px',
borderRadius: 10,
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#fff',
fontSize: 14,
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.25s ease, box-shadow 0.25s ease',
fontFamily: 'inherit',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.4)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(124, 58, 237, 0.08)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
{/* 登录按钮 */}
<button
type="submit"
disabled={!username || !password || loading}
style={{
width: '100%',
height: 46,
borderRadius: 12,
border: 'none',
background: 'linear-gradient(135deg, #7c3aed 0%, #059669 100%)',
color: '#fff',
fontSize: 15,
fontWeight: 500,
cursor: !username || !password || loading ? 'not-allowed' : 'pointer',
opacity: !username || !password ? 0.55 : 1,
transition: 'all 0.3s ease',
fontFamily: 'inherit',
letterSpacing: 0.5,
position: 'relative',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
if (username && password && !loading) {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 8px 30px rgba(124, 58, 237, 0.35), 0 4px 15px rgba(5, 150, 105, 0.25)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{loading ? (
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<span
style={{
display: 'inline-block',
width: 16,
height: 16,
border: '2px solid rgba(255,255,255,0.3)',
borderTopColor: '#fff',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}}
/>
登录中...
</span>
) : (
'登 录'
)}
</button>
</form>
{/* ──────── 版权信息 ──────── */}
<div
style={{
marginTop: 28,
textAlign: 'center',
fontSize: 12,
color: 'rgba(255,255,255,0.2)',
letterSpacing: 0.3,
}}
>
© 2025 SQL审核平台
</div>
</div>
{/* ──────── 底部辉光装饰 ──────── */}
<div
style={{
position: 'absolute',
bottom: '-20%',
left: '50%',
transform: 'translateX(-50%)',
width: '120%',
height: '50%',
background: 'radial-gradient(ellipse at center, rgba(124, 58, 237, 0.08) 0%, rgba(5, 150, 105, 0.05) 30%, transparent 70%)',
pointerEvents: 'none',
}}
/>
</div>
{/* 全局旋转动画 */}
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</>
);
}
@@ -0,0 +1,246 @@
import { useLogin } from '@/hooks/useLogin';
export default function CenteredCard() {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
// Light rays: 10 beams fanning out from bottom center
const rays = [
{ angle: -55, height: 70, opacity: 0.12, duration: 8 },
{ angle: -42, height: 85, opacity: 0.08, duration: 7 },
{ angle: -28, height: 60, opacity: 0.15, duration: 9 },
{ angle: -14, height: 90, opacity: 0.06, duration: 6 },
{ angle: -4, height: 75, opacity: 0.10, duration: 10 },
{ angle: 5, height: 80, opacity: 0.09, duration: 7 },
{ angle: 16, height: 65, opacity: 0.13, duration: 9 },
{ angle: 30, height: 88, opacity: 0.07, duration: 8 },
{ angle: 44, height: 72, opacity: 0.11, duration: 6 },
{ angle: 56, height: 55, opacity: 0.14, duration: 10 },
];
// Fading stars at the top
const stars = [
{ top: '6%', left: '15%', size: 2, opacity: 0.2, duration: 5 },
{ top: '10%', left: '72%', size: 3, opacity: 0.15, duration: 6 },
{ top: '3%', left: '45%', size: 2, opacity: 0.25, duration: 4.5 },
{ top: '14%', left: '30%', size: 2, opacity: 0.1, duration: 5.5 },
{ top: '8%', left: '88%', size: 3, opacity: 0.18, duration: 7 },
{ top: '5%', left: '60%', size: 2, opacity: 0.3, duration: 4 },
];
const inputStyle = {
padding: '14px 18px',
borderRadius: 10,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(251,191,36,0.06)',
color: '#fef3c7',
fontSize: 14,
outline: 'none' as const,
width: '100%',
transition: 'border-color 0.3s, background 0.3s, box-shadow 0.3s',
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.style.borderColor = 'rgba(251,191,36,0.3)';
e.target.style.background = 'rgba(255,255,255,0.06)';
e.target.style.boxShadow = '0 0 0 3px rgba(251,191,36,0.08)';
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.style.borderColor = 'rgba(251,191,36,0.06)';
e.target.style.background = 'rgba(255,255,255,0.04)';
e.target.style.boxShadow = 'none';
};
return (
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: 24,
overflow: 'hidden',
background: 'linear-gradient(to bottom, #0a0a1a 0%, #0f1729 25%, #1a1035 50%, #2d1810 75%, #1a0f05 100%)',
}}
>
{/* Dawn light rays */}
{rays.map((ray, i) => (
<div
key={`ray-${i}`}
style={{
position: 'fixed',
bottom: 0,
left: '50%',
width: 120,
height: `${ray.height}vh`,
transform: `translateX(-50%) rotate(${ray.angle}deg)`,
transformOrigin: 'bottom center',
background: `linear-gradient(to top, rgba(251,191,36,${ray.opacity}), transparent)`,
clipPath: 'polygon(40% 100%, 60% 100%, 55% 0%, 45% 0%)',
opacity: ray.opacity,
zIndex: 1,
animation: `dawn-ray-pulse ${ray.duration}s ease-in-out infinite`,
pointerEvents: 'none',
}}
/>
))}
{/* Horizon glow */}
<div
style={{
position: 'fixed',
bottom: -100,
left: '-10%',
width: '120%',
height: 200,
background: 'radial-gradient(ellipse at center, rgba(251,191,36,0.12), transparent 70%)',
zIndex: 2,
animation: 'dawn-horizon-glow 8s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
{/* Stars — fading remnants of night */}
{stars.map((star, i) => (
<div
key={`star-${i}`}
style={{
position: 'fixed',
top: star.top,
left: star.left,
width: star.size,
height: star.size,
borderRadius: '50%',
background: '#fff',
opacity: star.opacity,
zIndex: 1,
animation: `dawn-star-fade ${star.duration}s ease-in-out infinite`,
pointerEvents: 'none',
}}
/>
))}
{/* Glassmorphism card */}
<div
style={{
position: 'relative',
zIndex: 10,
width: '100%',
maxWidth: 420,
background: 'rgba(10,10,26,0.6)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(251,191,36,0.08)',
borderRadius: 20,
padding: '48px 36px',
boxShadow: '0 8px 40px rgba(0,0,0,0.4), 0 0 60px rgba(251,191,36,0.05)',
}}
>
{/* Logo — 44x44 golden gradient with sunrise SVG icon */}
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 24px',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<line x1="12" y1="2" x2="12" y2="5" />
<line x1="12" y1="19" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="7.05" y2="7.05" />
<line x1="16.95" y1="16.95" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="5" y2="12" />
<line x1="19" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="7.05" y2="16.95" />
<line x1="16.95" y1="7.05" x2="19.07" y2="4.93" />
</svg>
</div>
<h1 style={{ textAlign: 'center', fontSize: 22, fontWeight: 600, color: '#fef3c7', marginBottom: 6 }}>
SQL审核平台
</h1>
<p style={{ textAlign: 'center', color: 'rgba(251,191,36,0.5)', fontSize: 14, marginBottom: 36 }}>
守护每一次数据变更
</p>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && (
<div style={{ color: '#f87171', fontSize: 13, textAlign: 'center', padding: '10px 14px', background: 'rgba(248,113,113,0.08)', borderRadius: 10 }}>
{error}
</div>
)}
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<button
type="submit"
disabled={loading || !username || !password}
className="dg-gradient-bg w-full"
style={{
padding: '14px 20px',
borderRadius: 10,
border: 'none',
color: '#fff',
fontWeight: 600,
fontSize: 15,
cursor: loading || !username || !password ? 'not-allowed' : 'pointer',
opacity: loading || !username || !password ? 0.5 : 1,
marginTop: 8,
transition: 'opacity 0.2s, transform 0.1s',
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
boxShadow: '0 4px 20px rgba(251,191,36,0.25)',
}}
>
{loading ? '登录中…' : '登录'}
</button>
</form>
</div>
{/* Dawn keyframe animations */}
<style>{`
@keyframes dawn-ray-pulse {
0%, 100% { opacity: 0.08; }
50% { opacity: 0.15; }
}
@keyframes dawn-horizon-glow {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; }
}
@keyframes dawn-star-fade {
0%, 100% { opacity: 0.1; }
50% { opacity: 0.3; }
}
input::placeholder {
color: rgba(251,191,36,0.3);
}
`}</style>
</div>
);
}
@@ -0,0 +1,547 @@
import React, { useMemo } from 'react';
import { useLogin } from '@/hooks/useLogin';
/* ── 动画关键帧 ── */
const KEYFRAMES = `
@keyframes star-twinkle {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
@keyframes meteor-fly {
0% { transform: translateX(0) translateY(0) rotate(-45deg); opacity: 1; }
100% { transform: translateX(600px) translateY(600px) rotate(-45deg); opacity: 0; }
}
@keyframes planet-rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes float-particle {
0%, 100% { transform: translateY(0) translateX(0); opacity: 0.4; }
25% { transform: translateY(-20px) translateX(10px); opacity: 0.8; }
50% { transform: translateY(-40px) translateX(-5px); opacity: 0.5; }
75% { transform: translateY(-20px) translateX(15px); opacity: 0.9; }
}
@keyframes nebula-drift {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(30px, -20px) scale(1.05); }
66% { transform: translate(-20px, 15px) scale(0.95); }
}
@keyframes card-glow {
0%, 100% { box-shadow: 0 0 0 1px rgba(255,255,255,0.03), 0 24px 80px rgba(0,0,0,0.5), 0 0 60px rgba(79,70,229,0.08); }
50% { box-shadow: 0 0 0 1px rgba(255,255,255,0.05), 0 24px 80px rgba(0,0,0,0.5), 0 0 80px rgba(139,92,246,0.12); }
}
@keyframes shooting-star {
0% { transform: translateX(0) translateY(0) rotate(-35deg) scaleX(1); opacity: 1; }
70% { opacity: 0.6; }
100% { transform: translateX(500px) translateY(500px) rotate(-35deg) scaleX(0.5); opacity: 0; }
}
`;
/* ── 类型 ── */
interface Star {
id: number;
x: number;
y: number;
size: number;
opacity: number;
color: string;
twinkle: boolean;
delay: number;
duration: number;
}
interface Particle {
id: number;
x: number;
y: number;
size: number;
delay: number;
duration: number;
}
/* ── 组件 ── */
export default function CosmosView(): JSX.Element {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
/* 生成静态星星数据 */
const stars = useMemo<Star[]>(() => {
const colors = ['#ffffff', '#dbeafe', '#e0e7ff', '#f3e8ff', '#ede9fe'];
return Array.from({ length: 45 }, (_, i) => ({
id: i,
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 2.5 + 0.5,
opacity: Math.random() * 0.7 + 0.3,
color: colors[Math.floor(Math.random() * colors.length)],
twinkle: Math.random() > 0.4,
delay: Math.random() * 5,
duration: Math.random() * 2 + 1.5,
}));
}, []);
/* 生成漂浮粒子数据 */
const particles = useMemo<Particle[]>(() => {
return Array.from({ length: 15 }, (_, i) => ({
id: i,
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 2 + 1,
delay: Math.random() * 6,
duration: Math.random() * 4 + 4,
}));
}, []);
/* 输入框基础样式 */
const inputStyle: React.CSSProperties = {
width: '100%',
height: 48,
padding: '0 16px',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 12,
color: '#fff',
fontSize: 14,
outline: 'none',
transition: 'all 0.25s ease',
boxSizing: 'border-box',
};
const isFormValid = username.length > 0 && password.length > 0;
return (
<div
style={{
position: 'relative',
width: '100%',
minHeight: '100vh',
overflow: 'hidden',
background: '#030508',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
}}
>
<style>{KEYFRAMES}</style>
{/* ── 星云色块 ── */}
<div
style={{
position: 'absolute',
top: '-10%',
left: '-15%',
width: '60%',
height: '60%',
background: 'radial-gradient(circle, rgba(59,130,246,0.15), transparent 70%)',
borderRadius: '50%',
filter: 'blur(100px)',
animation: 'nebula-drift 20s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
bottom: '-15%',
right: '-10%',
width: '55%',
height: '55%',
background: 'radial-gradient(circle, rgba(139,92,246,0.12), transparent 70%)',
borderRadius: '50%',
filter: 'blur(100px)',
animation: 'nebula-drift 25s ease-in-out infinite reverse',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
top: '30%',
right: '25%',
width: '35%',
height: '35%',
background: 'radial-gradient(circle, rgba(236,72,153,0.08), transparent 70%)',
borderRadius: '50%',
filter: 'blur(100px)',
animation: 'nebula-drift 18s ease-in-out infinite 3s',
pointerEvents: 'none',
}}
/>
{/* ── 星星层 ── */}
{stars.map((star) => (
<div
key={star.id}
style={{
position: 'absolute',
left: `${star.x}%`,
top: `${star.y}%`,
width: star.size,
height: star.size,
borderRadius: '50%',
background: star.color,
opacity: star.twinkle ? undefined : star.opacity,
boxShadow: `0 0 ${star.size * 2}px ${star.color}`,
animation: star.twinkle
? `star-twinkle ${star.duration}s ease-in-out infinite ${star.delay}s`
: 'none',
pointerEvents: 'none',
}}
/>
))}
{/* ── 流星 ── */}
<div
style={{
position: 'absolute',
top: '8%',
left: '5%',
width: 120,
height: 2,
background: 'linear-gradient(90deg, rgba(255,255,255,0.9), rgba(255,255,255,0))',
borderRadius: '0 50% 50% 0',
transform: 'rotate(-45deg)',
animation: 'meteor-fly 4s ease-out infinite 2s',
pointerEvents: 'none',
filter: 'blur(0.5px)',
}}
/>
<div
style={{
position: 'absolute',
top: '15%',
left: '25%',
width: 80,
height: 1.5,
background: 'linear-gradient(90deg, rgba(200,220,255,0.8), rgba(255,255,255,0))',
borderRadius: '0 50% 50% 0',
transform: 'rotate(-35deg)',
animation: 'shooting-star 5s ease-out infinite 7s',
pointerEvents: 'none',
filter: 'blur(0.3px)',
}}
/>
{/* ── 漂浮粒子星尘 ── */}
{particles.map((p) => (
<div
key={`p-${p.id}`}
style={{
position: 'absolute',
left: `${p.x}%`,
top: `${p.y}%`,
width: p.size,
height: p.size,
borderRadius: '50%',
background: 'rgba(200, 210, 255, 0.5)',
boxShadow: '0 0 4px rgba(200,210,255,0.3)',
animation: `float-particle ${p.duration}s ease-in-out infinite ${p.delay}s`,
pointerEvents: 'none',
}}
/>
))}
{/* ── 行星装饰(左下角) ── */}
<div
style={{
position: 'absolute',
bottom: '8%',
left: '5%',
width: 100,
height: 100,
animation: 'planet-rotate 60s linear infinite',
pointerEvents: 'none',
}}
>
{/* 行星本体 */}
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 40,
height: 40,
borderRadius: '50%',
background: 'radial-gradient(circle at 35% 35%, rgba(100,149,237,0.9), rgba(65,45,120,0.8), rgba(30,20,60,0.95))',
boxShadow: 'inset -8px -8px 20px rgba(0,0,0,0.5), 0 0 20px rgba(100,149,237,0.2)',
}}
/>
{/* 行星光环 */}
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%) rotateX(75deg) rotateY(15deg)',
width: 72,
height: 72,
borderRadius: '50%',
border: '2px solid rgba(180,180,255,0.25)',
boxShadow: '0 0 8px rgba(180,180,255,0.15)',
}}
/>
{/* 内环 */}
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%) rotateX(75deg) rotateY(15deg)',
width: 56,
height: 56,
borderRadius: '50%',
border: '1px solid rgba(200,200,255,0.12)',
}}
/>
</div>
{/* ── 小行星(右上角装饰) ── */}
<div
style={{
position: 'absolute',
top: '12%',
right: '12%',
width: 24,
height: 24,
borderRadius: '50%',
background: 'radial-gradient(circle at 40% 40%, rgba(236,200,160,0.7), rgba(160,120,80,0.5))',
boxShadow: 'inset -4px -4px 10px rgba(0,0,0,0.4), 0 0 15px rgba(236,200,160,0.15)',
animation: 'float-particle 10s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
{/* ── 登录卡片 ── */}
<div
style={{
position: 'relative',
zIndex: 10,
marginLeft: '18vw',
maxWidth: 400,
width: '100%',
padding: '48px 36px',
background: 'rgba(5, 8, 16, 0.75)',
backdropFilter: 'blur(30px)',
WebkitBackdropFilter: 'blur(30px)',
borderRadius: 24,
border: '1px solid rgba(255,255,255,0.05)',
animation: 'card-glow 6s ease-in-out infinite',
boxSizing: 'border-box',
}}
>
{/* 品牌区域 */}
<div style={{ textAlign: 'center', marginBottom: 36 }}>
{/* Logo */}
<div
style={{
width: 44,
height: 44,
borderRadius: 14,
background: 'linear-gradient(135deg, #1e3a8a, #7c3aed)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 16px',
boxShadow: '0 4px 20px rgba(79,70,229,0.3), inset 0 1px 0 rgba(255,255,255,0.15)',
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
</div>
<h1
style={{
fontSize: 22,
fontWeight: 700,
color: '#fff',
margin: '0 0 8px',
letterSpacing: '0.5px',
}}
>
SQL审核平台
</h1>
<p
style={{
fontSize: 13,
color: 'rgba(255,255,255,0.4)',
margin: 0,
letterSpacing: '0.3px',
}}
>
企业级数据库审核与合规管理
</p>
</div>
{/* 表单区域 */}
<form onSubmit={handleLogin}>
{/* 用户名 */}
<div style={{ marginBottom: 16 }}>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'rgba(255,255,255,0.5)',
marginBottom: 6,
letterSpacing: '0.3px',
}}
>
用户名
</label>
<input
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
style={inputStyle}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(139,92,246,0.4)';
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
}}
/>
</div>
{/* 密码 */}
<div style={{ marginBottom: 24 }}>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'rgba(255,255,255,0.5)',
marginBottom: 6,
letterSpacing: '0.3px',
}}
>
密码
</label>
<input
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={inputStyle}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(139,92,246,0.4)';
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
}}
/>
</div>
{/* 登录按钮 */}
<button
type="submit"
disabled={!isFormValid || loading}
style={{
width: '100%',
height: 48,
borderRadius: 12,
border: 'none',
background: isFormValid
? 'linear-gradient(135deg, #4f46e5, #8b5cf6)'
: 'rgba(255,255,255,0.08)',
color: isFormValid ? '#fff' : 'rgba(255,255,255,0.3)',
fontSize: 15,
fontWeight: 600,
cursor: isFormValid && !loading ? 'pointer' : 'not-allowed',
transition: 'all 0.3s ease',
position: 'relative',
overflow: 'hidden',
letterSpacing: '0.5px',
boxShadow: isFormValid
? '0 4px 20px rgba(79,70,229,0.35), inset 0 1px 0 rgba(255,255,255,0.15)'
: 'none',
}}
onMouseEnter={(e) => {
if (isFormValid && !loading) {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 8px 30px rgba(79,70,229,0.45), inset 0 1px 0 rgba(255,255,255,0.2)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = isFormValid
? '0 4px 20px rgba(79,46,229,0.35), inset 0 1px 0 rgba(255,255,255,0.15)'
: 'none';
}}
>
{loading ? (
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<span
style={{
display: 'inline-block',
width: 16,
height: 16,
border: '2px solid rgba(255,255,255,0.3)',
borderTopColor: '#fff',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}}
/>
登录中...
</span>
) : (
'登 录'
)}
</button>
{/* 错误提示 */}
{error && (
<div
style={{
marginTop: 14,
padding: '10px 14px',
borderRadius: 10,
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.15)',
color: '#f87171',
fontSize: 13,
textAlign: 'center',
}}
>
{error}
</div>
)}
</form>
{/* 底部版权 */}
<p
style={{
marginTop: 28,
textAlign: 'center',
fontSize: 11,
color: 'rgba(255,255,255,0.2)',
letterSpacing: '0.3px',
}}
>
&copy; 2025 SQL审核平台 — 企业级数据库安全合规
</p>
</div>
{/* 全局旋转动画补充 */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
@@ -0,0 +1,263 @@
import { useLogin } from '@/hooks/useLogin';
export default function FullImage() {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
return (
<div
className="flex items-center justify-center min-h-screen relative"
style={{ padding: 24, overflow: 'hidden' }}
>
{/* Deep navy background */}
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 0,
background: '#0a1628',
}}
/>
{/* Star field — 30+ individual stars */}
<div style={{ position: 'fixed', inset: 0, zIndex: 1 }}>
{/* Row 1 - top area */}
<div style={{ position: 'absolute', top: '3%', left: '12%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 3s ease-in-out infinite' }} />
<div style={{ position: 'absolute', top: '5%', left: '28%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))' }} />
<div style={{ position: 'absolute', top: '2%', left: '45%', width: 3.5, height: 3.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 4s ease-in-out 1s infinite' }} />
<div style={{ position: 'absolute', top: '8%', left: '62%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.8), rgba(167,139,250,0.2))' }} />
<div style={{ position: 'absolute', top: '4%', left: '78%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 5s ease-in-out 2s infinite' }} />
<div style={{ position: 'absolute', top: '6%', left: '91%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))', animation: 'star-twinkle 4.2s ease-in-out 0.6s infinite' }} />
{/* Row 2 */}
<div style={{ position: 'absolute', top: '14%', left: '7%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))' }} />
<div style={{ position: 'absolute', top: '16%', left: '22%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))', animation: 'star-twinkle 4.5s ease-in-out 0.5s infinite' }} />
<div style={{ position: 'absolute', top: '12%', left: '55%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))' }} />
<div style={{ position: 'absolute', top: '18%', left: '70%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.8), rgba(167,139,250,0.2))', animation: 'star-twinkle 3.5s ease-in-out 1.5s infinite' }} />
<div style={{ position: 'absolute', top: '15%', left: '88%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))' }} />
{/* Row 3 */}
<div style={{ position: 'absolute', top: '25%', left: '5%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 5s ease-in-out 3s infinite' }} />
<div style={{ position: 'absolute', top: '28%', left: '35%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.3))' }} />
<div style={{ position: 'absolute', top: '22%', left: '50%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 4s ease-in-out 2s infinite' }} />
<div style={{ position: 'absolute', top: '30%', left: '82%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.8), rgba(167,139,250,0.2))' }} />
{/* Row 4 — mid area */}
<div style={{ position: 'absolute', top: '38%', left: '15%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))' }} />
<div style={{ position: 'absolute', top: '42%', left: '48%', width: 3.5, height: 3.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 6s ease-in-out 1s infinite' }} />
<div style={{ position: 'absolute', top: '36%', left: '75%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))' }} />
<div style={{ position: 'absolute', top: '44%', left: '92%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 3s ease-in-out 4s infinite' }} />
{/* Row 5 */}
<div style={{ position: 'absolute', top: '55%', left: '8%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 4s ease-in-out 2.5s infinite' }} />
<div style={{ position: 'absolute', top: '52%', left: '40%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.8), rgba(167,139,250,0.2))' }} />
<div style={{ position: 'absolute', top: '58%', left: '60%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 5.5s ease-in-out 0.8s infinite' }} />
<div style={{ position: 'absolute', top: '50%', left: '85%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))' }} />
{/* Row 6 — lower area */}
<div style={{ position: 'absolute', top: '68%', left: '18%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))' }} />
<div style={{ position: 'absolute', top: '72%', left: '52%', width: 3, height: 3, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 4.5s ease-in-out 3.5s infinite' }} />
<div style={{ position: 'absolute', top: '65%', left: '72%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))' }} />
{/* Row 7 — bottom area */}
<div style={{ position: 'absolute', top: '80%', left: '10%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 3.5s ease-in-out 1.2s infinite' }} />
<div style={{ position: 'absolute', top: '85%', left: '38%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.8), rgba(167,139,250,0.2))' }} />
<div style={{ position: 'absolute', top: '78%', left: '65%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.3))', animation: 'star-twinkle 5s ease-in-out 0.3s infinite' }} />
<div style={{ position: 'absolute', top: '88%', left: '82%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, rgba(147,197,253,0.8), rgba(147,197,253,0.2))' }} />
{/* Row 8 — very bottom */}
<div style={{ position: 'absolute', top: '93%', left: '25%', width: 2, height: 2, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))' }} />
<div style={{ position: 'absolute', top: '95%', left: '58%', width: 2.5, height: 2.5, borderRadius: '50%', background: 'radial-gradient(circle, #fff, rgba(255,255,255,0.2))', animation: 'star-twinkle 4s ease-in-out 2.2s infinite' }} />
</div>
{/* Constellation lines connecting adjacent stars */}
<div style={{ position: 'fixed', inset: 0, zIndex: 1 }}>
{/* Line 1: top 3%/12% → 5%/28% → 2%/45% */}
<div style={{ position: 'absolute', top: '4%', left: '12%', width: '34%', height: 1, background: 'linear-gradient(90deg, rgba(147,197,253,0.18), rgba(167,139,250,0.1), transparent)', transform: 'rotate(-3deg)' }} />
{/* Line 2: top 16%/22% → 12%/55% */}
<div style={{ position: 'absolute', top: '13%', left: '22%', width: '35%', height: 1, background: 'linear-gradient(90deg, rgba(147,197,253,0.15), rgba(167,139,250,0.08), transparent)', transform: 'rotate(-8deg)' }} />
{/* Line 3: top 42%/48% → 36%/75% → 30%/82% */}
<div style={{ position: 'absolute', top: '38%', left: '48%', width: '30%', height: 1, background: 'linear-gradient(90deg, rgba(147,197,253,0.15), rgba(167,139,250,0.08), transparent)', transform: 'rotate(-12deg)' }} />
{/* Line 4: top 55%/8% → 52%/40% */}
<div style={{ position: 'absolute', top: '53%', left: '8%', width: '34%', height: 1, background: 'linear-gradient(90deg, rgba(147,197,253,0.12), rgba(167,139,250,0.06), transparent)', transform: 'rotate(5deg)' }} />
{/* Line 5: top 72%/52% → 78%/65% */}
<div style={{ position: 'absolute', top: '74%', left: '52%', width: '15%', height: 1, background: 'linear-gradient(90deg, rgba(147,197,253,0.12), rgba(167,139,250,0.05), transparent)', transform: 'rotate(20deg)' }} />
{/* Line 6 (vertical): top 5%/28% → 16%/22% */}
<div style={{ position: 'absolute', top: '5%', left: '27%', width: 1, height: '12%', background: 'linear-gradient(180deg, rgba(147,197,253,0.12), rgba(167,139,250,0.05), transparent)', transform: 'rotate(5deg)' }} />
</div>
{/* Large pulsating glow orb — top right (blue-purple) */}
<div
style={{
position: 'fixed',
top: '-15%',
right: '-8%',
width: 500,
height: 500,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(99,102,241,0.25) 0%, rgba(168,85,247,0.1) 40%, transparent 70%)',
zIndex: 1,
filter: 'blur(80px)',
animation: 'star-glow-pulse 8s ease-in-out infinite',
}}
/>
{/* Smaller warm glow orb — bottom left */}
<div
style={{
position: 'fixed',
bottom: '-10%',
left: '-5%',
width: 300,
height: 300,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(249,115,22,0.12) 0%, transparent 70%)',
zIndex: 1,
filter: 'blur(60px)',
opacity: 0.08,
}}
/>
{/* Center card */}
<div
style={{
position: 'relative',
zIndex: 10,
width: '100%',
maxWidth: 400,
background: 'rgba(10, 22, 40, 0.7)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 16,
padding: '40px 32px',
boxShadow: '0 0 0 1px rgba(255,255,255,0.06), 0 8px 40px rgba(0,0,0,0.5)',
}}
>
{/* Small brand mark */}
<div style={{ textAlign: 'center', marginBottom: 28 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: 10,
background: 'linear-gradient(135deg, #6366f1, #a855f7)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 18,
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
</div>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff', marginBottom: 4, textAlign: 'center' }}>
SQL审核平台
</h1>
<p style={{ color: 'rgba(255,255,255,0.4)', fontSize: 13, textAlign: 'center' }}>
登录您的工作区
</p>
</div>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{error && (
<div style={{ color: '#f87171', fontSize: 13, textAlign: 'center', padding: '10px 14px', background: 'rgba(248,113,113,0.06)', borderRadius: 10 }}>
{error}
</div>
)}
{/* Inset-style input: username */}
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full"
style={{
padding: '12px 16px',
borderRadius: 10,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
color: '#fff',
fontSize: 14,
outline: 'none',
transition: 'border-color 0.3s, box-shadow 0.3s',
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.2)',
}}
onFocus={(e) => {
e.target.style.borderColor = 'rgba(99,102,241,0.4)';
e.target.style.boxShadow = 'inset 0 1px 3px rgba(0,0,0,0.2), 0 0 0 3px rgba(99,102,241,0.1)';
}}
onBlur={(e) => {
e.target.style.borderColor = 'rgba(255,255,255,0.06)';
e.target.style.boxShadow = 'inset 0 1px 3px rgba(0,0,0,0.2)';
}}
/>
{/* Inset-style input: password */}
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full"
style={{
padding: '12px 16px',
borderRadius: 10,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
color: '#fff',
fontSize: 14,
outline: 'none',
transition: 'border-color 0.3s, box-shadow 0.3s',
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.2)',
}}
onFocus={(e) => {
e.target.style.borderColor = 'rgba(99,102,241,0.4)';
e.target.style.boxShadow = 'inset 0 1px 3px rgba(0,0,0,0.2), 0 0 0 3px rgba(99,102,241,0.1)';
}}
onBlur={(e) => {
e.target.style.borderColor = 'rgba(255,255,255,0.06)';
e.target.style.boxShadow = 'inset 0 1px 3px rgba(0,0,0,0.2)';
}}
/>
{/* Full-width submit button */}
<button
type="submit"
disabled={loading || !username || !password}
className="dg-gradient-bg w-full"
style={{
padding: '12px 20px',
borderRadius: 10,
border: 'none',
color: '#fff',
fontWeight: 600,
fontSize: 14,
cursor: loading || !username || !password ? 'not-allowed' : 'pointer',
opacity: loading || !username || !password ? 0.5 : 1,
marginTop: 4,
transition: 'opacity 0.2s, transform 0.1s',
}}
>
{loading ? '登录中…' : '登录'}
</button>
</form>
</div>
{/* Keyframe animations */}
<style>{`
@keyframes star-twinkle {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
@keyframes star-glow-pulse {
0%, 100% { transform: scale(1); opacity: 0.7; }
50% { transform: scale(1.1); opacity: 1; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,362 @@
import React, { useState } from 'react';
import { useLogin } from '@/hooks/useLogin';
/* ------------------------------------------------------------------ */
/* KEYFRAMES */
/* ------------------------------------------------------------------ */
const KEYFRAMES = `
@keyframes mesh-drift-1 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30px, 20px); }
}
@keyframes mesh-drift-2 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-25px, -30px); }
}
@keyframes mesh-drift-3 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20px, -25px); }
}
@keyframes mesh-drift-4 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-30px, 20px); }
}
@keyframes mesh-drift-5 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(25px, 30px); }
}
@keyframes card-glow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
@keyframes input-glow-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
`;
/* ------------------------------------------------------------------ */
/* COMPONENT */
/* ------------------------------------------------------------------ */
export default function GradientMeshView(): React.JSX.Element {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
const [focusedField, setFocusedField] = useState<string | null>(null);
const [hoverBtn, setHoverBtn] = useState(false);
const isReady = username.trim().length > 0 && password.trim().length > 0;
/* ---- gradient mesh blobs ---- */
const blobs: React.CSSProperties[] = [
{
position: 'fixed', top: '-100px', left: '-100px',
width: '600px', height: '600px',
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.3), transparent 70%)',
filter: 'blur(80px)', pointerEvents: 'none',
animation: 'mesh-drift-1 12s ease-in-out infinite',
},
{
position: 'fixed', bottom: '-80px', right: '-80px',
width: '500px', height: '500px',
background: 'radial-gradient(circle, rgba(244, 63, 94, 0.2), transparent 70%)',
filter: 'blur(90px)', pointerEvents: 'none',
animation: 'mesh-drift-2 10s ease-in-out infinite',
},
{
position: 'fixed', top: '40%', right: '10%',
width: '400px', height: '400px',
background: 'radial-gradient(circle, rgba(16, 185, 129, 0.15), transparent 70%)',
filter: 'blur(100px)', pointerEvents: 'none',
animation: 'mesh-drift-3 14s ease-in-out infinite',
},
{
position: 'fixed', bottom: '5%', left: '5%',
width: '450px', height: '450px',
background: 'radial-gradient(circle, rgba(245, 158, 11, 0.1), transparent 70%)',
filter: 'blur(85px)', pointerEvents: 'none',
animation: 'mesh-drift-4 11s ease-in-out infinite',
},
{
position: 'fixed', top: '-50px', right: '20%',
width: '550px', height: '550px',
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.2), transparent 70%)',
filter: 'blur(95px)', pointerEvents: 'none',
animation: 'mesh-drift-5 13s ease-in-out infinite',
},
];
/* ---- input base style ---- */
const inputBase: React.CSSProperties = {
width: '100%',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 12,
padding: '14px 18px',
color: '#fff',
fontSize: 15,
outline: 'none',
transition: 'all 0.25s ease',
boxSizing: 'border-box',
};
const getInputStyle = (field: string): React.CSSProperties => ({
...inputBase,
borderColor: focusedField === field ? 'rgba(99,102,241,0.5)' : 'rgba(255,255,255,0.06)',
background: focusedField === field ? 'rgba(255,255,255,0.07)' : 'rgba(255,255,255,0.04)',
});
/* ---- card top accent line ---- */
const topAccent: React.CSSProperties = {
position: 'absolute',
top: 0, left: 24, right: 24,
height: 2,
borderRadius: 1,
background: 'linear-gradient(90deg, #6366f1, #f43f5e, #10b981)',
opacity: 0.6,
};
/* ---- subtle glow inside card bottom ---- */
const innerGlow: React.CSSProperties = {
position: 'absolute',
bottom: -60, left: '50%',
transform: 'translateX(-50%)',
width: '70%', height: 120,
background: 'radial-gradient(ellipse, rgba(99,102,241,0.08), transparent 70%)',
filter: 'blur(40px)',
pointerEvents: 'none',
animation: 'card-glow 8s ease-in-out infinite',
};
return (
<>
<style>{KEYFRAMES}</style>
{/* ===================== ROOT CONTAINER ===================== */}
<div
style={{
minHeight: '100vh',
width: '100%',
position: 'relative',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0a0a0f',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}}
>
{/* ---- gradient mesh blobs ---- */}
{blobs.map((b, i) => (
<div key={i} style={b} />
))}
{/* ---- subtle dot-grid texture ---- */}
<div
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
opacity: 0.03,
backgroundImage:
'radial-gradient(circle, rgba(255,255,255,0.8) 1px, transparent 1px)',
backgroundSize: '24px 24px',
}}
/>
{/* ===================== LOGIN CARD ===================== */}
<div
style={{
position: 'relative',
width: '100%',
maxWidth: 400,
margin: '0 20px',
background: 'rgba(255, 255, 255, 0.03)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 24,
padding: '48px 36px',
boxShadow:
'0 0 0 1px rgba(255,255,255,0.04), 0 25px 50px -12px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)',
zIndex: 10,
}}
>
{/* top accent line */}
<div style={topAccent} />
{/* inner glow */}
<div style={innerGlow} />
{/* ---- brand ---- */}
<div style={{ textAlign: 'center', marginBottom: 36 }}>
{/* Logo */}
<div
style={{
width: 48,
height: 48,
borderRadius: 16,
background: 'linear-gradient(135deg, #6366f1, #f43f5e)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}
>
<svg
width={20}
height={20}
viewBox="0 0 24 24"
fill="none"
stroke="#fff"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
</div>
<h1
style={{
color: '#fff',
fontSize: 24,
fontWeight: 700,
letterSpacing: '-0.5px',
margin: '0 0 8px 0',
}}
>
SQL审核平台
</h1>
<p
style={{
color: 'rgba(255,255,255,0.35)',
fontSize: 14,
letterSpacing: '0.3px',
margin: 0,
}}
>
企业级数据库审核与合规管理
</p>
</div>
{/* ---- form ---- */}
<form
onSubmit={handleLogin}
style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
>
{/* username */}
<div style={{ position: 'relative' }}>
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
onFocus={() => setFocusedField('username')}
onBlur={() => setFocusedField(null)}
style={getInputStyle('username')}
autoComplete="username"
/>
{focusedField === 'username' && (
<div
style={{
position: 'absolute',
inset: -1,
borderRadius: 13,
background: 'radial-gradient(ellipse at center, rgba(99,102,241,0.12), transparent 70%)',
filter: 'blur(8px)',
pointerEvents: 'none',
animation: 'input-glow-pulse 3s ease-in-out infinite',
}}
/>
)}
</div>
{/* password */}
<div style={{ position: 'relative' }}>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
onFocus={() => setFocusedField('password')}
onBlur={() => setFocusedField(null)}
style={getInputStyle('password')}
autoComplete="current-password"
/>
{focusedField === 'password' && (
<div
style={{
position: 'absolute',
inset: -1,
borderRadius: 13,
background: 'radial-gradient(ellipse at center, rgba(99,102,241,0.12), transparent 70%)',
filter: 'blur(8px)',
pointerEvents: 'none',
animation: 'input-glow-pulse 3s ease-in-out infinite',
}}
/>
)}
</div>
{/* error */}
{error && (
<div
style={{
background: 'rgba(239,68,68,0.08)',
color: '#f87171',
borderRadius: 10,
padding: '10px 14px',
fontSize: 13,
}}
>
{error}
</div>
)}
{/* submit */}
<button
type="submit"
disabled={!isReady || loading}
onMouseEnter={() => setHoverBtn(true)}
onMouseLeave={() => setHoverBtn(false)}
style={{
width: '100%',
marginTop: 4,
padding: '14px 24px',
borderRadius: 12,
border: 'none',
fontSize: 15,
fontWeight: 600,
color: '#fff',
cursor: isReady && !loading ? 'pointer' : 'not-allowed',
opacity: isReady ? 1 : 0.5,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%)',
boxShadow: hoverBtn && isReady && !loading
? '0 8px 32px rgba(99,102,241,0.45)'
: '0 4px 20px rgba(99,102,241,0.3)',
transform: hoverBtn && isReady && !loading ? 'translateY(-1px)' : 'translateY(0)',
transition: 'all 0.25s ease',
}}
>
{loading ? '登录中...' : '登录'}
</button>
</form>
{/* ---- copyright ---- */}
<p
style={{
textAlign: 'center',
color: 'rgba(255,255,255,0.2)',
fontSize: 12,
margin: '28px 0 0 0',
}}
>
&copy; 2025 SQL审核平台
</p>
</div>
</div>
</>
);
}
@@ -0,0 +1,479 @@
import React from 'react';
import { useLogin } from '@/hooks/useLogin';
/* ------------------------------------------------------------------ */
/* KEYFRAMES */
/* ------------------------------------------------------------------ */
const KEYFRAMES = `
@keyframes ink-spread {
0%, 100% {
transform: scale(1);
opacity: 0.45;
}
50% {
transform: scale(1.15);
opacity: 0.6;
}
}
@keyframes mist-flow {
0% {
transform: translateX(-8%);
}
50% {
transform: translateX(4%);
}
100% {
transform: translateX(-8%);
}
}
@keyframes gold-shimmer {
0%, 100% {
opacity: 0.35;
transform: scaleX(1);
}
50% {
opacity: 0.8;
transform: scaleX(1.06);
}
}
`;
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function InkWashView(): JSX.Element {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
return (
<>
<style>{KEYFRAMES}</style>
{/* ===================== 全屏容器 ===================== */}
<div
style={{
minHeight: '100vh',
position: 'relative',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f5f0e8',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
}}
>
{/* ────────── 水墨晕染装饰 ────────── */}
{/* 左上角淡墨点 */}
<div
style={{
position: 'absolute',
top: '-8%',
left: '-6%',
width: '50vw',
height: '50vh',
borderRadius: '50%',
background: 'radial-gradient(ellipse, rgba(160, 150, 135, 0.25) 0%, transparent 70%)',
filter: 'blur(80px)',
animation: 'ink-spread 10s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
{/* 右下角浓墨点 */}
<div
style={{
position: 'absolute',
bottom: '-10%',
right: '-8%',
width: '55vw',
height: '55vh',
borderRadius: '50%',
background: 'radial-gradient(ellipse, rgba(80, 72, 60, 0.35) 0%, transparent 70%)',
filter: 'blur(90px)',
animation: 'ink-spread 14s ease-in-out infinite reverse',
pointerEvents: 'none',
}}
/>
{/* 中央偏上淡淡墨痕 */}
<div
style={{
position: 'absolute',
top: '18%',
left: '35%',
width: '30vw',
height: '20vh',
borderRadius: '50%',
background: 'radial-gradient(ellipse, rgba(140, 130, 115, 0.15) 0%, transparent 70%)',
filter: 'blur(70px)',
animation: 'ink-spread 12s ease-in-out infinite 2s',
pointerEvents: 'none',
}}
/>
{/* ────────── 远山轮廓 ────────── */}
{/* 最远山 */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '40%',
background: 'linear-gradient(to top, rgba(180, 175, 165, 0.4) 0%, transparent 60%)',
borderRadius: '50% 50% 0 0 / 80px 80px 0 0',
pointerEvents: 'none',
}}
/>
{/* 中山 */}
<div
style={{
position: 'absolute',
bottom: 0,
left: '-10%',
right: '-5%',
height: '25%',
background: 'linear-gradient(to top, rgba(120, 115, 108, 0.5) 0%, transparent 50%)',
borderRadius: '40% 60% 0 0 / 60px 50px 0 0',
pointerEvents: 'none',
}}
/>
{/* 近山 */}
<div
style={{
position: 'absolute',
bottom: 0,
left: '-15%',
right: '-15%',
height: '15%',
background: 'linear-gradient(to top, rgba(60, 58, 52, 0.6) 0%, transparent 40%)',
borderRadius: '60% 40% 0 0 / 40px 30px 0 0',
pointerEvents: 'none',
}}
/>
{/* ────────── 雾气效果 ────────── */}
<div
style={{
position: 'absolute',
bottom: '8%',
left: 0,
right: 0,
height: '22%',
background: 'linear-gradient(to top, rgba(245, 240, 232, 0.85) 0%, rgba(245, 240, 232, 0.3) 50%, transparent 100%)',
animation: 'mist-flow 18s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
{/* ────────── 金色点缀线(水面微光) ────────── */}
<div
style={{
position: 'absolute',
bottom: '12%',
left: '20%',
width: '25%',
height: '1px',
background: '#c9a96e',
opacity: 0.35,
animation: 'gold-shimmer 6s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
bottom: '15%',
right: '18%',
width: '18%',
height: '1px',
background: '#c9a96e',
opacity: 0.3,
animation: 'gold-shimmer 8s ease-in-out infinite 1.5s',
pointerEvents: 'none',
}}
/>
{/* ────────── 竖排 SQL 背景装饰 ────────── */}
<div
style={{
position: 'absolute',
top: '12%',
right: '8%',
writingMode: 'vertical-rl',
fontSize: 72,
fontWeight: 700,
color: 'rgba(45, 42, 36, 0.04)',
letterSpacing: 12,
userSelect: 'none',
pointerEvents: 'none',
fontFamily: 'Georgia, "Times New Roman", serif',
}}
>
SQL
</div>
<div
style={{
position: 'absolute',
bottom: '18%',
left: '6%',
writingMode: 'vertical-rl',
fontSize: 56,
fontWeight: 700,
color: 'rgba(45, 42, 36, 0.03)',
letterSpacing: 8,
userSelect: 'none',
pointerEvents: 'none',
fontFamily: 'Georgia, "Times New Roman", serif',
}}
>
AUDIT
</div>
{/* ===================== 登录卡片 ===================== */}
<div
style={{
position: 'relative',
zIndex: 10,
width: '100%',
maxWidth: 380,
margin: '0 20px',
marginTop: '-6vh',
background: 'rgba(255, 253, 248, 0.85)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
border: '1px solid rgba(201, 169, 110, 0.2)',
borderRadius: 16,
padding: '44px 36px',
boxShadow: '0 4px 30px rgba(0,0,0,0.06), 0 0 0 1px rgba(255,255,255,0.8)',
}}
>
{/* ──── 品牌区域 ──── */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
{/* Logo */}
<div
style={{
width: 44,
height: 44,
borderRadius: 10,
background: 'linear-gradient(135deg, #3a362e, #1f1d19)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 18,
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#c9a96e"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
</div>
{/* 标题 */}
<h1
style={{
fontSize: 22,
fontWeight: 600,
color: '#2d2a24',
margin: '0 0 6px',
letterSpacing: 1,
}}
>
SQL审核平台
</h1>
{/* 副标题 */}
<p
style={{
fontSize: 13,
color: '#8a8478',
margin: 0,
letterSpacing: 0.5,
}}
>
企业级数据库审核与合规管理
</p>
</div>
{/* ──── 中国风细线装饰(上方) ──── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 24, gap: 8 }}>
<div style={{ width: 32, height: 1, background: 'linear-gradient(to left, #c9a96e, transparent)' }} />
<div style={{ width: 4, height: 4, borderRadius: '50%', background: '#c9a96e', opacity: 0.5 }} />
<div style={{ width: 32, height: 1, background: 'linear-gradient(to right, #c9a96e, transparent)' }} />
</div>
{/* ──── 表单 ──── */}
<form onSubmit={handleLogin}>
{/* 用户名 */}
<div style={{ marginBottom: 16 }}>
<label
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
color: '#5a5448',
marginBottom: 6,
}}
>
用户名
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
style={{
width: '100%',
height: 44,
padding: '0 14px',
fontSize: 14,
borderRadius: 10,
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.3s, box-shadow 0.3s',
background: '#faf8f4',
border: '1px solid #e5e0d5',
color: '#2d2a24',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#c9a96e';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(201, 169, 110, 0.12)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#e5e0d5';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
{/* 密码 */}
<div style={{ marginBottom: 20 }}>
<label
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
color: '#5a5448',
marginBottom: 6,
}}
>
密码
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
style={{
width: '100%',
height: 44,
padding: '0 14px',
fontSize: 14,
borderRadius: 10,
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.3s, box-shadow 0.3s',
background: '#faf8f4',
border: '1px solid #e5e0d5',
color: '#2d2a24',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#c9a96e';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(201, 169, 110, 0.12)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#e5e0d5';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
{/* 错误提示 */}
{error && (
<div
style={{
marginBottom: 16,
padding: '10px 14px',
borderRadius: 8,
background: '#fef2f2',
color: '#dc2626',
fontSize: 13,
lineHeight: 1.5,
}}
>
{error}
</div>
)}
{/* 登录按钮 */}
<button
type="submit"
disabled={!username || !password || loading}
style={{
width: '100%',
height: 46,
fontSize: 15,
fontWeight: 500,
color: '#fff',
border: 'none',
borderRadius: 10,
cursor: !username || !password || loading ? 'not-allowed' : 'pointer',
opacity: !username || !password || loading ? 0.65 : 1,
background: 'linear-gradient(135deg, #2d2a24, #4a453d)',
transition: 'all 0.3s ease',
letterSpacing: 1,
position: 'relative',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
if (username && password && !loading) {
e.currentTarget.style.background = 'linear-gradient(135deg, #3a362e, #5a554d)';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(45, 42, 36, 0.25)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #2d2a24, #4a453d)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{loading ? '登录中...' : '登 录'}
</button>
</form>
{/* ──── 中国风细线装饰(下方) ──── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 24, gap: 8 }}>
<div style={{ width: 24, height: 1, background: 'linear-gradient(to left, #c9a96e, transparent)' }} />
<div style={{ width: 3, height: 3, borderRadius: '50%', background: '#c9a96e', opacity: 0.4 }} />
<div style={{ width: 24, height: 1, background: 'linear-gradient(to right, #c9a96e, transparent)' }} />
</div>
{/* ──── 版权信息 ──── */}
<p
style={{
textAlign: 'center',
fontSize: 12,
color: '#a09a8e',
marginTop: 20,
marginBottom: 0,
letterSpacing: 0.3,
}}
>
&copy; SQL Audit Platform. All rights reserved.
</p>
</div>
</div>
</>
);
}
@@ -0,0 +1,267 @@
import { useLogin } from '@/hooks/useLogin';
/* ---------- Flowing curve configs ---------- */
const CURVES = [
{
// Curve 1: Wide pink-to-purple ribbon, top area
width: '140%', height: 320, top: '-5%', left: '-20%',
background: 'linear-gradient(135deg, #ec4899 0%, #a855f7 40%, #6366f1 100%)',
opacity: 0.12, blur: 60, rotate: -12,
animation: 'curve-flow-1 20s ease-in-out infinite',
},
{
// Curve 2: Thin bright pink ribbon, mid-upper
width: '120%', height: 180, top: '15%', left: '-10%',
background: 'linear-gradient(90deg, transparent 0%, #f472b6 30%, #c084fc 60%, transparent 100%)',
opacity: 0.18, blur: 40, rotate: -8,
animation: 'curve-flow-2 16s ease-in-out infinite',
},
{
// Curve 3: Deep purple band, center
width: '160%', height: 240, top: '35%', left: '-30%',
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 50%, #2563eb 100%)',
opacity: 0.10, blur: 70, rotate: 5,
animation: 'curve-flow-3 24s ease-in-out infinite',
},
{
// Curve 4: Bright cyan-pink accent, lower-mid
width: '110%', height: 120, top: '55%', left: '-5%',
background: 'linear-gradient(90deg, transparent 10%, #06b6d4 35%, #a855f7 65%, transparent 90%)',
opacity: 0.15, blur: 35, rotate: -15,
animation: 'curve-flow-4 14s ease-in-out infinite',
},
{
// Curve 5: Soft pink glow, bottom
width: '130%', height: 200, top: '70%', left: '-15%',
background: 'linear-gradient(135deg, #db2777 0%, #9333ea 60%, #4f46e5 100%)',
opacity: 0.08, blur: 80, rotate: 10,
animation: 'curve-flow-5 22s ease-in-out infinite',
},
{
// Curve 6: Thin bright line accent
width: '100%', height: 3, top: '40%', left: '0%',
background: 'linear-gradient(90deg, transparent 0%, #f472b6 20%, #c084fc 50%, #818cf8 80%, transparent 100%)',
opacity: 0.35, blur: 8, rotate: -5,
animation: 'curve-line-float 18s ease-in-out infinite',
},
{
// Curve 7: Another thin accent line
width: '90%', height: 2, top: '65%', left: '5%',
background: 'linear-gradient(90deg, transparent 5%, #818cf8 30%, #e879f9 70%, transparent 95%)',
opacity: 0.25, blur: 6, rotate: 8,
animation: 'curve-line-float 15s ease-in-out infinite reverse',
},
];
const KEYFRAMES = `
@keyframes curve-flow-1 {
0%, 100% { transform: rotate(-12deg) translateX(0) scaleY(1); }
33% { transform: rotate(-10deg) translateX(30px) scaleY(1.05); }
66% { transform: rotate(-14deg) translateX(-20px) scaleY(0.95); }
}
@keyframes curve-flow-2 {
0%, 100% { transform: rotate(-8deg) translateX(0); }
50% { transform: rotate(-6deg) translateX(40px); }
}
@keyframes curve-flow-3 {
0%, 100% { transform: rotate(5deg) translateX(0) scaleX(1); }
40% { transform: rotate(3deg) translateX(-30px) scaleX(1.02); }
70% { transform: rotate(7deg) translateX(20px) scaleX(0.98); }
}
@keyframes curve-flow-4 {
0%, 100% { transform: rotate(-15deg) translateX(0); }
50% { transform: rotate(-13deg) translateX(50px); }
}
@keyframes curve-flow-5 {
0%, 100% { transform: rotate(10deg) translateX(0); }
50% { transform: rotate(8deg) translateX(-40px); }
}
@keyframes curve-line-float {
0%, 100% { transform: rotate(-5deg) translateX(0); opacity: 0.3; }
25% { opacity: 0.5; }
50% { transform: rotate(-3deg) translateX(30px); opacity: 0.35; }
75% { opacity: 0.5; }
}
@keyframes curve-dot-pulse {
0%, 100% { opacity: 0.15; transform: scale(1); }
50% { opacity: 0.35; transform: scale(1.5); }
}
`;
/* ---------- Decorative dots ---------- */
const DOTS = [
{ top: '18%', left: '75%', size: 4, color: '#f472b6', delay: '0s' },
{ top: '30%', left: '20%', size: 3, color: '#818cf8', delay: '2s' },
{ top: '72%', left: '80%', size: 5, color: '#c084fc', delay: '1s' },
{ top: '85%', left: '30%', size: 3, color: '#ec4899', delay: '3s' },
{ top: '45%', left: '90%', size: 4, color: '#a855f7', delay: '1.5s' },
];
export default function MinimalDark() {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
return (
<>
<style>{KEYFRAMES}</style>
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: 24,
background: 'linear-gradient(160deg, #0a0a1a 0%, #0f0a2a 30%, #150825 60%, #0a0a1a 100%)',
overflow: 'hidden',
}}
>
{/* -------- Background glow orbs -------- */}
<div
style={{
position: 'fixed', top: '-20%', right: '-10%', width: 500, height: 500,
borderRadius: '50%', background: 'radial-gradient(circle, rgba(168,85,247,0.08), transparent 70%)',
filter: 'blur(40px)', pointerEvents: 'none',
}}
/>
<div
style={{
position: 'fixed', bottom: '-15%', left: '-5%', width: 400, height: 400,
borderRadius: '50%', background: 'radial-gradient(circle, rgba(236,72,153,0.06), transparent 70%)',
filter: 'blur(40px)', pointerEvents: 'none',
}}
/>
{/* -------- Flowing curves -------- */}
{CURVES.map((c, i) => (
<div
key={`curve-${i}`}
style={{
position: 'absolute',
top: c.top, left: c.left,
width: c.width, height: c.height,
background: c.background,
opacity: c.opacity,
filter: `blur(${c.blur}px)`,
borderRadius: '50%',
animation: c.animation,
pointerEvents: 'none',
}}
/>
))}
{/* -------- Decorative dots -------- */}
{DOTS.map((d, i) => (
<div
key={`dot-${i}`}
style={{
position: 'absolute',
top: d.top, left: d.left,
width: d.size, height: d.size,
borderRadius: '50%',
background: d.color,
animation: `curve-dot-pulse ${3 + i * 0.5}s ${d.delay} ease-in-out infinite`,
pointerEvents: 'none',
}}
/>
))}
{/* -------- Central content -------- */}
<div
style={{
width: '100%', maxWidth: 400, textAlign: 'center',
position: 'relative', zIndex: 10,
background: 'rgba(255,255,255,0.03)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
borderRadius: 24, padding: '48px 36px',
boxShadow: '0 0 0 1px rgba(255,255,255,0.05), 0 8px 40px rgba(0,0,0,0.3)',
}}
>
{/* Brand logo */}
<div
style={{
width: 44, height: 44, borderRadius: 12,
background: 'linear-gradient(135deg, #ec4899, #a855f7)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 24px',
boxShadow: '0 4px 20px rgba(168,85,247,0.3)',
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
</div>
{/* Brand name */}
<h1 style={{ fontSize: 22, fontWeight: 600, color: '#fff', marginBottom: 6, letterSpacing: '-0.3px' }}>
SQL审核平台
</h1>
<p style={{ color: 'rgba(255,255,255,0.4)', fontSize: 14, marginBottom: 36 }}>
企业级数据库审核与合规管理
</p>
{/* Form */}
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && (
<div style={{ color: '#f87171', fontSize: 13, textAlign: 'center', padding: '10px 14px', background: 'rgba(248,113,113,0.08)', borderRadius: 8 }}>
{error}
</div>
)}
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full"
style={{
padding: '13px 16px', borderRadius: 10,
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#fff', fontSize: 14, outline: 'none',
transition: 'border-color 0.2s, background 0.2s',
}}
onFocus={(e) => { e.target.style.borderColor = 'rgba(168,85,247,0.4)'; e.target.style.background = 'rgba(255,255,255,0.08)'; }}
onBlur={(e) => { e.target.style.borderColor = 'rgba(255,255,255,0.08)'; e.target.style.background = 'rgba(255,255,255,0.05)'; }}
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full"
style={{
padding: '13px 16px', borderRadius: 10,
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#fff', fontSize: 14, outline: 'none',
transition: 'border-color 0.2s, background 0.2s',
}}
onFocus={(e) => { e.target.style.borderColor = 'rgba(168,85,247,0.4)'; e.target.style.background = 'rgba(255,255,255,0.08)'; }}
onBlur={(e) => { e.target.style.borderColor = 'rgba(255,255,255,0.08)'; e.target.style.background = 'rgba(255,255,255,0.05)'; }}
/>
<button
type="submit"
disabled={loading || !username || !password}
className="dg-gradient-bg w-full"
style={{
padding: '13px 20px', borderRadius: 10,
border: 'none', color: '#fff', fontWeight: 600, fontSize: 15,
cursor: loading || !username || !password ? 'not-allowed' : 'pointer',
opacity: loading || !username || !password ? 0.5 : 1,
marginTop: 4,
transition: 'opacity 0.2s, transform 0.1s',
boxShadow: '0 4px 20px rgba(168,85,247,0.25)',
}}
>
{loading ? '登录中…' : '登录'}
</button>
</form>
</div>
</div>
</>
);
}
@@ -0,0 +1,505 @@
import React from 'react';
import { useLogin } from '@/hooks/useLogin';
const KEYFRAMES = `
@keyframes neon-pulse {
0%, 100% { opacity: 0.6; box-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 40px currentColor; }
50% { opacity: 1; box-shadow: 0 0 20px currentColor, 0 0 40px currentColor, 0 0 80px currentColor; }
}
@keyframes neon-flicker {
0%, 100% { opacity: 1; }
3% { opacity: 0.4; }
6% { opacity: 1; }
7% { opacity: 0.2; }
8% { opacity: 1; }
92% { opacity: 1; }
93% { opacity: 0.3; }
94% { opacity: 1; }
}
@keyframes rain-fall {
0% { transform: translateY(-100vh) translateX(0); opacity: 0; }
10% { opacity: 0.6; }
90% { opacity: 0.6; }
100% { transform: translateY(100vh) translateX(20px); opacity: 0; }
}
@keyframes grid-move {
0% { transform: perspective(500px) rotateX(60deg) translateY(0); }
100% { transform: perspective(500px) rotateX(60deg) translateY(40px); }
}
@keyframes city-breathe {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(1.02); }
}
@keyframes neon-glow-text {
0%, 100% { text-shadow: 0 0 10px rgba(236, 72, 153, 0.5), 0 0 20px rgba(236, 72, 153, 0.3); }
50% { text-shadow: 0 0 20px rgba(236, 72, 153, 0.8), 0 0 40px rgba(236, 72, 153, 0.5); }
}
`;
// Building data: [width, heightPercent, leftPercent, glowColor]
const BUILDINGS: [number, number, number, string][] = [
[40, 18, 0, 'rgba(236, 72, 153, 0.4)'],
[55, 28, 4, 'rgba(168, 85, 247, 0.35)'],
[35, 15, 9, 'rgba(6, 182, 212, 0.3)'],
[70, 35, 12, 'rgba(236, 72, 153, 0.45)'],
[45, 22, 19, 'rgba(168, 85, 247, 0.4)'],
[50, 30, 23, 'rgba(6, 182, 212, 0.35)'],
[30, 16, 28, 'rgba(236, 72, 153, 0.3)'],
[80, 38, 31, 'rgba(168, 85, 247, 0.5)'],
[42, 20, 39, 'rgba(6, 182, 212, 0.4)'],
[60, 32, 43, 'rgba(236, 72, 153, 0.45)'],
[38, 17, 49, 'rgba(168, 85, 247, 0.3)'],
[75, 36, 52, 'rgba(6, 182, 212, 0.5)'],
[48, 25, 60, 'rgba(236, 72, 153, 0.4)'],
[55, 30, 65, 'rgba(168, 85, 247, 0.45)'],
[33, 14, 70, 'rgba(6, 182, 212, 0.3)'],
[85, 40, 73, 'rgba(236, 72, 153, 0.5)'],
[44, 21, 82, 'rgba(168, 85, 247, 0.4)'],
[58, 33, 86, 'rgba(6, 182, 212, 0.45)'],
[36, 16, 92, 'rgba(236, 72, 153, 0.35)'],
[50, 27, 95, 'rgba(168, 85, 247, 0.4)'],
];
// Neon tube data: [topPercent, color, animationDelay, animationDuration]
const NEON_TUBES: [number, string, string, string][] = [
[22, '#ec4899', '0s', '3s'],
[38, '#06b6d4', '1s', '4s'],
[52, '#a855f7', '2s', '3.5s'],
];
// Rain drop data: [leftPercent, delay, duration]
const RAIN_DROPS: [number, string, string][] = [
[5, '0s', '1.5s'],
[15, '0.3s', '1.8s'],
[25, '0.7s', '1.3s'],
[35, '0.2s', '1.6s'],
[45, '0.9s', '1.4s'],
[55, '0.5s', '1.7s'],
[65, '0.1s', '1.5s'],
[75, '0.8s', '1.9s'],
[85, '0.4s', '1.3s'],
[95, '0.6s', '1.6s'],
[10, '1.1s', '1.4s'],
[30, '1.3s', '1.7s'],
[50, '1.0s', '1.5s'],
[70, '1.5s', '1.8s'],
[90, '1.2s', '1.4s'],
[20, '0.15s', '2.0s'],
[40, '0.85s', '1.35s'],
[60, '1.25s', '1.65s'],
[80, '0.45s', '1.75s'],
[8, '0.95s', '1.55s'],
];
export default function NeonCityView(): JSX.Element {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
return (
<>
<style>{KEYFRAMES}</style>
<div
style={{
minHeight: '100vh',
position: 'relative',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(180deg, #0a0418 0%, #1a0b2e 40%, #0d0718 100%)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}}
>
{/* Grid floor background */}
<div
style={{
position: 'absolute',
bottom: '-60%',
left: '-50%',
width: '200%',
height: '100%',
backgroundImage:
'linear-gradient(rgba(236, 72, 153, 0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(6, 182, 212, 0.04) 1px, transparent 1px)',
backgroundSize: '60px 60px',
animation: 'grid-move 8s linear infinite',
pointerEvents: 'none',
}}
/>
{/* Neon tubes */}
{NEON_TUBES.map(([top, color, delay, duration], i) => (
<div
key={`neon-${i}`}
style={{
position: 'absolute',
top: `${top}%`,
left: '5%',
right: '5%',
height: '2px',
background: color,
color: color,
boxShadow: `0 0 10px ${color}, 0 0 20px ${color}, 0 0 40px ${color}`,
animation: `neon-pulse ${duration} ease-in-out infinite ${delay}, neon-flicker ${duration} ease-in-out infinite ${delay}`,
borderRadius: '1px',
pointerEvents: 'none',
zIndex: 1,
}}
/>
))}
{/* City skyline buildings */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '40%',
animation: 'city-breathe 6s ease-in-out infinite',
pointerEvents: 'none',
zIndex: 2,
}}
>
{BUILDINGS.map(([width, heightPct, leftPct, glowColor], i) => (
<div
key={`building-${i}`}
style={{
position: 'absolute',
bottom: 0,
left: `${leftPct}%`,
width: `${width}px`,
height: `${heightPct}%`,
background: 'rgba(5, 3, 15, 0.9)',
borderRadius: '2px 2px 0 0',
boxShadow: `0 -2px 10px ${glowColor}`,
}}
/>
))}
</div>
{/* Rain drops */}
{RAIN_DROPS.map(([left, delay, duration], i) => (
<div
key={`rain-${i}`}
style={{
position: 'absolute',
left: `${left}%`,
top: 0,
width: '1px',
height: '25px',
background: 'linear-gradient(180deg, transparent, rgba(6, 182, 212, 0.3))',
animation: `rain-fall ${duration} linear infinite ${delay}`,
pointerEvents: 'none',
zIndex: 3,
}}
/>
))}
{/* Fog layer */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '35%',
background: 'linear-gradient(180deg, transparent 0%, rgba(10, 4, 24, 0.6) 50%, rgba(10, 4, 24, 0.85) 100%)',
pointerEvents: 'none',
zIndex: 4,
}}
/>
{/* Top glow */}
<div
style={{
position: 'absolute',
top: '-10%',
left: '50%',
transform: 'translateX(-50%)',
width: '600px',
height: '300px',
background: 'radial-gradient(ellipse, rgba(168, 85, 247, 0.12) 0%, transparent 70%)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
{/* Login card */}
<div
style={{
position: 'relative',
zIndex: 10,
width: '100%',
maxWidth: 400,
margin: '0 20px',
background: 'rgba(10, 5, 25, 0.8)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(236, 72, 153, 0.2)',
borderRadius: 16,
padding: '44px 36px',
boxShadow: '0 0 20px rgba(236, 72, 153, 0.1), 0 8px 40px rgba(0,0,0,0.5)',
overflow: 'hidden',
}}
>
{/* Top neon line decoration */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '2px',
background: 'linear-gradient(90deg, #ec4899, #06b6d4, #a855f7, #ec4899)',
backgroundSize: '200% 100%',
animation: 'neon-pulse 3s ease-in-out infinite',
}}
/>
{/* Brand area */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
{/* Logo */}
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: 'linear-gradient(135deg, #ec4899, #06b6d4)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
boxShadow: '0 0 15px rgba(236, 72, 153, 0.5), 0 0 30px rgba(6, 182, 212, 0.3)',
}}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="#fff"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
</div>
{/* Title */}
<h1
style={{
fontSize: 24,
fontWeight: 700,
color: '#fff',
margin: '0 0 8px',
letterSpacing: '0.5px',
animation: 'neon-glow-text 3s ease-in-out infinite',
}}
>
SQL审核平台
</h1>
{/* Subtitle */}
<p
style={{
fontSize: 14,
color: 'rgba(6, 182, 212, 0.8)',
margin: 0,
letterSpacing: '0.3px',
}}
>
企业级数据库审核与合规管理
</p>
</div>
{/* Login form */}
<form onSubmit={handleLogin}>
{/* Username input */}
<div style={{ marginBottom: 18 }}>
<label
htmlFor="username"
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
color: 'rgba(255,255,255,0.7)',
marginBottom: 8,
letterSpacing: '0.3px',
}}
>
用户名
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
autoComplete="username"
style={{
width: '100%',
height: 46,
padding: '0 16px',
fontSize: 14,
color: '#fff',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(236, 72, 153, 0.6)';
e.currentTarget.style.boxShadow = '0 0 10px rgba(236, 72, 153, 0.2)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
{/* Password input */}
<div style={{ marginBottom: 24 }}>
<label
htmlFor="password"
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
color: 'rgba(255,255,255,0.7)',
marginBottom: 8,
letterSpacing: '0.3px',
}}
>
密码
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoComplete="current-password"
style={{
width: '100%',
height: 46,
padding: '0 16px',
fontSize: 14,
color: '#fff',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(6, 182, 212, 0.6)';
e.currentTarget.style.boxShadow = '0 0 10px rgba(6, 182, 212, 0.2)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
{/* Error message */}
{error && (
<div
style={{
marginBottom: 16,
padding: '10px 14px',
borderRadius: 8,
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
color: '#f87171',
fontSize: 13,
textShadow: '0 0 8px rgba(239, 68, 68, 0.4)',
animation: 'neon-flicker 0.5s ease-in-out',
}}
>
{error}
</div>
)}
{/* Login button */}
<button
type="submit"
disabled={!username || !password || loading}
style={{
width: '100%',
height: 48,
fontSize: 15,
fontWeight: 600,
color: '#fff',
background: 'linear-gradient(135deg, #ec4899, #06b6d4)',
border: 'none',
borderRadius: 10,
cursor: !username || !password || loading ? 'not-allowed' : 'pointer',
opacity: !username || !password ? 0.6 : 1,
boxShadow: '0 0 15px rgba(236, 72, 153, 0.4)',
transition: 'all 0.3s ease',
letterSpacing: '0.5px',
}}
onMouseEnter={(e) => {
if (username && password && !loading) {
e.currentTarget.style.boxShadow = '0 0 25px rgba(236, 72, 153, 0.6), 0 0 40px rgba(6, 182, 212, 0.3)';
e.currentTarget.style.transform = 'translateY(-1px)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '0 0 15px rgba(236, 72, 153, 0.4)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
{loading ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ animation: 'spin 1s linear infinite' }}
>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
登录中...
</span>
) : (
'登 录'
)}
</button>
</form>
{/* Copyright */}
<p
style={{
textAlign: 'center',
fontSize: 12,
color: 'rgba(255,255,255,0.25)',
marginTop: 28,
marginBottom: 0,
}}
>
© SQL审核平台 · 安全合规的数据库管理
</p>
</div>
</div>
</>
);
}
@@ -0,0 +1,332 @@
import { useLogin } from '@/hooks/useLogin';
export default function SidePanel() {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
/* 15 bioluminescent particles scattered across the deep-sea scene */
const biolumParticles = [
{ top: '8%', left: '20%', size: 4, color: 'rgba(6,182,212,0.4)', delay: '0s', dur: '4s' },
{ top: '15%', left: '65%', size: 5, color: 'rgba(20,184,166,0.3)', delay: '1.2s', dur: '5s' },
{ top: '22%', left: '40%', size: 3, color: 'rgba(34,211,238,0.5)', delay: '0.5s', dur: '3.5s' },
{ top: '28%', left: '12%', size: 6, color: 'rgba(6,182,212,0.4)', delay: '2.8s', dur: '4.5s' },
{ top: '35%', left: '78%', size: 4, color: 'rgba(6,182,212,0.4)', delay: '3.5s', dur: '3s' },
{ top: '42%', left: '30%', size: 7, color: 'rgba(20,184,166,0.3)', delay: '1s', dur: '5.5s' },
{ top: '48%', left: '55%', size: 3, color: 'rgba(34,211,238,0.5)', delay: '4.2s', dur: '4s' },
{ top: '55%', left: '18%', size: 5, color: 'rgba(6,182,212,0.4)', delay: '0.8s', dur: '6s' },
{ top: '60%', left: '70%', size: 4, color: 'rgba(6,182,212,0.4)', delay: '5s', dur: '3.5s' },
{ top: '67%', left: '45%', size: 8, color: 'rgba(20,184,166,0.3)', delay: '2s', dur: '4.5s' },
{ top: '73%', left: '25%', size: 3, color: 'rgba(6,182,212,0.4)', delay: '6s', dur: '5s' },
{ top: '78%', left: '85%', size: 5, color: 'rgba(34,211,238,0.5)', delay: '1.5s', dur: '3s' },
{ top: '83%', left: '50%', size: 4, color: 'rgba(6,182,212,0.4)', delay: '7s', dur: '4.5s' },
{ top: '88%', left: '15%', size: 6, color: 'rgba(6,182,212,0.4)', delay: '3s', dur: '5.5s' },
{ top: '92%', left: '60%', size: 3, color: 'rgba(20,184,166,0.3)', delay: '8s', dur: '3.5s' },
];
/* 5 ocean wave layers */
const waveLayers = [
{ top: '20%', height: 80, border: 'border-bottom', borderColor: 'rgba(6,182,212,0.06)', anim: 'ocean-wave-1 12s ease-in-out infinite' },
{ top: '30%', height: 100, border: 'border-bottom', borderColor: 'rgba(20,184,166,0.08)', anim: 'ocean-wave-2 15s ease-in-out infinite' },
{ top: '45%', height: 120, border: 'border-top', borderColor: 'rgba(6,182,212,0.10)', anim: 'ocean-wave-3 18s ease-in-out infinite' },
{ top: '60%', height: 90, border: 'border-bottom', borderColor: 'rgba(20,184,166,0.06)', anim: 'ocean-wave-4 14s ease-in-out infinite' },
{ top: '75%', height: 70, border: 'border-top', borderColor: 'rgba(6,182,212,0.04)', anim: 'ocean-wave-5 20s ease-in-out infinite' },
];
/* Feature pills */
const featurePills = ['安全审核', '变更管控', '权限隔离'];
return (
<div className="flex min-h-screen">
{/* ========== Left 40% — Deep Sea Scene ========== */}
<div
style={{
position: 'relative',
width: '40%',
background: 'linear-gradient(180deg, #020810 0%, #041420 30%, #062030 60%, #041820 100%)',
overflow: 'hidden',
}}
>
{/* --- Deep-sea light shafts --- */}
<div
style={{
position: 'absolute',
top: 0,
left: '25%',
width: 1,
height: '100%',
background: 'linear-gradient(to bottom, rgba(6,182,212,0.08), transparent)',
animation: 'ocean-lightshaft 5s ease-in-out 0s infinite',
zIndex: 1,
}}
/>
<div
style={{
position: 'absolute',
top: 0,
left: '55%',
width: 2,
height: '100%',
background: 'linear-gradient(to bottom, rgba(6,182,212,0.06), transparent)',
animation: 'ocean-lightshaft 7s ease-in-out 2s infinite',
zIndex: 1,
}}
/>
<div
style={{
position: 'absolute',
top: 0,
left: '78%',
width: 1,
height: '100%',
background: 'linear-gradient(to bottom, rgba(20,184,166,0.05), transparent)',
animation: 'ocean-lightshaft 4s ease-in-out 4s infinite',
zIndex: 1,
}}
/>
{/* --- 5 Wave layers --- */}
{waveLayers.map((wave, i) => (
<div
key={`wave-${i}`}
style={{
position: 'absolute',
top: wave.top,
left: '-50%',
width: '200%',
height: wave.height,
borderRadius: '50%',
[wave.border]: `1px solid ${wave.borderColor}`,
animation: wave.anim,
zIndex: 2,
}}
/>
))}
{/* --- 15 Bioluminescent particles --- */}
{biolumParticles.map((p, i) => (
<div
key={`biolum-${i}`}
style={{
position: 'absolute',
top: p.top,
left: p.left,
width: p.size,
height: p.size,
borderRadius: '50%',
background: p.color,
boxShadow: `0 0 ${p.size * 2}px ${p.color}`,
animation: `ocean-biolum-pulse ${p.dur} ease-in-out ${p.delay} infinite`,
zIndex: 3,
}}
/>
))}
{/* --- Brand content (left-aligned, bottom section) --- */}
<div
style={{
position: 'relative',
zIndex: 10,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
height: '100%',
padding: '0 44px 56px',
}}
>
{/* Main title */}
<div style={{ fontSize: 42, fontWeight: 700, color: 'rgba(6,182,212,0.9)', marginBottom: 12, lineHeight: 1.2 }}>
SQL审核平台
</div>
{/* Subtitle */}
<div style={{ fontSize: 15, color: 'rgba(148,163,184,0.5)', marginBottom: 32 }}>
让数据安全如深海般沉静
</div>
{/* Feature pills */}
<div style={{ display: 'flex', gap: 10 }}>
{featurePills.map((pill) => (
<span
key={pill}
style={{
padding: '4px 12px',
borderRadius: 20,
background: 'rgba(6,182,212,0.08)',
color: 'rgba(6,182,212,0.5)',
fontSize: 11,
}}
>
{pill}
</span>
))}
</div>
</div>
{/* --- Keyframes (ocean- prefix) --- */}
<style>{`
@keyframes ocean-wave-1 {
0%, 100% { transform: translateX(-30px); }
50% { transform: translateX(30px); }
}
@keyframes ocean-wave-2 {
0%, 100% { transform: translateX(30px); }
50% { transform: translateX(-30px); }
}
@keyframes ocean-wave-3 {
0%, 100% { transform: translateX(-20px); }
50% { transform: translateX(20px); }
}
@keyframes ocean-wave-4 {
0%, 100% { transform: translateX(25px); }
50% { transform: translateX(-25px); }
}
@keyframes ocean-wave-5 {
0%, 100% { transform: translateX(-30px); }
50% { transform: translateX(30px); }
}
@keyframes ocean-biolum-pulse {
0%, 100% { opacity: 0.1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.5); }
}
@keyframes ocean-lightshaft {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.8; }
}
`}</style>
</div>
{/* ========== Right 60% — Login Form ========== */}
<div
className="flex items-center justify-center"
style={{ width: '60%', background: '#060e18' }}
>
<form
onSubmit={handleLogin}
style={{
width: '100%',
maxWidth: 400,
display: 'flex',
flexDirection: 'column',
padding: '0 40px',
}}
>
{/* Form title */}
<h2 style={{ fontSize: 28, fontWeight: 700, marginBottom: 8, color: '#e2e8f0' }}>
欢迎回来
</h2>
<p style={{ fontSize: 14, color: 'rgba(148,163,184,0.5)', marginBottom: 40 }}>
登录你的工作空间
</p>
{/* Error message */}
{error && (
<div style={{ color: '#f87171', marginBottom: 16 }}>
{error}
</div>
)}
{/* Username field */}
<div style={{ marginBottom: 24 }}>
<label
style={{
display: 'block',
fontSize: 12,
color: 'rgba(6,182,212,0.5)',
marginBottom: 8,
fontWeight: 500,
}}
>
用户名
</label>
<input
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full"
style={{
padding: '13px 16px',
borderRadius: 10,
background: 'rgba(6,30,50,0.6)',
border: '1px solid rgba(6,182,212,0.08)',
color: '#e2e8f0',
fontSize: 14,
outline: 'none',
transition: 'border-color 0.3s, background 0.3s',
}}
onFocus={(e) => {
e.target.style.borderColor = 'rgba(6,182,212,0.3)';
e.target.style.background = 'rgba(6,30,50,0.8)';
}}
onBlur={(e) => {
e.target.style.borderColor = 'rgba(6,182,212,0.08)';
e.target.style.background = 'rgba(6,30,50,0.6)';
}}
/>
</div>
{/* Password field */}
<div style={{ marginBottom: 32 }}>
<label
style={{
display: 'block',
fontSize: 12,
color: 'rgba(6,182,212,0.5)',
marginBottom: 8,
fontWeight: 500,
}}
>
密码
</label>
<input
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full"
style={{
padding: '13px 16px',
borderRadius: 10,
background: 'rgba(6,30,50,0.6)',
border: '1px solid rgba(6,182,212,0.08)',
color: '#e2e8f0',
fontSize: 14,
outline: 'none',
transition: 'border-color 0.3s, background 0.3s',
}}
onFocus={(e) => {
e.target.style.borderColor = 'rgba(6,182,212,0.3)';
e.target.style.background = 'rgba(6,30,50,0.8)';
}}
onBlur={(e) => {
e.target.style.borderColor = 'rgba(6,182,212,0.08)';
e.target.style.background = 'rgba(6,30,50,0.6)';
}}
/>
</div>
{/* Submit button */}
<button
type="submit"
disabled={loading || !username || !password}
className="dg-gradient-bg w-full"
style={{
padding: '13px 20px',
borderRadius: 10,
border: 'none',
color: '#fff',
fontWeight: 600,
fontSize: 15,
cursor: loading || !username || !password ? 'not-allowed' : 'pointer',
opacity: loading || !username || !password ? 0.5 : 1,
transition: 'opacity 0.2s, transform 0.1s',
background: 'linear-gradient(135deg, #06b6d4, #14b8a6)',
boxShadow: '0 4px 20px rgba(6,182,212,0.2)',
}}
>
{loading ? '登录中…' : '登录'}
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,276 @@
import { useLogin } from '@/hooks/useLogin';
/* ── Lattice node positions (percentage-based) ── */
const LATTICE_NODES = [
{ top: '15%', left: '12%', size: 5, delay: 0, duration: 2.5 },
{ top: '15%', left: '42%', size: 4, delay: 0.6, duration: 3 },
{ top: '15%', left: '72%', size: 5, delay: 1.2, duration: 2.8 },
{ top: '30%', left: '12%', size: 4, delay: 0.3, duration: 3.2 },
{ top: '30%', left: '42%', size: 6, delay: 0.9, duration: 2.6 },
{ top: '30%', left: '72%', size: 4, delay: 1.5, duration: 3.5 },
{ top: '45%', left: '12%', size: 5, delay: 0.2, duration: 2.4 },
{ top: '45%', left: '42%', size: 4, delay: 1.1, duration: 3.8 },
{ top: '45%', left: '72%', size: 5, delay: 0.7, duration: 2.9 },
{ top: '60%', left: '12%', size: 4, delay: 1.4, duration: 3.1 },
{ top: '60%', left: '42%', size: 5, delay: 0.4, duration: 2.7 },
{ top: '60%', left: '72%', size: 4, delay: 1.0, duration: 3.4 },
{ top: '75%', left: '27%', size: 6, delay: 0.5, duration: 2.3 },
{ top: '75%', left: '57%', size: 5, delay: 1.3, duration: 3.6 },
{ top: '85%', left: '42%', size: 4, delay: 0.8, duration: 2.2 },
{ top: '22%', left: '27%', size: 4, delay: 1.6, duration: 3.3 },
{ top: '22%', left: '57%', size: 5, delay: 0.1, duration: 2.1 },
{ top: '52%', left: '27%', size: 4, delay: 1.7, duration: 4.0 },
{ top: '52%', left: '57%', size: 5, delay: 0.35, duration: 2.5 },
];
const INPUT_STYLE_LIGHT: React.CSSProperties = {
padding: '12px 16px',
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 10,
color: '#1e293b',
fontSize: 14,
outline: 'none',
width: '100%',
transition: 'border-color 0.3s, box-shadow 0.3s',
};
const LABEL_STYLE_LIGHT: React.CSSProperties = {
display: 'block',
fontSize: 12,
color: '#64748b',
marginBottom: 8,
fontWeight: 500,
};
export default function SplitView() {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
return (
<div className="flex min-h-screen" style={{ fontFamily: 'system-ui, -apple-system, sans-serif' }}>
{/* ═══════════════════════════════════════════════════════════════
Left Panel 55% — 晶格矩阵区
═══════════════════════════════════════════════════════════════ */}
<div
className="relative overflow-hidden"
style={{
width: '55%',
background: '#080c14',
display: 'flex',
alignItems: 'center',
padding: '0 56px',
}}
>
{/* ── Perspective Grid ── */}
<div
style={{
position: 'absolute',
inset: 0,
perspective: '500px',
overflow: 'hidden',
}}
>
{/* Horizontal lines (11) */}
{Array.from({ length: 11 }, (_, i) => (
<div
key={`h-${i}`}
style={{
position: 'absolute',
left: 0,
right: 0,
top: `${8 + i * 8}%`,
height: 1,
background: 'rgba(0, 212, 170, 0.08)',
transform: 'rotateX(60deg)',
transformOrigin: 'center center',
}}
/>
))}
{/* Vertical lines (11) */}
{Array.from({ length: 11 }, (_, i) => (
<div
key={`v-${i}`}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${8 + i * 8}%`,
width: 1,
background: 'rgba(0, 212, 170, 0.08)',
transform: 'rotateX(60deg)',
transformOrigin: 'center center',
}}
/>
))}
</div>
{/* ── Lattice Glow Nodes ── */}
{LATTICE_NODES.map((n, i) => (
<div
key={`node-${i}`}
style={{
position: 'absolute',
top: n.top,
left: n.left,
width: n.size,
height: n.size,
borderRadius: '50%',
background: '#00d4aa',
boxShadow: '0 0 8px rgba(0,212,170,0.6)',
animation: `lattice-node-pulse ${n.duration}s ease-in-out ${n.delay}s infinite`,
}}
/>
))}
{/* ── Right-Bottom Glow Decoration ── */}
<div
style={{
position: 'absolute',
right: -60,
bottom: -60,
width: 300,
height: 300,
borderRadius: '50%',
background: 'radial-gradient(circle, #00d4aa, transparent)',
opacity: 0.06,
animation: 'lattice-glow-drift 15s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
{/* ── Brand Content (centered-low, above grid) ── */}
<div style={{ position: 'relative', zIndex: 10, maxWidth: 400 }}>
{/* Logo — 48×48 gradient square with hexagon icon */}
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
background: 'linear-gradient(135deg, #00d4aa, #0ea5e9)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 32,
boxShadow: '0 4px 24px rgba(0,212,170,0.3)',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L22 8.5V15.5L12 22L2 15.5V8.5L12 2Z" />
<path d="M12 22V15.5" />
<path d="M22 8.5L12 15.5L2 8.5" />
<path d="M2 15.5L12 8.5L22 15.5" />
<line x1="12" y1="2" x2="12" y2="8.5" />
</svg>
</div>
<h1 style={{ fontSize: 36, fontWeight: 700, color: '#e2e8f0', marginBottom: 10, letterSpacing: '-0.5px', lineHeight: 1.2 }}>
SQL审核平台
</h1>
<p style={{ color: 'rgba(148,163,184,0.7)', fontSize: 14, marginBottom: 24, lineHeight: 1.6 }}>
数据安全的第一道防线
</p>
<p style={{ color: 'rgba(0,212,170,0.5)', fontSize: 12, letterSpacing: '0.5px' }}>
实时审核 · 智能分析 · 合规管控
</p>
</div>
{/* ── Keyframes ── */}
<style>{`
@keyframes lattice-node-pulse {
0%, 100% {
opacity: 0.2;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.3);
}
}
@keyframes lattice-glow-drift {
0%, 100% {
transform: translateX(-30px);
}
50% {
transform: translateX(30px);
}
}
`}</style>
</div>
{/* ═══════════════════════════════════════════════════════════════
Right Panel 45% — 登录表单区
═══════════════════════════════════════════════════════════════ */}
<div
className="flex items-center justify-center"
style={{
width: '45%',
background: '#ffffff',
padding: 48,
}}
>
<form
onSubmit={handleLogin}
style={{ width: '100%', maxWidth: 360, display: 'flex', flexDirection: 'column' }}
>
<h2 style={{ fontSize: 24, fontWeight: 600, color: '#1e293b', marginBottom: 8 }}>
登录
</h2>
<p style={{ color: '#94a3b8', fontSize: 14, marginBottom: 40 }}>
访问你的工作空间
</p>
{error && <div style={{ color: '#ef4444', fontSize: 13, marginBottom: 16 }}>{error}</div>}
<div style={{ marginBottom: 24 }}>
<label style={LABEL_STYLE_LIGHT}>用户名</label>
<input
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full"
style={INPUT_STYLE_LIGHT}
onFocus={(e) => { e.target.style.borderColor = '#00d4aa'; e.target.style.boxShadow = '0 0 0 3px rgba(0,212,170,0.1)'; }}
onBlur={(e) => { e.target.style.borderColor = '#e2e8f0'; e.target.style.boxShadow = 'none'; }}
/>
</div>
<div style={{ marginBottom: 32 }}>
<label style={LABEL_STYLE_LIGHT}>密码</label>
<input
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full"
style={INPUT_STYLE_LIGHT}
onFocus={(e) => { e.target.style.borderColor = '#00d4aa'; e.target.style.boxShadow = '0 0 0 3px rgba(0,212,170,0.1)'; }}
onBlur={(e) => { e.target.style.borderColor = '#e2e8f0'; e.target.style.boxShadow = 'none'; }}
/>
</div>
<button
type="submit"
disabled={loading || !username || !password}
className="dg-gradient-bg w-full"
style={{
padding: '13px 20px',
borderRadius: 10,
border: 'none',
background: 'linear-gradient(135deg, #00d4aa, #0ea5e9)',
color: '#fff',
fontWeight: 600,
fontSize: 15,
cursor: loading || !username || !password ? 'not-allowed' : 'pointer',
opacity: loading || !username || !password ? 0.5 : 1,
boxShadow: '0 4px 20px rgba(0,212,170,0.2)',
transition: 'opacity 0.2s',
}}
>
{loading ? '登录中…' : '登录'}
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,155 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
@@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,239 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
@@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
@@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
@@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff. Show more