diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 55dfdc9..88d95bc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # SQL-Platform-Pro 系统架构文档 -> 生成时间: 2026-05-24 | 最新 Commit: 9813741 +> 生成时间: 2026-05-25 | 最新 Commit: 9813741+ ## 1. 系统定位 @@ -8,16 +8,17 @@ ## 2. 技术栈 -| 层级 | 技术 | 版本 | -|------|------|------| -| 后端框架 | FastAPI + SQLAlchemy 2 async | Python 3.11+ | -| 数据库 | SQLite (开发) | aiomysql / asyncpg / redis.asyncio / pymongo | -| 前端框架 | React 19 + TypeScript | Vite 7 | -| UI 组件 | shadcn/ui + Tailwind CSS 4 | Radix UI | -| 代码编辑器 | CodeMirror 6 | @codemirror/lang-sql | -| 图表 | Recharts | — | -| 认证 | JWT (30min) + bcrypt 4.0 | — | -| 部署 | Docker + docker-compose | run.sh PID 管理 | +|| 层级 | 技术 | 版本 | +||------|------|------| +|| 后端框架 | FastAPI + SQLAlchemy 2 async | Python 3.11+ | +|| 数据库 | SQLite (开发) | aiomysql / asyncpg / redis.asyncio / pymongo | +|| 定时调度 | APScheduler 3.11.2 + SQLite 持久化 | apscheduler.db | +|| 前端框架 | React 19 + TypeScript | Vite 7 | +|| UI 组件 | shadcn/ui + Tailwind CSS 4 | Radix UI | +|| 代码编辑器 | CodeMirror 6 | @codemirror/lang-sql | +|| 图表 | Recharts | — | +|| 认证 | JWT (30min) + bcrypt 4.0 | — | +|| 部署 | Docker + docker-compose | run.sh PID 管理 | ## 3. 系统架构图 @@ -27,23 +28,29 @@ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Dashboard │ │ Queries │ │ Tickets │ │ Instances│ │ │ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ -│ ┌──────┴──────┐ ┌────┴─────┐ ┌───┴──────┐ │ -│ │ SchemaSide │ │ SqlEditor│ │ Workflow │ │ -│ │ bar(Redis树)│ │ (CM6) │ │ Page │ │ -│ └─────────────┘ └──────────┘ └──────────┘ │ +│ ┌──────┴──────┐ ┌────┴─────┐ ┌───┴──────┐ ┌──┴───────┐ │ +│ │ SchemaSide │ │ SqlEditor│ │ Workflow │ │AutoTask │ │ +│ │ bar(Redis树)│ │ (CM6) │ │ Page │ │Page │ │ +│ └─────────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ ↕ axios /api/v1 │ ├─────────────────────────────────────────────────────────────┤ │ 后端 (FastAPI) │ │ ┌────────────────────────────────────────────────────┐ │ -│ │ API Layer (29 endpoints) │ │ +│ │ API Layer (29+ endpoints) │ │ │ │ auth | user | instance | ticket | query | audit │ │ │ │ audit_rule | notification | workflow | settings │ │ +│ │ ticket_scheduler | log_cleanup │ │ │ └──────────────────────┬─────────────────────────────┘ │ │ ┌──────────────────────┴─────────────────────────────┐ │ -│ │ Service Layer (31 services) │ │ +│ │ Service Layer (33+ services) │ │ │ │ query_service | audit_engine | ticket_executor │ │ │ │ instance_service | notification_sender │ │ │ │ approval_timeout | role_permissions │ │ +│ │ ticket_scheduler | log_cleanup | settings_seeds │ │ +│ └──────────────────────┬─────────────────────────────┘ │ +│ ┌──────────────────────┴─────────────────────────────┐ │ +│ │ Scheduler Layer (APScheduler) │ │ +│ │ 定时工单执行 | 日志自动清理 (cron 03:00) │ │ │ └──────────────────────┬─────────────────────────────┘ │ │ ┌──────────────────────┴─────────────────────────────┐ │ │ │ Connector Layer │ │ @@ -51,7 +58,7 @@ │ │ redis_connector | mongo_connector │ │ │ └──────────────────────┬─────────────────────────────┘ │ │ ┌──────────────────────┴─────────────────────────────┐ │ -│ │ SQLite(metadata) | MySQL | PostgreSQL | Redis | MongoDB│ +│ │ SQLite(metadata) | apscheduler.db | MySQL | PostgreSQL | Redis | MongoDB│ │ └────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` @@ -133,6 +140,44 @@ ticket_executor.py → 按 instance.db_type 执行: → ExecutionReport (status/affected_rows/duration/warnings/errors) ``` +### 4.6 定时调度器 (APScheduler) + +``` +ticket_scheduler.py: + ├─ APScheduler BackgroundScheduler + SQLite 持久化 (apscheduler.db) + ├─ 工单 scheduled_at 到期 → 自动触发执行 + ├─ 状态流转: approved → scheduled → executing → success/failed + ├─ 支持取消定时: POST /tickets/{id}/cancel-schedule + └─ 统计接口: GET /tickets/scheduled/stats + +log_cleanup.py: + ├─ APScheduler cron job → 每天 03:00 自动执行 + ├─ 读取 cleanup 分组配置 (6 项) + ├─ 按保留天数清理: + │ ├─ 工单 (只清理已完成/已拒绝, 0天=不清理) + │ ├─ 审计日志 + │ ├─ 查询历史 + │ └─ 定时任务日志 + ├─ 手动触发: POST /settings/cleanup/run + └─ 预览: GET /settings/cleanup/preview +``` + +### 4.7 系统设置种子机制 + +``` +settings_seeds.py: + ├─ SETTING_GROUPS: 分组元信息 (query_control / security / ticket / cleanup) + ├─ _ensure_settings(): 启动时自动补缺失配置项 + ├─ SETTING_DEFINITIONS: 所有设置项定义 (key/value_type/default/group/label) + └─ 新增 cleanup 分组 (6 项): + ├─ cleanup_enabled (bool, false) + ├─ cleanup_time (str, "03:00") + ├─ cleanup_ticket_days (int, 90) + ├─ cleanup_audit_days (int, 180) + ├─ cleanup_query_history_days (int, 30) + └─ cleanup_task_log_days (int, 30) +``` + ## 5. 数据模型 ### 5.1 核心 ORM 模型 (22 个) @@ -142,7 +187,7 @@ ticket_executor.py → 按 instance.db_type 执行: | User | 用户 | username, password_hash, role, display_name | | Instance | 数据源实例 | name, host, port, db_type, access_mode, group_id | | InstanceGroup | 权限组 | name, description, access_type, workflow_id | -| Ticket | 工单 | ticket_no, title, db_type, status, sql_content, audit_result | +|| Ticket | 工单 | ticket_no, title, db_type, status, sql_content, audit_result, **scheduled_at** | | TicketApproval | 审批记录 | ticket_id, step_number, approver_id, action, step_type | | AuditLog | 审计日志 | user_id, action, resource_type, resource_id | | AuditRule | 审核规则 | name, code, db_type, rule_type, severity | @@ -245,7 +290,7 @@ Redis Keys 目录树: ## 8. 系统配置 -12 项配置分 3 组: +18 项配置分 4 组: **查询控制 (7 项)**: - query_require_approval (bool) - 查询是否需要审批 @@ -265,6 +310,14 @@ Redis Keys 目录树: - ticket_default_workflow (string, "standard") - 默认审批流 - ticket_allow_rollback (bool) - 允许回滚 +**清理配置 (6 项)** 🆕: +- cleanup_enabled (bool, false) - 启用自动清理 +- cleanup_time (string, "03:00") - 清理执行时间 +- cleanup_ticket_days (int, 90) - 工单保留天数 (0=不清理) +- cleanup_audit_days (int, 180) - 审计日志保留天数 +- cleanup_query_history_days (int, 30) - 查询历史保留天数 +- cleanup_task_log_days (int, 30) - 定时任务日志保留天数 + ## 9. 四角色体系 | 角色 | 权限范围 | @@ -296,13 +349,16 @@ docker-compose up -d # 部署 ## 11. 关键设计决策 -| 决策 | 原因 | -|------|------| -| SQLite 开发数据库 | 零配置,单文件,便于版本管理 | -| AES-256-GCM 加密实例密码 | 安全存储,向后兼容明文 | -| JWT 30分钟过期 | 安全与便利平衡 | -| 暗色主题 CSS 变量体系 (--dg-) | 统一视觉风格 | -| CodeMirror 6 | 现代化编辑器,SQL 自动补全 | -| 权限组中间表设计 | 一组同时包含只读+写入实例 | -| React.lazy 代码分割 | 首屏 -44% (2676KB→1438KB) | -| 工单编号 20 位 | 时间戳+微秒+序号,全局唯一 | +|| 决策 | 原因 | +||------|------| +|| SQLite 开发数据库 | 零配置,单文件,便于版本管理 | +|| AES-256-GCM 加密实例密码 | 安全存储,向后兼容明文 | +|| JWT 30分钟过期 | 安全与便利平衡 | +|| 暗色主题 CSS 变量体系 (--dg-) | 统一视觉风格 | +|| CodeMirror 6 | 现代化编辑器,SQL 自动补全 | +|| 权限组中间表设计 | 一组同时包含只读+写入实例 | +|| React.lazy 代码分割 | 首屏 -44% (2676KB→1438KB) | +|| 工单编号 20 位 | 时间戳+微秒+序号,全局唯一 | +|| APScheduler + SQLite 持久化 | 定时任务跨重启不丢失,轻量级方案 | +|| 设置种子数据机制 | `_ensure_settings` 启动自动补全,保证配置完整性 | +|| 工单创建页单页化 | 简化三步向导为一键提交,提升操作效率 | diff --git a/docs/AUDIT_REPORT.md b/docs/AUDIT_REPORT.md index 4120e19..0a6a3f4 100644 --- a/docs/AUDIT_REPORT.md +++ b/docs/AUDIT_REPORT.md @@ -1,8 +1,8 @@ # SQL-Platform-Pro 全面架构审计报告 -> 生成时间: 2026-05-24 +> 生成时间: 2026-05-25 > 项目路径: ~/devops/sql-platform -> Git 总提交数: **56** +> Git 总提交数: **56+** --- @@ -10,15 +10,16 @@ ### 1.1 技术栈 -| 层级 | 技术 | -|------|------| -| 前端框架 | React 19 + TypeScript 5.9 + Vite 7 | -| UI 组件库 | Radix UI + Tailwind CSS (shadcn/ui 风格) | -| 后端框架 | FastAPI + SQLAlchemy 2 (异步) | -| 数据库 | SQLite (开发) / 可切换 MySQL/PostgreSQL | -| 多数据库支持 | MySQL (aiomysql), PostgreSQL (asyncpg), MongoDB (motor), Redis (redis) | -| 容器化 | Docker Compose + Dockerfile (前后端) | -| 数据库迁移 | Alembic | +|| 层级 | 技术 | +||------|------| +|| 前端框架 | React 19 + TypeScript 5.9 + Vite 7 | +|| UI 组件库 | Radix UI + Tailwind CSS (shadcn/ui 风格) | +|| 后端框架 | FastAPI + SQLAlchemy 2 (异步) | +|| 数据库 | SQLite (开发) / 可切换 MySQL/PostgreSQL | +|| 多数据库支持 | MySQL (aiomysql), PostgreSQL (asyncpg), MongoDB (motor), Redis (redis) | +|| 定时调度 | APScheduler 3.11.2 + SQLite 持久化 (apscheduler.db) | +|| 容器化 | Docker Compose + Dockerfile (前后端) | +|| 数据库迁移 | Alembic | ### 1.2 目录结构 @@ -220,8 +221,10 @@ sql-platform/ | `audit_rule_seeds_v2.py` | **500** | 审核规则种子 V2 | | `instance_service.py` | **134** | 实例服务 | | `settings_seeds.py` | **136** | 设置种子数据 | -| `workflow_seeds.py` | **93** | 工作流种子数据 | -| `audit_log_service.py` | **78** | 审计日志服务 | +|| `workflow_seeds.py` | **93** | 工作流种子数据 | +|| `audit_log_service.py` | **78** | 审计日志服务 | +|| `ticket_scheduler.py` 🆕 | ~200 | 定时工单调度 (APScheduler) | +|| `log_cleanup.py` 🆕 | ~180 | 日志自动清理 (APScheduler cron) | --- @@ -398,12 +401,16 @@ b39c426 fix: merge后14个测试修复+前端TS编译修复 - 895/895通过+前 | 数据字典 | ✅ | ✅ | ✅ (7) | 🟢 完整 | | 回滚 SQL | ✅ | ✅ | ✅ (10) | 🟢 完整 | | EXPLAIN 分析 | ✅ | ✅ | ✅ (40) | 🟢 完整 | -| SQL 助手 (AI) | ✅ | ✅ | ✅ (37) | 🟢 完整 | -| 权限组 | ✅ | ✅ | ✅ (20+21+14+16) | 🟢 完整 | -| 系统设置 | ✅ | ✅ | ✅ (18+10) | 🟢 完整 | -| LDAP 配置 | ✅ | ✅ | ✅ (18) | 🟢 完整 | -| Schema 漂移 | ✅ | ✅ | ✅ (24+13+9) | 🟢 完整 | -| 连接池 | ✅ | - | ✅ (21+12) | 🟡 基础设施,无独立前端 | +|| SQL 助手 (AI) | ✅ | ✅ | ✅ (37) | 🟢 完整 | +|| 权限组 | ✅ | ✅ | ✅ (20+21+14+16) | 🟢 完整 | +|| 系统设置 | ✅ | ✅ | ✅ (18+10) | 🟢 完整 | +|| 定时执行工单 🆕 | ✅ | ✅ | — | 🟢 完整 (APScheduler) | +|| 日志自动清理 🆕 | ✅ | ✅ | — | 🟢 完整 (APScheduler cron) | +|| SQL 检测按钮 🆕 | — | ✅ | — | 🟢 前端完整 | +|| 设置种子数据 🆕 | ✅ | — | — | 🟢 后端完整 | +|| LDAP 配置 | ✅ | ✅ | ✅ (18) | 🟢 完整 | +|| Schema 漂移 | ✅ | ✅ | ✅ (24+13+9) | 🟢 完整 | +|| 连接池 | ✅ | - | ✅ (21+12) | 🟡 基础设施,无独立前端 | --- diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 36c8adf..2cff037 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,6 +1,6 @@ # SQL-Platform-Pro 功能清单 -> 生成时间: 2026-05-24 | 版本: v1.0 +> 生成时间: 2026-05-25 | 版本: v1.1 ## ✅ 已完成功能 @@ -14,16 +14,20 @@ - [x] 查询结果表格 (点击单元格弹详情 + JSON 格式化) - [x] 查询历史记录 (500 条/用户上限) - [x] 收藏查询 / 快捷查询 -- [x] Schema 侧边栏 (MySQL 三级树 / Redis 目录树 / MongoDB 集合) -- [x] Redis key 点击自动插入查询命令 (GET/HGETALL/LRANGE/SMEMBERS/ZRANGE) +- [x] Schema 侧边栏 (MySQL 三级树 / Redis 目录树 / MongoDB 集合) — **已加宽 (grid-cols-5 2:3 布局)** +- [x] Redis key 点击自动插入查询命令 (GET/HGETALL/LRANGE/SMEMBERS/ZRANGE) — **onInsert prop** - [x] 两阶段 schema 加载 (选实例加载表名, 选库加载列名) - [x] 数据库品牌图标 (techicons.dev SVG) - [x] 实例按权限组过滤 (/instances/accessible?access_type=readonly) #### 2. 数据变更审批流 (TicketsPage + TicketCreatePage) -- [x] 工单创建 (选择变更授权实例 + 库表浏览辅助) +- [x] 工单创建 — 单页面一键提交 (原三步向导已简化) +- [x] DB 类型按频率排序 (MySQL → Redis → PostgreSQL → MongoDB) +- [x] 选择 DB 类型后自动筛选目标实例 +- [x] 库表浏览面板 (点击展开库/表/字段,辅助填写目标库) +- [x] Redis 目录树按 `:` 分隔构建层级 - [x] 工单编号生成 (20位: 时间戳+微秒+序号) -- [x] 工单状态机 (pending → reviewing → approved → executing → success/failed) +- [x] 工单状态机 (pending → reviewing → approved → **scheduled** → executing → success/failed) - [x] 多步审批流 (standard / parallel / any_one / condition) - [x] 审批人限定 (必须在权限组成员中) - [x] 工单撤销 (pending/reviewing 状态可撤销) @@ -44,6 +48,36 @@ - [x] 规则统计和筛选 - [x] 评分系统 (severity: error/warning/info) +#### 3.1 SQL 检测按钮 (TicketCreatePage) +- [x] 提交按钮旁新增「SQL 检测」按钮 +- [x] 验证通过前提交按钮置灰 + 提示文字 +- [x] SQL 内容变化自动重置验证状态 +- [x] 定时时间显示为醒目红色字体 + +#### 3.2 定时执行工单 (ticket_scheduler.py) +- [x] 基于 APScheduler + SQLite 持久化的定时调度器 (`apscheduler.db`) +- [x] Ticket 模型新增 `scheduled_at` 字段 +- [x] 新增 `SCHEDULED` 状态 (approved ↔ scheduled → executing → success/failed) +- [x] API: `GET /tickets/scheduled/list` 定时工单列表 +- [x] API: `POST /tickets/{id}/cancel-schedule` 取消定时 +- [x] API: `GET /tickets/scheduled/stats` 定时统计 +- [x] 前端: TicketCreatePage 定时执行 datetime picker +- [x] 前端: AutoTaskPage 定时任务管理页 + +#### 3.3 日志自动清理 (log_cleanup.py) +- [x] 系统设置新增 `cleanup` 分组 (6 项配置) +- [x] 启用自动清理开关 +- [x] 清理执行时间 (默认凌晨 3 点) +- [x] 工单保留天数 (默认 90 天) +- [x] 审计日志保留天数 (默认 180 天) +- [x] 查询历史保留天数 (默认 30 天) +- [x] 定时任务日志保留天数 (默认 30 天) +- [x] 只清理已完成/已拒绝的工单,0 天 = 不清理 +- [x] API: `POST /settings/cleanup/run` 手动触发清理 +- [x] API: `GET /settings/cleanup/preview` 预览待清理数据 +- [x] 前端: PlatformConfigPage 清理分组 + CleanupActions 预览/手动清理按钮 +- [x] APScheduler cron job 每天 03:00 自动执行 + #### 4. 审批流管理 (WorkflowPage) - [x] 审批流 CRUD (创建/编辑/删除) - [x] 步骤管理 (拖拽排序 + 类型选择) @@ -107,10 +141,13 @@ - [x] 测试发送功能 #### 11. 系统配置 (PlatformConfigPage) -- [x] 12 项配置 (查询控制 7 + 安全 3 + 工单 2) +- [x] 18 项配置 (查询控制 7 + 安全 3 + 工单 2 + **清理 6**) - [x] 分组卡片布局 (布尔→开关/整数→数字/字符串→文本) - [x] 批量保存 + 变更指示 + 重置默认值 - [x] 查询接口集成 (审批检查/长度检查) +- [x] **设置种子数据机制** (`settings_seeds.py` 统一管理所有设置项) +- [x] **`_ensure_settings` 启动时自动补缺失配置** +- [x] **`SETTING_GROUPS` 分组元信息** #### 12. 公告管理 (AnnouncementPage) - [x] 公告 CRUD (Markdown 内容) @@ -146,7 +183,6 @@ ## 🔨 开发中 / 待实现 ### 短期 (P1) -- [ ] 自动化任务前端 (AutoTaskPage.tsx + api/autoTask.ts) - [ ] 通知功能前端页面完善 - [ ] 两个页面 Redis 树抽取为共享组件 - [ ] LDAP 认证对接 (后端已有 LdapConfig 模型) @@ -169,19 +205,20 @@ ## 📊 项目统计 -| 指标 | 数值 | -|------|------| -| 总提交 | 56 | -| 源文件 | 326 | -| 后端代码 | ~22,000 行 Python | -| 前端代码 | ~14,000 行 TypeScript/TSX | -| API 路由 | 146 条 | -| 测试用例 | 1,075 个 | -| 数据库表 | 28 张 | -| ORM 模型 | 22 个 | -| 前端页面 | 26 个 | -| 审核规则 | 127 条 | -| 系统配置 | 12 项 | +|| 指标 | 数值 | +||------|------| +|| 总提交 | 56+ | +|| 源文件 | 328+ | +|| 后端代码 | ~23,000 行 Python | +|| 前端代码 | ~15,000 行 TypeScript/TSX | +|| API 路由 | 152+ 条 | +|| 测试用例 | 1,075+ 个 | +|| 数据库表 | 28+ 张 | +|| ORM 模型 | 22+ 个 | +|| 前端页面 | 26+ 个 | +|| 审核规则 | 127 条 | +|| 系统配置 | 18 项 | +|| 定时调度 | APScheduler + SQLite 持久化 | ## 🏗️ 数据库连接器支持矩阵 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4d1c0f8..c0fa807 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,33 +1,44 @@ # SQL-Platform-Pro 开发路线图 -> 更新时间: 2026-05-24 +> 更新时间: 2026-05-25 -## 当前版本: v1.0 (开发中) +## 当前版本: v1.1 (开发中) ### 已完成里程碑 -| 阶段 | 版本 | 测试数 | 关键交付 | -|------|------|--------|----------| -| Phase 1 | v0.1.0 | 44 | 后端 API + TDD 测试 | -| Phase 2 | v0.2.0 | 44 | 前后端集成 | -| Phase 3 | v0.3.0 | 85 | AES 加密 + SQL 审核引擎 + 审计日志 | -| Phase 4 | v0.4.0 | 128 | SQL AST + 规则 CRUD + Alembic + Docker | -| Phase 5 | v0.5.0 | 128 | run.sh 运维 + DBConnector + CI/CD | -| Phase 6 | v0.6.0 | 298 | Redis/MongoDB + 权限组 + 多步审批流 | -| Phase 7 | v0.7.0 | 475 | 多数据库查询/执行引擎 + SQL 助手 | -| Phase 8 | v0.8.0 | 700 | P2 高级功能测试 + endpoint 修复 | -| Phase 9 | v0.9.0 | 829 | P0 安全修复 — 实例访问控制全线接入权限组 | -| Phase 10 | v0.9.5 | 877 | 公告 + Dashboard 测试 | -| Phase 11 | v0.9.7 | 895 | 查询端点测试扩展 | -| Phase 12 | v0.9.8 | 1017 | P2 测试覆盖 — user/pool/schema_drift | -| Phase 13 | v0.9.9 | 1040 | 审核引擎优化 + 执行结果结构化 | -| Merge | v1.0-merge | — | SQL平台代码 v1 优化包 (193 条审核规则 + 16 页面 UI) | -| Current | — | 1075 | 多数据库浏览 + Redis 树 + 权限体系统一 + 代码分割 | +|| 阶段 | 版本 | 测试数 | 关键交付 | +||------|------|--------|----------| +|| Phase 1 | v0.1.0 | 44 | 后端 API + TDD 测试 | +|| Phase 2 | v0.2.0 | 44 | 前后端集成 | +|| Phase 3 | v0.3.0 | 85 | AES 加密 + SQL 审核引擎 + 审计日志 | +|| Phase 4 | v0.4.0 | 128 | SQL AST + 规则 CRUD + Alembic + Docker | +|| Phase 5 | v0.5.0 | 128 | run.sh 运维 + DBConnector + CI/CD | +|| Phase 6 | v0.6.0 | 298 | Redis/MongoDB + 权限组 + 多步审批流 | +|| Phase 7 | v0.7.0 | 475 | 多数据库查询/执行引擎 + SQL 助手 | +|| Phase 8 | v0.8.0 | 700 | P2 高级功能测试 + endpoint 修复 | +|| Phase 9 | v0.9.0 | 829 | P0 安全修复 — 实例访问控制全线接入权限组 | +|| Phase 10 | v0.9.5 | 877 | 公告 + Dashboard 测试 | +|| Phase 11 | v0.9.7 | 895 | 查询端点测试扩展 | +|| Phase 12 | v0.9.8 | 1017 | P2 测试覆盖 — user/pool/schema_drift | +|| Phase 13 | v0.9.9 | 1040 | 审核引擎优化 + 执行结果结构化 | +|| Merge | v1.0-merge | — | SQL平台代码 v1 优化包 (193 条审核规则 + 16 页面 UI) | +|| v1.0 | — | 1075 | 多数据库浏览 + Redis 树 + 权限体系统一 + 代码分割 | +|| **v1.1** 🆕 | — | 1075+ | **定时执行工单 + 日志自动清理 + SQL检测按钮 + 工单创建页重构 + SchemaSidebar 加宽 + 设置种子数据机制** | + +### v1.1 新增功能详情 + +|| 功能 | 说明 | 状态 | +||------|------|------| +|| 定时执行工单 | APScheduler + SQLite 持久化, scheduled_at 字段, SCHEDULED 状态 | ✅ | +|| 日志自动清理 | 6 项 cleanup 配置, cron 03:00, 预览/手动清理 API | ✅ | +|| SQL 检测按钮 | 提交前验证, 内容变化重置, 定时时间红色字体 | ✅ | +|| 工单创建页重构 | 三步向导→单页面一键提交, DB类型排序, 库表浏览面板 | ✅ | +|| SchemaSidebar 加宽 | grid-cols-5 (2:3), RedisTree onInsert prop | ✅ | +|| 设置种子数据机制 | settings_seeds.py, _ensure_settings, SETTING_GROUPS | ✅ | ### 下一步计划 #### Sprint 1: 功能补全 -- [ ] 自动化任务前端页面 - [ ] 通知页面完善 (渠道管理 + 模板管理) - [ ] Redis 树共享组件抽取 - [ ] LDAP 认证对接 diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index 9f09f53..74624fe 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -1,22 +1,20 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { - Plus, X, AlertTriangle, CheckCircle, Play, Loader2, + X, AlertTriangle, CheckCircle, Play, Loader2, ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star, StarOff, Download, Search, ArrowUpDown, Database, Table2, ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3, KeyRound, FolderOpen, Server, Zap, ClipboardPaste, + History, PanelRightClose, PanelRightOpen, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useActionPermission } from '@/hooks/useAuth'; -import PageHeader from '@/components/PageHeader'; -import EmptyState from '@/components/EmptyState'; -import { queryApi } from '@/api/query'; -import { instanceApi } from '@/api/instance'; import SqlEditor from '@/components/SqlEditor'; import SchemaSidebar from '@/components/SchemaSidebar'; +import { queryApi } from '@/api/query'; +import { instanceApi } from '@/api/instance'; import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates'; import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail'; -import { redisApi } from '@/api/redis'; import type { DBType, QueryTemplate } from '@/lib/queryTemplates'; /* ------------------------------------------------------------------ */ @@ -104,190 +102,11 @@ function downloadCSV(filename: string, content: string) { URL.revokeObjectURL(link.href); } -/* ------------------------------------------------------------------ */ -/* Main Page */ -/* ------------------------------------------------------------------ */ +/* ================================================================== */ +/* Main Page — 直接显示编辑器 + 可折叠历史抽屉 */ +/* ================================================================== */ export default function QueriesPage() { - const canCreate = useActionPermission('query.create'); - const [showNewQuery, setShowNewQuery] = useState(false); - const [history, setHistory] = useState(() => { - try { - const saved = localStorage.getItem('query_favorites'); - const favIds = saved ? new Set(JSON.parse(saved)) : new Set(); - 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) { - return ( - setShowNewQuery(false)} - onExecuted={handleExecuted} - history={history} - onToggleFavorite={toggleFavorite} - /> - ); - } - - return ( -
- setShowNewQuery(true), permission: 'query.create' } : undefined} - /> - - {/* History Controls */} - {history.length > 0 && ( -
-
- - setSearchHistory(e.target.value)} - /> -
- - -
- )} - - - - {history.length === 0 ? ( - - ) : filteredHistory.length === 0 ? ( -
- - 没有找到匹配的历史记录 -
- ) : ( -
- - - - - - - - - - - - - - {filteredHistory.map((q, idx) => ( - - - - - - - - - - ))} - -
#类型语句执行时长状态时间操作
{idx + 1} - {q.db_type && dbTypeIcon[q.db_type] && ( - {q.db_type} - )} - - {q.sql.substring(0, 120)} - - 1000 ? 'text-red-400 font-medium' : ''}>{q.execution_time_ms}ms - - - {q.status === 'success' ? '成功' : '失败'} - - {new Date(q.created_at).toLocaleString()} - -
-
- )} -
-
-
- ); -} - -/* ------------------------------------------------------------------ */ -/* NewQueryPanel */ -/* ------------------------------------------------------------------ */ -function NewQueryPanel({ - onCancel, onExecuted, history, onToggleFavorite, -}: { - onCancel: () => void; - onExecuted: (item: QueryHistoryItem) => void; - history: QueryHistoryItem[]; - onToggleFavorite: (id: number) => void; -}) { + // -- 编辑器 state -- const [sql, setSql] = useState(''); const [instanceId, setInstanceId] = useState(''); const [database, setDatabase] = useState(''); @@ -298,30 +117,42 @@ function NewQueryPanel({ const [executing, setExecuting] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState(null); + + // -- UI state -- const [showTemplates, setShowTemplates] = useState(false); const [templateSearch, setTemplateSearch] = useState(''); const [templateCategory, setTemplateCategory] = useState('all'); - const [sortState, setSortState] = useState({ column: null, direction: null }); + const [showSidebar, setShowSidebar] = useState(true); const [showHistory, setShowHistory] = useState(false); + const [sortState, setSortState] = useState({ column: null, direction: null }); const [rowHeight] = useState(36); const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 }); const resultContainerRef = useRef(null); - const [showSidebar, setShowSidebar] = useState(true); + // -- 历史 state -- + const [history, setHistory] = useState(() => { + try { + const saved = localStorage.getItem('query_favorites'); + const favIds = saved ? new Set(JSON.parse(saved)) : new Set(); + 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); + + /* ---- Data loading ---- */ useEffect(() => { instanceApi.accessible('readonly').then(res => { - const data = Array.isArray(res) ? res : []; - setInstances(data); + setInstances(Array.isArray(res) ? res : []); }).catch(() => { setInstances([]); }); }, []); - // 选实例后加载数据库列表 + 表名(用于 SQL 自动补全) useEffect(() => { if (!instanceId) { setDatabases([]); setDatabase(''); setEditorSchema({}); return; } const inst = instances.find(i => i.id === Number(instanceId)); - if (inst?.db_type === 'redis') { - setDatabases([]); setDatabase('0'); setEditorSchema({}); return; - } + if (inst?.db_type === 'redis') { setDatabases([]); setDatabase('0'); setEditorSchema({}); return; } const type = inst?.db_type || ''; if (type === 'mysql' || type === 'postgres' || type === 'postgresql') { setLoadingDbs(true); @@ -329,146 +160,80 @@ function NewQueryPanel({ .then(async data => { const dbs = Array.isArray(data) ? data : []; setDatabases(dbs); - // 快速加载所有库的表名(不含列名) const schema: Record> = {}; await Promise.all(dbs.map(async db => { try { const tables = await instanceDetailApi.listTables(Number(instanceId), db); - if (Array.isArray(tables)) { - schema[db] = {}; - for (const t of tables) { - schema[db][t.name] = []; - } - } + if (Array.isArray(tables)) { schema[db] = {}; for (const t of tables) { schema[db][t.name] = []; } } } catch { /* ignore */ } })); setEditorSchema(schema); }) .catch(() => { setDatabases([]); setEditorSchema({}); }) .finally(() => setLoadingDbs(false)); - } else { - setDatabases([]); - setEditorSchema({}); - } + } else { setDatabases([]); setEditorSchema({}); } }, [instanceId, instances]); - const selectedInstance = useMemo(() => { - return instances.find(inst => String(inst.id) === instanceId); - }, [instances, instanceId]); - + const selectedInstance = useMemo(() => instances.find(inst => String(inst.id) === instanceId), [instances, instanceId]); const dbType = (selectedInstance?.db_type || 'mysql') as DBType; const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const; - // 选了目标数据库后,加载该库的完整列名用于补全 useEffect(() => { if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return; - // 排除 Redis 残留的纯数字 database(如 "0") if (/^\d+$/.test(database)) return; instanceDetailApi.getDbSchema(Number(instanceId), database) - .then(data => { - if (data && typeof data === 'object') { - setEditorSchema(prev => ({ - ...prev, - [database]: data as Record, - })); - } - }) + .then(data => { if (data && typeof data === 'object') setEditorSchema(prev => ({ ...prev, [database]: data as Record })); }) .catch(() => { /* ignore */ }); }, [instanceId, database, dbType]); + /* ---- Templates ---- */ 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 templateCategories = useMemo(() => { const cats = new Set(templates.map(t => t.category)); return ['all', ...Array.from(cats)]; }, [templates]); const filteredTemplates = useMemo(() => { - let result = templates; - if (templateCategory !== 'all') result = result.filter(t => t.category === templateCategory); - if (templateSearch) { - const kw = templateSearch.toLowerCase(); - result = result.filter(t => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw)); - } - return result; + let r = templates; + if (templateCategory !== 'all') r = r.filter(t => t.category === templateCategory); + if (templateSearch) { const kw = templateSearch.toLowerCase(); r = r.filter(t => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw)); } + return r; }, [templates, templateCategory, templateSearch]); const handleApplyTemplate = useCallback((tpl: QueryTemplate) => { - const templateSQL = getTemplateSQL(tpl, dbType, { db: database || undefined }); - setSql(templateSQL); + setSql(getTemplateSQL(tpl, dbType, { db: database || undefined })); setShowTemplates(false); }, [dbType, database]); + const catLabel: Record = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询' }; + + /* ---- Execute ---- */ const handleExecute = useCallback(async () => { if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; } - // 检查未替换的模板占位符 - if (/\{(?:table|db|collection)\}/.test(sql)) { - setError('SQL 中含有未替换的占位符 {table}/{db}/{collection},请先替换或删除'); return; - } - setExecuting(true); - setError(''); - setResult(null); + if (/\{(?:table|db|collection)\}/.test(sql)) { setError('SQL 中含有未替换的占位符,请先替换或删除'); return; } + setExecuting(true); setError(''); setResult(null); try { - const res = await queryApi.execute({ - instance_id: Number(instanceId), - database: database || undefined, - sql, - }) as unknown as QueryResult; - - const execTime = res.execution_time_ms || 0; + const res = await queryApi.execute({ instance_id: Number(instanceId), database: database || undefined, sql }) as unknown as QueryResult; setResult(res); - onExecuted({ - id: Date.now(), - sql, - instance_id: Number(instanceId), - database, - db_type: dbType, - status: 'success', - execution_time_ms: execTime, - result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100), - created_at: new Date().toISOString(), - favorite: false, - }); + setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'success', execution_time_ms: res.execution_time_ms || 0, result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100), created_at: new Date().toISOString(), favorite: false }, ...prev]); } catch (e: unknown) { const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data?.detail; - const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '查询失败'); + const msg = Array.isArray(raw) ? raw.map(v => v.msg).join('; ') : (raw || '查询失败'); setError(msg); - onExecuted({ - id: Date.now(), - sql, - instance_id: Number(instanceId), - database, - db_type: dbType, - status: 'failed', - execution_time_ms: 0, - result_preview: msg, - created_at: new Date().toISOString(), - favorite: false, - }); - } finally { - setExecuting(false); - } - }, [sql, instanceId, database, dbType, onExecuted]); + setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'failed', execution_time_ms: 0, result_preview: msg, created_at: new Date().toISOString(), favorite: false }, ...prev]); + } finally { setExecuting(false); } + }, [sql, instanceId, database, dbType]); /* ---- 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] ?? ''; + const col = sortState.column; const dir = sortState.direction === 'asc' ? 1 : -1; + return { ...result, rows: [...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 }; - } + 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 }; }); }, []); @@ -482,225 +247,141 @@ function NewQueryPanel({ /* ---- Virtual Scroll ---- */ const handleResultScroll = useCallback(() => { if (!resultContainerRef.current) return; - const container = resultContainerRef.current; - const scrollTop = container.scrollTop; - const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 5); - const visibleCount = Math.ceil(container.clientHeight / rowHeight); - const end = Math.min((sortedResult?.rows?.length || 0), start + visibleCount + 10); - setVisibleRange({ start, end }); + const c = resultContainerRef.current; const start = Math.max(0, Math.floor(c.scrollTop / rowHeight) - 5); + const count = Math.ceil(c.clientHeight / rowHeight); + setVisibleRange({ start, end: Math.min((sortedResult?.rows?.length || 0), start + count + 10) }); }, [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); + downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, exportToCSV(result.columns, result.rows)); }, [result]); - const catLabel: Record = { - all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询', - }; + /* ---- History ---- */ + const toggleFavorite = useCallback((id: number) => { + setHistory(prev => { + const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h); + try { localStorage.setItem('query_favorites', JSON.stringify(next.filter(h => h.favorite).map(h => h.id))); } catch { /* noop */ } + return next; + }); + }, []); + const handleClearHistory = useCallback(() => { + if (!clearConfirm) { setClearConfirm(true); setTimeout(() => setClearConfirm(false), 3000); return; } + setHistory([]); setClearConfirm(false); + }, [clearConfirm]); + + const filteredHistory = useMemo(() => { + let r = history; + if (favFilter) r = r.filter(h => h.favorite); + if (searchHistory) { const kw = searchHistory.toLowerCase(); r = r.filter(h => h.sql.toLowerCase().includes(kw) || h.db_type.toLowerCase().includes(kw)); } + return r; + }, [history, favFilter, searchHistory]); + + /* ================================================================ */ + /* Render */ + /* ================================================================ */ return ( -
-
-
-

新建查询

- {selectedInstance && ( - - - {dbTypeLabel[dbType] || dbType} - - )} -
-
- - -
-
+
+ {/* ====== 主区域 ====== */} +
- {/* History Sidebar */} - {showHistory && ( - - -
- - - 查询历史 ({history.length}) - - -
-
- - {history.length === 0 ? ( -
暂无查询历史
- ) : ( -
- {history.map(h => ( -
- {h.db_type && dbTypeIcon[h.db_type] && ( - - )} - {h.sql.substring(0, 80)} - - {h.execution_time_ms}ms - - - -
- ))} -
+ {/* -- 顶栏: 实例+库选择 -- */} +
+
+ {selectedInstance && dbTypeIcon[dbType] && ( + )} - - - )} - - {/* Instance + Database selector */} -
-
- - -
-
- - {dbType === 'redis' ? ( - setInstanceId(e.target.value)} + 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 min-w-[200px]"> + + {instances.map(inst => ( + ))} - ) : ( - - )} -
-
- {/* Template selector */} -
-
- + {dbType === 'redis' ? ( + + ) : ( + + )} +
+ + {/* 右侧工具按钮组 */}
{instanceId && ( - )} - + + +
+ {/* -- 模板选择器 -- */} {showTemplates && ( -
- {/* Category tabs + search */} +
{templateCategories.map(cat => ( - ))}
- setTemplateSearch(e.target.value)} + 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="搜索模板..." - /> + placeholder="搜索模板..." />
- {/* Templates Grid */} {filteredTemplates.length === 0 ? (
无匹配模板
) : ( -
+
{filteredTemplates.map(tpl => ( - ))}
@@ -708,189 +389,204 @@ function NewQueryPanel({
)} - {/* CodeMirror Editor + Schema Sidebar */} -
- {/* Schema Sidebar */} + {/* -- 编辑器 + 侧边栏 -- */} +
+ {/* Schema 侧边栏 */} {showSidebar && instanceId && ( -
- setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' ')} - /> +
+ setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' ')} />
)} - {/* Editor */} -
-
- -
- - {dbTypeIcon[dbType] && } - {dbTypeLabel[dbType]} 模式 - -
- - - {sql.length} 字符 - - - Ctrl+Enter 执行 - - - Ctrl+Shift+F 格式化 - -
-
-
-
-
-
- {/* Security notice */} -
- - - 查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。 - -
- - {error && ( -
- - {error} -
- )} - - {/* Query Result */} - {result && ( - - -
- - - 查询结果 - -
- - - {result.execution_time_ms}ms + {/* SQL 编辑器 */} +
+
+ +
+ + {dbTypeIcon[dbType] && } + {dbTypeLabel[dbType]} 模式 · {sql.length} 字符 - {result.rows?.length || 0} 行 - {result.columns?.length || 0} 列 - {result.rows && result.rows.length > 0 && ( - - )} +
+ Ctrl+Enter 执行 + Ctrl+Shift+F 格式化 +
- - - {result.columns && result.columns.length > 0 ? ( -
- - - - - {result.columns.map(col => ( - - ))} - - - - {/* Virtual scroll: only render visible rows */} - - {(sortedResult?.rows || []).slice(visibleRange.start, visibleRange.end).map((row, i) => { - const realIndex = visibleRange.start + i; - return ( - - - {result.columns.map(col => { - const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k)); - const cellValue = row[col] ?? ''; - return ( - - ); - })} - - ); - })} - - -
# handleSort(col)} - className="text-left px-3 py-2.5 font-semibold text-xs cursor-pointer hover:text-blue-400 transition-colors select-none whitespace-nowrap" - > -
- {col} - - {sensitiveKeywords.some(k => col.toLowerCase().includes(k)) && ( - 敏 - )} -
-
{realIndex + 1} - {isSensitive - ? {maskData(String(cellValue))} - : String(cellValue) - } -
+
+
+ + {/* -- 错误提示 -- */} + {error && ( +
+ {error} +
+ )} + + {/* -- 查询结果 -- */} + {result && ( + + +
+ + + 查询结果 + +
+ {result.execution_time_ms}ms + {result.rows?.length || 0} 行 + {result.columns?.length || 0} 列 + {result.rows && result.rows.length > 0 && ( + + )} +
+
+ + {result.columns && result.columns.length > 0 ? ( +
+ + + + + {result.columns.map(col => ( + + ))} + + + + + {(sortedResult?.rows || []).slice(visibleRange.start, visibleRange.end).map((row, i) => { + const ri = visibleRange.start + i; + return ( + + + {result.columns.map(col => { + const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k)); + const cellValue = row[col] ?? ''; + return ( + + ); + })} + + ); + })} + + +
# handleSort(col)} + className="text-left px-3 py-2 font-semibold text-xs cursor-pointer hover:text-blue-400 transition-colors select-none whitespace-nowrap"> +
+ {col} + {sensitiveKeywords.some(k => col.toLowerCase().includes(k)) && ( + 敏 + )} +
+
{ri + 1} + {isSensitive ? {maskData(String(cellValue))} : String(cellValue)} +
+
+ ) : ( +
+ + 无结果数据 + {result.affected_rows > 0 && 影响行数: {result.affected_rows}} +
+ )} +
+
+ )} + + {/* -- 安全提示 -- */} +
+ + 查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。 +
+
+ + {/* ====== 右侧历史抽屉 ====== */} + {showHistory && ( +
+
+ + + 查询历史 + {history.length > 0 && ({history.length})} + + +
+ + {/* 搜索+筛选 */} + {history.length > 0 && ( +
+
+ + setSearchHistory(e.target.value)} /> +
+
+ +
+ +
+
+ )} + + {/* 历史列表 */} +
+ {history.length === 0 ? ( +
暂无查询历史
+ ) : filteredHistory.length === 0 ? ( +
没有匹配的记录
) : ( -
- - 无结果数据 - {result.affected_rows > 0 && ( - 影响行数: {result.affected_rows} - )} +
+ {filteredHistory.map(h => ( +
+
+ {h.db_type && dbTypeIcon[h.db_type] && } + + {h.execution_time_ms}ms + + {new Date(h.created_at).toLocaleTimeString()} +
+ +
+
+ {h.sql.substring(0, 80)} + +
+
+ ))}
)} - - +
+
)} - -
- - -
); }