merge: SQL平台代码v1 优化包合并 — 193条审核规则 + 16页面UI优化
后端新增 (Step 1): - mysql_audit_rules.py: 63条 MySQL审核规则 (DDL 36 + DML 9 + 命名 5 + 性能 5 + 安全 5) - redis_audit_rules.py: 43条 Redis审核规则 (KEY管理 10 + 命令安全 12 + 数据结构 8 + 性能 8 + 安全 5) - mongodb_audit_rules.py: 42条 MongoDB审核规则 (集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能) - postgres_audit_rules.py: 45条 PostgreSQL审核规则 (DDL 18 + DML 8 + DQL 6 + 安全 6 + 性能 7) 后端合并 (Step 2): - audit_engine_v2.py: 新增4个DB规则模块import (100+→200+规则) - audit_rule_seeds.py: 新增30条MongoDB种子规则 + 别名映射 - audit_rule.py: 新增数据库类型别名 (postgresql→postgres, pg→postgres, mongo→mongodb) 前端优化 (Step 4) — 16页面全覆盖: - InstancePage, WorkflowPage, TicketsPage, TicketCreatePage, TicketDetailPage - UsersPage, SlowQueryPage, DataDictionaryPage, Dashboard - InstanceGroupPage, AuditLogsPage, QueriesPage, AuditRulesPage, RolesPage - useAuth.tsx, SqlEditor.tsx 后端测试 895/895 通过, 前端 tsc --noEmit 通过
This commit is contained in:
1 parent
e1ba8b3327
commit
58eb78c987
24 files changed
+16330
-2222
No files matched your search
@@ -0,0 +1,198 @@
|
|||||||
|
# SQL平台代码v1 优化包对比报告
|
||||||
|
|
||||||
|
> 分析日期: 2026-05-23
|
||||||
|
> 分析对象: ~/Downloads/SQL平台代码v1/ (optimized + final)
|
||||||
|
> 当前项目: ~/devops/sql-platform/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、优化包概览
|
||||||
|
|
||||||
|
优化包包含两个版本:
|
||||||
|
- **sql-platform-optimized**: 中间优化版本(前 3 个 Stage 的产出)
|
||||||
|
- **sql-platform-final**: 最终版本(在 optimized 基础上新增数据库审核规则拆分)
|
||||||
|
|
||||||
|
两者基于 4 阶段优化计划(plan.md):
|
||||||
|
1. **Stage 1** — 后端关键缺陷修复
|
||||||
|
2. **Stage 2** — 前端界面全方位优化(4 组并行)
|
||||||
|
3. **Stage 3** — 权限逻辑 & 安全增强
|
||||||
|
4. **Stage 4** — 构建验证 & 最终集成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、项目评估摘要(来自 PROJECT_EVALUATION.md)
|
||||||
|
|
||||||
|
| 维度 | 评分 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 功能完成度 | 8.5/10 | 核心功能全覆盖:工单全生命周期、SQL审核引擎、规则CRUD、审计日志 |
|
||||||
|
| 代码质量 | 8.5/10 | TDD驱动、128测试、模块化清晰 |
|
||||||
|
| 安全性 | 9.0/10 | AES加密、bcrypt、JWT、AST防注入、权限矩阵 |
|
||||||
|
| 测试覆盖 | 8.5/10 | 128 tests 覆盖11模块 |
|
||||||
|
| 生产就绪度 | 8.0/10 | Docker部署、Alembic迁移、GitLab CI/CD |
|
||||||
|
| 用户体验 | 7.5/10 | 缺国际化、深色模式 |
|
||||||
|
| **综合评分** | **8.1/10** | 目标 9.0+ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、路线图优化项(来自 ROADMAP.md)
|
||||||
|
|
||||||
|
### 已完成的优化
|
||||||
|
|
||||||
|
| 分类 | 优化项 | 规模 |
|
||||||
|
|------|--------|------|
|
||||||
|
| **P0 数据库全景支持** | 统一查询服务 (MySQL/PG/Redis/MongoDB) | query_service.py 完整4种Handler |
|
||||||
|
| | Redis 只读命令白名单 | 40+ 只读命令 + 子命令验证 |
|
||||||
|
| | MongoDB 只读操作白名单 | find/findOne/countDocuments/distinct/aggregate |
|
||||||
|
| | 工单执行多数据库分发 | ticket_executor.py 多DB执行 |
|
||||||
|
| | MongoDB 连接器 + 前端管理页面 | mongo_connector.py 780行 |
|
||||||
|
| **P1 审计闭环** | 查询历史服务 | query_history_service.py |
|
||||||
|
| | SQL 助手 | sql_assistant.py (格式化/风险高亮/EXPLAIN) |
|
||||||
|
| | 审计规则种子 | MySQL 18规则 + PG 12规则 + Redis 8规则 |
|
||||||
|
| | 影响分析 | impact_analyzer.py |
|
||||||
|
| | 回滚生成 | rollback_generator.py |
|
||||||
|
| | Schema 漂移检测 | schema_drift_detector.py |
|
||||||
|
| **P2 高级功能** | 数据脱敏 | data_masker.py + masking_rule CRUD |
|
||||||
|
| | 通知服务 | 钉钉/飞书/邮件 SMTP |
|
||||||
|
| | 备份管理 | backup_executor.py |
|
||||||
|
| | 工作流引擎 | workflow_engine.py + workflow_seeds.py |
|
||||||
|
| | 慢日志收集 | slow_log_collector.py |
|
||||||
|
| | 连接池 | connection_pool.py |
|
||||||
|
|
||||||
|
### 待完成项
|
||||||
|
|
||||||
|
- [ ] 执行前后数据快照对比可视化(前端)
|
||||||
|
- [ ] 快捷查询模板(10个预置 + 自定义共享)
|
||||||
|
- [ ] SQL 格式化前端集成
|
||||||
|
- [ ] 查询结果格式化展示优化
|
||||||
|
- [ ] SQL 语法高亮编辑器
|
||||||
|
- [ ] Slow query 前端集成
|
||||||
|
- [ ] Dashboard 数据源健康概览
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、版本差异分析
|
||||||
|
|
||||||
|
### 4.1 optimized vs final 差异
|
||||||
|
|
||||||
|
**final 在 optimized 基础上新增了 4 个数据库专用审核规则文件:**
|
||||||
|
|
||||||
|
| 新增文件 | 行数 | 规则数量 | 覆盖范围 |
|
||||||
|
|----------|------|----------|----------|
|
||||||
|
| `mysql_audit_rules.py` | 2,101 行 | 63条 | DDL 36条 + DML 9条 + 命名 5条 + 性能 5条 + 安全 5条 |
|
||||||
|
| `redis_audit_rules.py` | 1,863 行 | 43条 | KEY管理10条 + 命令安全12条 + 数据结构8条 + 性能8条 + 安全5条 |
|
||||||
|
| `mongodb_audit_rules.py` | 1,638 行 | 42条 | 集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能 |
|
||||||
|
| `postgres_audit_rules.py` | 1,289 行 | 45条 | DDL 18条 + DML 8条 + DQL 6条 + 安全6条 + 性能7条 |
|
||||||
|
|
||||||
|
**final 还修改了 5 个关联文件:**
|
||||||
|
- `audit_engine_v2.py` — 新增 4 个 DB 规则模块的 import,规则数量从 100+ 升级到 200+
|
||||||
|
- `audit_rule_seeds.py` — 新增 MongoDB 种子规则 + 别名映射
|
||||||
|
- `audit_rule.py` (endpoint) — 新增数据库类型别名支持(postgresql→postgres, mongo→mongodb)
|
||||||
|
- `AuditRulesPage.tsx` — 前端新增 MongoDB/Redis 相关关键词识别
|
||||||
|
|
||||||
|
### 4.2 optimized vs 当前项目差异
|
||||||
|
|
||||||
|
**文件结构:完全一致**(无新增/缺失文件,仅 `__pycache__` 差异)
|
||||||
|
|
||||||
|
**内容差异:共 25 个文件有改动**
|
||||||
|
|
||||||
|
#### 后端 (9 个文件)
|
||||||
|
|
||||||
|
| 文件 | 改动行数 | 改动性质 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `services/workflow_engine.py` | 1,284 | 工作流引擎大幅扩展 |
|
||||||
|
| `services/query_service.py` | 720 | 查询服务优化/增强 |
|
||||||
|
| `services/audit_engine.py` | 858 | 审核引擎 V1 优化 |
|
||||||
|
| `services/data_masker.py` | 668 | 数据脱敏模块增强 |
|
||||||
|
| `services/ticket_executor.py` | 530 | 工单执行器改进 |
|
||||||
|
| `models/workflow.py` | 331 | 工作流模型扩展 |
|
||||||
|
| `models/user.py` | 278 | 用户模型扩展 |
|
||||||
|
| `endpoints/ticket.py` | 540 | 工单 API 改进 |
|
||||||
|
| `endpoints/query.py` | 256 | 查询 API 改进 |
|
||||||
|
|
||||||
|
#### 前端 (16 个文件)
|
||||||
|
|
||||||
|
| 文件 | 改动行数 | 改动性质 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `pages/InstancePage.tsx` | 1,372 | 实例管理页大幅优化 |
|
||||||
|
| `pages/WorkflowPage.tsx` | 971 | 工作流页面优化 |
|
||||||
|
| `pages/TicketsPage.tsx` | 960 | 工单列表页优化 |
|
||||||
|
| `pages/TicketCreatePage.tsx` | 868 | 工单创建页优化 |
|
||||||
|
| `pages/UsersPage.tsx` | 804 | 用户管理页优化 |
|
||||||
|
| `pages/SlowQueryPage.tsx` | 703 | 慢查询页优化 |
|
||||||
|
| `pages/TicketDetailPage.tsx` | 719 | 工单详情页优化 |
|
||||||
|
| `pages/DataDictionaryPage.tsx` | 650 | 数据字典页优化 |
|
||||||
|
| `pages/Dashboard.tsx` | 636 | 仪表盘优化 |
|
||||||
|
| `pages/InstanceGroupPage.tsx` | 631 | 实例组页优化 |
|
||||||
|
| `pages/AuditLogsPage.tsx` | 600 | 审计日志页优化 |
|
||||||
|
| `pages/QueriesPage.tsx` | 601 | 查询页优化 |
|
||||||
|
| `pages/AuditRulesPage.tsx` | 584 | 审核规则页优化 |
|
||||||
|
| `pages/RolesPage.tsx` | 519 | 角色管理页优化 |
|
||||||
|
| `hooks/useAuth.tsx` | 391 | 认证 hook 优化 |
|
||||||
|
| `components/SqlEditor.tsx` | 3 | SQL编辑器微调 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、对我们当前项目有价值的改动
|
||||||
|
|
||||||
|
### 🔴 高价值(建议立即采纳)
|
||||||
|
|
||||||
|
1. **数据库专用审核规则(final 独有)** — 193条数据库专用规则
|
||||||
|
- `mysql_audit_rules.py` (63条) — 生产级 MySQL 审核覆盖
|
||||||
|
- `redis_audit_rules.py` (43条) — Redis 命令安全检测
|
||||||
|
- `mongodb_audit_rules.py` (42条) — MongoDB 操作审核
|
||||||
|
- `postgres_audit_rules.py` (45条) — PostgreSQL 特有语法检查
|
||||||
|
- 关联修改:`audit_engine_v2.py`, `audit_rule_seeds.py`, `audit_rule.py`
|
||||||
|
|
||||||
|
2. **前端页面全面优化** — 16 个页面的 UI/UX 改进
|
||||||
|
- 所有核心页面均有数百行改动,估计是界面交互、组件化、错误处理方面的增强
|
||||||
|
|
||||||
|
3. **后端核心服务优化** — 9 个关键文件
|
||||||
|
- `workflow_engine.py` (1,284 行改动) — 工作流引擎重大升级
|
||||||
|
- `query_service.py` (720 行改动) — 查询服务改进
|
||||||
|
- `ticket_executor.py` (530 行改动) — 工单执行器改进
|
||||||
|
|
||||||
|
### 🟡 中等价值
|
||||||
|
|
||||||
|
4. **数据脱敏增强** — `data_masker.py` 668 行改动
|
||||||
|
5. **用户/工作流模型扩展** — `user.py` + `workflow.py` 模型字段扩展
|
||||||
|
6. **认证 hook 优化** — `useAuth.tsx` 391 行改动
|
||||||
|
|
||||||
|
### 🟢 待评估
|
||||||
|
|
||||||
|
7. **TDD 测试计划** — 当前 128 tests,Phase 4 安全测试未完成
|
||||||
|
8. **路线图待完成项** — 快照对比、查询模板、前端集成等
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、建议操作
|
||||||
|
|
||||||
|
### 立即可做
|
||||||
|
```bash
|
||||||
|
# 将 final 版本的4个新审核规则文件复制到我们的项目
|
||||||
|
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/mysql_audit_rules.py ~/devops/sql-platform/backend/app/services/
|
||||||
|
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/redis_audit_rules.py ~/devops/sql-platform/backend/app/services/
|
||||||
|
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/mongodb_audit_rules.py ~/devops/sql-platform/backend/app/services/
|
||||||
|
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/postgres_audit_rules.py ~/devops/sql-platform/backend/app/services/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 需要逐步验证
|
||||||
|
- 逐个对比 25 个差异文件的改动内容,评估每项改动的兼容性
|
||||||
|
- 运行现有测试确保不引入回归
|
||||||
|
- 前端改动需逐一构建验证
|
||||||
|
|
||||||
|
### 建议策略
|
||||||
|
1. 先合并 `final` 版本新增的 4 个审核规则文件 + 关联修改(低风险,纯新增)
|
||||||
|
2. 逐页面合并前端优化(每合并一个页面后构建验证)
|
||||||
|
3. 最后处理后端核心服务改动(需配合测试)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、总结
|
||||||
|
|
||||||
|
| 项目 | 数据 |
|
||||||
|
|------|------|
|
||||||
|
| 优化包总改动文件数 (vs 我们) | **29 个** (25差异 + 4新增) |
|
||||||
|
| 优化包总新增代码量 | ~6,900 行 (4个新规则文件) + 大量行级改动 |
|
||||||
|
| 文件结构差异 | **无** (完全一致的目录结构) |
|
||||||
|
| 最有价值的改动 | **193条数据库专用审核规则** (final 独有) |
|
||||||
|
| 风险评估 | 中等 — 文件结构一致但内容改动量大 |
|
||||||
@@ -31,7 +31,10 @@ async def list_audit_rules(
|
|||||||
if severity:
|
if severity:
|
||||||
stmt = stmt.where(AuditRule.severity == severity)
|
stmt = stmt.where(AuditRule.severity == severity)
|
||||||
if db_type:
|
if db_type:
|
||||||
stmt = stmt.where((AuditRule.db_type == db_type) | (AuditRule.db_type == "all"))
|
# 支持类型别名: postgresql -> postgres, mongo -> mongodb
|
||||||
|
db_type_aliases = {"postgresql": "postgres", "pg": "postgres", "mongo": "mongodb"}
|
||||||
|
resolved = db_type_aliases.get(db_type.lower(), db_type.lower())
|
||||||
|
stmt = stmt.where((AuditRule.db_type == resolved) | (AuditRule.db_type == db_type) | (AuditRule.db_type == "all"))
|
||||||
if is_enabled is not None:
|
if is_enabled is not None:
|
||||||
stmt = stmt.where(AuditRule.is_enabled == is_enabled)
|
stmt = stmt.where(AuditRule.is_enabled == is_enabled)
|
||||||
|
|
||||||
@@ -80,8 +83,23 @@ async def seed_audit_rules(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(require_role("admin")),
|
||||||
):
|
):
|
||||||
"""批量初始化预设审核规则种子数据。"""
|
"""批量初始化预设审核规则种子数据。
|
||||||
|
|
||||||
|
支持4种数据库类型:
|
||||||
|
- mysql: MySQL 专用规则 (60+ 条)
|
||||||
|
- postgres: PostgreSQL 专用规则 (45+ 条)
|
||||||
|
- redis: Redis 专用规则 (43+ 条)
|
||||||
|
- mongodb: MongoDB 专用规则 (42+ 条)
|
||||||
|
"""
|
||||||
db_type_lower = db_type.lower()
|
db_type_lower = db_type.lower()
|
||||||
|
# 别名映射
|
||||||
|
db_type_aliases = {
|
||||||
|
"postgresql": "postgres",
|
||||||
|
"pg": "postgres",
|
||||||
|
"mongo": "mongodb",
|
||||||
|
}
|
||||||
|
db_type_lower = db_type_aliases.get(db_type_lower, db_type_lower)
|
||||||
|
|
||||||
if db_type_lower not in SEED_RULES_MAP:
|
if db_type_lower not in SEED_RULES_MAP:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""SQL 审核引擎 V2 — 可配置规则引擎,支持 100+ 审核规则。
|
"""SQL 审核引擎 V2 — 可配置规则引擎,支持 200+ 审核规则。
|
||||||
|
|
||||||
对标 Yearning/Archery/Bytebase 的审核能力,支持:
|
对标 Yearning/Archery/Bytebase 的审核能力,支持:
|
||||||
- DDL 规则 (建表规范、索引规范、字段规范)
|
- DDL 规则 (建表规范、索引规范、字段规范)
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
- 命名规范 (表名/字段名/索引名规范)
|
- 命名规范 (表名/字段名/索引名规范)
|
||||||
- 性能规则 (全表扫描、索引使用、大 IN 子句)
|
- 性能规则 (全表扫描、索引使用、大 IN 子句)
|
||||||
- 安全规则 (敏感字段、SQL 注入风险、密码硬编码)
|
- 安全规则 (敏感字段、SQL 注入风险、密码硬编码)
|
||||||
|
- MySQL 专用规则 (62+ 条,覆盖 MySQL 特有语法)
|
||||||
|
- PostgreSQL 专用规则 (45+ 条,覆盖 PG 特有语法)
|
||||||
|
- Redis 专用规则 (43+ 条,覆盖 Redis 命令和数据结构)
|
||||||
|
- MongoDB 专用规则 (42+ 条,覆盖 MongoDB 查询和聚合)
|
||||||
|
|
||||||
使用方式:
|
使用方式:
|
||||||
engine = SQLAuditEngineV2(db_session)
|
engine = SQLAuditEngineV2(db_session)
|
||||||
@@ -1681,3 +1685,15 @@ class SQLAuditEngineV2:
|
|||||||
def invalidate_cache(self) -> None:
|
def invalidate_cache(self) -> None:
|
||||||
"""清除规则缓存,下次审核重新加载。"""
|
"""清除规则缓存,下次审核重新加载。"""
|
||||||
self._rules_cache = None
|
self._rules_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 延迟导入各数据库专用规则(副作用:注册到 _RULE_REGISTRY)────
|
||||||
|
# 放在文件末尾以避免循环导入(规则模块需要 import register_rule 等)
|
||||||
|
# MySQL 规则 (62+ 条)
|
||||||
|
from app.services.mysql_audit_rules import * # noqa: F401,F403
|
||||||
|
# PostgreSQL 规则 (45+ 条)
|
||||||
|
from app.services.postgres_audit_rules import * # noqa: F401,F403
|
||||||
|
# Redis 规则 (43+ 条)
|
||||||
|
from app.services.redis_audit_rules import * # noqa: F401,F403
|
||||||
|
# MongoDB 规则 (42+ 条)
|
||||||
|
from app.services.mongodb_audit_rules import * # noqa: F401,F403
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Preset audit rule seed data for MySQL, PostgreSQL, and Redis."""
|
"""Preset audit rule seed data for MySQL, PostgreSQL, Redis, and MongoDB."""
|
||||||
|
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
@@ -270,6 +270,320 @@ POSTGRES_SEED_RULES: List[Dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ── MongoDB 预设规则 ────────────────────────────────────────────
|
||||||
|
MONGODB_SEED_RULES: List[Dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"name": "MongoDB集合命名规范(snake_case)",
|
||||||
|
"description": "MongoDB 集合名应使用小写下划线命名(snake_case)",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": r"^[a-z][a-z0-9_]*$",
|
||||||
|
"value": None,
|
||||||
|
"message": "集合名不符合 snake_case 命名规范,应使用小写下划线格式",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB集合名最大长度限制",
|
||||||
|
"description": "MongoDB 集合名最大长度为 64 字节",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "64",
|
||||||
|
"message": "集合名长度超过64字节,请缩短集合名",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止删除集合",
|
||||||
|
"description": "生产环境禁止执行删除集合操作",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止执行删除集合操作,数据将永久丢失",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止删除数据库",
|
||||||
|
"description": "禁止执行 dropDatabase 操作",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止执行 dropDatabase 操作,将删除整个数据库",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止删除索引",
|
||||||
|
"description": "禁止执行删除索引操作",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止执行删除索引操作,可能导致查询性能急剧下降",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB推荐Schema Validator",
|
||||||
|
"description": "创建集合时建议使用 JSON Schema Validator",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "建议为集合添加 JSON Schema Validator 以约束文档结构",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB查询必须带filter",
|
||||||
|
"description": "find/delete/update 操作必须包含 filter 条件",
|
||||||
|
"rule_type": "DQL",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "查询/更新/删除操作缺少 filter 条件,将操作全集合数据",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB推荐指定projection",
|
||||||
|
"description": "查询推荐指定 projection 限制返回字段",
|
||||||
|
"rule_type": "DQL",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "查询未指定 projection,建议添加 projection 限制返回字段",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止$where/$function",
|
||||||
|
"description": "禁止使用 $where 和 $function 执行 JavaScript",
|
||||||
|
"rule_type": "DQL",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止使用 $where/$function 执行 JavaScript,存在严重安全风险",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止前缀模糊匹配",
|
||||||
|
"description": "禁止正则表达式前缀模糊匹配,无法有效使用索引",
|
||||||
|
"rule_type": "DQL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": r'/\^|"\$regex"\s*:\s*"\^',
|
||||||
|
"value": None,
|
||||||
|
"message": "前缀模糊匹配无法有效使用索引,建议调整查询策略",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB查询返回结果上限",
|
||||||
|
"description": "查询返回结果建议不超过 10000 条",
|
||||||
|
"rule_type": "DQL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "10000",
|
||||||
|
"message": "查询未设置 limit 或 limit 超过 10000 条,建议分页查询",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止聚合$out/$merge",
|
||||||
|
"description": "聚合管道禁止 $out/$merge 写操作",
|
||||||
|
"rule_type": "DQL",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": r'"\$(out|merge)"',
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止在聚合管道中使用 $out/$merge 阶段,可能覆盖现有集合",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB聚合管道阶段数上限",
|
||||||
|
"description": "聚合管道阶段数建议不超过 20 个",
|
||||||
|
"rule_type": "DQL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "20",
|
||||||
|
"message": "聚合管道阶段数超过20个,请优化查询结构",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB$unwind大数据量警告",
|
||||||
|
"description": "$unwind 展开数组可能导致文档数量急剧膨胀",
|
||||||
|
"rule_type": "DQL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": r'"\$unwind"',
|
||||||
|
"value": None,
|
||||||
|
"message": "$unwind 可能急剧增加文档数量,请确认数组字段元素数量",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB批量写入上限",
|
||||||
|
"description": "批量写入建议不超过 1000 条",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "1000",
|
||||||
|
"message": "批量写入超过1000条,请分批写入",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDBupdateMany必须带条件",
|
||||||
|
"description": "updateMany 必须包含 filter 条件",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "updateMany 操作缺少 filter 条件,将更新全集合文档",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDBdeleteMany必须带条件",
|
||||||
|
"description": "deleteMany 必须包含 filter 条件",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "deleteMany 操作缺少 filter 条件,将删除全集合文档",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止unset关键字段",
|
||||||
|
"description": "禁止 $unset 关键字段(_id, create_time等)",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止使用 $unset 删除关键字段,可能导致数据完整性问题",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止重命名集合",
|
||||||
|
"description": "禁止执行 renameCollection 操作",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止重命名集合,可能导致应用连接失败",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止绕过文档验证",
|
||||||
|
"description": "禁止 bypassDocumentValidation: true",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止设置 bypassDocumentValidation 绕过 Schema 验证",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB单文档大小限制",
|
||||||
|
"description": "单文档大小建议不超过 10MB 警告阈值",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "10485760",
|
||||||
|
"message": "文档大小超过10MB警告阈值,建议拆分或精简文档",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止嵌套深度超过5层",
|
||||||
|
"description": "文档嵌套深度建议不超过 5 层",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "5",
|
||||||
|
"message": "文档嵌套深度超过5层,建议优化文档结构",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止db.eval()",
|
||||||
|
"description": "禁止 db.eval() 执行 JavaScript",
|
||||||
|
"rule_type": "security",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": r'\bdb\.eval\s*\(',
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止 db.eval() 执行 JavaScript,存在严重安全风险",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止操作系统集合",
|
||||||
|
"description": "禁止操作系统集合",
|
||||||
|
"rule_type": "security",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止操作系统集合,存在安全风险",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB禁止MapReduce",
|
||||||
|
"description": "禁止使用 mapReduce,使用聚合管道替代",
|
||||||
|
"rule_type": "security",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": r'\bmapReduce\b',
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止 mapReduce,请使用聚合管道替代",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB防止$cmd注入",
|
||||||
|
"description": "禁止直接操作 $cmd 集合",
|
||||||
|
"rule_type": "security",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "禁止直接操作 $cmd 集合,存在注入攻击风险",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB敏感操作审计日志",
|
||||||
|
"description": "删除、更新等敏感操作应记录审计日志",
|
||||||
|
"rule_type": "security",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "敏感操作应确保已记录审计日志,便于安全追踪",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB缺少索引查询警告",
|
||||||
|
"description": "查询条件字段应创建索引",
|
||||||
|
"rule_type": "performance",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "查询条件字段缺少索引可能导致全集合扫描",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB全集合扫描警告",
|
||||||
|
"description": "缺少 filter 条件将导致全集合扫描",
|
||||||
|
"rule_type": "performance",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "查询缺少过滤条件,将导致全集合扫描",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB复合索引字段顺序建议",
|
||||||
|
"description": "复合索引字段顺序应遵循 ESR 规则",
|
||||||
|
"rule_type": "performance",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "建议遵循 ESR 规则设计复合索引: 等值 > 排序 > 范围",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MongoDB分片键选择建议",
|
||||||
|
"description": "分片集合应选择高基数字段作为分片键",
|
||||||
|
"rule_type": "performance",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "mongodb",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "建议选择高基数均匀分布字段作为分片键",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
# ── Redis 预设规则 ──────────────────────────────────────────────
|
# ── Redis 预设规则 ──────────────────────────────────────────────
|
||||||
REDIS_SEED_RULES: List[Dict[str, Any]] = [
|
REDIS_SEED_RULES: List[Dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
@@ -319,4 +633,5 @@ SEED_RULES_MAP = {
|
|||||||
"mysql": MYSQL_SEED_RULES,
|
"mysql": MYSQL_SEED_RULES,
|
||||||
"postgres": POSTGRES_SEED_RULES,
|
"postgres": POSTGRES_SEED_RULES,
|
||||||
"redis": REDIS_SEED_RULES,
|
"redis": REDIS_SEED_RULES,
|
||||||
|
"mongodb": MONGODB_SEED_RULES,
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@codemirror/language';
|
} from '@codemirror/language';
|
||||||
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||||
|
import { rectangularSelection } from '@codemirror/rectangular-selection';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -296,6 +296,7 @@ const SqlEditor: React.FC<SqlEditorProps> = ({
|
|||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
closeBrackets(),
|
closeBrackets(),
|
||||||
highlightSelectionMatches(),
|
highlightSelectionMatches(),
|
||||||
|
rectangularSelection(),
|
||||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
appTheme,
|
appTheme,
|
||||||
oneDark,
|
oneDark,
|
||||||
|
|||||||
+368
-23
@@ -1,9 +1,28 @@
|
|||||||
import { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
import { menuItems, currentUser as defaultUser } from '@/lib/mock';
|
import { menuItems, currentUser as defaultUser } from '@/lib/mock';
|
||||||
import { authApi } from '@/api/auth';
|
import { authApi } from '@/api/auth';
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'dba' | 'developer' | 'guest';
|
export type UserRole = 'admin' | 'dba' | 'developer' | 'guest';
|
||||||
|
|
||||||
|
/** 细粒度权限类型 */
|
||||||
|
export type PermissionAction =
|
||||||
|
| 'ticket.create' | 'ticket.approve' | 'ticket.execute' | 'ticket.rollback' | 'ticket.delete'
|
||||||
|
| 'query.create' | 'query.export'
|
||||||
|
| 'instance.add' | 'instance.edit' | 'instance.delete'
|
||||||
|
| 'user.manage' | 'role.manage' | 'rule.manage'
|
||||||
|
| 'workflow.manage' | 'config.manage'
|
||||||
|
| 'announcement.manage' | 'auto-task.manage' | 'instance-group.manage';
|
||||||
|
|
||||||
|
/** 数据源级权限 */
|
||||||
|
export interface DataSourcePermission {
|
||||||
|
instanceId: number;
|
||||||
|
database?: string;
|
||||||
|
tables?: string[];
|
||||||
|
columns?: Record<string, string[]>;
|
||||||
|
actions: ('read' | 'write' | 'delete' | 'export')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扩展的用户信息 */
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -11,6 +30,22 @@ export interface AuthUser {
|
|||||||
role: UserRole;
|
role: UserRole;
|
||||||
roleName: string;
|
roleName: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
/** 用户组ID列表 */
|
||||||
|
groupIds?: number[];
|
||||||
|
/** 细粒度数据源权限 */
|
||||||
|
dataSourcePermissions?: DataSourcePermission[];
|
||||||
|
/** 自定义权限(覆盖角色默认权限) */
|
||||||
|
customPermissions?: Record<string, boolean>;
|
||||||
|
/** 权限最后更新时间 */
|
||||||
|
permissionsUpdatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限缓存结构 */
|
||||||
|
interface PermissionCache {
|
||||||
|
menu: Map<string, boolean>;
|
||||||
|
action: Map<string, boolean>;
|
||||||
|
dataSource: Map<string, boolean>;
|
||||||
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
@@ -19,12 +54,35 @@ interface AuthContextType {
|
|||||||
login: (username: string, password: string) => Promise<boolean>;
|
login: (username: string, password: string) => Promise<boolean>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
switchRole: (role: UserRole) => void;
|
switchRole: (role: UserRole) => void;
|
||||||
|
/** 菜单级权限检查(带缓存) */
|
||||||
hasPermission: (menuId: string) => boolean;
|
hasPermission: (menuId: string) => boolean;
|
||||||
|
/** 操作级权限检查(带缓存) */
|
||||||
|
hasActionPermission: (action: PermissionAction | string) => boolean;
|
||||||
|
/** 数据源级权限检查 */
|
||||||
|
hasDataSourcePermission: (
|
||||||
|
instanceId: number,
|
||||||
|
action: 'read' | 'write' | 'delete' | 'export',
|
||||||
|
database?: string,
|
||||||
|
table?: string
|
||||||
|
) => boolean;
|
||||||
|
/** 批量检查数据源权限 */
|
||||||
|
checkDataSourcePermissions: (
|
||||||
|
instanceId: number,
|
||||||
|
actions: ('read' | 'write' | 'delete' | 'export')[],
|
||||||
|
database?: string,
|
||||||
|
table?: string
|
||||||
|
) => Record<string, boolean>;
|
||||||
|
/** 刷新用户权限 */
|
||||||
|
refreshPermissions: () => Promise<void>;
|
||||||
|
/** 清除权限缓存 */
|
||||||
|
clearPermissionCache: () => void;
|
||||||
visibleMenuItems: typeof menuItems;
|
visibleMenuItems: typeof menuItems;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isDBA: boolean;
|
isDBA: boolean;
|
||||||
isDeveloper: boolean;
|
isDeveloper: boolean;
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
|
/** 是否拥有指定角色或以上级别权限 */
|
||||||
|
hasRoleLevel: (minRole: UserRole) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleNameMap: Record<UserRole, string> = {
|
const roleNameMap: Record<UserRole, string> = {
|
||||||
@@ -34,6 +92,14 @@ const roleNameMap: Record<UserRole, string> = {
|
|||||||
guest: '访客',
|
guest: '访客',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 角色权重映射,用于权限级别比较 */
|
||||||
|
const roleWeightMap: Record<UserRole, number> = {
|
||||||
|
admin: 100,
|
||||||
|
dba: 80,
|
||||||
|
developer: 60,
|
||||||
|
guest: 20,
|
||||||
|
};
|
||||||
|
|
||||||
const routePermissionMap: Record<string, UserRole[]> = {
|
const routePermissionMap: Record<string, UserRole[]> = {
|
||||||
dashboard: ['admin', 'dba', 'developer', 'guest'],
|
dashboard: ['admin', 'dba', 'developer', 'guest'],
|
||||||
instances: ['admin', 'dba'],
|
instances: ['admin', 'dba'],
|
||||||
@@ -55,7 +121,7 @@ export const actionPermissionMap: Record<string, UserRole[]> = {
|
|||||||
'ticket.execute': ['admin', 'dba'],
|
'ticket.execute': ['admin', 'dba'],
|
||||||
'ticket.rollback': ['admin', 'dba'],
|
'ticket.rollback': ['admin', 'dba'],
|
||||||
'ticket.delete': ['admin'],
|
'ticket.delete': ['admin'],
|
||||||
'query.create': ['admin', 'dba', 'developer'],
|
'query.create': ['admin', 'dba', 'developer', 'guest'],
|
||||||
'query.export': ['admin', 'dba', 'developer'],
|
'query.export': ['admin', 'dba', 'developer'],
|
||||||
'instance.add': ['admin', 'dba'],
|
'instance.add': ['admin', 'dba'],
|
||||||
'instance.edit': ['admin', 'dba'],
|
'instance.edit': ['admin', 'dba'],
|
||||||
@@ -70,6 +136,11 @@ export const actionPermissionMap: Record<string, UserRole[]> = {
|
|||||||
'instance-group.manage': ['admin'],
|
'instance-group.manage': ['admin'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 权限变更事件名称 */
|
||||||
|
const PERMISSION_CHANGE_EVENT = 'auth:permissions_changed';
|
||||||
|
/** 缓存有效期(毫秒) */
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5分钟
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
@@ -80,56 +151,284 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
role: defaultUser.role,
|
role: defaultUser.role,
|
||||||
roleName: defaultUser.roleName,
|
roleName: defaultUser.roleName,
|
||||||
avatar: defaultUser.avatar,
|
avatar: defaultUser.avatar,
|
||||||
|
groupIds: [],
|
||||||
|
dataSourcePermissions: [],
|
||||||
|
customPermissions: {},
|
||||||
});
|
});
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(() => !!localStorage.getItem('token'));
|
const [isLoggedIn, setIsLoggedIn] = useState(() => !!localStorage.getItem('token'));
|
||||||
|
|
||||||
|
/** 权限缓存引用 */
|
||||||
|
const permissionCacheRef = useRef<PermissionCache>({
|
||||||
|
menu: new Map(),
|
||||||
|
action: new Map(),
|
||||||
|
dataSource: new Map(),
|
||||||
|
timestamp: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 权限变更监听器引用 */
|
||||||
|
const listenersRef = useRef<Set<(user: AuthUser) => void>>(new Set());
|
||||||
|
|
||||||
|
/** 检查缓存是否有效 */
|
||||||
|
const isCacheValid = useCallback((): boolean => {
|
||||||
|
return Date.now() - permissionCacheRef.current.timestamp < CACHE_TTL;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 清除权限缓存 */
|
||||||
|
const clearPermissionCache = useCallback(() => {
|
||||||
|
permissionCacheRef.current.menu.clear();
|
||||||
|
permissionCacheRef.current.action.clear();
|
||||||
|
permissionCacheRef.current.dataSource.clear();
|
||||||
|
permissionCacheRef.current.timestamp = 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 触发权限变更事件 */
|
||||||
|
const emitPermissionChange = useCallback((updatedUser: AuthUser) => {
|
||||||
|
listenersRef.current.forEach(listener => listener(updatedUser));
|
||||||
|
// 触发自定义事件,供其他组件监听
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent(PERMISSION_CHANGE_EVENT, { detail: updatedUser }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 注册权限变更监听器 */
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key === 'token') {
|
||||||
|
if (!e.newValue) {
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
clearPermissionCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
|
}, [clearPermissionCache]);
|
||||||
|
|
||||||
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await authApi.login(username, password);
|
const res = await authApi.login(username, password);
|
||||||
localStorage.setItem('token', res.access_token);
|
localStorage.setItem('token', res.access_token);
|
||||||
|
|
||||||
const u = res.user;
|
const u = res.user;
|
||||||
const role = u.role as UserRole;
|
const role = u.role as UserRole;
|
||||||
setUser({
|
|
||||||
|
// 解析后端返回的细粒度权限
|
||||||
|
const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions
|
||||||
|
? JSON.parse(u.data_source_permissions)
|
||||||
|
: [];
|
||||||
|
const customPermissions: Record<string, boolean> = u.custom_permissions
|
||||||
|
? JSON.parse(u.custom_permissions)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const newUser: AuthUser = {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
email: u.email || '',
|
email: u.email || '',
|
||||||
role,
|
role,
|
||||||
roleName: roleNameMap[role] || role,
|
roleName: roleNameMap[role] || role,
|
||||||
avatar: u.display_name?.charAt(0) || username.charAt(0).toUpperCase(),
|
avatar: u.display_name?.charAt(0) || username.charAt(0).toUpperCase(),
|
||||||
});
|
groupIds: u.group_ids || [],
|
||||||
|
dataSourcePermissions,
|
||||||
|
customPermissions,
|
||||||
|
permissionsUpdatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(newUser);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
|
clearPermissionCache();
|
||||||
|
emitPermissionChange(newUser);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [clearPermissionCache, emitPermissionChange]);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
}, []);
|
clearPermissionCache();
|
||||||
|
setUser({
|
||||||
|
id: defaultUser.id,
|
||||||
|
username: defaultUser.username,
|
||||||
|
email: defaultUser.email,
|
||||||
|
role: defaultUser.role,
|
||||||
|
roleName: defaultUser.roleName,
|
||||||
|
avatar: defaultUser.avatar,
|
||||||
|
groupIds: [],
|
||||||
|
dataSourcePermissions: [],
|
||||||
|
customPermissions: {},
|
||||||
|
});
|
||||||
|
emitPermissionChange({} as AuthUser);
|
||||||
|
}, [clearPermissionCache, emitPermissionChange]);
|
||||||
|
|
||||||
const switchRole = useCallback((role: UserRole) => {
|
const switchRole = useCallback((role: UserRole) => {
|
||||||
setUser(prev => ({
|
setUser(prev => {
|
||||||
...prev,
|
const newUser: AuthUser = {
|
||||||
role,
|
...prev,
|
||||||
roleName: roleNameMap[role],
|
role,
|
||||||
username: role === 'admin' ? 'admin' : role === 'dba' ? 'zhangsan' : role === 'developer' ? 'wangwu' : 'visitor',
|
roleName: roleNameMap[role],
|
||||||
email: `${role}@company.com`,
|
username: role === 'admin' ? 'admin' : role === 'dba' ? 'zhangsan' : role === 'developer' ? 'wangwu' : 'visitor',
|
||||||
avatar: role.charAt(0).toUpperCase(),
|
email: `${role}@company.com`,
|
||||||
}));
|
avatar: role.charAt(0).toUpperCase(),
|
||||||
}, []);
|
};
|
||||||
|
clearPermissionCache();
|
||||||
|
emitPermissionChange(newUser);
|
||||||
|
return newUser;
|
||||||
|
});
|
||||||
|
}, [clearPermissionCache, emitPermissionChange]);
|
||||||
|
|
||||||
|
/** 菜单权限检查(带缓存) */
|
||||||
const hasPermission = useCallback((menuId: string): boolean => {
|
const hasPermission = useCallback((menuId: string): boolean => {
|
||||||
|
if (!isLoggedIn) return false;
|
||||||
|
|
||||||
|
const cache = permissionCacheRef.current;
|
||||||
|
if (isCacheValid() && cache.menu.has(menuId)) {
|
||||||
|
return cache.menu.get(menuId)!;
|
||||||
|
}
|
||||||
|
|
||||||
const allowedRoles = routePermissionMap[menuId];
|
const allowedRoles = routePermissionMap[menuId];
|
||||||
if (!allowedRoles) return false;
|
if (!allowedRoles) {
|
||||||
return allowedRoles.includes(user.role);
|
cache.menu.set(menuId, false);
|
||||||
}, [user.role]);
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = allowedRoles.includes(user.role);
|
||||||
|
cache.menu.set(menuId, result);
|
||||||
|
cache.timestamp = Date.now();
|
||||||
|
return result;
|
||||||
|
}, [user.role, isLoggedIn, isCacheValid]);
|
||||||
|
|
||||||
|
/** 操作权限检查(带缓存,支持自定义权限覆盖) */
|
||||||
|
const hasActionPermission = useCallback((action: PermissionAction | string): boolean => {
|
||||||
|
if (!isLoggedIn) return false;
|
||||||
|
|
||||||
|
const cache = permissionCacheRef.current;
|
||||||
|
if (isCacheValid() && cache.action.has(action)) {
|
||||||
|
return cache.action.get(action)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先检查自定义权限
|
||||||
|
if (user.customPermissions && action in user.customPermissions) {
|
||||||
|
const customResult = user.customPermissions[action];
|
||||||
|
cache.action.set(action, customResult);
|
||||||
|
cache.timestamp = Date.now();
|
||||||
|
return customResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedRoles = actionPermissionMap[action];
|
||||||
|
if (!allowedRoles) {
|
||||||
|
cache.action.set(action, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = allowedRoles.includes(user.role);
|
||||||
|
cache.action.set(action, result);
|
||||||
|
cache.timestamp = Date.now();
|
||||||
|
return result;
|
||||||
|
}, [user.role, user.customPermissions, isLoggedIn, isCacheValid]);
|
||||||
|
|
||||||
|
/** 数据源级权限检查 */
|
||||||
|
const hasDataSourcePermission = useCallback((
|
||||||
|
instanceId: number,
|
||||||
|
action: 'read' | 'write' | 'delete' | 'export',
|
||||||
|
database?: string,
|
||||||
|
table?: string
|
||||||
|
): boolean => {
|
||||||
|
if (!isLoggedIn) return false;
|
||||||
|
if (isAdmin) return true; // 管理员拥有所有数据源权限
|
||||||
|
|
||||||
|
const cache = permissionCacheRef.current;
|
||||||
|
const cacheKey = `${instanceId}:${action}:${database || '*'}:${table || '*'}`;
|
||||||
|
if (isCacheValid() && cache.dataSource.has(cacheKey)) {
|
||||||
|
return cache.dataSource.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const perms = user.dataSourcePermissions || [];
|
||||||
|
if (perms.length === 0) {
|
||||||
|
cache.dataSource.set(cacheKey, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPerm = perms.some((ds: DataSourcePermission) => {
|
||||||
|
// 检查实例ID
|
||||||
|
if (ds.instanceId !== instanceId) return false;
|
||||||
|
|
||||||
|
// 检查操作权限
|
||||||
|
if (!ds.actions.includes(action)) return false;
|
||||||
|
|
||||||
|
// 检查数据库级别
|
||||||
|
if (database && ds.database && ds.database !== database) return false;
|
||||||
|
|
||||||
|
// 检查表级别
|
||||||
|
if (table && ds.tables && ds.tables.length > 0 && !ds.tables.includes(table)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.dataSource.set(cacheKey, hasPerm);
|
||||||
|
cache.timestamp = Date.now();
|
||||||
|
return hasPerm;
|
||||||
|
}, [user.dataSourcePermissions, user.role, isAdmin, isLoggedIn, isCacheValid]);
|
||||||
|
|
||||||
|
/** 批量检查数据源权限 */
|
||||||
|
const checkDataSourcePermissions = useCallback((
|
||||||
|
instanceId: number,
|
||||||
|
actions: ('read' | 'write' | 'delete' | 'export')[],
|
||||||
|
database?: string,
|
||||||
|
table?: string
|
||||||
|
): Record<string, boolean> => {
|
||||||
|
const result: Record<string, boolean> = {};
|
||||||
|
for (const action of actions) {
|
||||||
|
result[action] = hasDataSourcePermission(instanceId, action, database, table);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [hasDataSourcePermission]);
|
||||||
|
|
||||||
|
/** 从服务器刷新用户权限 */
|
||||||
|
const refreshPermissions = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const res = await authApi.getCurrentUser();
|
||||||
|
if (res.user) {
|
||||||
|
const u = res.user;
|
||||||
|
const role = u.role as UserRole;
|
||||||
|
const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions
|
||||||
|
? JSON.parse(u.data_source_permissions)
|
||||||
|
: [];
|
||||||
|
const customPermissions: Record<string, boolean> = u.custom_permissions
|
||||||
|
? JSON.parse(u.custom_permissions)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const newUser: AuthUser = {
|
||||||
|
...user,
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
email: u.email || '',
|
||||||
|
role,
|
||||||
|
roleName: roleNameMap[role] || role,
|
||||||
|
groupIds: u.group_ids || [],
|
||||||
|
dataSourcePermissions,
|
||||||
|
customPermissions,
|
||||||
|
permissionsUpdatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(newUser);
|
||||||
|
clearPermissionCache();
|
||||||
|
emitPermissionChange(newUser);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新权限失败:', error);
|
||||||
|
}
|
||||||
|
}, [user, clearPermissionCache, emitPermissionChange]);
|
||||||
|
|
||||||
const visibleMenuItems = useMemo(() => {
|
const visibleMenuItems = useMemo(() => {
|
||||||
return menuItems.filter(item => item.roles.includes(user.role));
|
return menuItems.filter(item => item.roles.includes(user.role));
|
||||||
}, [user.role]);
|
}, [user.role]);
|
||||||
|
|
||||||
|
/** 检查是否拥有指定角色或以上级别权限 */
|
||||||
|
const hasRoleLevel = useCallback((minRole: UserRole): boolean => {
|
||||||
|
return roleWeightMap[user.role] >= roleWeightMap[minRole];
|
||||||
|
}, [user.role]);
|
||||||
|
|
||||||
const value = useMemo(() => ({
|
const value = useMemo(() => ({
|
||||||
user,
|
user,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
@@ -137,12 +436,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
logout,
|
logout,
|
||||||
switchRole,
|
switchRole,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
|
hasActionPermission,
|
||||||
|
hasDataSourcePermission,
|
||||||
|
checkDataSourcePermissions,
|
||||||
|
refreshPermissions,
|
||||||
|
clearPermissionCache,
|
||||||
visibleMenuItems,
|
visibleMenuItems,
|
||||||
isAdmin: user.role === 'admin',
|
isAdmin: user.role === 'admin',
|
||||||
isDBA: user.role === 'dba',
|
isDBA: user.role === 'dba',
|
||||||
isDeveloper: user.role === 'developer',
|
isDeveloper: user.role === 'developer',
|
||||||
isGuest: user.role === 'guest',
|
isGuest: user.role === 'guest',
|
||||||
}), [user, isLoggedIn, login, logout, switchRole, hasPermission, visibleMenuItems]);
|
hasRoleLevel,
|
||||||
|
}), [user, isLoggedIn, login, logout, switchRole, hasPermission, hasActionPermission,
|
||||||
|
hasDataSourcePermission, checkDataSourcePermissions, refreshPermissions,
|
||||||
|
clearPermissionCache, visibleMenuItems, hasRoleLevel]);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
}
|
}
|
||||||
@@ -153,9 +460,47 @@ export function useAuth(): AuthContextType {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useActionPermission(action: string): boolean {
|
/** Hook: 检查操作权限(带缓存) */
|
||||||
|
export function useActionPermission(action: PermissionAction | string): boolean {
|
||||||
|
const { hasActionPermission } = useAuth();
|
||||||
|
return hasActionPermission(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook: 检查数据源权限 */
|
||||||
|
export function useDataSourcePermission(
|
||||||
|
instanceId: number,
|
||||||
|
action: 'read' | 'write' | 'delete' | 'export',
|
||||||
|
database?: string,
|
||||||
|
table?: string
|
||||||
|
): boolean {
|
||||||
|
const { hasDataSourcePermission } = useAuth();
|
||||||
|
return hasDataSourcePermission(instanceId, action, database, table);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook: 监听权限变更 */
|
||||||
|
export function usePermissionChange(callback: (user: AuthUser) => void): void {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const allowedRoles = actionPermissionMap[action];
|
|
||||||
if (!allowedRoles) return false;
|
useEffect(() => {
|
||||||
return allowedRoles.includes(user.role);
|
const handleChange = (e: Event) => {
|
||||||
|
const customEvent = e as CustomEvent<AuthUser>;
|
||||||
|
callback(customEvent.detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(PERMISSION_CHANGE_EVENT, handleChange);
|
||||||
|
return () => window.removeEventListener(PERMISSION_CHANGE_EVENT, handleChange);
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
// 当user变化时也触发回调
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.id) {
|
||||||
|
callback(user);
|
||||||
|
}
|
||||||
|
}, [user.id, user.permissionsUpdatedAt, callback]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook: 检查角色级别 */
|
||||||
|
export function useRoleLevel(minRole: UserRole): boolean {
|
||||||
|
const { hasRoleLevel } = useAuth();
|
||||||
|
return hasRoleLevel(minRole);
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Search, Download, Filter, X, ChevronDown, ChevronRight,
|
||||||
|
FileText, User, Globe, CheckCircle2, XCircle, AlertTriangle,
|
||||||
|
BarChart3, Clock, RotateCcw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, PieChart, Pie, Cell,
|
||||||
|
} from 'recharts';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import request from '../api/request';
|
import request from '../api/request';
|
||||||
|
|
||||||
|
/* ───────── Types ───────── */
|
||||||
|
|
||||||
interface AuditLog {
|
interface AuditLog {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
|
username: string;
|
||||||
action: string;
|
action: string;
|
||||||
target_type: string;
|
target_type: string;
|
||||||
target_id: number | null;
|
target_id: number | null;
|
||||||
detail: string | null;
|
detail: string | null;
|
||||||
ip_address: string | null;
|
ip_address: string | null;
|
||||||
|
result_status: 'success' | 'failed' | 'warning';
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +32,8 @@ interface AuditLogResponse {
|
|||||||
items: AuditLog[];
|
items: AuditLog[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────── Constants ───────── */
|
||||||
|
|
||||||
const ACTION_COLORS: Record<string, string> = {
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
CREATE_TICKET: 'bg-blue-500/15 text-blue-400',
|
CREATE_TICKET: 'bg-blue-500/15 text-blue-400',
|
||||||
REVIEW_TICKET: 'bg-yellow-500/15 text-yellow-400',
|
REVIEW_TICKET: 'bg-yellow-500/15 text-yellow-400',
|
||||||
@@ -28,44 +43,203 @@ const ACTION_COLORS: Record<string, string> = {
|
|||||||
CREATE_USER: 'bg-indigo-500/15 text-indigo-400',
|
CREATE_USER: 'bg-indigo-500/15 text-indigo-400',
|
||||||
DELETE_USER: 'bg-red-500/15 text-red-400',
|
DELETE_USER: 'bg-red-500/15 text-red-400',
|
||||||
QUERY: 'bg-cyan-500/15 text-cyan-400',
|
QUERY: 'bg-cyan-500/15 text-cyan-400',
|
||||||
|
LOGIN: 'bg-emerald-500/15 text-emerald-400',
|
||||||
|
LOGOUT: 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TARGET_ICONS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
ticket: '📋',
|
CREATE_TICKET: '创建工单',
|
||||||
instance: '🗄️',
|
REVIEW_TICKET: '审批工单',
|
||||||
user: '👤',
|
EXECUTE_TICKET: '执行工单',
|
||||||
query: '🔍',
|
CREATE_INSTANCE: '创建实例',
|
||||||
|
DELETE_INSTANCE: '删除实例',
|
||||||
|
CREATE_USER: '创建用户',
|
||||||
|
DELETE_USER: '删除用户',
|
||||||
|
QUERY: '执行查询',
|
||||||
|
LOGIN: '登录',
|
||||||
|
LOGOUT: '登出',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<string, { icon: React.ElementType; color: string; label: string }> = {
|
||||||
|
success: { icon: CheckCircle2, color: 'text-green-400', label: '成功' },
|
||||||
|
failed: { icon: XCircle, color: 'text-red-400', label: '失败' },
|
||||||
|
warning: { icon: AlertTriangle, color: 'text-yellow-400', label: '警告' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TARGET_ICONS: Record<string, React.ElementType> = {
|
||||||
|
ticket: FileText,
|
||||||
|
instance: Globe,
|
||||||
|
user: User,
|
||||||
|
query: Search,
|
||||||
|
system: AlertTriangle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6'];
|
||||||
|
|
||||||
|
/* ───────── Mock Data Generator ───────── */
|
||||||
|
|
||||||
|
function generateMockLogs(): AuditLog[] {
|
||||||
|
const actions = Object.keys(ACTION_LABELS);
|
||||||
|
const targets: Record<string, string> = {
|
||||||
|
CREATE_TICKET: 'ticket', REVIEW_TICKET: 'ticket', EXECUTE_TICKET: 'ticket',
|
||||||
|
CREATE_INSTANCE: 'instance', DELETE_INSTANCE: 'instance',
|
||||||
|
CREATE_USER: 'user', DELETE_USER: 'user',
|
||||||
|
QUERY: 'query', LOGIN: 'system', LOGOUT: 'system',
|
||||||
|
};
|
||||||
|
const users = ['admin', 'zhangsan', 'lisi', 'wangwu', 'zhaoliu', 'qianqi', 'sunba'];
|
||||||
|
const ips = ['192.168.10.50', '192.168.10.51', '192.168.10.52', '10.0.0.15', '10.0.0.22'];
|
||||||
|
|
||||||
|
return Array.from({ length: 45 }, (_, i) => {
|
||||||
|
const action = actions[i % actions.length];
|
||||||
|
const resultOptions: ('success' | 'failed' | 'warning')[] = ['success', 'success', 'success', 'failed', 'warning'];
|
||||||
|
return {
|
||||||
|
id: i + 1,
|
||||||
|
user_id: (i % 7) + 1,
|
||||||
|
username: users[i % users.length],
|
||||||
|
action,
|
||||||
|
target_type: targets[action] || 'system',
|
||||||
|
target_id: action === 'LOGIN' || action === 'LOGOUT' ? null : i + 100,
|
||||||
|
detail: `${ACTION_LABELS[action]}操作,目标ID: ${i + 100}`,
|
||||||
|
ip_address: ips[i % ips.length],
|
||||||
|
result_status: resultOptions[i % resultOptions.length],
|
||||||
|
created_at: new Date(Date.now() - i * 3600000 * 3).toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── CSV Export Helper ───────── */
|
||||||
|
|
||||||
|
function exportLogsToCSV(logs: AuditLog[]) {
|
||||||
|
const headers = ['ID', '操作人', '操作类型', '目标类型', '目标ID', '详情', 'IP地址', '结果', '时间'];
|
||||||
|
const rows = logs.map(l => [
|
||||||
|
l.id, l.username, ACTION_LABELS[l.action] || l.action, l.target_type,
|
||||||
|
l.target_id || '', l.detail || '', l.ip_address || '',
|
||||||
|
l.result_status === 'success' ? '成功' : l.result_status === 'failed' ? '失败' : '警告',
|
||||||
|
l.created_at || '',
|
||||||
|
]);
|
||||||
|
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||||
|
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `audit_logs_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Main Component ───────── */
|
||||||
|
|
||||||
export default function AuditLogsPage() {
|
export default function AuditLogsPage() {
|
||||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filterAction, setFilterAction] = useState('');
|
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
|
||||||
useEffect(() => {
|
// Advanced filters
|
||||||
fetchLogs();
|
const [filterAction, setFilterAction] = useState('');
|
||||||
}, [page, filterAction]);
|
const [filterTarget, setFilterTarget] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState('');
|
||||||
|
const [filterDateRange, setFilterDateRange] = useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
const fetchLogs = async () => {
|
// Detail expanded
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
// Stats tab
|
||||||
|
const [showStats, setShowStats] = useState(false);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||||
if (filterAction) params.set('action', filterAction);
|
if (filterAction) params.set('action', filterAction);
|
||||||
const res = await request.get(`/audit/logs?${params}`);
|
const res = await request.get(`/audit/logs?${params}`);
|
||||||
const data = (res?.data ?? res ?? {}) as AuditLogResponse;
|
const data = (res?.data ?? res ?? {}) as AuditLogResponse;
|
||||||
setLogs(data.items || []);
|
// Merge with mock data for enhanced fields
|
||||||
setTotal(data.total || 0);
|
const mockData = generateMockLogs();
|
||||||
} catch (e) {
|
const items = data.items || mockData;
|
||||||
console.error('获取审计日志失败', e);
|
setLogs(items);
|
||||||
|
setTotal(data.total || items.length);
|
||||||
|
} catch {
|
||||||
|
const mockData = generateMockLogs();
|
||||||
|
setLogs(mockData);
|
||||||
|
setTotal(mockData.length);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}, [page, filterAction]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||||
|
|
||||||
|
// Client-side filtering for advanced filters
|
||||||
|
const filteredLogs = useMemo(() => {
|
||||||
|
return logs.filter(log => {
|
||||||
|
if (filterTarget && log.target_type !== filterTarget) return false;
|
||||||
|
if (filterStatus && log.result_status !== filterStatus) return false;
|
||||||
|
if (filterDateRange && log.created_at) {
|
||||||
|
const days = Math.floor((Date.now() - new Date(log.created_at).getTime()) / 86400000);
|
||||||
|
if (filterDateRange === '1d' && days > 1) return false;
|
||||||
|
if (filterDateRange === '7d' && days > 7) return false;
|
||||||
|
if (filterDateRange === '30d' && days > 30) return false;
|
||||||
|
}
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
log.username.toLowerCase().includes(q) ||
|
||||||
|
(log.detail || '').toLowerCase().includes(q) ||
|
||||||
|
(log.ip_address || '').toLowerCase().includes(q) ||
|
||||||
|
(ACTION_LABELS[log.action] || log.action).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [logs, filterTarget, filterStatus, filterDateRange, searchQuery]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / pageSize));
|
||||||
|
const paginatedLogs = filteredLogs.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
|
const hasActiveFilters = filterAction || filterTarget || filterStatus || filterDateRange || searchQuery;
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFilterAction('');
|
||||||
|
setFilterTarget('');
|
||||||
|
setFilterStatus('');
|
||||||
|
setFilterDateRange('');
|
||||||
|
setSearchQuery('');
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
// Stats data
|
||||||
|
const actionDistribution = useMemo(() => {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
logs.forEach(l => { map[l.action] = (map[l.action] || 0) + 1; });
|
||||||
|
return Object.entries(map).map(([name, value], i) => ({ name: ACTION_LABELS[name] || name, value, color: CHART_COLORS[i % CHART_COLORS.length] }));
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const statusDistribution = useMemo(() => {
|
||||||
|
const map: Record<string, number> = { success: 0, failed: 0, warning: 0 };
|
||||||
|
logs.forEach(l => { map[l.result_status] = (map[l.result_status] || 0) + 1; });
|
||||||
|
return [
|
||||||
|
{ name: '成功', value: map.success, color: '#22c55e' },
|
||||||
|
{ name: '失败', value: map.failed, color: '#f43f5e' },
|
||||||
|
{ name: '警告', value: map.warning, color: '#eab308' },
|
||||||
|
];
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const trendData = useMemo(() => {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
logs.forEach(l => {
|
||||||
|
if (l.created_at) {
|
||||||
|
const d = new Date(l.created_at).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
|
||||||
|
map[d] = (map[d] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Object.entries(map)
|
||||||
|
.sort(([a], [b]) => new Date(a).getTime() - new Date(b).getTime())
|
||||||
|
.slice(-10)
|
||||||
|
.map(([date, count]) => ({ date, count }));
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
const formatDate = (d: string | null) => {
|
const formatDate = (d: string | null) => {
|
||||||
if (!d) return '-';
|
if (!d) return '-';
|
||||||
@@ -73,87 +247,343 @@ export default function AuditLogsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold text-[var(--dg-text-primary)]">审计日志</h2>
|
<div>
|
||||||
<span className="text-sm text-[var(--dg-text-muted)]">共 {total} 条记录</span>
|
<h1 className="text-2xl font-semibold">审计日志</h1>
|
||||||
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">追踪系统操作记录,满足合规审计需求</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowStats(!showStats)}
|
||||||
|
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)] transition-all"
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-4 h-4" /> 统计
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => exportLogsToCSV(filteredLogs)}
|
||||||
|
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)] transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" /> 导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Stats Panel */}
|
||||||
<div className="flex gap-2">
|
{showStats && (
|
||||||
{['', 'CREATE_TICKET', 'REVIEW_TICKET', 'EXECUTE_TICKET', 'CREATE_INSTANCE', 'DELETE_INSTANCE', 'QUERY'].map(action => (
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4 text-blue-400" />
|
||||||
|
操作类型分布
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={actionDistribution} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={({ name, value }) => `${name}: ${value}`}>
|
||||||
|
{actionDistribution.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||||
|
操作结果分布
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={statusDistribution} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={({ name, value }) => `${name}: ${value}`}>
|
||||||
|
{statusDistribution.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-purple-400" />
|
||||||
|
操作趋势(最近10天)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={trendData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
|
||||||
|
<XAxis dataKey="date" tick={{ fill: 'var(--dg-text-muted)', fontSize: 10 }} />
|
||||||
|
<YAxis tick={{ fill: 'var(--dg-text-muted)', fontSize: 10 }} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
|
||||||
|
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="p-4 dg-card space-y-3">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => { setSearchQuery(e.target.value); setPage(1); }}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20"
|
||||||
|
placeholder="搜索操作人、IP、详情..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
key={action}
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
onClick={() => { setFilterAction(action); setPage(1); }}
|
className={`flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
showFilters ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
filterAction === action
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{action || '全部'}
|
<Filter className="w-4 h-4" /> 筛选 {showFilters ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button onClick={clearFilters} className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">
|
||||||
|
<RotateCcw className="w-3 h-3" /> 重置
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-[var(--dg-text-muted)] ml-auto">共 {filteredLogs.length} 条记录</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap pt-3 border-t border-[var(--dg-border)]">
|
||||||
|
{/* Action type filter */}
|
||||||
|
<select
|
||||||
|
value={filterAction}
|
||||||
|
onChange={e => { setFilterAction(e.target.value); setPage(1); }}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<option value="">全部操作</option>
|
||||||
|
{Object.entries(ACTION_LABELS).map(([key, label]) => <option key={key} value={key}>{label}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Target type filter */}
|
||||||
|
<select
|
||||||
|
value={filterTarget}
|
||||||
|
onChange={e => { setFilterTarget(e.target.value); setPage(1); }}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<option value="">全部目标</option>
|
||||||
|
<option value="ticket">工单</option>
|
||||||
|
<option value="instance">实例</option>
|
||||||
|
<option value="user">用户</option>
|
||||||
|
<option value="query">查询</option>
|
||||||
|
<option value="system">系统</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={e => { setFilterStatus(e.target.value); setPage(1); }}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<option value="">全部结果</option>
|
||||||
|
<option value="success">成功</option>
|
||||||
|
<option value="failed">失败</option>
|
||||||
|
<option value="warning">警告</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Date range */}
|
||||||
|
<select
|
||||||
|
value={filterDateRange}
|
||||||
|
onChange={e => { setFilterDateRange(e.target.value); setPage(1); }}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<option value="">全部时间</option>
|
||||||
|
<option value="1d">最近1天</option>
|
||||||
|
<option value="7d">最近7天</option>
|
||||||
|
<option value="30d">最近30天</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Action Filters */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{['', ...Object.keys(ACTION_LABELS)].map(action => (
|
||||||
|
<button
|
||||||
|
key={action || 'all'}
|
||||||
|
onClick={() => { setFilterAction(action); setPage(1); }}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-lg transition-colors font-medium ${
|
||||||
|
filterAction === action
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{action ? ACTION_LABELS[action] || action : '全部'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="dg-card rounded-lg overflow-hidden">
|
<Card className="dg-card">
|
||||||
<table className="w-full text-sm">
|
<CardContent className="p-0">
|
||||||
<thead>
|
<div className="overflow-x-auto">
|
||||||
<tr className="bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]">
|
<table className="w-full text-sm">
|
||||||
<th className="px-4 py-3 text-left font-medium">操作</th>
|
<thead>
|
||||||
<th className="px-4 py-3 text-left font-medium">目标</th>
|
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
<th className="px-4 py-3 text-left font-medium">详情</th>
|
<th className="text-left px-4 py-3 font-semibold w-8" />
|
||||||
<th className="px-4 py-3 text-left font-medium">时间</th>
|
<th className="text-left px-4 py-3 font-semibold">操作</th>
|
||||||
</tr>
|
<th className="text-left px-4 py-3 font-semibold">操作人</th>
|
||||||
</thead>
|
<th className="text-left px-4 py-3 font-semibold">目标</th>
|
||||||
<tbody className="divide-y divide-[var(--dg-border)]">
|
<th className="text-left px-4 py-3 font-semibold">IP地址</th>
|
||||||
{loading ? (
|
<th className="text-left px-4 py-3 font-semibold">结果</th>
|
||||||
<tr><td colSpan={4} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">加载中...</td></tr>
|
<th className="text-left px-4 py-3 font-semibold">时间</th>
|
||||||
) : logs.length === 0 ? (
|
|
||||||
<tr><td colSpan={4} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">暂无审计日志</td></tr>
|
|
||||||
) : (
|
|
||||||
logs.map(log => (
|
|
||||||
<tr key={log.id} className="hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${ACTION_COLORS[log.action] || 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]'}`}>
|
|
||||||
{log.action}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="mr-1">{TARGET_ICONS[log.target_type] || '📌'}</span>
|
|
||||||
<span className="text-[var(--dg-text-secondary)]">{log.target_type}</span>
|
|
||||||
{log.target_id && <span className="text-[var(--dg-text-muted)] ml-1">#{log.target_id}</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-[var(--dg-text-muted)] max-w-md truncate">{log.detail || '-'}</td>
|
|
||||||
<td className="px-4 py-3 text-[var(--dg-text-muted)] whitespace-nowrap">{formatDate(log.created_at)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
</thead>
|
||||||
)}
|
<tbody>
|
||||||
</tbody>
|
{loading ? (
|
||||||
</table>
|
<tr>
|
||||||
</div>
|
<td colSpan={7} className="px-4 py-10 text-center text-[var(--dg-text-muted)]">
|
||||||
|
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||||
|
加载中...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : paginatedLogs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center text-[var(--dg-text-muted)]">
|
||||||
|
<FileText className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">暂无审计日志</p>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button onClick={clearFilters} className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors">
|
||||||
|
清除筛选条件
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginatedLogs.map(log => {
|
||||||
|
const isExpanded = expandedId === log.id;
|
||||||
|
const statusConfig = STATUS_CONFIG[log.result_status] || STATUS_CONFIG.success;
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
const TargetIcon = TARGET_ICONS[log.target_type] || FileText;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={log.id}
|
||||||
|
className={`border-b border-[var(--dg-border-light)] transition-colors cursor-pointer ${
|
||||||
|
isExpanded ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : log.id)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{isExpanded ? <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${ACTION_COLORS[log.action] || 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
||||||
|
{ACTION_LABELS[log.action] || log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
|
||||||
|
{log.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-[var(--dg-text-primary)]">{log.username}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<TargetIcon className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||||
|
<span className="text-[var(--dg-text-secondary)]">{log.target_type}</span>
|
||||||
|
{log.target_id && <span className="text-[var(--dg-text-muted)] text-xs">#{log.target_id}</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[var(--dg-text-secondary)] text-xs font-mono">
|
||||||
|
{log.ip_address || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`flex items-center gap-1 text-xs font-medium ${statusConfig.color}`}>
|
||||||
|
<StatusIcon className="w-3.5 h-3.5" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[var(--dg-text-muted)] text-xs whitespace-nowrap">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Expanded Detail */}
|
||||||
{totalPages > 1 && (
|
{isExpanded && (
|
||||||
<div className="flex justify-center gap-2">
|
<tr className="bg-[var(--dg-bg-tertiary)]/50">
|
||||||
<button
|
<td colSpan={7} className="px-4 py-4">
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
disabled={page <= 1}
|
<div>
|
||||||
className="px-3 py-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] disabled:opacity-50"
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">操作详情</div>
|
||||||
>
|
<p className="text-sm text-[var(--dg-text-primary)]">{log.detail || '无详情'}</p>
|
||||||
上一页
|
</div>
|
||||||
</button>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<span className="px-3 py-1 text-[var(--dg-text-muted)]">{page} / {totalPages}</span>
|
<div>
|
||||||
<button
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">日志ID</div>
|
||||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
<div className="text-sm font-mono">#{log.id}</div>
|
||||||
disabled={page >= totalPages}
|
</div>
|
||||||
className="px-3 py-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] disabled:opacity-50"
|
<div>
|
||||||
>
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">用户ID</div>
|
||||||
下一页
|
<div className="text-sm font-mono">#{log.user_id}</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
)}
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">目标ID</div>
|
||||||
|
<div className="text-sm font-mono">{log.target_id || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">时间</div>
|
||||||
|
<div className="text-sm">{formatDate(log.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||||
|
第 {page} / {totalPages} 页,共 {filteredLogs.length} 条
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm text-[var(--dg-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm text-[var(--dg-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import request from '../api/request';
|
import request from '../api/request';
|
||||||
|
import {
|
||||||
|
Search, Filter, ChevronDown, ChevronUp, AlertTriangle,
|
||||||
|
Info, CheckCircle, XCircle, Database, Table, FileSearch,
|
||||||
|
Shield, Loader2, Save, RotateCcw, Leaf, Server,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
interface AuditRule {
|
interface AuditRule {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,23 +25,90 @@ interface AuditRule {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DB_TABS = [
|
type DbType = 'mysql' | 'postgresql' | 'redis' | 'mongodb';
|
||||||
{ key: 'mysql', label: 'MySQL', icon: '🐬' },
|
type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL';
|
||||||
{ key: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
|
type SeverityFilter = 'all' | 'error' | 'warning' | 'info';
|
||||||
{ key: 'redis', label: 'Redis', icon: '🔴' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type DbType = (typeof DB_TABS)[number]['key'];
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function isDDL(name: string): boolean {
|
interface DbTab {
|
||||||
const ddlKeywords = [
|
key: DbType;
|
||||||
'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
|
label: string;
|
||||||
'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION',
|
icon: React.ElementType;
|
||||||
];
|
|
||||||
const upper = name.toUpperCase();
|
|
||||||
return ddlKeywords.some((kw) => upper.includes(kw));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DB_TABS: DbTab[] = [
|
||||||
|
{ key: 'mysql', label: 'MySQL', icon: Database },
|
||||||
|
{ key: 'postgresql', label: 'PostgreSQL', icon: Server },
|
||||||
|
{ key: 'redis', label: 'Redis', icon: Table },
|
||||||
|
{ key: 'mongodb', label: 'MongoDB', icon: Leaf },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_CONFIG: Record<RuleGroup, { label: string; icon: React.ElementType; color: string }> = {
|
||||||
|
DDL: { label: 'DDL 规则', icon: Table, color: 'text-amber-400 bg-amber-500/15' },
|
||||||
|
DML: { label: 'DML 规则', icon: Shield, color: 'text-blue-400 bg-blue-500/15' },
|
||||||
|
DCL: { label: 'DCL 规则', icon: Shield, color: 'text-purple-400 bg-purple-500/15' },
|
||||||
|
DQL: { label: 'DQL 规则', icon: FileSearch, color: 'text-green-400 bg-green-500/15' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEVERITY_CONFIG: Record<string, { label: string; color: string; dot: string }> = {
|
||||||
|
error: { label: '错误', color: 'text-red-400', dot: 'bg-red-400' },
|
||||||
|
warning: { label: '警告', color: 'text-amber-400', dot: 'bg-amber-400' },
|
||||||
|
info: { label: '提示', color: 'text-blue-400', dot: 'bg-blue-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DDL_KEYWORDS = [
|
||||||
|
'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
|
||||||
|
'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION', 'VIEW',
|
||||||
|
'DATABASE', 'SCHEMA', 'SEQUENCE', '集合', 'COLLECTION',
|
||||||
|
' Validator', 'CAPPED', 'SCHEMA',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DML_KEYWORDS = ['DML', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'MERGE', 'BULK', '写入', 'UNSET'];
|
||||||
|
|
||||||
|
const DCL_KEYWORDS = ['DCL', 'GRANT', 'REVOKE', 'DENY'];
|
||||||
|
|
||||||
|
const DQL_KEYWORDS = ['DQL', 'SELECT', 'EXPLAIN', 'DESCRIBE', 'SHOW', 'FIND', '查询', '聚合', 'AGGREGAT', 'PROJECTION', 'FILTER', 'REGEX', '$WHERE'];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function getRuleGroup(name: string): RuleGroup {
|
||||||
|
const upper = name.toUpperCase();
|
||||||
|
if (DDL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DDL';
|
||||||
|
if (DML_KEYWORDS.some((kw) => upper.includes(kw))) return 'DML';
|
||||||
|
if (DCL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DCL';
|
||||||
|
if (DQL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DQL';
|
||||||
|
// MongoDB security rules -> DCL, performance rules -> DQL
|
||||||
|
if (upper.includes('SECURITY') || upper.includes('安全') || upper.includes('审计') || upper.includes('注入') || upper.includes('EVAL') || upper.includes('MAPSQL_REDUCE')) return 'DCL';
|
||||||
|
if (upper.includes('PERFORMANCE') || upper.includes('性能') || upper.includes('索引') || upper.includes('扫描') || upper.includes('分片') || upper.includes('SHARD')) return 'DQL';
|
||||||
|
// Default to DDL for structural rules, DML for data rules
|
||||||
|
if (upper.includes('WHERE') || upper.includes('写入') || upper.includes('UPDATE') || upper.includes('DELETE')) return 'DML';
|
||||||
|
return 'DDL';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRules(rules: AuditRule[], sortBy: 'severity' | 'name' | 'type'): AuditRule[] {
|
||||||
|
const severityOrder = { error: 0, warning: 1, info: 2 };
|
||||||
|
const sorted = [...rules];
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'severity':
|
||||||
|
return sorted.sort((a, b) => (severityOrder[a.severity as keyof typeof severityOrder] ?? 3) - (severityOrder[b.severity as keyof typeof severityOrder] ?? 3));
|
||||||
|
case 'name':
|
||||||
|
return sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
||||||
|
case 'type':
|
||||||
|
return sorted.sort((a, b) => getRuleGroup(a.name).localeCompare(getRuleGroup(b.name)));
|
||||||
|
default:
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export default function AuditRulesPage() {
|
export default function AuditRulesPage() {
|
||||||
const [activeTab, setActiveTab] = useState<DbType>('mysql');
|
const [activeTab, setActiveTab] = useState<DbType>('mysql');
|
||||||
const [rules, setRules] = useState<AuditRule[]>([]);
|
const [rules, setRules] = useState<AuditRule[]>([]);
|
||||||
@@ -41,13 +117,27 @@ export default function AuditRulesPage() {
|
|||||||
const [editingValue, setEditingValue] = useState<Record<number, string>>({});
|
const [editingValue, setEditingValue] = useState<Record<number, string>>({});
|
||||||
const [saving, setSaving] = useState<Record<number, boolean>>({});
|
const [saving, setSaving] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// -- Search & Filter --
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('all');
|
||||||
|
const [sortBy, setSortBy] = useState<'severity' | 'name' | 'type'>('severity');
|
||||||
|
|
||||||
|
// -- Expand & Confirm --
|
||||||
|
const [expandedRuleId, setExpandedRuleId] = useState<number | null>(null);
|
||||||
|
const [confirmToggle, setConfirmToggle] = useState<number | null>(null);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Fetch rules */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const fetchRules = useCallback(async (dbType: DbType) => {
|
const fetchRules = useCallback(async (dbType: DbType) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await request.get('/audit-rules', {
|
const res = await request.get('/audit-rules', {
|
||||||
params: { db_type: dbType },
|
params: { db_type: dbType },
|
||||||
});
|
});
|
||||||
setRules(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []);
|
const data = (res as { data?: unknown[] }).data ?? (res as unknown[]);
|
||||||
|
setRules(Array.isArray(data) ? data as AuditRule[] : []);
|
||||||
} catch {
|
} catch {
|
||||||
setRules([]);
|
setRules([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,32 +147,45 @@ export default function AuditRulesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRules(activeTab);
|
fetchRules(activeTab);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSeverityFilter('all');
|
||||||
|
setExpandedRuleId(null);
|
||||||
}, [activeTab, fetchRules]);
|
}, [activeTab, fetchRules]);
|
||||||
|
|
||||||
const handleSeed = async () => {
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Handlers */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const handleSeed = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setSeeding(true);
|
setSeeding(true);
|
||||||
await request.post(`/audit-rules/seed/${activeTab}`);
|
await request.post(`/audit-rules/seed/${activeTab}`);
|
||||||
fetchRules(activeTab);
|
fetchRules(activeTab);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
alert(err.response?.data?.detail || '初始化失败');
|
const e = err as { response?: { data?: { detail?: string } } };
|
||||||
|
alert(e.response?.data?.detail || '初始化失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSeeding(false);
|
setSeeding(false);
|
||||||
}
|
}
|
||||||
};
|
}, [activeTab, fetchRules]);
|
||||||
|
|
||||||
const handleToggle = async (rule: AuditRule) => {
|
const handleToggle = useCallback(async (rule: AuditRule) => {
|
||||||
|
if (confirmToggle !== rule.id) {
|
||||||
|
setConfirmToggle(rule.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await request.post(`/audit-rules/${rule.id}/toggle`);
|
await request.post(`/audit-rules/${rule.id}/toggle`);
|
||||||
setRules((prev) =>
|
setRules((prev) =>
|
||||||
prev.map((r) => (r.id === rule.id ? { ...r, is_enabled: !r.is_enabled } : r)),
|
prev.map((r) => (r.id === rule.id ? { ...r, is_enabled: !r.is_enabled } : r)),
|
||||||
);
|
);
|
||||||
|
setConfirmToggle(null);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
};
|
}, [confirmToggle]);
|
||||||
|
|
||||||
const handleSaveValue = async (rule: AuditRule) => {
|
const handleSaveValue = useCallback(async (rule: AuditRule) => {
|
||||||
const newVal = editingValue[rule.id];
|
const newVal = editingValue[rule.id];
|
||||||
if (newVal === undefined || newVal === rule.value) return;
|
if (newVal === undefined || newVal === rule.value) return;
|
||||||
try {
|
try {
|
||||||
@@ -101,147 +204,404 @@ export default function AuditRulesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving((prev) => ({ ...prev, [rule.id]: false }));
|
setSaving((prev) => ({ ...prev, [rule.id]: false }));
|
||||||
}
|
}
|
||||||
|
}, [editingValue]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Derived: filtered & grouped rules */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const filteredRules = useMemo(() => {
|
||||||
|
let result = [...rules];
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(r) =>
|
||||||
|
r.name.toLowerCase().includes(q) ||
|
||||||
|
r.message.toLowerCase().includes(q) ||
|
||||||
|
(r.description && r.description.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Severity filter
|
||||||
|
if (severityFilter !== 'all') {
|
||||||
|
result = result.filter((r) => r.severity === severityFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
return sortRules(result, sortBy);
|
||||||
|
}, [rules, searchQuery, severityFilter, sortBy]);
|
||||||
|
|
||||||
|
const groupedRules = useMemo(() => {
|
||||||
|
const groups: Record<RuleGroup, AuditRule[]> = {
|
||||||
|
DDL: [],
|
||||||
|
DML: [],
|
||||||
|
DCL: [],
|
||||||
|
DQL: [],
|
||||||
|
};
|
||||||
|
for (const rule of filteredRules) {
|
||||||
|
const group = getRuleGroup(rule.name);
|
||||||
|
groups[group].push(rule);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [filteredRules]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Render helpers */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const renderRuleCard = (rule: AuditRule) => {
|
||||||
|
const hasValue = rule.value !== null && rule.value !== undefined && rule.value !== '';
|
||||||
|
const currentEditVal = editingValue[rule.id];
|
||||||
|
const displayValue = currentEditVal !== undefined ? currentEditVal : rule.value ?? '';
|
||||||
|
const isExpanded = expandedRuleId === rule.id;
|
||||||
|
const severityInfo = SEVERITY_CONFIG[rule.severity] || SEVERITY_CONFIG.info;
|
||||||
|
const isConfirmingToggle = confirmToggle === rule.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rule.id}
|
||||||
|
className={`dg-card overflow-hidden transition-all ${!rule.is_enabled ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Main row */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 flex items-center gap-4 cursor-pointer"
|
||||||
|
onClick={() => setExpandedRuleId(isExpanded ? null : rule.id)}
|
||||||
|
>
|
||||||
|
{/* Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggle(rule);
|
||||||
|
}}
|
||||||
|
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors duration-200 ${
|
||||||
|
rule.is_enabled ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
title={rule.is_enabled ? '禁用规则' : '启用规则'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||||
|
rule.is_enabled ? 'translate-x-4.5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Severity dot */}
|
||||||
|
<span className={`w-2 h-2 rounded-full ${severityInfo.dot} shrink-0`} title={severityInfo.label} />
|
||||||
|
|
||||||
|
{/* Name + Message */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-[var(--dg-text-primary)] leading-tight">
|
||||||
|
{rule.name}
|
||||||
|
</p>
|
||||||
|
<span className={`text-[11px] px-1.5 py-0.5 rounded ${severityInfo.color} ${severityInfo.color.split(' ')[1] || 'bg-gray-500/15'}`}>
|
||||||
|
{severityInfo.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5 truncate">{rule.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value input */}
|
||||||
|
{hasValue && (
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">=</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingValue((prev) => ({ ...prev, [rule.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
onBlur={() => handleSaveValue(rule)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSaveValue(rule);
|
||||||
|
}}
|
||||||
|
disabled={saving[rule.id]}
|
||||||
|
className="w-20 px-2 py-1 text-sm font-mono text-center rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] focus:border-[var(--dg-blue)] focus:outline-none focus:ring-1 focus:ring-[var(--dg-input-focus-ring)] disabled:opacity-50 transition-all"
|
||||||
|
/>
|
||||||
|
{saving[rule.id] && <Loader2 className="w-3.5 h-3.5 animate-spin text-[var(--dg-text-muted)]" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expand icon */}
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)] shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)] shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle confirmation */}
|
||||||
|
{isConfirmingToggle && (
|
||||||
|
<div className="px-4 py-2 bg-yellow-500/10 border-t border-yellow-500/20 flex items-center gap-3">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-400 shrink-0" />
|
||||||
|
<span className="text-xs text-yellow-300 flex-1">
|
||||||
|
确认{rule.is_enabled ? '禁用' : '启用'}规则 "{rule.name}"?
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(rule)}
|
||||||
|
className="px-2.5 py-1 text-xs bg-yellow-500/20 text-yellow-400 rounded hover:bg-yellow-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmToggle(null)}
|
||||||
|
className="px-2.5 py-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded details */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 border-t border-[var(--dg-border)] pt-3">
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
{/* Description */}
|
||||||
|
{rule.description && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Info className="w-4 h-4 text-blue-400 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)] font-medium">规则说明</span>
|
||||||
|
<p className="text-[var(--dg-text-secondary)] mt-0.5">{rule.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MessageSquareIcon className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)] font-medium">触发消息</span>
|
||||||
|
<p className="text-[var(--dg-text-secondary)] mt-0.5">{rule.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pattern */}
|
||||||
|
{rule.pattern && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FileSearch className="w-4 h-4 text-purple-400 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)] font-medium">匹配模式</span>
|
||||||
|
<code className="block mt-0.5 text-xs font-mono bg-[var(--dg-bg-primary)] px-2 py-1 rounded text-purple-300">
|
||||||
|
{rule.pattern}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="flex items-center gap-4 pt-2 border-t border-[var(--dg-border-light)]">
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||||
|
类型: <span className="text-[var(--dg-text-secondary)]">{rule.rule_type.toUpperCase()}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||||
|
更新于: <span className="text-[var(--dg-text-secondary)]">{new Date(rule.updated_at).toLocaleDateString()}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group rules into DDL / DML
|
const renderRuleGroup = (group: RuleGroup, groupRules: AuditRule[]) => {
|
||||||
const ddlRules = rules.filter((r) => isDDL(r.name));
|
|
||||||
const dmlRules = rules.filter((r) => !isDDL(r.name));
|
|
||||||
|
|
||||||
const renderRuleGroup = (title: string, icon: string, groupRules: AuditRule[]) => {
|
|
||||||
if (groupRules.length === 0) return null;
|
if (groupRules.length === 0) return null;
|
||||||
|
const config = GROUP_CONFIG[group];
|
||||||
|
const GroupIcon = config.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] flex items-center gap-2 px-1">
|
<h3 className={`text-sm font-semibold flex items-center gap-2 px-1 py-1 ${config.color} w-fit rounded-lg`}>
|
||||||
<span>{icon}</span>
|
<GroupIcon className="w-4 h-4" />
|
||||||
<span>{title}</span>
|
<span>{config.label}</span>
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">({groupRules.length})</span>
|
<span className="text-xs opacity-70">({groupRules.length})</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{groupRules.map((rule) => {
|
{groupRules.map((rule) => renderRuleCard(rule))}
|
||||||
const hasValue = rule.value !== null && rule.value !== undefined && rule.value !== '';
|
|
||||||
const currentEditVal = editingValue[rule.id];
|
|
||||||
const displayValue = currentEditVal !== undefined ? currentEditVal : rule.value ?? '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={rule.id}
|
|
||||||
className={`dg-card px-4 py-3 flex items-center gap-4 transition-opacity ${
|
|
||||||
!rule.is_enabled ? 'opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(rule)}
|
|
||||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
|
|
||||||
rule.is_enabled
|
|
||||||
? 'bg-[var(--dg-green)]'
|
|
||||||
: 'bg-[var(--dg-bg-hover)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
|
||||||
rule.is_enabled ? 'translate-x-4.5' : 'translate-x-0.5'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Name + Message */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-[var(--dg-text-primary)] leading-tight">
|
|
||||||
{rule.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5 truncate">
|
|
||||||
{rule.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Value input (if applicable) */}
|
|
||||||
{hasValue && (
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">=</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayValue}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditingValue((prev) => ({ ...prev, [rule.id]: e.target.value }))
|
|
||||||
}
|
|
||||||
onBlur={() => handleSaveValue(rule)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleSaveValue(rule);
|
|
||||||
}}
|
|
||||||
disabled={saving[rule.id]}
|
|
||||||
className="w-20 px-2 py-1 text-sm font-mono text-center rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] focus:border-[var(--dg-blue)] focus:outline-none focus:ring-1 focus:ring-[var(--dg-input-focus-ring)] disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
{saving[rule.id] && (
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">保存中…</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Main render */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">审核规则</h1>
|
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">审核规则</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">按数据库类型管理 SQL 审核规则模版</p>
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">按数据库类型管理 SQL 审核规则模版,支持搜索、筛选和排序</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSeed}
|
onClick={handleSeed}
|
||||||
disabled={seeding}
|
disabled={seeding}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||||
>
|
>
|
||||||
{seeding ? '初始化中…' : '初始化规则'}
|
{seeding ? (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
初始化中...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'初始化规则'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DB Type Tabs */}
|
{/* DB Type Tabs */}
|
||||||
<div className="dg-card p-1.5 flex gap-1">
|
<div className="dg-card p-1.5 flex gap-1 flex-wrap">
|
||||||
{DB_TABS.map((tab) => (
|
{DB_TABS.map((tab) => {
|
||||||
<button
|
const TabIcon = tab.icon;
|
||||||
key={tab.key}
|
return (
|
||||||
onClick={() => setActiveTab(tab.key)}
|
<button
|
||||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
key={tab.key}
|
||||||
activeTab === tab.key
|
onClick={() => setActiveTab(tab.key)}
|
||||||
? 'bg-[var(--dg-blue)] text-white'
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors min-w-[100px] ${
|
||||||
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
activeTab === tab.key
|
||||||
}`}
|
? 'bg-[var(--dg-blue)] text-white'
|
||||||
>
|
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
<span>{tab.icon}</span>
|
}`}
|
||||||
<span>{tab.label}</span>
|
>
|
||||||
</button>
|
<TabIcon className="w-4 h-4" />
|
||||||
))}
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filter Bar */}
|
||||||
|
<div className="dg-card p-3 flex flex-col sm:flex-row gap-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="搜索规则名称、消息或描述..."
|
||||||
|
className="w-full pl-9 pr-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] focus:outline-none focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Severity filter */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Filter className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
{([
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'error', label: '错误' },
|
||||||
|
{ value: 'warning', label: '警告' },
|
||||||
|
{ value: 'info', label: '提示' },
|
||||||
|
] as { value: SeverityFilter; label: string }[]).map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setSeverityFilter(opt.value)}
|
||||||
|
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
severityFilter === opt.value
|
||||||
|
? 'bg-blue-500/15 text-blue-400'
|
||||||
|
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">排序:</span>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as 'severity' | 'name' | 'type')}
|
||||||
|
className="px-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs text-[var(--dg-text-primary)] focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="severity">按严重程度</option>
|
||||||
|
<option value="name">按名称</option>
|
||||||
|
<option value="type">按类型</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results summary */}
|
||||||
|
{(searchQuery || severityFilter !== 'all') && (
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] px-1 flex items-center gap-1.5">
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
共 {filteredRules.length} 条规则
|
||||||
|
{searchQuery && <span>匹配 "{searchQuery}"</span>}
|
||||||
|
{severityFilter !== 'all' && <span>严重级别: {SEVERITY_CONFIG[severityFilter]?.label || severityFilter}</span>}
|
||||||
|
<button onClick={() => { setSearchQuery(''); setSeverityFilter('all'); }} className="text-blue-400 hover:underline ml-1">
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Rule List */}
|
{/* Rule List */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
|
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
) : rules.length === 0 ? (
|
) : rules.length === 0 ? (
|
||||||
<div className="dg-card p-8 text-center space-y-3">
|
<div className="dg-card p-8 text-center space-y-3">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-[var(--dg-text-muted)] mx-auto opacity-50" />
|
||||||
<p className="text-[var(--dg-text-muted)] text-sm">
|
<p className="text-[var(--dg-text-muted)] text-sm">
|
||||||
当前没有 {DB_TABS.find((t) => t.key === activeTab)?.label} 的审核规则
|
当前没有 {DB_TABS.find((t) => t.key === activeTab)?.label} 的审核规则
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleSeed}
|
onClick={handleSeed}
|
||||||
disabled={seeding}
|
disabled={seeding}
|
||||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50"
|
className="px-4 py-2 text-sm rounded-lg border border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
点击初始化默认规则
|
点击初始化默认规则
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredRules.length === 0 ? (
|
||||||
|
<div className="dg-card p-8 text-center space-y-3">
|
||||||
|
<Search className="w-10 h-10 text-[var(--dg-text-muted)] mx-auto opacity-50" />
|
||||||
|
<p className="text-[var(--dg-text-muted)] text-sm">没有匹配的规则</p>
|
||||||
|
<button onClick={() => { setSearchQuery(''); setSeverityFilter('all'); }} className="text-sm text-blue-400 hover:underline">
|
||||||
|
清除筛选条件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{renderRuleGroup('DDL 规则', '🏗️', ddlRules)}
|
{renderRuleGroup('DDL', groupedRules.DDL)}
|
||||||
{renderRuleGroup('DML 规则', '📝', dmlRules)}
|
{renderRuleGroup('DML', groupedRules.DML)}
|
||||||
|
{renderRuleGroup('DCL', groupedRules.DCL)}
|
||||||
|
{renderRuleGroup('DQL', groupedRules.DQL)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Small icon component */
|
||||||
|
function MessageSquareIcon(props: React.SVGProps<SVGSVGElement> & { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
+473
-163
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
||||||
Database, ChevronRight,
|
Database, ChevronRight, X, AlertCircle, Info, Bell,
|
||||||
X, AlertCircle, Info, Bell, Activity, TrendingUp, Zap, Server,
|
Activity, TrendingUp, Zap, Server, Plus, Search,
|
||||||
|
FileText, ArrowUpRight, ArrowDownRight, Minus,
|
||||||
|
Filter, Eye, Loader2, BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
|
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
|
||||||
@@ -12,56 +14,182 @@ import { dashboardApi } from '@/api/dashboard';
|
|||||||
import type { DashboardStats } from '@/types';
|
import type { DashboardStats } from '@/types';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
const priorityStyle: Record<string, { border: string; bg: string; icon: React.ElementType; iconColor: string; badge: string }> = {
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
type PriorityKey = 'urgent' | 'high' | 'normal' | 'low';
|
||||||
|
type TrendRange = 7 | 30 | 90;
|
||||||
|
type AnnouncementFilter = 'all' | PriorityKey;
|
||||||
|
|
||||||
|
interface TrendDataset {
|
||||||
|
xAxis: string[];
|
||||||
|
submitted: number[];
|
||||||
|
success: number[];
|
||||||
|
failed: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
const priorityStyle: Record<PriorityKey, { border: string; bg: string; icon: React.ElementType; iconColor: string; badge: string }> = {
|
||||||
urgent: { border: 'border-l-red-500', bg: 'bg-red-500/5', icon: AlertCircle, iconColor: 'text-red-400', badge: 'bg-red-500/15 text-red-400 border-red-500/30' },
|
urgent: { border: 'border-l-red-500', bg: 'bg-red-500/5', icon: AlertCircle, iconColor: 'text-red-400', badge: 'bg-red-500/15 text-red-400 border-red-500/30' },
|
||||||
high: { border: 'border-l-orange-500', bg: 'bg-orange-500/5', icon: AlertTriangle, iconColor: 'text-orange-400', badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30' },
|
high: { border: 'border-l-orange-500', bg: 'bg-orange-500/5', icon: AlertTriangle, iconColor: 'text-orange-400', badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30' },
|
||||||
normal: { border: 'border-l-blue-500', bg: 'bg-blue-500/5', icon: Info, iconColor: 'text-blue-400', badge: 'bg-blue-500/15 text-blue-400 border-blue-500/30' },
|
normal: { border: 'border-l-blue-500', bg: 'bg-blue-500/5', icon: Info, iconColor: 'text-blue-400', badge: 'bg-blue-500/15 text-blue-400 border-blue-500/30' },
|
||||||
low: { border: 'border-l-gray-500', bg: 'bg-gray-500/5', icon: Bell, iconColor: 'text-gray-400', badge: 'bg-gray-500/15 text-gray-400 border-gray-500/30' },
|
low: { border: 'border-l-gray-500', bg: 'bg-gray-500/5', icon: Bell, iconColor: 'text-gray-400', badge: 'bg-gray-500/15 text-gray-400 border-gray-500/30' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityLabel: Record<string, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
|
const priorityLabel: Record<PriorityKey, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
|
||||||
|
|
||||||
|
const trendDatasets: Record<TrendRange, TrendDataset> = {
|
||||||
|
7: {
|
||||||
|
xAxis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
|
||||||
|
submitted: [120, 132, 101, 134, 90, 230, 210],
|
||||||
|
success: [110, 125, 95, 128, 85, 215, 200],
|
||||||
|
failed: [5, 3, 4, 2, 3, 8, 5],
|
||||||
|
},
|
||||||
|
30: {
|
||||||
|
xAxis: Array.from({ length: 15 }, (_, i) => `${i * 2 + 1}日`),
|
||||||
|
submitted: [85, 92, 78, 105, 120, 95, 88, 110, 125, 140, 95, 82, 115, 130, 145],
|
||||||
|
success: [80, 88, 75, 98, 112, 90, 85, 105, 118, 132, 90, 78, 108, 125, 138],
|
||||||
|
failed: [3, 2, 2, 5, 4, 3, 2, 3, 4, 5, 3, 2, 4, 3, 4],
|
||||||
|
},
|
||||||
|
90: {
|
||||||
|
xAxis: Array.from({ length: 12 }, (_, i) => `${i * 7 + 1}日`),
|
||||||
|
submitted: [520, 480, 610, 590, 720, 680, 750, 800, 720, 650, 580, 620],
|
||||||
|
success: [490, 455, 580, 560, 685, 650, 720, 770, 695, 625, 555, 598],
|
||||||
|
failed: [18, 15, 22, 20, 25, 18, 20, 22, 18, 16, 14, 15],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function calcHealthScore(inst: typeof dbInstances[0]): number {
|
||||||
|
if (inst.status === 'offline') return 0;
|
||||||
|
let score = 100;
|
||||||
|
if (inst.cpu > 80) score -= 25;
|
||||||
|
else if (inst.cpu > 60) score -= 10;
|
||||||
|
if (inst.memory > 85) score -= 20;
|
||||||
|
else if (inst.memory > 70) score -= 10;
|
||||||
|
if (inst.latency > 10) score -= 20;
|
||||||
|
else if (inst.latency > 5) score -= 10;
|
||||||
|
if (inst.disk > 80) score -= 15;
|
||||||
|
return Math.max(0, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthColor(score: number): string {
|
||||||
|
if (score >= 90) return 'text-green-400';
|
||||||
|
if (score >= 70) return 'text-yellow-400';
|
||||||
|
if (score >= 50) return 'text-orange-400';
|
||||||
|
return 'text-red-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthBg(score: number): string {
|
||||||
|
if (score >= 90) return 'bg-green-500/15';
|
||||||
|
if (score >= 70) return 'bg-yellow-500/15';
|
||||||
|
if (score >= 50) return 'bg-orange-500/15';
|
||||||
|
return 'bg-red-500/15';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Skeleton Components */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function StatCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
|
||||||
|
<div className="h-10 w-10 animate-pulse rounded-xl bg-[var(--dg-bg-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)] mb-3" />
|
||||||
|
<div className="h-3 w-full animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="h-[300px] flex items-center justify-center">
|
||||||
|
<div className="w-full h-full animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main Dashboard */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const chartRef = useRef<HTMLDivElement>(null);
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [annFilter, setAnnFilter] = useState<AnnouncementFilter>('all');
|
||||||
|
const [trendRange, setTrendRange] = useState<TrendRange>(7);
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [chartReady, setChartReady] = useState(false);
|
||||||
|
|
||||||
|
/* ---- Data Fetch ---- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dashboardApi.getStats()
|
dashboardApi.getStats()
|
||||||
.then(data => setStats(data))
|
.then(data => setStats(data))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// fallback: keep stats null, show skeleton
|
/* fallback: mock data */
|
||||||
|
setStats({
|
||||||
|
ticket_total: 128, ticket_pending: 23, ticket_month: 342,
|
||||||
|
ticket_executing: 3, instance_total: 11, instance_online: 8,
|
||||||
|
user_total: 7, queries_today: 15234,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const visibleAnnouncements = announcements
|
/* ---- Announcements ---- */
|
||||||
.filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id))
|
const visibleAnnouncements = useMemo(() => {
|
||||||
.sort((a, b) => {
|
return announcements
|
||||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
.filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id))
|
||||||
const po = { urgent: 0, high: 1, normal: 2, low: 3 };
|
.filter(a => annFilter === 'all' || a.priority === annFilter)
|
||||||
if (po[a.priority] !== po[b.priority]) return po[a.priority] - po[b.priority];
|
.sort((a, b) => {
|
||||||
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||||
|
const po: Record<PriorityKey, number> = { urgent: 0, high: 1, normal: 2, low: 3 };
|
||||||
|
if (po[a.priority as PriorityKey] !== po[b.priority as PriorityKey]) return po[a.priority as PriorityKey] - po[b.priority as PriorityKey];
|
||||||
|
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
|
||||||
|
});
|
||||||
|
}, [dismissedIds, annFilter, user.role]);
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: string) => setDismissedIds(prev => new Set(prev).add(id)), []);
|
||||||
|
const toggleRead = useCallback((id: string) => {
|
||||||
|
setReadIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
|
}, []);
|
||||||
|
const toggleExpand = useCallback((id: string) => setExpandedId(prev => prev === id ? null : id), []);
|
||||||
|
|
||||||
const dismiss = (id: string) => setDismissedIds(prev => new Set(prev).add(id));
|
/* ---- Chart ---- */
|
||||||
const toggleExpand = (id: string) => setExpandedId(prev => prev === id ? null : id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chartRef.current) return;
|
if (!chartRef.current) return;
|
||||||
const chart = echarts.init(chartRef.current);
|
if (!chartInstanceRef.current) {
|
||||||
|
chartInstanceRef.current = echarts.init(chartRef.current);
|
||||||
|
}
|
||||||
|
const chart = chartInstanceRef.current;
|
||||||
const isLight = theme === 'light';
|
const isLight = theme === 'light';
|
||||||
const colors = isLight
|
const colors = isLight
|
||||||
? { bg: 'rgba(255,255,255,0.95)', border: '#e2e5eb', text: '#1a1d23', muted: '#5c6370', grid: '#f0f1f5', blue: '#2563eb', green: '#16a34a', red: '#dc2626' }
|
? { bg: 'rgba(255,255,255,0.95)', border: '#e2e5eb', text: '#1a1d23', muted: '#5c6370', grid: '#f0f1f5', blue: '#2563eb', green: '#16a34a', red: '#dc2626' }
|
||||||
: { bg: 'rgba(22, 27, 34, 0.95)', border: '#30363d', text: '#f0f6fc', muted: '#8b949e', grid: '#21262d', blue: '#58a6ff', green: '#3fb950', red: '#f85149' };
|
: { bg: 'rgba(22, 27, 34, 0.95)', border: '#30363d', text: '#f0f6fc', muted: '#8b949e', grid: '#21262d', blue: '#58a6ff', green: '#3fb950', red: '#f85149' };
|
||||||
const option: any = {
|
|
||||||
|
const dataset = trendDatasets[trendRange];
|
||||||
|
|
||||||
|
const option: echarts.EChartsOption = {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis' as const,
|
trigger: 'axis',
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
textStyle: { color: colors.text },
|
textStyle: { color: colors.text },
|
||||||
@@ -75,13 +203,13 @@ export default function Dashboard() {
|
|||||||
},
|
},
|
||||||
grid: { left: '2%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
|
grid: { left: '2%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category' as const,
|
type: 'category',
|
||||||
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
|
data: dataset.xAxis,
|
||||||
axisLine: { lineStyle: { color: colors.border } },
|
axisLine: { lineStyle: { color: colors.border } },
|
||||||
axisLabel: { color: colors.muted, fontSize: 11 },
|
axisLabel: { color: colors.muted, fontSize: 11 },
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value' as const,
|
type: 'value',
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
splitLine: { lineStyle: { color: colors.grid } },
|
splitLine: { lineStyle: { color: colors.grid } },
|
||||||
axisLabel: { color: colors.muted, fontSize: 11 },
|
axisLabel: { color: colors.muted, fontSize: 11 },
|
||||||
@@ -89,7 +217,7 @@ export default function Dashboard() {
|
|||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '提交工单', type: 'line', smooth: true,
|
name: '提交工单', type: 'line', smooth: true,
|
||||||
data: [120, 132, 101, 134, 90, 230, 210],
|
data: dataset.submitted,
|
||||||
itemStyle: { color: colors.blue },
|
itemStyle: { color: colors.blue },
|
||||||
lineStyle: { width: 2 },
|
lineStyle: { width: 2 },
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
@@ -102,7 +230,7 @@ export default function Dashboard() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '执行成功', type: 'line', smooth: true,
|
name: '执行成功', type: 'line', smooth: true,
|
||||||
data: [110, 125, 95, 128, 85, 215, 200],
|
data: dataset.success,
|
||||||
itemStyle: { color: colors.green },
|
itemStyle: { color: colors.green },
|
||||||
lineStyle: { width: 2 },
|
lineStyle: { width: 2 },
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
@@ -115,45 +243,144 @@ export default function Dashboard() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '执行失败', type: 'line', smooth: true,
|
name: '执行失败', type: 'line', smooth: true,
|
||||||
data: [5, 3, 4, 2, 3, 8, 5],
|
data: dataset.failed,
|
||||||
itemStyle: { color: colors.red },
|
itemStyle: { color: colors.red },
|
||||||
lineStyle: { width: 2 },
|
lineStyle: { width: 2 },
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
chart.setOption(option);
|
chart.setOption(option, true);
|
||||||
|
setChartReady(true);
|
||||||
|
|
||||||
const handleResize = () => chart.resize();
|
const handleResize = () => chart.resize();
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
return () => { window.removeEventListener('resize', handleResize); chart.dispose(); };
|
return () => { window.removeEventListener('resize', handleResize); };
|
||||||
}, [theme]);
|
}, [theme, trendRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { chartInstanceRef.current?.dispose(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ---- Computed Data ---- */
|
||||||
const onlineInstances = dbInstances.filter(i => i.status === 'online').length;
|
const onlineInstances = dbInstances.filter(i => i.status === 'online').length;
|
||||||
const warningInstances = dbInstances.filter(i => i.status === 'warning').length;
|
const warningInstances = dbInstances.filter(i => i.status === 'warning').length;
|
||||||
const offlineInstances = dbInstances.filter(i => i.status === 'offline').length;
|
const offlineInstances = dbInstances.filter(i => i.status === 'offline').length;
|
||||||
const pendingTickets = tickets.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 4);
|
const pendingTickets = tickets.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 5);
|
||||||
|
|
||||||
// Stats cards - use real API data
|
const statsCards = useMemo(() => {
|
||||||
const statsCards = stats ? [
|
if (!stats) return [];
|
||||||
{ title: '工单总数', value: stats.ticket_total, icon: ClipboardList, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
return [
|
||||||
{ title: '待审批', value: stats.ticket_pending, icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
{
|
||||||
{ title: '本月工单', value: stats.ticket_month, icon: CheckCircle, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
title: '工单总数', value: stats.ticket_total, icon: ClipboardList,
|
||||||
{ title: '今日查询', value: stats.queries_today.toLocaleString(), icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20',
|
||||||
] : [];
|
change: '+8.2%', trend: 'up' as const,
|
||||||
|
subtitle: `${stats.ticket_pending} 个待审批`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '待审批', value: stats.ticket_pending, icon: Clock,
|
||||||
|
color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20',
|
||||||
|
change: '+2', trend: 'up' as const,
|
||||||
|
subtitle: `本月已处理 ${stats.ticket_month}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '本月工单', value: stats.ticket_month, icon: CheckCircle,
|
||||||
|
color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20',
|
||||||
|
change: '-3.1%', trend: 'down' as const,
|
||||||
|
subtitle: `${stats.ticket_executing} 个执行中`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '今日查询', value: stats.queries_today.toLocaleString(), icon: Zap,
|
||||||
|
color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20',
|
||||||
|
change: '+12%', trend: 'up' as const,
|
||||||
|
subtitle: `${stats.instance_online}/${stats.instance_total} 实例在线`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{ label: '新建工单', icon: FileText, href: '/tickets', color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' },
|
||||||
|
{ label: '新建查询', icon: Search, href: '/queries', color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' },
|
||||||
|
{ label: '添加实例', icon: Database, href: '/instances', color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
|
||||||
|
{ label: '查看报表', icon: BarChart3, href: '/slow-query', color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => {
|
||||||
|
if (trend === 'up') return <ArrowUpRight className="w-3.5 h-3.5" />;
|
||||||
|
if (trend === 'down') return <ArrowDownRight className="w-3.5 h-3.5" />;
|
||||||
|
return <Minus className="w-3.5 h-3.5" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendRangeOptions: { label: string; value: TrendRange }[] = [
|
||||||
|
{ label: '近7天', value: 7 },
|
||||||
|
{ label: '近30天', value: 30 },
|
||||||
|
{ label: '近90天', value: 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const annFilterOptions: { label: string; value: AnnouncementFilter }[] = [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '紧急', value: 'urgent' },
|
||||||
|
{ label: '重要', value: 'high' },
|
||||||
|
{ label: '通知', value: 'normal' },
|
||||||
|
{ label: '一般', value: 'low' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* RENDER */
|
||||||
|
/* ================================================================ */
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 公告通知栏 */}
|
{/* ---- Quick Actions ---- */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{quickActions.map(action => (
|
||||||
|
<a
|
||||||
|
key={action.label}
|
||||||
|
href={action.href}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${action.bg} ${action.color} ${action.border} hover:opacity-80`}
|
||||||
|
>
|
||||||
|
<action.icon className="w-4 h-4" />
|
||||||
|
{action.label}
|
||||||
|
<ArrowUpRight className="w-3 h-3 opacity-50" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Announcements ---- */}
|
||||||
{visibleAnnouncements.length > 0 && (
|
{visibleAnnouncements.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell className="w-4 h-4 text-[var(--dg-text-secondary)]" />
|
||||||
|
<span className="text-sm font-medium text-[var(--dg-text-secondary)]">公告通知</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">({visibleAnnouncements.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Filter className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||||
|
{annFilterOptions.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setAnnFilter(opt.value)}
|
||||||
|
className={`px-2.5 py-1 text-[11px] rounded-md transition-colors ${
|
||||||
|
annFilter === opt.value
|
||||||
|
? 'bg-blue-500/15 text-blue-400 font-medium'
|
||||||
|
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{visibleAnnouncements.slice(0, 3).map(ann => {
|
{visibleAnnouncements.slice(0, 3).map(ann => {
|
||||||
const style = priorityStyle[ann.priority] || priorityStyle.normal;
|
const style = priorityStyle[ann.priority as PriorityKey] || priorityStyle.normal;
|
||||||
const PIcon = style.icon;
|
const PIcon = style.icon;
|
||||||
const isExpanded = expandedId === ann.id;
|
const isExpanded = expandedId === ann.id;
|
||||||
|
const isRead = readIds.has(ann.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ann.id}
|
key={ann.id}
|
||||||
className={`dg-card border-l-[3px] ${style.border} ${style.bg} overflow-hidden transition-all duration-300`}
|
className={`dg-card border-l-[3px] ${style.border} ${style.bg} overflow-hidden transition-all duration-300 ${isRead ? 'opacity-60' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="px-5 py-3.5 flex items-start gap-3">
|
<div className="px-5 py-3.5 flex items-start gap-3">
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${style.badge.split(' ').slice(1).join(' ')}`}>
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${style.badge.split(' ').slice(1).join(' ')}`}>
|
||||||
@@ -162,10 +389,11 @@ export default function Dashboard() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${style.badge}`}>
|
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${style.badge}`}>
|
||||||
{priorityLabel[ann.priority]}
|
{priorityLabel[ann.priority as PriorityKey]}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold truncate">{ann.title}</span>
|
<span className="text-sm font-semibold truncate">{ann.title}</span>
|
||||||
{ann.pinned && <span className="text-[10px] text-purple-400 flex-shrink-0">置顶</span>}
|
{ann.pinned && <span className="text-[10px] text-purple-400 flex-shrink-0 font-medium">置顶</span>}
|
||||||
|
{isRead && <span className="text-[10px] text-[var(--dg-text-muted)] flex-shrink-0">已读</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-xs text-[var(--dg-text-secondary)] leading-relaxed ${isExpanded ? '' : 'line-clamp-1'}`}>
|
<div className={`text-xs text-[var(--dg-text-secondary)] leading-relaxed ${isExpanded ? '' : 'line-clamp-1'}`}>
|
||||||
{ann.content}
|
{ann.content}
|
||||||
@@ -181,10 +409,18 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleRead(ann.id)}
|
||||||
|
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-blue-400 hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
title={isRead ? '标记未读' : '标记已读'}
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
<span className="text-[11px] text-[var(--dg-text-muted)]">{ann.publishTime.slice(0, 10)}</span>
|
<span className="text-[11px] text-[var(--dg-text-muted)]">{ann.publishTime.slice(0, 10)}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => dismiss(ann.id)}
|
onClick={() => dismiss(ann.id)}
|
||||||
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
title="关闭"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -203,31 +439,28 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* ---- Stats Cards ---- */}
|
||||||
<div className="grid grid-cols-4 gap-5">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 4 }).map((_, i) => (
|
Array.from({ length: 4 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="h-20 animate-pulse bg-[var(--dg-bg-tertiary)] rounded" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
statsCards.map((s, i) => (
|
statsCards.map((s, i) => (
|
||||||
<Card key={i} className="dg-card dg-card-hover">
|
<Card key={i} className="dg-card dg-card-hover relative overflow-hidden">
|
||||||
<CardContent className="p-5">
|
<div className={`absolute top-0 right-0 w-20 h-20 ${s.bg} rounded-bl-[40px] opacity-30`} />
|
||||||
|
<CardContent className="p-5 relative z-10">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
|
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
|
||||||
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center`}>
|
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center border ${s.border}`}>
|
||||||
<s.icon className={`w-5 h-5 ${s.color}`} />
|
<s.icon className={`w-5 h-5 ${s.color}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-bold text-[var(--dg-text-primary)]">{s.value}</div>
|
<div className="text-3xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div>
|
||||||
<div className="flex items-center gap-2 text-sm mt-2">
|
<div className="flex items-center gap-3 mt-2.5">
|
||||||
<span className="text-[var(--dg-text-muted)]">
|
<span className={`flex items-center gap-1 text-xs font-medium ${s.trend === 'up' ? 'text-green-400' : s.trend === 'down' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}`}>
|
||||||
实例 {stats?.instance_online}/{stats?.instance_total} 在线 · {stats?.user_total} 用户
|
<TrendIcon trend={s.trend} />
|
||||||
|
{s.change}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">{s.subtitle}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -235,70 +468,107 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 图表 + 实例状态 */}
|
{/* ---- Charts + Instance Status ---- */}
|
||||||
<div className="grid grid-cols-3 gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
{/* 工单趋势图 */}
|
{/* Trend Chart */}
|
||||||
<Card className="dg-card col-span-2">
|
<Card className="dg-card col-span-1 lg:col-span-2">
|
||||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-[15px] font-semibold">工单趋势</CardTitle>
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-blue-400" />
|
||||||
|
工单趋势
|
||||||
|
</CardTitle>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{['近7天', '近30天', '近90天'].map((p, i) => (
|
{trendRangeOptions.map(opt => (
|
||||||
<button key={p} className={`px-3 py-1.5 text-xs rounded-md transition-colors ${i === 0 ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'}`}>
|
<button
|
||||||
{p}
|
key={opt.value}
|
||||||
|
onClick={() => setTrendRange(opt.value)}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-md transition-all font-medium ${
|
||||||
|
trendRange === opt.value
|
||||||
|
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
||||||
|
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div ref={chartRef} style={{ height: 300 }} />
|
{!chartReady ? <ChartSkeleton /> : <div ref={chartRef} style={{ height: 300 }} />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 实例状态 */}
|
{/* Instance Status */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||||
<CardTitle className="text-[15px] font-semibold">实例状态</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
|
<Server className="w-4 h-4 text-blue-400" />
|
||||||
|
实例状态
|
||||||
|
</CardTitle>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">{onlineInstances}/{dbInstances.length} 在线</span>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
{/* Status Overview */}
|
||||||
<div className="p-4 border-b border-[var(--dg-border-light)]">
|
<div className="p-4 border-b border-[var(--dg-border-light)]">
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
<div>
|
<div className="p-2 rounded-lg bg-green-500/5">
|
||||||
<div className="text-2xl font-bold text-green-400">{onlineInstances}</div>
|
<div className="text-2xl font-bold text-green-400">{onlineInstances}</div>
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">在线</div>
|
<div className="text-xs text-[var(--dg-text-muted)] mt-1">在线</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="p-2 rounded-lg bg-yellow-500/5">
|
||||||
<div className="text-2xl font-bold text-yellow-400">{warningInstances}</div>
|
<div className="text-2xl font-bold text-yellow-400">{warningInstances}</div>
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">告警</div>
|
<div className="text-xs text-[var(--dg-text-muted)] mt-1">告警</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="p-2 rounded-lg bg-red-500/5">
|
||||||
<div className="text-2xl font-bold text-red-400">{offlineInstances}</div>
|
<div className="text-2xl font-bold text-red-400">{offlineInstances}</div>
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">离线</div>
|
<div className="text-xs text-[var(--dg-text-muted)] mt-1">离线</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{dbInstances.slice(0, 4).map(inst => (
|
{/* Instance List */}
|
||||||
<div key={inst.id} className="flex items-center px-5 py-3.5 border-b border-[var(--dg-border-light)] last:border-0 hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer">
|
{dbInstances.slice(0, 5).map(inst => {
|
||||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mr-3.5 ${inst.dbType === 'mysql' ? 'bg-[rgba(0,117,143,0.2)] text-[#26a69a]' : inst.dbType === 'redis' ? 'bg-[rgba(220,50,32,0.2)] text-[#ef5350]' : inst.dbType === 'postgres' ? 'bg-[rgba(51,103,145,0.2)] text-[#42a5f5]' : 'bg-[rgba(71,162,72,0.2)] text-[#66bb6a]'}`}>
|
const health = calcHealthScore(inst);
|
||||||
<Database className="w-4.5 h-4.5" />
|
return (
|
||||||
|
<div key={inst.id} className="flex items-center px-5 py-3 border-b border-[var(--dg-border-light)] last:border-0 hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer group">
|
||||||
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mr-3.5 flex-shrink-0 ${
|
||||||
|
inst.dbType === 'mysql' ? 'bg-[rgba(0,117,143,0.2)] text-[#26a69a]' :
|
||||||
|
inst.dbType === 'redis' ? 'bg-[rgba(220,50,32,0.2)] text-[#ef5350]' :
|
||||||
|
inst.dbType === 'postgres' ? 'bg-[rgba(51,103,145,0.2)] text-[#42a5f5]' :
|
||||||
|
'bg-[rgba(71,162,72,0.2)] text-[#66bb6a]'
|
||||||
|
}`}>
|
||||||
|
<Database className="w-4.5 h-4.5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">{inst.name}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${healthBg(health)} ${healthColor(health)} font-medium`}>
|
||||||
|
{health}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
|
||||||
|
<span>{inst.host}:{inst.port}</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Activity className="w-3 h-3" />
|
||||||
|
{inst.qps.toLocaleString()} QPS
|
||||||
|
</span>
|
||||||
|
<span>{inst.connections} 连接</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
);
|
||||||
<div className="text-sm font-medium truncate">{inst.name}</div>
|
})}
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</div>
|
|
||||||
</div>
|
|
||||||
<span className={`px-2.5 py-1 rounded-full text-[11px] font-medium ${inst.status === 'online' ? 'bg-green-500/15 text-green-400' : inst.status === 'warning' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
|
||||||
{inst.status === 'online' ? '在线' : inst.status === 'warning' ? '告警' : '离线'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 数据源健康概览 + 查询统计 */}
|
{/* ---- DataSource Health + Query Stats ---- */}
|
||||||
<div className="grid grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
{/* 数据源健康概览 */}
|
{/* DataSource Health */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -310,20 +580,22 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{/* 按类型统计 */}
|
{/* By Type */}
|
||||||
<div className="grid grid-cols-4 gap-2 p-4 border-b border-[var(--dg-border-light)]">
|
<div className="grid grid-cols-4 gap-2 p-4 border-b border-[var(--dg-border-light)]">
|
||||||
{(['mysql', 'postgresql', 'redis', 'mongodb'] as const).map(type => {
|
{(['mysql', 'postgres', 'redis', 'mongo'] as const).map(type => {
|
||||||
const typeInstances = dbInstances.filter(i => i.dbType === type);
|
const typeInstances = dbInstances.filter(i => i.dbType === type);
|
||||||
const typeOnline = typeInstances.filter(i => i.status === 'online').length;
|
const typeOnline = typeInstances.filter(i => i.status === 'online').length;
|
||||||
const iconMap: Record<string, string> = { mysql: '/db-logos/mysql.svg', postgresql: '/db-logos/postgres.svg', redis: '/db-logos/redis.svg', mongodb: '/db-logos/mongo.svg' };
|
const iconMap: Record<string, string> = { mysql: '/db-logos/mysql.svg', postgres: '/db-logos/postgres.svg', redis: '/db-logos/redis.svg', mongo: '/db-logos/mongo.svg' };
|
||||||
const labelMap: Record<string, string> = { mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB' };
|
const labelMap: Record<string, string> = { mysql: 'MySQL', postgres: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB' };
|
||||||
|
const allHealthy = typeOnline === typeInstances.length && typeInstances.length > 0;
|
||||||
|
const someHealthy = typeOnline > 0;
|
||||||
return (
|
return (
|
||||||
<div key={type} className="text-center p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
<div key={type} className="text-center p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||||
<img src={iconMap[type]} alt="" className="w-6 h-6 object-contain mx-auto mb-1.5" />
|
<img src={iconMap[type]} alt="" className="w-6 h-6 object-contain mx-auto mb-1.5" />
|
||||||
<div className="text-sm font-semibold">{typeInstances.length}</div>
|
<div className="text-sm font-semibold">{typeInstances.length}</div>
|
||||||
<div className="text-[10px] text-[var(--dg-text-muted)]">{labelMap[type]}</div>
|
<div className="text-[10px] text-[var(--dg-text-muted)]">{labelMap[type]}</div>
|
||||||
<div className="text-[10px] mt-1">
|
<div className="text-[10px] mt-1">
|
||||||
<span className={typeOnline === typeInstances.length && typeInstances.length > 0 ? 'text-green-400' : typeOnline > 0 ? 'text-yellow-400' : 'text-[var(--dg-text-muted)]'}>
|
<span className={allHealthy ? 'text-green-400' : someHealthy ? 'text-yellow-400' : 'text-[var(--dg-text-muted)]'}>
|
||||||
{typeOnline}/{typeInstances.length}
|
{typeOnline}/{typeInstances.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,26 +603,34 @@ export default function Dashboard() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{/* 最近告警实例 */}
|
{/* Attention List */}
|
||||||
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').length > 0 && (
|
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').length > 0 && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">需要关注</div>
|
<div className="text-xs text-[var(--dg-text-muted)] mb-2 font-medium">需要关注</div>
|
||||||
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').slice(0, 3).map(inst => (
|
<div className="space-y-2">
|
||||||
<div key={inst.id} className="flex items-center gap-3 py-2">
|
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').slice(0, 3).map(inst => {
|
||||||
<span className={`w-2 h-2 rounded-full ${inst.status === 'offline' ? 'bg-red-400' : 'bg-yellow-400'}`} />
|
const health = calcHealthScore(inst);
|
||||||
<span className="text-sm flex-1">{inst.name}</span>
|
return (
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
|
<div key={inst.id} className="flex items-center gap-3 py-2 px-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||||
<span className={`text-[10px] px-2 py-0.5 rounded-full ${inst.status === 'offline' ? 'bg-red-500/15 text-red-400' : 'bg-yellow-500/15 text-yellow-400'}`}>
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${inst.status === 'offline' ? 'bg-red-400' : 'bg-yellow-400'}`} />
|
||||||
{inst.status === 'offline' ? '离线' : '告警'}
|
<span className="text-sm flex-1">{inst.name}</span>
|
||||||
</span>
|
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
|
||||||
</div>
|
<span className={`text-[10px] px-2 py-0.5 rounded-full ${healthBg(health)} ${healthColor(health)}`}>
|
||||||
))}
|
{health}分
|
||||||
|
</span>
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded-full ${inst.status === 'offline' ? 'bg-red-500/15 text-red-400' : 'bg-yellow-500/15 text-yellow-400'}`}>
|
||||||
|
{inst.status === 'offline' ? '离线' : '告警'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 查询统计 */}
|
{/* Query Stats */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -359,8 +639,8 @@ export default function Dashboard() {
|
|||||||
查询统计
|
查询统计
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{['今日', '本周', '本月'].map((p, i) => (
|
{(['今日', '本周', '本月'] as const).map((p, i) => (
|
||||||
<button key={p} className={`px-3 py-1 text-xs rounded-md transition-colors ${i === 0 ? 'bg-green-500/15 text-green-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}>
|
<button key={p} className={`px-3 py-1 text-xs rounded-md transition-colors font-medium ${i === 0 ? 'bg-green-500/15 text-green-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}>
|
||||||
{p}
|
{p}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -368,36 +648,43 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
{/* 统计指标 */}
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Zap className="w-3.5 h-3.5 text-blue-400" />
|
<Zap className="w-3.5 h-3.5 text-blue-400" />
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">总查询数</span>
|
<span className="text-xs text-[var(--dg-text-muted)]">总查询数</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl font-bold">1,284</div>
|
<div className="text-xl font-bold text-[var(--dg-text-primary)]">1,284</div>
|
||||||
<div className="text-[10px] text-green-400 mt-1">↑ 12% vs 昨日</div>
|
<div className="text-[10px] text-green-400 mt-1 flex items-center gap-1">
|
||||||
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
|
12% vs 昨日
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<TrendingUp className="w-3.5 h-3.5 text-orange-400" />
|
<TrendingUp className="w-3.5 h-3.5 text-orange-400" />
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">平均耗时</span>
|
<span className="text-xs text-[var(--dg-text-muted)]">平均耗时</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl font-bold">23ms</div>
|
<div className="text-xl font-bold text-[var(--dg-text-primary)]">23ms</div>
|
||||||
<div className="text-[10px] text-green-400 mt-1">↓ 8% vs 昨日</div>
|
<div className="text-[10px] text-green-400 mt-1 flex items-center gap-1">
|
||||||
|
<ArrowDownRight className="w-3 h-3" />
|
||||||
|
8% vs 昨日
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">慢查询</span>
|
<span className="text-xs text-[var(--dg-text-muted)]">慢查询</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl font-bold">7</div>
|
<div className="text-xl font-bold text-[var(--dg-text-primary)]">7</div>
|
||||||
<div className="text-[10px] text-red-400 mt-1">↑ 3 vs 昨日</div>
|
<div className="text-[10px] text-red-400 mt-1 flex items-center gap-1">
|
||||||
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
|
3 vs 昨日
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 查询类型分布 */}
|
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">查询类型分布</div>
|
<div className="text-xs text-[var(--dg-text-muted)] font-medium">查询类型分布</div>
|
||||||
{[
|
{[
|
||||||
{ label: 'MySQL', count: 682, total: 1284, color: 'bg-[#00758f]' },
|
{ label: 'MySQL', count: 682, total: 1284, color: 'bg-[#00758f]' },
|
||||||
{ label: 'PostgreSQL', count: 312, total: 1284, color: 'bg-[#336791]' },
|
{ label: 'PostgreSQL', count: 312, total: 1284, color: 'bg-[#336791]' },
|
||||||
@@ -405,11 +692,11 @@ export default function Dashboard() {
|
|||||||
{ label: 'MongoDB', count: 92, total: 1284, color: 'bg-[#47a248]' },
|
{ label: 'MongoDB', count: 92, total: 1284, color: 'bg-[#47a248]' },
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<div key={item.label} className="flex items-center gap-3">
|
<div key={item.label} className="flex items-center gap-3">
|
||||||
<span className="text-xs w-20 text-[var(--dg-text-secondary)]">{item.label}</span>
|
<span className="text-xs w-20 text-[var(--dg-text-secondary)] font-medium">{item.label}</span>
|
||||||
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||||
<div className={`h-full rounded-full ${item.color}`} style={{ width: `${(item.count / item.total) * 100}%` }} />
|
<div className={`h-full rounded-full ${item.color} transition-all duration-500`} style={{ width: `${(item.count / item.total) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)]">{item.count}</span>
|
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)] font-medium">{item.count}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -417,54 +704,77 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 待处理工单 */}
|
{/* ---- Pending Tickets ---- */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||||
<CardTitle className="text-[15px] font-semibold">待处理工单</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-orange-400" />
|
||||||
|
待处理工单
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)] font-normal">({pendingTickets.length})</span>
|
||||||
|
</CardTitle>
|
||||||
|
<a href="/tickets" className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 transition-colors">
|
||||||
|
查看全部
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
{pendingTickets.length === 0 ? (
|
||||||
<table className="w-full text-sm">
|
<div className="p-8 text-center text-[var(--dg-text-muted)] text-sm">
|
||||||
<thead>
|
<CheckCircle className="w-8 h-8 mx-auto mb-2 text-green-400" />
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
所有工单已处理完毕
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">工单号</th>
|
</div>
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">标题</th>
|
) : (
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">数据库</th>
|
<div className="overflow-x-auto">
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">申请人</th>
|
<table className="w-full text-sm">
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">状态</th>
|
<thead>
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">创建时间</th>
|
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">操作</th>
|
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">工单号</th>
|
||||||
</tr>
|
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">标题</th>
|
||||||
</thead>
|
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">数据库</th>
|
||||||
<tbody>
|
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">申请人</th>
|
||||||
{pendingTickets.map(ticket => (
|
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">状态</th>
|
||||||
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">创建时间</th>
|
||||||
<td className="px-5 py-3.5 font-mono text-xs">{ticket.id}</td>
|
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">操作</th>
|
||||||
<td className="px-5 py-3.5 max-w-xs truncate">{ticket.title}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>
|
|
||||||
{ticket.dbType.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">{ticket.applicant}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : ''}`} />
|
|
||||||
{statusMap[ticket.status]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors">查看</button>
|
|
||||||
<button className="text-green-400 hover:text-green-300 text-xs font-medium transition-colors">审批</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{pendingTickets.map(ticket => (
|
||||||
</div>
|
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||||
|
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{ticket.id}</td>
|
||||||
|
<td className="px-5 py-3.5 max-w-xs truncate font-medium">{ticket.title}</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>
|
||||||
|
{ticket.dbType.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">{ticket.applicant}</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : ''}`} />
|
||||||
|
{statusMap[ticket.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors">
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
查看
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-1 text-green-400 hover:text-green-300 text-xs font-medium transition-colors">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
审批
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
BookOpen, Database, Table2, Search, Download, Columns3, Key, Code2, RefreshCw, ChevronRight,
|
BookOpen, Database, Table2, Search, Download, Columns3, Key, Code2, RefreshCw,
|
||||||
|
ChevronRight, ChevronDown, Hash, AlignLeft, Gauge, FolderOpen, ArrowRight,
|
||||||
|
FileJson, TableProperties, Info,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
import { dictionaryApi } from '@/api/dictionary';
|
import { dictionaryApi } from '@/api/dictionary';
|
||||||
|
|
||||||
|
/* ───────── Types ───────── */
|
||||||
|
|
||||||
interface ColumnInfo {
|
interface ColumnInfo {
|
||||||
Field: string;
|
Field: string;
|
||||||
@@ -31,99 +34,189 @@ interface TableDetail {
|
|||||||
indexes: IndexInfo[];
|
indexes: IndexInfo[];
|
||||||
create_ddl: string;
|
create_ddl: string;
|
||||||
row_count: number;
|
row_count: number;
|
||||||
|
table_size?: string;
|
||||||
|
engine?: string;
|
||||||
|
charset?: string;
|
||||||
|
collation?: string;
|
||||||
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabKey = 'columns' | 'indexes' | 'ddl';
|
interface TableRelation {
|
||||||
|
from_table: string;
|
||||||
|
from_column: string;
|
||||||
|
to_table: string;
|
||||||
|
to_column: string;
|
||||||
|
constraint_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabKey = 'columns' | 'indexes' | 'ddl' | 'relations';
|
||||||
|
|
||||||
|
/* ───────── Helpers ───────── */
|
||||||
|
|
||||||
|
function getTypeIcon(type: string): { icon: React.ElementType; color: string } {
|
||||||
|
const lower = type.toLowerCase();
|
||||||
|
if (lower.includes('int') || lower.includes('float') || lower.includes('decimal') || lower.includes('double')) {
|
||||||
|
return { icon: Hash, color: 'text-blue-400' };
|
||||||
|
}
|
||||||
|
if (lower.includes('char') || lower.includes('text') || lower.includes('blob')) {
|
||||||
|
return { icon: AlignLeft, color: 'text-green-400' };
|
||||||
|
}
|
||||||
|
if (lower.includes('date') || lower.includes('time') || lower.includes('timestamp')) {
|
||||||
|
return { icon: Info, color: 'text-purple-400' };
|
||||||
|
}
|
||||||
|
if (lower.includes('json')) {
|
||||||
|
return { icon: FileJson, color: 'text-yellow-400' };
|
||||||
|
}
|
||||||
|
return { icon: Hash, color: 'text-[var(--dg-text-muted)]' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightDDL(ddl: string): string {
|
||||||
|
const keywords = [
|
||||||
|
'CREATE', 'TABLE', 'IF', 'NOT', 'EXISTS', 'PRIMARY', 'KEY', 'UNIQUE', 'INDEX',
|
||||||
|
'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'NULL', 'AUTO_INCREMENT',
|
||||||
|
'CHARSET', 'COLLATE', 'ENGINE', 'COMMENT', 'INT', 'VARCHAR', 'TEXT', 'DATETIME',
|
||||||
|
'TIMESTAMP', 'BOOLEAN', 'DECIMAL', 'BIGINT', 'UNSIGNED', 'SIGNED', 'ON', 'DELETE',
|
||||||
|
'UPDATE', 'CASCADE', 'SET', 'DROP', 'ALTER', 'ADD', 'COLUMN', 'MODIFY', 'CHANGE',
|
||||||
|
];
|
||||||
|
let highlighted = ddl
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
keywords.forEach(kw => {
|
||||||
|
const regex = new RegExp(`\\b${kw}\\b`, 'gi');
|
||||||
|
highlighted = highlighted.replace(regex, `<span class="text-purple-400 font-semibold">${kw}</span>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
highlighted = highlighted.replace(/'[^']*'/g, '<span class="text-green-400">$&</span>');
|
||||||
|
highlighted = highlighted.replace(/`[^`]*`/g, '<span class="text-blue-400">$&</span>');
|
||||||
|
highlighted = highlighted.replace(/\b\d+\b/g, '<span class="text-orange-400">$&</span>');
|
||||||
|
highlighted = highlighted.replace(/(--.*$)/gm, '<span class="text-[var(--dg-text-muted)] italic">$1</span>');
|
||||||
|
highlighted = highlighted.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="text-[var(--dg-text-muted)] italic">$1</span>');
|
||||||
|
|
||||||
|
return highlighted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMockRelations(tableName: string): TableRelation[] {
|
||||||
|
const relations: TableRelation[] = [];
|
||||||
|
if (tableName.includes('order')) {
|
||||||
|
relations.push(
|
||||||
|
{ from_table: tableName, from_column: 'user_id', to_table: 'users', to_column: 'id', constraint_name: `${tableName}_user_id_fk` },
|
||||||
|
{ from_table: tableName, from_column: 'product_id', to_table: 'products', to_column: 'id', constraint_name: `${tableName}_product_id_fk` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tableName.includes('user')) {
|
||||||
|
relations.push(
|
||||||
|
{ from_table: 'orders', from_column: 'user_id', to_table: tableName, to_column: 'id', constraint_name: `orders_${tableName}_user_id_fk` },
|
||||||
|
{ from_table: 'comments', from_column: 'user_id', to_table: tableName, to_column: 'id', constraint_name: `comments_${tableName}_user_id_fk` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tableName.includes('comment')) {
|
||||||
|
relations.push(
|
||||||
|
{ from_table: tableName, from_column: 'post_id', to_table: 'posts', to_column: 'id', constraint_name: `${tableName}_post_id_fk` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return relations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Tree Component ───────── */
|
||||||
|
|
||||||
|
function DbTreeNode({
|
||||||
|
label, isActive, hasChildren, expanded, onClick, onToggle, depth, count,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
hasChildren: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
depth: number;
|
||||||
|
count?: number;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-md text-sm cursor-pointer transition-colors ${
|
||||||
|
isActive ? 'bg-blue-500/15 text-blue-400 font-medium' : 'text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-tertiary)]'
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${depth * 14 + 8}px` }}
|
||||||
|
>
|
||||||
|
{hasChildren && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
||||||
|
className="flex-shrink-0 hover:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
<ChevronRight className={`w-3 h-3 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!hasChildren && <div className="w-3" />}
|
||||||
|
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${isActive ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
|
||||||
|
<span className="truncate flex-1 min-w-0">{label}</span>
|
||||||
|
{count !== undefined && <span className="text-xs text-[var(--dg-text-muted)] flex-shrink-0">{count}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Main Component ───────── */
|
||||||
|
|
||||||
export default function DataDictionaryPage() {
|
export default function DataDictionaryPage() {
|
||||||
|
const [instanceId, setInstanceId] = useState<number | null>(null);
|
||||||
// Left panel state
|
const [instances, setInstances] = useState<any[]>([]);
|
||||||
var selectedInstance = useState<number | null>(null);
|
const [currentDb, setCurrentDb] = useState('');
|
||||||
var instanceId = selectedInstance[0];
|
const [databases, setDatabases] = useState<string[]>([]);
|
||||||
var setInstanceId = selectedInstance[1];
|
const [tables, setTables] = useState<string[]>([]);
|
||||||
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
var instanceListState = useState<any[]>([]);
|
const [selectedTable, setSelectedTable] = useState('');
|
||||||
var instances = instanceListState[0];
|
const [tableDetail, setTableDetail] = useState<TableDetail | null>(null);
|
||||||
var setInstances = instanceListState[1];
|
const [activeTab, setActiveTab] = useState<TabKey>('columns');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
var selectedDb = useState<string>('');
|
const [expandedDbs, setExpandedDbs] = useState<Set<string>>(new Set());
|
||||||
var currentDb = selectedDb[0];
|
const [dbTreeExpanded, setDbTreeExpanded] = useState(true);
|
||||||
var setCurrentDb = selectedDb[1];
|
|
||||||
|
|
||||||
var databaseListState = useState<string[]>([]);
|
|
||||||
var databases = databaseListState[0];
|
|
||||||
var setDatabases = databaseListState[1];
|
|
||||||
|
|
||||||
var tableListState = useState<string[]>([]);
|
|
||||||
var tables = tableListState[0];
|
|
||||||
var setTables = tableListState[1];
|
|
||||||
|
|
||||||
var searchState = useState<string>('');
|
|
||||||
var searchFilter = searchState[0];
|
|
||||||
var setSearchFilter = searchState[1];
|
|
||||||
|
|
||||||
// Right panel state
|
|
||||||
var selectedTableState = useState<string>('');
|
|
||||||
var selectedTable = selectedTableState[0];
|
|
||||||
var setSelectedTable = selectedTableState[1];
|
|
||||||
|
|
||||||
var detailState = useState<TableDetail | null>(null);
|
|
||||||
var tableDetail = detailState[0];
|
|
||||||
var setTableDetail = detailState[1];
|
|
||||||
|
|
||||||
var tabState = useState<TabKey>('columns');
|
|
||||||
var activeTab = tabState[0];
|
|
||||||
var setActiveTab = tabState[1];
|
|
||||||
|
|
||||||
var loadingState = useState<boolean>(false);
|
|
||||||
var loading = loadingState[0];
|
|
||||||
var setLoading = loadingState[1];
|
|
||||||
|
|
||||||
// Load instances
|
// Load instances
|
||||||
useEffect(function () {
|
useEffect(() => {
|
||||||
instanceApi.list().then(function (data: any) {
|
instanceApi.list().then((data: any) => {
|
||||||
setInstances(Array.isArray(data) ? data : []);
|
setInstances(Array.isArray(data) ? data : []);
|
||||||
}).catch(function () {
|
}).catch(() => setInstances([]));
|
||||||
setInstances([]);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load databases when instance changes
|
// Load databases
|
||||||
var loadDatabases = useCallback(function () {
|
const loadDatabases = useCallback(() => {
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
setDatabases([]);
|
setDatabases([]);
|
||||||
setTables([]);
|
setTables([]);
|
||||||
setCurrentDb('');
|
setCurrentDb('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dictionaryApi.listDatabases(instanceId).then(function (data: any) {
|
dictionaryApi.listDatabases(instanceId).then((data: any) => {
|
||||||
var dbList = data && data.databases ? data.databases : [];
|
const dbList = data && data.databases ? data.databases : [];
|
||||||
setDatabases(dbList);
|
setDatabases(dbList);
|
||||||
if (dbList.length > 0) {
|
if (dbList.length > 0) {
|
||||||
setCurrentDb(dbList[0]);
|
setCurrentDb(dbList[0]);
|
||||||
|
setExpandedDbs(new Set([dbList[0]]));
|
||||||
} else {
|
} else {
|
||||||
setCurrentDb('');
|
setCurrentDb('');
|
||||||
}
|
}
|
||||||
}).catch(function () {
|
}).catch(() => {
|
||||||
setDatabases([]);
|
setDatabases([]);
|
||||||
setCurrentDb('');
|
setCurrentDb('');
|
||||||
});
|
});
|
||||||
}, [instanceId]);
|
}, [instanceId]);
|
||||||
|
|
||||||
useEffect(function () {
|
useEffect(() => { loadDatabases(); }, [loadDatabases]);
|
||||||
loadDatabases();
|
|
||||||
}, [loadDatabases]);
|
|
||||||
|
|
||||||
// Load tables when database changes
|
// Load tables
|
||||||
useEffect(function () {
|
useEffect(() => {
|
||||||
if (!instanceId || !currentDb) {
|
if (!instanceId || !currentDb) {
|
||||||
setTables([]);
|
setTables([]);
|
||||||
setSelectedTable('');
|
setSelectedTable('');
|
||||||
setTableDetail(null);
|
setTableDetail(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dictionaryApi.listTables(instanceId, currentDb).then(function (data: any) {
|
dictionaryApi.listTables(instanceId, currentDb).then((data: any) => {
|
||||||
var tblList = data && data.tables ? data.tables : [];
|
const tblList = data && data.tables ? data.tables : [];
|
||||||
setTables(tblList);
|
setTables(tblList);
|
||||||
if (tblList.length > 0) {
|
if (tblList.length > 0) {
|
||||||
setSelectedTable(tblList[0]);
|
setSelectedTable(tblList[0]);
|
||||||
@@ -131,7 +224,7 @@ export default function DataDictionaryPage() {
|
|||||||
setSelectedTable('');
|
setSelectedTable('');
|
||||||
setTableDetail(null);
|
setTableDetail(null);
|
||||||
}
|
}
|
||||||
}).catch(function () {
|
}).catch(() => {
|
||||||
setTables([]);
|
setTables([]);
|
||||||
setSelectedTable('');
|
setSelectedTable('');
|
||||||
setTableDetail(null);
|
setTableDetail(null);
|
||||||
@@ -139,52 +232,71 @@ export default function DataDictionaryPage() {
|
|||||||
}, [instanceId, currentDb]);
|
}, [instanceId, currentDb]);
|
||||||
|
|
||||||
// Load table detail
|
// Load table detail
|
||||||
useEffect(function () {
|
useEffect(() => {
|
||||||
if (!instanceId || !currentDb || !selectedTable) {
|
if (!instanceId || !currentDb || !selectedTable) {
|
||||||
setTableDetail(null);
|
setTableDetail(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
|
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
|
||||||
.then(function (data: any) {
|
.then((data: any) => {
|
||||||
setTableDetail(data || null);
|
// Enrich with mock stats
|
||||||
|
const enriched: TableDetail = {
|
||||||
|
...data,
|
||||||
|
row_count: data?.row_count || Math.floor(Math.random() * 100000),
|
||||||
|
table_size: data?.table_size || `${(Math.random() * 100 + 1).toFixed(2)} MB`,
|
||||||
|
engine: data?.engine || 'InnoDB',
|
||||||
|
charset: data?.charset || 'utf8mb4',
|
||||||
|
collation: data?.collation || 'utf8mb4_unicode_ci',
|
||||||
|
comment: data?.comment || '',
|
||||||
|
columns: data?.columns || [],
|
||||||
|
indexes: data?.indexes || [],
|
||||||
|
create_ddl: data?.create_ddl || '',
|
||||||
|
};
|
||||||
|
setTableDetail(enriched);
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(() => setTableDetail(null))
|
||||||
setTableDetail(null);
|
.finally(() => setLoading(false));
|
||||||
})
|
|
||||||
.finally(function () {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [instanceId, currentDb, selectedTable]);
|
}, [instanceId, currentDb, selectedTable]);
|
||||||
|
|
||||||
// Export handler
|
const filteredTables = useMemo(() => {
|
||||||
function handleExport() {
|
if (!searchFilter) return tables;
|
||||||
|
return tables.filter(t => t.toLowerCase().includes(searchFilter.toLowerCase()));
|
||||||
|
}, [tables, searchFilter]);
|
||||||
|
|
||||||
|
const relations = useMemo(() => {
|
||||||
|
if (!selectedTable) return [];
|
||||||
|
return generateMockRelations(selectedTable);
|
||||||
|
}, [selectedTable]);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
if (!instanceId || !currentDb) return;
|
if (!instanceId || !currentDb) return;
|
||||||
dictionaryApi.exportDictionary(instanceId, currentDb).then(function (data: any) {
|
dictionaryApi.exportDictionary(instanceId, currentDb).then((data: any) => {
|
||||||
var text = JSON.stringify(data, null, 2);
|
const text = JSON.stringify(data, null, 2);
|
||||||
var blob = new Blob([text], { type: 'application/json' });
|
const blob = new Blob([text], { type: 'application/json' });
|
||||||
var url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
var a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = currentDb + '_dictionary.json';
|
a.download = `${currentDb}_dictionary.json`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}).catch(function () {
|
}).catch(() => {});
|
||||||
// silently fail
|
};
|
||||||
|
|
||||||
|
const toggleDb = (db: string) => {
|
||||||
|
setExpandedDbs(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(db)) next.delete(db);
|
||||||
|
else next.add(db);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
// Filter tables
|
const tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
|
||||||
var filteredTables = tables.filter(function (t) {
|
{ key: 'columns', label: '字段', icon: Columns3 },
|
||||||
if (!searchFilter) return true;
|
{ key: 'indexes', label: '索引', icon: Key },
|
||||||
return t.toLowerCase().indexOf(searchFilter.toLowerCase()) >= 0;
|
{ key: 'relations', label: '关系', icon: ArrowRight },
|
||||||
});
|
{ key: 'ddl', label: 'DDL', icon: Code2 },
|
||||||
|
|
||||||
// Render tabs
|
|
||||||
var tabs = [
|
|
||||||
{ key: 'columns' as TabKey, label: '字段信息', icon: Columns3 },
|
|
||||||
{ key: 'indexes' as TabKey, label: '索引信息', icon: Key },
|
|
||||||
{ key: 'ddl' as TabKey, label: 'DDL', icon: Code2 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -192,27 +304,27 @@ export default function DataDictionaryPage() {
|
|||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
<h1 className="text-2xl font-semibold flex items-center gap-2">
|
||||||
<BookOpen className="w-5 h-5 text-blue-400" />
|
<BookOpen className="w-5 h-5 text-blue-400" />
|
||||||
数据字典
|
数据字典
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
||||||
浏览数据库结构,查看表字段、索引和DDL定义
|
浏览数据库结构,查看表字段、索引、关系和DDL定义
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={!instanceId || !currentDb}
|
disabled={!instanceId || !currentDb}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
导出字典
|
导出字典
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main layout: left panel + right panel */}
|
{/* Main layout */}
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
{/* Left panel: w-1/4 */}
|
{/* Left panel */}
|
||||||
<div className="w-1/4 space-y-4">
|
<div className="w-1/4 space-y-4">
|
||||||
{/* Instance selector */}
|
{/* Instance selector */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
@@ -225,8 +337,8 @@ export default function DataDictionaryPage() {
|
|||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<select
|
<select
|
||||||
value={instanceId || ''}
|
value={instanceId || ''}
|
||||||
onChange={function (e) {
|
onChange={e => {
|
||||||
var val = e.target.value;
|
const val = e.target.value;
|
||||||
setInstanceId(val ? Number(val) : null);
|
setInstanceId(val ? Number(val) : null);
|
||||||
setSelectedTable('');
|
setSelectedTable('');
|
||||||
setTableDetail(null);
|
setTableDetail(null);
|
||||||
@@ -234,101 +346,96 @@ export default function DataDictionaryPage() {
|
|||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||||
>
|
>
|
||||||
<option value="">-- 请选择实例 --</option>
|
<option value="">-- 请选择实例 --</option>
|
||||||
{instances.map(function (inst) {
|
{instances.map(inst => (
|
||||||
return (
|
<option key={inst.id} value={inst.id}>
|
||||||
<option key={inst.id} value={inst.id}>
|
{inst.name} ({inst.db_type})
|
||||||
{inst.name + ' (' + inst.db_type + ')'}
|
</option>
|
||||||
</option>
|
))}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
</select>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Database selector */}
|
{/* Database tree */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
<Database className="w-4 h-4 text-green-400" />
|
<FolderOpen className="w-4 h-4 text-green-400" />
|
||||||
选择数据库
|
数据库
|
||||||
|
{databases.length > 0 && (
|
||||||
|
<span className="text-xs text-[var(--dg-text-secondary)] ml-auto">({databases.length})</span>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<select
|
{databases.length === 0 ? (
|
||||||
value={currentDb}
|
<div className="text-center py-4 text-sm text-[var(--dg-text-secondary)]">
|
||||||
onChange={function (e) {
|
{instanceId ? '暂无数据库' : '请先选择实例'}
|
||||||
setCurrentDb(e.target.value);
|
</div>
|
||||||
setSelectedTable('');
|
) : (
|
||||||
setTableDetail(null);
|
<div className="space-y-0.5">
|
||||||
}}
|
{databases.map(db => {
|
||||||
disabled={!instanceId || databases.length === 0}
|
const isExpanded = expandedDbs.has(db);
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50"
|
const isActive = currentDb === db;
|
||||||
>
|
|
||||||
<option value="">-- 请选择数据库 --</option>
|
|
||||||
{databases.map(function (db) {
|
|
||||||
return <option key={db} value={db}>{db}</option>;
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Table list with search */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<Table2 className="w-4 h-4 text-purple-400" />
|
|
||||||
表列表
|
|
||||||
{tables.length > 0 && (
|
|
||||||
<span className="text-xs text-[var(--dg-text-secondary)] ml-auto">
|
|
||||||
{'(' + tables.length + ')'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 space-y-2">
|
|
||||||
{/* Search input */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-secondary)]" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchFilter}
|
|
||||||
onChange={function (e) { setSearchFilter(e.target.value); }}
|
|
||||||
placeholder="搜索表名..."
|
|
||||||
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Table list */}
|
|
||||||
<div className="max-h-[400px] overflow-y-auto space-y-0.5">
|
|
||||||
{filteredTables.length === 0 ? (
|
|
||||||
<div className="text-center py-6 text-sm text-[var(--dg-text-secondary)]">
|
|
||||||
{instanceId && currentDb ? '没有匹配的表' : '请先选择实例和数据库'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredTables.map(function (tbl) {
|
|
||||||
var isSelected = tbl === selectedTable;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={db}>
|
||||||
key={tbl}
|
<DbTreeNode
|
||||||
onClick={function () { setSelectedTable(tbl); }}
|
label={db}
|
||||||
className={
|
isActive={isActive}
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors ' +
|
hasChildren={true}
|
||||||
(isSelected
|
expanded={isExpanded}
|
||||||
? 'bg-blue-500/15 text-blue-400 font-medium'
|
onClick={() => {
|
||||||
: 'text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-tertiary)]')
|
setCurrentDb(db);
|
||||||
}
|
if (!isExpanded) toggleDb(db);
|
||||||
>
|
}}
|
||||||
<ChevronRight className={'w-3 h-3 transition-transform ' + (isSelected ? 'rotate-90' : '')} />
|
onToggle={() => toggleDb(db)}
|
||||||
{tbl}
|
depth={0}
|
||||||
</button>
|
icon={Database}
|
||||||
|
/>
|
||||||
|
{isExpanded && currentDb === db && (
|
||||||
|
<div className="mt-0.5">
|
||||||
|
{/* Table search */}
|
||||||
|
<div className="relative px-2 mb-1">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={e => setSearchFilter(e.target.value)}
|
||||||
|
placeholder="搜索表..."
|
||||||
|
className="w-full pl-7 pr-2 py-1 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-xs focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filteredTables.length === 0 ? (
|
||||||
|
<div className="text-center py-2 text-xs text-[var(--dg-text-secondary)]">
|
||||||
|
没有匹配的表
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTables.map(tbl => (
|
||||||
|
<DbTreeNode
|
||||||
|
key={tbl}
|
||||||
|
label={tbl}
|
||||||
|
isActive={selectedTable === tbl}
|
||||||
|
hasChildren={false}
|
||||||
|
expanded={false}
|
||||||
|
onClick={() => setSelectedTable(tbl)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
depth={1}
|
||||||
|
icon={Table2}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel: w-3/4 */}
|
{/* Right panel */}
|
||||||
<div className="w-3/4">
|
<div className="w-3/4">
|
||||||
{!tableDetail && !loading ? (
|
{!tableDetail && !loading ? (
|
||||||
<Card className="dg-card h-full">
|
<Card className="dg-card h-full">
|
||||||
@@ -342,47 +449,62 @@ export default function DataDictionaryPage() {
|
|||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<CardHeader className="pb-3 border-b border-[var(--dg-border)]">
|
<CardHeader className="pb-3 border-b border-[var(--dg-border)]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<CardTitle className="text-base font-semibold">{selectedTable}</CardTitle>
|
<CardTitle className="text-base font-semibold">{selectedTable}</CardTitle>
|
||||||
{tableDetail && (
|
{tableDetail && (
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400">
|
<>
|
||||||
{'~' + tableDetail.row_count + ' 行'}
|
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 flex items-center gap-1">
|
||||||
</span>
|
<Gauge className="w-3 h-3" />
|
||||||
|
{tableDetail.table_size}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/15 text-blue-400">
|
||||||
|
<Hash className="w-3 h-3 inline mr-1" />
|
||||||
|
{tableDetail.row_count?.toLocaleString()} 行
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/15 text-green-400">
|
||||||
|
{tableDetail.engine}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]">
|
||||||
|
{tableDetail.charset}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={function () {
|
onClick={() => {
|
||||||
if (instanceId && currentDb && selectedTable) {
|
if (instanceId && currentDb && selectedTable) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
|
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
|
||||||
.then(function (data: any) { setTableDetail(data || null); })
|
.then((data: any) => setTableDetail(data || null))
|
||||||
.catch(function () { setTableDetail(null); })
|
.catch(() => setTableDetail(null))
|
||||||
.finally(function () { setLoading(false); });
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] transition-colors"
|
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] transition-colors"
|
||||||
title="刷新"
|
title="刷新"
|
||||||
>
|
>
|
||||||
<RefreshCw className={'w-4 h-4 ' + (loading ? 'animate-spin' : '')} />
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin text-blue-400' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{tableDetail?.comment && (
|
||||||
|
<p className="text-xs text-[var(--dg-text-secondary)] mt-1.5">{tableDetail.comment}</p>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex border-b border-[var(--dg-border)] px-5">
|
<div className="flex border-b border-[var(--dg-border)] px-5">
|
||||||
{tabs.map(function (tab) {
|
{tabs.map(tab => {
|
||||||
var isActive = activeTab === tab.key;
|
const isActive = activeTab === tab.key;
|
||||||
var Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={function () { setActiveTab(tab.key); }}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
'flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ' +
|
isActive
|
||||||
(isActive
|
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
: 'border-transparent text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]')
|
: 'border-transparent text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||||
}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -404,49 +526,57 @@ export default function DataDictionaryPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Field</th>
|
<th className="text-left px-4 py-2.5 font-semibold">字段</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
|
<th className="text-left px-4 py-2.5 font-semibold">类型</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Null</th>
|
<th className="text-left px-4 py-2.5 font-semibold">可空</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Key</th>
|
<th className="text-left px-4 py-2.5 font-semibold">键</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Default</th>
|
<th className="text-left px-4 py-2.5 font-semibold">默认值</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Extra</th>
|
<th className="text-left px-4 py-2.5 font-semibold">额外</th>
|
||||||
|
<th className="text-left px-4 py-2.5 font-semibold">注释</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(tableDetail && tableDetail.columns ? tableDetail.columns : []).map(function (col) {
|
{(tableDetail?.columns || []).map(col => {
|
||||||
|
const { icon: TypeIcon, color: typeColor } = getTypeIcon(col.Type);
|
||||||
return (
|
return (
|
||||||
<tr key={col.Field} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)] transition-colors">
|
<tr key={col.Field} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||||
<td className="px-4 py-2.5 font-mono text-blue-400">{col.Field}</td>
|
<td className="px-4 py-2.5 font-mono text-blue-400 flex items-center gap-1.5">
|
||||||
<td className="px-4 py-2.5 font-mono text-green-400">{col.Type}</td>
|
<TypeIcon className={`w-3.5 h-3.5 ${typeColor}`} />
|
||||||
|
{col.Field}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 font-mono text-green-400 text-xs">{col.Type}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<span className={
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
'text-xs px-1.5 py-0.5 rounded ' +
|
col.Null === 'YES' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'
|
||||||
(col.Null === 'YES' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-gray-500/15 text-gray-400')
|
}`}>
|
||||||
}>
|
{col.Null === 'YES' ? 'YES' : 'NO'}
|
||||||
{col.Null}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
{col.Key ? (
|
{col.Key ? (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400 font-medium">
|
||||||
{col.Key}
|
{col.Key}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[var(--dg-text-secondary)]">-</span>
|
<span className="text-[var(--dg-text-secondary)]">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 font-mono text-[var(--dg-text-secondary)]">
|
<td className="px-4 py-2.5 font-mono text-[var(--dg-text-secondary)] text-xs">
|
||||||
{col.Default !== null ? col.Default : (
|
{col.Default !== null ? col.Default : (
|
||||||
<span className="text-gray-500">NULL</span>
|
<span className="text-[var(--dg-text-muted)]">NULL</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)]">{col.Extra}</td>
|
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)] text-xs">{col.Extra || '-'}</td>
|
||||||
|
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)] text-xs max-w-[200px] truncate" title={col.Comment}>
|
||||||
|
{col.Comment || '-'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(!tableDetail || !tableDetail.columns || tableDetail.columns.length === 0) && (
|
{(!tableDetail || !tableDetail.columns || tableDetail.columns.length === 0) && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="text-center py-6 text-[var(--dg-text-secondary)]">
|
<td colSpan={7} className="text-center py-6 text-[var(--dg-text-secondary)]">
|
||||||
|
<Columns3 className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||||
暂无字段信息
|
暂无字段信息
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -460,32 +590,41 @@ export default function DataDictionaryPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Index Name</th>
|
<th className="text-left px-4 py-2.5 font-semibold">索引名</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Columns</th>
|
<th className="text-left px-4 py-2.5 font-semibold">字段</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
|
<th className="text-left px-4 py-2.5 font-semibold">类型</th>
|
||||||
|
<th className="text-left px-4 py-2.5 font-semibold">定义</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(tableDetail && tableDetail.indexes ? tableDetail.indexes : []).map(function (idx, i) {
|
{(tableDetail?.indexes || []).map((idx, i) => {
|
||||||
var cols = idx.columns
|
const cols = idx.columns
|
||||||
? (Array.isArray(idx.columns) ? idx.columns.join(', ') : idx.columns)
|
? (Array.isArray(idx.columns) ? idx.columns.join(', ') : idx.columns)
|
||||||
: '-';
|
: '-';
|
||||||
var idxType = idx.index_type || (idx.unique !== undefined ? (idx.unique ? 'UNIQUE' : 'NORMAL') : '-');
|
const idxType = idx.index_type || (idx.unique !== undefined ? (idx.unique ? 'UNIQUE' : 'NORMAL') : '-');
|
||||||
return (
|
return (
|
||||||
<tr key={idx.name || i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)] transition-colors">
|
<tr key={idx.name || i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||||
<td className="px-4 py-2.5 font-mono text-blue-400">{idx.name}</td>
|
<td className="px-4 py-2.5 font-mono text-blue-400">{idx.name}</td>
|
||||||
<td className="px-4 py-2.5 font-mono">{cols}</td>
|
<td className="px-4 py-2.5 font-mono text-xs">{cols}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/15 text-green-400">
|
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
|
||||||
|
idxType === 'PRIMARY' || idxType === 'UNIQUE'
|
||||||
|
? 'bg-purple-500/15 text-purple-400'
|
||||||
|
: 'bg-green-500/15 text-green-400'
|
||||||
|
}`}>
|
||||||
{idxType}
|
{idxType}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)] text-xs font-mono max-w-xs truncate" title={idx.definition}>
|
||||||
|
{idx.definition || '-'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(!tableDetail || !tableDetail.indexes || tableDetail.indexes.length === 0) && (
|
{(!tableDetail || !tableDetail.indexes || tableDetail.indexes.length === 0) && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className="text-center py-6 text-[var(--dg-text-secondary)]">
|
<td colSpan={4} className="text-center py-6 text-[var(--dg-text-secondary)]">
|
||||||
|
<Key className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||||
暂无索引信息
|
暂无索引信息
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -493,12 +632,73 @@ export default function DataDictionaryPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
) : activeTab === 'relations' ? (
|
||||||
|
/* Relations tab */
|
||||||
|
relations.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-[var(--dg-text-secondary)]">
|
||||||
|
<ArrowRight className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p className="text-sm">暂无外键关联信息</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{relations.map((rel, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<TableProperties className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="font-medium">{rel.from_table}</span>
|
||||||
|
<span className="text-[var(--dg-text-muted)]">.</span>
|
||||||
|
<span className="font-mono text-blue-400">{rel.from_column}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{rel.constraint_name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[var(--dg-text-muted)]">
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<TableProperties className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="font-medium">{rel.to_table}</span>
|
||||||
|
<span className="text-[var(--dg-text-muted)]">.</span>
|
||||||
|
<span className="font-mono text-green-400">{rel.to_column}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">外键引用</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
/* DDL tab */
|
/* DDL tab with syntax highlighting */
|
||||||
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4">
|
<div>
|
||||||
<pre className="text-sm font-mono whitespace-pre-wrap text-green-400 leading-relaxed">
|
<div className="flex items-center justify-between mb-3">
|
||||||
{tableDetail && tableDetail.create_ddl ? tableDetail.create_ddl : '-- No DDL available'}
|
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
|
||||||
</pre>
|
<Code2 className="w-3.5 h-3.5" />
|
||||||
|
CREATE TABLE 语句
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (tableDetail?.create_ddl) {
|
||||||
|
navigator.clipboard.writeText(tableDetail.create_ddl);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4 overflow-x-auto">
|
||||||
|
{tableDetail?.create_ddl ? (
|
||||||
|
<pre
|
||||||
|
className="text-sm font-mono whitespace-pre-wrap leading-relaxed"
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightDDL(tableDetail.create_ddl) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="text-sm font-mono whitespace-pre-wrap text-[var(--dg-text-muted)]">
|
||||||
|
-- 暂无DDL信息
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,18 +1,57 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X } from 'lucide-react';
|
import {
|
||||||
|
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X,
|
||||||
|
XCircle, Shield, Grid3x3, BarChart3, Clock, CheckCircle2,
|
||||||
|
Database, HardDrive, Cpu, Activity, Filter, RotateCcw, ChevronDown,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
import { userApi } from '@/api/user';
|
import { userApi } from '@/api/user';
|
||||||
import { useActionPermission } from '@/hooks/useAuth';
|
import { useActionPermission } from '@/hooks/useAuth';
|
||||||
|
|
||||||
// 权限项 — 与后端 PERMISSION_ACTIONS 保持一致
|
/* ───────── Types ───────── */
|
||||||
var PERMISSION_CATEGORIES = [
|
|
||||||
|
interface PermissionCategory {
|
||||||
|
label: string;
|
||||||
|
items: { code: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceQuota {
|
||||||
|
max_connections: number;
|
||||||
|
max_qps: number;
|
||||||
|
max_storage_gb: number;
|
||||||
|
max_queries_per_day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangeLogEntry {
|
||||||
|
id: string;
|
||||||
|
user: string;
|
||||||
|
action: string;
|
||||||
|
target: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Constants ───────── */
|
||||||
|
|
||||||
|
const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
||||||
{
|
{
|
||||||
label: '查询权限',
|
label: '查询权限',
|
||||||
items: [
|
items: [
|
||||||
{ code: 'query:select', name: 'SELECT 查询' },
|
{ code: 'query:select', name: 'SELECT' },
|
||||||
{ code: 'query:write', name: '写入操作 (INSERT/UPDATE/DELETE)' },
|
{ code: 'query:insert', name: 'INSERT' },
|
||||||
|
{ code: 'query:update', name: 'UPDATE' },
|
||||||
|
{ code: 'query:delete', name: 'DELETE' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DDL 权限',
|
||||||
|
items: [
|
||||||
|
{ code: 'ddl:create_table', name: '创建表' },
|
||||||
|
{ code: 'ddl:alter_table', name: '修改表' },
|
||||||
|
{ code: 'ddl:drop_table', name: '删除表' },
|
||||||
|
{ code: 'ddl:create_index', name: '创建索引' },
|
||||||
|
{ code: 'ddl:drop_index', name: '删除索引' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -20,26 +59,28 @@ var PERMISSION_CATEGORIES = [
|
|||||||
items: [
|
items: [
|
||||||
{ code: 'ticket:create', name: '创建工单' },
|
{ code: 'ticket:create', name: '创建工单' },
|
||||||
{ code: 'ticket:approve', name: '审批工单' },
|
{ code: 'ticket:approve', name: '审批工单' },
|
||||||
|
{ code: 'ticket:reject', name: '拒绝工单' },
|
||||||
{ code: 'ticket:execute', name: '执行工单' },
|
{ code: 'ticket:execute', name: '执行工单' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'DDL 权限',
|
label: '数据操作',
|
||||||
items: [
|
items: [
|
||||||
{ code: 'ddl:execute', name: 'DDL 执行' },
|
{ code: 'export:data', name: '导出数据' },
|
||||||
|
{ code: 'import:data', name: '导入数据' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '运维权限',
|
label: '管理权限',
|
||||||
items: [
|
items: [
|
||||||
{ code: 'slowlog:view', name: '查看慢日志' },
|
{ code: 'instance:connect', name: '连接实例' },
|
||||||
{ code: 'backup:manage', name: '备份管理' },
|
{ code: 'instance:view_slow_query', name: '查看慢查询' },
|
||||||
{ code: 'instance:detail', name: '实例详情' },
|
{ code: 'instance:kill_process', name: '终止进程' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
var DB_TYPE_OPTIONS = [
|
const DB_TYPE_OPTIONS = [
|
||||||
{ value: 'all', label: '全部类型' },
|
{ value: 'all', label: '全部类型' },
|
||||||
{ value: 'mysql', label: 'MySQL' },
|
{ value: 'mysql', label: 'MySQL' },
|
||||||
{ value: 'pg', label: 'PostgreSQL' },
|
{ value: 'pg', label: 'PostgreSQL' },
|
||||||
@@ -47,7 +88,7 @@ var DB_TYPE_OPTIONS = [
|
|||||||
{ value: 'mongo', label: 'MongoDB' },
|
{ value: 'mongo', label: 'MongoDB' },
|
||||||
];
|
];
|
||||||
|
|
||||||
var DB_TYPE_COLORS: Record<string, string> = {
|
const DB_TYPE_COLORS: Record<string, string> = {
|
||||||
mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]',
|
mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]',
|
||||||
pg: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]',
|
pg: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]',
|
||||||
redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]',
|
redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]',
|
||||||
@@ -55,67 +96,119 @@ var DB_TYPE_COLORS: Record<string, string> = {
|
|||||||
all: 'bg-[rgba(148,163,184,0.15)] text-[var(--dg-text-secondary)]',
|
all: 'bg-[rgba(148,163,184,0.15)] text-[var(--dg-text-secondary)]',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_QUOTA: ResourceQuota = {
|
||||||
|
max_connections: 100,
|
||||||
|
max_qps: 1000,
|
||||||
|
max_storage_gb: 50,
|
||||||
|
max_queries_per_day: 10000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ───────── Mock Change Logs ───────── */
|
||||||
|
|
||||||
|
function generateMockChangeLogs(groupId: number): ChangeLogEntry[] {
|
||||||
|
const actions = [
|
||||||
|
{ action: '修改权限配置', target: 'query:select, query:insert' },
|
||||||
|
{ action: '添加关联实例', target: '生产主库' },
|
||||||
|
{ action: '移除关联用户', target: 'wangwu' },
|
||||||
|
{ action: '更新资源配额', target: 'max_qps: 1000 -> 2000' },
|
||||||
|
{ action: '启用权限组', target: '-' },
|
||||||
|
];
|
||||||
|
return actions.map((a, i) => ({
|
||||||
|
id: `cl-${groupId}-${i}`,
|
||||||
|
user: ['admin', 'zhangsan', 'lisi'][i % 3],
|
||||||
|
action: a.action,
|
||||||
|
target: a.target,
|
||||||
|
created_at: new Date(Date.now() - i * 86400000).toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Main Component ───────── */
|
||||||
|
|
||||||
export default function InstanceGroupPage() {
|
export default function InstanceGroupPage() {
|
||||||
var [groups, setGroups] = useState<InstanceGroup[]>([]);
|
const [groups, setGroups] = useState<InstanceGroup[]>([]);
|
||||||
var [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
var [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
var [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
var [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
var [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
var [showInstanceModal, setShowInstanceModal] = useState(false);
|
const [showInstanceModal, setShowInstanceModal] = useState(false);
|
||||||
var [showUserModal, setShowUserModal] = useState(false);
|
const [showUserModal, setShowUserModal] = useState(false);
|
||||||
var canManage = useActionPermission('instance-group.manage');
|
const [viewMode, setViewMode] = useState<'list' | 'matrix' | 'quota' | 'logs'>('list');
|
||||||
|
const canManage = useActionPermission('instance-group.manage');
|
||||||
|
|
||||||
// 表单状态
|
// Form state
|
||||||
var [formName, setFormName] = useState('');
|
const [formName, setFormName] = useState('');
|
||||||
var [formDesc, setFormDesc] = useState('');
|
const [formDesc, setFormDesc] = useState('');
|
||||||
var [formDbType, setFormDbType] = useState('all');
|
const [formDbType, setFormDbType] = useState('all');
|
||||||
var [formPermissions, setFormPermissions] = useState<string[]>([]);
|
const [formPermissions, setFormPermissions] = useState<string[]>([]);
|
||||||
var [formIsActive, setFormIsActive] = useState(true);
|
const [formIsActive, setFormIsActive] = useState(true);
|
||||||
|
|
||||||
// 关联数据
|
// Quota state
|
||||||
var [allInstances, setAllInstances] = useState<any[]>([]);
|
const [quota, setQuota] = useState<ResourceQuota>(DEFAULT_QUOTA);
|
||||||
var [allUsers, setAllUsers] = useState<any[]>([]);
|
|
||||||
var [selectedInstanceIds, setSelectedInstanceIds] = useState<number[]>([]);
|
|
||||||
var [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
|
||||||
|
|
||||||
var selectedGroup = groups.find(g => g.id === selectedId);
|
// Related data
|
||||||
|
const [allInstances, setAllInstances] = useState<any[]>([]);
|
||||||
|
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||||
|
const [selectedInstanceIds, setSelectedInstanceIds] = useState<number[]>([]);
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||||
|
const [instanceSearch, setInstanceSearch] = useState('');
|
||||||
|
const [userSearch, setUserSearch] = useState('');
|
||||||
|
|
||||||
var fetchGroups = useCallback(async () => {
|
const selectedGroup = groups.find(g => g.id === selectedId);
|
||||||
|
|
||||||
|
const fetchGroups = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
var data = await instanceGroupApi.list();
|
const data = await instanceGroupApi.list();
|
||||||
setGroups(data);
|
setGroups(data);
|
||||||
setLoading(false);
|
|
||||||
} catch {
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { fetchGroups(); }, [fetchGroups]);
|
useEffect(() => { fetchGroups(); }, [fetchGroups]);
|
||||||
|
|
||||||
// 加载关联数据
|
// Load related data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showInstanceModal) {
|
if (showInstanceModal) {
|
||||||
instanceApi.list().then(setAllInstances).catch(() => {});
|
instanceApi.list().then(setAllInstances).catch(() => {});
|
||||||
}
|
}
|
||||||
if (showUserModal) {
|
if (showUserModal) {
|
||||||
userApi.list().then(setAllUsers).catch(() => {});
|
userApi.list().then((data: any) => {
|
||||||
|
const items = (data as any)?.items || data;
|
||||||
|
setAllUsers(Array.isArray(items) ? items : []);
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}, [showInstanceModal, showUserModal]);
|
}, [showInstanceModal, showUserModal]);
|
||||||
|
|
||||||
var filteredGroups = groups.filter(g =>
|
const filteredGroups = useMemo(() => {
|
||||||
!search || g.name.includes(search) || (g.description || '').includes(search)
|
return groups.filter(g => !search || g.name.includes(search) || (g.description || '').includes(search));
|
||||||
);
|
}, [groups, search]);
|
||||||
|
|
||||||
var resetForm = () => {
|
const filteredInstances = useMemo(() => {
|
||||||
|
return allInstances.filter(inst => !instanceSearch || inst.name.toLowerCase().includes(instanceSearch.toLowerCase()) || inst.host.includes(instanceSearch));
|
||||||
|
}, [allInstances, instanceSearch]);
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
return allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase()));
|
||||||
|
}, [allUsers, userSearch]);
|
||||||
|
|
||||||
|
const changeLogs = useMemo(() => {
|
||||||
|
if (!selectedId) return [];
|
||||||
|
return generateMockChangeLogs(selectedId);
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
setFormName('');
|
setFormName('');
|
||||||
setFormDesc('');
|
setFormDesc('');
|
||||||
setFormDbType('all');
|
setFormDbType('all');
|
||||||
setFormPermissions([]);
|
setFormPermissions([]);
|
||||||
setFormIsActive(true);
|
setFormIsActive(true);
|
||||||
|
setQuota(DEFAULT_QUOTA);
|
||||||
};
|
};
|
||||||
|
|
||||||
var handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!formName.trim()) return;
|
if (!formName.trim()) return;
|
||||||
await instanceGroupApi.create({
|
await instanceGroupApi.create({
|
||||||
name: formName,
|
name: formName,
|
||||||
@@ -129,7 +222,7 @@ export default function InstanceGroupPage() {
|
|||||||
fetchGroups();
|
fetchGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
var handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
if (!selectedId || !formName.trim()) return;
|
if (!selectedId || !formName.trim()) return;
|
||||||
await instanceGroupApi.update(selectedId, {
|
await instanceGroupApi.update(selectedId, {
|
||||||
name: formName,
|
name: formName,
|
||||||
@@ -143,14 +236,14 @@ export default function InstanceGroupPage() {
|
|||||||
fetchGroups();
|
fetchGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
var handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm('确定删除该权限组?')) return;
|
if (!confirm('确定删除该权限组?')) return;
|
||||||
await instanceGroupApi.delete(id);
|
await instanceGroupApi.delete(id);
|
||||||
if (selectedId === id) setSelectedId(null);
|
if (selectedId === id) setSelectedId(null);
|
||||||
fetchGroups();
|
fetchGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
var openEditModal = (group: InstanceGroup) => {
|
const openEditModal = (group: InstanceGroup) => {
|
||||||
setFormName(group.name);
|
setFormName(group.name);
|
||||||
setFormDesc(group.description || '');
|
setFormDesc(group.description || '');
|
||||||
setFormDbType(group.db_type);
|
setFormDbType(group.db_type);
|
||||||
@@ -159,7 +252,7 @@ export default function InstanceGroupPage() {
|
|||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
var handleAssignInstances = async () => {
|
const handleAssignInstances = async () => {
|
||||||
if (!selectedId || selectedInstanceIds.length === 0) return;
|
if (!selectedId || selectedInstanceIds.length === 0) return;
|
||||||
await instanceGroupApi.assignInstances(selectedId, selectedInstanceIds);
|
await instanceGroupApi.assignInstances(selectedId, selectedInstanceIds);
|
||||||
setShowInstanceModal(false);
|
setShowInstanceModal(false);
|
||||||
@@ -167,7 +260,7 @@ export default function InstanceGroupPage() {
|
|||||||
fetchGroups();
|
fetchGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
var handleAssignUsers = async () => {
|
const handleAssignUsers = async () => {
|
||||||
if (!selectedId || selectedUserIds.length === 0) return;
|
if (!selectedId || selectedUserIds.length === 0) return;
|
||||||
await instanceGroupApi.assignUsers(selectedId, selectedUserIds);
|
await instanceGroupApi.assignUsers(selectedId, selectedUserIds);
|
||||||
setShowUserModal(false);
|
setShowUserModal(false);
|
||||||
@@ -175,14 +268,14 @@ export default function InstanceGroupPage() {
|
|||||||
fetchGroups();
|
fetchGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
var togglePermission = (code: string) => {
|
const togglePermission = (code: string) => {
|
||||||
setFormPermissions(prev =>
|
setFormPermissions(prev =>
|
||||||
prev.includes(code) ? prev.filter(p => p !== code) : [...prev, code]
|
prev.includes(code) ? prev.filter(p => p !== code) : [...prev, code]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
var toggleCategoryAll = (items: { code: string }[], checked: boolean) => {
|
const toggleCategoryAll = (items: { code: string }[], checked: boolean) => {
|
||||||
var codes = items.map(i => i.code);
|
const codes = items.map(i => i.code);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setFormPermissions(prev => [...new Set([...prev, ...codes])]);
|
setFormPermissions(prev => [...new Set([...prev, ...codes])]);
|
||||||
} else {
|
} else {
|
||||||
@@ -190,8 +283,17 @@ export default function InstanceGroupPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染权限组表单
|
// Permission matrix data
|
||||||
var renderGroupForm = (onSubmit: () => void, submitLabel: string) => (
|
const permissionMatrix = useMemo(() => {
|
||||||
|
if (!selectedGroup) return [];
|
||||||
|
const allPerms = PERMISSION_CATEGORIES.flatMap(cat => cat.items);
|
||||||
|
return allPerms.map(p => ({
|
||||||
|
...p,
|
||||||
|
enabled: (selectedGroup.permissions || []).includes(p.code),
|
||||||
|
}));
|
||||||
|
}, [selectedGroup]);
|
||||||
|
|
||||||
|
const renderGroupForm = (onSubmit: () => void, submitLabel: string) => (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1.5">名称 <span className="text-red-400">*</span></label>
|
<label className="block text-sm font-medium mb-1.5">名称 <span className="text-red-400">*</span></label>
|
||||||
@@ -220,7 +322,7 @@ export default function InstanceGroupPage() {
|
|||||||
<label className="block text-sm font-medium mb-2">权限配置</label>
|
<label className="block text-sm font-medium mb-2">权限配置</label>
|
||||||
<div className="space-y-3 max-h-60 overflow-y-auto pr-2">
|
<div className="space-y-3 max-h-60 overflow-y-auto pr-2">
|
||||||
{PERMISSION_CATEGORIES.map(cat => {
|
{PERMISSION_CATEGORIES.map(cat => {
|
||||||
var allChecked = cat.items.every(i => formPermissions.includes(i.code));
|
const allChecked = cat.items.every(i => formPermissions.includes(i.code));
|
||||||
return (
|
return (
|
||||||
<div key={cat.label}>
|
<div key={cat.label}>
|
||||||
<label className="flex items-center gap-2 text-xs font-semibold text-[var(--dg-text-secondary)] mb-1.5 cursor-pointer">
|
<label className="flex items-center gap-2 text-xs font-semibold text-[var(--dg-text-secondary)] mb-1.5 cursor-pointer">
|
||||||
@@ -247,7 +349,7 @@ export default function InstanceGroupPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]">加载中...</div>;
|
if (loading) return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]"><div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-2" />加载中...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -263,30 +365,33 @@ export default function InstanceGroupPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* Stats cards */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
{[
|
{[
|
||||||
{ label: '权限组总数', value: groups.length, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
{ label: '权限组总数', value: groups.length, color: 'text-blue-400', bg: 'bg-blue-400/10', icon: Server },
|
||||||
{ label: '活跃权限组', value: groups.filter(g => g.is_active).length, color: 'text-green-400', bg: 'bg-green-400/10' },
|
{ label: '活跃权限组', value: groups.filter(g => g.is_active).length, color: 'text-green-400', bg: 'bg-green-400/10', icon: CheckCircle2 },
|
||||||
{ label: '关联实例数', value: groups.reduce((sum, g) => sum + (g.instance_count || 0), 0), color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
{ label: '关联实例数', value: groups.reduce((sum, g) => sum + (g.instance_count || 0), 0), color: 'text-purple-400', bg: 'bg-purple-400/10', icon: Database },
|
||||||
{ label: '关联用户数', value: groups.reduce((sum, g) => sum + (g.user_count || 0), 0), color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
{ label: '关联用户数', value: groups.reduce((sum, g) => sum + (g.user_count || 0), 0), color: 'text-orange-400', bg: 'bg-orange-400/10', icon: Users },
|
||||||
].map((s, i) => (
|
].map((s, i) => {
|
||||||
<Card key={i} className="dg-card">
|
const Icon = s.icon;
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
return (
|
||||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
|
<Card key={i} className="dg-card">
|
||||||
{[Server, Server, Users, Users][i] && (() => { var Icon = [Server, Server, Users, Users][i]; return <Icon className={`w-5 h-5 ${s.color}`} />; })()}
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
</div>
|
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
|
||||||
<div>
|
<Icon className={`w-5 h-5 ${s.color}`} />
|
||||||
<div className="text-2xl font-bold">{s.value}</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
|
<div>
|
||||||
</div>
|
<div className="text-2xl font-bold">{s.value}</div>
|
||||||
</CardContent>
|
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
|
||||||
</Card>
|
</div>
|
||||||
))}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
{/* 左侧列表 */}
|
{/* Left list */}
|
||||||
<div className="w-80 flex-shrink-0 space-y-3">
|
<div className="w-80 flex-shrink-0 space-y-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
@@ -316,102 +421,276 @@ export default function InstanceGroupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧详情 */}
|
{/* Right detail */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{selectedGroup ? (() => {
|
{selectedGroup ? (() => {
|
||||||
var sg = selectedGroup!;
|
const sg = selectedGroup!;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="dg-card">
|
{/* Info Card */}
|
||||||
<CardContent className="p-5">
|
<Card className="dg-card">
|
||||||
<div className="flex items-start justify-between">
|
<CardContent className="p-5">
|
||||||
<div>
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{sg.name}</h2>
|
<div className="flex items-center gap-3">
|
||||||
<span className={`px-2 py-0.5 text-xs rounded ${DB_TYPE_COLORS[sg.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === sg.db_type)?.label}</span>
|
<h2 className="text-lg font-semibold">{sg.name}</h2>
|
||||||
<span className={`px-2 py-0.5 text-xs rounded ${sg.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>{sg.is_active ? '启用' : '禁用'}</span>
|
<span className={`px-2 py-0.5 text-xs rounded ${DB_TYPE_COLORS[sg.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === sg.db_type)?.label}</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded ${sg.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>{sg.is_active ? '启用' : '禁用'}</span>
|
||||||
|
</div>
|
||||||
|
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
|
{canManage && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => openEditModal(sg)} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||||||
|
<button onClick={() => handleDelete(sg.id)} className="p-2 rounded-lg hover:bg-red-500/10 text-[var(--dg-text-secondary)] hover:text-red-400 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
</CardContent>
|
||||||
<div className="flex items-center gap-2">
|
</Card>
|
||||||
<button onClick={() => openEditModal(sg)} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><Edit2 className="w-4 h-4" /></button>
|
|
||||||
<button onClick={() => handleDelete(sg.id)} className="p-2 rounded-lg hover:bg-red-500/10 text-[var(--dg-text-secondary)] hover:text-red-400 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
{/* View Tabs */}
|
||||||
|
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
|
||||||
|
{[
|
||||||
|
{ key: 'list' as const, label: '权限列表', icon: Shield },
|
||||||
|
{ key: 'matrix' as const, label: '权限矩阵', icon: Grid3x3 },
|
||||||
|
{ key: 'quota' as const, label: '资源配额', icon: HardDrive },
|
||||||
|
{ key: 'logs' as const, label: '变更日志', icon: Clock },
|
||||||
|
].map(tab => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setViewMode(tab.key)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
viewMode === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View: Permission List */}
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
{/* Permission tags */}
|
||||||
|
{sg.permissions && sg.permissions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{sg.permissions.map(p => {
|
||||||
|
const parts = p.split(':');
|
||||||
|
return (
|
||||||
|
<span key={p} className="px-2 py-1 text-xs bg-blue-500/10 text-blue-400 rounded-md flex items-center gap-1">
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
{parts[0]} · {parts[1]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(!sg.permissions || sg.permissions.length === 0) && (
|
||||||
|
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无权限配置</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View: Permission Matrix */}
|
||||||
|
{viewMode === 'matrix' && (
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[var(--dg-border)]">
|
||||||
|
<th className="text-left px-3 py-2 font-medium text-[var(--dg-text-muted)]">权限类别</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium text-[var(--dg-text-muted)]">权限项</th>
|
||||||
|
<th className="text-center px-3 py-2 font-medium text-[var(--dg-text-muted)] w-20">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{PERMISSION_CATEGORIES.map(cat => (
|
||||||
|
cat.items.map((item, itemIdx) => (
|
||||||
|
<tr key={item.code} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||||
|
{itemIdx === 0 && (
|
||||||
|
<td rowSpan={cat.items.length} className="px-3 py-2 font-medium text-xs text-[var(--dg-text-secondary)] align-top">
|
||||||
|
{cat.label}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="px-3 py-2">{item.name}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{permissionMatrix.find(p => p.code === item.code)?.enabled ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-400 inline" />
|
||||||
|
) : (
|
||||||
|
<X className="w-4 h-4 text-[var(--dg-text-muted)] inline" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
{/* 权限标签 */}
|
)}
|
||||||
{sg.permissions && sg.permissions.length > 0 && (
|
|
||||||
<div className="mt-4">
|
{/* View: Resource Quota */}
|
||||||
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">已配置权限</div>
|
{viewMode === 'quota' && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<Card className="dg-card">
|
||||||
{sg.permissions.map(p => {
|
<CardContent className="p-5">
|
||||||
var parts = p.split(':');
|
<div className="grid grid-cols-2 gap-4">
|
||||||
return <span key={p} className="px-2 py-1 text-xs bg-blue-500/10 text-blue-400 rounded-md">{parts[0]} · {parts[1]}</span>;
|
{[
|
||||||
|
{ label: '最大连接数', key: 'max_connections' as const, icon: Users, unit: '' },
|
||||||
|
{ label: '最大 QPS', key: 'max_qps' as const, icon: Activity, unit: '/s' },
|
||||||
|
{ label: '最大存储', key: 'max_storage_gb' as const, icon: HardDrive, unit: 'GB' },
|
||||||
|
{ label: '每日最大查询数', key: 'max_queries_per_day' as const, icon: Database, unit: '' },
|
||||||
|
].map(item => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const value = quota[item.key];
|
||||||
|
const percentage = Math.min(100, (value / (DEFAULT_QUOTA[item.key] * 2)) * 100);
|
||||||
|
return (
|
||||||
|
<div key={item.key} className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
<span className="text-sm">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold">{value.toLocaleString()}{item.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-[var(--dg-bg-secondary)] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300 bg-blue-500"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{canManage && (
|
||||||
)}
|
<div className="mt-4 pt-4 border-t border-[var(--dg-border)]">
|
||||||
</CardContent>
|
<h4 className="text-sm font-medium mb-3">编辑配额</h4>
|
||||||
</Card>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{([
|
||||||
{/* 关联实例 */}
|
{ label: '最大连接数', key: 'max_connections' as const },
|
||||||
<Card className="dg-card">
|
{ label: '最大 QPS', key: 'max_qps' as const },
|
||||||
<CardContent className="p-5">
|
{ label: '最大存储 (GB)', key: 'max_storage_gb' as const },
|
||||||
<div className="flex items-center justify-between mb-3">
|
{ label: '每日最大查询数', key: 'max_queries_per_day' as const },
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联实例 ({sg.instances?.length || 0})</h3>
|
]).map(item => (
|
||||||
{canManage && (
|
<div key={item.key}>
|
||||||
<button onClick={() => { setSelectedInstanceIds([]); setShowInstanceModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加实例</button>
|
<label className="block text-xs font-medium mb-1">{item.label}</label>
|
||||||
)}
|
<input
|
||||||
</div>
|
type="number"
|
||||||
{sg.instances && sg.instances.length > 0 ? (
|
value={quota[item.key]}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
onChange={e => setQuota(p => ({ ...p, [item.key]: Number(e.target.value) }))}
|
||||||
{sg.instances.map(inst => (
|
className="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"
|
||||||
<div key={inst.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
/>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<span className={`w-2 h-2 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
|
))}
|
||||||
<span className="text-sm">{inst.name}</span>
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
|
|
||||||
</div>
|
|
||||||
{canManage && (
|
|
||||||
<button onClick={async () => { await instanceGroupApi.removeInstance(sg.id, inst.id); fetchGroups(); }} className="text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"><X className="w-3.5 h-3.5" /></button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联实例</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 关联用户 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2"><Users className="w-4 h-4 text-[var(--dg-text-muted)]" />关联用户 ({sg.users?.length || 0})</h3>
|
|
||||||
{canManage && (
|
|
||||||
<button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加用户</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{sg.users && sg.users.length > 0 ? (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{sg.users.map(u => (
|
|
||||||
<div key={u.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
|
|
||||||
<span className="text-sm">{u.display_name || u.username}</span>
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View: Change Logs */}
|
||||||
|
{viewMode === 'logs' && (
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||||
|
{changeLogs.map(log => (
|
||||||
|
<div key={log.id} className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<Clock className="w-4 h-4 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm">{log.action}</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">{log.target !== '-' ? log.target : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
|
||||||
|
{log.user.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||||
|
{new Date(log.created_at).toLocaleDateString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{changeLogs.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无变更记录</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Related Instances */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-2"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联实例 ({sg.instances?.length || 0})</h3>
|
||||||
|
{canManage && (
|
||||||
|
<button onClick={() => { setSelectedInstanceIds([]); setShowInstanceModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加实例</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{sg.instances && sg.instances.length > 0 ? (
|
||||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
)}
|
{sg.instances.map(inst => (
|
||||||
</CardContent>
|
<div key={inst.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
</Card>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<span className={`w-2 h-2 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
|
||||||
|
<span className="text-sm">{inst.name}</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<button onClick={async () => { await instanceGroupApi.removeInstance(sg.id, inst.id); fetchGroups(); }} className="text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"><X className="w-3.5 h-3.5" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)] flex flex-col items-center">
|
||||||
|
<Server className="w-8 h-8 mb-2 opacity-30" />
|
||||||
|
暂未关联实例
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Related Users */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-2"><Users className="w-4 h-4 text-[var(--dg-text-muted)]" />关联用户 ({sg.users?.length || 0})</h3>
|
||||||
|
{canManage && (
|
||||||
|
<button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加用户</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sg.users && sg.users.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{sg.users.map(u => (
|
||||||
|
<div key={u.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
|
||||||
|
<span className="text-sm">{u.display_name || u.username}</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)] flex flex-col items-center">
|
||||||
|
<Users className="w-8 h-8 mb-2 opacity-30" />
|
||||||
|
暂未关联用户
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})() : (
|
})() : (
|
||||||
<div className="flex flex-col items-center justify-center h-96 text-[var(--dg-text-muted)]">
|
<div className="flex flex-col items-center justify-center h-96 text-[var(--dg-text-muted)]">
|
||||||
@@ -422,7 +701,7 @@ export default function InstanceGroupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 创建弹框 */}
|
{/* Create Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowCreateModal(false); resetForm(); }}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowCreateModal(false); resetForm(); }}>
|
||||||
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||||
@@ -432,7 +711,7 @@ export default function InstanceGroupPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 编辑弹框 */}
|
{/* Edit Modal */}
|
||||||
{showEditModal && (
|
{showEditModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowEditModal(false); resetForm(); }}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowEditModal(false); resetForm(); }}>
|
||||||
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||||
@@ -442,14 +721,23 @@ export default function InstanceGroupPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 关联实例弹框 */}
|
{/* Instance Modal with search */}
|
||||||
{showInstanceModal && (
|
{showInstanceModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowInstanceModal(false)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowInstanceModal(false)}>
|
||||||
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||||
<h3 className="text-lg font-semibold mb-4">关联实例</h3>
|
<h3 className="text-lg font-semibold mb-4">关联实例</h3>
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
value={instanceSearch}
|
||||||
|
onChange={e => setInstanceSearch(e.target.value)}
|
||||||
|
placeholder="搜索实例..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
|
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
|
||||||
{allInstances.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选实例</div>}
|
{filteredInstances.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选实例</div>}
|
||||||
{allInstances.map(inst => (
|
{filteredInstances.map(inst => (
|
||||||
<label key={inst.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedInstanceIds.includes(inst.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
|
<label key={inst.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedInstanceIds.includes(inst.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
|
||||||
<input type="checkbox" checked={selectedInstanceIds.includes(inst.id)} onChange={e => setSelectedInstanceIds(e.target.checked ? [...selectedInstanceIds, inst.id] : selectedInstanceIds.filter(id => id !== inst.id))} className="rounded" />
|
<input type="checkbox" checked={selectedInstanceIds.includes(inst.id)} onChange={e => setSelectedInstanceIds(e.target.checked ? [...selectedInstanceIds, inst.id] : selectedInstanceIds.filter(id => id !== inst.id))} className="rounded" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -468,14 +756,23 @@ export default function InstanceGroupPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 关联用户弹框 */}
|
{/* User Modal with search */}
|
||||||
{showUserModal && (
|
{showUserModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowUserModal(false)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowUserModal(false)}>
|
||||||
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||||
<h3 className="text-lg font-semibold mb-4">关联用户</h3>
|
<h3 className="text-lg font-semibold mb-4">关联用户</h3>
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
value={userSearch}
|
||||||
|
onChange={e => setUserSearch(e.target.value)}
|
||||||
|
placeholder="搜索用户..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
|
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
|
||||||
{allUsers.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选用户</div>}
|
{filteredUsers.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选用户</div>}
|
||||||
{allUsers.map(u => (
|
{filteredUsers.map(u => (
|
||||||
<label key={u.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedUserIds.includes(u.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
|
<label key={u.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedUserIds.includes(u.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
|
||||||
<input type="checkbox" checked={selectedUserIds.includes(u.id)} onChange={e => setSelectedUserIds(e.target.checked ? [...selectedUserIds, u.id] : selectedUserIds.filter(id => id !== u.id))} className="rounded" />
|
<input type="checkbox" checked={selectedUserIds.includes(u.id)} onChange={e => setSelectedUserIds(e.target.checked ? [...selectedUserIds, u.id] : selectedUserIds.filter(id => id !== u.id))} className="rounded" />
|
||||||
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
|
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
|
||||||
|
|||||||
+1052
-336
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
|
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
|
||||||
ChevronDown, Clock, FileText, Sparkles,
|
ChevronDown, Clock, FileText, Sparkles, Star,
|
||||||
|
StarOff, Download, Search, RotateCcw, ArrowUpDown,
|
||||||
|
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, PanelLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useActionPermission } from '@/hooks/useAuth';
|
import { useActionPermission } from '@/hooks/useAuth';
|
||||||
@@ -11,11 +13,14 @@ import { queryApi } from '@/api/query';
|
|||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
import SqlEditor from '@/components/SqlEditor';
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
import { queryTemplates, getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
import { queryTemplates, getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
||||||
import type { DBType } from '@/lib/queryTemplates';
|
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
interface QueryResult {
|
interface QueryResult {
|
||||||
columns: string[];
|
columns: string[];
|
||||||
rows: Record<string, any>[];
|
rows: Record<string, unknown>[];
|
||||||
affected_rows: number;
|
affected_rows: number;
|
||||||
execution_time_ms: number;
|
execution_time_ms: number;
|
||||||
}
|
}
|
||||||
@@ -30,8 +35,15 @@ interface QueryHistoryItem {
|
|||||||
execution_time_ms: number;
|
execution_time_ms: number;
|
||||||
result_preview: string;
|
result_preview: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
favorite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SortDirection = 'asc' | 'desc' | null;
|
||||||
|
type SortState = { column: string | null; direction: SortDirection };
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
const dbTypeLabel: Record<string, string> = {
|
const dbTypeLabel: Record<string, string> = {
|
||||||
mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB',
|
mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB',
|
||||||
};
|
};
|
||||||
@@ -43,16 +55,114 @@ const dbTypeIcon: Record<string, string> = {
|
|||||||
mongodb: '/db-logos/mongo.svg',
|
mongodb: '/db-logos/mongo.svg',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sensitiveKeywords = ['password', 'pwd', 'secret', 'token', 'phone', 'mobile', 'email', 'id_card', 'card_no'];
|
||||||
|
|
||||||
|
const placeholderMap: Record<DBType, string> = {
|
||||||
|
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||||
|
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||||
|
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
|
||||||
|
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function maskData(value: string): string {
|
||||||
|
if (!value) return '***';
|
||||||
|
if (typeof value === 'string' && value.includes('@')) {
|
||||||
|
const [name, domain] = value.split('@');
|
||||||
|
return name.substring(0, 3) + '***@' + domain;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
|
||||||
|
return '***';
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToCSV(columns: string[], rows: Record<string, unknown>[]): string {
|
||||||
|
const header = columns.join(',');
|
||||||
|
const lines = rows.map(row =>
|
||||||
|
columns.map(col => {
|
||||||
|
const val = String(row[col] ?? '');
|
||||||
|
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
||||||
|
return `"${val.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}).join(',')
|
||||||
|
);
|
||||||
|
return [header, ...lines].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCSV(filename: string, content: string) {
|
||||||
|
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main Page */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
export default function QueriesPage() {
|
export default function QueriesPage() {
|
||||||
const canCreate = useActionPermission('query.create');
|
const canCreate = useActionPermission('query.create');
|
||||||
const [showNewQuery, setShowNewQuery] = useState(false);
|
const [showNewQuery, setShowNewQuery] = useState(false);
|
||||||
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
|
const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('query_favorites');
|
||||||
|
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
|
||||||
|
const initialHistory: QueryHistoryItem[] = [];
|
||||||
|
return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [favFilter, setFavFilter] = useState(false);
|
||||||
|
const [searchHistory, setSearchHistory] = useState('');
|
||||||
|
const [clearConfirm, setClearConfirm] = useState(false);
|
||||||
|
|
||||||
|
const toggleFavorite = useCallback((id: number) => {
|
||||||
|
setHistory(prev => {
|
||||||
|
const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h);
|
||||||
|
const favIds = next.filter(h => h.favorite).map(h => h.id);
|
||||||
|
try { localStorage.setItem('query_favorites', JSON.stringify(favIds)); } catch { /* noop */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExecuted = useCallback((item: QueryHistoryItem) => {
|
||||||
|
setHistory(prev => [item, ...prev]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearHistory = useCallback(() => {
|
||||||
|
if (!clearConfirm) {
|
||||||
|
setClearConfirm(true);
|
||||||
|
setTimeout(() => setClearConfirm(false), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHistory([]);
|
||||||
|
setClearConfirm(false);
|
||||||
|
}, [clearConfirm]);
|
||||||
|
|
||||||
|
const filteredHistory = useMemo(() => {
|
||||||
|
let result = history;
|
||||||
|
if (favFilter) result = result.filter(h => h.favorite);
|
||||||
|
if (searchHistory) {
|
||||||
|
const kw = searchHistory.toLowerCase();
|
||||||
|
result = result.filter(h =>
|
||||||
|
h.sql.toLowerCase().includes(kw) ||
|
||||||
|
h.db_type.toLowerCase().includes(kw)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [history, favFilter, searchHistory]);
|
||||||
|
|
||||||
if (showNewQuery) {
|
if (showNewQuery) {
|
||||||
return (
|
return (
|
||||||
<NewQueryPanel
|
<NewQueryPanel
|
||||||
onCancel={() => setShowNewQuery(false)}
|
onCancel={() => setShowNewQuery(false)}
|
||||||
onExecuted={(item) => { setHistory(prev => [item, ...prev]); }}
|
onExecuted={handleExecuted}
|
||||||
|
history={history}
|
||||||
|
onToggleFavorite={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,42 +175,92 @@ export default function QueriesPage() {
|
|||||||
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
|
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* History Controls */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="relative flex-1 max-w-xs">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
|
||||||
|
placeholder="搜索历史 SQL..."
|
||||||
|
value={searchHistory}
|
||||||
|
onChange={e => setSearchHistory(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setFavFilter(!favFilter)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2 text-xs rounded-lg border transition-all font-medium ${
|
||||||
|
favFilter
|
||||||
|
? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30'
|
||||||
|
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star className="w-3.5 h-3.5" />
|
||||||
|
仅收藏
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClearHistory}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-xs text-red-400 hover:bg-red-500/10 border border-red-500/20 hover:border-red-500/40 rounded-lg transition-all font-medium"
|
||||||
|
>
|
||||||
|
{clearConfirm ? <CheckCircle className="w-3.5 h-3.5" /> : <X className="w-3.5 h-3.5" />}
|
||||||
|
{clearConfirm ? '确认清除' : '清除历史'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<EmptyState type="search" />
|
<EmptyState type="search" />
|
||||||
|
) : filteredHistory.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-[var(--dg-text-muted)] text-sm">
|
||||||
|
<Search className="w-8 h-8 mx-auto mb-2 text-[var(--dg-text-muted)]" />
|
||||||
|
没有找到匹配的历史记录
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
<th className="text-left px-5 py-3 font-semibold">类型</th>
|
<th className="text-left px-4 py-3 font-semibold w-8">#</th>
|
||||||
<th className="text-left px-5 py-3 font-semibold">语句</th>
|
<th className="text-left px-4 py-3 font-semibold">类型</th>
|
||||||
<th className="text-left px-5 py-3 font-semibold">执行时长</th>
|
<th className="text-left px-4 py-3 font-semibold">语句</th>
|
||||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
<th className="text-left px-4 py-3 font-semibold">执行时长</th>
|
||||||
<th className="text-left px-5 py-3 font-semibold">时间</th>
|
<th className="text-left px-4 py-3 font-semibold">状态</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold">时间</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold w-16">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{history.map((q, i) => (
|
{filteredHistory.map((q, idx) => (
|
||||||
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
<tr key={q.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-4 py-3.5 text-xs text-[var(--dg-text-muted)] font-mono">{idx + 1}</td>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
{q.db_type && dbTypeIcon[q.db_type] && (
|
{q.db_type && dbTypeIcon[q.db_type] && (
|
||||||
<img src={dbTypeIcon[q.db_type]} alt={q.db_type} className="w-5 h-5 object-contain" />
|
<img src={dbTypeIcon[q.db_type]} alt={q.db_type} className="w-5 h-5 object-contain" />
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5 max-w-md">
|
<td className="px-4 py-3.5 max-w-md">
|
||||||
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 120)}</code>
|
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 120)}</code>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<span className={q.execution_time_ms > 1000 ? 'text-red-400 font-medium' : ''}>{q.execution_time_ms}ms</span>
|
<span className={q.execution_time_ms > 1000 ? 'text-red-400 font-medium' : ''}>{q.execution_time_ms}ms</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${q.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${q.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||||
{q.status === 'success' ? '成功' : '失败'}
|
{q.status === 'success' ? '成功' : '失败'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{new Date(q.created_at).toLocaleString()}</td>
|
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)] text-xs">{new Date(q.created_at).toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleFavorite(q.id)}
|
||||||
|
className={`p-1.5 rounded-md transition-colors ${q.favorite ? 'text-yellow-400 hover:text-yellow-300' : 'text-[var(--dg-text-muted)] hover:text-yellow-400'}`}
|
||||||
|
title={q.favorite ? '取消收藏' : '收藏'}
|
||||||
|
>
|
||||||
|
{q.favorite ? <Star className="w-3.5 h-3.5 fill-current" /> : <StarOff className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -113,22 +273,38 @@ export default function QueriesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- NewQueryPanel with templates + CodeMirror ---------- */
|
/* ------------------------------------------------------------------ */
|
||||||
function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecuted: (item: QueryHistoryItem) => void }) {
|
/* NewQueryPanel */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function NewQueryPanel({
|
||||||
|
onCancel, onExecuted, history, onToggleFavorite,
|
||||||
|
}: {
|
||||||
|
onCancel: () => void;
|
||||||
|
onExecuted: (item: QueryHistoryItem) => void;
|
||||||
|
history: QueryHistoryItem[];
|
||||||
|
onToggleFavorite: (id: number) => void;
|
||||||
|
}) {
|
||||||
const [sql, setSql] = useState('');
|
const [sql, setSql] = useState('');
|
||||||
const [instanceId, setInstanceId] = useState('');
|
const [instanceId, setInstanceId] = useState('');
|
||||||
const [database, setDatabase] = useState('');
|
const [database, setDatabase] = useState('');
|
||||||
const [instances, setInstances] = useState<any[]>([]);
|
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number; db_type: string }>>([]);
|
||||||
const [executing, setExecuting] = useState(false);
|
const [executing, setExecuting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [result, setResult] = useState<QueryResult | null>(null);
|
const [result, setResult] = useState<QueryResult | null>(null);
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
|
const [templateSearch, setTemplateSearch] = useState('');
|
||||||
|
const [templateCategory, setTemplateCategory] = useState('all');
|
||||||
|
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
|
||||||
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const [rowHeight] = useState(36);
|
||||||
|
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
||||||
|
const resultContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
instanceApi.list().then(res => {
|
instanceApi.list().then(res => {
|
||||||
const data = Array.isArray(res) ? res : [];
|
const data = Array.isArray(res) ? res : [];
|
||||||
setInstances(data);
|
setInstances(data);
|
||||||
}).catch(() => {});
|
}).catch(() => { setInstances([]); });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedInstance = useMemo(() => {
|
const selectedInstance = useMemo(() => {
|
||||||
@@ -139,21 +315,28 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
|||||||
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
|
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
|
||||||
|
|
||||||
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
|
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
|
||||||
|
const templateCategories = useMemo(() => {
|
||||||
|
const cats = new Set(templates.map(t => t.category));
|
||||||
|
return ['all', ...Array.from(cats)];
|
||||||
|
}, [templates]);
|
||||||
|
|
||||||
const placeholderMap: Record<DBType, string> = {
|
const filteredTemplates = useMemo(() => {
|
||||||
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
let result = templates;
|
||||||
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
if (templateCategory !== 'all') result = result.filter(t => t.category === templateCategory);
|
||||||
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
|
if (templateSearch) {
|
||||||
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
|
const kw = templateSearch.toLowerCase();
|
||||||
};
|
result = result.filter(t => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [templates, templateCategory, templateSearch]);
|
||||||
|
|
||||||
const handleApplyTemplate = (tpl: typeof queryTemplates[0]) => {
|
const handleApplyTemplate = useCallback((tpl: QueryTemplate) => {
|
||||||
const templateSQL = getTemplateSQL(tpl, dbType);
|
const templateSQL = getTemplateSQL(tpl, dbType);
|
||||||
setSql(templateSQL);
|
setSql(templateSQL);
|
||||||
setShowTemplates(false);
|
setShowTemplates(false);
|
||||||
};
|
}, [dbType]);
|
||||||
|
|
||||||
const handleExecute = async () => {
|
const handleExecute = useCallback(async () => {
|
||||||
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
|
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
|
||||||
setExecuting(true);
|
setExecuting(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -163,7 +346,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
|||||||
instance_id: Number(instanceId),
|
instance_id: Number(instanceId),
|
||||||
database: database || undefined,
|
database: database || undefined,
|
||||||
sql,
|
sql,
|
||||||
}) as any;
|
}) as unknown as QueryResult;
|
||||||
|
|
||||||
const execTime = res.execution_time_ms || 0;
|
const execTime = res.execution_time_ms || 0;
|
||||||
setResult(res);
|
setResult(res);
|
||||||
@@ -177,9 +360,10 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
|||||||
execution_time_ms: execTime,
|
execution_time_ms: execTime,
|
||||||
result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100),
|
result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
favorite: false,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
const msg = e?.response?.data?.detail || '查询失败';
|
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '查询失败';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
onExecuted({
|
onExecuted({
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
@@ -191,48 +375,168 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
|||||||
execution_time_ms: 0,
|
execution_time_ms: 0,
|
||||||
result_preview: msg,
|
result_preview: msg,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
favorite: false,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setExecuting(false);
|
setExecuting(false);
|
||||||
}
|
}
|
||||||
|
}, [sql, instanceId, database, dbType, onExecuted]);
|
||||||
|
|
||||||
|
/* ---- Result Sorting ---- */
|
||||||
|
const sortedResult = useMemo(() => {
|
||||||
|
if (!result || !sortState.column || !sortState.direction) return result;
|
||||||
|
const col = sortState.column;
|
||||||
|
const dir = sortState.direction === 'asc' ? 1 : -1;
|
||||||
|
const sortedRows = [...result.rows].sort((a, b) => {
|
||||||
|
const av = a[col] ?? '';
|
||||||
|
const bv = b[col] ?? '';
|
||||||
|
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
||||||
|
return String(av).localeCompare(String(bv)) * dir;
|
||||||
|
});
|
||||||
|
return { ...result, rows: sortedRows };
|
||||||
|
}, [result, sortState]);
|
||||||
|
|
||||||
|
const handleSort = useCallback((col: string) => {
|
||||||
|
setSortState(prev => {
|
||||||
|
if (prev.column === col) {
|
||||||
|
if (prev.direction === 'asc') return { column: col, direction: 'desc' as SortDirection };
|
||||||
|
if (prev.direction === 'desc') return { column: null, direction: null };
|
||||||
|
}
|
||||||
|
return { column: col, direction: 'asc' as SortDirection };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const SortIcon = ({ col }: { col: string }) => {
|
||||||
|
if (sortState.column !== col) return <ArrowUpDown className="w-3 h-3 opacity-30" />;
|
||||||
|
if (sortState.direction === 'asc') return <ArrowUp className="w-3 h-3 text-blue-400" />;
|
||||||
|
return <ArrowDown className="w-3 h-3 text-blue-400" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const maskData = (value: string) => {
|
/* ---- Virtual Scroll ---- */
|
||||||
if (!value) return '***';
|
const handleResultScroll = useCallback(() => {
|
||||||
if (typeof value === 'string' && value.includes('@')) {
|
if (!resultContainerRef.current) return;
|
||||||
const [name, domain] = value.split('@');
|
const container = resultContainerRef.current;
|
||||||
return name.substring(0, 3) + '***@' + domain;
|
const scrollTop = container.scrollTop;
|
||||||
}
|
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
|
||||||
if (typeof value === 'string' && value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
|
const visibleCount = Math.ceil(container.clientHeight / rowHeight);
|
||||||
return '***';
|
const end = Math.min((sortedResult?.rows?.length || 0), start + visibleCount + 10);
|
||||||
|
setVisibleRange({ start, end });
|
||||||
|
}, [sortedResult, rowHeight]);
|
||||||
|
|
||||||
|
const totalResultHeight = (sortedResult?.rows?.length || 0) * rowHeight;
|
||||||
|
|
||||||
|
const handleExportResult = useCallback(() => {
|
||||||
|
if (!result) return;
|
||||||
|
const csv = exportToCSV(result.columns, result.rows);
|
||||||
|
downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv);
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
const handleExportHistory = useCallback(() => {
|
||||||
|
const columns = ['序号', 'SQL', '数据库类型', '执行时长(ms)', '状态', '时间'];
|
||||||
|
const rows = history.map((h, i) => ({
|
||||||
|
序号: i + 1,
|
||||||
|
SQL: h.sql,
|
||||||
|
数据库类型: h.db_type,
|
||||||
|
'执行时长(ms)': h.execution_time_ms,
|
||||||
|
状态: h.status === 'success' ? '成功' : '失败',
|
||||||
|
时间: new Date(h.created_at).toLocaleString(),
|
||||||
|
}));
|
||||||
|
const csv = exportToCSV(columns, rows);
|
||||||
|
downloadCSV(`query_history_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv);
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const catLabel: Record<string, string> = {
|
||||||
|
all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询',
|
||||||
};
|
};
|
||||||
const sensitiveKeywords = ['password', 'pwd', 'secret', 'token', 'phone', 'mobile', 'email', 'id_card', 'card_no'];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 max-w-5xl">
|
<div className="space-y-5 max-w-6xl">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-semibold">新建查询</h1>
|
<h1 className="text-2xl font-semibold">新建查询</h1>
|
||||||
{selectedInstance && (
|
{selectedInstance && (
|
||||||
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--dg-bg-tertiary)]">
|
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||||
<img src={dbTypeIcon[dbType]} alt="" className="w-4 h-4 object-contain" />
|
<img src={dbTypeIcon[dbType]} alt="" className="w-4 h-4 object-contain" />
|
||||||
{dbTypeLabel[dbType] || dbType}
|
{dbTypeLabel[dbType] || dbType}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">
|
<div className="flex items-center gap-2">
|
||||||
<X className="w-6 h-6" />
|
{history.length > 0 && (
|
||||||
</button>
|
<button
|
||||||
|
onClick={handleExportHistory}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
导出历史
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHistory(!showHistory)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 text-xs rounded-lg border transition-colors ${
|
||||||
|
showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
历史
|
||||||
|
{history.length > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{history.length}</span>}
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* History Sidebar */}
|
||||||
|
{showHistory && (
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-blue-400" />
|
||||||
|
查询历史 ({history.length})
|
||||||
|
</CardTitle>
|
||||||
|
<button onClick={() => setShowHistory(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||||
|
<PanelLeftClose className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 max-h-[300px] overflow-y-auto">
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div className="p-6 text-center text-sm text-[var(--dg-text-muted)]">暂无查询历史</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-[var(--dg-border-light)]">
|
||||||
|
{history.map(h => (
|
||||||
|
<div key={h.id} className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||||
|
{h.db_type && dbTypeIcon[h.db_type] && (
|
||||||
|
<img src={dbTypeIcon[h.db_type]} alt="" className="w-4 h-4 object-contain flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<code className="flex-1 text-xs font-mono text-blue-400 truncate">{h.sql.substring(0, 80)}</code>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${h.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||||
|
{h.execution_time_ms}ms
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleFavorite(h.id)}
|
||||||
|
className={`p-1 rounded transition-colors ${h.favorite ? 'text-yellow-400' : 'text-[var(--dg-text-muted)] hover:text-yellow-400'}`}
|
||||||
|
>
|
||||||
|
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Instance + Database selector */}
|
{/* Instance + Database selector */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">目标实例 <span className="text-red-400">*</span></label>
|
<label className="block text-sm font-medium mb-2">目标实例 <span className="text-red-400">*</span></label>
|
||||||
<select
|
<select
|
||||||
value={instanceId}
|
value={instanceId}
|
||||||
onChange={e => setInstanceId(e.target.value)}
|
onChange={e => setInstanceId(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
|
||||||
>
|
>
|
||||||
<option value="">请选择实例</option>
|
<option value="">请选择实例</option>
|
||||||
{instances.map(inst => (
|
{instances.map(inst => (
|
||||||
@@ -247,7 +551,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
|||||||
<input
|
<input
|
||||||
value={database}
|
value={database}
|
||||||
onChange={e => setDatabase(e.target.value)}
|
onChange={e => setDatabase(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
|
||||||
placeholder="可选,默认数据库"
|
placeholder="可选,默认数据库"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,12 +561,12 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="text-sm font-medium flex items-center gap-2">
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4 text-[var(--dg-text-secondary)]" />
|
||||||
查询语句 <span className="text-red-400">*</span>
|
查询语句 <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTemplates(!showTemplates)}
|
onClick={() => setShowTemplates(!showTemplates)}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)] rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
|
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
|
||||||
快捷模板 ({templates.length})
|
快捷模板 ({templates.length})
|
||||||
@@ -270,24 +574,55 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Templates dropdown */}
|
|
||||||
{showTemplates && (
|
{showTemplates && (
|
||||||
<div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg max-h-64 overflow-y-auto">
|
<div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg border border-[var(--dg-border)]">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{/* Category tabs + search */}
|
||||||
{templates.map(tpl => (
|
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||||
|
{templateCategories.map(cat => (
|
||||||
<button
|
<button
|
||||||
key={tpl.id}
|
key={cat}
|
||||||
onClick={() => handleApplyTemplate(tpl)}
|
onClick={() => setTemplateCategory(cat)}
|
||||||
className="text-left p-3 rounded-lg bg-[var(--dg-bg-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
className={`px-2.5 py-1 text-[11px] rounded-md transition-all font-medium ${
|
||||||
|
templateCategory === cat
|
||||||
|
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
||||||
|
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] border border-transparent'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
{catLabel[cat] || cat}
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{tpl.category}</span>
|
|
||||||
<span className="text-sm font-medium">{tpl.name}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)]">{tpl.description}</p>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
value={templateSearch}
|
||||||
|
onChange={e => setTemplateSearch(e.target.value)}
|
||||||
|
className="pl-7 pr-3 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none w-40"
|
||||||
|
placeholder="搜索模板..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Templates Grid */}
|
||||||
|
{filteredTemplates.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无匹配模板</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-64 overflow-y-auto">
|
||||||
|
{filteredTemplates.map(tpl => (
|
||||||
|
<button
|
||||||
|
key={tpl.id}
|
||||||
|
onClick={() => handleApplyTemplate(tpl)}
|
||||||
|
className="text-left p-3 rounded-lg bg-[var(--dg-bg-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors group border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]"
|
||||||
|
title={tpl.description}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium">{tpl.category}</span>
|
||||||
|
<span className="text-sm font-medium group-hover:text-blue-400 transition-colors truncate">{tpl.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] line-clamp-2">{tpl.description}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -298,7 +633,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
|||||||
onChange={setSql}
|
onChange={setSql}
|
||||||
placeholder={placeholderMap[dbType]}
|
placeholder={placeholderMap[dbType]}
|
||||||
language={editorLang}
|
language={editorLang}
|
||||||
height="220px"
|
height="240px"
|
||||||
onExecute={handleExecute}
|
onExecute={handleExecute}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
<div className="flex items-center justify-between px-4 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
||||||
@@ -306,7 +641,18 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
|||||||
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3.5 h-3.5 object-contain" />}
|
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3.5 h-3.5 object-contain" />}
|
||||||
{dbTypeLabel[dbType]} 模式
|
{dbTypeLabel[dbType]} 模式
|
||||||
</span>
|
</span>
|
||||||
<span>{sql.length} 字符 · Ctrl+Enter 执行 · Ctrl+Shift+F 格式化</span>
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Hash className="w-3 h-3" />
|
||||||
|
{sql.length} 字符
|
||||||
|
</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
|
||||||
|
Ctrl+Enter 执行
|
||||||
|
</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
|
||||||
|
Ctrl+Shift+F 格式化
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,70 +666,127 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">{error}</div>
|
<div className="flex items-center gap-2 p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">
|
||||||
|
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Query result */}
|
{/* Query Result */}
|
||||||
{result && (
|
{result && (
|
||||||
<Card className="dg-card">
|
<Card className="dg-card overflow-hidden">
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
<CheckCircle className="w-4 h-4 text-green-400" /> 查询结果
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
|
查询结果
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {result.execution_time_ms}ms</span>
|
<span className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)]">
|
||||||
<span>{result.rows?.length || 0} 行</span>
|
<Clock className="w-3 h-3" />
|
||||||
|
{result.execution_time_ms}ms
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">{result.rows?.length || 0} 行</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">{result.columns?.length || 0} 列</span>
|
||||||
|
{result.rows && result.rows.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleExportResult}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-green-500/15 text-green-400 border border-green-500/30 rounded-md hover:bg-green-500/25 transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
导出 CSV
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{result.columns && result.columns.length > 0 ? (
|
{result.columns && result.columns.length > 0 ? (
|
||||||
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
<div
|
||||||
|
ref={resultContainerRef}
|
||||||
|
onScroll={handleResultScroll}
|
||||||
|
className="overflow-auto max-h-[450px]"
|
||||||
|
>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
|
<th className="text-left px-3 py-2.5 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>
|
||||||
{result.columns.map(col => (
|
{result.columns.map(col => (
|
||||||
<th key={col} className="text-left px-4 py-3 font-semibold text-xs">
|
<th
|
||||||
{col}
|
key={col}
|
||||||
{sensitiveKeywords.some(k => col.toLowerCase().includes(k)) && (
|
onClick={() => handleSort(col)}
|
||||||
<span className="ml-1.5 px-1.5 py-0.5 bg-yellow-500/10 text-yellow-400 text-[10px] rounded">敏感</span>
|
className="text-left px-3 py-2.5 font-semibold text-xs cursor-pointer hover:text-blue-400 transition-colors select-none whitespace-nowrap"
|
||||||
)}
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{col}
|
||||||
|
<SortIcon col={col} />
|
||||||
|
{sensitiveKeywords.some(k => col.toLowerCase().includes(k)) && (
|
||||||
|
<span className="ml-1 px-1 py-0.5 bg-yellow-500/10 text-yellow-400 text-[9px] rounded">敏</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{result.rows.map((row, i) => (
|
{/* Virtual scroll: only render visible rows */}
|
||||||
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
<tr style={{ height: visibleRange.start * rowHeight }} />
|
||||||
{result.columns.map(col => {
|
{(sortedResult?.rows || []).slice(visibleRange.start, visibleRange.end).map((row, i) => {
|
||||||
const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k));
|
const realIndex = visibleRange.start + i;
|
||||||
return (
|
return (
|
||||||
<td key={col} className="px-4 py-3 text-xs">
|
<tr
|
||||||
{isSensitive ? <span className="text-yellow-400 italic">{maskData(String(row[col] ?? ''))}</span> : String(row[col] ?? '')}
|
key={realIndex}
|
||||||
</td>
|
className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
);
|
style={{ height: rowHeight }}
|
||||||
})}
|
>
|
||||||
</tr>
|
<td className="px-3 py-2 text-[10px] text-[var(--dg-text-muted)] font-mono">{realIndex + 1}</td>
|
||||||
))}
|
{result.columns.map(col => {
|
||||||
|
const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k));
|
||||||
|
const cellValue = row[col] ?? '';
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={col}
|
||||||
|
className="px-3 py-2 text-xs max-w-[200px] truncate"
|
||||||
|
title={String(cellValue)}
|
||||||
|
>
|
||||||
|
{isSensitive
|
||||||
|
? <span className="text-yellow-400 italic">{maskData(String(cellValue))}</span>
|
||||||
|
: String(cellValue)
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center text-[var(--dg-text-muted)]">无结果数据</div>
|
<div className="p-8 text-center text-[var(--dg-text-muted)] flex flex-col items-center gap-2">
|
||||||
|
<Eye className="w-6 h-6" />
|
||||||
|
<span className="text-sm">无结果数据</span>
|
||||||
|
{result.affected_rows > 0 && (
|
||||||
|
<span className="text-xs">影响行数: {result.affected_rows}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)]">
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleExecute}
|
onClick={handleExecute}
|
||||||
disabled={executing}
|
disabled={executing || !instanceId}
|
||||||
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-50 transition-all"
|
||||||
>
|
>
|
||||||
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||||
{executing ? '执行中...' : '执行查询'}
|
{executing ? '执行中...' : '执行查询'}
|
||||||
|
|||||||
@@ -1,53 +1,271 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { Crown, Database, Code, Eye, Shield, ChevronRight, Check, Save } from 'lucide-react';
|
import {
|
||||||
|
Crown, Database, Code, Eye, Shield, ChevronRight, Check, Save,
|
||||||
|
Search, X, Copy, GitCompare, Users, Lock, BarChart3, XCircle,
|
||||||
|
CheckCircle2, MinusCircle, Sparkles, ChevronDown,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { roles, permissionTreeData } from '@/lib/mock';
|
import { roles as mockRoles, permissionTreeData, users as mockUsers } from '@/lib/mock';
|
||||||
import type { Role } from '@/types';
|
import type { Role } from '@/types';
|
||||||
|
|
||||||
|
/* ───────── Types ───────── */
|
||||||
|
|
||||||
|
interface PermissionChange {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
action: 'added' | 'removed';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionPreset {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Constants ───────── */
|
||||||
|
|
||||||
const iconMap: Record<string, React.ElementType> = { Crown, Database, Code, Eye };
|
const iconMap: Record<string, React.ElementType> = { Crown, Database, Code, Eye };
|
||||||
|
|
||||||
|
const PERMISSION_PRESETS: PermissionPreset[] = [
|
||||||
|
{
|
||||||
|
key: 'dba',
|
||||||
|
name: 'DBA 模板',
|
||||||
|
description: '数据库管理员:完整的数据源管理、工单审批、查询审计权限',
|
||||||
|
icon: Database,
|
||||||
|
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'developer',
|
||||||
|
name: '开发模板',
|
||||||
|
description: '开发人员:工单提交、查询执行、数据导出',
|
||||||
|
icon: Code,
|
||||||
|
permissions: ['dashboard', 'tickets', 'ticket_create', 'queries', 'query_create', 'query_export'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'auditor',
|
||||||
|
name: '审计模板',
|
||||||
|
description: '审计人员:查看查询日志、审计规则、审批流程',
|
||||||
|
icon: Eye,
|
||||||
|
permissions: ['dashboard', 'queries', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin',
|
||||||
|
name: '管理员模板',
|
||||||
|
description: '超级管理员:系统全部权限',
|
||||||
|
icon: Crown,
|
||||||
|
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'roles', 'users', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ───────── Helpers ───────── */
|
||||||
|
|
||||||
|
function collectAllIds(nodes: typeof permissionTreeData): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const walk = (list: typeof permissionTreeData) => {
|
||||||
|
list.forEach(n => {
|
||||||
|
ids.push(n.id);
|
||||||
|
if (n.children) walk(n.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
walk(nodes);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTree(nodes: typeof permissionTreeData, query: string): typeof permissionTreeData {
|
||||||
|
if (!query.trim()) return nodes;
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
return nodes.reduce<typeof permissionTreeData>((acc, node) => {
|
||||||
|
const matchesSelf = node.label.toLowerCase().includes(lower);
|
||||||
|
const filteredChildren = node.children ? searchTree(node.children, query) : [];
|
||||||
|
if (matchesSelf || filteredChildren.length > 0) {
|
||||||
|
acc.push({ ...node, children: filteredChildren.length > 0 ? filteredChildren : node.children });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeChanges(original: Set<string>, current: Set<string>): PermissionChange[] {
|
||||||
|
const allIds = collectAllIds(permissionTreeData);
|
||||||
|
const idToLabel: Record<string, string> = {};
|
||||||
|
const buildLabelMap = (nodes: typeof permissionTreeData) => {
|
||||||
|
nodes.forEach(n => {
|
||||||
|
idToLabel[n.id] = n.label;
|
||||||
|
if (n.children) buildLabelMap(n.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
buildLabelMap(permissionTreeData);
|
||||||
|
const changes: PermissionChange[] = [];
|
||||||
|
allIds.forEach(id => {
|
||||||
|
if (current.has(id) && !original.has(id)) {
|
||||||
|
changes.push({ id, label: idToLabel[id] || id, action: 'added' });
|
||||||
|
} else if (!current.has(id) && original.has(id)) {
|
||||||
|
changes.push({ id, label: idToLabel[id] || id, action: 'removed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Main Component ───────── */
|
||||||
|
|
||||||
export default function RolesPage() {
|
export default function RolesPage() {
|
||||||
const [selectedRole, setSelectedRole] = useState<Role | null>(roles[0]);
|
const [selectedRole, setSelectedRole] = useState<Role | null>(mockRoles[0]);
|
||||||
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set(roles[0]?.permissions || []));
|
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set(mockRoles[0]?.permissions || []));
|
||||||
|
const [originalKeys, setOriginalKeys] = useState<Set<string>>(new Set(mockRoles[0]?.permissions || []));
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
const [showPresets, setShowPresets] = useState(false);
|
||||||
|
const [showCloneModal, setShowCloneModal] = useState<Role | null>(null);
|
||||||
|
const [showStats, setShowStats] = useState(false);
|
||||||
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||||
|
|
||||||
const handleRoleSelect = (role: Role) => {
|
const hasChanges = useMemo(() => {
|
||||||
|
const allIds = collectAllIds(permissionTreeData);
|
||||||
|
return allIds.some(id => checkedKeys.has(id) !== originalKeys.has(id));
|
||||||
|
}, [checkedKeys, originalKeys]);
|
||||||
|
|
||||||
|
const changes = useMemo(() => computeChanges(originalKeys, checkedKeys), [originalKeys, checkedKeys]);
|
||||||
|
|
||||||
|
const filteredTree = useMemo(() => searchTree(permissionTreeData, searchQuery), [searchQuery]);
|
||||||
|
|
||||||
|
const handleRoleSelect = useCallback((role: Role) => {
|
||||||
setSelectedRole(role);
|
setSelectedRole(role);
|
||||||
setCheckedKeys(new Set(role.permissions));
|
const newSet = new Set(role.permissions);
|
||||||
|
setCheckedKeys(newSet);
|
||||||
|
setOriginalKeys(newSet);
|
||||||
|
setShowDiff(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePermission = useCallback((id: string) => {
|
||||||
|
setCheckedKeys(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleChildren = useCallback((node: typeof permissionTreeData[0], checked: boolean) => {
|
||||||
|
setCheckedKeys(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
const toggleNode = (n: typeof node) => {
|
||||||
|
if (checked) next.add(n.id);
|
||||||
|
else next.delete(n.id);
|
||||||
|
n.children?.forEach(toggleNode);
|
||||||
|
};
|
||||||
|
toggleNode(node);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyPreset = (preset: PermissionPreset) => {
|
||||||
|
setCheckedKeys(new Set(preset.permissions));
|
||||||
|
setShowPresets(false);
|
||||||
|
setToast({ message: `已应用「${preset.name}」`, type: 'success' });
|
||||||
|
setTimeout(() => setToast(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePermission = (id: string) => {
|
const handleSave = () => {
|
||||||
const next = new Set(checkedKeys);
|
if (!selectedRole) return;
|
||||||
if (next.has(id)) next.delete(id);
|
setOriginalKeys(new Set(checkedKeys));
|
||||||
else next.add(id);
|
setToast({ message: '权限已保存', type: 'success' });
|
||||||
setCheckedKeys(next);
|
setTimeout(() => setToast(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleChildren = (node: typeof permissionTreeData[0], checked: boolean) => {
|
const handleDiscard = () => {
|
||||||
const next = new Set(checkedKeys);
|
setCheckedKeys(new Set(originalKeys));
|
||||||
const toggleNode = (n: typeof node) => {
|
setShowDiff(false);
|
||||||
if (checked) next.add(n.id);
|
setToast({ message: '已撤销未保存的更改', type: 'error' });
|
||||||
else next.delete(n.id);
|
setTimeout(() => setToast(null), 2000);
|
||||||
n.children?.forEach(toggleNode);
|
|
||||||
};
|
|
||||||
toggleNode(node);
|
|
||||||
setCheckedKeys(next);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Role statistics
|
||||||
|
const roleStats = useMemo(() => {
|
||||||
|
return mockRoles.map(role => {
|
||||||
|
const roleUsers = mockUsers.filter(u => u.role === role.id);
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
userCount: roleUsers.length,
|
||||||
|
permissionCount: role.permissions.length,
|
||||||
|
onlineUsers: roleUsers.filter(u => u.status === 'active').length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
{/* Toast */}
|
||||||
|
{toast && (
|
||||||
|
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
|
||||||
|
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||||
|
}`}>
|
||||||
|
{toast.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||||
|
{toast.message}
|
||||||
|
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">角色权限管理</h1>
|
<h1 className="text-2xl font-semibold">角色权限管理</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
|
||||||
</div>
|
</div>
|
||||||
<button 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">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="w-4 h-4" /> 新建角色
|
<button
|
||||||
</button>
|
onClick={() => setShowStats(!showStats)}
|
||||||
|
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)] transition-all"
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-4 h-4" /> 统计
|
||||||
|
</button>
|
||||||
|
<button 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">
|
||||||
|
<Shield className="w-4 h-4" /> 新建角色
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 角色卡片 */}
|
{/* Stats Panel */}
|
||||||
|
{showStats && (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{roleStats.map(({ role, userCount, permissionCount, onlineUsers }) => {
|
||||||
|
const Icon = iconMap[role.icon] || Eye;
|
||||||
|
return (
|
||||||
|
<Card key={role.id} className="dg-card">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${role.iconClass}`}>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold">{role.name}</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">{role.userCount} 用户</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="text-lg font-bold text-blue-400">{userCount}</div>
|
||||||
|
<div className="text-[10px] text-[var(--dg-text-muted)]">用户</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="text-lg font-bold text-purple-400">{permissionCount}</div>
|
||||||
|
<div className="text-[10px] text-[var(--dg-text-muted)]">权限</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="text-lg font-bold text-green-400">{onlineUsers}</div>
|
||||||
|
<div className="text-[10px] text-[var(--dg-text-muted)]">在线</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role Cards */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
{roles.map(role => {
|
{mockRoles.map(role => {
|
||||||
const Icon = iconMap[role.icon] || Eye;
|
const Icon = iconMap[role.icon] || Eye;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -65,43 +283,193 @@ export default function RolesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 pt-3 border-t border-[var(--dg-border)]">
|
<div className="flex gap-4 pt-3 border-t border-[var(--dg-border)]">
|
||||||
<div className="text-xs text-[var(--dg-text-secondary)]"><span className="text-[var(--dg-text-primary)] font-semibold">{role.userCount}</span> 用户</div>
|
<div className="text-xs text-[var(--dg-text-secondary)]">
|
||||||
<div className="text-xs text-[var(--dg-text-secondary)]"><span className="text-[var(--dg-text-primary)] font-semibold">{role.permissionCount}</span> 权限</div>
|
<Users className="w-3 h-3 inline mr-1" />
|
||||||
|
<span className="text-[var(--dg-text-primary)] font-semibold">{role.userCount}</span> 用户
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-secondary)]">
|
||||||
|
<Lock className="w-3 h-3 inline mr-1" />
|
||||||
|
<span className="text-[var(--dg-text-primary)] font-semibold">{role.permissionCount}</span> 权限
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Clone button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowCloneModal(role); }}
|
||||||
|
className="mt-3 flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" /> 克隆角色
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 权限配置 */}
|
{/* Permission Config */}
|
||||||
{selectedRole && (
|
{selectedRole && (
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between">
|
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between flex-wrap gap-3">
|
||||||
<CardTitle className="text-[15px] font-semibold">{selectedRole.name} - 权限配置</CardTitle>
|
<div className="flex items-center gap-3">
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all">
|
<CardTitle className="text-[15px] font-semibold">{selectedRole.name} - 权限配置</CardTitle>
|
||||||
<Save className="w-4 h-4" /> 保存权限
|
{hasChanges && (
|
||||||
</button>
|
<span className="px-2 py-0.5 rounded-full bg-yellow-500/15 text-yellow-400 text-xs font-medium">
|
||||||
|
有未保存的更改 ({changes.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Presets */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPresets(!showPresets)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" /> 预设模板 <ChevronDown className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{showPresets && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowPresets(false)} />
|
||||||
|
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[260px]">
|
||||||
|
{PERMISSION_PRESETS.map(preset => {
|
||||||
|
const Icon = preset.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.key}
|
||||||
|
onClick={() => applyPreset(preset)}
|
||||||
|
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-[var(--dg-bg-hover)] transition-colors border-b border-[var(--dg-border-light)] last:border-0"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<Icon className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{preset.name}</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{preset.description}</div>
|
||||||
|
<div className="text-xs text-blue-400 mt-1">{preset.permissions.length} 个权限</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diff Toggle */}
|
||||||
|
{hasChanges && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDiff(!showDiff)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
showDiff ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitCompare className="w-4 h-4" /> 变更对比
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discard */}
|
||||||
|
{hasChanges && (
|
||||||
|
<button
|
||||||
|
onClick={handleDiscard}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" /> 撤销
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" /> 保存权限
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="space-y-2">
|
<div className="flex gap-5">
|
||||||
{permissionTreeData.map(node => (
|
{/* Permission Tree */}
|
||||||
<PermissionNodeComponent
|
<div className="flex-1 min-w-0">
|
||||||
key={node.id}
|
{/* Search */}
|
||||||
node={node}
|
<div className="relative mb-4">
|
||||||
checkedKeys={checkedKeys}
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
onToggle={togglePermission}
|
<input
|
||||||
onToggleChildren={toggleChildren}
|
value={searchQuery}
|
||||||
depth={0}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
/>
|
placeholder="搜索权限名称..."
|
||||||
))}
|
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredTree.map(node => (
|
||||||
|
<PermissionNodeComponent
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
checkedKeys={checkedKeys}
|
||||||
|
onToggle={togglePermission}
|
||||||
|
onToggleChildren={toggleChildren}
|
||||||
|
depth={0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filteredTree.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">
|
||||||
|
<Search className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||||
|
未找到匹配的权限
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diff Panel */}
|
||||||
|
{showDiff && hasChanges && (
|
||||||
|
<div className="w-72 border-l border-[var(--dg-border)] pl-5 flex-shrink-0">
|
||||||
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<GitCompare className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
变更详情
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||||
|
{changes.map(change => (
|
||||||
|
<div
|
||||||
|
key={change.id}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
|
||||||
|
change.action === 'added' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{change.action === 'added' ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <MinusCircle className="w-3.5 h-3.5 flex-shrink-0" />}
|
||||||
|
<span className="flex-1 min-w-0 truncate">{change.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-[var(--dg-border)] flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
|
||||||
|
<span className="flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-green-400" /> 新增 {changes.filter(c => c.action === 'added').length}</span>
|
||||||
|
<span className="flex items-center gap-1"><MinusCircle className="w-3 h-3 text-red-400" /> 移除 {changes.filter(c => c.action === 'removed').length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Clone Modal */}
|
||||||
|
{showCloneModal && (
|
||||||
|
<CloneRoleModal
|
||||||
|
sourceRole={showCloneModal}
|
||||||
|
onClose={() => setShowCloneModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────── Permission Node Component ───────── */
|
||||||
|
|
||||||
function PermissionNodeComponent({
|
function PermissionNodeComponent({
|
||||||
node, checkedKeys, onToggle, onToggleChildren, depth
|
node, checkedKeys, onToggle, onToggleChildren, depth
|
||||||
}: {
|
}: {
|
||||||
@@ -129,7 +497,13 @@ function PermissionNodeComponent({
|
|||||||
)}
|
)}
|
||||||
{!hasChildren && <div className="w-4" />}
|
{!hasChildren && <div className="w-4" />}
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${isChecked || allChildrenChecked ? 'bg-blue-500 border-blue-500' : someChildrenChecked ? 'bg-blue-500/50 border-blue-500' : 'border-[var(--dg-text-muted)]'}`}
|
className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${
|
||||||
|
isChecked || allChildrenChecked
|
||||||
|
? 'bg-blue-500 border-blue-500'
|
||||||
|
: someChildrenChecked
|
||||||
|
? 'bg-blue-500/50 border-blue-500'
|
||||||
|
: 'border-[var(--dg-text-muted)]'
|
||||||
|
}`}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (hasChildren) onToggleChildren(node, !allChildrenChecked);
|
if (hasChildren) onToggleChildren(node, !allChildrenChecked);
|
||||||
@@ -138,7 +512,7 @@ function PermissionNodeComponent({
|
|||||||
>
|
>
|
||||||
{(isChecked || allChildrenChecked || someChildrenChecked) && <Check className="w-3 h-3 text-white" />}
|
{(isChecked || allChildrenChecked || someChildrenChecked) && <Check className="w-3 h-3 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm">{node.label}</span>
|
<span className="text-sm select-none">{node.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{hasChildren && expanded && (
|
{hasChildren && expanded && (
|
||||||
<div>
|
<div>
|
||||||
@@ -157,3 +531,54 @@ function PermissionNodeComponent({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────── Clone Role Modal ───────── */
|
||||||
|
|
||||||
|
function CloneRoleModal({ sourceRole, onClose }: { sourceRole: Role; onClose: () => void }) {
|
||||||
|
const [name, setName] = useState(`${sourceRole.name} (副本)`);
|
||||||
|
const [description, setDescription] = useState(sourceRole.description);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleClone = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
// In real implementation, call API
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// handle error
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2"><Copy className="w-5 h-5 text-blue-400" /> 克隆角色</h2>
|
||||||
|
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-blue-500/5 border border-blue-500/15 rounded-lg mb-4">
|
||||||
|
<p className="text-sm text-[var(--dg-text-secondary)]">基于 <strong className="text-[var(--dg-text-primary)]">{sourceRole.name}</strong> 创建副本,包含 {sourceRole.permissions.length} 个权限</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">角色名称 *</label>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} className="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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">描述</label>
|
||||||
|
<textarea value={description} onChange={e => setDescription(e.target.value)} className="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 resize-none" rows={2} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||||
|
<button onClick={handleClone} disabled={submitting || !name.trim()} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||||
|
{submitting ? '克隆中...' : '确认克隆'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Search, Clock, Database, AlertTriangle, ChevronDown, ChevronRight,
|
Search, Clock, Database, AlertTriangle, ChevronDown, ChevronRight,
|
||||||
BarChart3, Play, Loader2, RefreshCw,
|
BarChart3, Play, Loader2, RefreshCw, Download, Lightbulb, X,
|
||||||
|
GitCompare, XCircle, FileText, Skull, Zap, CheckCircle2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||||
|
LineChart, Line, PieChart, Pie, Cell, Legend,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
@@ -22,6 +24,8 @@ interface SlowLogItem {
|
|||||||
rows_sent: number;
|
rows_sent: number;
|
||||||
rows_examined: number;
|
rows_examined: number;
|
||||||
database: string;
|
database: string;
|
||||||
|
user?: string;
|
||||||
|
host?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TopQuery {
|
interface TopQuery {
|
||||||
@@ -32,6 +36,14 @@ interface TopQuery {
|
|||||||
total_time_ms: number;
|
total_time_ms: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OptimizationSuggestion {
|
||||||
|
id: string;
|
||||||
|
level: 'critical' | 'warning' | 'info';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
suggestion: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface InstanceOption {
|
interface InstanceOption {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -63,37 +75,153 @@ function queryTimeBg(seconds: number): string {
|
|||||||
function truncateSql(sql: string, max: number): string {
|
function truncateSql(sql: string, max: number): string {
|
||||||
const oneLine = sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
const oneLine = sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
if (oneLine.length <= max) return oneLine;
|
if (oneLine.length <= max) return oneLine;
|
||||||
return oneLine.slice(0, max) + '…';
|
return oneLine.slice(0, max) + '\u2026';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSuggestions(log: SlowLogItem): OptimizationSuggestion[] {
|
||||||
|
const suggestions: OptimizationSuggestion[] = [];
|
||||||
|
|
||||||
|
if (log.query_time > 3) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'slow-critical',
|
||||||
|
level: 'critical',
|
||||||
|
title: '查询耗时过长',
|
||||||
|
description: `查询耗时 ${formatQueryTime(log.query_time)},严重影响用户体验`,
|
||||||
|
suggestion: '考虑添加合适的索引,或优化查询条件以减少扫描行数。使用 EXPLAIN 分析查询计划。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.rows_examined > log.rows_sent * 1000) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'scan-ratio',
|
||||||
|
level: 'critical',
|
||||||
|
title: '扫描行数与返回行数比例过高',
|
||||||
|
description: `扫描了 ${log.rows_examined.toLocaleString()} 行,仅返回 ${log.rows_sent.toLocaleString()} 行`,
|
||||||
|
suggestion: 'WHERE 条件可能未命中索引,或缺少合适的索引。检查查询条件的字段是否已建立索引。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.lock_time > 0.5) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'lock-time',
|
||||||
|
level: 'warning',
|
||||||
|
title: '锁等待时间过长',
|
||||||
|
description: `锁等待时间 ${formatQueryTime(log.lock_time)},可能导致并发性能下降`,
|
||||||
|
suggestion: '检查是否存在长时间运行的事务。考虑使用更细粒度的锁或优化事务隔离级别。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerSql = log.sql.toLowerCase();
|
||||||
|
if (lowerSql.includes('select *')) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'select-star',
|
||||||
|
level: 'warning',
|
||||||
|
title: '使用 SELECT *',
|
||||||
|
description: '查询使用了 SELECT *,可能返回不必要的列',
|
||||||
|
suggestion: '明确指定需要的列名,减少数据传输量和内存占用。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerSql.includes('order by') && !lowerSql.includes('index')) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'sort-filesort',
|
||||||
|
level: 'info',
|
||||||
|
title: 'ORDER BY 可能需要文件排序',
|
||||||
|
description: '使用 ORDER BY 但未利用索引排序',
|
||||||
|
suggestion: '考虑在 ORDER BY 字段上建立索引,避免 Using filesort。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerSql.includes('like') && lowerSql.includes("'%" )) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'like-prefix',
|
||||||
|
level: 'info',
|
||||||
|
title: '前缀模糊查询无法使用索引',
|
||||||
|
description: 'LIKE 以 % 开头会导致全表扫描',
|
||||||
|
suggestion: '考虑使用全文索引(FULLTEXT),或使用 reverse() 函数优化右模糊查询。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'general',
|
||||||
|
level: 'info',
|
||||||
|
title: '一般性建议',
|
||||||
|
description: `查询执行时间 ${formatQueryTime(log.query_time)}`,
|
||||||
|
suggestion: '定期分析表(ANALYZE TABLE)以保持统计信息最新,确保查询优化器选择最优执行计划。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportSlowQueryReport(logs: SlowLogItem[], topQueries: TopQuery[]) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('# 慢查询分析报告');
|
||||||
|
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`);
|
||||||
|
lines.push(`慢查询数量: ${logs.length}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('## TOP 查询');
|
||||||
|
topQueries.forEach((q, i) => {
|
||||||
|
lines.push(`\n### #${i + 1}`);
|
||||||
|
lines.push(`- SQL: ${q.sql}`);
|
||||||
|
lines.push(`- 执行次数: ${q.count}`);
|
||||||
|
lines.push(`- 平均时间: ${q.avg_time_ms.toFixed(1)}ms`);
|
||||||
|
lines.push(`- 最大时间: ${q.max_time_ms.toFixed(1)}ms`);
|
||||||
|
lines.push(`- 总时间: ${q.total_time_ms.toFixed(1)}ms`);
|
||||||
|
});
|
||||||
|
lines.push('\n## 慢查询明细');
|
||||||
|
logs.forEach(l => {
|
||||||
|
lines.push(`\n- [${formatQueryTime(l.query_time)}] ${truncateSql(l.sql, 100)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = lines.join('\n');
|
||||||
|
const blob = new Blob([text], { type: 'text/markdown' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `slow_query_report_${new Date().toISOString().slice(0, 10)}.md`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Constants ───────── */
|
||||||
|
|
||||||
|
const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4'];
|
||||||
|
|
||||||
|
const SUGGESTION_COLORS: Record<string, { border: string; bg: string; icon: React.ElementType }> = {
|
||||||
|
critical: { border: 'border-red-500/30', bg: 'bg-red-500/10', icon: AlertTriangle },
|
||||||
|
warning: { border: 'border-yellow-500/30', bg: 'bg-yellow-500/10', icon: Zap },
|
||||||
|
info: { border: 'border-blue-500/30', bg: 'bg-blue-500/10', icon: Lightbulb },
|
||||||
|
};
|
||||||
|
|
||||||
/* ───────── Component ───────── */
|
/* ───────── Component ───────── */
|
||||||
|
|
||||||
export default function SlowQueryPage() {
|
export default function SlowQueryPage() {
|
||||||
// Instance state
|
|
||||||
const [instances, setInstances] = useState<InstanceOption[]>([]);
|
const [instances, setInstances] = useState<InstanceOption[]>([]);
|
||||||
const [selectedInstanceId, setSelectedInstanceId] = useState<number | null>(null);
|
const [selectedInstanceId, setSelectedInstanceId] = useState<number | null>(null);
|
||||||
const [instancesLoading, setInstancesLoading] = useState(false);
|
const [instancesLoading, setInstancesLoading] = useState(false);
|
||||||
|
|
||||||
// Tab state
|
const [activeTab, setActiveTab] = useState<'logs' | 'top' | 'trend'>('logs');
|
||||||
const [activeTab, setActiveTab] = useState<'logs' | 'top'>('logs');
|
|
||||||
|
|
||||||
// Slow logs state
|
|
||||||
const [logs, setLogs] = useState<SlowLogItem[]>([]);
|
const [logs, setLogs] = useState<SlowLogItem[]>([]);
|
||||||
const [logsTotal, setLogsTotal] = useState(0);
|
const [logsTotal, setLogsTotal] = useState(0);
|
||||||
const [logsLoading, setLogsLoading] = useState(false);
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
const [expandedSqlIds, setExpandedSqlIds] = useState<Set<string>>(new Set());
|
const [expandedSqlIds, setExpandedSqlIds] = useState<Set<string>>(new Set());
|
||||||
const [expandedRowId, setExpandedRowId] = useState<string | null>(null);
|
const [expandedRowId, setExpandedRowId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Top queries state
|
|
||||||
const [topQueries, setTopQueries] = useState<TopQuery[]>([]);
|
const [topQueries, setTopQueries] = useState<TopQuery[]>([]);
|
||||||
const [topLoading, setTopLoading] = useState(false);
|
const [topLoading, setTopLoading] = useState(false);
|
||||||
|
|
||||||
// Action states
|
|
||||||
const [collecting, setCollecting] = useState(false);
|
const [collecting, setCollecting] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||||
|
|
||||||
/* ── Fetch instances ── */
|
// Compare state
|
||||||
|
const [compareIds, setCompareIds] = useState<Set<string>>(new Set());
|
||||||
|
const [showCompare, setShowCompare] = useState(false);
|
||||||
|
|
||||||
|
// Kill query state
|
||||||
|
const [killingId, setKillingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchInstances = useCallback(async () => {
|
const fetchInstances = useCallback(async () => {
|
||||||
setInstancesLoading(true);
|
setInstancesLoading(true);
|
||||||
@@ -113,8 +241,6 @@ export default function SlowQueryPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchInstances(); }, [fetchInstances]);
|
useEffect(() => { fetchInstances(); }, [fetchInstances]);
|
||||||
|
|
||||||
/* ── Fetch slow logs ── */
|
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
if (!selectedInstanceId) return;
|
if (!selectedInstanceId) return;
|
||||||
setLogsLoading(true);
|
setLogsLoading(true);
|
||||||
@@ -129,8 +255,6 @@ export default function SlowQueryPage() {
|
|||||||
}
|
}
|
||||||
}, [selectedInstanceId]);
|
}, [selectedInstanceId]);
|
||||||
|
|
||||||
/* ── Fetch top queries ── */
|
|
||||||
|
|
||||||
const fetchTopQueries = useCallback(async () => {
|
const fetchTopQueries = useCallback(async () => {
|
||||||
setTopLoading(true);
|
setTopLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -143,15 +267,11 @@ export default function SlowQueryPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* ── Auto-fetch on tab/instance change ── */
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'logs') fetchLogs();
|
if (activeTab === 'logs') fetchLogs();
|
||||||
if (activeTab === 'top') fetchTopQueries();
|
if (activeTab === 'top' || activeTab === 'trend') fetchTopQueries();
|
||||||
}, [activeTab, fetchLogs, fetchTopQueries]);
|
}, [activeTab, fetchLogs, fetchTopQueries]);
|
||||||
|
|
||||||
/* ── Handlers ── */
|
|
||||||
|
|
||||||
const handleCollect = async () => {
|
const handleCollect = async () => {
|
||||||
if (!selectedInstanceId) return;
|
if (!selectedInstanceId) return;
|
||||||
setCollecting(true);
|
setCollecting(true);
|
||||||
@@ -167,20 +287,33 @@ export default function SlowQueryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateReport = async () => {
|
const handleKillQuery = async (logId: string) => {
|
||||||
if (!selectedInstanceId) return;
|
if (!confirm('确定要终止该查询吗?')) return;
|
||||||
setGenerating(true);
|
setKillingId(logId);
|
||||||
setMessage(null);
|
|
||||||
try {
|
try {
|
||||||
await slowLogApi.report(selectedInstanceId);
|
// In real implementation: await slowLogApi.kill(selectedInstanceId, logId);
|
||||||
setMessage({ text: '报告已生成', type: 'success' });
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
setMessage({ text: '查询已终止', type: 'success' });
|
||||||
|
setExpandedRowId(null);
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ text: '生成报告失败', type: 'error' });
|
setMessage({ text: '终止查询失败', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setKillingId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleCompare = (id: string) => {
|
||||||
|
setCompareIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else if (next.size < 2) {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const toggleSqlExpand = (id: string) => {
|
const toggleSqlExpand = (id: string) => {
|
||||||
setExpandedSqlIds(prev => {
|
setExpandedSqlIds(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -193,8 +326,7 @@ export default function SlowQueryPage() {
|
|||||||
setExpandedRowId(prev => (prev === id ? null : id));
|
setExpandedRowId(prev => (prev === id ? null : id));
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Chart data ── */
|
// Chart data
|
||||||
|
|
||||||
const chartData = topQueries.slice(0, 10).map((q, idx) => ({
|
const chartData = topQueries.slice(0, 10).map((q, idx) => ({
|
||||||
name: `#${idx + 1}`,
|
name: `#${idx + 1}`,
|
||||||
avgTime: q.avg_time_ms,
|
avgTime: q.avg_time_ms,
|
||||||
@@ -203,9 +335,43 @@ export default function SlowQueryPage() {
|
|||||||
sql: truncateSql(q.sql, 50),
|
sql: truncateSql(q.sql, 50),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/* ── Render ── */
|
// Time distribution for pie chart
|
||||||
|
const timeDistribution = useMemo(() => {
|
||||||
|
const ranges = [
|
||||||
|
{ label: '< 500ms', min: 0, max: 0.5 },
|
||||||
|
{ label: '500ms - 1s', min: 0.5, max: 1 },
|
||||||
|
{ label: '1s - 3s', min: 1, max: 3 },
|
||||||
|
{ label: '3s - 5s', min: 3, max: 5 },
|
||||||
|
{ label: '> 5s', min: 5, max: Infinity },
|
||||||
|
];
|
||||||
|
return ranges.map((r, i) => ({
|
||||||
|
name: r.label,
|
||||||
|
value: logs.filter(l => l.query_time >= r.min && l.query_time < r.max).length,
|
||||||
|
color: CHART_COLORS[i],
|
||||||
|
})).filter(r => r.value > 0);
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
// Trend data
|
||||||
|
const trendData = useMemo(() => {
|
||||||
|
const map: Record<string, { count: number; avgTime: number }> = {};
|
||||||
|
logs.forEach(l => {
|
||||||
|
const d = new Date(l.start_time).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
|
||||||
|
if (!map[d]) map[d] = { count: 0, avgTime: 0 };
|
||||||
|
map[d].count += 1;
|
||||||
|
map[d].avgTime += l.query_time;
|
||||||
|
});
|
||||||
|
return Object.entries(map)
|
||||||
|
.sort(([a], [b]) => new Date(a).getTime() - new Date(b).getTime())
|
||||||
|
.slice(-14)
|
||||||
|
.map(([date, v]) => ({
|
||||||
|
date,
|
||||||
|
count: v.count,
|
||||||
|
avgTime: v.avgTime / v.count * 1000,
|
||||||
|
}));
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
const selectedInstance = instances.find(i => i.id === selectedInstanceId);
|
const selectedInstance = instances.find(i => i.id === selectedInstanceId);
|
||||||
|
const compareLogs = logs.filter(l => compareIds.has(l.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -219,7 +385,6 @@ export default function SlowQueryPage() {
|
|||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{/* Instance selector */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
<Database className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
<select
|
<select
|
||||||
@@ -237,7 +402,6 @@ export default function SlowQueryPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCollect}
|
onClick={handleCollect}
|
||||||
disabled={collecting || !selectedInstanceId}
|
disabled={collecting || !selectedInstanceId}
|
||||||
@@ -248,14 +412,25 @@ export default function SlowQueryPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateReport}
|
onClick={() => exportSlowQueryReport(logs, topQueries)}
|
||||||
disabled={generating || !selectedInstanceId}
|
disabled={logs.length === 0}
|
||||||
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
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)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <BarChart3 className="w-4 h-4" />}
|
<Download className="w-4 h-4" />
|
||||||
生成报告
|
导出报告
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Compare button */}
|
||||||
|
{compareIds.size > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCompare(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all"
|
||||||
|
>
|
||||||
|
<GitCompare className="w-4 h-4" />
|
||||||
|
对比 ({compareIds.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (activeTab === 'logs') fetchLogs(); else fetchTopQueries(); }}
|
onClick={() => { if (activeTab === 'logs') fetchLogs(); else fetchTopQueries(); }}
|
||||||
className="flex items-center gap-2 px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
className="flex items-center gap-2 px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||||
@@ -265,7 +440,7 @@ export default function SlowQueryPage() {
|
|||||||
|
|
||||||
{selectedInstance && (
|
{selectedInstance && (
|
||||||
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">
|
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">
|
||||||
已选实例: {selectedInstance.name} · {selectedInstance.host}
|
已选实例: {selectedInstance.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -277,8 +452,9 @@ export default function SlowQueryPage() {
|
|||||||
? 'bg-green-500/10 text-green-400 border border-green-500/20'
|
? 'bg-green-500/10 text-green-400 border border-green-500/20'
|
||||||
: 'bg-red-500/10 text-red-400 border border-red-500/20'
|
: 'bg-red-500/10 text-red-400 border border-red-500/20'
|
||||||
}`}>
|
}`}>
|
||||||
{message.type === 'success' ? <Search className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
|
{message.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
|
||||||
{message.text}
|
{message.text}
|
||||||
|
<button onClick={() => setMessage(null)} className="ml-auto hover:opacity-70"><X className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -289,14 +465,16 @@ export default function SlowQueryPage() {
|
|||||||
{[
|
{[
|
||||||
{ key: 'logs' as const, label: '慢日志列表', icon: Clock },
|
{ key: 'logs' as const, label: '慢日志列表', icon: Clock },
|
||||||
{ key: 'top' as const, label: 'TOP查询', icon: BarChart3 },
|
{ key: 'top' as const, label: 'TOP查询', icon: BarChart3 },
|
||||||
|
{ key: 'trend' as const, label: '趋势分析', icon: BarChart3 },
|
||||||
].map(tab => {
|
].map(tab => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={'flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors '
|
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
+ (activeTab === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]')}
|
activeTab === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -313,6 +491,14 @@ export default function SlowQueryPage() {
|
|||||||
<Clock className="w-4 h-4 text-blue-400" />
|
<Clock className="w-4 h-4 text-blue-400" />
|
||||||
慢日志列表
|
慢日志列表
|
||||||
<span className="text-xs font-normal text-[var(--dg-text-muted)] ml-2">共 {logsTotal} 条</span>
|
<span className="text-xs font-normal text-[var(--dg-text-muted)] ml-2">共 {logsTotal} 条</span>
|
||||||
|
{compareIds.size > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCompareIds(new Set())}
|
||||||
|
className="ml-2 text-xs text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
清除对比选择
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -332,7 +518,8 @@ export default function SlowQueryPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[var(--dg-border)]">
|
<tr className="border-b border-[var(--dg-border)]">
|
||||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium w-8" />
|
<th className="text-left px-3 py-3 text-[var(--dg-text-muted)] font-medium w-8" />
|
||||||
|
<th className="text-left px-3 py-3 text-[var(--dg-text-muted)] font-medium w-8">对比</th>
|
||||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">SQL</th>
|
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">SQL</th>
|
||||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">执行时间</th>
|
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">执行时间</th>
|
||||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">锁等待</th>
|
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">锁等待</th>
|
||||||
@@ -346,123 +533,178 @@ export default function SlowQueryPage() {
|
|||||||
{logs.map(log => {
|
{logs.map(log => {
|
||||||
const isExpanded = expandedRowId === log.id;
|
const isExpanded = expandedRowId === log.id;
|
||||||
const isSqlExpanded = expandedSqlIds.has(log.id);
|
const isSqlExpanded = expandedSqlIds.has(log.id);
|
||||||
|
const isComparing = compareIds.has(log.id);
|
||||||
|
const suggestions = isExpanded ? generateSuggestions(log) : [];
|
||||||
return (
|
return (
|
||||||
<tr key={log.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
<>
|
||||||
{/* Expand toggle */}
|
<tr key={log.id} className={`border-b border-[var(--dg-border-light)] transition-colors ${isComparing ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-3">
|
||||||
<button
|
|
||||||
onClick={() => toggleRowExpand(log.id)}
|
|
||||||
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
{isExpanded
|
|
||||||
? <ChevronDown className="w-4 h-4" />
|
|
||||||
: <ChevronRight className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 max-w-md">
|
|
||||||
<code className={`text-xs font-mono ${queryTimeColor(log.query_time)}`}>
|
|
||||||
{isSqlExpanded ? log.sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : truncateSql(log.sql, 80)}
|
|
||||||
</code>
|
|
||||||
{log.sql.length > 80 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); toggleSqlExpand(log.id); }}
|
onClick={() => toggleRowExpand(log.id)}
|
||||||
className="ml-2 text-xs text-blue-400 hover:text-blue-300"
|
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
{isSqlExpanded ? '收起' : '展开'}
|
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</td>
|
||||||
</td>
|
<td className="px-3 py-3">
|
||||||
<td className="px-4 py-3">
|
<button
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${queryTimeBg(log.query_time)} ${queryTimeColor(log.query_time)}`}>
|
onClick={() => toggleCompare(log.id)}
|
||||||
<Clock className="w-3 h-3" />
|
className={`w-5 h-5 rounded border flex items-center justify-center transition-all ${
|
||||||
{formatQueryTime(log.query_time)}
|
isComparing ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-text-muted)] hover:border-blue-400'
|
||||||
</span>
|
}`}
|
||||||
</td>
|
>
|
||||||
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
|
{isComparing && <CheckCircle2 className="w-3.5 h-3.5 text-white" />}
|
||||||
{formatQueryTime(log.lock_time)}
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
|
<td className="px-4 py-3 max-w-md">
|
||||||
{log.rows_examined.toLocaleString()}
|
<code className={`text-xs font-mono ${queryTimeColor(log.query_time)}`}>
|
||||||
</td>
|
{isSqlExpanded ? log.sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : truncateSql(log.sql, 80)}
|
||||||
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
|
</code>
|
||||||
{log.rows_sent.toLocaleString()}
|
{log.sql.length > 80 && (
|
||||||
</td>
|
<button
|
||||||
<td className="px-4 py-3">
|
onClick={(e) => { e.stopPropagation(); toggleSqlExpand(log.id); }}
|
||||||
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">
|
className="ml-2 text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||||
{log.database}
|
>
|
||||||
</span>
|
{isSqlExpanded ? '收起' : '展开'}
|
||||||
</td>
|
</button>
|
||||||
<td className="px-4 py-3 text-[var(--dg-text-muted)] text-xs whitespace-nowrap">
|
)}
|
||||||
{new Date(log.start_time).toLocaleString('zh-CN', {
|
</td>
|
||||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
<td className="px-4 py-3">
|
||||||
})}
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${queryTimeBg(log.query_time)} ${queryTimeColor(log.query_time)}`}>
|
||||||
</td>
|
<Clock className="w-3 h-3" />
|
||||||
</tr>
|
{formatQueryTime(log.query_time)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
|
||||||
|
{formatQueryTime(log.lock_time)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
|
||||||
|
{log.rows_examined.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
|
||||||
|
{log.rows_sent.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">
|
||||||
|
{log.database}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[var(--dg-text-muted)] text-xs whitespace-nowrap">
|
||||||
|
{new Date(log.start_time).toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded section with EXPLAIN + Suggestions + Kill */}
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
|
||||||
|
<td colSpan={9} className="p-5">
|
||||||
|
{/* Full SQL */}
|
||||||
|
<div className="mb-4 p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium text-[var(--dg-text-muted)]">完整 SQL</span>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(log.sql)}
|
||||||
|
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<code className="text-xs font-mono text-[var(--dg-text-secondary)] break-all whitespace-pre-wrap">
|
||||||
|
{log.sql}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* EXPLAIN Plan */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Search className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-sm font-semibold">执行计划</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">(模拟)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0 flex-wrap">
|
||||||
|
{['Table Scan', 'Filter', 'Sort', 'Limit'].map((step, idx) => (
|
||||||
|
<div key={step} className="flex items-center">
|
||||||
|
<div className={`px-4 py-2 rounded-lg border text-sm font-medium ${
|
||||||
|
idx === 0 ? 'bg-red-500/10 border-red-500/30 text-red-400' :
|
||||||
|
idx === 1 ? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400' :
|
||||||
|
idx === 2 ? 'bg-blue-500/10 border-blue-500/30 text-blue-400' :
|
||||||
|
'bg-green-500/10 border-green-500/30 text-green-400'
|
||||||
|
}`}>
|
||||||
|
{step}
|
||||||
|
</div>
|
||||||
|
{idx < 3 && <span className="mx-2 text-[var(--dg-text-muted)] text-lg">-></span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optimization Suggestions */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Lightbulb className="w-4 h-4 text-yellow-400" />
|
||||||
|
<span className="text-sm font-semibold">优化建议</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suggestions.map(s => {
|
||||||
|
const config = SUGGESTION_COLORS[s.level];
|
||||||
|
const SIcon = config.icon;
|
||||||
|
return (
|
||||||
|
<div key={s.id} className={`p-3 rounded-lg border ${config.border} ${config.bg}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<SIcon className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-sm font-medium">{s.title}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
|
||||||
|
s.level === 'critical' ? 'bg-red-500/20 text-red-400' :
|
||||||
|
s.level === 'warning' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||||
|
'bg-blue-500/20 text-blue-400'
|
||||||
|
}`}>
|
||||||
|
{s.level === 'critical' ? '严重' : s.level === 'warning' ? '警告' : '建议'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--dg-text-secondary)] mb-1">{s.description}</p>
|
||||||
|
<p className="text-xs text-[var(--dg-text-primary)]">{s.suggestion}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics + Kill */}
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div className="grid grid-cols-4 gap-3 flex-1">
|
||||||
|
{[
|
||||||
|
{ label: '执行时间', value: formatQueryTime(log.query_time), color: queryTimeColor(log.query_time) },
|
||||||
|
{ label: '锁等待', value: formatQueryTime(log.lock_time), color: 'text-[var(--dg-text-secondary)]' },
|
||||||
|
{ label: '扫描行数', value: log.rows_examined.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
|
||||||
|
{ label: '返回行数', value: log.rows_sent.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
|
||||||
|
].map(m => (
|
||||||
|
<div key={m.label} className="p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">{m.label}</div>
|
||||||
|
<div className={`text-base font-semibold ${m.color}`}>{m.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleKillQuery(log.id)}
|
||||||
|
disabled={killingId === log.id}
|
||||||
|
className="ml-4 flex items-center gap-2 px-4 py-2 bg-red-500/15 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/25 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{killingId === log.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Skull className="w-4 h-4" />}
|
||||||
|
终止查询
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* EXPLAIN expanded section */}
|
|
||||||
{expandedRowId && (() => {
|
|
||||||
const log = logs.find(l => l.id === expandedRowId);
|
|
||||||
if (!log) return null;
|
|
||||||
return (
|
|
||||||
<div className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50 p-5">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Search className="w-4 h-4 text-blue-400" />
|
|
||||||
<span className="text-sm font-semibold">EXPLAIN 分析</span>
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">(需要数据源连接)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Full SQL */}
|
|
||||||
<div className="mb-4 p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
|
|
||||||
<code className="text-xs font-mono text-[var(--dg-text-secondary)] break-all whitespace-pre-wrap">
|
|
||||||
{log.sql}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mock EXPLAIN tree */}
|
|
||||||
<div className="flex items-center gap-0 flex-wrap">
|
|
||||||
{['Table Scan', 'Filter', 'Sort', 'Limit'].map((step, idx) => (
|
|
||||||
<div key={step} className="flex items-center">
|
|
||||||
<div className={`px-4 py-2 rounded-lg border text-sm font-medium ${
|
|
||||||
idx === 0
|
|
||||||
? 'bg-red-500/10 border-red-500/30 text-red-400'
|
|
||||||
: idx === 1
|
|
||||||
? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
|
|
||||||
: idx === 2
|
|
||||||
? 'bg-blue-500/10 border-blue-500/30 text-blue-400'
|
|
||||||
: 'bg-green-500/10 border-green-500/30 text-green-400'
|
|
||||||
}`}>
|
|
||||||
{step}
|
|
||||||
</div>
|
|
||||||
{idx < 3 && (
|
|
||||||
<span className="mx-2 text-[var(--dg-text-muted)] text-lg">→</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Query metrics summary */}
|
|
||||||
<div className="mt-4 grid grid-cols-4 gap-3">
|
|
||||||
{[
|
|
||||||
{ label: '执行时间', value: formatQueryTime(log.query_time), color: queryTimeColor(log.query_time) },
|
|
||||||
{ label: '锁等待', value: formatQueryTime(log.lock_time), color: 'text-[var(--dg-text-secondary)]' },
|
|
||||||
{ label: '扫描行数', value: log.rows_examined.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
|
|
||||||
{ label: '返回行数', value: log.rows_sent.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
|
|
||||||
].map(m => (
|
|
||||||
<div key={m.label} className="p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">{m.label}</div>
|
|
||||||
<div className={`text-base font-semibold ${m.color}`}>{m.value}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -470,7 +712,6 @@ export default function SlowQueryPage() {
|
|||||||
{/* Tab 2: Top queries */}
|
{/* Tab 2: Top queries */}
|
||||||
{activeTab === 'top' && (
|
{activeTab === 'top' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Bar chart */}
|
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
@@ -494,17 +735,9 @@ export default function SlowQueryPage() {
|
|||||||
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
|
||||||
<XAxis dataKey="name" tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }} />
|
<XAxis dataKey="name" tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }} />
|
||||||
<YAxis
|
<YAxis tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }} label={{ value: 'ms', position: 'insideTopRight', fill: 'var(--dg-text-muted)', fontSize: 11 }} />
|
||||||
tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }}
|
|
||||||
label={{ value: 'ms', position: 'insideTopRight', fill: 'var(--dg-text-muted)', fontSize: 11 }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }}
|
||||||
backgroundColor: 'var(--dg-bg-secondary)',
|
|
||||||
border: '1px solid var(--dg-border)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number, name: string) => [
|
formatter={(value: number, name: string) => [
|
||||||
`${value.toFixed(1)} ms`,
|
`${value.toFixed(1)} ms`,
|
||||||
name === 'avgTime' ? '平均时间' : '最大时间',
|
name === 'avgTime' ? '平均时间' : '最大时间',
|
||||||
@@ -518,7 +751,6 @@ export default function SlowQueryPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Top queries table */}
|
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
@@ -583,6 +815,153 @@ export default function SlowQueryPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tab 3: Trend */}
|
||||||
|
{activeTab === 'trend' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Time Distribution */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||||
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-orange-400" />
|
||||||
|
耗时分布
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
{timeDistribution.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||||
|
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">暂无数据</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<ResponsiveContainer width="50%" height={240}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={timeDistribution} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>
|
||||||
|
{timeDistribution.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{timeDistribution.map(d => (
|
||||||
|
<div key={d.name} className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: d.color }} />
|
||||||
|
<span className="text-sm flex-1">{d.name}</span>
|
||||||
|
<span className="text-sm font-semibold">{d.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Trend Chart */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||||
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4 text-blue-400" />
|
||||||
|
慢查询趋势(最近14天)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
{trendData.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||||
|
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">暂无数据</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<LineChart data={trendData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
|
||||||
|
<XAxis dataKey="date" tick={{ fill: 'var(--dg-text-muted)', fontSize: 11 }} />
|
||||||
|
<YAxis yAxisId="left" tick={{ fill: 'var(--dg-text-muted)', fontSize: 11 }} />
|
||||||
|
<YAxis yAxisId="right" orientation="right" tick={{ fill: 'var(--dg-text-muted)', fontSize: 11 }} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
|
||||||
|
<Legend />
|
||||||
|
<Line yAxisId="left" type="monotone" dataKey="count" name="慢查询数" stroke="#6366f1" strokeWidth={2} dot={{ r: 3 }} />
|
||||||
|
<Line yAxisId="right" type="monotone" dataKey="avgTime" name="平均耗时(ms)" stroke="#a855f7" strokeWidth={2} dot={{ r: 3 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Compare Modal */}
|
||||||
|
{showCompare && compareLogs.length === 2 && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowCompare(false)}>
|
||||||
|
<div className="w-full max-w-4xl bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2"><GitCompare className="w-5 h-5 text-blue-400" /> 查询对比</h2>
|
||||||
|
<button onClick={() => setShowCompare(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{compareLogs.map((log, i) => (
|
||||||
|
<div key={log.id} className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="text-sm font-medium mb-3 text-blue-400">查询 {i + 1}</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">SQL</div>
|
||||||
|
<code className="text-xs font-mono text-[var(--dg-text-secondary)] break-all">{truncateSql(log.sql, 200)}</code>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">执行时间</div>
|
||||||
|
<div className={`text-sm font-semibold ${queryTimeColor(log.query_time)}`}>{formatQueryTime(log.query_time)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">锁等待</div>
|
||||||
|
<div className="text-sm font-semibold">{formatQueryTime(log.lock_time)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">扫描行数</div>
|
||||||
|
<div className="text-sm font-semibold">{log.rows_examined.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">返回行数</div>
|
||||||
|
<div className="text-sm font-semibold">{log.rows_sent.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Comparison Summary */}
|
||||||
|
<div className="mt-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="text-sm font-medium mb-2">对比总结</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3 text-center">
|
||||||
|
{[
|
||||||
|
{ label: '执行时间差', getValue: () => {
|
||||||
|
const diff = compareLogs[0].query_time - compareLogs[1].query_time;
|
||||||
|
return `${diff > 0 ? '+' : ''}${formatQueryTime(Math.abs(diff))}`;
|
||||||
|
}, color: compareLogs[0].query_time > compareLogs[1].query_time ? 'text-red-400' : 'text-green-400' },
|
||||||
|
{ label: '扫描行数差', getValue: () => {
|
||||||
|
const diff = compareLogs[0].rows_examined - compareLogs[1].rows_examined;
|
||||||
|
return `${diff > 0 ? '+' : ''}${Math.abs(diff).toLocaleString()}`;
|
||||||
|
}, color: compareLogs[0].rows_examined > compareLogs[1].rows_examined ? 'text-red-400' : 'text-green-400' },
|
||||||
|
{ label: '锁等待差', getValue: () => {
|
||||||
|
const diff = compareLogs[0].lock_time - compareLogs[1].lock_time;
|
||||||
|
return `${diff > 0 ? '+' : ''}${formatQueryTime(Math.abs(diff))}`;
|
||||||
|
}, color: 'text-[var(--dg-text-secondary)]' },
|
||||||
|
{ label: '返回行数差', getValue: () => {
|
||||||
|
const diff = compareLogs[0].rows_sent - compareLogs[1].rows_sent;
|
||||||
|
return `${diff > 0 ? '+' : ''}${Math.abs(diff).toLocaleString()}`;
|
||||||
|
}, color: 'text-[var(--dg-text-secondary)]' },
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.label}>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">{item.label}</div>
|
||||||
|
<div className={`text-sm font-semibold ${item.color}`}>{item.getValue()}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ChevronRight, Check, AlertTriangle, Shield } from 'lucide-react';
|
import {
|
||||||
|
ChevronRight, Check, AlertTriangle, Shield, Database,
|
||||||
|
FileText, Sparkles, RotateCcw, Loader2, Info,
|
||||||
|
} from 'lucide-react';
|
||||||
import { ticketApi } from '@/api/ticket';
|
import { ticketApi } from '@/api/ticket';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
|
import SqlEditor, { formatSQL } from '@/components/SqlEditor';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
interface InstanceItem {
|
interface InstanceItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -25,283 +33,741 @@ interface AuditResult {
|
|||||||
findings: AuditFinding[];
|
findings: AuditFinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
var levelColor: Record<string, string> = {
|
interface StepConfig {
|
||||||
|
num: number;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const STEPS: StepConfig[] = [
|
||||||
|
{ num: 1, label: '填写信息', icon: FileText },
|
||||||
|
{ num: 2, label: 'SQL 审核', icon: Shield },
|
||||||
|
{ num: 3, label: '确认提交', icon: Check },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEVEL_COLOR: Record<string, string> = {
|
||||||
error: 'text-red-400 bg-red-500/15',
|
error: 'text-red-400 bg-red-500/15',
|
||||||
warning: 'text-yellow-400 bg-yellow-500/15',
|
warning: 'text-amber-400 bg-amber-500/15',
|
||||||
info: 'text-blue-400 bg-blue-500/15',
|
info: 'text-blue-400 bg-blue-500/15',
|
||||||
};
|
};
|
||||||
|
|
||||||
var levelLabel: Record<string, string> = {
|
const LEVEL_LABEL: Record<string, string> = {
|
||||||
error: '错误',
|
error: '错误',
|
||||||
warning: '警告',
|
warning: '警告',
|
||||||
info: '提示',
|
info: '提示',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TicketCreatePage() {
|
const RISK_LABEL: Record<string, string> = {
|
||||||
var navigate = useNavigate();
|
low: '低风险',
|
||||||
var [step, setStep] = useState(1);
|
medium: '中风险',
|
||||||
var [title, setTitle] = useState('');
|
high: '高风险',
|
||||||
var [priority, setPriority] = useState('medium');
|
};
|
||||||
var [instanceId, setInstanceId] = useState('');
|
|
||||||
var [database, setDatabase] = useState('');
|
|
||||||
var [sqlContent, setSqlContent] = useState('');
|
|
||||||
var [reason, setReason] = useState('');
|
|
||||||
var [instances, setInstances] = useState<InstanceItem[]>([]);
|
|
||||||
var [auditResult, setAuditResult] = useState<AuditResult | null>(null);
|
|
||||||
var [auditing, setAuditing] = useState(false);
|
|
||||||
var [submitting, setSubmitting] = useState(false);
|
|
||||||
var [error, setError] = useState('');
|
|
||||||
|
|
||||||
useEffect(function () {
|
const RISK_COLOR: Record<string, string> = {
|
||||||
instanceApi.list().then(function (res) {
|
low: 'text-green-400 bg-green-500/15 border-green-500/30',
|
||||||
var data = (res as any)?.items || res;
|
medium: 'text-amber-400 bg-amber-500/15 border-amber-500/30',
|
||||||
setInstances(Array.isArray(data) ? data : []);
|
high: 'text-red-400 bg-red-500/15 border-red-500/30',
|
||||||
}).catch(function () {
|
};
|
||||||
setInstances([]);
|
|
||||||
});
|
const PRIORITY_OPTIONS = [
|
||||||
|
{ value: 'low', label: '低' },
|
||||||
|
{ value: 'medium', label: '中' },
|
||||||
|
{ value: 'high', label: '高' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helper: generate rollback SQL suggestion */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function generateRollbackSuggestion(sql: string): string {
|
||||||
|
const upper = sql.toUpperCase().trim();
|
||||||
|
|
||||||
|
// UPDATE -> generate reverse UPDATE
|
||||||
|
if (upper.startsWith('UPDATE')) {
|
||||||
|
const tableMatch = sql.match(/UPDATE\s+(\w+)/i);
|
||||||
|
const table = tableMatch ? tableMatch[1] : 'table';
|
||||||
|
const whereMatch = sql.match(/WHERE\s+(.+)/i);
|
||||||
|
const where = whereMatch ? whereMatch[1].trim() : '';
|
||||||
|
return `-- 建议回滚语句(请根据实际业务调整):\n-- UPDATE ${table} SET ... WHERE ${where || '...'};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE -> generate INSERT from backup
|
||||||
|
if (upper.startsWith('DELETE')) {
|
||||||
|
const tableMatch = sql.match(/FROM\s+(\w+)/i) || sql.match(/DELETE\s+(\w+)/i);
|
||||||
|
const table = tableMatch ? tableMatch[1] : 'table';
|
||||||
|
return `-- 建议回滚语句:\n-- INSERT INTO ${table} SELECT * FROM ${table}_backup WHERE ...;\n-- 或从 Binlog 恢复`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALTER -> generate reverse ALTER
|
||||||
|
if (upper.startsWith('ALTER')) {
|
||||||
|
return `-- DDL 回滚说明:\n-- 1. ALTER TABLE 操作无法直接回滚\n-- 2. 建议先使用 pt-online-schema-change 或 gh-ost 进行在线变更\n-- 3. 执行前请确保有完整备份`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// INSERT -> generate DELETE
|
||||||
|
if (upper.startsWith('INSERT')) {
|
||||||
|
const tableMatch = sql.match(/INTO\s+(\w+)/i);
|
||||||
|
const table = tableMatch ? tableMatch[1] : 'table';
|
||||||
|
return `-- 建议回滚语句:\n-- DELETE FROM ${table} WHERE ...;\n-- 请根据 INSERT 条件编写对应的 DELETE 语句`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `-- 请根据业务逻辑编写回滚语句`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function TicketCreatePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// -- Step state --
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
|
||||||
|
// -- Form fields --
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [priority, setPriority] = useState('medium');
|
||||||
|
const [instanceId, setInstanceId] = useState('');
|
||||||
|
const [database, setDatabase] = useState('');
|
||||||
|
const [sqlContent, setSqlContent] = useState('');
|
||||||
|
const [rollbackSql, setRollbackSql] = useState('');
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
// -- Data / Loading --
|
||||||
|
const [instances, setInstances] = useState<InstanceItem[]>([]);
|
||||||
|
const [databases, setDatabases] = useState<string[]>([]);
|
||||||
|
const [loadingInstances, setLoadingInstances] = useState(false);
|
||||||
|
const [loadingDatabases, setLoadingDatabases] = useState(false);
|
||||||
|
const [auditing, setAuditing] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// -- Audit --
|
||||||
|
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Fetch instances on mount */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingInstances(true);
|
||||||
|
instanceApi.list()
|
||||||
|
.then((res: unknown) => {
|
||||||
|
const data = (res as { items?: unknown[] })?.items ?? res;
|
||||||
|
setInstances(Array.isArray(data) ? data as InstanceItem[] : []);
|
||||||
|
})
|
||||||
|
.catch(() => setInstances([]))
|
||||||
|
.finally(() => setLoadingInstances(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
var handleAudit = async function () {
|
/* ---------------------------------------------------------------- */
|
||||||
if (!sqlContent || !instanceId) {
|
/* Fetch databases when instance changes */
|
||||||
setError('请填写SQL和选择实例');
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) {
|
||||||
|
setDatabases([]);
|
||||||
|
setDatabase('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingDatabases(true);
|
||||||
|
instanceApi.getDatabases?.(Number(instanceId))
|
||||||
|
.then((res: unknown) => {
|
||||||
|
const data = (res as { databases?: string[] })?.databases
|
||||||
|
|| (res as string[])
|
||||||
|
|| [];
|
||||||
|
setDatabases(Array.isArray(data) ? data as string[] : []);
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
setDatabase(String(data[0]));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback: no auto database list
|
||||||
|
setDatabases([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingDatabases(false));
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* SQL change callback */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const handleSqlChange = useCallback((val: string) => {
|
||||||
|
setSqlContent(val);
|
||||||
|
// Auto-generate rollback suggestion when SQL changes
|
||||||
|
if (val.trim().length > 10) {
|
||||||
|
setRollbackSql(generateRollbackSuggestion(val));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Format SQL */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const handleFormatSQL = useCallback(() => {
|
||||||
|
if (!sqlContent.trim()) return;
|
||||||
|
const formatted = formatSQL(sqlContent);
|
||||||
|
setSqlContent(formatted);
|
||||||
|
}, [sqlContent]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Audit handler */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const handleAudit = useCallback(async () => {
|
||||||
|
if (!sqlContent.trim() || !instanceId) {
|
||||||
|
setError('请填写 SQL 语句并选择实例');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAuditing(true);
|
setAuditing(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
var result = await ticketApi.audit({
|
const result = await ticketApi.audit({
|
||||||
sql_content: sqlContent,
|
sql_content: sqlContent,
|
||||||
instance_id: Number(instanceId),
|
instance_id: Number(instanceId),
|
||||||
database: database,
|
database,
|
||||||
}) as unknown as AuditResult;
|
}) as unknown as AuditResult;
|
||||||
setAuditResult(result);
|
setAuditResult(result);
|
||||||
setStep(2);
|
setStep(2);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setError(e?.response?.data?.detail || '审核失败');
|
const err = e as { response?: { data?: { detail?: string } } };
|
||||||
|
setError(err?.response?.data?.detail || '审核失败,请检查 SQL 语法');
|
||||||
} finally {
|
} finally {
|
||||||
setAuditing(false);
|
setAuditing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [sqlContent, instanceId, database]);
|
||||||
|
|
||||||
var handleSubmit = async function () {
|
/* ---------------------------------------------------------------- */
|
||||||
if (!title || !sqlContent || !instanceId || !database || !reason) {
|
/* Pre-submit audit check */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const handlePreSubmitAudit = useCallback(async () => {
|
||||||
|
if (!title.trim() || !sqlContent.trim() || !instanceId || !database || !reason.trim()) {
|
||||||
|
setError('请填写所有必填项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If already audited and SQL hasn't changed, go directly to step 3
|
||||||
|
if (auditResult) {
|
||||||
|
setStep(3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Auto-audit before submission
|
||||||
|
setAuditing(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await ticketApi.audit({
|
||||||
|
sql_content: sqlContent,
|
||||||
|
instance_id: Number(instanceId),
|
||||||
|
database,
|
||||||
|
}) as unknown as AuditResult;
|
||||||
|
setAuditResult(result);
|
||||||
|
setStep(3);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { detail?: string } } };
|
||||||
|
setError(err?.response?.data?.detail || '预审核失败,请检查 SQL 语法');
|
||||||
|
} finally {
|
||||||
|
setAuditing(false);
|
||||||
|
}
|
||||||
|
}, [title, sqlContent, instanceId, database, reason, auditResult]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Submit handler */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!title.trim() || !sqlContent.trim() || !instanceId || !database || !reason.trim()) {
|
||||||
setError('请填写所有必填项');
|
setError('请填写所有必填项');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
var inst = instances.find(function (i) { return i.id === Number(instanceId); });
|
const inst = instances.find((i) => i.id === Number(instanceId));
|
||||||
await ticketApi.create({
|
await ticketApi.create({
|
||||||
title: title,
|
title: title.trim(),
|
||||||
db_type: inst?.db_type || 'mysql',
|
db_type: inst?.db_type || 'mysql',
|
||||||
instance_id: Number(instanceId),
|
instance_id: Number(instanceId),
|
||||||
target_database: database,
|
target_database: database,
|
||||||
sql_content: sqlContent,
|
sql_content: sqlContent,
|
||||||
reason: reason,
|
rollback_sql: rollbackSql || undefined,
|
||||||
|
reason: reason.trim(),
|
||||||
priority: priority,
|
priority: priority,
|
||||||
});
|
});
|
||||||
navigate('/tickets');
|
navigate('/tickets');
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setError(e?.response?.data?.detail || '提交失败');
|
const err = e as { response?: { data?: { detail?: string } } };
|
||||||
|
setError(err?.response?.data?.detail || '提交失败,请稍后重试');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, navigate]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Derived values */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const selectedInstance = useMemo(
|
||||||
|
() => instances.find((i) => i.id === Number(instanceId)),
|
||||||
|
[instances, instanceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const riskLevel = useMemo(() => {
|
||||||
|
if (!auditResult) return '';
|
||||||
|
if (auditResult.score >= 90) return 'low';
|
||||||
|
if (auditResult.score >= 60) return 'medium';
|
||||||
|
return 'high';
|
||||||
|
}, [auditResult]);
|
||||||
|
|
||||||
|
const isStep1Valid = title.trim() && sqlContent.trim() && instanceId && database && reason.trim();
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Render helpers */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const renderStepIndicator = () => (
|
||||||
|
<div className="flex items-center gap-2 p-4 bg-[var(--dg-bg-secondary)] rounded-lg">
|
||||||
|
{STEPS.map((s, idx) => {
|
||||||
|
const isActive = step === s.num;
|
||||||
|
const isDone = step > s.num;
|
||||||
|
const StepIcon = s.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={s.num} className="flex items-center flex-1">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 flex-1 px-4 py-2.5 rounded-lg transition-all duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30 shadow-sm'
|
||||||
|
: isDone
|
||||||
|
? 'bg-green-500/10 text-green-400'
|
||||||
|
: 'text-[var(--dg-text-muted)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDone ? (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<StepIcon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
{idx < STEPS.length - 1 && (
|
||||||
|
<ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] mx-1 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderError = () =>
|
||||||
|
error ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-3 rounded-lg border border-red-500/20">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
/* ========== Step 1: Fill info ========== */
|
||||||
|
const renderStep1 = () => (
|
||||||
|
<div className="space-y-5 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
工单标题 <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||||
|
placeholder="请输入工单标题,如:用户表添加索引"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority + Instance */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">优先级</label>
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
{PRIORITY_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
选择实例 <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={instanceId}
|
||||||
|
onChange={(e) => setInstanceId(e.target.value)}
|
||||||
|
disabled={loadingInstances}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{loadingInstances ? '加载中...' : '请选择实例'}
|
||||||
|
</option>
|
||||||
|
{instances.map((inst) => (
|
||||||
|
<option key={inst.id} value={String(inst.id)}>
|
||||||
|
{inst.name} ({inst.host}:{inst.port}) [{inst.db_type.toUpperCase()}]
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Database */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
数据库 <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
{databases.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={database}
|
||||||
|
onChange={(e) => setDatabase(e.target.value)}
|
||||||
|
disabled={loadingDatabases}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loadingDatabases ? (
|
||||||
|
<option>加载中...</option>
|
||||||
|
) : (
|
||||||
|
databases.map((db) => (
|
||||||
|
<option key={db} value={db}>{db}</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={database}
|
||||||
|
onChange={(e) => setDatabase(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||||
|
placeholder="目标数据库名"
|
||||||
|
/>
|
||||||
|
{selectedInstance && (
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
|
||||||
|
{selectedInstance.db_type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SQL Editor */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="block text-sm font-medium">
|
||||||
|
SQL 语句 <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleFormatSQL}
|
||||||
|
disabled={!sqlContent.trim()}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-blue-400 bg-blue-500/10 rounded-md hover:bg-blue-500/20 transition-colors disabled:opacity-40"
|
||||||
|
title="格式化 SQL (Ctrl+Shift+F)"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
格式化
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||||
|
{sqlContent.trim() ? `${sqlContent.trim().split(/;\s*/).filter(Boolean).length} 条语句` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SqlEditor
|
||||||
|
value={sqlContent}
|
||||||
|
onChange={handleSqlChange}
|
||||||
|
language="sql"
|
||||||
|
height="220px"
|
||||||
|
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] mt-1.5 flex items-center gap-1">
|
||||||
|
<Info className="w-3 h-3" />
|
||||||
|
支持 Ctrl+Shift+F 格式化代码
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rollback SQL */}
|
||||||
|
{rollbackSql && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<RotateCcw className="w-4 h-4 text-purple-400" />
|
||||||
|
<label className="text-sm font-medium">回滚 SQL(自动建议)</label>
|
||||||
|
</div>
|
||||||
|
<SqlEditor
|
||||||
|
value={rollbackSql}
|
||||||
|
onChange={setRollbackSql}
|
||||||
|
language="sql"
|
||||||
|
height="100px"
|
||||||
|
placeholder="请输入回滚 SQL 语句..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reason */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
变更原因 <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y focus:outline-none focus:border-blue-500 transition-colors"
|
||||||
|
placeholder="请描述变更原因、影响范围、回滚方案等"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleAudit}
|
||||||
|
disabled={auditing || !isStep1Valid}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{auditing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
审核中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
审核并继续
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ========== Step 2: Audit result ========== */
|
||||||
|
const renderStep2 = () => {
|
||||||
|
if (!auditResult) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||||
|
{/* Header with score */}
|
||||||
|
<div className="flex items-center justify-between mb-4 pb-4 border-b border-[var(--dg-border)]">
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-blue-400" />
|
||||||
|
SQL 审核结果
|
||||||
|
</h3>
|
||||||
|
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border ${RISK_COLOR[riskLevel] || ''}`}>
|
||||||
|
<span className="text-2xl font-bold">{auditResult.score}</span>
|
||||||
|
<span>分 · {RISK_LABEL[riskLevel] || ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Findings */}
|
||||||
|
{auditResult.findings.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-[var(--dg-text-secondary)]">
|
||||||
|
<Check className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||||
|
<p className="text-lg font-medium">审核通过,未发现风险</p>
|
||||||
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">SQL 语句符合规范要求</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{auditResult.findings.map((f, idx) => (
|
||||||
|
<div key={idx} className={`flex items-start gap-3 p-3 rounded-lg ${LEVEL_COLOR[f.level] || ''}`}>
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<span>{LEVEL_LABEL[f.level] || f.level}</span>
|
||||||
|
<span className="opacity-60">·</span>
|
||||||
|
<span className="font-mono text-xs opacity-80">{f.rule}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm opacity-80 mt-0.5">{f.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-[var(--dg-border)] grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">错误</div>
|
||||||
|
<div className="text-lg font-semibold text-red-400 mt-0.5">
|
||||||
|
{auditResult.findings.filter((f) => f.level === 'error').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">警告</div>
|
||||||
|
<div className="text-lg font-semibold text-amber-400 mt-0.5">
|
||||||
|
{auditResult.findings.filter((f) => f.level === 'warning').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">提示</div>
|
||||||
|
<div className="text-lg font-semibold text-blue-400 mt-0.5">
|
||||||
|
{auditResult.findings.filter((f) => f.level === 'info').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(3)}
|
||||||
|
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all"
|
||||||
|
>
|
||||||
|
下一步:确认提交
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
var riskLevel = auditResult
|
/* ========== Step 3: Confirm ========== */
|
||||||
? auditResult.score >= 90 ? 'low'
|
const renderStep3 = () => {
|
||||||
: auditResult.score >= 60 ? 'medium' : 'high'
|
const inst = instances.find((i) => i.id === Number(instanceId));
|
||||||
: '';
|
|
||||||
|
|
||||||
var riskLabel: Record<string, string> = { low: '低风险', medium: '中风险', high: '高风险' };
|
return (
|
||||||
var riskColor: Record<string, string> = {
|
<div className="space-y-4">
|
||||||
low: 'text-green-400 bg-green-500/15 border-green-500/30',
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-4">
|
||||||
medium: 'text-yellow-400 bg-yellow-500/15 border-yellow-500/30',
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
high: 'text-red-400 bg-red-500/15 border-red-500/30',
|
<Check className="w-5 h-5 text-green-400" />
|
||||||
|
确认工单信息
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Info grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||||
|
<span className="text-[var(--dg-text-muted)] text-xs">标题</span>
|
||||||
|
<p className="font-medium mt-1">{title}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||||
|
<span className="text-[var(--dg-text-muted)] text-xs">优先级</span>
|
||||||
|
<p className="font-medium mt-1">
|
||||||
|
{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||||
|
<span className="text-[var(--dg-text-muted)] text-xs">实例</span>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
|
<Database className="w-3.5 h-3.5 text-[var(--dg-text-secondary)]" />
|
||||||
|
<span className="font-medium">{inst?.name || instanceId}</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||||
|
({inst?.host}:{inst?.port})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||||
|
<span className="text-[var(--dg-text-muted)] text-xs">数据库</span>
|
||||||
|
<p className="font-medium mt-1">{database}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SQL Preview */}
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">SQL 语句</span>
|
||||||
|
<pre className="mt-2 text-xs font-mono overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto">
|
||||||
|
{sqlContent}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rollback preview */}
|
||||||
|
{rollbackSql && (
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-purple-500/20">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">回滚 SQL</span>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs font-mono overflow-x-auto whitespace-pre-wrap text-purple-300/80">
|
||||||
|
{rollbackSql}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reason */}
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">变更原因</span>
|
||||||
|
<p className="mt-1 text-sm">{reason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audit badge */}
|
||||||
|
{auditResult && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Shield className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[var(--dg-text-muted)]">审核评分:</span>
|
||||||
|
<span className={`font-medium ${RISK_COLOR[riskLevel]?.split(' ')[0] || ''}`}>
|
||||||
|
{auditResult.score} 分 · {RISK_LABEL[riskLevel]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
提交中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>确认提交</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Main render */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 max-w-4xl mx-auto">
|
<div className="space-y-5 max-w-4xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button onClick={function () { navigate('/tickets'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
<button
|
||||||
|
onClick={() => navigate('/tickets')}
|
||||||
|
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all"
|
||||||
|
aria-label="返回工单列表"
|
||||||
|
>
|
||||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-2xl font-semibold">新建数据变更工单</h1>
|
<h1 className="text-2xl font-semibold">新建数据变更工单</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Steps indicator */}
|
{/* Steps */}
|
||||||
<div className="flex items-center gap-2 p-4 bg-[var(--dg-bg-secondary)] rounded-lg">
|
{renderStepIndicator()}
|
||||||
{[
|
|
||||||
{ num: 1, label: '填写信息' },
|
|
||||||
{ num: 2, label: 'SQL审核' },
|
|
||||||
{ num: 3, label: '确认提交' },
|
|
||||||
].map(function (s, idx) {
|
|
||||||
var isActive = step === s.num;
|
|
||||||
var isDone = step > s.num;
|
|
||||||
return (
|
|
||||||
<div key={s.num} className="flex items-center flex-1">
|
|
||||||
<div className={'flex items-center gap-2 flex-1 px-4 py-2.5 rounded-lg transition-colors ' + (isActive ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : isDone ? 'bg-green-500/10 text-green-400' : 'text-[var(--dg-text-muted)]')}>
|
|
||||||
{isDone ? <Check className="w-4 h-4" /> : <span className="w-5 h-5 rounded-full border text-xs flex items-center justify-center">{s.num}</span>}
|
|
||||||
<span className="text-sm font-medium">{s.label}</span>
|
|
||||||
</div>
|
|
||||||
{idx < 2 && <ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] mx-1 shrink-0" />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="text-sm text-red-400 bg-red-500/10 px-4 py-2 rounded-lg">{error}</div>}
|
{/* Error */}
|
||||||
|
{renderError()}
|
||||||
|
|
||||||
{/* Step 1: Fill info */}
|
{/* Step content */}
|
||||||
{step === 1 && (
|
{step === 1 && renderStep1()}
|
||||||
<div className="space-y-4 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
{step === 2 && renderStep2()}
|
||||||
<div>
|
{step === 3 && renderStep3()}
|
||||||
<label className="block text-sm font-medium mb-2">工单标题 <span className="text-red-400">*</span></label>
|
|
||||||
<input value={title} onChange={function (e) { setTitle(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="请输入工单标题" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">优先级</label>
|
|
||||||
<select value={priority} onChange={function (e) { setPriority(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
|
||||||
<option value="low">低</option>
|
|
||||||
<option value="medium">中</option>
|
|
||||||
<option value="high">高</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">选择实例 <span className="text-red-400">*</span></label>
|
|
||||||
<select value={instanceId} onChange={function (e) { setInstanceId(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
|
||||||
<option value="">请选择实例</option>
|
|
||||||
{instances.map(function (inst) {
|
|
||||||
return <option key={inst.id} value={String(inst.id)}>{inst.name} ({inst.host}:{inst.port})</option>;
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">数据库 <span className="text-red-400">*</span></label>
|
|
||||||
<input value={database} onChange={function (e) { setDatabase(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="目标数据库名" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">SQL语句 <span className="text-red-400">*</span></label>
|
|
||||||
<textarea value={sqlContent} onChange={function (e) { setSqlContent(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono min-h-[180px] resize-y" placeholder="请输入SQL语句" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">变更原因 <span className="text-red-400">*</span></label>
|
|
||||||
<textarea value={reason} onChange={function (e) { setReason(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y" placeholder="请描述变更原因" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
|
||||||
<button onClick={handleAudit} disabled={auditing} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
|
|
||||||
{auditing ? '审核中...' : '开始审核'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2: Audit result */}
|
|
||||||
{step === 2 && auditResult && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
|
||||||
<Shield className="w-5 h-5 text-blue-400" />
|
|
||||||
SQL审核结果
|
|
||||||
</h3>
|
|
||||||
<div className={'flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border ' + riskColor[riskLevel]}>
|
|
||||||
<span className="text-2xl font-bold">{auditResult.score}</span>
|
|
||||||
<span>分 · {riskLabel[riskLevel]}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{auditResult.findings.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-[var(--dg-text-secondary)]">
|
|
||||||
<Check className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
|
||||||
<p className="text-lg font-medium">审核通过,未发现风险</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{auditResult.findings.map(function (f, idx) {
|
|
||||||
return (
|
|
||||||
<div key={idx} className={'flex items-start gap-3 p-3 rounded-lg ' + (levelColor[f.level] || '')}>
|
|
||||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<span>{levelLabel[f.level] || f.level}</span>
|
|
||||||
<span className="opacity-60">·</span>
|
|
||||||
<span className="font-mono text-xs">{f.rule}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm opacity-80 mt-0.5">{f.message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<button onClick={function () { setStep(1); }} className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
|
||||||
上一步
|
|
||||||
</button>
|
|
||||||
<button onClick={function () { setStep(3); }} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
|
|
||||||
下一步:确认提交
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3: Confirm and submit */}
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold">确认工单信息</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-[var(--dg-text-muted)]">标题</span>
|
|
||||||
<p className="font-medium mt-1">{title}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[var(--dg-text-muted)]">优先级</span>
|
|
||||||
<p className="font-medium mt-1">{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[var(--dg-text-muted)]">实例</span>
|
|
||||||
<p className="font-medium mt-1">{instances.find(function (i) { return i.id === Number(instanceId); })?.name || instanceId}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[var(--dg-text-muted)]">数据库</span>
|
|
||||||
<p className="font-medium mt-1">{database}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-[var(--dg-text-muted)]">SQL语句</span>
|
|
||||||
<pre className="mt-1 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{sqlContent}</pre>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-[var(--dg-text-muted)]">变更原因</span>
|
|
||||||
<p className="mt-1 text-sm">{reason}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<button onClick={function () { setStep(2); }} className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
|
||||||
上一步
|
|
||||||
</button>
|
|
||||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
|
|
||||||
{submitting ? '提交中...' : '确认提交'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ChevronRight, FileCode, Clock, CheckCircle, XCircle,
|
ChevronRight, FileCode, Clock, CheckCircle, XCircle,
|
||||||
Play, RotateCcw, X, Shield,
|
Play, RotateCcw, X, Shield, User, Calendar,
|
||||||
|
MessageSquare, Loader2, AlertTriangle, Database,
|
||||||
|
BarChart3, ChevronDown, ChevronUp, Link2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ticketApi } from '@/api/ticket';
|
import { ticketApi } from '@/api/ticket';
|
||||||
import { statusMap, statusColorMap } from '@/lib/mock';
|
import { statusMap, statusColorMap } from '@/lib/mock';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
interface TicketData {
|
interface TicketData {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
db_type: string;
|
db_type: string;
|
||||||
instance_id: number;
|
instance_id: number;
|
||||||
|
instance_name?: string;
|
||||||
target_database: string;
|
target_database: string;
|
||||||
sql_content: string;
|
sql_content: string;
|
||||||
rollback_sql: string | null;
|
rollback_sql: string | null;
|
||||||
@@ -25,6 +33,8 @@ interface TicketData {
|
|||||||
reason: string | null;
|
reason: string | null;
|
||||||
audit_result: string | null;
|
audit_result: string | null;
|
||||||
execute_result: string | null;
|
execute_result: string | null;
|
||||||
|
affected_rows?: number;
|
||||||
|
execute_duration_ms?: number;
|
||||||
create_time: string;
|
create_time: string;
|
||||||
review_time: string | null;
|
review_time: string | null;
|
||||||
execute_time: string | null;
|
execute_time: string | null;
|
||||||
@@ -38,252 +48,641 @@ interface TimelineEntry {
|
|||||||
detail: string | null;
|
detail: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var actionIcon: Record<string, React.ReactNode> = {
|
interface RelatedTicket {
|
||||||
CREATE_TICKET: <CheckCircle className="w-4 h-4 text-blue-400" />,
|
id: number;
|
||||||
REVIEW_TICKET: <Shield className="w-4 h-4 text-green-400" />,
|
title: string;
|
||||||
EXECUTE_TICKET: <Play className="w-4 h-4 text-purple-400" />,
|
status: string;
|
||||||
CANCEL_TICKET: <XCircle className="w-4 h-4 text-red-400" />,
|
applicant_name: string | null;
|
||||||
ROLLBACK_TICKET: <RotateCcw className="w-4 h-4 text-orange-400" />,
|
create_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const ACTION_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||||
|
CREATE_TICKET: { icon: FileCode, color: 'text-blue-400', bg: 'bg-blue-500/15' },
|
||||||
|
REVIEW_TICKET: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
|
||||||
|
EXECUTE_TICKET: { icon: Play, color: 'text-purple-400', bg: 'bg-purple-500/15' },
|
||||||
|
CANCEL_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||||
|
ROLLBACK_TICKET: { icon: RotateCcw, color: 'text-orange-400', bg: 'bg-orange-500/15' },
|
||||||
|
REJECT_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PRIORITY_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
high: { label: '高', color: 'text-red-400 bg-red-500/15' },
|
||||||
|
medium: { label: '中', color: 'text-amber-400 bg-amber-500/15' },
|
||||||
|
low: { label: '低', color: 'text-green-400 bg-green-500/15' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export default function TicketDetailPage() {
|
export default function TicketDetailPage() {
|
||||||
var params = useParams();
|
const params = useParams();
|
||||||
var navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
var auth = useAuth();
|
const auth = useAuth();
|
||||||
var ticketId = Number(params.id);
|
const ticketId = Number(params.id);
|
||||||
|
|
||||||
var [ticket, setTicket] = useState<TicketData | null>(null);
|
// -- Data --
|
||||||
var [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
const [ticket, setTicket] = useState<TicketData | null>(null);
|
||||||
var [loading, setLoading] = useState(true);
|
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
||||||
var [actionLoading, setActionLoading] = useState(false);
|
const [relatedTickets, setRelatedTickets] = useState<RelatedTicket[]>([]);
|
||||||
var [comment, setComment] = useState('');
|
const [loading, setLoading] = useState(true);
|
||||||
var [showRejectInput, setShowRejectInput] = useState(false);
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
var fetchTicket = async function () {
|
// -- Action states --
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [showCommentInput, setShowCommentInput] = useState(false);
|
||||||
|
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||||
|
const [showRollbackConfirm, setShowRollbackConfirm] = useState(false);
|
||||||
|
|
||||||
|
// -- UI expansion --
|
||||||
|
const [showFullAudit, setShowFullAudit] = useState(false);
|
||||||
|
const [showRelated, setShowRelated] = useState(true);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Fetch ticket data */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const fetchTicket = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
var data = await ticketApi.get(ticketId) as unknown as TicketData;
|
const data = await ticketApi.get(ticketId) as unknown as TicketData;
|
||||||
setTicket(data);
|
setTicket(data);
|
||||||
|
setError('');
|
||||||
} catch {
|
} catch {
|
||||||
|
setError('工单不存在或加载失败');
|
||||||
setTicket(null);
|
setTicket(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [ticketId]);
|
||||||
|
|
||||||
var fetchTimeline = async function () {
|
const fetchTimeline = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
var data = await ticketApi.timeline(ticketId) as any;
|
const res = await ticketApi.timeline(ticketId) as { timeline?: TimelineEntry[] };
|
||||||
setTimeline(data.timeline || []);
|
setTimeline(res?.timeline || []);
|
||||||
} catch {
|
} catch {
|
||||||
setTimeline([]);
|
setTimeline([]);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(function () {
|
|
||||||
fetchTicket();
|
|
||||||
fetchTimeline();
|
|
||||||
}, [ticketId]);
|
}, [ticketId]);
|
||||||
|
|
||||||
var handleAction = async function (action: string) {
|
// Mock related tickets fetch
|
||||||
setActionLoading(true);
|
const fetchRelated = useCallback(async () => {
|
||||||
|
// In real implementation: await ticketApi.related(ticketId)
|
||||||
|
setRelatedTickets([
|
||||||
|
{ id: ticketId - 1, title: '用户表索引优化', status: 'success', applicant_name: '张三', create_time: '2024-01-14 10:00:00' },
|
||||||
|
{ id: ticketId + 1, title: '订单数据归档', status: 'approved', applicant_name: '李四', create_time: '2024-01-15 14:00:00' },
|
||||||
|
]);
|
||||||
|
}, [ticketId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNaN(ticketId)) {
|
||||||
|
setLoading(true);
|
||||||
|
fetchTicket();
|
||||||
|
fetchTimeline();
|
||||||
|
fetchRelated();
|
||||||
|
}
|
||||||
|
}, [ticketId, fetchTicket, fetchTimeline, fetchRelated]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Action handler */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const handleAction = useCallback(async (action: string) => {
|
||||||
|
if (!ticket) return;
|
||||||
|
|
||||||
|
// Reset comment state
|
||||||
|
const actionComment = comment.trim() || (action === 'reject' ? '审批拒绝' : action === 'approve' ? '审批通过' : '');
|
||||||
|
|
||||||
|
setActionLoading(action);
|
||||||
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (action === 'approve') {
|
switch (action) {
|
||||||
await ticketApi.review(ticketId, { action: 'approve', comment: '审批通过' });
|
case 'approve':
|
||||||
} else if (action === 'reject') {
|
await ticketApi.review(ticketId, { action: 'approve', comment: actionComment });
|
||||||
await ticketApi.review(ticketId, { action: 'reject', comment: comment || '拒绝' });
|
break;
|
||||||
setShowRejectInput(false);
|
case 'reject':
|
||||||
setComment('');
|
await ticketApi.review(ticketId, { action: 'reject', comment: actionComment });
|
||||||
} else if (action === 'execute') {
|
setShowCommentInput(false);
|
||||||
await ticketApi.execute(ticketId);
|
setComment('');
|
||||||
} else if (action === 'rollback') {
|
break;
|
||||||
await ticketApi.rollback(ticketId);
|
case 'execute':
|
||||||
} else if (action === 'cancel') {
|
await ticketApi.execute(ticketId);
|
||||||
await ticketApi.cancel(ticketId);
|
break;
|
||||||
|
case 'rollback':
|
||||||
|
await ticketApi.rollback(ticketId);
|
||||||
|
setShowRollbackConfirm(false);
|
||||||
|
break;
|
||||||
|
case 'cancel':
|
||||||
|
await ticketApi.cancel(ticketId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
fetchTicket();
|
fetchTicket();
|
||||||
fetchTimeline();
|
fetchTimeline();
|
||||||
} catch {
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { detail?: string } } };
|
||||||
|
setError(err?.response?.data?.detail || `操作失败: ${action}`);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(null);
|
||||||
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
};
|
}, [ticket, ticketId, comment, fetchTicket, fetchTimeline]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Permission checks */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const { isAdminOrDba, isApplicant, canApprove, canExecute, canRollback, canCancel } = useMemo(() => {
|
||||||
|
if (!ticket) {
|
||||||
|
return { isAdminOrDba: false, isApplicant: false, canApprove: false, canExecute: false, canRollback: false, canCancel: false };
|
||||||
|
}
|
||||||
|
const adminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
|
||||||
|
const applicant = ticket.applicant_id === auth.user.id;
|
||||||
|
return {
|
||||||
|
isAdminOrDba: adminOrDba,
|
||||||
|
isApplicant: applicant,
|
||||||
|
canApprove: adminOrDba && (ticket.status === 'pending' || ticket.status === 'reviewing'),
|
||||||
|
canExecute: adminOrDba && ticket.status === 'approved',
|
||||||
|
canRollback: adminOrDba && (ticket.status === 'success' || ticket.status === 'failed'),
|
||||||
|
canCancel: applicant && (ticket.status === 'pending' || ticket.status === 'reviewing'),
|
||||||
|
};
|
||||||
|
}, [ticket, auth.user.role, auth.user.id]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Parse execute result for visualization */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const executeStats = useMemo(() => {
|
||||||
|
if (!ticket?.execute_result) return null;
|
||||||
|
// Try to extract affected rows and duration from execute result
|
||||||
|
const result = ticket.execute_result;
|
||||||
|
const affectedMatch = result.match(/(\d+)\s*(?:row|rows)?\s*(?:affected|changed)/i);
|
||||||
|
const durationMatch = result.match(/(\d+(?:\.\d+)?)\s*(?:ms|seconds?|s)/i);
|
||||||
|
return {
|
||||||
|
affectedRows: ticket.affected_rows || (affectedMatch ? parseInt(affectedMatch[1], 10) : undefined),
|
||||||
|
durationMs: ticket.execute_duration_ms || (durationMatch ? parseFloat(durationMatch[1]) : undefined),
|
||||||
|
raw: result,
|
||||||
|
};
|
||||||
|
}, [ticket]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Loading & Error states */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]">加载中...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ticket) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
|
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)] gap-3">
|
||||||
<p>工单不存在</p>
|
<Loader2 className="w-8 h-8 animate-spin" />
|
||||||
<button onClick={function () { navigate('/tickets'); }} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm">返回列表</button>
|
<p className="text-sm">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAdminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
|
if (error || !ticket) {
|
||||||
var isApplicant = ticket.applicant_id === auth.user.id;
|
return (
|
||||||
var priorityLabel = ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低';
|
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)] gap-4">
|
||||||
|
<AlertTriangle className="w-12 h-12 text-amber-400" />
|
||||||
|
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{error || '工单不存在'}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/tickets')}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
返回列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityInfo = PRIORITY_MAP[ticket.priority] || PRIORITY_MAP.medium;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Render helpers */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const renderActionButtons = () => {
|
||||||
|
if (showRollbackConfirm) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-orange-300">确认回滚操作</p>
|
||||||
|
<p className="text-xs text-orange-300/70">回滚操作不可逆,将撤销已执行的 SQL 变更</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('rollback')}
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-700 text-white rounded-md text-sm transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === 'rollback' ? <Loader2 className="w-4 h-4 animate-spin" /> : '确认回滚'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRollbackConfirm(false)}
|
||||||
|
className="px-3 py-1.5 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* Approve / Reject */}
|
||||||
|
{canApprove && (
|
||||||
|
<>
|
||||||
|
{!showCommentInput ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => { setPendingAction('approve'); setShowCommentInput(true); }}
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4" /> 审批通过
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setPendingAction('reject'); setShowCommentInput(true); }}
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" /> 审批拒绝
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<MessageSquare className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder={pendingAction === 'reject' ? '请输入拒绝原因...' : '请输入审批备注(可选)...'}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => pendingAction && handleAction(pendingAction)}
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
pendingAction === 'reject'
|
||||||
|
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||||
|
: 'bg-green-600 hover:bg-green-700 text-white'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{actionLoading === pendingAction ? <Loader2 className="w-4 h-4 animate-spin" /> : pendingAction === 'reject' ? '确认拒绝' : '确认通过'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowCommentInput(false); setComment(''); setPendingAction(null); }}
|
||||||
|
className="px-3 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execute */}
|
||||||
|
{canExecute && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('execute')}
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all"
|
||||||
|
>
|
||||||
|
{actionLoading === 'execute' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||||
|
执行
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rollback */}
|
||||||
|
{canRollback && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRollbackConfirm(true)}
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-400 hover:bg-orange-500/25 transition-all"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" /> 回滚
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel */}
|
||||||
|
{canCancel && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('cancel')}
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-gray-500/15 border border-gray-500/30 rounded-lg text-sm font-medium text-gray-400 hover:bg-gray-500/25 transition-all"
|
||||||
|
>
|
||||||
|
{actionLoading === 'cancel' ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
|
||||||
|
取消工单
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTimeline = () => {
|
||||||
|
if (timeline.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||||
|
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-4">
|
||||||
|
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
|
||||||
|
</h3>
|
||||||
|
<div className="relative pl-8">
|
||||||
|
{/* Vertical line */}
|
||||||
|
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
|
||||||
|
|
||||||
|
{timeline.map((entry, idx) => {
|
||||||
|
const config = ACTION_CONFIG[entry.action] || {
|
||||||
|
icon: CheckCircle,
|
||||||
|
color: 'text-[var(--dg-text-muted)]',
|
||||||
|
bg: 'bg-[var(--dg-bg-tertiary)]',
|
||||||
|
};
|
||||||
|
const Icon = config.icon;
|
||||||
|
const isLast = idx === timeline.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className={`relative ${isLast ? '' : 'mb-6'}`}>
|
||||||
|
{/* Icon node */}
|
||||||
|
<div className={`absolute -left-8 top-0.5 w-6 h-6 rounded-full ${config.bg} border-2 border-[var(--dg-border)] flex items-center justify-center z-10`}>
|
||||||
|
<Icon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-2">
|
||||||
|
{/* Label + User */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium">{entry.label}</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
{entry.user}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time */}
|
||||||
|
{entry.time && (
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(entry.time).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail */}
|
||||||
|
{entry.detail && (
|
||||||
|
<div className="text-xs text-[var(--dg-text-secondary)] mt-1.5 p-2.5 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border-light)]">
|
||||||
|
{entry.detail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderExecuteStats = () => {
|
||||||
|
if (!executeStats) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
{executeStats.affectedRows !== undefined && (
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
|
||||||
|
<BarChart3 className="w-3.5 h-3.5" />
|
||||||
|
影响行数
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
|
||||||
|
{executeStats.affectedRows.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{executeStats.durationMs !== undefined && (
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
执行耗时
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
|
||||||
|
{executeStats.durationMs >= 1000
|
||||||
|
? `${(executeStats.durationMs / 1000).toFixed(2)}s`
|
||||||
|
: `${Math.round(executeStats.durationMs)}ms`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRelatedTickets = () => {
|
||||||
|
if (relatedTickets.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRelated(!showRelated)}
|
||||||
|
className="flex items-center justify-between w-full text-left"
|
||||||
|
>
|
||||||
|
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
|
<Link2 className="w-4 h-4 text-blue-400" /> 关联工单
|
||||||
|
</h3>
|
||||||
|
{showRelated ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showRelated && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{relatedTickets.map((rt) => (
|
||||||
|
<button
|
||||||
|
key={rt.id}
|
||||||
|
onClick={() => navigate(`/tickets/${rt.id}`)}
|
||||||
|
className="w-full flex items-center justify-between p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)] hover:border-blue-500/30 hover:bg-blue-500/5 transition-all text-left"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||||
|
TK-{rt.id} · {rt.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||||
|
由 {rt.applicant_name || '-'} 创建于 {new Date(rt.create_time).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColorMap[rt.status] || ''}`}>
|
||||||
|
{statusMap[rt.status] || rt.status}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Main render */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 max-w-5xl">
|
<div className="space-y-5 max-w-5xl">
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-3 rounded-lg border border-red-500/20">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button onClick={function () { navigate('/tickets'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
<button
|
||||||
|
onClick={() => navigate('/tickets')}
|
||||||
|
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all shrink-0"
|
||||||
|
aria-label="返回工单列表"
|
||||||
|
>
|
||||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
<h1 className="text-2xl font-semibold flex items-center gap-3 flex-wrap">
|
||||||
{ticket.title}
|
{ticket.title}
|
||||||
<span className={'px-2.5 py-1 rounded-full text-xs font-medium ' + (statusColorMap[ticket.status] || '')}>
|
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
||||||
{statusMap[ticket.status] || ticket.status}
|
{statusMap[ticket.status] || ticket.status}
|
||||||
</span>
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityInfo.color}`}>
|
||||||
|
{priorityInfo.label}优先级
|
||||||
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
||||||
TK-{ticket.id} · 由 {ticket.applicant_name || '-'} 创建于 {new Date(ticket.create_time).toLocaleString()}
|
TK-{ticket.id} · 由 {ticket.applicant_name || '-'} 创建于 {new Date(ticket.create_time).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
{/* Approve/Reject for admin/dba when pending */}
|
{/* Action buttons */}
|
||||||
{isAdminOrDba && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
{renderActionButtons()}
|
||||||
<>
|
|
||||||
<button onClick={function () { handleAction('approve'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all">
|
|
||||||
<CheckCircle className="w-4 h-4" /> 通过
|
|
||||||
</button>
|
|
||||||
{!showRejectInput ? (
|
|
||||||
<button onClick={function () { setShowRejectInput(true); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all">
|
|
||||||
<XCircle className="w-4 h-4" /> 拒绝
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input value={comment} onChange={function (e) { setComment(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-48" placeholder="拒绝原因..." />
|
|
||||||
<button onClick={function () { handleAction('reject'); }} className="px-3 py-2 bg-red-500/15 border border-red-500/30 rounded-lg text-sm text-red-400">确认拒绝</button>
|
|
||||||
<button onClick={function () { setShowRejectInput(false); }} className="px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm">取消</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{/* Execute for admin/dba when approved */}
|
|
||||||
{isAdminOrDba && ticket.status === 'approved' && (
|
|
||||||
<button onClick={function () { handleAction('execute'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all">
|
|
||||||
<Play className="w-4 h-4" /> 执行
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Rollback for admin when completed/success */}
|
|
||||||
{isAdminOrDba && (ticket.status === 'success' || ticket.status === 'failed') && (
|
|
||||||
<button onClick={function () { handleAction('rollback'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-400 hover:bg-orange-500/25 transition-all">
|
|
||||||
<RotateCcw className="w-4 h-4" /> 回滚
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Cancel for applicant when pending */}
|
|
||||||
{isApplicant && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
|
||||||
<button onClick={function () { handleAction('cancel'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-gray-500/15 border border-gray-500/30 rounded-lg text-sm font-medium text-gray-400 hover:bg-gray-500/25 transition-all">
|
|
||||||
<X className="w-4 h-4" /> 取消工单
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info cards */}
|
{/* Info cards */}
|
||||||
<div className="grid grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
|
{/* Ticket Info */}
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
||||||
<h3 className="text-[15px] font-semibold border-b border-[var(--dg-border)] pb-3">工单信息</h3>
|
<h3 className="text-[15px] font-semibold border-b border-[var(--dg-border)] pb-3">工单信息</h3>
|
||||||
{[
|
<div className="space-y-1">
|
||||||
{ label: '工单号', value: 'TK-' + ticket.id },
|
{[
|
||||||
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
{ label: '工单号', value: `TK-${ticket.id}` },
|
||||||
{ label: '目标数据库', value: ticket.target_database },
|
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
||||||
{ label: '申请人', value: ticket.applicant_name || '-' },
|
{ label: '目标数据库', value: ticket.target_database, icon: Database },
|
||||||
{ label: '审批人', value: ticket.approver_name || '-' },
|
{ label: '申请人', value: ticket.applicant_name || '-' },
|
||||||
{ label: '优先级', value: priorityLabel },
|
{ label: '审批人', value: ticket.approver_name || '-' },
|
||||||
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
{ label: '优先级', value: priorityInfo.label },
|
||||||
{ label: '变更原因', value: ticket.reason || '-' },
|
{
|
||||||
].map(function (item, idx) {
|
label: '创建时间',
|
||||||
return (
|
value: new Date(ticket.create_time).toLocaleString(),
|
||||||
<div key={idx} className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0">
|
},
|
||||||
<span className="text-sm text-[var(--dg-text-secondary)]">{item.label}</span>
|
ticket.review_time && {
|
||||||
<span className="text-sm font-medium">{item.value}</span>
|
label: '审批时间',
|
||||||
</div>
|
value: new Date(ticket.review_time).toLocaleString(),
|
||||||
);
|
},
|
||||||
})}
|
ticket.execute_time && {
|
||||||
|
label: '执行时间',
|
||||||
|
value: new Date(ticket.execute_time).toLocaleString(),
|
||||||
|
},
|
||||||
|
{ label: '变更原因', value: ticket.reason || '-' },
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item, idx) => {
|
||||||
|
if (!item) return null;
|
||||||
|
const ItemIcon = (item as { icon?: React.ElementType }).icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-[var(--dg-text-secondary)] flex items-center gap-1.5">
|
||||||
|
{ItemIcon && <ItemIcon className="w-3.5 h-3.5" />}
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
{/* SQL Content (SqlEditor read-only) */}
|
||||||
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
||||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||||
<FileCode className="w-4 h-4 text-blue-400" /> SQL 内容
|
<FileCode className="w-4 h-4 text-blue-400" /> SQL 内容
|
||||||
</h3>
|
</h3>
|
||||||
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{ticket.sql_content}</pre>
|
<SqlEditor
|
||||||
|
value={ticket.sql_content}
|
||||||
|
onChange={() => { /* read-only */ }}
|
||||||
|
language="sql"
|
||||||
|
height="280px"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audit result */}
|
{/* Audit result */}
|
||||||
{ticket.audit_result && (
|
{ticket.audit_result && (
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
<button
|
||||||
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
|
onClick={() => setShowFullAudit(!showFullAudit)}
|
||||||
</h3>
|
className="flex items-center justify-between w-full text-left"
|
||||||
<div className="mt-3 text-sm">{ticket.audit_result}</div>
|
>
|
||||||
|
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
|
||||||
|
</h3>
|
||||||
|
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||||
|
</button>
|
||||||
|
<div className={`mt-3 text-sm text-[var(--dg-text-secondary)] ${showFullAudit ? '' : 'line-clamp-3'}`}>
|
||||||
|
{ticket.audit_result}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rollback SQL */}
|
{/* Rollback SQL */}
|
||||||
{ticket.rollback_sql && (
|
{ticket.rollback_sql && (
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
||||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||||
<RotateCcw className="w-4 h-4 text-purple-400" /> 回滚 SQL
|
<RotateCcw className="w-4 h-4 text-purple-400" /> 回滚 SQL
|
||||||
</h3>
|
</h3>
|
||||||
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{ticket.rollback_sql}</pre>
|
<SqlEditor
|
||||||
|
value={ticket.rollback_sql}
|
||||||
|
onChange={() => { /* read-only */ }}
|
||||||
|
language="sql"
|
||||||
|
height="120px"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Execute result */}
|
{/* Execute result */}
|
||||||
{ticket.execute_result && (
|
{ticket.execute_result && (
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
|
||||||
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
||||||
</h3>
|
</h3>
|
||||||
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap">{ticket.execute_result}</pre>
|
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
||||||
{ticket.execute_time && <div className="text-xs text-[var(--dg-text-muted)] mt-2">执行时间: {new Date(ticket.execute_time).toLocaleString()}</div>}
|
{ticket.execute_result}
|
||||||
|
</pre>
|
||||||
|
{renderExecuteStats()}
|
||||||
|
{ticket.execute_time && (
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
{timeline.length > 0 && (
|
{renderTimeline()}
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
|
||||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
{/* Related tickets */}
|
||||||
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
|
{renderRelatedTickets()}
|
||||||
</h3>
|
|
||||||
<div className="mt-4 relative pl-8">
|
|
||||||
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
|
|
||||||
{timeline.map(function (entry, idx) {
|
|
||||||
return (
|
|
||||||
<div key={idx} className="relative mb-6 last:mb-0">
|
|
||||||
<div className="absolute -left-8 top-0.5 w-5 h-5 rounded-full bg-[var(--dg-bg-secondary)] border-2 border-[var(--dg-border)] flex items-center justify-center z-10">
|
|
||||||
{actionIcon[entry.action] || <div className="w-2 h-2 rounded-full bg-[var(--dg-text-muted)]" />}
|
|
||||||
</div>
|
|
||||||
<div className="ml-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">{entry.label}</span>
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">by {entry.user}</span>
|
|
||||||
</div>
|
|
||||||
{entry.time && (
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
|
||||||
{new Date(entry.time).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{entry.detail && (
|
|
||||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 p-2 bg-[var(--dg-bg-primary)] rounded-lg">
|
|
||||||
{entry.detail}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+793
-165
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,15 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Plus, Search, Edit2, Trash2, XCircle } from 'lucide-react';
|
import {
|
||||||
|
Plus, Search, Edit2, Trash2, XCircle, Eye, Download, Upload,
|
||||||
|
CheckSquare, Square, X, RotateCcw, KeyRound, ChevronDown,
|
||||||
|
UserCheck, UserX, Filter, MoreHorizontal, Lock,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { roles } from '@/lib/mock';
|
import { roles, users as mockUsers } from '@/lib/mock';
|
||||||
import { userApi } from '@/api/user';
|
import { userApi } from '@/api/user';
|
||||||
|
|
||||||
|
/* ───────── Types ───────── */
|
||||||
|
|
||||||
interface UserItem {
|
interface UserItem {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -12,8 +18,25 @@ interface UserItem {
|
|||||||
role: string;
|
role: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
last_login?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserActivity {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
target: string;
|
||||||
|
created_at: string;
|
||||||
|
status: 'success' | 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Constants ───────── */
|
||||||
|
|
||||||
const roleNameMap: Record<string, string> = {
|
const roleNameMap: Record<string, string> = {
|
||||||
admin: '超级管理员',
|
admin: '超级管理员',
|
||||||
dba: '数据库管理员',
|
dba: '数据库管理员',
|
||||||
@@ -25,25 +48,121 @@ const roleTagStyle: Record<string, string> = {
|
|||||||
admin: 'bg-purple-500/15 text-purple-400',
|
admin: 'bg-purple-500/15 text-purple-400',
|
||||||
dba: 'bg-blue-500/15 text-blue-400',
|
dba: 'bg-blue-500/15 text-blue-400',
|
||||||
developer: 'bg-green-500/15 text-green-400',
|
developer: 'bg-green-500/15 text-green-400',
|
||||||
guest: 'bg-gray-500/15 text-gray-400',
|
guest: 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ value: '', label: '全部角色' },
|
||||||
|
{ value: 'admin', label: '超级管理员' },
|
||||||
|
{ value: 'dba', label: '数据库管理员' },
|
||||||
|
{ value: 'developer', label: '开发人员' },
|
||||||
|
{ value: 'guest', label: '访客' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'active', label: '启用' },
|
||||||
|
{ value: 'disabled', label: '禁用' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BATCH_OPTIONS = [
|
||||||
|
{ key: 'enable', label: '批量启用', icon: UserCheck },
|
||||||
|
{ key: 'disable', label: '批量禁用', icon: UserX },
|
||||||
|
{ key: 'delete', label: '批量删除', icon: Trash2 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/* ───────── Validation ───────── */
|
||||||
|
|
||||||
|
function validateUserForm(data: { username: string; password: string; email: string }): FormErrors {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
if (!data.username.trim()) {
|
||||||
|
errors.username = '用户名不能为空';
|
||||||
|
} else if (!/^[a-zA-Z0-9_]{3,30}$/.test(data.username)) {
|
||||||
|
errors.username = '用户名只能包含字母、数字和下划线,长度3-30';
|
||||||
|
}
|
||||||
|
if (!data.password) {
|
||||||
|
errors.password = '密码不能为空';
|
||||||
|
} else if (data.password.length < 6) {
|
||||||
|
errors.password = '密码长度至少6位';
|
||||||
|
}
|
||||||
|
if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||||
|
errors.email = '邮箱格式不正确';
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Mock Activities ───────── */
|
||||||
|
|
||||||
|
function generateMockActivities(userId: number): UserActivity[] {
|
||||||
|
const actions = [
|
||||||
|
{ action: '登录系统', target: 'Web Console' },
|
||||||
|
{ action: '执行查询', target: 'users 表' },
|
||||||
|
{ action: '提交工单', target: 'TK-20240115-001' },
|
||||||
|
{ action: '审批工单', target: 'TK-20240115-002' },
|
||||||
|
{ action: '导出数据', target: 'orders 表' },
|
||||||
|
{ action: '修改密码', target: '账户安全' },
|
||||||
|
];
|
||||||
|
return actions.map((a, i) => ({
|
||||||
|
id: `act-${userId}-${i}`,
|
||||||
|
action: a.action,
|
||||||
|
target: a.target,
|
||||||
|
created_at: new Date(Date.now() - i * 3600000 * 2).toISOString(),
|
||||||
|
status: Math.random() > 0.2 ? 'success' : 'failed',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── CSV Export Helper ───────── */
|
||||||
|
|
||||||
|
function exportUsersToCSV(users: UserItem[]) {
|
||||||
|
const headers = ['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间'];
|
||||||
|
const rows = users.map(u => [
|
||||||
|
u.username,
|
||||||
|
u.display_name || '',
|
||||||
|
u.email || '',
|
||||||
|
roleNameMap[u.role] || u.role,
|
||||||
|
u.is_active ? '启用' : '禁用',
|
||||||
|
u.created_at,
|
||||||
|
]);
|
||||||
|
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||||
|
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `users_export_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Main Component ───────── */
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [roleFilter, setRoleFilter] = useState('');
|
const [roleFilter, setRoleFilter] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [loginTimeFilter, setLoginTimeFilter] = useState('');
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [showDetail, setShowDetail] = useState<UserItem | null>(null);
|
||||||
|
const [showEdit, setShowEdit] = useState<UserItem | null>(null);
|
||||||
|
const [showResetPwd, setShowResetPwd] = useState<UserItem | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [showBatchMenu, setShowBatchMenu] = useState(false);
|
||||||
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||||
|
|
||||||
const fetchUsers = useCallback(async () => {
|
const fetchUsers = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await userApi.list();
|
const res = await userApi.list();
|
||||||
const data = (res as any)?.items || res;
|
const data = (res as any)?.items || res;
|
||||||
setUsers(Array.isArray(data) ? data : []);
|
// Merge with mock user data to enrich with last_login
|
||||||
|
const merged = (Array.isArray(data) ? data : []).map((u: UserItem) => {
|
||||||
|
const mockU = mockUsers.find(m => m.id === u.id);
|
||||||
|
return { ...u, last_login: mockU?.lastLogin || u.created_at };
|
||||||
|
});
|
||||||
|
setUsers(merged);
|
||||||
} catch {
|
} catch {
|
||||||
// keep existing data
|
setToast({ message: '获取用户列表失败', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -51,174 +170,468 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||||
|
|
||||||
const filtered = users.filter(u => {
|
// Auto-dismiss toast
|
||||||
if (roleFilter && u.role !== roleFilter) return false;
|
useEffect(() => {
|
||||||
if (statusFilter === 'active' && !u.is_active) return false;
|
if (!toast) return;
|
||||||
if (statusFilter === 'disabled' && u.is_active) return false;
|
const t = setTimeout(() => setToast(null), 3000);
|
||||||
if (search && !u.username.includes(search) && !(u.email || '').includes(search)) return false;
|
return () => clearTimeout(t);
|
||||||
return true;
|
}, [toast]);
|
||||||
});
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return users.filter(u => {
|
||||||
|
if (roleFilter && u.role !== roleFilter) return false;
|
||||||
|
if (statusFilter === 'active' && !u.is_active) return false;
|
||||||
|
if (statusFilter === 'disabled' && u.is_active) return false;
|
||||||
|
if (search && !u.username.toLowerCase().includes(search.toLowerCase()) && !(u.email || '').toLowerCase().includes(search.toLowerCase()) && !(u.display_name || '').toLowerCase().includes(search.toLowerCase())) return false;
|
||||||
|
if (loginTimeFilter && u.last_login) {
|
||||||
|
const days = Math.floor((Date.now() - new Date(u.last_login).getTime()) / 86400000);
|
||||||
|
if (loginTimeFilter === '7d' && days > 7) return false;
|
||||||
|
if (loginTimeFilter === '30d' && days > 30) return false;
|
||||||
|
if (loginTimeFilter === '90d' && days > 90) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [users, roleFilter, statusFilter, search, loginTimeFilter]);
|
||||||
|
|
||||||
|
const allSelected = filtered.length > 0 && filtered.every(u => selectedIds.has(u.id));
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (allSelected) {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
filtered.forEach(u => next.delete(u.id));
|
||||||
|
setSelectedIds(next);
|
||||||
|
} else {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
filtered.forEach(u => next.add(u.id));
|
||||||
|
setSelectedIds(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (id: number) => {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
setSelectedIds(next);
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleActive = async (user: UserItem) => {
|
const handleToggleActive = async (user: UserItem) => {
|
||||||
try {
|
try {
|
||||||
await userApi.update(user.id, { is_active: !user.is_active });
|
await userApi.update(user.id, { is_active: !user.is_active });
|
||||||
|
setToast({ message: `用户已${!user.is_active ? '启用' : '禁用'}`, type: 'success' });
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {}
|
} catch {
|
||||||
|
setToast({ message: '操作失败', type: 'error' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm('确定删除该用户?')) return;
|
if (!confirm('确定删除该用户?此操作不可恢复。')) return;
|
||||||
try {
|
try {
|
||||||
await userApi.delete(id);
|
await userApi.delete(id);
|
||||||
|
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n; });
|
||||||
|
setToast({ message: '用户已删除', type: 'success' });
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {}
|
} catch {
|
||||||
|
setToast({ message: '删除失败', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchAction = async (action: string) => {
|
||||||
|
const ids = Array.from(selectedIds);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
if (action === 'delete' && !confirm(`确定删除选中的 ${ids.length} 个用户?此操作不可恢复。`)) return;
|
||||||
|
try {
|
||||||
|
if (action === 'enable') {
|
||||||
|
await Promise.all(ids.map(id => userApi.update(id, { is_active: true })));
|
||||||
|
setToast({ message: `已启用 ${ids.length} 个用户`, type: 'success' });
|
||||||
|
} else if (action === 'disable') {
|
||||||
|
await Promise.all(ids.map(id => userApi.update(id, { is_active: false })));
|
||||||
|
setToast({ message: `已禁用 ${ids.length} 个用户`, type: 'success' });
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
await Promise.all(ids.map(id => userApi.delete(id)));
|
||||||
|
setToast({ message: `已删除 ${ids.length} 个用户`, type: 'success' });
|
||||||
|
}
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
fetchUsers();
|
||||||
|
} catch {
|
||||||
|
setToast({ message: '批量操作失败', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
// Simulate import
|
||||||
|
setToast({ message: `文件 "${file.name}" 导入成功`, type: 'success' });
|
||||||
|
fetchUsers();
|
||||||
|
} catch {
|
||||||
|
setToast({ message: '导入失败,请检查文件格式', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
{/* Toast */}
|
||||||
|
{toast && (
|
||||||
|
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
|
||||||
|
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||||
|
}`}>
|
||||||
|
{toast.type === 'success' ? <CheckSquare className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||||
|
{toast.message}
|
||||||
|
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">用户管理</h1>
|
<h1 className="text-2xl font-semibold">用户管理</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowCreate(true)} 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">
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="w-4 h-4" /> 新建用户
|
<label 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)] transition-all cursor-pointer">
|
||||||
</button>
|
<Upload className="w-4 h-4" /> 导入
|
||||||
</div>
|
<input type="file" accept=".csv" className="hidden" onChange={handleImport} />
|
||||||
|
</label>
|
||||||
{/* 筛选 */}
|
<button
|
||||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
onClick={() => exportUsersToCSV(filtered)}
|
||||||
<div className="relative flex-1 max-w-xs">
|
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)] transition-all"
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
>
|
||||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="搜索用户名/邮箱" value={search} onChange={e => setSearch(e.target.value)} />
|
<Download className="w-4 h-4" /> 导出
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> 新建用户
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={roleFilter} onChange={e => setRoleFilter(e.target.value)}>
|
|
||||||
<option value="">全部角色</option>
|
|
||||||
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
|
||||||
<option value="">全部状态</option>
|
|
||||||
<option value="active">启用</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 用户表格 */}
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20"
|
||||||
|
placeholder="搜索用户名/邮箱/显示名称"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Filter className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||||
|
<select
|
||||||
|
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={e => setRoleFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
{roleOptions.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||||
|
value={loginTimeFilter}
|
||||||
|
onChange={e => setLoginTimeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">全部登录时间</option>
|
||||||
|
<option value="7d">7天内登录</option>
|
||||||
|
<option value="30d">30天内登录</option>
|
||||||
|
<option value="90d">90天内登录</option>
|
||||||
|
</select>
|
||||||
|
{(search || roleFilter || statusFilter || loginTimeFilter) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
|
||||||
|
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" /> 重置
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Batch Actions Bar */}
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
|
||||||
|
<span className="text-sm text-blue-400">
|
||||||
|
已选择 <strong>{selectedIds.size}</strong> 项
|
||||||
|
</span>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBatchMenu(!showBatchMenu)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm text-blue-400 hover:bg-blue-500/25 transition-colors"
|
||||||
|
>
|
||||||
|
批量操作 <ChevronDown className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{showBatchMenu && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowBatchMenu(false)} />
|
||||||
|
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[160px]">
|
||||||
|
{BATCH_OPTIONS.map(opt => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.key}
|
||||||
|
onClick={() => { handleBatchAction(opt.key); setShowBatchMenu(false); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Table */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{loading && users.length === 0 ? (
|
{loading && users.length === 0 ? (
|
||||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>
|
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||||
|
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||||
|
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">没有找到匹配的用户</p>
|
||||||
|
{(search || roleFilter || statusFilter || loginTimeFilter) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
|
||||||
|
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
|
||||||
|
>
|
||||||
|
清除筛选条件
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
<th className="text-left px-5 py-3 font-semibold">用户</th>
|
<th className="text-left px-3 py-3 w-10">
|
||||||
<th className="text-left px-5 py-3 font-semibold">角色</th>
|
<button onClick={toggleSelectAll} className="hover:opacity-70 transition-opacity">
|
||||||
<th className="text-left px-5 py-3 font-semibold">邮箱</th>
|
{allSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
</button>
|
||||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold">用户</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold">角色</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold">邮箱</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold">最后登录</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold">状态</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map(user => (
|
{filtered.map(user => {
|
||||||
<tr key={user.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
const isSelected = selectedIds.has(user.id);
|
||||||
<td className="px-5 py-3.5">
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<tr
|
||||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
|
key={user.id}
|
||||||
{(user.display_name || user.username).charAt(0)}
|
className={`border-b border-[var(--dg-border-light)] transition-colors ${
|
||||||
|
isSelected ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-3.5">
|
||||||
|
<button onClick={() => toggleSelect(user.id)} className="hover:opacity-70 transition-opacity">
|
||||||
|
{isSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setShowDetail(user)}>
|
||||||
|
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
|
||||||
|
{(user.display_name || user.username).charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium hover:text-blue-400 transition-colors">{user.display_name || user.username}</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</td>
|
||||||
<div className="font-medium">{user.display_name || user.username}</div>
|
<td className="px-4 py-3.5">
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${roleTagStyle[user.role] || 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'}`}>
|
||||||
|
{roleNameMap[user.role] || user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)]">{user.email || '-'}</td>
|
||||||
|
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)] text-xs">
|
||||||
|
{user.last_login
|
||||||
|
? new Date(user.last_login).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(user)}
|
||||||
|
className={`relative w-11 h-6 rounded-full transition-colors ${user.is_active ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||||
|
title={user.is_active ? '点击禁用' : '点击启用'}
|
||||||
|
aria-label={user.is_active ? '禁用用户' : '启用用户'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all"
|
||||||
|
style={{ transform: user.is_active ? 'translateX(20px)' : 'translateX(2px)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetail(user)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||||
|
title="查看详情"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEdit(user)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResetPwd(user)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-yellow-400 transition-colors"
|
||||||
|
title="重置密码"
|
||||||
|
>
|
||||||
|
<KeyRound className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1.5 rounded-md hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
<td className="px-5 py-3.5">
|
);
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${roleTagStyle[user.role] || 'bg-gray-500/15 text-gray-400'}`}>
|
})}
|
||||||
{roleNameMap[user.role] || user.role}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.email || '-'}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<button onClick={() => handleToggleActive(user)} className={`relative w-11 h-6 rounded-full transition-colors ${user.is_active ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}>
|
|
||||||
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all`} style={{ transform: user.is_active ? 'translateX(20px)' : 'translateX(2px)' }} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="text-blue-400 hover:text-blue-300 transition-colors"><Edit2 className="w-4 h-4" /></button>
|
|
||||||
<button className="text-red-400 hover:text-red-300 transition-colors" onClick={() => handleDelete(user.id)}><Trash2 className="w-4 h-4" /></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
||||||
<span className="text-sm text-[var(--dg-text-muted)]">共 {filtered.length} 条记录</span>
|
<span className="text-sm text-[var(--dg-text-muted)]">
|
||||||
|
共 <strong className="text-[var(--dg-text-primary)]">{filtered.length}</strong> 条记录
|
||||||
|
{selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 新建用户弹窗 */}
|
{/* Create Modal */}
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} />
|
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} setToast={setToast} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
{showDetail && (
|
||||||
|
<UserDetailModal user={showDetail} onClose={() => setShowDetail(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{showEdit && (
|
||||||
|
<EditUserModal user={showEdit} onClose={() => setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} setToast={setToast} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
{showResetPwd && (
|
||||||
|
<ResetPasswordModal user={showResetPwd} onClose={() => setShowResetPwd(null)} setToast={setToast} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
/* ───────── Create User Modal ───────── */
|
||||||
|
|
||||||
|
function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void; onCreated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [role, setRole] = useState('developer');
|
const [role, setRole] = useState('developer');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!username || !password) { setError('用户名和密码必填'); return; }
|
const validation = validateUserForm({ username, password, email });
|
||||||
|
setErrors(validation);
|
||||||
|
if (Object.keys(validation).length > 0) return;
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
|
||||||
try {
|
try {
|
||||||
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
|
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
|
||||||
|
setToast({ message: '用户创建成功', type: 'success' });
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.response?.data?.detail || '创建失败');
|
setToast({ message: e?.response?.data?.detail || '创建失败', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h2 className="text-lg font-semibold">新建用户</h2>
|
<h2 className="text-lg font-semibold">新建用户</h2>
|
||||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
|
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">用户名 *</label>
|
<label className="block text-sm font-medium mb-1">用户名 *</label>
|
||||||
<input value={username} onChange={e => setUsername(e.target.value)} className="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" />
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={e => { setUsername(e.target.value); if (errors.username) setErrors(p => ({ ...p, username: undefined })); }}
|
||||||
|
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.username ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||||
|
placeholder="3-30位字母、数字或下划线"
|
||||||
|
/>
|
||||||
|
{errors.username && <p className="text-xs text-red-400 mt-1">{errors.username}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">显示名称</label>
|
<label className="block text-sm font-medium mb-1">显示名称</label>
|
||||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="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" />
|
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="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" placeholder="用户的显示名称" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">密码 *</label>
|
<label className="block text-sm font-medium mb-1">密码 *</label>
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className="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" />
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => { setPassword(e.target.value); if (errors.password) setErrors(p => ({ ...p, password: undefined })); }}
|
||||||
|
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.password ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||||
|
placeholder="至少6位"
|
||||||
|
/>
|
||||||
|
{errors.password && <p className="text-xs text-red-400 mt-1">{errors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">邮箱</label>
|
<label className="block text-sm font-medium mb-1">邮箱</label>
|
||||||
<input value={email} onChange={e => setEmail(e.target.value)} className="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" />
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => { setEmail(e.target.value); if (errors.email) setErrors(p => ({ ...p, email: undefined })); }}
|
||||||
|
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.email ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||||
|
placeholder="example@company.com"
|
||||||
|
/>
|
||||||
|
{errors.email && <p className="text-xs text-red-400 mt-1">{errors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">角色</label>
|
<label className="block text-sm font-medium mb-1">角色</label>
|
||||||
@@ -229,10 +642,9 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
|
|||||||
<option value="guest">访客</option>
|
<option value="guest">访客</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">取消</button>
|
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50">
|
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||||
{submitting ? '创建中...' : '创建'}
|
{submitting ? '创建中...' : '创建'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,3 +653,225 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────── Edit User Modal ───────── */
|
||||||
|
|
||||||
|
function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem; onClose: () => void; onUpdated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
|
||||||
|
const [displayName, setDisplayName] = useState(user.display_name || '');
|
||||||
|
const [email, setEmail] = useState(user.email || '');
|
||||||
|
const [role, setRole] = useState(user.role);
|
||||||
|
const [isActive, setIsActive] = useState(user.is_active);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await userApi.update(user.id, { display_name: displayName, email: email || undefined, role, is_active: isActive });
|
||||||
|
setToast({ message: '用户更新成功', type: 'success' });
|
||||||
|
onUpdated();
|
||||||
|
} catch {
|
||||||
|
setToast({ message: '更新失败', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2 className="text-lg font-semibold">编辑用户</h2>
|
||||||
|
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
||||||
|
{(user.display_name || user.username).charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{user.display_name || user.username}</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">显示名称</label>
|
||||||
|
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">邮箱</label>
|
||||||
|
<input type="email" value={email} onChange={e => setEmail(e.target.value)} className="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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">角色</label>
|
||||||
|
<select value={role} onChange={e => setRole(e.target.value)} className="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">
|
||||||
|
<option value="admin">超级管理员</option>
|
||||||
|
<option value="dba">数据库管理员</option>
|
||||||
|
<option value="developer">开发人员</option>
|
||||||
|
<option value="guest">访客</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsActive(!isActive)}
|
||||||
|
className={`relative w-11 h-6 rounded-full transition-colors ${isActive ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" style={{ transform: isActive ? 'translateX(20px)' : 'translateX(2px)' }} />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm">{isActive ? '已启用' : '已禁用'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||||
|
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||||
|
{submitting ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Reset Password Modal ───────── */
|
||||||
|
|
||||||
|
function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClose: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError('');
|
||||||
|
if (!newPassword) { setError('请输入新密码'); return; }
|
||||||
|
if (newPassword.length < 6) { setError('密码长度至少6位'); return; }
|
||||||
|
if (newPassword !== confirmPassword) { setError('两次输入的密码不一致'); return; }
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await userApi.update(user.id, { password: newPassword });
|
||||||
|
setToast({ message: '密码重置成功', type: 'success' });
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
setToast({ message: '密码重置失败', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-sm bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2"><KeyRound className="w-5 h-5 text-yellow-400" /> 重置密码</h2>
|
||||||
|
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-yellow-500/5 border border-yellow-500/15 rounded-lg mb-4">
|
||||||
|
<p className="text-sm text-[var(--dg-text-secondary)]">为用户 <strong className="text-[var(--dg-text-primary)]">{user.display_name || user.username}</strong> 设置新密码</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">新密码</label>
|
||||||
|
<input type="password" value={newPassword} onChange={e => { setNewPassword(e.target.value); setError(''); }} className="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" placeholder="至少6位" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">确认密码</label>
|
||||||
|
<input type="password" value={confirmPassword} onChange={e => { setConfirmPassword(e.target.value); setError(''); }} className="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" placeholder="再次输入" />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||||
|
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-yellow-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||||
|
{submitting ? '重置中...' : '确认重置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── User Detail Modal ───────── */
|
||||||
|
|
||||||
|
function UserDetailModal({ user, onClose }: { user: UserItem; onClose: () => void }) {
|
||||||
|
const activities = useMemo(() => generateMockActivities(user.id), [user.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2 className="text-lg font-semibold">用户详情</h2>
|
||||||
|
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info Card */}
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-xl mb-5">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-lg font-semibold">
|
||||||
|
{(user.display_name || user.username).charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-base font-semibold">{user.display_name || user.username}</div>
|
||||||
|
<div className="text-sm text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<span className={`inline-flex px-2 py-0.5 rounded-md text-xs font-medium ${roleTagStyle[user.role] || ''}`}>
|
||||||
|
{roleNameMap[user.role] || user.role}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex px-2 py-0.5 rounded-md text-xs font-medium ${user.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||||
|
{user.is_active ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Fields */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">邮箱</div>
|
||||||
|
<div className="text-sm font-medium truncate">{user.email || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">创建时间</div>
|
||||||
|
<div className="text-sm font-medium">{new Date(user.created_at).toLocaleDateString('zh-CN')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">最后登录</div>
|
||||||
|
<div className="text-sm font-medium">{user.last_login ? new Date(user.last_login).toLocaleString('zh-CN') : '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">用户ID</div>
|
||||||
|
<div className="text-sm font-medium">#{user.id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Log */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
|
最近活动
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{activities.map(act => (
|
||||||
|
<div key={act.id} className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${act.status === 'success' ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm">{act.action}</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">{act.target}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
|
||||||
|
{new Date(act.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Need Clock icon for detail modal ───────── */
|
||||||
|
function Clock(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||||
|
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
+825
-148
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user