mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
🐛 fix(data-grid): 修复数据输出列序与时间精度问题
- 统一复制、导出、JSON/Text 视图按表格展示列序输出 - 表级导出改用显式列查询,避免 SELECT * 丢失界面列序 - 保留 datetime(3) 等时间字段的小数秒展示与复制输出 Refs #434
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,483 +0,0 @@
|
||||
# JVM 缓存可视化编辑设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前用户在公司 Java 项目中经常把缓存或运行时状态直接保存在 JVM 内存中。出现数据脏值、缓存穿透、临时纠偏或排障时,通常只有两种方式:
|
||||
|
||||
- 为特定业务临时补管理接口
|
||||
- 重启应用并依赖重新初始化
|
||||
|
||||
这两种方式都存在明显问题:
|
||||
|
||||
- 临时接口会污染业务代码,并带来后续维护和权限风险
|
||||
- 重启应用成本高,且不适合用于精确修复单个缓存项
|
||||
|
||||
GoNavi 现有已具备三类可复用基础:
|
||||
|
||||
- 统一连接与工作台能力:`frontend/src/components/ConnectionModal.tsx`、`frontend/src/components/Sidebar.tsx`、`frontend/src/components/TabManager.tsx`
|
||||
- 独立运行时能力样板:Redis 通过 `internal/app/methods_redis.go` 和专用前端视图实现,不依赖 SQL `Database` 抽象
|
||||
- AI 与日志能力底座:`frontend/src/components/AIChatPanel.tsx`、`frontend/src/components/QueryEditor.tsx`、`frontend/src/components/LogPanel.tsx`
|
||||
|
||||
因此,GoNavi 有条件扩展出 JVM 运行时连接与受控编辑能力,但不能简单把该需求理解为“新数据库驱动”。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 为 GoNavi 增加统一的 `JVM Connector` 子系统,用于连接和浏览 Java 服务的运行时缓存/管理对象
|
||||
- 在同一套 UI 下支持多种接入模式,并根据目标 JVM 能力自动协商或手动切换
|
||||
- 提供结构化的缓存浏览、值检查、受控修改、操作预览和审计记录
|
||||
- 允许 AI 参与解释、分析和生成修改计划,但不默认开放 AI 自动执行
|
||||
- 尽量避免强依赖 `-javaagent` 或运行时动态 attach,适配企业内对生产进程注入普遍敏感的环境
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不承诺“任意 JVM 内任意对象均可直接读写”
|
||||
- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测
|
||||
- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象
|
||||
- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入
|
||||
- 不绕过目标服务现有认证、鉴权和网络边界
|
||||
|
||||
## 4. 需求与约束
|
||||
|
||||
### 4.1 需求清单
|
||||
|
||||
- 统一配置 JVM 连接
|
||||
- 探测当前 JVM 支持的接入模式与可用能力
|
||||
- 浏览缓存空间、管理对象和受控操作
|
||||
- 查看值快照与元数据
|
||||
- 执行受控修改,并提供 before/after 预览
|
||||
- 将操作结果写入审计记录
|
||||
- 支持 AI 对资源结构和修改方案进行分析
|
||||
|
||||
### 4.2 已确认约束
|
||||
|
||||
- 用户倾向通用型产品形态,但目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
|
||||
- 企业环境下,稳定性与安全性优先级高于“黑科技式通用能力”
|
||||
- 一期应优先基于标准协议和业务可控接入面,而不是侵入式 runtime 操作
|
||||
|
||||
## 5. 现状分析
|
||||
|
||||
### 5.1 GoNavi 架构启示
|
||||
|
||||
- `internal/db/database.go` 面向标准化数据源 CRUD,适合 SQL 类资源
|
||||
- `internal/app/methods_redis.go` 证明 GoNavi 已支持“独立运行时系统能力线”
|
||||
- `frontend/src/components/RedisViewer.tsx` 与 `frontend/src/components/RedisCommandEditor.tsx` 提供了树形浏览、结构化值编辑和控制台交互样板
|
||||
- `frontend/src/components/AIChatPanel.tsx` 与 `frontend/src/components/ai/AIMessageBubble.tsx` 已具备 AI 交互和危险执行确认能力
|
||||
|
||||
### 5.2 结论
|
||||
|
||||
JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector` 子系统,而不是复用 SQL `Database` 接口。
|
||||
|
||||
## 6. 方案比较
|
||||
|
||||
### 方案 A:单一路径通用 Agent
|
||||
|
||||
- 描述:统一要求目标 JVM 通过 `-javaagent` 或运行时 attach 暴露运行时对象访问能力
|
||||
- 优点:
|
||||
- 理论能力上限最高
|
||||
- 可覆盖更多自研缓存和深层对象
|
||||
- 缺点:
|
||||
- 与已知企业约束直接冲突
|
||||
- 风险最高,部署与安全成本高
|
||||
- 与首期产品化目标不匹配
|
||||
|
||||
### 方案 B:多接入模式 + 能力协商
|
||||
|
||||
- 描述:统一做 `JVM Connector`,底层同时支持 `JMX`、`Management Endpoint`、`Agent`
|
||||
- 优点:
|
||||
- 产品形态统一
|
||||
- 能根据目标 JVM 能力降级
|
||||
- 可先做低风险路径,后续再扩展高级模式
|
||||
- 缺点:
|
||||
- 不同模式能力不一致,UI 与权限模型更复杂
|
||||
|
||||
### 方案 C:只做业务侧管理端点
|
||||
|
||||
- 描述:完全放弃通用接入,只提供官方 Starter/管理端点接入
|
||||
- 优点:
|
||||
- 结构最稳,AI 最容易接入
|
||||
- 权限、审计、预览、回滚最好做
|
||||
- 缺点:
|
||||
- 不满足“尽量通用”的产品定位
|
||||
- 无法覆盖仅开放 JMX 的存量系统
|
||||
|
||||
## 7. 选型
|
||||
|
||||
采用方案 B。当前已落地:
|
||||
|
||||
- `JMX Provider`
|
||||
- `Management Endpoint Provider`
|
||||
- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent)
|
||||
|
||||
## 8. 目标架构
|
||||
|
||||
### 8.1 总体结构
|
||||
|
||||
新增统一的 `JVM Connector` 子系统,分为五层:
|
||||
|
||||
- `Connection Layer`
|
||||
- 新增 `jvm` 连接类型
|
||||
- 保存目标地址、认证、允许模式、首选模式、环境标签等配置
|
||||
- `Capability Layer`
|
||||
- 建立连接后探测当前支持的 provider 与能力矩阵
|
||||
- `Provider Layer`
|
||||
- `JMX Provider`
|
||||
- `Management Endpoint Provider`
|
||||
- `Agent Provider`(预留)
|
||||
- `Resource Layer`
|
||||
- 将不同来源统一映射为结构化资源
|
||||
- `Guard Layer`
|
||||
- 统一负责预览、确认、审计、回读验证、错误归一化
|
||||
|
||||
### 8.2 设计原则
|
||||
|
||||
- UI 统一,协议多态
|
||||
- 读写分离,修改必须经过 Guard Layer
|
||||
- provider 不得自行绕过权限与审计链路
|
||||
- 能力不足时显式降级,不提供“看似可用、实际不可执行”的假入口
|
||||
|
||||
## 9. Provider 设计
|
||||
|
||||
### 9.1 JMX Provider
|
||||
|
||||
- 负责:
|
||||
- 建立 JMX/RMI 连接
|
||||
- 发现 MBean
|
||||
- 读取属性
|
||||
- 调用白名单操作
|
||||
- 写入允许修改的白名单属性
|
||||
- 适用场景:
|
||||
- 目标 JVM 已开放 JMX
|
||||
- 缓存或管理对象已暴露为 MBean
|
||||
- 特点:
|
||||
- 低侵入、标准化、可落地
|
||||
- key/value 级资源能力通常有限
|
||||
|
||||
### 9.2 Management Endpoint Provider
|
||||
|
||||
- 负责:
|
||||
- 调用业务服务暴露的 GoNavi 管理端点或 Starter
|
||||
- 返回结构化缓存资源、元数据和受控动作
|
||||
- 提供修改预览与回滚信息
|
||||
- 适用场景:
|
||||
- 业务方愿意接入轻量 Starter/管理端点
|
||||
- 需要更强的 key/value 级浏览与修改能力
|
||||
- 特点:
|
||||
- 最适合产品化和 AI 协同
|
||||
- 权限、脱敏、审计、回滚最容易做
|
||||
|
||||
### 9.3 Agent Provider
|
||||
|
||||
- 负责:
|
||||
- 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口
|
||||
- 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力
|
||||
- 定位:
|
||||
- 高级模式
|
||||
- 不默认启用
|
||||
- 需要目标 Java 服务以 `-javaagent` 方式显式启动
|
||||
|
||||
## 10. 统一资源模型
|
||||
|
||||
建议统一抽象以下资源:
|
||||
|
||||
- `runtime`
|
||||
- 目标 JVM 实例
|
||||
- `cacheNamespace`
|
||||
- 缓存空间,如某个 CacheManager 下的 cacheName
|
||||
- `cacheEntry`
|
||||
- 具体缓存项 key/value
|
||||
- `managedBean`
|
||||
- 可读写的托管对象或 MBean
|
||||
- `operation`
|
||||
- 受控操作,如 `evict`、`put`、`refresh`、`clear`
|
||||
- `auditRecord`
|
||||
- 每次读写与 AI 建议的审计记录
|
||||
|
||||
统一资源模型要求:
|
||||
|
||||
- 每个资源都有稳定 ID、显示名、provider 来源、能力标签、敏感级别
|
||||
- 值快照必须区分原始值、展示值和可编辑值
|
||||
- 资源定位信息必须可写入审计
|
||||
|
||||
## 11. AI 协同设计
|
||||
|
||||
### 11.1 AI 的角色
|
||||
|
||||
AI 在 JVM 场景中只能作为“受控编排者”,不能作为直接执行者。
|
||||
|
||||
AI 可以:
|
||||
|
||||
- 解释缓存/Bean 的结构和当前状态
|
||||
- 生成筛选条件和定位建议
|
||||
- 生成结构化修改计划
|
||||
- 生成风险说明和回滚建议
|
||||
- 对执行前后结果做对比分析
|
||||
|
||||
AI 不应默认做:
|
||||
|
||||
- 直接执行 JVM 修改
|
||||
- 自由生成任意脚本并直写内存
|
||||
- 绕过人工确认直接调用 provider
|
||||
|
||||
### 11.2 AI 输出形态
|
||||
|
||||
AI 不直接输出脚本,而输出结构化变更计划,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"targetType": "cacheEntry",
|
||||
"selector": {
|
||||
"namespace": "userSessionCache",
|
||||
"key": "user:1001"
|
||||
},
|
||||
"action": "updateValue",
|
||||
"payload": {
|
||||
"format": "json",
|
||||
"value": {
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
},
|
||||
"reason": "修复错误缓存态"
|
||||
}
|
||||
```
|
||||
|
||||
### 11.3 AI 执行链路
|
||||
|
||||
1. AI 读取结构化上下文
|
||||
2. AI 产出结构化变更计划
|
||||
3. Guard Layer 校验目标资源、能力和权限
|
||||
4. UI 展示修改预览与风险提示
|
||||
5. 用户确认
|
||||
6. provider 执行
|
||||
7. 系统回读验证并写审计
|
||||
|
||||
### 11.4 一期 AI 边界
|
||||
|
||||
- 支持 AI 分析资源
|
||||
- 支持 AI 生成修改计划
|
||||
- 不默认支持 AI 自动执行修改
|
||||
|
||||
## 12. 页面与交互设计
|
||||
|
||||
### 12.1 连接层
|
||||
|
||||
在 `ConnectionModal` 中新增 `JVM` 类型,建议配置:
|
||||
|
||||
- 连接名称
|
||||
- 目标地址/端口
|
||||
- 认证信息
|
||||
- 允许模式列表
|
||||
- 首选模式
|
||||
- 环境标签(DEV/UAT/PROD)
|
||||
- 默认权限级别(只读/读写)
|
||||
|
||||
### 12.2 侧边栏
|
||||
|
||||
展示结构:
|
||||
|
||||
- 连接
|
||||
- 模式能力
|
||||
- 资源类型
|
||||
- `cacheNamespace` / `managedBean` / `operation`
|
||||
|
||||
每个连接或节点显示能力徽标,例如:
|
||||
|
||||
- `JMX`
|
||||
- `Endpoint`
|
||||
- `Agent`
|
||||
- `只读`
|
||||
- `可写`
|
||||
|
||||
### 12.3 主工作区 Tab
|
||||
|
||||
建议新增以下 Tab 类型:
|
||||
|
||||
- `概览`
|
||||
- `资源浏览`
|
||||
- `值检查器`
|
||||
- `修改预览`
|
||||
- `AI 助手`
|
||||
- `审计记录`
|
||||
|
||||
### 12.4 标准操作流
|
||||
|
||||
1. 用户连接 JVM
|
||||
2. 系统探测 provider 能力
|
||||
3. 用户选择资源并读取快照
|
||||
4. 用户手工修改或让 AI 生成计划
|
||||
5. 系统生成 before/after 预览
|
||||
6. 用户二次确认
|
||||
7. provider 执行
|
||||
8. 系统回读验证
|
||||
9. 写入审计与操作日志
|
||||
|
||||
## 13. 权限与审计
|
||||
|
||||
### 13.1 权限模型
|
||||
|
||||
权限建议分四层:
|
||||
|
||||
- `连接级`
|
||||
- 决定默认 `readonly` / `readwrite`
|
||||
- `模式级`
|
||||
- 决定某 provider 支持哪些动作
|
||||
- `资源级`
|
||||
- 某些资源永远只读
|
||||
- `环境级`
|
||||
- `PROD` 默认强制二次确认,禁用 AI 自动执行
|
||||
|
||||
### 13.2 审计要求
|
||||
|
||||
JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogPanel 样式。
|
||||
|
||||
建议记录:
|
||||
|
||||
- 连接 ID / 名称
|
||||
- provider 类型
|
||||
- 资源定位信息
|
||||
- 动作类型
|
||||
- 修改原因
|
||||
- AI 是否参与
|
||||
- 执行前摘要
|
||||
- 执行后摘要
|
||||
- 结果状态
|
||||
- 耗时
|
||||
- 错误信息
|
||||
|
||||
建议本地独立落盘为 `jvm_audit.jsonl` 或等价结构,不混入 `sqlLogs`。
|
||||
|
||||
## 14. 错误处理与兼容性边界
|
||||
|
||||
### 14.1 错误分层
|
||||
|
||||
- `连接层失败`
|
||||
- 认证失败、证书失败、JMX/RMI 不通、端点 401/403
|
||||
- `能力层失败`
|
||||
- 连接成功但不支持列 key、写值或批量操作
|
||||
- `执行层失败`
|
||||
- 资源不存在、值格式非法、provider 拒绝写入
|
||||
- `验证层失败`
|
||||
- 执行返回成功但回读校验不一致
|
||||
|
||||
所有错误都应显式标明是哪个 provider、哪一层失败,避免泛化为“修改失败”。
|
||||
|
||||
### 14.2 首期兼容性承诺
|
||||
|
||||
优先承诺以下边界:
|
||||
|
||||
- Java 8 / 11 / 17 / 21
|
||||
- Spring Boot 服务优先
|
||||
- JMX 标准 MBean
|
||||
- Management Endpoint 模式下优先支持:
|
||||
- Caffeine
|
||||
- Ehcache
|
||||
- Guava Cache
|
||||
- Spring Cache 抽象下可枚举缓存
|
||||
- 接入 GoNavi Starter 的自研缓存
|
||||
- 值类型首期优先:
|
||||
- string
|
||||
- number
|
||||
- boolean
|
||||
- JSON object / JSON array
|
||||
- map / list 的结构化展示
|
||||
|
||||
### 14.3 首期不承诺
|
||||
|
||||
- 任意 Java 对象深度反射编辑
|
||||
- 无类型信息的二进制对象直接改写
|
||||
- 跨 classloader 任意对象定位
|
||||
- 生产环境默认开放批量危险写入
|
||||
|
||||
## 15. MVP 分期
|
||||
|
||||
### Phase 1:连接与只读探测
|
||||
|
||||
- JVM 连接类型
|
||||
- JMX / Endpoint 能力探测
|
||||
- 资源树浏览
|
||||
- 值查看
|
||||
- 概览页与能力徽标
|
||||
- 不开放写入
|
||||
|
||||
### Phase 2:受控修改与审计
|
||||
|
||||
- 白名单资源写入
|
||||
- before/after 预览
|
||||
- 二次确认
|
||||
- 审计日志
|
||||
- 回读验证
|
||||
- 环境级保护策略
|
||||
|
||||
### Phase 3:AI 协同
|
||||
|
||||
- AI 解释资源
|
||||
- AI 生成修改计划
|
||||
- AI 风险分析
|
||||
- AI 回滚建议
|
||||
- 仍默认不允许 AI 自动执行
|
||||
|
||||
### Phase 4:高级模式
|
||||
|
||||
- Agent Provider
|
||||
- 预埋 Java Agent 的 runtime 资源治理能力
|
||||
- 仅在特殊环境启用
|
||||
|
||||
## 16. 验证策略
|
||||
|
||||
### 16.1 功能验证
|
||||
|
||||
- 能连接 JMX 目标
|
||||
- 能连接 Endpoint 目标
|
||||
- 能列出缓存空间
|
||||
- 能查看 key/value
|
||||
- 能完成受控修改并回读成功
|
||||
|
||||
### 16.2 兼容性验证
|
||||
|
||||
- Java 8 / 11 / 17 / 21
|
||||
- 本地、容器、K8s 内网场景
|
||||
- 开启认证 / 不开启认证
|
||||
- 仅 JMX、仅 Endpoint、双模式并存
|
||||
|
||||
### 16.3 安全验证
|
||||
|
||||
- 只读连接无法写入
|
||||
- `PROD` 环境必须二次确认
|
||||
- AI 无法绕过人工确认直接执行
|
||||
- 审计日志完整记录修改链路
|
||||
|
||||
### 16.4 稳定性验证
|
||||
|
||||
- 目标 JVM 不可达时 UI 不假死
|
||||
- 资源树大数量时支持分页或懒加载
|
||||
- 回读失败时标识“不确定状态”
|
||||
- provider 超时、部分失败、降级路径清晰
|
||||
|
||||
## 17. 风险与缓解
|
||||
|
||||
### 17.1 风险
|
||||
|
||||
- 多 provider 模式会带来能力不一致,用户可能误解“所有 JVM 都能随便改”
|
||||
- JMX 模式的 key/value 级能力可能明显不足
|
||||
- 管理端点模式需要业务接入,推广成本高于纯客户端方案
|
||||
- 若未来引入 Agent 模式,可能引入新的安全审核和兼容性成本
|
||||
|
||||
### 17.2 缓解
|
||||
|
||||
- 在 UI 中显式展示能力矩阵和当前 provider 来源
|
||||
- 所有修改都强制经过预览、确认与审计
|
||||
- 首期将“通用”定义为“统一入口 + 多模式协商”,而不是“单通道万能能力”
|
||||
- Agent 仅作为高级扩展位,避免污染 MVP 边界
|
||||
|
||||
## 18. 最终结论
|
||||
|
||||
JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采用“统一入口、多 provider、能力协商、强 Guard Layer”的产品化方案。
|
||||
|
||||
推荐结论如下:
|
||||
|
||||
- 新增独立的 `JVM Connector` 子系统
|
||||
- 首期支持 `JMX + Management Endpoint`
|
||||
- `Agent` 作为高级可选模式交付
|
||||
- AI 首期支持分析与生成修改计划,不默认开放自动执行
|
||||
- 所有修改必须经过预览、确认、审计和回读验证
|
||||
|
||||
这一路径能够在兼顾企业安全约束的前提下,为用户提供可持续演进的 JVM 运行时缓存治理能力。
|
||||
@@ -1,73 +0,0 @@
|
||||
# 需求进度追踪 - AI聊天发送快捷键
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:AI 聊天发送快捷键
|
||||
- 提出日期:2026-04-28
|
||||
- 负责人:Claude Code
|
||||
- 目标:将 AI 聊天发送快捷键纳入工具中心快捷键管理,支持录制自定义 Enter 相关组合键,降低输入法 Enter 上屏时误发送的风险。
|
||||
- 非目标:不调整后端 AI 服务配置,不改发送按钮行为,不把 AI 发送快捷键放在 AI 设置弹窗的独立入口。
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:工具中心快捷键管理、AI 聊天输入框、本地前端偏好持久化。
|
||||
- 验收标准:工具中心出现“AI 聊天发送”快捷键;默认 Enter 发送;可录制 Enter / Cmd+Enter / Ctrl+Enter / Alt+Enter 等 Enter 相关组合;普通字符键不可录制为 AI 发送;Shift+Enter 始终换行;输入法 composing 状态不发送;刷新后快捷键保持;AI 设置弹窗不再出现独立“聊天输入”快捷键入口。
|
||||
- 依赖与约束:沿用 Zustand `lite-db-storage` 中的 `shortcutOptions` 持久化;保持现有 AI 后端接口不变。
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认输入法 Enter 上屏导致误发送,需要支持录制自定义快捷键,并复用工具中心快捷键体系。
|
||||
- [x] 阶段 2(影响分析):影响工具中心快捷键配置、AIChatPanel、AIChatInput、store 和相关测试。
|
||||
- [x] 阶段 3(方案设计):采用共享 `shortcutOptions` action,AI 输入框局部消费,不走全局快捷键执行器。
|
||||
- [x] 阶段 4(实施计划):计划已按用户反馈调整为工具中心统一方案。
|
||||
- [x] 阶段 5(实现与自检):目标红灯测试已补充,新方案核心实现已完成。
|
||||
- [x] 阶段 6(评审与交付):已完成代码审查反馈修复、目标测试、全量测试、构建、diff 检查和浏览器手工验证。
|
||||
- [ ] 阶段 7(发布与观察):发布后观察用户输入法场景反馈。
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:新增工具中心 AI 发送 action 目标测试;实现 Enter 默认快捷键、Enter 组合录制规则、AI 输入框按 `shortcutOptions` 判定发送;移除 AI 设置独立入口;修复刷新后录制值被启动配置刷新覆盖的问题;限制 AI 发送快捷键只能录制 0 或 1 个修饰键的 Enter 组合;消费 AI 发送快捷键后阻止事件继续冒泡;更新 store、工具函数和输入框提示测试。
|
||||
- 进行中:无。
|
||||
- 待处理:发布后观察输入法场景反馈。
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:默认 Enter 发送在少数未标记 composing 的输入法中仍可能误发。
|
||||
- 阻塞:无。
|
||||
- 缓解措施:用户可在工具中心录制 Cmd+Enter / Ctrl+Enter / Alt+Enter,普通 Enter 不再触发发送;AI 发送录制限制为 Enter 相关组合并保留 Shift+Enter 换行;输入法 composing 状态始终不发送。
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。
|
||||
- 决策 2:`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。
|
||||
- 决策 3:AI 发送快捷键允许默认无修饰键 Enter,但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。
|
||||
- 决策 4:输入法 composing 状态始终不发送。
|
||||
- 决策 5:AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter,拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。
|
||||
- 决策 6:AI 输入框命中发送快捷键后同时执行 `preventDefault` 和 `stopPropagation`,避免事件继续冒泡到全局快捷键处理器。
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:初版两档下拉方案红灯测试。
|
||||
- 结果:已确认旧实现失败。
|
||||
- 证据:`aiChatSendShortcut.test.ts` 缺模块失败;`store.test.ts` 新增字段缺失失败;`AIChatInput.notice.test.tsx` placeholder 仍为 Enter 失败。
|
||||
- 验证项:工具中心统一方案红灯测试。
|
||||
- 结果:已确认旧实现失败。
|
||||
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts` 显示缺少 `sendAIChatMessage` action、`canRecordShortcutForAction` 和自定义 binding 判定失败;`src/store.test.ts` 显示 `shortcutOptions.sendAIChatMessage` 缺失;`src/components/ai/AIChatInput.notice.test.tsx` 显示 placeholder 未渲染 `Meta+Enter 发送`。
|
||||
- 验证项:工具中心统一方案目标绿灯测试。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts`(6 passed)、`src/components/ai/AIChatInput.notice.test.tsx`(2 passed)、`src/store.test.ts`(10 passed)。
|
||||
- 验证项:代码审查反馈红灯测试。
|
||||
- 结果:已确认旧实现失败。
|
||||
- 证据:多修饰键 Enter 组合被误放行、缺少 `consumeAIChatSendShortcutOnKeyDown`、脏持久化 `sendAIChatMessage: A` 未回退到 Enter。
|
||||
- 验证项:代码审查反馈修复后目标测试。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts src/components/ai/AIChatInput.notice.test.tsx src/store.test.ts`(3 files passed,22 tests passed)。
|
||||
- 验证项:浏览器手工验证。
|
||||
- 结果:已通过。
|
||||
- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持;AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送,Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送;Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。
|
||||
- 验证项:前端全量测试。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend test -- --run`(88 files passed,421 tests passed)。
|
||||
- 验证项:diff 空白检查。
|
||||
- 结果:已通过。
|
||||
- 证据:`git diff --check` 无输出。
|
||||
- 验证项:生产构建。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。
|
||||
- 负责人:Claude Code
|
||||
@@ -1,246 +0,0 @@
|
||||
# 需求进度追踪 - JVM缓存可视化编辑
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:JVM缓存可视化编辑
|
||||
- 提出日期:2026-04-22
|
||||
- 负责人:Codex
|
||||
- 目标:完成 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的通用能力交付,降低“改缓存只能写接口或重启应用”的运维与排障成本
|
||||
- 非目标:不承诺覆盖所有 Java 框架/所有对象类型,不绕过目标应用现有安全控制,不在首期开放脚本式任意表达式执行
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:
|
||||
- 交付 JVM 共享契约、连接配置、provider 注册、连接测试与能力探测
|
||||
- 交付 Endpoint / JMX / Agent 三种接入模式及其资源浏览、读值、预览、执行链路
|
||||
- 交付 JVM 资源页、预览弹窗、审计查看、AI 草稿生成与回填能力
|
||||
- 交付 Guard、审计、来源标记、真实集成测试与构建验证
|
||||
- 验收标准:
|
||||
- 可以在 GoNavi 中新增 JVM 连接并完成连接测试
|
||||
- 可以按资源树浏览 JVM 对象并查看结构化快照
|
||||
- 可以对支持写入的资源执行预览和确认写入,且带 Guard 与审计
|
||||
- 可以通过 AI 生成结构化修改草稿,但不会跳过人工确认直接执行
|
||||
- 可以通过真实 JMX 与真实 HTTP contract 完成端到端验证,并通过前后端构建回归
|
||||
- 依赖与约束:
|
||||
- 需复用 GoNavi 当前 Wails + React + driver-agent 架构
|
||||
- 新能力不得破坏现有数据库/Redis 工作流
|
||||
- 高风险写操作必须具备明确鉴权、审计与回滚思路
|
||||
- JMX 模式要求 GoNavi 运行机器本地可用 `java` 可执行文件
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):完成
|
||||
- [x] 阶段 2(影响分析):完成
|
||||
- [x] 阶段 3(方案设计):完成(已形成正式设计文档)
|
||||
- [x] 阶段 4(实施计划):完成(已形成正式实施计划)
|
||||
- [x] 阶段 5(实现与自检):完成(Task 1 至 Task 7 已完成,代码与构建回归通过)
|
||||
- [x] 阶段 6(评审与交付):完成(已完成契约复核、上下文隔离修正、文档回填与交付检查)
|
||||
- [ ] 阶段 7(发布与观察):未开始
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:
|
||||
- 确认 GoNavi 当前存在统一驱动接口与可选 driver-agent 机制
|
||||
- 确认前端已有 Redis 结构化浏览、命令编辑器、Monaco 编辑器、DataGrid 编辑能力可复用
|
||||
- 初步判断 JVM 运行时对象编辑不适合直接复用 SQL/Database 抽象,需新增非数据库协议层
|
||||
- 用户已确认目标方向为“通用型 JVM 接入”
|
||||
- 用户已确认升级到完整模式,开始高风险架构评估
|
||||
- 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
|
||||
- 已形成 JVM 缓存可视化编辑正式设计文档
|
||||
- 已形成 JVM Connector MVP 正式实施计划文档
|
||||
- 已完成 Task 1:JVM 共享契约与配置归一化
|
||||
- 已完成 Task 2:Provider 注册、连接测试与能力探测 API
|
||||
- 已完成 Task 3:JVM 连接表单、图标与展示文案接入
|
||||
- 已完成 Task 4:只读资源浏览与 JVM Tab
|
||||
- 已完成 Task 5:写入预览、Guard 和审计记录
|
||||
- 已完成 Task 6:AI 结构化变更计划
|
||||
- 已完成 Task 7:全量回归、文档回填与交付检查
|
||||
- 已完成 JVM AI 计划解析、资源定位解析、AI 计划到当前 JVM 变更草稿的显式映射,避免把 `payload.format/value` 包装层直接透传到现有 JVM 写入契约
|
||||
- 已完成 AI 聊天面板 JVM 上下文注入、AI 气泡“应用到 JVM 预览”入口以及 JVM 资源页草稿回填闭环
|
||||
- 已完成 JVM AI 计划来源上下文绑定:消息现在绑定生成时的 `tabId + connectionId + providerMode + resourcePath`,避免切换 JVM 页签后误投递到当前激活页
|
||||
- 已完成 Endpoint provider 真实 HTTP contract 与补测,支持资源浏览、读值、预览和执行
|
||||
- 已完成可手工启动的 Java Endpoint fixture 与真实集成补测,可直接验证 Endpoint 模式端到端行为
|
||||
- 已完成 JMX provider 真实 helper 接入与补测,支持 `domain -> mbean -> attribute/operation` 浏览、attribute `set`、operation `invoke`
|
||||
- 已完成 JMX helper 预编译 runtime jar 内嵌分发,运行时不再依赖仓库源码目录,也不再要求本地 `javac`
|
||||
- 已完成 JVM 快照动作提示与 payload 模板回填,前端可直接根据 `supportedActions` 生成草稿
|
||||
- 已完成 AI 参与来源写入 JVM 审计记录,审计页可区分“手工”与“AI 辅助”
|
||||
- 已完成 Agent provider、Agent 连接表单与概览展示,支持通过独立 Agent Base URL 接入 GoNavi Java Agent
|
||||
- 已完成真实 Java Agent fixture 与集成验证,可通过 `-javaagent` 方式真实验证 Agent 模式资源浏览、预览与执行
|
||||
- 已完成 JVM 收口优化:Endpoint 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义
|
||||
- 待处理:
|
||||
- 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:
|
||||
- 直接修改 JVM 内对象属于高风险运行时操作,误改可能造成业务状态污染
|
||||
- 不同缓存框架(Caffeine/Ehcache/Guava/自研 Map)缺少统一标准协议
|
||||
- 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性
|
||||
- 若目标 JVM 不允许预埋或动态注入 Agent,则“通用型”能力边界会明显收缩
|
||||
- 多接入模式会带来能力不一致问题,UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
|
||||
- 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力
|
||||
- 当前 AI 计划若只提供 `namespace + key`,仍更适合 endpoint/cache 风格资源;JMX 复杂 target 仍建议优先使用 `resourcePath`
|
||||
- JMX helper 已改为内嵌 jar 分发,但操作者机器仍需本地存在可用 `java`
|
||||
- Agent 模式要求目标 Java 服务显式以 `-javaagent` 方式启动 GoNavi Java Agent,并额外暴露管理端口
|
||||
- JMX operation preview 仅做参数/签名校验和预览快照,不预测真实副作用
|
||||
- JMX 参数转换当前覆盖基础类型、`ObjectName` 和部分数组;复杂对象写入仍是后续扩展项
|
||||
- 历史旧 AI 消息不包含 JVM 来源上下文,若需要应用到预览,需在目标 JVM 资源页重新生成计划
|
||||
- 阻塞:
|
||||
- 当前开发收口阶段无新增阻塞
|
||||
- 缓解措施:
|
||||
- 优先收敛到标准接入面(JMX / Spring Actuator / Java Agent 三选一)
|
||||
- 首期只支持白名单对象类型与受控写操作
|
||||
- 要求变更审计、预览、确认与失败回滚路径
|
||||
- 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入”
|
||||
- JMX helper 改为内嵌 runtime jar,默认写入用户缓存目录;必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath
|
||||
- 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:先做可行性评估与方案设计,不直接进入实现
|
||||
- 决策 2:默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程
|
||||
- 决策 3:已按完整模式推进,后续方案将优先评估通用 Agent 路径是否成立
|
||||
- 决策 4:由于目标服务大概率不允许 agent/attach,后续推荐方向转为“多接入模式 + 能力协商”
|
||||
- 决策 5:AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入
|
||||
- 决策 6:AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
|
||||
- 决策 7:当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
|
||||
- 决策 8:JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
|
||||
- 决策 9:JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java`
|
||||
- 决策 10:Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:
|
||||
- GoNavi 驱动代理机制核查
|
||||
- GoNavi 现有 Redis/编辑器/UI 复用能力核查
|
||||
- JVM Connector 正式设计文档自检
|
||||
- JVM Connector 实施计划文档自检
|
||||
- Task 1:JVM 共享契约与配置归一化
|
||||
- Task 2:Provider 注册、连接测试与能力探测 API
|
||||
- Task 6:AI 计划解析、资源定位解析、契约映射与页签上下文隔离
|
||||
- Task 7:Java Endpoint fixture 真实集成验证
|
||||
- Task 7:JMX helper 内嵌分发与运行时缓存验证
|
||||
- Task 7:Agent provider 与真实 Java Agent 集成验证
|
||||
- Task 7:后端全量测试
|
||||
- Task 7:前端全量测试
|
||||
- Task 7:前端生产构建
|
||||
- Task 7:Wails 生产构建
|
||||
- 结果:
|
||||
- 已确认存在可复用的连接桥接与编辑器基础设施
|
||||
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
|
||||
- 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节
|
||||
- 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测
|
||||
- Task 1 已完成规格审查与代码质量审查,结论均通过
|
||||
- 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API
|
||||
- Task 2 已完成规格审查与代码质量审查,结论均通过
|
||||
- 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入
|
||||
- Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题
|
||||
- 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点
|
||||
- Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题
|
||||
- 已完成 Task 5:后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力
|
||||
- Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底
|
||||
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
|
||||
- 已完成 Task 6:AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
|
||||
- 已完成 Task 6:AI 聊天消息与 JVM 来源页签绑定,AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
|
||||
- 已完成 Task 7:Java Endpoint fixture,可真实验证 `resources / value / preview / apply` 四个 endpoint contract
|
||||
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过
|
||||
- 已完成 Task 7:JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测
|
||||
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过
|
||||
- 已完成 Task 7:Agent provider、Java agent fixture 与真实 `-javaagent` 集成测试
|
||||
- `go test ./internal/jvm -run 'TestAgentProvider' -count=1` 通过
|
||||
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过(11 tests)
|
||||
- `go test ./... -count=1` 通过
|
||||
- `cd frontend && npm test -- --run` 通过(61 files,259 tests)
|
||||
- `cd frontend && npm run build` 通过;构建中存在既有 chunk size / dynamic import 警告,但未阻塞产物生成
|
||||
- `wails build -clean` 通过,成功生成 macOS 应用包
|
||||
- 已完成 JVM 收口优化:模式能力探测现在按当前 mode 做业务化错误翻译,避免概览页继续回显 `non-JRMP server`、`baseURL is required` 这类原始报错
|
||||
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 再次通过(Endpoint 能力探测只读语义回归)
|
||||
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` 再次通过(能力探测模式透传与中文错误翻译回归)
|
||||
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` 通过(JVM 资源页布局回归)
|
||||
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` 通过(风险等级、审计结果等本地化展示回归)
|
||||
- `cd frontend && npm run build` 再次通过
|
||||
- `wails build -clean` 再次通过,成功生成最新可验收桌面包
|
||||
- 证据(日志/截图/链接):
|
||||
- `cmd/optional-driver-agent/main.go`
|
||||
- `internal/db/database.go`
|
||||
- `frontend/src/components/RedisViewer.tsx`
|
||||
- `frontend/src/components/RedisCommandEditor.tsx`
|
||||
- `frontend/src/components/QueryEditor.tsx`
|
||||
- `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md`
|
||||
- `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`
|
||||
- `internal/connection/types.go`
|
||||
- `internal/jvm/types.go`
|
||||
- `internal/jvm/config.go`
|
||||
- `internal/jvm/config_test.go`
|
||||
- `frontend/src/types.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.test.ts`
|
||||
- `go test ./internal/jvm -count=1`
|
||||
- `go test ./...`
|
||||
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
|
||||
- `cd frontend && npm test -- --run`
|
||||
- `cd frontend && npm run build`
|
||||
- `internal/jvm/provider.go`
|
||||
- `internal/jvm/jmx_provider.go`
|
||||
- `internal/jvm/http_provider.go`
|
||||
- `internal/jvm/http_provider_test.go`
|
||||
- `internal/jvm/jmx_helper.go`
|
||||
- `internal/jvm/jmx_helper_test.go`
|
||||
- `internal/jvm/provider_contract_test.go`
|
||||
- `internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar`
|
||||
- `internal/jvm/jmxhelper_assets/README.md`
|
||||
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java`
|
||||
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java`
|
||||
- `tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java`
|
||||
- `internal/app/methods_jvm.go`
|
||||
- `internal/app/methods_jvm_test.go`
|
||||
- `frontend/wailsjs/go/app/App.d.ts`
|
||||
- `frontend/wailsjs/go/app/App.js`
|
||||
- `frontend/wailsjs/go/models.ts`
|
||||
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1`
|
||||
- `go test ./internal/jvm ./internal/app -count=1`
|
||||
- `wails build -clean`
|
||||
- `frontend/src/components/DatabaseIcons.tsx`
|
||||
- `frontend/src/components/ConnectionModal.tsx`
|
||||
- `frontend/src/utils/jvmRuntimePresentation.ts`
|
||||
- `frontend/src/utils/jvmRuntimePresentation.test.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.test.ts`
|
||||
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
|
||||
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
|
||||
- `cd frontend && npm run build`
|
||||
- `internal/app/methods_jvm.go`
|
||||
- `internal/app/methods_jvm_test.go`
|
||||
- `frontend/src/components/Sidebar.tsx`
|
||||
- `frontend/src/components/TabManager.tsx`
|
||||
- `frontend/src/components/JVMOverview.tsx`
|
||||
- `frontend/src/components/JVMResourceBrowser.tsx`
|
||||
- `frontend/src/components/jvm/JVMModeBadge.tsx`
|
||||
- `frontend/src/store.ts`
|
||||
- `frontend/src/types.ts`
|
||||
- `go test ./internal/app -run 'TestJVM(ListResources|GetValue)' -count=1`
|
||||
- `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1`
|
||||
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
|
||||
- `cd frontend && npm run build`
|
||||
- `internal/jvm/guard.go`
|
||||
- `internal/jvm/guard_test.go`
|
||||
- `internal/jvm/audit_store.go`
|
||||
- `internal/jvm/audit_store_test.go`
|
||||
- `internal/app/methods_jvm.go`
|
||||
- `internal/app/methods_jvm_test.go`
|
||||
- `frontend/src/components/JVMAuditViewer.tsx`
|
||||
- `frontend/src/components/jvm/JVMChangePreviewModal.tsx`
|
||||
- `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1`
|
||||
- `go test ./internal/jvm ./internal/app -count=1`
|
||||
- `cd frontend && npm run build`
|
||||
- `frontend/src/utils/jvmAiPlan.ts`
|
||||
- `frontend/src/utils/jvmAiPlan.test.ts`
|
||||
- `frontend/src/components/AIChatPanel.tsx`
|
||||
- `frontend/src/components/ai/AIMessageBubble.tsx`
|
||||
- `frontend/src/components/JVMResourceBrowser.tsx`
|
||||
- `frontend/src/types.ts`
|
||||
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts`
|
||||
- `go test ./... -count=1`
|
||||
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1`
|
||||
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1`
|
||||
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx`
|
||||
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts`
|
||||
- `cd frontend && npm test -- --run`
|
||||
- `wails build -clean`
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入
|
||||
- 负责人:Codex
|
||||
@@ -1,24 +0,0 @@
|
||||
# SQL 方言适配需求进度追踪
|
||||
|
||||
## 背景
|
||||
|
||||
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
|
||||
- GitHub 相关问题:Refs #402(金仓字段类型/DDL 方言)、Refs #409(Oracle 删除数据 DATE 字面量)。
|
||||
|
||||
## 范围
|
||||
|
||||
- 表设计 ALTER TABLE 预览:按 MySQL-family、PostgreSQL-family、Oracle/Dameng、SQL Server、SQLite、DuckDB、ClickHouse、TDengine 分支生成。
|
||||
- 新建表 DDL 预览:避免 Oracle/Dameng/SQL Server/SQLite/DuckDB/ClickHouse/TDengine 输出 MySQL 表选项。
|
||||
- SQL 自动补全:按当前连接方言解析关键字和函数,避免 Oracle/SQL Server 出现 MySQL-only 提示。
|
||||
- 表设计字段类型:按数据源给出候选类型,不再大量回退到 MySQL 通用类型。
|
||||
- Oracle/Dameng 数据复制/删除 SQL:DATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
|
||||
- `npm run build`
|
||||
|
||||
## 风险与后续
|
||||
|
||||
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER,并用中文注释阻止 MySQL 专属子句外溢。
|
||||
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。
|
||||
@@ -1,71 +0,0 @@
|
||||
# 需求进度追踪 - 发布脚本测试版号与 Mac 打包无交互
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:发布脚本测试版号与 Mac 打包无交互
|
||||
- 提出日期:2026-04-24
|
||||
- 负责人:Codex
|
||||
- 目标:
|
||||
- `build-release.sh` 不再触发 macOS DMG/Finder 排版交互。
|
||||
- `build-release.sh` 与开发态应用内版本号统一使用测试版号来源。
|
||||
- 非目标:
|
||||
- 不调整 GitHub Release 工作流。
|
||||
- 不修改正式发布 tag 版本策略。
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:
|
||||
- 发布脚本 `build-release.sh`
|
||||
- 版本解析逻辑 `internal/app/version.go`
|
||||
- 共享测试版号文件
|
||||
- 验收标准:
|
||||
- `bash build-release.sh` 的 macOS 打包不再调用 `create-dmg` 或触发 Finder 排版。
|
||||
- 本地开发态版本显示与发布脚本默认版本号一致。
|
||||
- 保留环境变量覆盖版本号能力。
|
||||
- 依赖与约束:
|
||||
- 维持现有 Windows/Linux 构建逻辑不变。
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认去掉 DMG 排版,统一测试版号来源
|
||||
- [x] 阶段 2(影响分析):锁定 `build-release.sh` 与 `internal/app/version.go`
|
||||
- [x] 阶段 3(方案设计):共享 `version/dev-version.txt`,macOS 改 ZIP 打包
|
||||
- [x] 阶段 4(实施计划):先补版本回归测试,再改实现
|
||||
- [ ] 阶段 5(实现与自检):
|
||||
- [ ] 阶段 6(评审与交付):
|
||||
- [ ] 阶段 7(发布与观察):
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:
|
||||
- 新增共享测试版号文件。
|
||||
- 新增版本回归测试。
|
||||
- 改造发布脚本 macOS 打包为无交互 ZIP。
|
||||
- 进行中:
|
||||
- 自检验证。
|
||||
- 待处理:
|
||||
- 无。
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:
|
||||
- 正式发版若未覆盖 `GONAVI_VERSION`,默认会使用测试版号。
|
||||
- 阻塞:
|
||||
- 无。
|
||||
- 缓解措施:
|
||||
- 允许通过 `GONAVI_VERSION` 环境变量显式覆盖。
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:以 `version/dev-version.txt` 作为本地开发/测试共享版本号来源。
|
||||
- 决策 2:发布脚本的 macOS 产物改为 ZIP,避免 `create-dmg` 的 Finder 交互。
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:
|
||||
- 版本回归测试
|
||||
- 发布脚本语法检查
|
||||
- 发布脚本运行输出
|
||||
- 结果:
|
||||
- 进行中
|
||||
- 证据(日志/截图/链接):
|
||||
- 待补充
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:
|
||||
- 跑通回归测试和脚本验证,确认输出产物与版本号
|
||||
- 负责人:
|
||||
- Codex
|
||||
@@ -1 +1 @@
|
||||
0295a42fd931778d85157816d79d29e5
|
||||
d0464f9da25e9356e61652e638c99ffe
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGrid from './DataGrid';
|
||||
import DataGrid, { formatCellDisplayText } from './DataGrid';
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
@@ -83,6 +83,10 @@ describe('DataGrid layout', () => {
|
||||
expect(markup).toContain('当前页查找...');
|
||||
});
|
||||
|
||||
it('preserves fractional seconds when rendering datetime values', () => {
|
||||
expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456');
|
||||
});
|
||||
|
||||
it('renders a DDL action for table data pages only', () => {
|
||||
const tableMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
|
||||
@@ -23,13 +23,13 @@ import {
|
||||
arrayMove
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||
import { ImportData, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||
import ImportPreviewModal from './ImportPreviewModal';
|
||||
import { useStore } from '../store';
|
||||
import type { ColumnDefinition, IndexDefinition } from '../types';
|
||||
import { v4 as generateUuid } from 'uuid';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
@@ -51,6 +51,11 @@ import {
|
||||
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
|
||||
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
|
||||
import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard';
|
||||
import {
|
||||
buildDataGridSelectBaseSql,
|
||||
pickDataGridOutputRows,
|
||||
resolveDataGridOutputColumnNames,
|
||||
} from './dataGridOutput';
|
||||
import {
|
||||
buildClipboardCsv,
|
||||
buildClipboardJson,
|
||||
@@ -181,7 +186,7 @@ const looksLikeDateTimeText = (val: string): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing.
|
||||
// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss[.fraction]` for display/editing.
|
||||
// Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`.
|
||||
// Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged.
|
||||
const normalizeDateTimeString = (val: string) => {
|
||||
@@ -200,16 +205,16 @@ const normalizeDateTimeString = (val: string) => {
|
||||
}
|
||||
|
||||
const match = val.match(
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
);
|
||||
const normalized = match ? `${match[1]} ${match[2]}` : val;
|
||||
const normalized = match ? `${match[1]} ${match[2]}${match[3] || ''}` : val;
|
||||
trimSimpleCache(normalizedDateTimeCache, DATE_TIME_CACHE_LIMIT);
|
||||
normalizedDateTimeCache.set(val, normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
// --- Helper: Format Value ---
|
||||
const formatCellDisplayText = (val: any): string => {
|
||||
export const formatCellDisplayText = (val: any): string => {
|
||||
try {
|
||||
if (val === null) return 'NULL';
|
||||
if (typeof val === 'object') {
|
||||
@@ -1079,7 +1084,7 @@ const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverf
|
||||
const VIRTUAL_CELL_WRAPPER_STYLE: React.CSSProperties = { margin: -8, padding: '8px 8px 8px 8px' };
|
||||
|
||||
const DataGrid: React.FC<DataGridProps> = ({
|
||||
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
|
||||
data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
|
||||
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
|
||||
onApplyQuickWhereCondition,
|
||||
scrollSnapshot, onScrollSnapshotChange
|
||||
@@ -1206,6 +1211,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setDisplayColumnNames(allOrderedColumnNames.filter(col => !hiddenSet.has(col)));
|
||||
}, [allOrderedColumnNames, localHiddenColumns]);
|
||||
|
||||
const displayOutputColumnNames = useMemo(
|
||||
() => resolveDataGridOutputColumnNames(
|
||||
displayColumnNames.length > 0 || allOrderedColumnNames.length > 0 ? displayColumnNames : visibleColumnNames,
|
||||
GONAVI_ROW_KEY,
|
||||
),
|
||||
[displayColumnNames, allOrderedColumnNames, visibleColumnNames]
|
||||
);
|
||||
|
||||
// Handle Dragging
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
@@ -1510,15 +1523,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const exportData = async (rows: any[], format: string) => {
|
||||
const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0);
|
||||
try {
|
||||
const cleanRows = rows.map((row) => {
|
||||
const next: Record<string, any> = {};
|
||||
displayColumnNames.forEach((columnName) => {
|
||||
next[columnName] = row?.[columnName];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
const cleanRows = pickDataGridOutputRows(rows, displayOutputColumnNames);
|
||||
// Pass tableName (or 'export') as default filename
|
||||
const res = await ExportData(cleanRows, displayColumnNames, tableName || 'export', format);
|
||||
const res = await ExportData(cleanRows, displayOutputColumnNames, tableName || 'export', format);
|
||||
if (res.success) {
|
||||
void message.success("导出成功");
|
||||
} else if (res.message !== "已取消") {
|
||||
@@ -3435,26 +3442,15 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const jsonViewText = useMemo(() => {
|
||||
if (viewMode !== 'json') return '';
|
||||
const cleanRows = mergedDisplayData.map((row) => {
|
||||
const next: Record<string, any> = {};
|
||||
visibleColumnNames.forEach((columnName) => {
|
||||
next[columnName] = row?.[columnName];
|
||||
});
|
||||
return normalizeValueForJsonView(next);
|
||||
});
|
||||
const cleanRows = pickDataGridOutputRows(mergedDisplayData, displayOutputColumnNames)
|
||||
.map((row) => normalizeValueForJsonView(row));
|
||||
return JSON.stringify(cleanRows, null, 2);
|
||||
}, [viewMode, mergedDisplayData, visibleColumnNames]);
|
||||
}, [viewMode, mergedDisplayData, displayOutputColumnNames]);
|
||||
|
||||
const textViewRows = useMemo(() => {
|
||||
if (viewMode !== 'text') return [];
|
||||
return mergedDisplayData.map((row) => {
|
||||
const next: Record<string, any> = {};
|
||||
visibleColumnNames.forEach((columnName) => {
|
||||
next[columnName] = row?.[columnName];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [viewMode, mergedDisplayData, visibleColumnNames]);
|
||||
return pickDataGridOutputRows(mergedDisplayData, displayOutputColumnNames);
|
||||
}, [viewMode, mergedDisplayData, displayOutputColumnNames]);
|
||||
|
||||
const currentTextRow = useMemo(() => {
|
||||
if (viewMode !== 'text') return null;
|
||||
@@ -3919,7 +3915,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const copiedRows = buildCopiedRowsForPaste({
|
||||
rows: mergedDisplayData as Array<Record<string, any>>,
|
||||
selectedRowKeys,
|
||||
columnNames: visibleColumnNames,
|
||||
columnNames: displayOutputColumnNames,
|
||||
rowKeyField: GONAVI_ROW_KEY,
|
||||
rowKeyToString: rowKeyStr,
|
||||
});
|
||||
@@ -3930,7 +3926,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
setCopiedRowsForPaste(copiedRows);
|
||||
void message.success(`已复制 ${copiedRows.length} 行,可粘贴为新增行`);
|
||||
}, [selectedRowKeys, mergedDisplayData, visibleColumnNames, rowKeyStr]);
|
||||
}, [selectedRowKeys, mergedDisplayData, displayOutputColumnNames, rowKeyStr]);
|
||||
|
||||
const handlePasteCopiedRowsAsNew = useCallback(() => {
|
||||
if (copiedRowsForPaste.length === 0) {
|
||||
@@ -3940,7 +3936,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const nextRows = buildPastedRowsFromCopiedRows({
|
||||
rows: copiedRowsForPaste,
|
||||
columnNames: visibleColumnNames,
|
||||
columnNames: displayOutputColumnNames,
|
||||
rowKeyField: GONAVI_ROW_KEY,
|
||||
createRowKey: (index) => {
|
||||
pastedRowSequenceRef.current += 1;
|
||||
@@ -3956,7 +3952,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setAddedRows(prev => [...prev, ...nextRows]);
|
||||
setSelectedRowKeys(nextRows.map(row => row[GONAVI_ROW_KEY]));
|
||||
void message.success(`已粘贴 ${nextRows.length} 行为新增行,请检查后提交事务`);
|
||||
}, [copiedRowsForPaste, visibleColumnNames]);
|
||||
}, [copiedRowsForPaste, displayOutputColumnNames]);
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
setDeletedRowKeys(prev => {
|
||||
@@ -4050,16 +4046,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
pickRowsForClipboard({
|
||||
rows: mergedDisplayData as Array<Record<string, unknown>>,
|
||||
selectedRowKeys,
|
||||
columnNames: visibleColumnNames,
|
||||
columnNames: displayOutputColumnNames,
|
||||
rowKeyField: GONAVI_ROW_KEY,
|
||||
rowKeyToString: rowKeyStr,
|
||||
})
|
||||
), [mergedDisplayData, selectedRowKeys, visibleColumnNames, rowKeyStr]);
|
||||
), [mergedDisplayData, selectedRowKeys, displayOutputColumnNames, rowKeyStr]);
|
||||
|
||||
const getClipboardColumnNames = useCallback((rows: Array<Record<string, unknown>>) => {
|
||||
if (rows.length === 0) return [];
|
||||
return visibleColumnNames.filter((columnName) => columnName !== GONAVI_ROW_KEY);
|
||||
}, [visibleColumnNames]);
|
||||
return displayOutputColumnNames;
|
||||
}, [displayOutputColumnNames]);
|
||||
|
||||
const handleCopyQueryResultCsv = useCallback(() => {
|
||||
const rows = getClipboardRows();
|
||||
@@ -4199,7 +4195,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return null;
|
||||
}
|
||||
const records = getTargets(record);
|
||||
const orderedCols = visibleColumnNames.filter(c => c !== GONAVI_ROW_KEY);
|
||||
const orderedCols = displayOutputColumnNames;
|
||||
if (mode === 'insert') {
|
||||
return records.map((row: any) => buildCopyInsertSQL({
|
||||
dbType,
|
||||
@@ -4248,7 +4244,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}, [
|
||||
supportsCopyInsert,
|
||||
getTargets,
|
||||
visibleColumnNames,
|
||||
displayOutputColumnNames,
|
||||
dbType,
|
||||
tableName,
|
||||
columnTypeMapByLowerName,
|
||||
@@ -4277,19 +4273,13 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const handleCopyJson = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const cleanRecords = records.map((r: any) => {
|
||||
const next: Record<string, any> = {};
|
||||
visibleColumnNames.forEach((columnName) => {
|
||||
next[columnName] = r?.[columnName];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
const cleanRecords = pickDataGridOutputRows(records, displayOutputColumnNames);
|
||||
copyToClipboard(JSON.stringify(cleanRecords, null, 2));
|
||||
}, [getTargets, visibleColumnNames, copyToClipboard]);
|
||||
}, [getTargets, displayOutputColumnNames, copyToClipboard]);
|
||||
|
||||
const handleCopyCsv = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const orderedCols = visibleColumnNames.filter(c => c !== GONAVI_ROW_KEY);
|
||||
const orderedCols = displayOutputColumnNames;
|
||||
const header = orderedCols.map(c => `"${c}"`).join(',');
|
||||
const lines = records.map((r: any) => {
|
||||
const values = orderedCols.map(c => {
|
||||
@@ -4302,7 +4292,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return values.join(',');
|
||||
});
|
||||
copyToClipboard([header, ...lines].join('\n'));
|
||||
}, [getTargets, visibleColumnNames, copyToClipboard]);
|
||||
}, [getTargets, displayOutputColumnNames, copyToClipboard]);
|
||||
|
||||
const buildConnConfig = useCallback(() => {
|
||||
if (!connectionId) return null;
|
||||
@@ -4362,7 +4352,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (!tableName || !pagination) return '';
|
||||
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
|
||||
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
|
||||
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
const baseSql = buildDataGridSelectBaseSql({
|
||||
dbType,
|
||||
tableName,
|
||||
columnNames: displayOutputColumnNames,
|
||||
whereSql: whereSQL,
|
||||
});
|
||||
const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||||
const hasSortForBuffer = hasExplicitSort(sortInfo);
|
||||
@@ -4372,7 +4367,36 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
|
||||
}
|
||||
return sql;
|
||||
}, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
|
||||
}, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]);
|
||||
|
||||
const buildAllRowsSql = useCallback((dbType: string) => {
|
||||
if (!tableName) return '';
|
||||
return buildDataGridSelectBaseSql({
|
||||
dbType,
|
||||
tableName,
|
||||
columnNames: displayOutputColumnNames,
|
||||
});
|
||||
}, [tableName, displayOutputColumnNames]);
|
||||
|
||||
const buildFilteredAllSql = useCallback((dbType: string) => {
|
||||
if (!tableName) return '';
|
||||
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
|
||||
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
|
||||
if (!whereSQL) return '';
|
||||
let sql = buildDataGridSelectBaseSql({
|
||||
dbType,
|
||||
tableName,
|
||||
columnNames: displayOutputColumnNames,
|
||||
whereSql: whereSQL,
|
||||
});
|
||||
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||||
const hasSortForBuffer = hasExplicitSort(sortInfo);
|
||||
if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
|
||||
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
|
||||
}
|
||||
return sql;
|
||||
}, [tableName, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]);
|
||||
|
||||
// Context Menu Export
|
||||
const handleExportSelected = useCallback(async (format: string, record: any) => {
|
||||
@@ -4406,9 +4430,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} WHERE ${pkWhere}`;
|
||||
const sql = buildDataGridSelectBaseSql({
|
||||
dbType,
|
||||
tableName,
|
||||
columnNames: displayOutputColumnNames,
|
||||
whereSql: `WHERE ${pkWhere}`,
|
||||
});
|
||||
await exportByQuery(sql, format, tableName || 'export');
|
||||
}, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery]);
|
||||
}, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery, displayOutputColumnNames]);
|
||||
|
||||
// Export
|
||||
const handleExport = async (format: string) => {
|
||||
@@ -4423,12 +4452,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
// 查询结果页导出统一按当前结果集(已加载数据)导出,避免再次执行原 SQL 造成大数据导出或长时间阻塞。
|
||||
if (isQueryResultExport) {
|
||||
const sql = String(resultSql || '').trim();
|
||||
if (!hasChanges && supportsSqlQueryExport && sql) {
|
||||
await exportByQuery(sql, format, tableName || 'query_result');
|
||||
} else {
|
||||
await exportData(mergedDisplayData, format);
|
||||
}
|
||||
await exportData(mergedDisplayData, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4440,19 +4464,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (!tableName) return;
|
||||
const config = buildConnConfig();
|
||||
if (!config) return;
|
||||
const hide = message.loading(`正在导出全部数据...`, 0);
|
||||
try {
|
||||
const res = await ExportTable(buildRpcConnectionConfig(config) as any, dbName || '', tableName, format);
|
||||
if (res.success) {
|
||||
void message.success("导出成功");
|
||||
} else if (res.message !== "已取消") {
|
||||
void message.error("导出失败: " + res.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
void message.error("导出失败: " + (e?.message || String(e)));
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
const sql = buildAllRowsSql(resolveDataSourceType(config));
|
||||
if (!sql) return;
|
||||
await exportByQuery(sql, format, tableName || 'export');
|
||||
};
|
||||
const handlePage = async () => {
|
||||
instance.destroy();
|
||||
@@ -4505,11 +4519,18 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
void message.error('当前数据源不支持按筛选结果导出');
|
||||
return;
|
||||
}
|
||||
const config = buildConnConfig();
|
||||
if (!config) return;
|
||||
if (hasChanges) {
|
||||
void message.warning("当前存在未提交修改,筛选结果导出基于数据库已提交数据。");
|
||||
}
|
||||
|
||||
await exportByQuery(filteredExportSql, format, `${tableName || 'export'}_filtered`);
|
||||
const sql = buildFilteredAllSql(resolveDataSourceType(config));
|
||||
if (!sql) {
|
||||
void message.warning('当前未应用筛选条件');
|
||||
return;
|
||||
}
|
||||
await exportByQuery(sql, format, `${tableName || 'export'}_filtered`);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
@@ -4655,7 +4676,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
{ key: 'json', label: 'JSON', onClick: handleCopyQueryResultJson },
|
||||
{ key: 'markdown', label: 'Markdown', onClick: handleCopyQueryResultMarkdown },
|
||||
];
|
||||
const canCopyQueryResult = isQueryResultExport && mergedDisplayData.length > 0 && visibleColumnNames.length > 0;
|
||||
const canCopyQueryResult = isQueryResultExport && mergedDisplayData.length > 0 && displayOutputColumnNames.length > 0;
|
||||
|
||||
const columnInfoSettingContent = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 200, maxWidth: 300 }}>
|
||||
@@ -6139,7 +6160,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
|
||||
{currentTextRow ? displayColumnNames.map((col) => (
|
||||
{currentTextRow ? displayOutputColumnNames.map((col) => (
|
||||
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
|
||||
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)', wordBreak: 'break-all' }}>
|
||||
{col} :
|
||||
|
||||
@@ -37,4 +37,19 @@ describe('dataGridClipboardExport', () => {
|
||||
|
||||
expect(rows).toEqual([{ total: 2 }]);
|
||||
});
|
||||
|
||||
it('keeps copied row fields in the provided display column order', () => {
|
||||
const rows = pickRowsForClipboard({
|
||||
rows: [
|
||||
{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
|
||||
],
|
||||
selectedRowKeys: [],
|
||||
columnNames: ['name', 'id'],
|
||||
rowKeyField: '__gonavi_row_key__',
|
||||
});
|
||||
|
||||
expect(Object.keys(rows[0])).toEqual(['name', 'id']);
|
||||
expect(buildClipboardCsv(rows, ['name', 'id'])).toBe('"name","id"\n"alpha","1"');
|
||||
expect(buildClipboardJson(rows)).toBe('[\n {\n "name": "alpha",\n "id": 1\n }\n]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,38 @@ describe('buildCopyInsertSQL', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves fractional seconds for MySQL datetime precision columns', () => {
|
||||
const sql = buildCopyInsertSQL({
|
||||
dbType: 'mysql',
|
||||
tableName: 'events',
|
||||
orderedCols: ['created_at'],
|
||||
record: {
|
||||
created_at: '2026-05-10T09:12:33.456+08:00',
|
||||
},
|
||||
columnTypesByLowerName: {
|
||||
created_at: 'datetime(3)',
|
||||
},
|
||||
});
|
||||
|
||||
expect(sql).toBe(
|
||||
"INSERT INTO `events` (`created_at`) VALUES ('2026-05-10 09:12:33.456');",
|
||||
);
|
||||
});
|
||||
|
||||
it('uses ordered columns for copy-as-insert output', () => {
|
||||
const sql = buildCopyInsertSQL({
|
||||
dbType: 'mysql',
|
||||
tableName: 'users',
|
||||
orderedCols: ['name', 'id'],
|
||||
record: {
|
||||
id: 7,
|
||||
name: 'Ada',
|
||||
},
|
||||
});
|
||||
|
||||
expect(sql).toBe("INSERT INTO `users` (`name`, `id`) VALUES ('Ada', '7');");
|
||||
});
|
||||
|
||||
it('keeps RFC3339-looking text unchanged for non-temporal columns', () => {
|
||||
const sql = buildCopyInsertSQL({
|
||||
dbType: 'postgres',
|
||||
|
||||
@@ -51,9 +51,9 @@ const normalizeDateTimeString = (val: string): string => {
|
||||
}
|
||||
|
||||
const match = val.match(
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
);
|
||||
return match ? `${match[1]} ${match[2]}` : val;
|
||||
return match ? `${match[1]} ${match[2]}${match[3] || ''}` : val;
|
||||
};
|
||||
|
||||
const normalizeTimezoneAwareDateTimeString = (val: string): string => {
|
||||
@@ -66,13 +66,14 @@ const normalizeTimezoneAwareDateTimeString = (val: string): string => {
|
||||
}
|
||||
|
||||
const match = val.match(
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
);
|
||||
if (!match) {
|
||||
return val;
|
||||
}
|
||||
const suffix = match[3] || '';
|
||||
return `${match[1]} ${match[2]}${suffix}`;
|
||||
const fractional = match[3] || '';
|
||||
const suffix = match[4] || '';
|
||||
return `${match[1]} ${match[2]}${fractional}${suffix}`;
|
||||
};
|
||||
|
||||
const isTemporalColumnType = (columnType?: string): boolean => {
|
||||
@@ -165,22 +166,36 @@ const toNormalizedLiteralText = (value: any, columnType?: string): string => {
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const hasFractionalSeconds = (value: string): boolean => /\d{2}:\d{2}:\d{2}\.\d+/.test(value);
|
||||
|
||||
const stripFractionalSeconds = (value: string): string => (
|
||||
value.replace(/(\d{2}:\d{2}:\d{2})\.\d+/, '$1')
|
||||
);
|
||||
|
||||
const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => {
|
||||
if (!isTemporalColumnType(columnType)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = toNormalizedLiteralText(value, columnType);
|
||||
const escaped = escapeLiteral(normalized);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
const rawType = String(columnType || '').toLowerCase();
|
||||
const isTimestamp = rawType.includes('timestamp');
|
||||
const oracleValue = isTimestamp ? normalized : stripFractionalSeconds(normalized);
|
||||
const escaped = escapeLiteral(oracleValue);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(oracleValue)) {
|
||||
return `TO_DATE('${escaped}', 'YYYY-MM-DD')`;
|
||||
}
|
||||
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) {
|
||||
const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
|
||||
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`;
|
||||
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(oracleValue)) {
|
||||
const compactOffset = oracleValue.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
|
||||
const temporalFormat = hasFractionalSeconds(oracleValue)
|
||||
? 'YYYY-MM-DD HH24:MI:SS.FFTZH:TZM'
|
||||
: 'YYYY-MM-DD HH24:MI:SSTZH:TZM';
|
||||
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', '${temporalFormat}')`;
|
||||
}
|
||||
const rawType = String(columnType || '').toLowerCase();
|
||||
if (rawType.includes('timestamp')) {
|
||||
return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
|
||||
if (isTimestamp) {
|
||||
const temporalFormat = hasFractionalSeconds(oracleValue)
|
||||
? 'YYYY-MM-DD HH24:MI:SS.FF'
|
||||
: 'YYYY-MM-DD HH24:MI:SS';
|
||||
return `TO_TIMESTAMP('${escaped}', '${temporalFormat}')`;
|
||||
}
|
||||
return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
|
||||
};
|
||||
|
||||
37
frontend/src/components/dataGridOutput.test.ts
Normal file
37
frontend/src/components/dataGridOutput.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildDataGridSelectBaseSql,
|
||||
pickDataGridOutputRows,
|
||||
resolveDataGridOutputColumnNames,
|
||||
} from './dataGridOutput';
|
||||
|
||||
const rowKeyField = '__gonavi_row_key__';
|
||||
|
||||
describe('dataGridOutput helpers', () => {
|
||||
it('resolves exportable columns in display order without the internal row key', () => {
|
||||
expect(resolveDataGridOutputColumnNames(['name', rowKeyField, 'id'], rowKeyField)).toEqual(['name', 'id']);
|
||||
});
|
||||
|
||||
it('keeps exact column names when resolving output order', () => {
|
||||
expect(resolveDataGridOutputColumnNames([' full name ', 'id'], rowKeyField)).toEqual([' full name ', 'id']);
|
||||
});
|
||||
|
||||
it('picks row values in display column order', () => {
|
||||
const rows = pickDataGridOutputRows([
|
||||
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
|
||||
], ['name', 'id']);
|
||||
|
||||
expect(Object.keys(rows[0])).toEqual(['name', 'id']);
|
||||
expect(rows[0]).toEqual({ name: 'alpha', id: 1 });
|
||||
});
|
||||
|
||||
it('builds table SELECT SQL with explicit display columns', () => {
|
||||
expect(buildDataGridSelectBaseSql({
|
||||
dbType: 'mysql',
|
||||
tableName: 'users',
|
||||
columnNames: ['name', 'id'],
|
||||
whereSql: "WHERE `id` = '7'",
|
||||
})).toBe("SELECT `name`, `id` FROM `users` WHERE `id` = '7'");
|
||||
});
|
||||
});
|
||||
41
frontend/src/components/dataGridOutput.ts
Normal file
41
frontend/src/components/dataGridOutput.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||
|
||||
export const resolveDataGridOutputColumnNames = (
|
||||
displayColumnNames: string[],
|
||||
rowKeyField: string,
|
||||
): string[] => (
|
||||
(displayColumnNames || [])
|
||||
.map((columnName) => String(columnName ?? ''))
|
||||
.filter((columnName) => columnName && columnName !== rowKeyField)
|
||||
);
|
||||
|
||||
export const pickDataGridOutputRows = (
|
||||
rows: Array<Record<string, any>>,
|
||||
columnNames: string[],
|
||||
): Array<Record<string, any>> => (
|
||||
(rows || []).map((row) => {
|
||||
const next: Record<string, any> = {};
|
||||
(columnNames || []).forEach((columnName) => {
|
||||
next[columnName] = row?.[columnName];
|
||||
});
|
||||
return next;
|
||||
})
|
||||
);
|
||||
|
||||
export const buildDataGridSelectBaseSql = ({
|
||||
dbType,
|
||||
tableName,
|
||||
columnNames,
|
||||
whereSql = '',
|
||||
}: {
|
||||
dbType: string;
|
||||
tableName: string;
|
||||
columnNames: string[];
|
||||
whereSql?: string;
|
||||
}): string => {
|
||||
const selectList = columnNames.length > 0
|
||||
? columnNames.map((columnName) => quoteIdentPart(dbType, columnName)).join(', ')
|
||||
: '*';
|
||||
const wherePart = String(whereSql || '').trim();
|
||||
return `SELECT ${selectList} FROM ${quoteQualifiedIdent(dbType, tableName)}${wherePart ? ` ${wherePart}` : ''}`;
|
||||
};
|
||||
@@ -22,6 +22,20 @@ describe('dataGridRowClipboard', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies row fields in display column order', () => {
|
||||
const copiedRows = buildCopiedRowsForPaste({
|
||||
rows: [
|
||||
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
|
||||
],
|
||||
selectedRowKeys: ['row-1'],
|
||||
columnNames: ['name', 'id'],
|
||||
rowKeyField,
|
||||
});
|
||||
|
||||
expect(Object.keys(copiedRows[0])).toEqual(['name', 'id']);
|
||||
expect(copiedRows[0]).toEqual({ name: 'alpha', id: 1 });
|
||||
});
|
||||
|
||||
it('builds pasted rows as new rows with fresh internal keys', () => {
|
||||
const pastedRows = buildPastedRowsFromCopiedRows({
|
||||
rows: [
|
||||
|
||||
Reference in New Issue
Block a user