🐛 fix(data-grid): 修复数据输出列序与时间精度问题

- 统一复制、导出、JSON/Text 视图按表格展示列序输出
- 表级导出改用显式列查询,避免 SELECT * 丢失界面列序
- 保留 datetime(3) 等时间字段的小数秒展示与复制输出
Refs #434
This commit is contained in:
Syngnat
2026-05-10 12:32:41 +08:00
parent baed7a2721
commit d26d7d2ff0
15 changed files with 271 additions and 2421 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 3AI 协同
- 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 运行时缓存治理能力。

View File

@@ -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` actionAI 输入框局部消费,不走全局快捷键执行器。
- [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. 决策记录
- 决策 1AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。
- 决策 2`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。
- 决策 3AI 发送快捷键允许默认无修饰键 Enter但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。
- 决策 4输入法 composing 状态始终不发送。
- 决策 5AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。
- 决策 6AI 输入框命中发送快捷键后同时执行 `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 passed22 tests passed
- 验证项:浏览器手工验证。
- 结果:已通过。
- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。
- 验证项:前端全量测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run`88 files passed421 tests passed
- 验证项diff 空白检查。
- 结果:已通过。
- 证据:`git diff --check` 无输出。
- 验证项:生产构建。
- 结果:已通过。
- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。
## 8. 下一步
- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。
- 负责人Claude Code

View File

@@ -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 1JVM 共享契约与配置归一化
- 已完成 Task 2Provider 注册、连接测试与能力探测 API
- 已完成 Task 3JVM 连接表单、图标与展示文案接入
- 已完成 Task 4只读资源浏览与 JVM Tab
- 已完成 Task 5写入预览、Guard 和审计记录
- 已完成 Task 6AI 结构化变更计划
- 已完成 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后续推荐方向转为“多接入模式 + 能力协商”
- 决策 5AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入
- 决策 6AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
- 决策 7当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
- 决策 8JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
- 决策 9JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java`
- 决策 10Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach
## 7. 验证记录
- 验证项:
- GoNavi 驱动代理机制核查
- GoNavi 现有 Redis/编辑器/UI 复用能力核查
- JVM Connector 正式设计文档自检
- JVM Connector 实施计划文档自检
- Task 1JVM 共享契约与配置归一化
- Task 2Provider 注册、连接测试与能力探测 API
- Task 6AI 计划解析、资源定位解析、契约映射与页签上下文隔离
- Task 7Java Endpoint fixture 真实集成验证
- Task 7JMX helper 内嵌分发与运行时缓存验证
- Task 7Agent provider 与真实 Java Agent 集成验证
- Task 7后端全量测试
- Task 7前端全量测试
- Task 7前端生产构建
- Task 7Wails 生产构建
- 结果:
- 已确认存在可复用的连接桥接与编辑器基础设施
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
- 已完成正式实施计划落盘与自检,已补齐共享 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 6AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
- 已完成 Task 6AI 聊天消息与 JVM 来源页签绑定AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
- 已完成 Task 7Java Endpoint fixture可真实验证 `resources / value / preview / apply` 四个 endpoint contract
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过
- 已完成 Task 7JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过
- 已完成 Task 7Agent 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 files259 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

View File

@@ -1,24 +0,0 @@
# SQL 方言适配需求进度追踪
## 背景
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
- GitHub 相关问题Refs #402(金仓字段类型/DDL 方言、Refs #409Oracle 删除数据 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 数据复制/删除 SQLDATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
## 验证
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
- `npm run build`
## 风险与后续
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER并用中文注释阻止 MySQL 专属子句外溢。
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。

View File

@@ -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

View File

@@ -1 +1 @@
0295a42fd931778d85157816d79d29e5
d0464f9da25e9356e61652e638c99ffe

View File

@@ -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

View File

@@ -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} :

View File

@@ -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]');
});
});

View File

@@ -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',

View File

@@ -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')`;
};

View 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'");
});
});

View 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}` : ''}`;
};

View File

@@ -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: [