feat: 添加 seed.go 初始化文件 — 默认用户+审批流程+系统设置

- seedDefaultUsers: 4个默认用户(admin/dba/auditor/developer, 密码admin123)
- seedDefaultWorkflow: 默认审批流程(提交→DBA审核→执行)
- seedDefaultSettings: 数据脱敏默认配置
- 所有操作幂等,已有数据不覆盖
- main.go调用seedAll()统一初始化
This commit is contained in:
logan committed 2026-05-29 21:31:25 +08:00
1 parent 86eea25031
commit ad12f30355
228 files changed
+118 -58764

No files matched your search

+2 -2
View File
@@ -60,8 +60,8 @@ func main() {
&model.UserGroup{},
)
// Seed default settings (idempotent)
seedDefaultSettings(db)
// Seed default data (idempotent): users, workflow, settings
seedAll(db)
// Setup Gin
r := gin.Default()
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"log"
"sql-platform/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// seedAll initializes the database with default data (idempotent).
// Safe to run on every startup — skips existing records.
func seedAll(db *gorm.DB) {
seedDefaultUsers(db)
seedDefaultWorkflow(db)
seedDefaultSettings(db)
log.Println("[seed] Default data initialized")
}
// seedDefaultUsers creates default users if they don't exist.
func seedDefaultUsers(db *gorm.DB) {
type seedUser struct {
username string
displayName string
email string
role string
password string
active bool
}
users := []seedUser{
{"admin", "超级管理员", "admin@opsx.local", "admin", "admin123", true},
{"dba", "DBA管理员", "dba@opsx.local", "dba", "admin123", true},
{"auditor", "审计员", "auditor@opsx.local", "auditor", "admin123", true},
{"developer", "开发者", "developer@opsx.local", "developer", "admin123", true},
}
for _, u := range users {
var existing model.User
if db.Where("username = ?", u.username).First(&existing).Error != nil {
hash, _ := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost)
email := u.email
active := u.active
user := model.User{
Username: u.username,
DisplayName: u.displayName,
Email: &email,
HashedPassword: string(hash),
Role: u.role,
IsActive: &active,
}
if err := db.Create(&user).Error; err != nil {
log.Printf("[seed] Failed to create user %s: %v", u.username, err)
} else {
log.Printf("[seed] Created user: %s (%s)", u.username, u.role)
}
}
}
}
// seedDefaultWorkflow creates a default approval workflow if none exists.
func seedDefaultWorkflow(db *gorm.DB) {
var count int64
db.Model(&model.WorkflowDefinition{}).Count(&count)
if count > 0 {
return // Already has workflows
}
// Create default workflow
t := true
wf := model.WorkflowDefinition{
Name: "默认审批流程",
Description: strPtr("系统默认审批流程:开发者提交 → DBA审核 → 执行"),
IsDefault: &t,
IsActive: &t,
}
if err := db.Create(&wf).Error; err != nil {
log.Printf("[seed] Failed to create default workflow: %v", err)
return
}
// Create steps
steps := []model.WorkflowStep{
{
WorkflowID: wf.ID,
StepNumber: 1,
Name: "提交审核",
StepType: "submit",
IsRequired: &t,
TimeoutHours: intPtr(0),
},
{
WorkflowID: wf.ID,
StepNumber: 2,
Name: "DBA审核",
StepType: "audit",
ApproverRole: strPtr("dba"),
IsRequired: &t,
TimeoutHours: intPtr(24),
},
{
WorkflowID: wf.ID,
StepNumber: 3,
Name: "执行变更",
StepType: "execute",
IsRequired: &t,
TimeoutHours: intPtr(0),
},
}
for _, s := range steps {
db.Create(&s)
}
log.Printf("[seed] Created default workflow: %s (id=%d)", wf.Name, wf.ID)
}
func intPtr(v int) *int { return &v }
-133
View File
@@ -1,133 +0,0 @@
# 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 类型安全,默认导出组件
@@ -1,32 +0,0 @@
# 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
@@ -1,97 +0,0 @@
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
@@ -1,198 +0,0 @@
# 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 独有) |
| 风险评估 | 中等 — 文件结构一致但内容改动量大 |
@@ -1,179 +0,0 @@
# 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 人天** | — |
@@ -1,149 +0,0 @@
# 优化清单可行性评估
> 对 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?建议新建表,便于查询和展示
@@ -1,126 +0,0 @@
# 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
@@ -1,40 +0,0 @@
# 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
@@ -1,64 +0,0 @@
# 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 完整流水线测试
- [ ] 生产环境部署文档
@@ -1,247 +0,0 @@
# 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 ✅
@@ -1,30 +0,0 @@
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:
@@ -1,3 +0,0 @@
node_modules/
dist/
.git/
@@ -1,24 +0,0 @@
# 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?
@@ -1,547 +0,0 @@
# 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*
@@ -1,13 +0,0 @@
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;"]
@@ -1,73 +0,0 @@
# 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...
},
},
])
```
@@ -1,22 +0,0 @@
{
"$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": {}
}
@@ -1,23 +0,0 @@
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,
},
},
])
@@ -1,12 +0,0 @@
<!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>
@@ -1,31 +0,0 @@
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
@@ -1,20 +0,0 @@
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
@@ -1,94 +0,0 @@
{
"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"
}
}
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -1 +0,0 @@
<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

Before

Width:  |  Height:  |  Size: 5.5 KiB

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1 +0,0 @@
<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

Before

Width:  |  Height:  |  Size: 9.0 KiB

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,42 +0,0 @@
#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;
}
@@ -1,97 +0,0 @@
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;
@@ -1,34 +0,0 @@
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'),
};
@@ -1,5 +0,0 @@
import request from './request';
export const dashboardApi = {
getStats: () => request.get<any, any>('/dashboard/stats'),
};
@@ -1,51 +0,0 @@
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'),
};
@@ -1,22 +0,0 @@
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 },
});
},
};
@@ -1,14 +0,0 @@
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`),
};
@@ -1,89 +0,0 @@
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;
}
@@ -1,38 +0,0 @@
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 }),
};
@@ -1,24 +0,0 @@
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');
},
};
@@ -1,9 +0,0 @@
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`),
};
@@ -1,15 +0,0 @@
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),
};
@@ -1,43 +0,0 @@
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 } }),
};
@@ -1,50 +0,0 @@
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;
@@ -1,37 +0,0 @@
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);
},
};
@@ -1,25 +0,0 @@
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),
};
@@ -1,11 +0,0 @@
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`),
};
@@ -1,139 +0,0 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { ChevronRight, Home } from 'lucide-react';
import { menuItems, menuSections } from '@/constants/menu';
interface BreadcrumbItem {
label: string;
path?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
}
export default function Breadcrumb({ items }: BreadcrumbProps) {
const navigate = useNavigate();
if (items.length === 0) return null;
return (
<nav aria-label="breadcrumb" className="flex items-center text-xs">
<ol className="flex items-center gap-1">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={index} className="flex items-center gap-1">
{index > 0 && (
<ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
)}
{isLast ? (
<span className="text-[var(--dg-text-primary)] font-medium">
{item.label}
</span>
) : item.path ? (
<button
type="button"
onClick={() => navigate(item.path!)}
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors inline-flex items-center gap-1 cursor-pointer"
>
{index === 0 && <Home className="w-3 h-3" />}
{item.label}
</button>
) : (
<span className="text-[var(--dg-text-secondary)]">
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
/**
* Hook that builds breadcrumb items based on the current route.
*
* It matches the current pathname against menuItems, then constructs:
* Home > Section label > Page label
*
* For detail/sub pages (e.g. /instances/:id, /tickets/:id, /tickets/create),
* it prepends the parent breadcrumb entries.
*/
export function useBreadcrumb(): { items: BreadcrumbItem[] } {
const location = useLocation();
const pathname = location.pathname;
const items: BreadcrumbItem[] = [];
// Find a direct menu item match by id (exact route)
const directMatch = menuItems.find(
(item) => item.id === pathname.replace(/^\//, '')
);
if (directMatch) {
// Exact menu match: Home > Section > Page
const section = menuSections.find((s) => s.id === directMatch.section);
items.push({ label: '首页', path: '/dashboard' });
if (section && section.id !== 'overview') {
items.push({ label: section.label });
}
items.push({ label: directMatch.label });
return { items };
}
// Parse the pathname for sub/detail pages
const segments = pathname.split('/').filter(Boolean); // e.g. ['instances', '123'] or ['tickets', 'create']
if (segments.length === 0) {
return { items: [{ label: '首页', path: '/dashboard' }] };
}
const rootSegment = segments[0];
const parentMenuItem = menuItems.find((item) => item.id === rootSegment);
if (!parentMenuItem) {
// Unknown route — just show Home
return { items: [{ label: '首页', path: '/dashboard' }] };
}
const section = menuSections.find((s) => s.id === parentMenuItem.section);
// Home
items.push({ label: '首页', path: '/dashboard' });
// Section (skip for overview section like dashboard)
if (section && section.id !== 'overview') {
items.push({ label: section.label });
}
// Parent page (clickable for detail pages)
if (segments.length > 1) {
items.push({ label: parentMenuItem.label, path: `/${rootSegment}` });
}
// Sub-page label
if (segments.length > 1) {
const subSegment = segments[1];
const isDetailPage = !isNaN(Number(subSegment));
if (isDetailPage) {
// Numeric ID — use a generic "详情" label
items.push({ label: '详情' });
} else {
// Named sub-pages like 'create'
const subPageLabels: Record<string, string> = {
create: '新建',
edit: '编辑',
};
items.push({ label: subPageLabels[subSegment] || subSegment });
}
} else {
// Single segment — this is the current page
items.push({ label: parentMenuItem.label });
}
return { items };
}
@@ -1,59 +0,0 @@
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>
);
}
@@ -1,276 +0,0 @@
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>
);
}
@@ -1,27 +0,0 @@
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>
);
}
@@ -1,53 +0,0 @@
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>
);
}
@@ -1,205 +0,0 @@
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>
);
}
@@ -1,55 +0,0 @@
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>
);
}
@@ -1,614 +0,0 @@
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>
);
}
@@ -1,65 +0,0 @@
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>
);
}
@@ -1,432 +0,0 @@
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;
@@ -1,42 +0,0 @@
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>
);
}
@@ -1,429 +0,0 @@
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>
</>
);
}
@@ -1,246 +0,0 @@
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>
);
}
@@ -1,547 +0,0 @@
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>
);
}
@@ -1,263 +0,0 @@
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>
);
}
@@ -1,362 +0,0 @@
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>
</>
);
}
@@ -1,479 +0,0 @@
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>
</>
);
}
@@ -1,267 +0,0 @@
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>
</>
);
}
@@ -1,505 +0,0 @@
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>
</>
);
}
@@ -1,332 +0,0 @@
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>
);
}
@@ -1,276 +0,0 @@
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>
);
}
@@ -1,64 +0,0 @@
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 }
@@ -1,155 +0,0 @@
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,
}
@@ -1,66 +0,0 @@
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 }
@@ -1,11 +0,0 @@
"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 }
@@ -1,51 +0,0 @@
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 }
@@ -1,46 +0,0 @@
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 }
@@ -1,109 +0,0 @@
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,
}
@@ -1,83 +0,0 @@
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,
}
@@ -1,62 +0,0 @@
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 }
@@ -1,220 +0,0 @@
"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 }
@@ -1,92 +0,0 @@
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,
}
@@ -1,239 +0,0 @@
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,
}
@@ -1,357 +0,0 @@
"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,
}
@@ -1,32 +0,0 @@
"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 }
@@ -1,31 +0,0 @@
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 }
@@ -1,182 +0,0 @@
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,
}
@@ -1,252 +0,0 @@
"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,
}
@@ -1,141 +0,0 @@
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,
}
@@ -1,135 +0,0 @@
"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,
}
@@ -1,255 +0,0 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
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-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-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 DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-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">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-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">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-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 gap-2 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 size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-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-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -1,104 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
@@ -1,246 +0,0 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
@@ -1,167 +0,0 @@
"use client"
import * as React from "react"
import type * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
@@ -1,44 +0,0 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
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 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
@@ -1,170 +0,0 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
@@ -1,75 +0,0 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
@@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className
)}
{...props}
/>
)
}
export { Input }
@@ -1,193 +0,0 @@
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"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
@@ -1,28 +0,0 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
@@ -1,24 +0,0 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -1,274 +0,0 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in 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-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-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 MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs 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">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs 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">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-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 flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-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-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff. Show more