From ed4a7b96d45410110316e382a613e4ae157a0884 Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:39:09 +0800 Subject: [PATCH 01/22] =?UTF-8?q?=F0=9F=90=9B=20fix(query):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=8D=83=E4=B8=87=E7=BA=A7=E8=A1=A8=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E3=80=81=E8=A1=A8=E5=A4=B4=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E4=B8=8D=E6=98=BE=E7=A4=BA=E5=8F=8Adatetime?= =?UTF-8?q?=20INSERT=E6=A0=BC=E5=BC=8F=E9=97=AE=E9=A2=98=20refs=20#307?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QueryEditor: SQL编辑器查询 timeout 下限设为 120s,防止大表全量查询被 30s 超时取消 - QueryEditor: 放宽表名提取正则,支持 SELECT col1,col2 FROM table 形式,修复表头备注/类型不显示 - DataGrid: handleCopyInsert 对 datetime 值调用 normalizeDateTimeString,消除 RFC3339 格式中的 T 和时区后缀 --- frontend/src/components/DataGrid.tsx | 3 ++- frontend/src/components/QueryEditor.tsx | 15 ++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 0ec7de6..ab5f5d6 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -3504,7 +3504,8 @@ const DataGrid: React.FC = ({ const values = orderedCols.map(c => { const v = r[c]; if (v === null || v === undefined) return 'NULL'; - const escaped = String(v).replace(/'/g, "''"); + const str = typeof v === 'string' ? normalizeDateTimeString(v) : String(v); + const escaped = str.replace(/'/g, "''"); return `'${escaped}'`; }); const targetTable = tableName || 'table'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 7d2fbe5..fbc0b52 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1586,13 +1586,14 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return; } - const config = { - ...conn.config, + const config = { + ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, - ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } + ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }, + timeout: Math.max(Number(conn.config.timeout) || 30, 120), }; try { @@ -1842,8 +1843,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { let simpleTableName: string | undefined = undefined; if (rawStatement) { - // 支持多行 SQL:SELECT * FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等 - const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+(?:[\w`"]+\.)?[`"]?(\w+)[`"]?\s*(?:$|[\s;])/im); + // 支持多行 SQL:SELECT [cols] FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等 + // JOIN 查询表名歧义,不提取 + const hasJoin = /\bJOIN\b/i.test(rawStatement); + const tableMatch = !hasJoin + ? rawStatement.match(/^\s*SELECT\s+.+?\s+FROM\s+(?:[\w`"\[\].]+\.)?[`"\[]?(\w+)[`"\]]?\s*(?:$|[\s;])/im) + : null; if (tableMatch) { simpleTableName = tableMatch[1]; if (!forceReadOnlyResult) { From a7bee7f3b6653ae77c2eb27874c9217ec7b70cfd Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Mar 2026 16:48:06 +0800 Subject: [PATCH 02/22] =?UTF-8?q?=E2=9C=A8=20feat(ai-entry):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96AI=E5=8A=A9=E6=89=8B=E8=B4=B4=E8=BE=B9=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 AI 助手入口从侧栏工具区迁移为主内容区右侧贴边标签 - 调整打开态贴边标签锚点到面板左外沿,避免遮挡头部操作区 - 重排侧栏顶部工具布局,恢复四项按钮的稳定网格结构 - 新增 aiEntryLayout 布局辅助与回归测试,覆盖打开态附着位置 --- .../plans/2026-03-28-ai-edge-handle-entry.md | 87 ++++++++++ .../2026-03-28-ai-edge-handle-entry-design.md | 158 ++++++++++++++++++ ...度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md | 63 +++++++ frontend/src/App.tsx | 102 ++++++++--- frontend/src/utils/aiEntryLayout.test.ts | 71 ++++++++ frontend/src/utils/aiEntryLayout.ts | 59 +++++++ 6 files changed, 520 insertions(+), 20 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md create mode 100644 docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md create mode 100644 docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md create mode 100644 frontend/src/utils/aiEntryLayout.test.ts create mode 100644 frontend/src/utils/aiEntryLayout.ts diff --git a/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md b/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md new file mode 100644 index 0000000..dd4c510 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md @@ -0,0 +1,87 @@ +# AI Edge Handle Entry Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 AI 助手入口从标题栏移除,改为主内容区右侧的 `A2` 贴边标签入口,并保持现有 AI 面板开关与聊天逻辑不变。 + +**Architecture:** 保留现有 `App.tsx` 作为 AI 入口装配点,不改 AI 面板展开方向和 store 状态。通过 `aiEntryLayout.ts` 承载可测试的布局/样式决策,让 `App.tsx` 只负责在“主内容关闭态”和“面板展开态”两个壳层里挂载同一个贴边标签。 + +**Tech Stack:** React 18, TypeScript, Ant Design, Vitest, Vite + +--- + +## File Map + +- Modify: `frontend/src/App.tsx` + - 移除标题栏 AI 入口渲染与相关样式状态 + - 在主内容区右边缘挂载关闭态入口 + - 在 AI 面板外沿挂载打开态入口 +- Modify: `frontend/src/utils/aiEntryLayout.ts` + - 将现有标题栏专用布局 helper 改为右侧贴边标签 helper + - 保留 `SIDEBAR_UTILITY_ITEM_KEYS` +- Modify: `frontend/src/utils/aiEntryLayout.test.ts` + - 先写失败测试,锁定入口位置、附着位置和贴边标签样式约束 +- Modify: `docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md` + - 同步“设计已切换到右侧贴边标签方案”和后续验证状态 + +## Chunk 1: Layout Contract Regression + +### Task 1: 把布局辅助契约改成“右侧贴边标签” + +**Files:** +- Modify: `frontend/src/utils/aiEntryLayout.test.ts` +- Modify: `frontend/src/utils/aiEntryLayout.ts` + +- [ ] **Step 1: 写失败测试,锁定新的布局语义** +- [ ] **Step 2: 运行测试,确认它因旧契约而失败** +- [ ] **Step 3: 用最小改动实现新的 helper 契约** +- [ ] **Step 4: 重新运行测试,确认 helper 契约变绿** +- [ ] **Step 5: 提交这一小块** + +## Chunk 2: Remove Titlebar Entry + +### Task 2: 从标题栏彻底移除 AI 入口 + +**Files:** +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: 写出本次要删除的标题栏入口代码清单** +- [ ] **Step 2: 删除标题栏 AI 入口相关状态与 JSX** +- [ ] **Step 3: 运行前端构建,确认清理后没有遗留引用** +- [ ] **Step 4: 提交标题栏清理** + +## Chunk 3: Mount The Edge Handle + +### Task 3: 在主内容区右边缘挂载关闭态贴边标签 + +**Files:** +- Modify: `frontend/src/App.tsx` +- Modify: `frontend/src/utils/aiEntryLayout.ts` + +- [ ] **Step 1: 为关闭态入口写最小布局决策代码** +- [ ] **Step 2: 把主内容横向容器改成可承载绝对定位标签** +- [ ] **Step 3: 运行构建,确认关闭态入口接入不破坏布局** +- [ ] **Step 4: 提交关闭态入口挂载** + +### Task 4: 打开 AI 面板时让标签贴住面板外沿 + +**Files:** +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: 给 AI 面板外层加一个仅负责贴边标签定位的壳层** +- [ ] **Step 2: 用附着位置决策保证开关态只渲染一个入口** +- [ ] **Step 3: 重新运行 helper 测试和构建** +- [ ] **Step 4: 提交打开态连续关系** + +## Chunk 4: Manual Verification And Docs + +### Task 5: 手工验证空间关系,再更新追踪文档 + +**Files:** +- Modify: `docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md` + +- [ ] **Step 1: 启动本地开发环境做手工验证** +- [ ] **Step 2: 如发现重叠,优先调整偏移而不是改回胶囊按钮** +- [ ] **Step 3: 运行最终验证命令** +- [ ] **Step 4: 更新需求追踪文档** +- [ ] **Step 5: 提交收尾文档与验证结果** diff --git a/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md b/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md new file mode 100644 index 0000000..c49bf2b --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md @@ -0,0 +1,158 @@ +# AI 助手入口重设计:右侧贴边标签方案 + +## 1. 背景 + +当前 AI 助手入口已经尝试过两种方案: + +1. 标题栏右侧、靠近窗口控制按钮的胶囊按钮 +2. 标题栏左侧、紧跟应用标题的胶囊按钮 + +这两种方案都存在同一个问题:入口仍然被感知为“标题栏按钮”或“全局工具按钮”,而不是“从右侧展开的协作面板入口”。用户已经明确选择将 AI 助手定义为协作入口,而非标题栏功能按钮或左侧工作流工具。 + +本次设计目标是重新定义 AI 助手入口的空间归属关系,让入口与右侧 AI 面板形成直接、自然的视觉和交互映射。 + +## 2. 设计目标 + +- 让 AI 入口看起来像右侧面板的入口,而不是标题栏按钮 +- 让入口位置与 AI 面板展开方向保持一致 +- 降低入口对标题栏和主工作区视觉层级的干扰 +- 保持现有 AI 面板开关逻辑、面板位置、聊天状态和快捷键不变 +- 保持左侧 `Sider` 顶部工具组为四个常规工具入口,不再放回 AI + +## 3. 非目标 + +- 不修改 AI 面板本体的信息架构 +- 不修改 AI 聊天、会话、模型、上下文逻辑 +- 不新增 AI 专属状态存储字段 +- 不重新设计标题栏 +- 不把 AI 面板改成左侧抽屉或底部抽屉 + +## 4. 已评估方案 + +### 方案 A:标题栏入口 + +把 AI 放在标题栏右侧或标题后方。 + +结论:放弃。 + +原因: + +- 容易被识别成窗口级按钮,而不是右侧协作面板入口 +- 入口与 AI 面板的空间关系过远 +- 标题栏承担拖拽、双击、窗口控制等系统职责,额外入口容易显得突兀 + +### 方案 B:内容区悬浮胶囊 + +把 AI 入口放在内容区右上角,作为脱离标题栏的悬浮按钮。 + +结论:未采用。 + +原因: + +- 比标题栏好,但仍然像“漂浮的按钮” +- 视觉上还是偏独立动作,而不是面板把手 +- 容易与未来内容区工具条或右上角操作区冲突 + +### 方案 C:右侧贴边入口 + +把 AI 入口挂在主内容区右边缘、贴近 AI 面板出现位置。 + +结论:采用。 + +原因: + +- 最符合“右侧面板从这里展开”的直觉 +- 可以脱离标题栏和窗口控制区 +- 与 AI 面板的视觉关系最强 +- 更符合“协作入口”而不是“工具按钮”的产品定位 + +## 5. 最终方案 + +### 5.1 位置 + +- AI 入口彻底离开标题栏 +- AI 入口不再出现在左侧 `Sider` 顶部工具组 +- 入口挂载在主内容区容器内部,而不是窗口根节点 +- 位置锚点是主内容区最右侧边缘,即现有 AI 面板展开出来的那条边 +- 默认位于内容区右上方,明显低于标题栏底边,避免再与标题栏形成视觉混淆 + +### 5.2 形态 + +选定形态为 `A2:上角贴边标签`。 + +入口外观定义如下: + +- 采用“半嵌入式贴边标签”而不是完整胶囊按钮 +- 标签右侧贴边,左侧圆角外露 +- 默认文案收敛为 `AI` +- 保留一个轻量机器人图标,用于快速识别 +- 视觉风格使用面板边框色、弱背景和轻圆角 +- 不使用强阴影、不使用主按钮视觉、不做明显浮层感 + +目标效果是:入口更像右侧协作面板的标签,而不是单独悬浮的交互控件。 + +### 5.3 状态 + +关闭态: + +- 标签保持低存在感 +- 弱背景、弱边框、常规文字色 +- 保证可发现,但不抢主内容注意力 + +悬停态: + +- 仅做轻微提亮 +- 允许边框轻微增强 +- 不放大、不上浮、不出现明显浮层阴影 + +打开态: + +- 标签切换到 AI 激活色 +- 视觉上与右侧 AI 面板顶部外沿形成连续感 +- 不隐藏入口,让用户明确知道“标签已经展开成右侧这块面板” + +## 6. 交互定义 + +- 点击贴边标签时,仍然调用现有 AI 面板开关逻辑 +- AI 面板继续从右侧展开,不改变方向 +- 关闭面板后,标签恢复为单独的贴边入口 +- 打开面板时,标签保留在面板外沿作为“已展开标识” +- 当面板宽度被拖动时,标签始终贴在面板外沿,不与主内容区脱节 +- 在窄窗口下优先保证标签可点击,必要时继续保留 `AI` 简写,不要求展开成长文案 + +## 7. 实现边界 + +- 主入口显示与定位由 `App.tsx` 管理 +- AI 面板本身只保留现有展开、关闭和宽度调整行为 +- 状态继续复用现有 `aiPanelVisible`、`toggleAIPanel`、`setAIPanelVisible` +- 不新增持久化字段,不改 store 结构 +- 左侧 `Sider` 顶部继续保留四个工具按钮:`工具 / 代理 / 主题 / 关于` +- 标题栏恢复为纯窗口级区域,不继续承载 AI 入口 + +## 8. 验证口径 + +实现后重点验证以下事项: + +1. 关闭态时,右侧贴边标签是否自然,不像悬浮按钮 +2. 打开态时,标签是否与 AI 面板形成清晰连续关系 +3. 拖动 AI 面板宽度后,标签是否仍贴在面板外沿 +4. 窄窗口下标签是否仍可点击,不与内容区遮挡 +5. 标题栏空白区、窗口控制按钮、左侧侧栏工具组是否恢复干净,不再承担 AI 入口职责 + +## 9. 风险与注意事项 + +- 贴边标签如果做得过大,会重新变成“漂浮按钮” +- 贴边标签如果做得过窄,会影响关闭态可发现性 +- 标签定位需要明确绑定主内容区右边界,而不是整个窗口最右边界,否则在不同布局状态下容易错位 +- 打开态与面板外沿的连续关系必须处理好,否则会显得像两个无关控件 + +## 10. 决策结论 + +本次 AI 助手入口重设计的最终结论是: + +- 不再使用标题栏入口 +- 不回退到左侧工具条入口 +- 采用主内容区右侧的贴边标签方案 +- 采用 `A2` 形态,即“上角贴边标签” + +这是当前最符合“协作入口”定位的方案,也是最能解释 AI 面板空间来源的方案。 diff --git a/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md b/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md new file mode 100644 index 0000000..b5261ee --- /dev/null +++ b/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md @@ -0,0 +1,63 @@ +# 需求进度追踪 - AI助手入口迁移到主内容区右侧贴边标签 + +## 1. 需求摘要 +- 需求名称:AI 助手入口迁移到主内容区右侧贴边标签 +- 提出日期:2026-03-28 +- 负责人:Codex +- 目标:将 AI 助手入口从左侧 `Sider` 顶部工具条和标题栏中移出,改为主内容区右侧边缘的 `A2` 贴边标签入口,并保持现有 AI 面板开关逻辑不变。 +- 非目标:不调整 AI 面板展开方向,不修改 AI 聊天逻辑,不新增快捷键或状态存储。 + +## 2. 范围与验收 +- 范围:`App.tsx` 主内容区与 AI 面板装配位置、左侧 `Sider` 顶部工具条、`aiEntryLayout` 布局辅助与相关前端回归测试。 +- 验收标准:主内容区右侧边缘出现 `A2` 贴边标签入口;标题栏与左侧 `Sider` 顶部都不再显示 AI 入口;按钮点击仍能正常打开/关闭 AI 面板,且打开后标签与面板外沿形成连续关系。 +- 依赖与约束:不修改 AI 面板展开方向;保持现有 store 结构与聊天逻辑;需兼容窄窗口下的点击区域。 + +## 3. 里程碑与进度 +- [x] 阶段 1(需求澄清):确认 AI 入口应按“协作入口”重新设计 +- [x] 阶段 2(影响分析):定位标题栏、侧栏工具条与 AI 面板开关实现 +- [x] 阶段 3(方案设计):完成 `A2` 右侧贴边标签方案设计与评审 +- [x] 阶段 4(实施计划):输出实现计划文档 +- [x] 阶段 5(实现与自检):完成 helper 单测与前端构建验证 +- [ ] 阶段 6(评审与交付):待手工界面确认后整理风险、回滚和验证点 +- [ ] 阶段 7(发布与观察):待后续观察 + +## 4. 变更清单 +- 已完成:新增 `frontend/src/utils/aiEntryLayout.ts` 与配套单测;左侧 `Sider` 顶部恢复四个工具按钮;AI 入口改为主内容区右侧贴边标签;修复打开 AI 面板后贴边标签压住头部按钮区的问题,打开态现在附着到面板左外沿。 +- 进行中:无。 +- 待处理:本机手工确认贴边标签的实际视觉位置、打开态连续关系和窄窗口点击区。 + +## 5. 风险与阻塞 +- 风险:贴边标签如果尺寸过大,会重新变成悬浮按钮;如果过小,会影响关闭态可发现性;如果锚点错误,打开面板后会与外沿脱节。 +- 阻塞:自动化浏览器手工验证受限于本机 Playwright Chrome 安装失败(权限不足);尝试直接通过 Edge 拉起本地 URL 也被当前执行策略拦截,无法由 Codex 自动代开页面。 +- 缓解措施:当前已用 helper 单测与 `npm run build` 验证静态接线;界面最终位置仍需你在本机直接运行后再看一眼。 + +## 6. 决策记录 +- 决策 1:AI 入口定位为“协作入口”,而不是标题栏按钮或侧栏工具按钮。 +- 决策 2:放弃标题栏右侧与标题后入口方案,改为主内容区右侧贴边标签。 +- 决策 3:选定 `A2` 形态,即上角贴边标签,而不是竖向拉手或极简细窄开关。 +- 决策 4:左侧 `Sider` 顶部保留四个工具按钮,AI 不再回到该区域。 +- 决策 5:打开态贴边标签的锚点必须落在 `panel-shell` 左外沿,而不能落在面板右上角内部,否则会与 `AIChatHeader` 操作区重叠。 + +## 7. 验证记录 +- 验证项:`npm test -- src/utils/aiEntryLayout.test.ts` +- 结果:通过,`6/6` 用例通过。 +- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中执行,Vitest 输出 `1 passed / 6 passed`。 + +- 验证项:`npm test -- src/utils/aiEntryLayout.test.ts`(打开态锚点回归) +- 结果:先失败后通过;新增打开态外侧附着回归后,最终 `8/8` 用例通过。 +- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中先得到 `resolveAIEdgeHandleDockStyle is not a function` 的红灯,随后补充 dock style helper 后重新执行,Vitest 输出 `1 passed / 8 passed`。 + +- 验证项:`npm run build` +- 结果:通过,`tsc && vite build` 退出码为 `0`。 +- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中执行,Vite 完成生产构建,存在既有 chunk size warning,但不阻塞本次改动。 + +- 手工验证项:浏览器态界面核对 +- 结果:未完成。 +- 证据(日志/截图/链接):2026-03-28 16:24:29 +08:00 再次尝试时,Playwright 仍因缺少 Chrome 且安装需要更高权限而失败;尝试通过本机 Edge 打开 `http://localhost:5173` 时又被当前命令执行策略拦截,未能自动采集界面证据。 + +- 计划文档:`docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md` +- 设计文档:`docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md` + +## 8. 下一步 +- 下一步行动:当前实现先保留在隔离 worktree `C:\Users\yangguofeng\.config\superpowers\worktrees\GoNavi\ai-edge-handle-entry`,待你本机目测确认后,再决定是否合并回 `dev`。 +- 负责人:Codex diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 457b836..00cbaaa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,13 @@ import { isShortcutMatch, normalizeShortcutCombo, } from './utils/shortcuts'; +import { + SIDEBAR_UTILITY_ITEM_KEYS, + resolveAIEntryPlacement, + resolveAIEdgeHandleAttachment, + resolveAIEdgeHandleDockStyle, + resolveAIEdgeHandleStyle, +} from './utils/aiEntryLayout'; import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App'; import './App.css'; @@ -1125,6 +1132,61 @@ function App() { const [capturingShortcutAction, setCapturingShortcutAction] = useState(null); const [isProxyModalOpen, setIsProxyModalOpen] = useState(false); const [isAISettingsOpen, setIsAISettingsOpen] = useState(false); + const aiEntryPlacement = resolveAIEntryPlacement(); + const aiEdgeHandleAttachment = resolveAIEdgeHandleAttachment(aiPanelVisible); + const aiEdgeHandleDockStyle = useMemo( + () => resolveAIEdgeHandleDockStyle(aiEdgeHandleAttachment), + [aiEdgeHandleAttachment], + ); + const aiEdgeHandleStyle = useMemo(() => ( + resolveAIEdgeHandleStyle({ + darkMode, + aiPanelVisible, + effectiveUiScale, + }) + ), [aiPanelVisible, darkMode, effectiveUiScale]); + const sidebarUtilityItems = useMemo(() => { + const itemMap = { + tools: { + key: 'tools', + title: '工具', + icon: , + onClick: () => setIsToolsModalOpen(true), + }, + proxy: { + key: 'proxy', + title: '代理', + icon: , + onClick: () => setIsProxyModalOpen(true), + }, + theme: { + key: 'theme', + title: '主题', + icon: , + onClick: () => setIsThemeModalOpen(true), + }, + about: { + key: 'about', + title: '关于', + icon: , + onClick: () => setIsAboutOpen(true), + }, + } as const; + + return SIDEBAR_UTILITY_ITEM_KEYS.map((key) => itemMap[key]); + }, []); + const renderAIEdgeHandle = () => ( + + + + ); // Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制 @@ -1634,24 +1696,12 @@ function App() { >
-
-
@@ -1760,12 +1810,24 @@ function App() { /> -
+
+ {aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'content-shell' && ( +
+ {renderAIEdgeHandle()} +
+ )} {aiPanelVisible && ( - setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} /> +
+ {aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'panel-shell' && ( +
+ {renderAIEdgeHandle()} +
+ )} + setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} /> +
)}
{isLogPanelOpen && ( diff --git a/frontend/src/utils/aiEntryLayout.test.ts b/frontend/src/utils/aiEntryLayout.test.ts new file mode 100644 index 0000000..e44f444 --- /dev/null +++ b/frontend/src/utils/aiEntryLayout.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + SIDEBAR_UTILITY_ITEM_KEYS, + resolveAIEntryPlacement, + resolveAIEdgeHandleAttachment, + resolveAIEdgeHandleDockStyle, + resolveAIEdgeHandleStyle, +} from './aiEntryLayout'; + +describe('ai entry layout', () => { + it('keeps the sidebar utility group free of the AI entry', () => { + expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']); + }); + + it('anchors the AI entry to the content edge', () => { + expect(resolveAIEntryPlacement()).toBe('content-edge'); + }); + + it('attaches the closed handle to the content shell', () => { + expect(resolveAIEdgeHandleAttachment(false)).toBe('content-shell'); + }); + + it('attaches the open handle to the panel shell', () => { + expect(resolveAIEdgeHandleAttachment(true)).toBe('panel-shell'); + }); + + it('keeps the closed handle docked on the content edge', () => { + expect(resolveAIEdgeHandleDockStyle('content-shell')).toMatchObject({ + position: 'absolute', + top: 16, + right: 0, + zIndex: 12, + }); + }); + + it('keeps the open handle outside the panel shell to avoid header overlap', () => { + expect(resolveAIEdgeHandleDockStyle('panel-shell')).toMatchObject({ + position: 'absolute', + top: 16, + right: '100%', + zIndex: 12, + }); + }); + + it('uses the attached active appearance when the AI panel is open', () => { + const style = resolveAIEdgeHandleStyle({ + darkMode: true, + aiPanelVisible: true, + effectiveUiScale: 1, + }); + + expect(style.color).toBe('#ffd666'); + expect(style.background).toBe('rgba(255,214,102,0.12)'); + expect(style.borderRadius).toBe('10px 0 0 10px'); + expect(style.borderRight).toBe('none'); + expect(style.height).toBe(24); + }); + + it('uses the subdued attached appearance when the AI panel is closed', () => { + const style = resolveAIEdgeHandleStyle({ + darkMode: false, + aiPanelVisible: false, + effectiveUiScale: 1, + }); + + expect(style.color).toBe('rgba(22,32,51,0.82)'); + expect(style.background).toBe('rgba(15,23,42,0.04)'); + expect(style.paddingInline).toBe(8); + expect(style.borderRadius).toBe('10px 0 0 10px'); + }); +}); diff --git a/frontend/src/utils/aiEntryLayout.ts b/frontend/src/utils/aiEntryLayout.ts new file mode 100644 index 0000000..a0624fb --- /dev/null +++ b/frontend/src/utils/aiEntryLayout.ts @@ -0,0 +1,59 @@ +import type { CSSProperties } from 'react'; + +export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const; + +export type AIEntryPlacement = 'content-edge'; +export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell'; + +export interface ResolveAIEdgeHandleStyleInput { + darkMode: boolean; + aiPanelVisible: boolean; + effectiveUiScale: number; +} + +export const resolveAIEntryPlacement = (): AIEntryPlacement => 'content-edge'; + +export const resolveAIEdgeHandleAttachment = ( + aiPanelVisible: boolean, +): AIEdgeHandleAttachment => (aiPanelVisible ? 'panel-shell' : 'content-shell'); + +export const resolveAIEdgeHandleDockStyle = ( + attachment: AIEdgeHandleAttachment, +): CSSProperties => ({ + position: 'absolute', + top: 16, + right: attachment === 'panel-shell' ? '100%' : 0, + zIndex: 12, +}); + +export const resolveAIEdgeHandleStyle = ({ + darkMode, + aiPanelVisible, + effectiveUiScale, +}: ResolveAIEdgeHandleStyleInput): CSSProperties => { + const inactiveColor = darkMode ? 'rgba(255,255,255,0.86)' : 'rgba(22,32,51,0.82)'; + const inactiveBackground = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(15,23,42,0.04)'; + const inactiveBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)'; + + return { + height: Math.max(24, Math.round(24 * effectiveUiScale)), + paddingInline: Math.max(8, Math.round(8 * effectiveUiScale)), + borderRadius: '10px 0 0 10px', + border: `1px solid ${aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.22)' : 'rgba(24,144,255,0.18)') : inactiveBorder}`, + borderRight: 'none', + background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)') : inactiveBackground, + color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : inactiveColor, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: Math.max(4, Math.round(4 * effectiveUiScale)), + fontSize: Math.max(12, Math.round(12 * effectiveUiScale)), + fontWeight: 600, + lineHeight: 1, + boxShadow: 'none', + backdropFilter: 'none', + WebkitBackdropFilter: 'none', + whiteSpace: 'nowrap', + flexShrink: 0, + }; +}; From fcd4d4026c04d9664e310c77bafe3f8f487b929b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Mar 2026 17:35:21 +0800 Subject: [PATCH 03/22] =?UTF-8?q?=F0=9F=94=A7=20chore(gitignore):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=9C=AC=E5=9C=B0=E8=BF=BD=E8=B8=AA=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=B9=B6=E8=A1=A5=E5=85=85=E5=BF=BD=E7=95=A5=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从版本控制中移除 docs/superpowers 下的计划与设计文档 - 从版本控制中移除 docs/需求追踪 下的本地进度追踪文档 - 补充忽略规则,避免本地需求追踪与 superpowers 文档再次误提交 --- .gitignore | 2 + .../plans/2026-03-28-ai-edge-handle-entry.md | 87 ---------- .../2026-03-28-ai-edge-handle-entry-design.md | 158 ------------------ ...度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md | 63 ------- 4 files changed, 2 insertions(+), 308 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md delete mode 100644 docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md delete mode 100644 docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md diff --git a/.gitignore b/.gitignore index 6a07141..f70d8a2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ GoNavi-Wails.exe .claude/ .gemini/ **/tmpclaude-* +docs/superpowers/ +docs/需求追踪/ CLAUDE.md **/CLAUDE.md diff --git a/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md b/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md deleted file mode 100644 index dd4c510..0000000 --- a/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md +++ /dev/null @@ -1,87 +0,0 @@ -# AI Edge Handle Entry Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 将 AI 助手入口从标题栏移除,改为主内容区右侧的 `A2` 贴边标签入口,并保持现有 AI 面板开关与聊天逻辑不变。 - -**Architecture:** 保留现有 `App.tsx` 作为 AI 入口装配点,不改 AI 面板展开方向和 store 状态。通过 `aiEntryLayout.ts` 承载可测试的布局/样式决策,让 `App.tsx` 只负责在“主内容关闭态”和“面板展开态”两个壳层里挂载同一个贴边标签。 - -**Tech Stack:** React 18, TypeScript, Ant Design, Vitest, Vite - ---- - -## File Map - -- Modify: `frontend/src/App.tsx` - - 移除标题栏 AI 入口渲染与相关样式状态 - - 在主内容区右边缘挂载关闭态入口 - - 在 AI 面板外沿挂载打开态入口 -- Modify: `frontend/src/utils/aiEntryLayout.ts` - - 将现有标题栏专用布局 helper 改为右侧贴边标签 helper - - 保留 `SIDEBAR_UTILITY_ITEM_KEYS` -- Modify: `frontend/src/utils/aiEntryLayout.test.ts` - - 先写失败测试,锁定入口位置、附着位置和贴边标签样式约束 -- Modify: `docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md` - - 同步“设计已切换到右侧贴边标签方案”和后续验证状态 - -## Chunk 1: Layout Contract Regression - -### Task 1: 把布局辅助契约改成“右侧贴边标签” - -**Files:** -- Modify: `frontend/src/utils/aiEntryLayout.test.ts` -- Modify: `frontend/src/utils/aiEntryLayout.ts` - -- [ ] **Step 1: 写失败测试,锁定新的布局语义** -- [ ] **Step 2: 运行测试,确认它因旧契约而失败** -- [ ] **Step 3: 用最小改动实现新的 helper 契约** -- [ ] **Step 4: 重新运行测试,确认 helper 契约变绿** -- [ ] **Step 5: 提交这一小块** - -## Chunk 2: Remove Titlebar Entry - -### Task 2: 从标题栏彻底移除 AI 入口 - -**Files:** -- Modify: `frontend/src/App.tsx` - -- [ ] **Step 1: 写出本次要删除的标题栏入口代码清单** -- [ ] **Step 2: 删除标题栏 AI 入口相关状态与 JSX** -- [ ] **Step 3: 运行前端构建,确认清理后没有遗留引用** -- [ ] **Step 4: 提交标题栏清理** - -## Chunk 3: Mount The Edge Handle - -### Task 3: 在主内容区右边缘挂载关闭态贴边标签 - -**Files:** -- Modify: `frontend/src/App.tsx` -- Modify: `frontend/src/utils/aiEntryLayout.ts` - -- [ ] **Step 1: 为关闭态入口写最小布局决策代码** -- [ ] **Step 2: 把主内容横向容器改成可承载绝对定位标签** -- [ ] **Step 3: 运行构建,确认关闭态入口接入不破坏布局** -- [ ] **Step 4: 提交关闭态入口挂载** - -### Task 4: 打开 AI 面板时让标签贴住面板外沿 - -**Files:** -- Modify: `frontend/src/App.tsx` - -- [ ] **Step 1: 给 AI 面板外层加一个仅负责贴边标签定位的壳层** -- [ ] **Step 2: 用附着位置决策保证开关态只渲染一个入口** -- [ ] **Step 3: 重新运行 helper 测试和构建** -- [ ] **Step 4: 提交打开态连续关系** - -## Chunk 4: Manual Verification And Docs - -### Task 5: 手工验证空间关系,再更新追踪文档 - -**Files:** -- Modify: `docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md` - -- [ ] **Step 1: 启动本地开发环境做手工验证** -- [ ] **Step 2: 如发现重叠,优先调整偏移而不是改回胶囊按钮** -- [ ] **Step 3: 运行最终验证命令** -- [ ] **Step 4: 更新需求追踪文档** -- [ ] **Step 5: 提交收尾文档与验证结果** diff --git a/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md b/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md deleted file mode 100644 index c49bf2b..0000000 --- a/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md +++ /dev/null @@ -1,158 +0,0 @@ -# AI 助手入口重设计:右侧贴边标签方案 - -## 1. 背景 - -当前 AI 助手入口已经尝试过两种方案: - -1. 标题栏右侧、靠近窗口控制按钮的胶囊按钮 -2. 标题栏左侧、紧跟应用标题的胶囊按钮 - -这两种方案都存在同一个问题:入口仍然被感知为“标题栏按钮”或“全局工具按钮”,而不是“从右侧展开的协作面板入口”。用户已经明确选择将 AI 助手定义为协作入口,而非标题栏功能按钮或左侧工作流工具。 - -本次设计目标是重新定义 AI 助手入口的空间归属关系,让入口与右侧 AI 面板形成直接、自然的视觉和交互映射。 - -## 2. 设计目标 - -- 让 AI 入口看起来像右侧面板的入口,而不是标题栏按钮 -- 让入口位置与 AI 面板展开方向保持一致 -- 降低入口对标题栏和主工作区视觉层级的干扰 -- 保持现有 AI 面板开关逻辑、面板位置、聊天状态和快捷键不变 -- 保持左侧 `Sider` 顶部工具组为四个常规工具入口,不再放回 AI - -## 3. 非目标 - -- 不修改 AI 面板本体的信息架构 -- 不修改 AI 聊天、会话、模型、上下文逻辑 -- 不新增 AI 专属状态存储字段 -- 不重新设计标题栏 -- 不把 AI 面板改成左侧抽屉或底部抽屉 - -## 4. 已评估方案 - -### 方案 A:标题栏入口 - -把 AI 放在标题栏右侧或标题后方。 - -结论:放弃。 - -原因: - -- 容易被识别成窗口级按钮,而不是右侧协作面板入口 -- 入口与 AI 面板的空间关系过远 -- 标题栏承担拖拽、双击、窗口控制等系统职责,额外入口容易显得突兀 - -### 方案 B:内容区悬浮胶囊 - -把 AI 入口放在内容区右上角,作为脱离标题栏的悬浮按钮。 - -结论:未采用。 - -原因: - -- 比标题栏好,但仍然像“漂浮的按钮” -- 视觉上还是偏独立动作,而不是面板把手 -- 容易与未来内容区工具条或右上角操作区冲突 - -### 方案 C:右侧贴边入口 - -把 AI 入口挂在主内容区右边缘、贴近 AI 面板出现位置。 - -结论:采用。 - -原因: - -- 最符合“右侧面板从这里展开”的直觉 -- 可以脱离标题栏和窗口控制区 -- 与 AI 面板的视觉关系最强 -- 更符合“协作入口”而不是“工具按钮”的产品定位 - -## 5. 最终方案 - -### 5.1 位置 - -- AI 入口彻底离开标题栏 -- AI 入口不再出现在左侧 `Sider` 顶部工具组 -- 入口挂载在主内容区容器内部,而不是窗口根节点 -- 位置锚点是主内容区最右侧边缘,即现有 AI 面板展开出来的那条边 -- 默认位于内容区右上方,明显低于标题栏底边,避免再与标题栏形成视觉混淆 - -### 5.2 形态 - -选定形态为 `A2:上角贴边标签`。 - -入口外观定义如下: - -- 采用“半嵌入式贴边标签”而不是完整胶囊按钮 -- 标签右侧贴边,左侧圆角外露 -- 默认文案收敛为 `AI` -- 保留一个轻量机器人图标,用于快速识别 -- 视觉风格使用面板边框色、弱背景和轻圆角 -- 不使用强阴影、不使用主按钮视觉、不做明显浮层感 - -目标效果是:入口更像右侧协作面板的标签,而不是单独悬浮的交互控件。 - -### 5.3 状态 - -关闭态: - -- 标签保持低存在感 -- 弱背景、弱边框、常规文字色 -- 保证可发现,但不抢主内容注意力 - -悬停态: - -- 仅做轻微提亮 -- 允许边框轻微增强 -- 不放大、不上浮、不出现明显浮层阴影 - -打开态: - -- 标签切换到 AI 激活色 -- 视觉上与右侧 AI 面板顶部外沿形成连续感 -- 不隐藏入口,让用户明确知道“标签已经展开成右侧这块面板” - -## 6. 交互定义 - -- 点击贴边标签时,仍然调用现有 AI 面板开关逻辑 -- AI 面板继续从右侧展开,不改变方向 -- 关闭面板后,标签恢复为单独的贴边入口 -- 打开面板时,标签保留在面板外沿作为“已展开标识” -- 当面板宽度被拖动时,标签始终贴在面板外沿,不与主内容区脱节 -- 在窄窗口下优先保证标签可点击,必要时继续保留 `AI` 简写,不要求展开成长文案 - -## 7. 实现边界 - -- 主入口显示与定位由 `App.tsx` 管理 -- AI 面板本身只保留现有展开、关闭和宽度调整行为 -- 状态继续复用现有 `aiPanelVisible`、`toggleAIPanel`、`setAIPanelVisible` -- 不新增持久化字段,不改 store 结构 -- 左侧 `Sider` 顶部继续保留四个工具按钮:`工具 / 代理 / 主题 / 关于` -- 标题栏恢复为纯窗口级区域,不继续承载 AI 入口 - -## 8. 验证口径 - -实现后重点验证以下事项: - -1. 关闭态时,右侧贴边标签是否自然,不像悬浮按钮 -2. 打开态时,标签是否与 AI 面板形成清晰连续关系 -3. 拖动 AI 面板宽度后,标签是否仍贴在面板外沿 -4. 窄窗口下标签是否仍可点击,不与内容区遮挡 -5. 标题栏空白区、窗口控制按钮、左侧侧栏工具组是否恢复干净,不再承担 AI 入口职责 - -## 9. 风险与注意事项 - -- 贴边标签如果做得过大,会重新变成“漂浮按钮” -- 贴边标签如果做得过窄,会影响关闭态可发现性 -- 标签定位需要明确绑定主内容区右边界,而不是整个窗口最右边界,否则在不同布局状态下容易错位 -- 打开态与面板外沿的连续关系必须处理好,否则会显得像两个无关控件 - -## 10. 决策结论 - -本次 AI 助手入口重设计的最终结论是: - -- 不再使用标题栏入口 -- 不回退到左侧工具条入口 -- 采用主内容区右侧的贴边标签方案 -- 采用 `A2` 形态,即“上角贴边标签” - -这是当前最符合“协作入口”定位的方案,也是最能解释 AI 面板空间来源的方案。 diff --git a/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md b/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md deleted file mode 100644 index b5261ee..0000000 --- a/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md +++ /dev/null @@ -1,63 +0,0 @@ -# 需求进度追踪 - AI助手入口迁移到主内容区右侧贴边标签 - -## 1. 需求摘要 -- 需求名称:AI 助手入口迁移到主内容区右侧贴边标签 -- 提出日期:2026-03-28 -- 负责人:Codex -- 目标:将 AI 助手入口从左侧 `Sider` 顶部工具条和标题栏中移出,改为主内容区右侧边缘的 `A2` 贴边标签入口,并保持现有 AI 面板开关逻辑不变。 -- 非目标:不调整 AI 面板展开方向,不修改 AI 聊天逻辑,不新增快捷键或状态存储。 - -## 2. 范围与验收 -- 范围:`App.tsx` 主内容区与 AI 面板装配位置、左侧 `Sider` 顶部工具条、`aiEntryLayout` 布局辅助与相关前端回归测试。 -- 验收标准:主内容区右侧边缘出现 `A2` 贴边标签入口;标题栏与左侧 `Sider` 顶部都不再显示 AI 入口;按钮点击仍能正常打开/关闭 AI 面板,且打开后标签与面板外沿形成连续关系。 -- 依赖与约束:不修改 AI 面板展开方向;保持现有 store 结构与聊天逻辑;需兼容窄窗口下的点击区域。 - -## 3. 里程碑与进度 -- [x] 阶段 1(需求澄清):确认 AI 入口应按“协作入口”重新设计 -- [x] 阶段 2(影响分析):定位标题栏、侧栏工具条与 AI 面板开关实现 -- [x] 阶段 3(方案设计):完成 `A2` 右侧贴边标签方案设计与评审 -- [x] 阶段 4(实施计划):输出实现计划文档 -- [x] 阶段 5(实现与自检):完成 helper 单测与前端构建验证 -- [ ] 阶段 6(评审与交付):待手工界面确认后整理风险、回滚和验证点 -- [ ] 阶段 7(发布与观察):待后续观察 - -## 4. 变更清单 -- 已完成:新增 `frontend/src/utils/aiEntryLayout.ts` 与配套单测;左侧 `Sider` 顶部恢复四个工具按钮;AI 入口改为主内容区右侧贴边标签;修复打开 AI 面板后贴边标签压住头部按钮区的问题,打开态现在附着到面板左外沿。 -- 进行中:无。 -- 待处理:本机手工确认贴边标签的实际视觉位置、打开态连续关系和窄窗口点击区。 - -## 5. 风险与阻塞 -- 风险:贴边标签如果尺寸过大,会重新变成悬浮按钮;如果过小,会影响关闭态可发现性;如果锚点错误,打开面板后会与外沿脱节。 -- 阻塞:自动化浏览器手工验证受限于本机 Playwright Chrome 安装失败(权限不足);尝试直接通过 Edge 拉起本地 URL 也被当前执行策略拦截,无法由 Codex 自动代开页面。 -- 缓解措施:当前已用 helper 单测与 `npm run build` 验证静态接线;界面最终位置仍需你在本机直接运行后再看一眼。 - -## 6. 决策记录 -- 决策 1:AI 入口定位为“协作入口”,而不是标题栏按钮或侧栏工具按钮。 -- 决策 2:放弃标题栏右侧与标题后入口方案,改为主内容区右侧贴边标签。 -- 决策 3:选定 `A2` 形态,即上角贴边标签,而不是竖向拉手或极简细窄开关。 -- 决策 4:左侧 `Sider` 顶部保留四个工具按钮,AI 不再回到该区域。 -- 决策 5:打开态贴边标签的锚点必须落在 `panel-shell` 左外沿,而不能落在面板右上角内部,否则会与 `AIChatHeader` 操作区重叠。 - -## 7. 验证记录 -- 验证项:`npm test -- src/utils/aiEntryLayout.test.ts` -- 结果:通过,`6/6` 用例通过。 -- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中执行,Vitest 输出 `1 passed / 6 passed`。 - -- 验证项:`npm test -- src/utils/aiEntryLayout.test.ts`(打开态锚点回归) -- 结果:先失败后通过;新增打开态外侧附着回归后,最终 `8/8` 用例通过。 -- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中先得到 `resolveAIEdgeHandleDockStyle is not a function` 的红灯,随后补充 dock style helper 后重新执行,Vitest 输出 `1 passed / 8 passed`。 - -- 验证项:`npm run build` -- 结果:通过,`tsc && vite build` 退出码为 `0`。 -- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中执行,Vite 完成生产构建,存在既有 chunk size warning,但不阻塞本次改动。 - -- 手工验证项:浏览器态界面核对 -- 结果:未完成。 -- 证据(日志/截图/链接):2026-03-28 16:24:29 +08:00 再次尝试时,Playwright 仍因缺少 Chrome 且安装需要更高权限而失败;尝试通过本机 Edge 打开 `http://localhost:5173` 时又被当前命令执行策略拦截,未能自动采集界面证据。 - -- 计划文档:`docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md` -- 设计文档:`docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md` - -## 8. 下一步 -- 下一步行动:当前实现先保留在隔离 worktree `C:\Users\yangguofeng\.config\superpowers\worktrees\GoNavi\ai-edge-handle-entry`,待你本机目测确认后,再决定是否合并回 `dev`。 -- 负责人:Codex From eeef0f06ede02a54475f50d676e8b3b05dc2a8f5 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Mar 2026 17:40:27 +0800 Subject: [PATCH 04/22] =?UTF-8?q?=F0=9F=90=9B=20fix(app):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BE=9B=E5=BA=94=E5=95=86=E9=A2=84=E8=AE=BE=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E5=B9=B6=E5=85=BC=E5=AE=B9Wails=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=B5=84=E6=BA=90=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽离供应商预设匹配逻辑,避免自定义 OpenAI 端点误识别为千问 Coding Plan - 调整 AI 设置弹窗的预设回填逻辑,并补充预设识别回归测试 - 通过 dev/prod build tag 拆分前端资源装配,避免开发模式依赖 frontend/dist --- assets_dev.go | 9 ++ assets_prod.go | 13 +++ frontend/package.json.md5 | 2 +- frontend/src/components/AISettingsModal.tsx | 29 +----- frontend/src/utils/aiProviderPresets.test.ts | 92 +++++++++++++++++-- frontend/src/utils/aiProviderPresets.ts | 93 +++++++++++++++++--- main.go | 4 - 7 files changed, 193 insertions(+), 49 deletions(-) create mode 100644 assets_dev.go create mode 100644 assets_prod.go diff --git a/assets_dev.go b/assets_dev.go new file mode 100644 index 0000000..2d3caf3 --- /dev/null +++ b/assets_dev.go @@ -0,0 +1,9 @@ +//go:build dev + +package main + +import "os" + +// 开发模式下由 Wails DevServer 提供前端资源,这里只提供一个稳定的占位 FS, +// 避免编译时依赖 frontend/dist 被并发重建。 +var assets = os.DirFS(".") diff --git a/assets_prod.go b/assets_prod.go new file mode 100644 index 0000000..6cf94d5 --- /dev/null +++ b/assets_prod.go @@ -0,0 +1,13 @@ +//go:build !dev + +package main + +import ( + "embed" + "io/fs" +) + +//go:embed all:frontend/dist +var embeddedAssets embed.FS + +var assets fs.FS = embeddedAssets diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 3018db7..efbd2b6 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -dcb87159cf0f1f6f750d1c4870911d3f \ No newline at end of file +6ba85e4f456d2c0d230cab198c7dc02b \ No newline at end of file diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index e148379..403f352 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -3,12 +3,10 @@ import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Ta import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons'; import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types'; import { - getProviderFingerprint, - getProviderHostname, - matchQwenPresetKey, QWEN_BAILIAN_ANTHROPIC_BASE_URL, QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, QWEN_CODING_PLAN_MODELS, + resolveProviderPresetKey, resolvePresetBaseURL, resolvePresetModelSelection, resolvePresetTransport, @@ -62,28 +60,9 @@ const PROVIDER_PRESETS: ProviderPreset[] = [ const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1]; -const matchProviderPreset = (provider: Pick): ProviderPreset => { - const qwenPresetKey = matchQwenPresetKey(provider); - if (qwenPresetKey) { - return findPreset(qwenPresetKey); - } - const fingerprint = getProviderFingerprint(provider.baseUrl); - const exactPreset = PROVIDER_PRESETS.find(pr => - pr.backendType === provider.type - && fingerprint !== '' - && fingerprint === getProviderFingerprint(pr.defaultBaseUrl) - ); - if (exactPreset) { - return exactPreset; - } - - const host = getProviderHostname(provider.baseUrl); - if (host.endsWith('moonshot.cn')) { - return findPreset('moonshot'); - } - return PROVIDER_PRESETS.find(pr => pr.backendType === provider.type && host !== '' && host === getProviderHostname(pr.defaultBaseUrl)) - || PROVIDER_PRESETS.find(pr => pr.backendType === provider.type) - || findPreset('custom'); +const matchProviderPreset = (provider: Pick): ProviderPreset => { + const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom'); + return findPreset(presetKey); }; const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [ diff --git a/frontend/src/utils/aiProviderPresets.test.ts b/frontend/src/utils/aiProviderPresets.test.ts index 98bfc67..c1384e1 100644 --- a/frontend/src/utils/aiProviderPresets.test.ts +++ b/frontend/src/utils/aiProviderPresets.test.ts @@ -1,15 +1,37 @@ import { describe, expect, it } from 'vitest'; - +import type { AIProviderType } from '../types'; import { - matchQwenPresetKey, + LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + QWEN_BAILIAN_ANTHROPIC_BASE_URL, QWEN_BAILIAN_MODELS_BASE_URL, QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, QWEN_CODING_PLAN_MODELS, + matchQwenPresetKey, resolvePresetBaseURL, resolvePresetModelSelection, resolvePresetTransport, + resolveProviderPresetKey, } from './aiProviderPresets'; +type PresetMatcher = { + key: string; + backendType: AIProviderType; + defaultBaseUrl: string; + fixedApiFormat?: string; +}; + +const PRESETS: PresetMatcher[] = [ + { key: 'openai', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1' }, + { key: 'qwen-bailian', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL }, + { + key: 'qwen-coding-plan', + backendType: 'custom', + defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, + fixedApiFormat: 'claude-cli', + }, + { key: 'custom', backendType: 'custom', defaultBaseUrl: '' }, +]; + describe('ai provider preset helpers', () => { it('maps legacy Bailian compatible-mode URL back to the Bailian preset', () => { expect(matchQwenPresetKey({ @@ -18,13 +40,6 @@ describe('ai provider preset helpers', () => { })).toBe('qwen-bailian'); }); - it('maps Coding Plan anthropic URL to the dedicated Coding Plan preset', () => { - expect(matchQwenPresetKey({ - type: 'anthropic', - baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, - })).toBe('qwen-coding-plan'); - }); - it('maps Coding Plan Claude CLI config back to the dedicated Coding Plan preset', () => { expect(matchQwenPresetKey({ type: 'custom', @@ -33,6 +48,21 @@ describe('ai provider preset helpers', () => { })).toBe('qwen-coding-plan'); }); + it('maps legacy Coding Plan OpenAI config back to the dedicated Coding Plan preset', () => { + expect(matchQwenPresetKey({ + type: 'openai', + baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + })).toBe('qwen-coding-plan'); + }); + + it('does not treat a custom OpenAI endpoint as the built-in Coding Plan preset', () => { + expect(matchQwenPresetKey({ + type: 'custom', + apiFormat: 'openai', + baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + })).toBeNull(); + }); + it('does not keep a baked-in model list for the Coding Plan preset', () => { expect(QWEN_CODING_PLAN_MODELS).toEqual([ 'qwen3.5-plus', @@ -109,3 +139,47 @@ describe('ai provider preset helpers', () => { }); }); }); + +describe('resolveProviderPresetKey', () => { + it('不会把自定义 OpenAI 端点误识别成千问 Coding Plan', () => { + const key = resolveProviderPresetKey( + { + type: 'custom', + apiFormat: 'openai', + baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('custom'); + }); + + it('仍然能识别当前内置的千问 Coding Plan 预设', () => { + const key = resolveProviderPresetKey( + { + type: 'custom', + apiFormat: 'claude-cli', + baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('qwen-coding-plan'); + }); + + it('仍然能识别当前内置的千问百炼预设', () => { + const key = resolveProviderPresetKey( + { + type: 'anthropic', + apiFormat: undefined, + baseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('qwen-bailian'); + }); +}); diff --git a/frontend/src/utils/aiProviderPresets.ts b/frontend/src/utils/aiProviderPresets.ts index 7d39a5c..0e96558 100644 --- a/frontend/src/utils/aiProviderPresets.ts +++ b/frontend/src/utils/aiProviderPresets.ts @@ -49,6 +49,13 @@ export interface ResolvePresetTransportResult { apiFormat?: string; } +export interface ProviderPresetMatcher { + key: string; + backendType: AIProviderType; + defaultBaseUrl: string; + fixedApiFormat?: string; +} + export const getProviderHostname = (raw?: string): string => { if (!raw) return ''; try { @@ -71,25 +78,91 @@ export const getProviderFingerprint = (raw?: string): string => { export const matchQwenPresetKey = (provider: Pick): string | null => { const fingerprint = getProviderFingerprint(provider.baseUrl); - const bailianFingerprints = new Set([ - getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL), - getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL), - ]); - if (fingerprint !== '' && bailianFingerprints.has(fingerprint)) { + + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL) + && provider.type === 'anthropic' + ) { return 'qwen-bailian'; } - const codingPlanFingerprints = new Set([ - getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL), - getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL), - ]); - if (fingerprint !== '' && codingPlanFingerprints.has(fingerprint)) { + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL) + && provider.type === 'openai' + ) { + return 'qwen-bailian'; + } + + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL) + && provider.type === 'custom' + && provider.apiFormat === 'claude-cli' + ) { + return 'qwen-coding-plan'; + } + + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL) + && provider.type === 'openai' + ) { return 'qwen-coding-plan'; } return null; }; +export const resolveProviderPresetKey = ( + provider: Pick, + presets: ProviderPresetMatcher[], + fallbackKey = 'custom', +): string => { + const qwenPresetKey = matchQwenPresetKey(provider); + if (qwenPresetKey) { + return qwenPresetKey; + } + + const fingerprint = getProviderFingerprint(provider.baseUrl); + const exactPreset = presets.find((preset) => + preset.backendType === provider.type + && fingerprint !== '' + && fingerprint === getProviderFingerprint(preset.defaultBaseUrl) + && (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat), + ); + if (exactPreset) { + return exactPreset.key; + } + + // custom 供应商必须保守处理,避免仅凭 host 错误吞掉用户显式保存的自定义配置。 + if (provider.type === 'custom') { + return fallbackKey; + } + + const host = getProviderHostname(provider.baseUrl); + if (provider.type === 'anthropic' && host.endsWith('moonshot.cn')) { + const moonshotPreset = presets.find((preset) => preset.key === 'moonshot'); + if (moonshotPreset) { + return moonshotPreset.key; + } + } + + const hostPreset = presets.find((preset) => + preset.backendType === provider.type + && host !== '' + && host === getProviderHostname(preset.defaultBaseUrl) + && (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat), + ); + if (hostPreset) { + return hostPreset.key; + } + + const typePreset = presets.find((preset) => preset.backendType === provider.type && !preset.fixedApiFormat); + return typePreset?.key || fallbackKey; +}; + export const resolvePresetModelSelection = ({ presetKey, presetDefaultModel, diff --git a/main.go b/main.go index 8c4d999..d312635 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "embed" aiservice "GoNavi-Wails/internal/ai/service" "GoNavi-Wails/internal/app" @@ -15,9 +14,6 @@ import ( "github.com/wailsapp/wails/v2/pkg/options/windows" ) -//go:embed all:frontend/dist -var assets embed.FS - func main() { // Create an instance of the app structure application := app.NewApp() From c85de27aac013c22c9dbc950513a7984a003c2bd Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:17:37 +0800 Subject: [PATCH 05/22] =?UTF-8?q?=E2=9A=A1=20perf(query):=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=86=99=E8=AF=AD=E5=8F=A5=E8=B5=B0=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=80=A7=20Exec=20=E5=87=8F=E5=B0=91=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E5=BE=80=E8=BF=94=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=A4=A7=E9=87=8F?= =?UTF-8?q?=20INSERT=20=E6=89=A7=E8=A1=8C=E6=85=A2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 BatchWriteExecer 可选接口(ExecBatchContext) - MySQL/MariaDB/Doris/PostgreSQL/SQLite/DuckDB 实现该接口 - DBQueryMulti 检测到纯写操作时走批量路径,500 条 INSERT 从 500 次网络往返降至 1 次 - 混合语句(SELECT + INSERT)及不支持该接口的驱动继续走原有逐条执行路径 --- internal/app/methods_db.go | 42 ++++++++++++++++++++++++++++++++++++ internal/db/database.go | 7 ++++++ internal/db/duckdb_impl.go | 11 ++++++++++ internal/db/mariadb_impl.go | 11 ++++++++++ internal/db/mysql_impl.go | 11 ++++++++++ internal/db/postgres_impl.go | 11 ++++++++++ internal/db/sqlite_impl.go | 11 ++++++++++ 7 files changed, 104 insertions(+) diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 14a8bb1..59c6d1c 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -581,6 +581,48 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu } } + // 全部为写操作且驱动支持批量 Exec → 一次性发送,大幅减少网络往返 + // 适用于 MySQL/MariaDB/Doris/PostgreSQL/SQLite/DuckDB 等支持多语句 Exec 的驱动 + if !allReadOnly { + allWrite := true + for _, stmt := range statements { + if strings.TrimSpace(stmt) != "" && isReadOnlySQLQuery(runConfig.Type, stmt) { + allWrite = false + break + } + } + if allWrite { + if batcher, ok := dbInst.(db.BatchWriteExecer); ok { + affected, batchErr := batcher.ExecBatchContext(ctx, query) + if batchErr != nil && shouldRefreshCachedConnection(batchErr) { + if a.invalidateCachedDatabase(runConfig, batchErr) { + retryInst, retryErr := a.getDatabaseForcePing(runConfig) + if retryErr != nil { + logger.Error(retryErr, "DBQueryMulti 批量写重建连接失败:%s", formatConnSummary(runConfig)) + return connection.QueryResult{Success: false, Message: retryErr.Error(), QueryID: queryID} + } + if retryBatcher, ok2 := retryInst.(db.BatchWriteExecer); ok2 { + affected, batchErr = retryBatcher.ExecBatchContext(ctx, query) + } + } + } + if batchErr != nil { + logger.Error(batchErr, "DBQueryMulti 批量写执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) + return connection.QueryResult{Success: false, Message: batchErr.Error(), QueryID: queryID} + } + logger.Infof("DBQueryMulti 批量写执行成功:%s 语句数=%d affectedRows=%d", formatConnSummary(runConfig), len(statements), affected) + return connection.QueryResult{ + Success: true, + Data: []connection.ResultSetData{{ + Rows: []map[string]interface{}{{"affectedRows": affected}}, + Columns: []string{"affectedRows"}, + }}, + QueryID: queryID, + } + } + } + } + var resultSets []connection.ResultSetData for idx, stmt := range statements { stmt = strings.TrimSpace(stmt) diff --git a/internal/db/database.go b/internal/db/database.go index 038a48a..4e6f228 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -50,6 +50,13 @@ type MultiResultQuerierContext interface { QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) } +// BatchWriteExecer 是可选接口,支持将多条写语句一次性批量发送执行。 +// 驱动的底层连接需支持多语句协议(如 MySQL multiStatements=true、PostgreSQL 原生多语句)。 +// 实现此接口可大幅减少批量 INSERT/UPDATE/DELETE 的网络往返次数。 +type BatchWriteExecer interface { + ExecBatchContext(ctx context.Context, query string) (int64, error) +} + // BatchApplier 定义了批量变更提交接口。 // 支持批量编辑的驱动实现此接口,用于一次性提交前端 DataGrid 中的增删改操作。 type BatchApplier interface { diff --git a/internal/db/duckdb_impl.go b/internal/db/duckdb_impl.go index 843b49b..eeefdf2 100644 --- a/internal/db/duckdb_impl.go +++ b/internal/db/duckdb_impl.go @@ -90,6 +90,17 @@ func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error) return scanRows(rows) } +func (d *DuckDB) ExecBatchContext(ctx context.Context, query string) (int64, error) { + if d.conn == nil { + return 0, fmt.Errorf("连接未打开") + } + res, err := d.conn.ExecContext(ctx, query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) { if d.conn == nil { return 0, fmt.Errorf("连接未打开") diff --git a/internal/db/mariadb_impl.go b/internal/db/mariadb_impl.go index b21b5ed..c13e83b 100644 --- a/internal/db/mariadb_impl.go +++ b/internal/db/mariadb_impl.go @@ -135,6 +135,17 @@ func (m *MariaDB) Query(query string) ([]map[string]interface{}, []string, error return scanRows(rows) } +func (m *MariaDB) ExecBatchContext(ctx context.Context, query string) (int64, error) { + if m.conn == nil { + return 0, fmt.Errorf("连接未打开") + } + res, err := m.conn.ExecContext(ctx, query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (m *MariaDB) ExecContext(ctx context.Context, query string) (int64, error) { if m.conn == nil { return 0, fmt.Errorf("连接未打开") diff --git a/internal/db/mysql_impl.go b/internal/db/mysql_impl.go index baca324..d0459f9 100644 --- a/internal/db/mysql_impl.go +++ b/internal/db/mysql_impl.go @@ -329,6 +329,17 @@ func (m *MySQLDB) Query(query string) ([]map[string]interface{}, []string, error return scanRows(rows) } +func (m *MySQLDB) ExecBatchContext(ctx context.Context, query string) (int64, error) { + if m.conn == nil { + return 0, fmt.Errorf("连接未打开") + } + res, err := m.conn.ExecContext(ctx, query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (m *MySQLDB) ExecContext(ctx context.Context, query string) (int64, error) { if m.conn == nil { return 0, fmt.Errorf("连接未打开") diff --git a/internal/db/postgres_impl.go b/internal/db/postgres_impl.go index dc6a06b..727b3dc 100644 --- a/internal/db/postgres_impl.go +++ b/internal/db/postgres_impl.go @@ -233,6 +233,17 @@ func (p *PostgresDB) Query(query string) ([]map[string]interface{}, []string, er return scanRows(rows) } +func (p *PostgresDB) ExecBatchContext(ctx context.Context, query string) (int64, error) { + if p.conn == nil { + return 0, fmt.Errorf("连接未打开") + } + res, err := p.conn.ExecContext(ctx, query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (p *PostgresDB) ExecContext(ctx context.Context, query string) (int64, error) { if p.conn == nil { return 0, fmt.Errorf("连接未打开") diff --git a/internal/db/sqlite_impl.go b/internal/db/sqlite_impl.go index 4f76866..7d74610 100644 --- a/internal/db/sqlite_impl.go +++ b/internal/db/sqlite_impl.go @@ -222,6 +222,17 @@ func (s *SQLiteDB) Query(query string) ([]map[string]interface{}, []string, erro return scanRows(rows) } +func (s *SQLiteDB) ExecBatchContext(ctx context.Context, query string) (int64, error) { + if s.conn == nil { + return 0, fmt.Errorf("连接未打开") + } + res, err := s.conn.ExecContext(ctx, query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (s *SQLiteDB) ExecContext(ctx context.Context, query string) (int64, error) { if s.conn == nil { return 0, fmt.Errorf("连接未打开") From 6e55d638770125d30546c6192d56255291506126 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 30 Mar 2026 10:43:46 +0800 Subject: [PATCH 06/22] =?UTF-8?q?=F0=9F=93=9D=20docs(readme):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0AI=E5=8A=A9=E6=89=8B=E5=8A=9F=E8=83=BD=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E4=B8=8E=E7=95=8C=E9=9D=A2=E6=88=AA=E5=9B=BE=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=8F=8B=E6=83=85=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 核心特性:补充 AI 智能助手的多模型支持、表结构上下文和快捷指令介绍 - 界面更新:移除旧版截图,替换为全新的 AI 对话、模型配置与上下文选择界面截图 - 友情链接:在文档底部补充 linux.do 及 AI全书 链接 - 多语言:同步更新中英文双语版 README 细节内容 --- README.md | 22 ++++++++++++++++------ README.zh-CN.md | 22 ++++++++++++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c2ad140..0505250 100644 --- a/README.md +++ b/README.md @@ -53,19 +53,24 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience

📸 Screenshots

- image - image - image + image + image + image
- image - image - image + image + image + image
--- ## Key Features +### AI Assistant (New) +- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support. +- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance. +- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs. + ### Performance - **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets). - **Virtualized rendering**: keeps large result sets responsive. @@ -207,6 +212,11 @@ For the full workflow, branch model, and maintainer sync rules, see: External contributors should open pull requests directly against `main`. +## Links + +- [linux.do](https://linux.do/) +- [AIBook](https://aibook.ren/) +- ## License Licensed under [Apache-2.0](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index 6c74566..d83d025 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -52,19 +52,24 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做

📸 项目截图

- image - image - image + image + image + image
- image - image - image + image + image + image
--- ## 核心特性 +### AI 智能助手 (New) +- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude,同时支持任意自定义兼容 OpenAI 格式的 API。 +- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI,让 SQL 生成、分析变得更精准。 +- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等)。 + ### 性能与交互 - 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。 - 虚拟滚动渲染,降低大结果集卡顿风险。 @@ -190,6 +195,11 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0 外部贡献者统一直接向 `main` 发起 Pull Request。 +## 友情链接 + +- [linux.do](https://linux.do/) +- [AI全书](https://aibook.ren/) + ## 开源协议 本项目采用 [Apache-2.0 协议](LICENSE)。 From aa9d8d243a02afaebb8462d7a22761ded4a5dfd8 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 30 Mar 2026 16:48:19 +0800 Subject: [PATCH 07/22] =?UTF-8?q?=E2=9C=A8=20feat(redis/monitor/oracle/dat?= =?UTF-8?q?a-viewer):=20=E6=96=B0=E5=A2=9E=20Redis=20=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E5=B9=B6=E4=BC=98=E5=8C=96=20Oracle=20?= =?UTF-8?q?=E5=A4=A7=E8=A1=A8=E9=A2=84=E8=A7=88=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RedisMonitor 面板,展示 QPS、内存、CPU、连接数和键数量趋势图 - 引入 recharts 依赖并补齐监控图表所需前端包与锁文件 - Sidebar 新增 Redis 实例监控入口,TabManager 与 TabData 接入 redis-monitor 页签类型 - RedisCommandEditor 支持多行脚本块解析、选区执行、耗时记录与终端化结果展示 - Oracle 表预览移除自动精确 COUNT(*),避免打开大表时额外阻塞 - 无筛选整表预览接入 ALL_TABLES.NUM_ROWS 近似总数展示,并拆分近似总数与近似总页数语义 --- README.md | 2 +- frontend/package-lock.json | 189 +++++++++ frontend/package.json | 1 + frontend/package.json.md5 | 2 +- frontend/src/components/DataGrid.tsx | 52 +-- frontend/src/components/DataViewer.tsx | 191 +++++---- frontend/src/components/QueryEditor.tsx | 2 +- .../src/components/RedisCommandEditor.tsx | 391 ++++++++++++++---- frontend/src/components/RedisMonitor.tsx | 378 +++++++++++++++++ frontend/src/components/Sidebar.tsx | 31 +- frontend/src/components/TabManager.tsx | 10 +- frontend/src/types.ts | 2 +- .../src/utils/approximateTableCount.test.ts | 28 ++ frontend/src/utils/approximateTableCount.ts | 106 +++++ frontend/src/utils/dataGridPagination.test.ts | 57 +++ frontend/src/utils/dataGridPagination.ts | 92 +++++ .../src/utils/dataSourceCapabilities.test.ts | 32 ++ frontend/src/utils/dataSourceCapabilities.ts | 10 +- .../src/utils/dataViewerAutoFetch.test.ts | 26 ++ frontend/src/utils/dataViewerAutoFetch.ts | 16 + 20 files changed, 1424 insertions(+), 194 deletions(-) create mode 100644 frontend/src/components/RedisMonitor.tsx create mode 100644 frontend/src/utils/approximateTableCount.test.ts create mode 100644 frontend/src/utils/approximateTableCount.ts create mode 100644 frontend/src/utils/dataGridPagination.test.ts create mode 100644 frontend/src/utils/dataGridPagination.ts create mode 100644 frontend/src/utils/dataSourceCapabilities.test.ts create mode 100644 frontend/src/utils/dataViewerAutoFetch.test.ts create mode 100644 frontend/src/utils/dataViewerAutoFetch.ts diff --git a/README.md b/README.md index 0505250..a7ccd2e 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ External contributors should open pull requests directly against `main`. - [linux.do](https://linux.do/) - [AIBook](https://aibook.ren/) -- + ## License Licensed under [Apache-2.0](LICENSE). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dd3c9b5..98891ca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "react-markdown": "^10.1.0", "react-resizable": "^3.1.3", "react-syntax-highlighter": "^16.1.1", + "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "sql-formatter": "^15.7.0", "uuid": "^9.0.1", @@ -1210,6 +1211,42 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1567,6 +1604,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2001,6 +2050,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -3046,6 +3101,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -3130,6 +3191,16 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -3211,6 +3282,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", @@ -3404,6 +3481,16 @@ "node": ">=0.10.0" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -5511,6 +5598,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5555,6 +5665,51 @@ "react": ">= 0.14.0" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/refractor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", @@ -5637,6 +5792,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -5888,6 +6049,12 @@ "node": ">=12.22" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", @@ -6178,6 +6345,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index 512b5ac..904b7a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "react-markdown": "^10.1.0", "react-resizable": "^3.1.3", "react-syntax-highlighter": "^16.1.1", + "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "sql-formatter": "^15.7.0", "uuid": "^9.0.1", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index efbd2b6..b8be944 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -6ba85e4f456d2c0d230cab198c7dc02b \ No newline at end of file +f697e821b4acd5cf614d63d46453e8a4 \ No newline at end of file diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index ab5f5d6..bc82d9d 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -32,6 +32,7 @@ import 'react-resizable/css/styles.css'; import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; +import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination'; import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout'; // --- Error Boundary --- @@ -818,6 +819,7 @@ interface DataGridProps { total: number, totalKnown?: boolean, totalApprox?: boolean, + approximateTotal?: number, totalCountLoading?: boolean, totalCountCancelled?: boolean, }; @@ -995,7 +997,9 @@ const DataGrid: React.FC = ({ const selectionColumnWidth = 46; const currentConnConfig = connections.find(c => c.id === connectionId)?.config; const dataSourceCaps = getDataSourceCapabilities(currentConnConfig); - const isDuckDBConnection = dataSourceCaps.type === 'duckdb'; + const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount; + const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount; + const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages; const supportsCopyInsert = dataSourceCaps.supportsCopyInsert; const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport; const isQueryResultExport = exportScope === 'queryResult'; @@ -4483,37 +4487,20 @@ const DataGrid: React.FC = ({ const paginationSummaryText = useMemo(() => { if (!pagination) return ''; - const total = Number.isFinite(pagination.total) ? pagination.total : 0; - const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0)); - const hasValidRange = total > 0 && rangeStart > 0; - const rangeEnd = hasValidRange ? Math.min(total, rangeStart + pagination.pageSize - 1) : 0; - const currentCount = hasValidRange ? Math.max(0, rangeEnd - rangeStart + 1) : 0; - - if (pagination.totalKnown === false) { - if (isDuckDBConnection) { - if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`; - if (pagination.totalApprox && Number.isFinite(total) && total > 0) return `当前 ${currentCount} 条 / 约 ${total} 条`; - if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`; - return `当前 ${currentCount} 条 / 总数未统计`; - } - return `当前 ${currentCount} 条 / 正在统计总数…`; - } - - if (isDuckDBConnection && (!Number.isFinite(total) || total <= 0)) { - return '当前 0 条 / 共 0 条'; - } - - return `当前 ${currentCount} 条 / 共 ${total} 条`; - }, [pagination, isDuckDBConnection]); + return resolvePaginationSummaryText({ + pagination, + prefersManualTotalCount, + supportsApproximateTableCount, + }); + }, [pagination, prefersManualTotalCount, supportsApproximateTableCount]); const paginationPageText = useMemo(() => { if (!pagination) return ''; - const total = Number.isFinite(pagination.total) ? pagination.total : 0; - const canShowTotalPages = pagination.totalKnown !== false || (isDuckDBConnection && pagination.totalApprox && total > 0); - if (!canShowTotalPages || total <= 0) return `第 ${pagination.current} 页`; - const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pagination.pageSize))); - return `第 ${pagination.current} / ${totalPages} 页`; - }, [pagination, isDuckDBConnection]); + return resolvePaginationPageText({ + pagination, + supportsApproximateTotalPages, + }); + }, [pagination, supportsApproximateTotalPages]); const handlePageSizeChange = useCallback((value: string) => { if (!pagination || !onPageChange) return; @@ -4680,7 +4667,7 @@ const DataGrid: React.FC = ({ - {isDuckDBConnection && onRequestTotalCount && ( + {prefersManualTotalCount && onRequestTotalCount && ( <>
@@ -5539,7 +5526,10 @@ const DataGrid: React.FC = ({ { return null; }; -const parseDuckDBApproxTotalRow = (row: any): number | null => { - if (!row || typeof row !== 'object') return null; - const entries = Object.entries(row as Record); - if (entries.length === 0) return null; - - const preferredKeys = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'count', 'total']; - for (const preferred of preferredKeys) { - for (const [key, raw] of entries) { - if (String(key || '').trim().toLowerCase() !== preferred) continue; - const parsed = toNonNegativeFiniteNumber(raw); - if (parsed !== null) return parsed; - } - } - - for (const [key, raw] of entries) { - const normalized = String(key || '').trim().toLowerCase(); - if (normalized.includes('estimate') || normalized.includes('row') || normalized.includes('count') || normalized.includes('total')) { - const parsed = toNonNegativeFiniteNumber(raw); - if (parsed !== null) return parsed; - } - } - return null; -}; - const normalizeDuckDBIdentifier = (raw: string): string => { const text = String(raw || '').trim(); if (text.length >= 2) { @@ -201,7 +180,7 @@ const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => { }; }; -const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { +const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => { const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]); const [data, setData] = useState([]); const [columnNames, setColumnNames] = useState([]); @@ -214,6 +193,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const countKeyRef = useRef(''); const duckdbApproxSeqRef = useRef(0); const duckdbApproxKeyRef = useRef(''); + const oracleApproxSeqRef = useRef(0); + const oracleApproxKeyRef = useRef(''); const manualCountSeqRef = useRef(0); const manualCountKeyRef = useRef(''); const pkSeqRef = useRef(0); @@ -228,6 +209,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { left: initialViewerSnapshot.scrollLeft, }); const initialLoadRef = useRef(false); + const skipNextAutoFetchRef = useRef(false); const [pagination, setPagination] = useState({ current: initialViewerSnapshot.currentPage, @@ -246,8 +228,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const duckdbSafeSelectCacheRef = useRef>({}); const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config; const currentConnCaps = getDataSourceCapabilities(currentConnConfig); - const currentConnType = currentConnCaps.type; const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult; + const preferManualTotalCount = currentConnCaps.preferManualTotalCount; + const supportsApproximateTableCount = currentConnCaps.supportsApproximateTableCount; + const supportsApproximateTotalPages = currentConnCaps.supportsApproximateTotalPages; const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial) => { const normalizedTabId = String(tabId || '').trim(); if (!normalizedTabId) return; @@ -288,6 +272,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { pkKeyRef.current = ''; countKeyRef.current = ''; duckdbApproxKeyRef.current = ''; + oracleApproxKeyRef.current = ''; manualCountKeyRef.current = ''; duckdbSafeSelectCacheRef.current = {}; latestConfigRef.current = null; @@ -297,6 +282,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { latestCountKeyRef.current = ''; scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft }; initialLoadRef.current = false; + skipNextAutoFetchRef.current = true; setPagination(prev => ({ ...prev, current: snapshot.currentPage, @@ -304,6 +290,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { total: 0, totalKnown: false, totalApprox: false, + approximateTotal: undefined, totalCountLoading: false, totalCountCancelled: false, })); @@ -317,10 +304,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }); }, [tab.id, persistViewerSnapshot]); - const handleDuckDBManualCount = useCallback(async () => { - if (latestDbTypeRef.current !== 'duckdb') { - return; - } + const handleManualTotalCount = useCallback(async () => { const config = latestConfigRef.current; const dbName = latestDbNameRef.current; const countSql = latestCountSqlRef.current; @@ -341,7 +325,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const resCount = await DBQuery(countConfig as any, dbName, countSql); const countDuration = Date.now() - countStart; addSqlLog({ - id: `log-${Date.now()}-duckdb-manual-count`, + id: `log-${Date.now()}-manual-count`, timestamp: Date.now(), sql: countSql, status: resCount?.success ? 'success' : 'error', @@ -375,6 +359,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { total, totalKnown: true, totalApprox: false, + approximateTotal: undefined, totalCountLoading: false, totalCountCancelled: false, })); @@ -386,7 +371,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { } }, [addSqlLog]); - const handleDuckDBCancelManualCount = useCallback(() => { + const handleCancelManualTotalCount = useCallback(() => { manualCountSeqRef.current++; setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true })); }, []); @@ -438,7 +423,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const totalRows = Number(pagination.total); const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0; const totalKnown = pagination.totalKnown && hasFiniteTotal; - const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0; + const approximateTotalRows = Number(pagination.approximateTotal); + const hasApproximateTotalPages = + !totalKnown && + supportsApproximateTotalPages && + pagination.totalApprox && + Number.isFinite(approximateTotalRows) && + approximateTotalRows > 0; + const effectiveTotalRows = hasApproximateTotalPages ? approximateTotalRows : totalRows; + const totalPages = Number.isFinite(effectiveTotalRows) && effectiveTotalRows > 0 ? Math.max(1, Math.ceil(effectiveTotalRows / size)) : 0; const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page); const offset = (currentPage - 1) * size; const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse'; @@ -632,6 +625,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { total: derivedTotal, totalKnown: true, totalApprox: false, + approximateTotal: undefined, totalCountLoading: false, totalCountCancelled: false, }; @@ -647,13 +641,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { } } const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey; - if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) { + const hasApproximateTotalForCurrentKey = + prev.totalApprox && + (duckdbApproxKeyRef.current === countKey || oracleApproxKeyRef.current === countKey) && + Number.isFinite(prev.approximateTotal) && + Number(prev.approximateTotal) >= minExpectedTotal; + if (hasApproximateTotalForCurrentKey) { return { ...prev, current: currentPage, pageSize: size, + total: derivedTotal, totalKnown: false, totalApprox: true, + approximateTotal: prev.approximateTotal, totalCountLoading: keepManualCounting, totalCountCancelled: false, }; @@ -665,12 +666,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { total: derivedTotal, totalKnown: false, totalApprox: false, + approximateTotal: undefined, totalCountLoading: keepManualCounting, totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled, }; }); - const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB; + const shouldRunAsyncCount = !derivedTotalKnown && !preferManualTotalCount; if (shouldRunAsyncCount) { if (countKeyRef.current !== countKey) { countKeyRef.current = countKey; @@ -695,7 +697,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }); if (countSeqRef.current !== countSeq) return; - if (countKeyRef.current !== countKey) return; + if (latestCountKeyRef.current !== countKey) return; if (!resCount.success) return; if (!Array.isArray(resCount.data) || resCount.data.length === 0) return; @@ -708,6 +710,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { total, totalKnown: true, totalApprox: false, + approximateTotal: undefined, totalCountLoading: false, totalCountCancelled: false, })); @@ -720,48 +723,88 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { } } - if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) { - duckdbApproxKeyRef.current = countKey; - const approxSeq = ++duckdbApproxSeqRef.current; - const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName); - const escapedSchema = escapeSQLLiteral(schemaName); - const escapedTable = escapeSQLLiteral(pureTableName); - const approxConfig: any = { ...(config as any), timeout: 3 }; - const approxSqlCandidates = [ - `SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`, - `SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`, - ]; + if (!derivedTotalKnown) { + const approximateCountStrategy = supportsApproximateTableCount + ? resolveApproximateTableCountStrategy({ dbType: dbTypeLower, whereSQL }) + : 'none'; - (async () => { - for (const approxSql of approxSqlCandidates) { - try { - const approxRes = await DBQuery(approxConfig as any, dbName, approxSql); - if (duckdbApproxSeqRef.current !== approxSeq) return; - if (countKeyRef.current !== countKey) return; - if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue; + if (approximateCountStrategy === 'duckdb-estimated-size' && duckdbApproxKeyRef.current !== countKey) { + duckdbApproxKeyRef.current = countKey; + const approxSeq = ++duckdbApproxSeqRef.current; + const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName); + const escapedSchema = escapeSQLLiteral(schemaName); + const escapedTable = escapeSQLLiteral(pureTableName); + const approxConfig: any = { ...(config as any), timeout: 3 }; + const approxSqlCandidates = [ + `SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`, + `SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`, + ]; - const approxTotal = parseDuckDBApproxTotalRow(approxRes.data[0]); - if (approxTotal === null) continue; - if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue; + (async () => { + for (const approxSql of approxSqlCandidates) { + try { + const approxRes = await DBQuery(approxConfig as any, dbName, approxSql); + if (duckdbApproxSeqRef.current !== approxSeq) return; + if (latestCountKeyRef.current !== countKey) return; + if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue; + + const approxTotal = parseApproximateTableCountRow(approxRes.data[0]); + if (approxTotal === null) continue; + if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue; + + setPagination(prev => { + if (latestCountKeyRef.current !== countKey) return prev; + if (prev.totalKnown) return prev; + return { + ...prev, + totalKnown: false, + totalApprox: true, + approximateTotal: approxTotal, + totalCountCancelled: false, + }; + }); + return; + } catch { + if (duckdbApproxSeqRef.current !== approxSeq) return; + if (latestCountKeyRef.current !== countKey) return; + } + } + })(); + } + + if (approximateCountStrategy === 'oracle-num-rows' && oracleApproxKeyRef.current !== countKey) { + oracleApproxKeyRef.current = countKey; + const approxSeq = ++oracleApproxSeqRef.current; + const approxConfig: any = { ...(config as any), timeout: 3 }; + const approxSql = buildOracleApproximateTotalSql({ dbName, tableName }); + + DBQuery(approxConfig as any, dbName, approxSql) + .then((approxRes: any) => { + if (oracleApproxSeqRef.current !== approxSeq) return; + if (latestCountKeyRef.current !== countKey) return; + if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) return; + + const approxTotal = parseApproximateTableCountRow(approxRes.data[0], ['approx_total', 'num_rows', 'estimated_rows', 'row_count', 'count', 'total']); + if (approxTotal === null) return; + if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) return; setPagination(prev => { - if (countKeyRef.current !== countKey) return prev; + if (latestCountKeyRef.current !== countKey) return prev; if (prev.totalKnown) return prev; return { ...prev, - total: approxTotal, totalKnown: false, totalApprox: true, + approximateTotal: approxTotal, totalCountCancelled: false, }; }); - return; - } catch { - if (duckdbApproxSeqRef.current !== approxSeq) return; - if (countKeyRef.current !== countKey) return; - } - } - })(); + }) + .catch(() => { + if (oracleApproxSeqRef.current !== approxSeq) return; + if (latestCountKeyRef.current !== countKey) return; + }); + } } } else { message.error(String(resData.message || '查询失败')); @@ -780,7 +823,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }); } if (fetchSeqRef.current === seq) setLoading(false); - }, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]); + }, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]); // 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。 // 主键信息只会在首次加载后更新一次,避免循环查询。 @@ -828,7 +871,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]); useEffect(() => { - if (!initialLoadRef.current) { + const action = resolveDataViewerAutoFetchAction({ + skipNextAutoFetch: skipNextAutoFetchRef.current, + hasInitialLoad: initialLoadRef.current, + }); + if (action === 'skip') { + skipNextAutoFetchRef.current = false; + return; + } + if (action === 'load-current-page') { initialLoadRef.current = true; fetchData(pagination.current, pagination.pageSize); return; @@ -851,8 +902,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { onSort={handleSort} onPageChange={handlePageChange} pagination={pagination} - onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined} - onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined} + onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined} + onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined} showFilter={showFilter} onToggleFilter={handleToggleFilter} onApplyFilter={handleApplyFilter} diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index fbc0b52..4499651 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -183,7 +183,7 @@ let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type let sharedVisibleDbs: string[] = []; let sharedColumnsCacheData: Record = {}; -const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { +const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); type ResultSet = { diff --git a/frontend/src/components/RedisCommandEditor.tsx b/frontend/src/components/RedisCommandEditor.tsx index 0ca8366..7cc7b28 100644 --- a/frontend/src/components/RedisCommandEditor.tsx +++ b/frontend/src/components/RedisCommandEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; import { Button, Space, message } from 'antd'; import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons'; import { useStore } from '../store'; @@ -14,6 +14,67 @@ interface CommandResult { result: any; error?: string; timestamp: number; + durationMs: number; +} + +// 智能解析 Redis 脚本块,保护多行引号内的换行符 +function parseRedisScriptBlocks(script: string): string[] { + const blocks: string[] = []; + let currentBlock = ""; + let inQuote: string | null = null; + let isEscaping = false; + + const lines = script.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!inQuote && (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('#'))) { + continue; + } + + for (let j = 0; j < line.length; j++) { + const char = line[j]; + + if (isEscaping) { + isEscaping = false; + currentBlock += char; + continue; + } + + if (char === '\\') { + isEscaping = true; + currentBlock += char; + continue; + } + + if (char === '"' || char === "'") { + if (inQuote === char) { + inQuote = null; + } else if (!inQuote) { + inQuote = char; + } + } + + currentBlock += char; + } + + if (inQuote || (i < lines.length - 1 && currentBlock.trim() !== '')) { + if (!inQuote) { + blocks.push(currentBlock.trim()); + currentBlock = ""; + } else { + currentBlock += '\n'; + } + } + } + + if (currentBlock.trim() !== '') { + blocks.push(currentBlock.trim()); + } + + return blocks.filter(b => b.trim() !== ''); } const RedisCommandEditor: React.FC = ({ connectionId, redisDB }) => { @@ -23,6 +84,13 @@ const RedisCommandEditor: React.FC = ({ connectionId, r const [command, setCommand] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); + + // UI Layout state + const [editorHeight, setEditorHeight] = useState(250); + const dragRef = useRef<{ startY: number; startHeight: number } | null>(null); + const containerRef = useRef(null); + const resultsEndRef = useRef(null); + const editorRef = useRef(null); const getConfig = useCallback(() => { @@ -37,77 +105,173 @@ const RedisCommandEditor: React.FC = ({ connectionId, r }; }, [connection, redisDB]); - const handleEditorMount: OnMount = (editor) => { + const handleEditorMount: OnMount = (editor, monaco) => { editorRef.current = editor; - // Add keyboard shortcut for execute editor.addCommand( - // Ctrl/Cmd + Enter - 2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter + monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => handleExecute() ); + + if (!(window as any).__redisCompletionRegistered) { + (window as any).__redisCompletionRegistered = true; + + const redisCommands = [ + "APPEND", "AUTH", "BGREWRITEAOF", "BGSAVE", "BITCOUNT", "BITFIELD", "BITOP", + "BITPOS", "BLPOP", "BRPOP", "BRPOPLPUSH", "BZMPOP", "BZPOPMIN", "BZPOPMAX", + "CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DBSIZE", "DEBUG", "DECR", "DECRBY", + "DEL", "DISCARD", "DUMP", "ECHO", "EVAL", "EVALSHA", "EXEC", "EXISTS", "EXPIRE", + "EXPIREAT", "EXPIRETIME", "FLUSHALL", "FLUSHDB", "GEOADD", "GEODIST", "GEOHASH", + "GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH", "GEOSEARCHSTORE", + "GET", "GETBIT", "GETDEL", "GETEX", "GETRANGE", "GETSET", "HDEL", "HELLO", + "HEXISTS", "HGET", "HGETALL", "HINCRBY", "HINCRBYFLOAT", "HKEYS", "HLEN", + "HMGET", "HMSET", "HSCAN", "HSET", "HSETNX", "HSTRLEN", "HVALS", "INCR", + "INCRBY", "INCRBYFLOAT", "INFO", "KEYS", "LASTSAVE", "LCS", "LINDEX", "LINSERT", + "LLEN", "LMOVE", "LMPOP", "LPOP", "LPOS", "LPUSH", "LPUSHX", "LRANGE", "LREM", + "LSET", "LTRIM", "MEMORY", "MGET", "MIGRATE", "MODULE", "MONITOR", "MOVE", "MSET", + "MSETNX", "MULTI", "OBJECT", "PERSIST", "PEXPIRE", "PEXPIREAT", "PEXPIRETIME", + "PFADD", "PFCOUNT", "PFMERGE", "PING", "PSETEX", "PSUBSCRIBE", "PTTL", "PUBLISH", + "PUBSUB", "PUNSUBSCRIBE", "QUIT", "RANDOMKEY", "READONLY", "READWRITE", "RENAME", + "RENAMENX", "RESET", "RESTORE", "ROLE", "RPOP", "RPOPLPUSH", "RPUSH", "RPUSHX", + "SADD", "SAVE", "SCAN", "SCARD", "SCRIPT", "SDIFF", "SDIFFSTORE", "SELECT", + "SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SHUTDOWN", "SINTER", "SINTERCARD", + "SINTERSTORE", "SISMEMBER", "SLAVEOF", "SLOWLOG", "SMEMBERS", "SMISMEMBER", + "SMOVE", "SORT", "SORT_RO", "SPOP", "SRANDMEMBER", "SREM", "SSCAN", "STRLEN", + "SUBSCRIBE", "SUNION", "SUNIONSTORE", "SWAPDB", "SYNC", "TIME", "TOUCH", "TTL", + "TYPE", "UNLINK", "UNSUBSCRIBE", "UNWATCH", "WAIT", "WATCH", "XACK", "XADD", + "XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XINFO", "XLEN", "XPENDING", "XRANGE", + "XREAD", "XREADGROUP", "XREVRANGE", "XTRIM", "ZADD", "ZCARD", "ZCOUNT", "ZDIFF", + "ZDIFFSTORE", "ZINCRBY", "ZINTER", "ZINTERCARD", "ZINTERSTORE", "ZLEXCOUNT", + "ZMPOP", "ZMSCORE", "ZPOPMAX", "ZPOPMIN", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX", + "ZRANGEBYSCORE", "ZRANK", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK", + "ZREMRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK", + "ZSCAN", "ZSCORE", "ZUNION", "ZUNIONSTORE" + ]; + + monaco.languages.registerCompletionItemProvider('redis', { + provideCompletionItems: (model: any, position: any) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn + }; + return { + suggestions: redisCommands.map(cmd => ({ + label: cmd, + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: cmd, + range: range, + detail: "Redis Command" + })) + }; + } + }); + } }; const handleExecute = async () => { const config = getConfig(); if (!config) return; - const cmdToExecute = command.trim(); + let cmdToExecute = ''; + + // 1. 获取用户是否有高亮选中的文本 + const selection = editorRef.current?.getSelection(); + if (selection && !selection.isEmpty()) { + cmdToExecute = editorRef.current?.getModel()?.getValueInRange(selection) || ''; + } else { + // 没有选中则取全部文本 + cmdToExecute = editorRef.current?.getValue() || ''; + } + + cmdToExecute = cmdToExecute.trim(); if (!cmdToExecute) { - message.warning('请输入命令'); + message.warning('请输入要执行的命令'); return; } - // Support multiple commands separated by newlines - const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#')); + // 2. 智能解析多行命令 + const commands = parseRedisScriptBlocks(cmdToExecute); + if (commands.length === 0) return; setLoading(true); const newResults: CommandResult[] = []; for (const cmd of commands) { - const trimmedCmd = cmd.trim(); - if (!trimmedCmd) continue; - + const start = Date.now(); try { - const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd); + const res = await (window as any).go.app.App.RedisExecuteCommand(config, cmd); newResults.push({ - command: trimmedCmd, + command: cmd, result: res.success ? res.data : null, error: res.success ? undefined : res.message, - timestamp: Date.now() + timestamp: Date.now(), + durationMs: Date.now() - start }); } catch (e: any) { newResults.push({ - command: trimmedCmd, + command: cmd, result: null, error: e?.message || String(e), - timestamp: Date.now() + timestamp: Date.now(), + durationMs: Date.now() - start }); } } - setResults(prev => [...newResults, ...prev]); + setResults(prev => [...prev, ...newResults]); setLoading(false); }; + + // Auto scroll to bottom when new results arrive + useEffect(() => { + if (resultsEndRef.current) { + resultsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [results]); const handleClear = () => { setResults([]); }; - const formatResult = (result: any): string => { + const formatResult = (result: any): React.ReactNode => { if (result === null || result === undefined) { - return '(nil)'; + return (nil); } if (typeof result === 'string') { - return `"${result}"`; + // 尝试美化 JSON 字符串 + try { + const parsed = JSON.parse(result); + if (typeof parsed === 'object' && parsed !== null) { + return ( +
+ {JSON.stringify(parsed, null, 2)} +
+ ); + } + } catch (e) { + // not a valid json, just return string + } + return "{result}"; } if (typeof result === 'number') { - return `(integer) ${result}`; + return (integer) {result}; } if (Array.isArray(result)) { if (result.length === 0) { return '(empty array)'; } - return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n'); + return ( +
+ {result.map((item, index) => ( +
+ {index + 1}) +
{formatResult(item)}
+
+ ))} +
+ ); } if (typeof result === 'object') { return JSON.stringify(result, null, 2); @@ -115,18 +279,56 @@ const RedisCommandEditor: React.FC = ({ connectionId, r return String(result); }; + // Resizing logic + const handleDragStart = (e: React.MouseEvent) => { + e.preventDefault(); + dragRef.current = { startY: e.clientY, startHeight: editorHeight }; + document.addEventListener('mousemove', handleDragMove); + document.addEventListener('mouseup', handleDragEnd); + document.body.style.cursor = 'row-resize'; + }; + + const handleDragMove = useCallback((e: MouseEvent) => { + if (!dragRef.current) return; + const delta = e.clientY - dragRef.current.startY; + let newHeight = dragRef.current.startHeight + delta; + + // 限制高度 + const minHeight = 100; + const maxHeight = containerRef.current ? containerRef.current.clientHeight - 100 : 800; + if (newHeight < minHeight) newHeight = minHeight; + if (newHeight > maxHeight) newHeight = maxHeight; + + setEditorHeight(newHeight); + + // 更新编辑器布局 + if (editorRef.current) { + editorRef.current.layout(); + } + }, []); + + const handleDragEnd = useCallback(() => { + dragRef.current = null; + document.removeEventListener('mousemove', handleDragMove); + document.removeEventListener('mouseup', handleDragEnd); + document.body.style.cursor = 'default'; + if (editorRef.current) { + editorRef.current.layout(); + } + }, [handleDragMove]); + if (!connection) { return
连接不存在
; } return ( -
- {/* Command Input */} -
-
+
+ {/* Editor Top Pane */} +
+
- Redis 命令 - db{redisDB} + Redis Console + db{redisDB} -
- setCommand(value || '')} - onMount={handleEditorMount} - options={{ - minimap: { enabled: false }, - lineNumbers: 'on', - fontSize: 14, - wordWrap: 'on', - scrollBeyondLastLine: false, - automaticLayout: true, - tabSize: 2 - }} - /> +
+ setCommand(value || '')} + onMount={handleEditorMount} + options={{ + minimap: { enabled: false }, + lineNumbers: 'on', + fontSize: 14, + wordWrap: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 4, + padding: { top: 10, bottom: 10 } + }} + /> +
- {/* Results */} -
- {results.length === 0 ? ( -
- 输入 Redis 命令并按 Ctrl+Enter 执行 -
- 支持多行命令,每行一个命令 -
- ) : ( - results.map((item, index) => ( -
-
- > {item.command} + {/* Resizer Handle */} +
+
+
+ + {/* Results Terminal Bottom Pane */} +
+
+ Execution Output + +
+
+ {results.length === 0 ? ( +
+
在此终端执行命令,结果会以原样输出
+
+ Tips: 选中任意行Ctrl + Enter 仅执行选中段落
- {item.error ? ( -
- (error) {item.error} -
- ) : ( -
- {formatResult(item.result)} -
- )}
- )) - )} -
- - {/* Common Commands Help */} -
- 常用命令: - - KEYS * | - GET key | - SET key value | - HGETALL key | - INFO | - DBSIZE - + ) : ( + results.map((item, index) => ( +
+
+ + {item.command} + [{item.durationMs}ms] +
+ +
+ {item.error ? ( +
+ (error) {item.error} +
+ ) : ( +
+ {formatResult(item.result)} +
+ )} +
+
+ )) + )} +
+
); diff --git a/frontend/src/components/RedisMonitor.tsx b/frontend/src/components/RedisMonitor.tsx new file mode 100644 index 0000000..95000ea --- /dev/null +++ b/frontend/src/components/RedisMonitor.tsx @@ -0,0 +1,378 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { Card, Row, Col, Statistic, Select, Button, message, Tag, Typography, Tooltip, Spin } from 'antd'; +import { AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid, Legend, LineChart, Line } from 'recharts'; +import { + DesktopOutlined, + DashboardOutlined, + ApiOutlined, + HddOutlined, + ReloadOutlined, + PlayCircleOutlined, + PauseCircleOutlined +} from '@ant-design/icons'; +import { useStore } from '../store'; +import { SavedConnection } from '../types'; +import { RedisGetServerInfo } from '../../wailsjs/go/app/App'; + +const { Title, Text } = Typography; + +interface RedisMonitorProps { + connectionId: string; + redisDB: number; +} + +// Data point for charts +interface MetricPoint { + time: string; + qps: number; + memory: number; // in MB + memory_rss: number; // in MB + clients: number; + cpuSys: number; + cpuUser: number; + hitRate: number; + keys: number; +} + +const MAX_HISTORY_POINTS = 60; // Keep up to 60 data points + +const RedisMonitor: React.FC = ({ connectionId, redisDB }) => { + const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); + const darkMode = theme === 'dark'; + + const [isRunning, setIsRunning] = useState(true); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [history, setHistory] = useState([]); + const [currentInfo, setCurrentInfo] = useState>({}); + + // Ref to track if component is mounted to prevent state updates after unmount + const mountedRef = useRef(true); + // Interval ref + const intervalRef = useRef(null); + // Previous ops counter to calculate QPS if instantaneous_ops_per_sec is not enough + const prevMetricsRef = useRef({ prevOps: 0, prevTime: 0 }); + + const connection = connections.find((c: SavedConnection) => c.id === connectionId); + + const fetchMetrics = async () => { + if (!connection) return; + + try { + const config = { ...connection.config, redisDB } as any; + const res = await RedisGetServerInfo(config); + + if (!mountedRef.current) return; + + if (!res.success) { + setError(res.message || 'Failed to fetch Redis info'); + return; + } + + setError(null); + const infoMap = res.data as Record; + setCurrentInfo(infoMap); + + const now = new Date(); + const timeStr = now.toLocaleTimeString([], { hour12: false, second: '2-digit' }); + + // Parse values + const qps = parseInt(infoMap['instantaneous_ops_per_sec'] || '0', 10); + const memBytes = parseInt(infoMap['used_memory'] || '0', 10); + const memRssBytes = parseInt(infoMap['used_memory_rss'] || '0', 10); + const clients = parseInt(infoMap['connected_clients'] || '0', 10); + const cpuSys = parseFloat(infoMap['used_cpu_sys'] || '0'); + const cpuUser = parseFloat(infoMap['used_cpu_user'] || '0'); + + const hits = parseInt(infoMap['keyspace_hits'] || '0', 10); + const misses = parseInt(infoMap['keyspace_misses'] || '0', 10); + const hitRate = (hits + misses) > 0 ? (hits / (hits + misses)) * 100 : 0; + + let keys = 0; + Object.keys(infoMap).forEach(k => { + if (k.startsWith('db')) { + const m = infoMap[k].match(/keys=(\d+)/); + if (m) keys += parseInt(m[1], 10); + } + }); + + const point: MetricPoint = { + time: timeStr, + qps, + memory: parseFloat((memBytes / 1024 / 1024).toFixed(2)), + memory_rss: parseFloat((memRssBytes / 1024 / 1024).toFixed(2)), + clients, + cpuSys: parseFloat(cpuSys.toFixed(2)), + cpuUser: parseFloat(cpuUser.toFixed(2)), + hitRate: parseFloat(hitRate.toFixed(2)), + keys + }; + + setHistory(prev => { + const next = [...prev, point]; + if (next.length > MAX_HISTORY_POINTS) { + return next.slice(next.length - MAX_HISTORY_POINTS); + } + return next; + }); + + if (loading) setLoading(false); + + } catch (err: any) { + if (mountedRef.current) { + setError(err.message || 'Unknown error'); + if (loading) setLoading(false); + } + } + }; + + useEffect(() => { + mountedRef.current = true; + fetchMetrics(); // initial fetch + return () => { + mountedRef.current = false; + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, []); + + useEffect(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + if (isRunning) { + intervalRef.current = setInterval(fetchMetrics, 2000); // 2 second interval + } + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [isRunning, connectionId, redisDB, connection]); + + if (!connection) { + return
Connection not found.
; + } + + // Determine styles for charts based on theme + const chartTextColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)'; + const chartGridColor = darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; + const cardBgColor = darkMode ? '#1f1f1f' : '#ffffff'; + + const getFormatMemoryString = (bytes: string) => { + const val = parseInt(bytes || '0', 10); + if (val > 1024*1024*1024) return (val/1024/1024/1024).toFixed(2) + ' GB'; + if (val > 1024*1024) return (val/1024/1024).toFixed(2) + ' MB'; + if (val > 1024) return (val/1024).toFixed(2) + ' KB'; + return val + ' B'; + }; + + const getUptimeString = (seconds: string) => { + const d = parseInt(seconds || '0', 10); + if (d < 60) return `${d}s`; + if (d < 3600) return `${Math.floor(d/60)}m ${d%60}s`; + if (d < 86400) return `${Math.floor(d/3600)}h ${Math.floor((d%3600)/60)}m`; + return `${Math.floor(d/86400)}d ${Math.floor((d%86400)/3600)}h`; + }; + + return ( +
+
+
+ + <DashboardOutlined style={{ marginRight: 8, color: '#1677ff' }} /> + Redis 实例监控 + + + {connection.name} + {currentInfo.redis_version && ` • Redis ${currentInfo.redis_version}`} + {currentInfo.os && ` • ${currentInfo.os}`} + +
+
+ {error && {error}} + {loading && !error && } + + + +
+
+ + + + + 已用内存 (Used)} + value={getFormatMemoryString(currentInfo.used_memory || '0')} + valueStyle={{ color: '#eb2f96', fontWeight: 600 }} + suffix={Peak: {getFormatMemoryString(currentInfo.used_memory_peak || '0')}} + /> + + + + + 客户端数量 (Clients)} + value={currentInfo.connected_clients || '0'} + valueStyle={{ color: '#1677ff', fontWeight: 600 }} + suffix={Blocked: {currentInfo.blocked_clients || '0'}} + /> + + + + + 吞吐量 (OPS)} + value={currentInfo.instantaneous_ops_per_sec || '0'} + valueStyle={{ color: '#52c41a', fontWeight: 600 }} + suffix={cmds/s} + /> + + + + + 启动时长 (Uptime)} + value={getUptimeString(currentInfo.uptime_in_seconds || '0')} + valueStyle={{ color: '#fa8c16', fontWeight: 600 }} + suffix={Days: {currentInfo.uptime_in_days || '0'}} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [`${value} MB`]} + /> + + + + + + + + + + + + + + + + + + [`${value} s`]} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ {['redis_version', 'os', 'arch_bits', 'multiplexing_api', 'gcc_version', 'run_id', 'tcp_port', 'uptime_in_days', 'hz', 'lru_clock', 'role', 'maxmemory_human', 'maxmemory_policy', 'mem_fragmentation_ratio', 'keyspace_hits', 'keyspace_misses', 'total_connections_received'].map(key => ( + currentInfo[key] ? ( +
+ {key} + {currentInfo[key]} +
+ ) : null + ))} +
+
+
+
+ ); +}; + +export default RedisMonitor; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a15a62f..64a0232 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -30,7 +30,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, CodeOutlined, TagOutlined, CheckOutlined, - FilterOutlined + FilterOutlined, + DashboardOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; @@ -3098,6 +3099,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }); } }, + { + key: 'open-monitor', + label: 'Redis 实例监控', + icon: , + onClick: () => { + addTab({ + id: `redis-monitor-${node.key}-${Date.now()}`, + title: `监控: ${node.title}`, + type: 'redis-monitor', + connectionId: node.key, + redisDB: 0 + }); + } + }, { type: 'divider' }, { key: 'edit', @@ -3309,6 +3324,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> redisDB: redisDB }); } + }, + { + key: 'open-monitor', + label: 'Redis 实例监控', + icon: , + onClick: () => { + addTab({ + id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`, + title: `监控: ${connections.find(c => c.id === id)?.name || id}`, + type: 'redis-monitor', + connectionId: id, + redisDB: redisDB + }); + } } ]; } else if (node.type === 'database') { diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 4c8cb73..acf5439 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -12,6 +12,7 @@ import QueryEditor from './QueryEditor'; import TableDesigner from './TableDesigner'; import RedisViewer from './RedisViewer'; import RedisCommandEditor from './RedisCommandEditor'; +import RedisMonitor from './RedisMonitor'; import TriggerViewer from './TriggerViewer'; import DefinitionViewer from './DefinitionViewer'; import TableOverview from './TableOverview'; @@ -199,17 +200,20 @@ const TabManager: React.FC = () => { const items = useMemo(() => tabs.map((tab, index) => { const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name; const displayTitle = buildTabDisplayTitle(tab, connectionName); + const tabIsActive = tab.id === activeTabId; let content; if (tab.type === 'query') { - content = ; + content = ; } else if (tab.type === 'table') { - content = ; + content = ; } else if (tab.type === 'design') { content = ; } else if (tab.type === 'redis-keys') { content = ; } else if (tab.type === 'redis-command') { content = ; + } else if (tab.type === 'redis-monitor') { + content = ; } else if (tab.type === 'trigger') { content = ; } else if (tab.type === 'view-def' || tab.type === 'routine-def') { @@ -256,7 +260,7 @@ const TabManager: React.FC = () => { key: tab.id, children: content, }; - }), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]); + }), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]); return ( <> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7879668..88c2fb4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -118,7 +118,7 @@ export interface TriggerDefinition { export interface TabData { id: string; title: string; - type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview'; + type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview'; connectionId: string; dbName?: string; tableName?: string; diff --git a/frontend/src/utils/approximateTableCount.test.ts b/frontend/src/utils/approximateTableCount.test.ts new file mode 100644 index 0000000..cc0a4ff --- /dev/null +++ b/frontend/src/utils/approximateTableCount.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildOracleApproximateTotalSql, + parseApproximateTableCountRow, + resolveApproximateTableCountStrategy, +} from './approximateTableCount'; + +describe('approximateTableCount', () => { + it('uses oracle metadata approximate total only for unfiltered full-table preview', () => { + expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: '' })).toBe('oracle-num-rows'); + expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: 'WHERE id = 1' })).toBe('none'); + }); + + it('keeps duckdb approximate count on unfiltered previews', () => { + expect(resolveApproximateTableCountStrategy({ dbType: 'duckdb', whereSQL: '' })).toBe('duckdb-estimated-size'); + }); + + it('builds Oracle approx count SQL from owner and table name', () => { + expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("owner = 'HR'"); + expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("table_name = 'EMPLOYEES'"); + }); + + it('parses approximate total rows using preferred keys', () => { + expect(parseApproximateTableCountRow({ NUM_ROWS: '1234' }, ['num_rows'])).toBe(1234); + expect(parseApproximateTableCountRow({ approx_total: 5678 }, ['approx_total'])).toBe(5678); + }); +}); diff --git a/frontend/src/utils/approximateTableCount.ts b/frontend/src/utils/approximateTableCount.ts new file mode 100644 index 0000000..c699692 --- /dev/null +++ b/frontend/src/utils/approximateTableCount.ts @@ -0,0 +1,106 @@ +export type ApproximateTableCountStrategy = 'none' | 'duckdb-estimated-size' | 'oracle-num-rows'; + +const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); + +const toNonNegativeFiniteNumber = (value: unknown): number | null => { + if (typeof value === 'number') { + return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null; + } + if (typeof value === 'bigint') { + return value >= 0n && value <= MAX_SAFE_BIGINT ? Number(value) : null; + } + if (typeof value === 'string') { + const text = value.trim(); + if (!text) return null; + if (/^[+-]?\d+$/.test(text)) { + try { + const parsed = BigInt(text); + return parsed >= 0n && parsed <= MAX_SAFE_BIGINT ? Number(parsed) : null; + } catch { + return null; + } + } + const parsed = Number(text); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null; + } + return null; +}; + +const stripOuterQuotes = (value: string): string => { + const trimmed = String(value || '').trim(); + if (trimmed.length < 2) return trimmed; + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first === '"' && last === '"') || (first === '`' && last === '`') || (first === '[' && last === ']')) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +}; + +const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''"); + +const resolveOracleOwnerAndTable = (params: { dbName: string; tableName: string }) => { + const rawTable = String(params.tableName || '').trim(); + const parts = rawTable.split('.').map(stripOuterQuotes).filter(Boolean); + const tableName = String(parts[parts.length - 1] || rawTable || '').trim(); + const ownerCandidate = parts.length >= 2 ? parts[parts.length - 2] : String(params.dbName || '').trim(); + return { + owner: ownerCandidate.toUpperCase(), + tableName: tableName.toUpperCase(), + }; +}; + +export const resolveApproximateTableCountStrategy = (params: { + dbType: string; + whereSQL: string; +}): ApproximateTableCountStrategy => { + const dbType = String(params.dbType || '').trim().toLowerCase(); + const whereSQL = String(params.whereSQL || '').trim(); + if (whereSQL) return 'none'; + if (dbType === 'duckdb') return 'duckdb-estimated-size'; + if (dbType === 'oracle') return 'oracle-num-rows'; + return 'none'; +}; + +export const buildOracleApproximateTotalSql = (params: { dbName: string; tableName: string }): string => { + const { owner, tableName } = resolveOracleOwnerAndTable(params); + const escapedTable = escapeSQLLiteral(tableName); + if (!owner) { + return `SELECT num_rows AS approx_total FROM user_tables WHERE table_name = '${escapedTable}' AND ROWNUM = 1`; + } + return `SELECT num_rows AS approx_total FROM all_tables WHERE owner = '${escapeSQLLiteral(owner)}' AND table_name = '${escapedTable}' AND ROWNUM = 1`; +}; + +export const parseApproximateTableCountRow = ( + row: unknown, + preferredKeys: string[] = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'num_rows', 'count', 'total'], +): number | null => { + if (!row || typeof row !== 'object') return null; + const entries = Object.entries(row as Record); + if (entries.length === 0) return null; + + for (const preferredKey of preferredKeys) { + const normalizedPreferred = String(preferredKey || '').trim().toLowerCase(); + for (const [key, value] of entries) { + if (String(key || '').trim().toLowerCase() !== normalizedPreferred) continue; + const parsed = toNonNegativeFiniteNumber(value); + if (parsed !== null) return parsed; + } + } + + for (const [key, value] of entries) { + const normalizedKey = String(key || '').trim().toLowerCase(); + if (!normalizedKey.includes('estimate') && !normalizedKey.includes('row') && !normalizedKey.includes('count') && !normalizedKey.includes('total')) { + continue; + } + const parsed = toNonNegativeFiniteNumber(value); + if (parsed !== null) return parsed; + } + + for (const [, value] of entries) { + const parsed = toNonNegativeFiniteNumber(value); + if (parsed !== null) return parsed; + } + + return null; +}; diff --git a/frontend/src/utils/dataGridPagination.test.ts b/frontend/src/utils/dataGridPagination.test.ts new file mode 100644 index 0000000..97ef98e --- /dev/null +++ b/frontend/src/utils/dataGridPagination.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolvePaginationPageText, + resolvePaginationSummaryText, + resolvePaginationTotalForControl, +} from './dataGridPagination'; + +describe('dataGridPagination', () => { + it('shows Oracle approximate total in summary but not in total-page chip', () => { + const pagination = { + current: 3, + pageSize: 100, + total: 301, + totalKnown: false, + totalApprox: true, + approximateTotal: 1832451, + }; + + expect(resolvePaginationSummaryText({ + pagination, + prefersManualTotalCount: true, + supportsApproximateTableCount: true, + })).toContain('约 1832451 条'); + + expect(resolvePaginationPageText({ + pagination, + supportsApproximateTotalPages: false, + })).toBe('第 3 页'); + + expect(resolvePaginationTotalForControl({ + pagination, + supportsApproximateTotalPages: false, + })).toBe(301); + }); + + it('still allows DuckDB to use approximate totals for page counts', () => { + const pagination = { + current: 2, + pageSize: 100, + total: 201, + totalKnown: false, + totalApprox: true, + approximateTotal: 1000, + }; + + expect(resolvePaginationPageText({ + pagination, + supportsApproximateTotalPages: true, + })).toBe('第 2 / 10 页'); + + expect(resolvePaginationTotalForControl({ + pagination, + supportsApproximateTotalPages: true, + })).toBe(1000); + }); +}); diff --git a/frontend/src/utils/dataGridPagination.ts b/frontend/src/utils/dataGridPagination.ts new file mode 100644 index 0000000..96e2d71 --- /dev/null +++ b/frontend/src/utils/dataGridPagination.ts @@ -0,0 +1,92 @@ +export type PaginationStateLike = { + current: number; + pageSize: number; + total: number; + totalKnown?: boolean; + totalApprox?: boolean; + approximateTotal?: number; + totalCountLoading?: boolean; + totalCountCancelled?: boolean; +}; + +const toFiniteNonNegativeNumber = (value: unknown): number | null => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +}; + +const resolveApproximateTotal = (pagination: PaginationStateLike): number | null => { + if (!pagination.totalApprox) return null; + const approximateTotal = toFiniteNonNegativeNumber(pagination.approximateTotal); + return approximateTotal !== null && approximateTotal > 0 ? approximateTotal : null; +}; + +const resolveCurrentCount = (pagination: PaginationStateLike): number => { + const total = toFiniteNonNegativeNumber(pagination.total) ?? 0; + const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0)); + const hasValidRange = total > 0 && rangeStart > 0; + if (!hasValidRange) return 0; + const rangeEnd = Math.min(total, rangeStart + pagination.pageSize - 1); + return Math.max(0, rangeEnd - rangeStart + 1); +}; + +export const resolvePaginationSummaryText = (params: { + pagination: PaginationStateLike; + prefersManualTotalCount: boolean; + supportsApproximateTableCount: boolean; +}): string => { + const { pagination, prefersManualTotalCount, supportsApproximateTableCount } = params; + const currentCount = resolveCurrentCount(pagination); + const total = toFiniteNonNegativeNumber(pagination.total) ?? 0; + const approximateTotal = resolveApproximateTotal(pagination); + + if (pagination.totalKnown === false) { + if (prefersManualTotalCount) { + if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`; + if (supportsApproximateTableCount && approximateTotal !== null) return `当前 ${currentCount} 条 / 约 ${approximateTotal} 条`; + if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`; + return `当前 ${currentCount} 条 / 总数未统计`; + } + return `当前 ${currentCount} 条 / 正在统计总数…`; + } + + if (!Number.isFinite(total) || total <= 0) { + return '当前 0 条 / 共 0 条'; + } + + return `当前 ${currentCount} 条 / 共 ${total} 条`; +}; + +export const resolvePaginationPageText = (params: { + pagination: PaginationStateLike; + supportsApproximateTotalPages: boolean; +}): string => { + const { pagination, supportsApproximateTotalPages } = params; + const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0; + const approximateTotal = resolveApproximateTotal(pagination); + const effectiveTotal = + pagination.totalKnown !== false + ? exactTotal + : supportsApproximateTotalPages && approximateTotal !== null + ? approximateTotal + : 0; + + if (effectiveTotal <= 0) return `第 ${pagination.current} 页`; + + const totalPages = Math.max(1, Math.ceil(effectiveTotal / Math.max(1, pagination.pageSize))); + if (pagination.totalKnown === false && !(supportsApproximateTotalPages && approximateTotal !== null)) { + return `第 ${pagination.current} 页`; + } + return `第 ${pagination.current} / ${totalPages} 页`; +}; + +export const resolvePaginationTotalForControl = (params: { + pagination: PaginationStateLike; + supportsApproximateTotalPages: boolean; +}): number => { + const { pagination, supportsApproximateTotalPages } = params; + const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0; + const approximateTotal = resolveApproximateTotal(pagination); + if (pagination.totalKnown !== false) return exactTotal; + if (supportsApproximateTotalPages && approximateTotal !== null) return approximateTotal; + return exactTotal; +}; diff --git a/frontend/src/utils/dataSourceCapabilities.test.ts b/frontend/src/utils/dataSourceCapabilities.test.ts new file mode 100644 index 0000000..c839c92 --- /dev/null +++ b/frontend/src/utils/dataSourceCapabilities.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { getDataSourceCapabilities } from './dataSourceCapabilities'; + +describe('dataSourceCapabilities', () => { + it('treats Oracle table preview totals as manual exact count plus approximate metadata count', () => { + expect(getDataSourceCapabilities({ type: 'oracle' })).toMatchObject({ + type: 'oracle', + preferManualTotalCount: true, + supportsApproximateTableCount: true, + supportsApproximateTotalPages: false, + }); + }); + + it('keeps DuckDB manual count and approximate total support', () => { + expect(getDataSourceCapabilities({ type: 'duckdb' })).toMatchObject({ + type: 'duckdb', + preferManualTotalCount: true, + supportsApproximateTableCount: true, + supportsApproximateTotalPages: true, + }); + }); + + it('keeps MySQL on automatic total count mode', () => { + expect(getDataSourceCapabilities({ type: 'mysql' })).toMatchObject({ + type: 'mysql', + preferManualTotalCount: false, + supportsApproximateTableCount: false, + supportsApproximateTotalPages: false, + }); + }); +}); diff --git a/frontend/src/utils/dataSourceCapabilities.ts b/frontend/src/utils/dataSourceCapabilities.ts index 8d30854..56331f4 100644 --- a/frontend/src/utils/dataSourceCapabilities.ts +++ b/frontend/src/utils/dataSourceCapabilities.ts @@ -64,6 +64,9 @@ const COPY_INSERT_TYPES = new Set([ const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']); const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']); +const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle']); +const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']); +const APPROXIMATE_TOTAL_PAGE_TYPES = new Set(['duckdb']); export type DataSourceCapabilities = { type: string; @@ -71,6 +74,9 @@ export type DataSourceCapabilities = { supportsSqlQueryExport: boolean; supportsCopyInsert: boolean; forceReadOnlyQueryResult: boolean; + preferManualTotalCount: boolean; + supportsApproximateTableCount: boolean; + supportsApproximateTotalPages: boolean; }; export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => { @@ -81,6 +87,8 @@ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCap supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type), supportsCopyInsert: COPY_INSERT_TYPES.has(type), forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type), + preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type), + supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type), + supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type), }; }; - diff --git a/frontend/src/utils/dataViewerAutoFetch.test.ts b/frontend/src/utils/dataViewerAutoFetch.test.ts new file mode 100644 index 0000000..85b3af1 --- /dev/null +++ b/frontend/src/utils/dataViewerAutoFetch.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveDataViewerAutoFetchAction } from './dataViewerAutoFetch'; + +describe('resolveDataViewerAutoFetchAction', () => { + it('skips one fetch while tab state is hydrating', () => { + expect(resolveDataViewerAutoFetchAction({ + skipNextAutoFetch: true, + hasInitialLoad: false, + })).toBe('skip'); + }); + + it('loads current page on the first real fetch', () => { + expect(resolveDataViewerAutoFetchAction({ + skipNextAutoFetch: false, + hasInitialLoad: false, + })).toBe('load-current-page'); + }); + + it('reloads from first page after sort or filter changes', () => { + expect(resolveDataViewerAutoFetchAction({ + skipNextAutoFetch: false, + hasInitialLoad: true, + })).toBe('reload-first-page'); + }); +}); diff --git a/frontend/src/utils/dataViewerAutoFetch.ts b/frontend/src/utils/dataViewerAutoFetch.ts new file mode 100644 index 0000000..c9e1860 --- /dev/null +++ b/frontend/src/utils/dataViewerAutoFetch.ts @@ -0,0 +1,16 @@ +export type DataViewerAutoFetchAction = 'skip' | 'load-current-page' | 'reload-first-page'; + +export const resolveDataViewerAutoFetchAction = (params: { + skipNextAutoFetch: boolean; + hasInitialLoad: boolean; +}): DataViewerAutoFetchAction => { + if (params.skipNextAutoFetch) { + return 'skip'; + } + + if (!params.hasInitialLoad) { + return 'load-current-page'; + } + + return 'reload-first-page'; +}; From 31644dee6b3a1e76d734baf65f0e8e1a0b61f50d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 30 Mar 2026 19:46:05 +0800 Subject: [PATCH 08/22] =?UTF-8?q?=F0=9F=90=9B=20fix(dameng):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=BE=BE=E6=A2=A6=E6=95=B0=E6=8D=AE=E6=BA=90=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E6=A0=8F=E6=97=A0=E6=B3=95=E5=B1=95=E5=BC=80=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E8=8A=82=E7=82=B9=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 权限适配:取消对 SYSDBA schema 的默认过滤,并增加 `SELECT USER FROM DUAL` 兜底查询 - 树节点容错:Sidebar 当数据库为空时不再阻断加载状态,允许用户重试刷新并增加明确提示 - 类型修正:修复 RedisMonitor 组件 `NodeJS.Timeout` 在 Vite 下的编译报错 - 测试覆盖:补充达梦 SYSDBA 过滤及兜底查询逻辑的单元测试 --- frontend/package.json.md5 | 2 +- frontend/src/components/RedisMonitor.tsx | 2 +- frontend/src/components/Sidebar.tsx | 10 ++++- internal/db/dameng_metadata.go | 8 ++-- internal/db/dameng_metadata_test.go | 47 ++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index b8be944..ad6ce0c 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -f697e821b4acd5cf614d63d46453e8a4 \ No newline at end of file +20168ff7047e0ecea00acb73f413f7db \ No newline at end of file diff --git a/frontend/src/components/RedisMonitor.tsx b/frontend/src/components/RedisMonitor.tsx index 95000ea..0e5cc25 100644 --- a/frontend/src/components/RedisMonitor.tsx +++ b/frontend/src/components/RedisMonitor.tsx @@ -51,7 +51,7 @@ const RedisMonitor: React.FC = ({ connectionId, redisDB }) => // Ref to track if component is mounted to prevent state updates after unmount const mountedRef = useRef(true); // Interval ref - const intervalRef = useRef(null); + const intervalRef = useRef | null>(null); // Previous ops counter to calculate QPS if instantaneous_ops_per_sec is not enough const prevMetricsRef = useRef({ prevOps: 0, prevTime: 0 }); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 64a0232..58658f3 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1036,13 +1036,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title)); } - setTreeData(origin => updateTreeData(origin, node.key, dbs)); + if (dbs.length > 0) { + setTreeData(origin => updateTreeData(origin, node.key, dbs)); + } else { + // 空列表:清理 loadedKeys 以允许重新加载,不设置 children = [] + setLoadedKeys(prev => prev.filter(k => k !== node.key)); + message.warning({ content: '未获取到可见数据库/schema,请检查账号权限或右键刷新', key: `conn-${conn.id}-dbs` }); + } } else { setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); + setLoadedKeys(prev => prev.filter(k => k !== node.key)); message.error({ content: res.message, key: `conn-${conn.id}-dbs` }); } } catch (e: any) { setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); + setLoadedKeys(prev => prev.filter(k => k !== node.key)); message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-dbs` }); } finally { loadingNodesRef.current.delete(loadKey); diff --git a/internal/db/dameng_metadata.go b/internal/db/dameng_metadata.go index b0f698b..15d12ad 100644 --- a/internal/db/dameng_metadata.go +++ b/internal/db/dameng_metadata.go @@ -9,9 +9,9 @@ import ( ) var damengDatabaseQueries = []string{ - // 优先使用达梦原生系统表 - "SELECT DISTINCT OBJECT_NAME AS DATABASE_NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND OBJECT_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','__RECYCLE_USER__') ORDER BY OBJECT_NAME", - "SELECT SCHEMA_NAME AS DATABASE_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','INFORMATION_SCHEMA') ORDER BY SCHEMA_NAME", + // 优先使用达梦原生系统表(SYSDBA 保留:作为默认管理员 schema,大多数用户在此创建业务表) + "SELECT DISTINCT OBJECT_NAME AS DATABASE_NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND OBJECT_NAME NOT IN ('SYS','SYSAUDITOR','SYSSSO','CTISYS','__RECYCLE_USER__') ORDER BY OBJECT_NAME", + "SELECT SCHEMA_NAME AS DATABASE_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('SYS','SYSAUDITOR','SYSSSO','CTISYS','INFORMATION_SCHEMA') ORDER BY SCHEMA_NAME", // Oracle 兼容层 "SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS DATABASE_NAME FROM DUAL", "SELECT SYS_CONTEXT('USERENV', 'CURRENT_USER') AS DATABASE_NAME FROM DUAL", @@ -21,6 +21,8 @@ var damengDatabaseQueries = []string{ "SELECT USERNAME AS DATABASE_NAME FROM SYS.DBA_USERS ORDER BY USERNAME", "SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_OBJECTS ORDER BY OWNER", "SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_TABLES ORDER BY OWNER", + // 最终兜底:获取当前连接用户作为 schema 名称 + "SELECT USER AS DATABASE_NAME FROM DUAL", } type damengQueryFunc func(query string) ([]map[string]interface{}, []string, error) diff --git a/internal/db/dameng_metadata_test.go b/internal/db/dameng_metadata_test.go index 5310679..b5b6557 100644 --- a/internal/db/dameng_metadata_test.go +++ b/internal/db/dameng_metadata_test.go @@ -71,3 +71,50 @@ func TestCollectDamengDatabaseNames_ReturnsErrorWhenNoNameResolved(t *testing.T) t.Fatalf("错误不符合预期: %v", err) } } + +// TestCollectDamengDatabaseNames_IncludesSYSDBA 验证 SYSDBA(达梦默认管理员 schema) +// 不会被系统 schema 过滤排除。 +func TestCollectDamengDatabaseNames_IncludesSYSDBA(t *testing.T) { + t.Parallel() + + got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) { + switch query { + case damengDatabaseQueries[0]: + // 查询 0 返回 SYSDBA(之前会被排除,修复后应该返回) + return []map[string]interface{}{{"DATABASE_NAME": "SYSDBA"}}, nil, nil + default: + return nil, nil, errors.New("permission denied") + } + }) + if err != nil { + t.Fatalf("collectDamengDatabaseNames 返回错误: %v", err) + } + + want := []string{"SYSDBA"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("SYSDBA 应该包含在结果中, got=%v want=%v", got, want) + } +} + +// TestCollectDamengDatabaseNames_FallbackToCurrentUser 验证当所有查询都失败时 +// 兜底查询 SELECT USER FROM DUAL 能返回当前用户作为 schema。 +func TestCollectDamengDatabaseNames_FallbackToCurrentUser(t *testing.T) { + t.Parallel() + + lastQuery := damengDatabaseQueries[len(damengDatabaseQueries)-1] + got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) { + if query == lastQuery { + return []map[string]interface{}{{"DATABASE_NAME": "SYSDBA"}}, nil, nil + } + // 前面所有查询要么返回空要么报错 + return []map[string]interface{}{}, nil, nil + }) + if err != nil { + t.Fatalf("collectDamengDatabaseNames 返回错误: %v", err) + } + + want := []string{"SYSDBA"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("兜底查询应该返回当前用户, got=%v want=%v", got, want) + } +} From 9c962463201b9fc07ac6251a6808a6d981009b74 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 31 Mar 2026 12:09:33 +0800 Subject: [PATCH 09/22] =?UTF-8?q?=E2=9C=A8=20feat(postgres):=20PostgreSQL?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E4=B8=8D=E5=B8=A6=20schema=20=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E7=9A=84=E8=A1=A8=E5=90=8D=E8=A1=A5=E5=85=A8=E4=B8=8E?= =?UTF-8?q?=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端优化:连接成功后自动查询所有用户 schema 并将 search_path 写入 DSN 重建连接池 - 连接池修复:SET search_path 仅对单个连接生效,改为 DSN 级别配置使所有连接生效 - 表名补全:前端智能匹配 schema.table 中的纯表名部分,输入表名即可触发补全 - 同名表处理:跨 schema 存在同名表时补全自动显示 schema.table 格式以区分 - 列补全增强:FROM/JOIN 引用纯表名时关联列提示和别名列提示均可正确匹配 --- frontend/package.json.md5 | 2 +- frontend/src/components/QueryEditor.tsx | 69 ++++++++++++++--- internal/db/postgres_impl.go | 98 +++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 13 deletions(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index ad6ce0c..b8be944 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -20168ff7047e0ecea00acb73f413f7db \ No newline at end of file +f697e821b4acd5cf614d63d46453e8a4 \ No newline at end of file diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 4499651..1904e44 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -716,11 +716,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // Prefer preloaded MySQL all-columns cache let cols: { name: string, type?: string, tableName?: string, dbName?: string }[]; if (sharedAllColumnsData.length > 0) { + const tiTableLower = (tableInfo.tableName || '').toLowerCase(); cols = sharedAllColumnsData - .filter(c => - (c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() && - (c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase() - ) + .filter(c => { + if ((c.dbName || '').toLowerCase() !== (tableInfo.dbName || '').toLowerCase()) return false; + const cTableLower = (c.tableName || '').toLowerCase(); + if (cTableLower === tiTableLower) return true; + // schema.table 格式匹配纯表名 + const parsed = splitSchemaAndTable(c.tableName || ''); + return (parsed.table || '').toLowerCase() === tiTableLower; + }) .map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName })); } else { const dbCols = await getColumnsByDB(tableInfo.tableName); @@ -773,7 +778,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc .filter(c => { const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase(); const shortIdent = (c.tableName || '').toLowerCase(); - return (foundTables.has(fullIdent) || foundTables.has(shortIdent)) && startsWithPrefix(c.name || ''); + // 对 schema.table 格式,也用纯表名部分匹配(如 public.users → users) + const parsed = splitSchemaAndTable(c.tableName || ''); + const pureIdent = (parsed.table || '').toLowerCase(); + return (foundTables.has(fullIdent) || foundTables.has(shortIdent) || (pureIdent && foundTables.has(pureIdent))) && startsWithPrefix(c.name || ''); }) .map(c => { // 当前库的表字段优先级更高 @@ -788,24 +796,61 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }); - // 表提示:当前库显示表名,其他库显示 db.table 格式 + // 表提示:当前库智能处理 schema.table 格式 + // 1. 构建纯表名到 schema 列表的映射,检测同名表 + const currentDbTables = sharedTablesData.filter(t => + (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase() + ); + const tableNameToSchemas = new Map(); + for (const t of currentDbTables) { + const parsed = splitSchemaAndTable(t.tableName || ''); + const pureTable = (parsed.table || t.tableName || '').toLowerCase(); + const schemas = tableNameToSchemas.get(pureTable) || []; + schemas.push(parsed.schema || ''); + tableNameToSchemas.set(pureTable, schemas); + } + const tableSuggestions = sharedTablesData .filter(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); - const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; - return startsWithPrefix(label || ''); + if (!isCurrentDb) { + // 跨库:用 db.table 格式匹配 + return startsWithPrefix(`${t.dbName}.${t.tableName}`); + } + // 当前库:同时用完整名和纯表名匹配 + const parsed = splitSchemaAndTable(t.tableName || ''); + const pureTable = parsed.table || t.tableName || ''; + return startsWithPrefix(t.tableName || '') || startsWithPrefix(pureTable); }) .map(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); - const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; - const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; + if (!isCurrentDb) { + const label = `${t.dbName}.${t.tableName}`; + return { + label, + kind: monaco.languages.CompletionItemKind.Class, + insertText: label, + detail: `Table (${t.dbName})`, + range, + sortText: sortGroups.tableOther + t.tableName, + }; + } + // 当前库:检查是否有跨 schema 同名表 + const parsed = splitSchemaAndTable(t.tableName || ''); + const pureTable = parsed.table || t.tableName || ''; + const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || []; + const hasDuplicate = schemas.length > 1; + // 同名表存在于多个 schema → 显示 schema.table;否则只显示纯表名 + const label = hasDuplicate ? t.tableName : pureTable; + const insertText = hasDuplicate ? t.tableName : pureTable; + const schemaInfo = parsed.schema ? ` (${parsed.schema})` : ''; return { label, kind: monaco.languages.CompletionItemKind.Class, insertText, - detail: `Table (${t.dbName})`, + detail: `Table${schemaInfo}`, range, - sortText: isCurrentDb ? sortGroups.tableCurrent + t.tableName : sortGroups.tableOther + t.tableName, + sortText: sortGroups.tableCurrent + pureTable, }; }); diff --git a/internal/db/postgres_impl.go b/internal/db/postgres_impl.go index 727b3dc..5970003 100644 --- a/internal/db/postgres_impl.go +++ b/internal/db/postgres_impl.go @@ -166,6 +166,9 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error { logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName) } + // 设置 search_path,使所有用户 schema 下的表可以不带 schema 前缀访问 + p.ensureSearchPath(dsn) + cleanupOnFailure = false return nil } @@ -611,6 +614,101 @@ ORDER BY table_schema, table_name, ordinal_position` return cols, nil } +// ensureSearchPath 查询当前数据库中所有用户 schema,通过重建连接池将 search_path 写入 DSN。 +// 仅使用 SET search_path 只对连接池中的单个连接生效,后续查询可能拿到未设置的连接。 +// 将 search_path 写入 DSN (lib/pq 支持任意 PostgreSQL runtime parameter), +// 使连接池中每个连接建立时自动携带 search_path,与金仓行为一致。 +func (p *PostgresDB) ensureSearchPath(baseDSN string) { + if p.conn == nil { + return + } + + rawSchemas := p.queryUserSchemas() + if len(rawSchemas) == 0 { + return + } + + // 构建 search_path SQL 片段(带双引号转义),用于 SET 兜底 + searchPathSQL, normalizedSchemas := buildKingbaseSearchPathCommon(rawSchemas) + if strings.TrimSpace(searchPathSQL) == "" { + return + } + + // 策略 1:将 search_path 写入 DSN,重建连接池 + // lib/pq 支持在 URL 查参数中设置任意 PostgreSQL runtime parameter, + // 如 ?search_path=ce,public,每个新连接建立时会自动 SET search_path。 + searchPathDSNVal := strings.Join(normalizedSchemas, ",") + u, parseErr := url.Parse(baseDSN) + if parseErr == nil { + q := u.Query() + q.Set("search_path", searchPathDSNVal) + u.RawQuery = q.Encode() + newDSN := u.String() + + newDB, err := sql.Open("postgres", newDSN) + if err == nil { + newDB.SetConnMaxLifetime(5 * time.Minute) + oldConn := p.conn + p.conn = newDB + if err := p.Ping(); err == nil { + _ = oldConn.Close() + logger.Infof("PostgreSQL 已通过 DSN 配置 search_path:%s", searchPathDSNVal) + return + } + // DSN 方式失败,回滚 + _ = newDB.Close() + p.conn = oldConn + logger.Warnf("PostgreSQL DSN search_path 验证失败,回退至 SET 方式") + } + } + + // 策略 2 兜底:通过 SET search_path 设置(仅影响单个连接,但聊胜于无) + timeout := p.pingTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + ctx, cancel := utils.ContextWithTimeout(timeout) + defer cancel() + + if _, err := p.conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", searchPathSQL)); err != nil { + logger.Warnf("PostgreSQL 设置 search_path 失败:%v", err) + return + } + logger.Infof("PostgreSQL 已通过 SET 设置 search_path:%s", searchPathSQL) +} + +// queryUserSchemas 查询当前数据库中所有用户 schema。 +func (p *PostgresDB) queryUserSchemas() []string { + if p.conn == nil { + return nil + } + + query := `SELECT nspname FROM pg_namespace + WHERE nspname NOT IN ('pg_catalog', 'information_schema') + AND nspname NOT LIKE 'pg_%' + ORDER BY nspname` + + rows, err := p.conn.Query(query) + if err != nil { + logger.Warnf("PostgreSQL 查询用户 schema 失败:%v", err) + return nil + } + defer rows.Close() + + var schemas []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + continue + } + name = strings.TrimSpace(name) + if name != "" { + schemas = append(schemas, name) + } + } + return schemas +} + func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet) error { if p.conn == nil { return fmt.Errorf("连接未打开") From 29b96719d5514d16ef8516fe1e8efcbb206676a4 Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:29:03 +0800 Subject: [PATCH 10/22] =?UTF-8?q?=F0=9F=90=9B=20fix(sql):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=97=B6=E9=97=B4=E5=AD=97=E6=AE=B5=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E4=B8=8E=E5=AF=BC=E5=87=BASQL=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/DataGrid.tsx | 30 ++-- frontend/src/components/DataSyncModal.tsx | 31 ++++- .../src/components/dataGridCopyInsert.test.ts | 61 ++++++++ frontend/src/components/dataGridCopyInsert.ts | 131 ++++++++++++++++++ internal/app/methods_file.go | 10 +- internal/app/methods_file_export_test.go | 14 ++ internal/sync/preview.go | 10 ++ 7 files changed, 269 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/dataGridCopyInsert.test.ts create mode 100644 frontend/src/components/dataGridCopyInsert.ts diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index ab5f5d6..669ea9a 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -33,6 +33,7 @@ import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout'; +import { buildCopyInsertSQL, normalizeTemporalLiteralText } from './dataGridCopyInsert'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -995,6 +996,7 @@ const DataGrid: React.FC = ({ const selectionColumnWidth = 46; const currentConnConfig = connections.find(c => c.id === connectionId)?.config; const dataSourceCaps = getDataSourceCapabilities(currentConnConfig); + const dbType = dataSourceCaps.type; const isDuckDBConnection = dataSourceCaps.type === 'duckdb'; const supportsCopyInsert = dataSourceCaps.supportsCopyInsert; const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport; @@ -1336,6 +1338,16 @@ const DataGrid: React.FC = ({ return next; }, [columnMetaMap]); + const columnTypeMapByLowerName = useMemo(() => { + const next: Record = {}; + Object.entries(columnMetaMapByLowerName).forEach(([name, meta]) => { + const type = String(meta?.type || '').trim(); + if (!name || !type) return; + next[name] = type; + }); + return next; + }, [columnMetaMapByLowerName]); + const normalizeCommitCellValue = useCallback( (columnName: string, value: any, mode: 'insert' | 'update') => { if (value === undefined) return undefined; @@ -1357,7 +1369,7 @@ const DataGrid: React.FC = ({ // INSERT 空时间值直接忽略字段,让数据库默认值生效;UPDATE 空时间值转 NULL。 return mode === 'insert' ? undefined : null; } - return normalizeDateTimeString(value); + return normalizeTemporalLiteralText(value, meta?.type, true); } return value; @@ -3501,17 +3513,15 @@ const DataGrid: React.FC = ({ // 使用 columnNames 保持表定义的字段顺序,而非 Object.keys() 的不确定顺序 const orderedCols = columnNames.filter(c => c !== GONAVI_ROW_KEY); const sqlList = records.map((r: any) => { - const values = orderedCols.map(c => { - const v = r[c]; - if (v === null || v === undefined) return 'NULL'; - const str = typeof v === 'string' ? normalizeDateTimeString(v) : String(v); - const escaped = str.replace(/'/g, "''"); - return `'${escaped}'`; + return buildCopyInsertSQL({ + dbType, + tableName, + orderedCols, + record: r, + columnTypesByLowerName: columnTypeMapByLowerName, }); - const targetTable = tableName || 'table'; - return `INSERT INTO \`${targetTable}\` (${orderedCols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`; }); - copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, tableName, columnNames, getTargets, copyToClipboard]); + copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, columnNames, getTargets, copyToClipboard, dbType, tableName, columnTypeMapByLowerName]); const handleCopyJson = useCallback((record: any) => { const records = getTargets(record); diff --git a/frontend/src/components/DataSyncModal.tsx b/frontend/src/components/DataSyncModal.tsx index 1389be7..7775c08 100644 --- a/frontend/src/components/DataSyncModal.tsx +++ b/frontend/src/components/DataSyncModal.tsx @@ -6,6 +6,7 @@ import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview import { SavedConnection } from '../types'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert'; const { Title, Text } = Typography; const { Step } = Steps; @@ -74,7 +75,10 @@ const toSqlLiteral = (value: any, dbType: string): string => { return value ? 'TRUE' : 'FALSE'; } if (value instanceof Date) { - return `'${value.toISOString().replace(/'/g, "''")}'`; + return `'${formatLocalDateTimeLiteral(value).replace(/'/g, "''")}'`; + } + if (typeof value === 'string') { + return `'${value.replace(/'/g, "''")}'`; } if (typeof value === 'object') { try { @@ -86,6 +90,20 @@ const toSqlLiteral = (value: any, dbType: string): string => { return `'${String(value).replace(/'/g, "''")}'`; }; +const toTypedSqlLiteral = (value: any, dbType: string, columnType?: string): string => { + if (typeof value === 'string') { + const normalized = normalizeTemporalLiteralText(value, columnType, false); + return toSqlLiteral(normalized, dbType); + } + if (value instanceof Date) { + const normalized = String(columnType || '').trim() + ? formatLocalDateTimeLiteral(value) + : value.toISOString(); + return toSqlLiteral(normalized, dbType); + } + return toSqlLiteral(value, dbType); +}; + const resolveRedisDbIndex = (raw?: string): number => { const value = Number(String(raw || '').trim()); return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0; @@ -100,6 +118,9 @@ const buildSqlPreview = ( if (!previewData || !tableName) return { sqlText: '', statementCount: 0 }; const tableExpr = quoteSqlTable(dbType, tableName); const pkCol = String(previewData.pkColumn || 'id'); + const columnTypesByLowerName = previewData?.columnTypes && typeof previewData.columnTypes === 'object' + ? previewData.columnTypes as Record + : {}; const statements: string[] = []; const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : []; @@ -118,7 +139,7 @@ const buildSqlPreview = ( const columns = Object.keys(row); if (columns.length === 0) return; const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', '); - const valExpr = columns.map((c) => toSqlLiteral(row[c], dbType)).join(', '); + const valExpr = columns.map((c) => toTypedSqlLiteral(row[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])).join(', '); statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`); }); } @@ -134,10 +155,10 @@ const buildSqlPreview = ( const setCols = changedColumns.filter((c: string) => String(c) !== pkCol); if (setCols.length === 0) return; const setExpr = setCols - .map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toSqlLiteral(source[c], dbType)}`) + .map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toTypedSqlLiteral(source[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])}`) .join(', '); statements.push( - `UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`, + `UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`, ); }); } @@ -147,7 +168,7 @@ const buildSqlPreview = ( const pk = String(rowWrap?.pk ?? ''); if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return; statements.push( - `DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`, + `DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`, ); }); } diff --git a/frontend/src/components/dataGridCopyInsert.test.ts b/frontend/src/components/dataGridCopyInsert.test.ts new file mode 100644 index 0000000..01729e1 --- /dev/null +++ b/frontend/src/components/dataGridCopyInsert.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCopyInsertSQL } from './dataGridCopyInsert'; + +describe('buildCopyInsertSQL', () => { + it('normalizes PostgreSQL timestamp values for copy-as-insert and uses PostgreSQL identifier quoting', () => { + const sql = buildCopyInsertSQL({ + dbType: 'postgres', + tableName: 'public.OrderLog', + orderedCols: ['CreatedAt', 'note'], + record: { + CreatedAt: '2026-01-21T18:32:26+08:00', + note: "O'Brien", + }, + columnTypesByLowerName: { + createdat: 'timestamp without time zone', + note: 'text', + }, + }); + + expect(sql).toBe( + `INSERT INTO public."OrderLog" ("CreatedAt", note) VALUES ('2026-01-21 18:32:26', 'O''Brien');`, + ); + }); + + it('keeps timezone offsets for timezone-aware PostgreSQL columns while still removing the T separator', () => { + const sql = buildCopyInsertSQL({ + dbType: 'postgres', + tableName: 'public.audit_log', + orderedCols: ['created_at'], + record: { + created_at: '2026-01-21T18:32:26+08:00', + }, + columnTypesByLowerName: { + created_at: 'timestamp with time zone', + }, + }); + + expect(sql).toBe( + `INSERT INTO public.audit_log (created_at) VALUES ('2026-01-21 18:32:26+08:00');`, + ); + }); + + it('keeps RFC3339-looking text unchanged for non-temporal columns', () => { + const sql = buildCopyInsertSQL({ + dbType: 'postgres', + tableName: 'public.audit_log', + orderedCols: ['payload'], + record: { + payload: '2026-01-21T18:32:26+08:00', + }, + columnTypesByLowerName: { + payload: 'text', + }, + }); + + expect(sql).toBe( + `INSERT INTO public.audit_log (payload) VALUES ('2026-01-21T18:32:26+08:00');`, + ); + }); +}); diff --git a/frontend/src/components/dataGridCopyInsert.ts b/frontend/src/components/dataGridCopyInsert.ts new file mode 100644 index 0000000..3034584 --- /dev/null +++ b/frontend/src/components/dataGridCopyInsert.ts @@ -0,0 +1,131 @@ +import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; + +type BuildCopyInsertSQLParams = { + dbType: string; + tableName?: string; + orderedCols: string[]; + record: Record; + columnTypesByLowerName?: Record; +}; + +const looksLikeDateTimeText = (val: string): boolean => { + if (!val) return false; + const len = val.length; + if (len < 19 || len > 64) return false; + const charCode0 = val.charCodeAt(0); + if (charCode0 < 48 || charCode0 > 57) return false; + return ( + val[4] === '-' && + val[7] === '-' && + (val[10] === ' ' || val[10] === 'T') && + val[13] === ':' && + val[16] === ':' + ); +}; + +const normalizeDateTimeString = (val: string): string => { + if (!looksLikeDateTimeText(val)) { + return val; + } + + if (/^0{4}-0{2}-0{2}/.test(val)) { + return val; + } + + 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_\/+-]+)?)?$/ + ); + return match ? `${match[1]} ${match[2]}` : val; +}; + +const normalizeTimezoneAwareDateTimeString = (val: string): string => { + if (!looksLikeDateTimeText(val)) { + return val; + } + + if (/^0{4}-0{2}-0{2}/.test(val)) { + return val; + } + + 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_\/+-]+)?)?$/ + ); + if (!match) { + return val; + } + const suffix = match[3] || ''; + return `${match[1]} ${match[2]}${suffix}`; +}; + +const isTemporalColumnType = (columnType?: string): boolean => { + const raw = String(columnType || '').trim().toLowerCase(); + if (!raw) return false; + if (raw.includes('datetime') || raw.includes('timestamp') || raw.includes('timestamptz')) return true; + const base = raw.split(/[ (]/)[0]; + return base === 'date' || base === 'time' || base === 'timetz' || base === 'year'; +}; + +const isTimezoneAwareColumnType = (columnType?: string): boolean => { + const raw = String(columnType || '').trim().toLowerCase(); + if (!raw) return false; + return ( + raw.includes('with time zone') || + raw.includes('with timezone') || + raw.includes('datetimeoffset') || + raw.includes('timestamptz') || + raw.includes('timetz') + ); +}; + +export const normalizeTemporalLiteralText = ( + value: string, + columnType?: string, + normalizeWhenTypeMissing = false, +): string => { + const rawType = String(columnType || '').trim(); + if (!rawType) { + return normalizeWhenTypeMissing ? normalizeDateTimeString(value) : value; + } + if (!isTemporalColumnType(rawType)) { + return value; + } + return isTimezoneAwareColumnType(rawType) + ? normalizeTimezoneAwareDateTimeString(value) + : normalizeDateTimeString(value); +}; + +export const formatLocalDateTimeLiteral = (value: Date): string => { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + const hour = String(value.getHours()).padStart(2, '0'); + const minute = String(value.getMinutes()).padStart(2, '0'); + const second = String(value.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; +}; + +export const buildCopyInsertSQL = ({ + dbType, + tableName, + orderedCols, + record, + columnTypesByLowerName = {}, +}: BuildCopyInsertSQLParams): string => { + const targetTable = quoteQualifiedIdent(dbType, tableName || 'table'); + const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col)); + const values = orderedCols.map((col) => { + const value = record?.[col]; + if (value === null || value === undefined) return 'NULL'; + + const columnType = columnTypesByLowerName[String(col || '').toLowerCase()]; + const raw = + typeof value === 'string' + ? normalizeTemporalLiteralText(value, columnType, true) + : value instanceof Date + ? formatLocalDateTimeLiteral(value) + : String(value); + return `'${escapeLiteral(raw)}'`; + }); + + return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`; +}; diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 3d02edf..d2ad585 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -574,7 +574,7 @@ func isDateTimeColumnType(columnType string) bool { if typ == "" { return false } - return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") + return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") || strings.Contains(typ, "timestamptz") } func isTimeOnlyColumnType(columnType string) bool { @@ -585,7 +585,7 @@ func isTimeOnlyColumnType(columnType string) bool { if strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") { return false } - return strings.Contains(typ, "time") + return strings.Contains(typ, "time") || strings.Contains(typ, "timetz") } func isDateOnlyColumnType(dbType, columnType string) bool { @@ -1717,6 +1717,10 @@ func dumpTableSQL( if err != nil { return err } + columnTypeMap := map[string]string{} + if defs, colErr := dbInst.GetColumns(schemaName, pureTableName); colErr == nil { + columnTypeMap = buildImportColumnTypeMap(defs) + } if len(data) == 0 { if _, err := w.WriteString("-- (0 rows)\n"); err != nil { return err @@ -1733,7 +1737,7 @@ func dumpTableSQL( for _, row := range data { values := make([]string, 0, len(columns)) for _, c := range columns { - values = append(values, formatSQLValue(config.Type, row[c])) + values = append(values, formatImportSQLValue(config.Type, columnTypeMap[normalizeColumnName(c)], row[c])) } if _, err := w.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n", quotedTable, strings.Join(quotedCols, ", "), strings.Join(values, ", "))); err != nil { return err diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go index 5ddaf9c..998d85d 100644 --- a/internal/app/methods_file_export_test.go +++ b/internal/app/methods_file_export_test.go @@ -273,3 +273,17 @@ func TestWriteRowsToFile_HTML_EscapeHeader(t *testing.T) { t.Fatalf("html 表头未正确转义: %s", content) } } + +func TestFormatImportSQLValue_NormalizesTimestampWithoutTimezone(t *testing.T) { + got := formatImportSQLValue("postgres", "timestamp without time zone", "2026-01-21T18:32:26+08:00") + if got != "'2026-01-21 18:32:26'" { + t.Fatalf("时间字面量归一化异常,want=%q got=%q", "'2026-01-21 18:32:26'", got) + } +} + +func TestFormatImportSQLValue_LeavesTextLiteralUntouched(t *testing.T) { + got := formatImportSQLValue("postgres", "text", "2026-01-21T18:32:26+08:00") + if got != "'2026-01-21T18:32:26+08:00'" { + t.Fatalf("文本字段不应被归一化,want=%q got=%q", "'2026-01-21T18:32:26+08:00'", got) + } +} diff --git a/internal/sync/preview.go b/internal/sync/preview.go index 2ce6434..592d0de 100644 --- a/internal/sync/preview.go +++ b/internal/sync/preview.go @@ -21,6 +21,7 @@ type PreviewUpdateRow struct { type TableDiffPreview struct { Table string `json:"table"` PKColumn string `json:"pkColumn"` + ColumnTypes map[string]string `json:"columnTypes,omitempty"` TotalInserts int `json:"totalInserts"` TotalUpdates int `json:"totalUpdates"` TotalDeletes int `json:"totalDeletes"` @@ -112,6 +113,7 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta out := TableDiffPreview{ Table: tableName, PKColumn: pkCol, + ColumnTypes: make(map[string]string, len(cols)), TotalInserts: 0, TotalUpdates: 0, TotalDeletes: 0, @@ -119,6 +121,14 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta Updates: make([]PreviewUpdateRow, 0), Deletes: make([]PreviewRow, 0), } + for _, col := range cols { + name := strings.ToLower(strings.TrimSpace(col.Name)) + typ := strings.TrimSpace(col.Type) + if name == "" || typ == "" { + continue + } + out.ColumnTypes[name] = typ + } sourcePKSet := make(map[string]struct{}, len(sourceRows)) for _, sRow := range sourceRows { From 4f7ac7149a85d28af593f002d666311ec03c6b0b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 13:52:57 +0800 Subject: [PATCH 11/22] =?UTF-8?q?=F0=9F=93=9D=20docs(readme):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E9=A1=B9=E7=9B=AE=20Star=20=E5=A2=9E=E9=95=BF?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E5=9B=BE=E4=B8=8E=E5=8A=A8=E6=80=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=BE=BD=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量 - 增长趋势:底部区域新增 Star History 的动态增长曲线图表 - 兼容性修复:将 HTML `` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题 - 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新 --- README.md | 6 ++++++ README.zh-CN.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index a7ccd2e..5fa1356 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ [![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/) [![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE) [![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions) +[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers) +[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases) **Language**: English | [简体中文](README.zh-CN.md) @@ -212,6 +214,10 @@ For the full workflow, branch model, and maintainer sync rules, see: External contributors should open pull requests directly against `main`. +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=Syngnat/GoNavi&type=Date)](https://star-history.com/#Syngnat/GoNavi&Date) + ## Links - [linux.do](https://linux.do/) diff --git a/README.zh-CN.md b/README.zh-CN.md index d83d025..420bbd6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,6 +5,8 @@ [![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/) [![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE) [![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions) +[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers) +[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases) **语言**: [English](README.md) | 简体中文 @@ -195,6 +197,10 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0 外部贡献者统一直接向 `main` 发起 Pull Request。 +## Star History (Star 增长趋势) + +[![Star History Chart](https://api.star-history.com/svg?repos=Syngnat/GoNavi&type=Date)](https://star-history.com/#Syngnat/GoNavi&Date) + ## 友情链接 - [linux.do](https://linux.do/) From c2c8870841915541e74dcfa93c170280ed43e553 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 14:03:14 +0800 Subject: [PATCH 12/22] =?UTF-8?q?=F0=9F=93=9D=20docs(readme):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E9=A1=B9=E7=9B=AE=20Star=20=E5=A2=9E=E9=95=BF?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E5=9B=BE=E4=B8=8E=E5=8A=A8=E6=80=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=BE=BD=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量 - 增长趋势:底部区域新增 Star History 的动态增长曲线图表 - 兼容性修复:将 HTML `` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题 - 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新 --- README.md | 8 +++++++- README.zh-CN.md | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5fa1356..cd3dc84 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,13 @@ External contributors should open pull requests directly against `main`. ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=Syngnat/GoNavi&type=Date)](https://star-history.com/#Syngnat/GoNavi&Date) + + + + + Star History Chart + + ## Links diff --git a/README.zh-CN.md b/README.zh-CN.md index 420bbd6..b5430f8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -199,7 +199,13 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0 ## Star History (Star 增长趋势) -[![Star History Chart](https://api.star-history.com/svg?repos=Syngnat/GoNavi&type=Date)](https://star-history.com/#Syngnat/GoNavi&Date) + + + + + Star History Chart + + ## 友情链接 From 15f72c013dbda52f9480122bcc534940aa51ffeb Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 14:04:26 +0800 Subject: [PATCH 13/22] =?UTF-8?q?=F0=9F=93=9D=20docs(readme):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E9=A1=B9=E7=9B=AE=20Star=20=E5=A2=9E=E9=95=BF?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E5=9B=BE=E4=B8=8E=E5=8A=A8=E6=80=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=BE=BD=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量 - 增长趋势:底部区域新增 Star History 的动态增长曲线图表 - 兼容性修复:将 HTML `` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题 - 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新 --- README.md | 9 ++++----- README.zh-CN.md | 8 ++++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cd3dc84..9b42c4a 100644 --- a/README.md +++ b/README.md @@ -215,12 +215,11 @@ For the full workflow, branch model, and maintainer sync rules, see: External contributors should open pull requests directly against `main`. ## Star History - - + - - - Star History Chart + + + Star History Chart diff --git a/README.zh-CN.md b/README.zh-CN.md index b5430f8..b392a06 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -199,11 +199,11 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0 ## Star History (Star 增长趋势) - + - - - Star History Chart + + + Star History Chart From e464c2cce12845d888849ba0e94169bbe0cfaa62 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 14:49:28 +0800 Subject: [PATCH 14/22] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A5=E6=9C=9F=E6=97=B6=E9=97=B4=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=AD=97=E6=AE=B5=E7=BC=96=E8=BE=91=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=B9=B6=E4=B8=AD=E6=96=87=E5=8C=96=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复"此刻"按钮点击后自动提交的问题,改为自定义按钮仅填值、需点击"确定"才保存 - 修复 datetime 编辑态点击外部后不退出的问题,通过 onBlur + pickerOpenRef 兜底 - 全局配置 dayjs 中文 locale,日期选择器月份/星期等文本显示为中文 - 为 time/date/year 类型 picker 添加 onBlur 兜底,确保焦点离开后退出编辑 - save 函数增加 editing 守卫和 catch 兜底,防止重复保存或异常时卡死编辑态 - refs #289 --- frontend/src/components/DataGrid.tsx | 27 +++++++++++++++++++++++++-- frontend/src/main.tsx | 6 ++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index f90d89a..e50a2ce 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -571,6 +571,7 @@ const EditableCell: React.FC = React.memo(({ }) => { const [editing, setEditing] = useState(false); const inputRef = useRef(null); + const pickerOpenRef = useRef(false); const form = useContext(EditableContext); const cellContextMenuContext = useContext(CellContextMenuContext); @@ -596,7 +597,7 @@ const EditableCell: React.FC = React.memo(({ const save = async () => { try { - if (!form) return; + if (!form || !editing) return; const fieldName = getCellFieldName(record, dataIndex); await form.validateFields([fieldName]); let nextValue = form.getFieldValue(fieldName); @@ -617,6 +618,8 @@ const EditableCell: React.FC = React.memo(({ } } catch (errInfo) { console.log('Save failed:', errInfo); + // 日期时间类型保存失败时兜底退出编辑,避免 DatePicker 卡在编辑态 + if (isDateTimeField && editing) setEditing(false); } }; @@ -642,6 +645,7 @@ const EditableCell: React.FC = React.memo(({ style={{ width: '100%' }} format={TEMPORAL_FORMATS[pickerType]} onChange={() => setTimeout(save, 0)} + onBlur={() => setTimeout(save, 0)} needConfirm={false} /> ) : pickerType === 'datetime' ? ( @@ -649,12 +653,30 @@ const EditableCell: React.FC = React.memo(({ ref={inputRef} style={{ width: '100%' }} showTime + showNow={false} format={TEMPORAL_FORMATS[pickerType]} + renderExtraFooter={() => ( + { + // 自定义"此刻":仅将当前时间填入表单字段,面板保持打开。 + // 用户需点击"确定"才真正保存,替代内置 showNow 的自动提交行为。 + const fieldName = getCellFieldName(record, dataIndex); + setCellFieldValue(form, fieldName, dayjs()); + }} + >此刻 + )} onOk={() => setTimeout(save, 0)} onOpenChange={(open) => { - // 面板关闭(点击外部)且非通过"确定"按钮触发时退出编辑,不保存 + pickerOpenRef.current = open; + // 面板关闭(点击外部)时退出编辑,不保存;仅"确定"按钮(onOk)触发保存 if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0); }} + onBlur={() => { + // 兜底:面板未打开或已关闭时,点击外部通过 blur 退出编辑。 + // 延迟检查面板状态,避免点击自定义"此刻"按钮时误退出(此时面板仍打开)。 + setTimeout(() => { if (editing && !pickerOpenRef.current) setEditing(false); }, 150); + }} needConfirm /> ) : ( @@ -664,6 +686,7 @@ const EditableCell: React.FC = React.memo(({ format={TEMPORAL_FORMATS[pickerType]} picker={pickerType as any} onChange={() => setTimeout(save, 0)} + onBlur={() => setTimeout(save, 0)} needConfirm={false} /> ) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4c9d370..4f8fa6e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,12 @@ import ReactDOM from 'react-dom/client' import App from './App' // import './index.css' // Optional global styles +// 全局配置 dayjs 使用中文 locale,使 Ant Design 的 DatePicker/TimePicker 等组件 +// 的月份、星期等文本显示为中文。必须在 Ant Design 组件渲染前完成配置。 +import dayjs from 'dayjs' +import 'dayjs/locale/zh-cn' +dayjs.locale('zh-cn') + // 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。 // Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。 // 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。 From 2c2baca69fe336a6305aa5e705151cf9a2dbc73d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 14:53:44 +0800 Subject: [PATCH 15/22] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A5=E6=9C=9F=E6=97=B6=E9=97=B4=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E4=BA=8C=E6=AC=A1=E7=BC=96=E8=BE=91=E6=97=B6=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E6=AE=8B=E7=95=99=E4=B8=8A=E6=AC=A1=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Form.Item 默认 preserve={true},DatePicker 卸载后表单仍保留旧 dayjs 值 - 再次进入编辑时 DatePicker 读取到残留值,导致日历面板显示上次选择的日期圆圈 - 设置 preserve={false} 确保每次编辑态卸载后清除字段值,消除残留标记 - refs #290 --- frontend/src/components/DataGrid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index e50a2ce..53112ac 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -637,7 +637,7 @@ const EditableCell: React.FC = React.memo(({ if (editable) { childNode = editing ? ( - + {isDateTimeField ? ( pickerType === 'time' ? ( Date: Wed, 1 Apr 2026 15:03:02 +0800 Subject: [PATCH 16/22] =?UTF-8?q?=F0=9F=8E=A8=20style(data-grid):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E7=AD=9B=E9=80=89=E9=9D=A2=E6=9D=BF=E4=B8=BA?= =?UTF-8?q?=20flex=20=E5=88=86=E5=8C=BA=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 外层改为 flex column,拆分为可滚动内容区(maxHeight: 200px)和固定操作栏 - "添加排序"从内容区提升到操作栏,条件渲染依赖 onSort 存在性 - "添加条件"使用 primary ghost 按钮增强辨识度 - refs #295 --- frontend/src/components/DataGrid.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 53112ac..9d168ce 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -4801,7 +4801,11 @@ const DataGrid: React.FC = ({ padding: `${filterTopPadding}px ${panelPaddingX}px ${panelPaddingY}px ${panelPaddingX}px`, background: 'transparent', boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', }}> + {/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */} +
{filterConditions.map((cond, condIndex) => (
= ({ }} />
))} -
)} -
0) ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) ? `1px dashed ${panelFrameColor}` : 'none' }}> - +
+
0) || filterConditions.length > 0 ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? `1px dashed ${panelFrameColor}` : 'none' }}> + + {onSort && ( + + )}
From 08ab06c038160a43dfcc90e155abae9bc8724d75 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 15:29:42 +0800 Subject: [PATCH 17/22] =?UTF-8?q?=E2=9C=A8=20feat(sidebar/table-overview):?= =?UTF-8?q?=20=E4=BC=98=E5=8C=96=E4=BE=A7=E8=BE=B9=E6=A0=8F=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=B9=B6=E6=96=B0=E5=A2=9E=E8=A1=A8=E6=A6=82=E8=A7=88?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复连接刷新后数据库节点无法再次展开的问题,刷新时清除子节点 expandedKeys/loadedKeys/loadingRef - 表概览由双击改为单击"表(N)"分组节点打开,双击仅触发展开/折叠 - 使用 clickTimerRef 延时防抖区分单击与双击事件,避免双击同时打开表概览 - 表概览新增列表视图模式,展示表名、注释、行数、数据大小、索引大小、引擎等列 - 工具栏新增卡片/列表视图切换按钮,两种视图共享搜索、排序和右键菜单 - refs #296 - refs #324 --- frontend/src/components/Sidebar.tsx | 59 ++++++--- frontend/src/components/TableOverview.tsx | 146 +++++++++++++++++++++- 2 files changed, 187 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 58658f3..b50d09f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -175,6 +175,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const [selectedKeys, setSelectedKeys] = useState([]); const selectedNodesRef = useRef([]); const loadingNodesRef = useRef>(new Set()); + const clickTimerRef = useRef | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null); // Virtual Scroll State @@ -1456,6 +1457,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false); else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false); else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false); + else if (type === 'object-group' && dataRef?.groupKey === 'tables') { + // 单击延迟打开表概览,双击时会取消此定时器 + if (clickTimerRef.current) clearTimeout(clickTimerRef.current); + const { id, dbName: gDbName, schemaName } = dataRef; + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = null; + addTab({ + id: `table-overview-${id}-${gDbName}${schemaName ? `-${schemaName}` : ''}`, + title: `表概览 - ${gDbName}${schemaName ? ` (${schemaName})` : ''}`, + type: 'table-overview' as any, + connectionId: id, + dbName: gDbName, + schemaName, + } as any); + }, 250); + } }; const onExpand = (newExpandedKeys: React.Key[]) => { @@ -1464,7 +1481,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; const onDoubleClick = (e: any, node: any) => { - // 保证用户直接双击节点未触发 onClick/onSelect 时也能强行拿到选中状态 + // 双击时取消单击延迟动作(如表概览打开),让双击只触发展开/折叠 + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } const { type, dataRef, key: nodeKey } = node; if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' }); else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); @@ -1472,18 +1493,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName }); else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` }); - if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') { - const { id, dbName, schemaName } = node.dataRef; - addTab({ - id: `table-overview-${id}-${dbName}${schemaName ? `-${schemaName}` : ''}`, - title: `表概览 - ${dbName}${schemaName ? ` (${schemaName})` : ''}`, - type: 'table-overview' as any, - connectionId: id, - dbName, - schemaName, - } as any); - return; - } if (node.type === 'table') { const { tableName, dbName, id } = node.dataRef; // 记录表访问 @@ -3090,7 +3099,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> key: 'refresh', label: '刷新', icon: , - onClick: () => loadDatabases(node) + onClick: () => { + const connKey = String(node.key); + // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData + setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); + setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); + // 清除 loadingNodesRef 中残留的子节点加载标记 + Array.from(loadingNodesRef.current).forEach(lk => { + if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); + }); + loadDatabases(node); + } }, { type: 'divider' }, { @@ -3207,7 +3226,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> key: 'refresh', label: '刷新', icon: , - onClick: () => loadDatabases(node) + onClick: () => { + const connKey = String(node.key); + // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData + setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); + setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); + // 清除 loadingNodesRef 中残留的子节点加载标记 + Array.from(loadingNodesRef.current).forEach(lk => { + if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); + }); + loadDatabases(node); + } }, { type: 'divider' }, { diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index f1d641d..da611e3 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd'; -import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons'; +import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App'; import type { TabData } from '../types'; @@ -22,6 +22,7 @@ interface TableStatRow { type SortField = 'name' | 'rows' | 'dataSize'; type SortOrder = 'asc' | 'desc'; +type ViewMode = 'card' | 'list'; const formatSize = (bytes: number): string => { if (!bytes || bytes <= 0) return '—'; @@ -146,6 +147,7 @@ const TableOverview: React.FC = ({ tab }) => { const [searchText, setSearchText] = useState(''); const [sortField, setSortField] = useState('name'); const [sortOrder, setSortOrder] = useState('asc'); + const [viewMode, setViewMode] = useState('card'); const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]); @@ -366,14 +368,43 @@ const TableOverview: React.FC = ({ tab }) => { +
+ +
setViewMode('card')} + style={{ + padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s', + background: viewMode === 'card' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent', + boxShadow: viewMode === 'card' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none', + color: viewMode === 'card' ? accentColor : textMuted, + }} + > + +
+
+ +
setViewMode('list')} + style={{ + padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s', + background: viewMode === 'list' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent', + boxShadow: viewMode === 'list' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none', + color: viewMode === 'list' ? accentColor : textMuted, + }} + > + +
+
+
- {/* Cards Grid */} + {/* Content Area */}
{sortedFiltered.length === 0 ? ( - ) : ( + ) : viewMode === 'card' ? ( + /* ========== 卡片视图 ========== */
= ({ tab }) => { ))}
+ ) : ( + /* ========== 列表/表格视图 ========== */ +
+ + + + {[ + { field: 'name' as SortField, label: '表名', width: undefined }, + { field: null, label: '注释', width: undefined }, + { field: 'rows' as SortField, label: '行数', width: 100 }, + { field: 'dataSize' as SortField, label: '数据大小', width: 110 }, + { field: null, label: '索引大小', width: 110 }, + { field: null, label: '引擎', width: 90 }, + ].map((col, idx) => ( + + ))} + + + + {sortedFiltered.map((t, rowIdx) => ( + , onClick: () => { + setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' }); + addTab({ + id: `query-${Date.now()}`, + title: '新建查询', + type: 'query', + connectionId: tab.connectionId, + dbName: tab.dbName, + query: `SELECT * FROM ${t.name};`, + }); + }}, + { type: 'divider' }, + { key: 'design-table', label: '设计表', icon: , onClick: () => openDesign(t.name) }, + { key: 'copy-structure', label: '复制表结构', icon: , onClick: () => handleCopyStructure(t.name) }, + { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(t.name, 'sql') }, + { key: 'rename-table', label: '重命名表', icon: , onClick: () => handleRenameTable(t.name) }, + { key: 'drop-table', label: '删除表', icon: , danger: true, onClick: () => handleDeleteTable(t.name) }, + { type: 'divider' }, + { key: 'export', label: '导出表数据', icon: , children: [ + { key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') }, + { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') }, + { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') }, + { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') }, + { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') }, + ]}, + ], + }} + > + openTable(t.name)} + style={{ + cursor: 'pointer', + transition: 'background 0.12s', + borderBottom: rowIdx < sortedFiltered.length - 1 ? `1px solid ${cardBorder}` : 'none', + }} + onMouseEnter={e => { (e.currentTarget as HTMLTableRowElement).style.background = cardHoverBg; }} + onMouseLeave={e => { (e.currentTarget as HTMLTableRowElement).style.background = 'transparent'; }} + > + + + + + + + + + ))} + +
toggleSort(col.field!) : undefined} + style={{ + padding: '10px 14px', + textAlign: idx >= 2 ? 'right' : 'left', + fontWeight: 600, + color: textSecondary, + borderBottom: `1px solid ${cardBorder}`, + cursor: col.field ? 'pointer' : 'default', + userSelect: 'none', + whiteSpace: 'nowrap', + width: col.width, + }} + > + {col.label} + {col.field && sortField === col.field && ( + + {sortOrder === 'asc' ? '↑' : '↓'} + + )} +
+
+ + + {t.name} + +
+
+ {t.comment ? ( + {t.comment} + ) : ( + + )} + {formatRows(t.rows)}{formatSize(t.dataSize)}{formatSize(t.indexSize)}{t.engine || '—'}
+
)}
From f7107a16250b4c3bb8277e6d8ce1f96c4bdbdc90 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 15:45:50 +0800 Subject: [PATCH 18/22] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=95=E5=85=83=E6=A0=BC=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=80=BC=E4=B8=A2=E5=A4=B1=E5=8F=8A=E6=97=A5=E6=9C=9F=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=E6=BB=9A=E5=8A=A8=E5=81=8F=E7=A7=BB=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 Form.Item 的 preserve={false},修复嵌套字段名下编辑后值变为 undefined 的问题 - 将表单值初始化移至 useEffect([editing]),确保每次编辑时从 record 重新读取并覆盖旧值 - 新增 cellRef 绑定单元格 DOM,用于定位滚动容器 - DatePicker/TimePicker 面板打开时在 ant-table-wrapper 上拦截 wheel 事件,阻止表格滚动导致选择器漂移 - 面板关闭时自动移除 wheel 事件监听,恢复正常滚动 - refs #297 --- frontend/src/components/DataGrid.tsx | 45 +++++++++++++++++++++------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 9d168ce..5357f4c 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -571,28 +571,47 @@ const EditableCell: React.FC = React.memo(({ }) => { const [editing, setEditing] = useState(false); const inputRef = useRef(null); + const cellRef = useRef(null); const pickerOpenRef = useRef(false); + const scrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null); const form = useContext(EditableContext); const cellContextMenuContext = useContext(CellContextMenuContext); + /** DatePicker 面板打开时锁定表格滚动,关闭时恢复 */ + const lockTableScroll = useCallback((lock: boolean) => { + if (lock) { + // 查找虚拟滚动容器或常规滚动容器 + const tableWrapper = cellRef.current?.closest?.('.ant-table-wrapper') as HTMLElement | null; + if (tableWrapper) { + const handler = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); }; + tableWrapper.addEventListener('wheel', handler, { capture: true, passive: false }); + scrollLockRef.current = { el: tableWrapper, handler }; + } + } else if (scrollLockRef.current) { + const { el, handler } = scrollLockRef.current; + el.removeEventListener('wheel', handler, { capture: true } as any); + scrollLockRef.current = null; + } + }, []); + useEffect(() => { if (editing) { + // 每次进入编辑时强制设置表单值(覆盖 form store 中可能残留的旧值) + const raw = record[dataIndex]; + const fieldName = getCellFieldName(record, dataIndex); + if (isDateTimeField) { + const dayjsVal = parseToDayjs(raw, pickerType); + setCellFieldValue(form, fieldName, dayjsVal); + } else { + const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; + setCellFieldValue(form, fieldName, initialValue); + } inputRef.current?.focus(); } }, [editing]); const toggleEdit = () => { setEditing(!editing); - const raw = record[dataIndex]; - const fieldName = getCellFieldName(record, dataIndex); - if (isDateTimeField) { - // 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用 - const dayjsVal = parseToDayjs(raw, pickerType); - setCellFieldValue(form, fieldName, dayjsVal); - } else { - const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; - setCellFieldValue(form, fieldName, initialValue); - } }; const save = async () => { @@ -637,7 +656,7 @@ const EditableCell: React.FC = React.memo(({ if (editable) { childNode = editing ? ( - + {isDateTimeField ? ( pickerType === 'time' ? ( = React.memo(({ style={{ width: '100%' }} format={TEMPORAL_FORMATS[pickerType]} onChange={() => setTimeout(save, 0)} + onOpenChange={lockTableScroll} onBlur={() => setTimeout(save, 0)} needConfirm={false} /> @@ -669,6 +689,7 @@ const EditableCell: React.FC = React.memo(({ onOk={() => setTimeout(save, 0)} onOpenChange={(open) => { pickerOpenRef.current = open; + lockTableScroll(open); // 面板关闭(点击外部)时退出编辑,不保存;仅"确定"按钮(onOk)触发保存 if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0); }} @@ -686,6 +707,7 @@ const EditableCell: React.FC = React.memo(({ format={TEMPORAL_FORMATS[pickerType]} picker={pickerType as any} onChange={() => setTimeout(save, 0)} + onOpenChange={lockTableScroll} onBlur={() => setTimeout(save, 0)} needConfirm={false} /> @@ -745,6 +767,7 @@ const EditableCell: React.FC = React.memo(({ return ( Date: Wed, 1 Apr 2026 15:54:29 +0800 Subject: [PATCH 19/22] =?UTF-8?q?=F0=9F=94=A5=20remove(table-designer):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=20MySQL=20=E7=B4=A2=E5=BC=95=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E4=B8=AD=E4=B8=8D=E6=94=AF=E6=8C=81=E7=9A=84=20HASH?= =?UTF-8?q?=20=E5=92=8C=20RTREE=20=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MySQL InnoDB 引擎不支持手动创建 HASH/RTREE 索引,执行后会静默降级为 BTREE - 从 MYSQL_INDEX_TYPE_OPTIONS 中移除 HASH 和 RTREE,避免用户误选导致修改"不生效" - MySQL 下仅保留 DEFAULT/BTREE/FULLTEXT/SPATIAL 四种索引类型 - refs #298 --- frontend/src/components/TableDesigner.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 46f58f5..619914b 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -220,10 +220,8 @@ const COMMON_DEFAULTS = [ const MYSQL_INDEX_TYPE_OPTIONS = [ { label: '默认', value: 'DEFAULT' }, { label: 'BTREE', value: 'BTREE' }, - { label: 'HASH', value: 'HASH' }, { label: 'FULLTEXT', value: 'FULLTEXT' }, { label: 'SPATIAL', value: 'SPATIAL' }, - { label: 'RTREE', value: 'RTREE' }, ]; const PGLIKE_INDEX_TYPE_OPTIONS = [ From 1eb517f083e6efa1afb1fe9e25cf05b5f166a9ee Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 16:04:22 +0800 Subject: [PATCH 20/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(table-desig?= =?UTF-8?q?ner):=20=E6=8C=89=E7=B4=A2=E5=BC=95=E7=B1=BB=E5=88=AB=E7=B2=BE?= =?UTF-8?q?=E7=A1=AE=E5=88=86=E7=B1=BB=E7=B4=A2=E5=BC=95=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MySQL InnoDB:所有索引类别均为固定方法(BTREE/FULLTEXT/RTREE),移除无意义的"默认"选项 - PostgreSQL:普通索引保留全部方法选项,主键和唯一索引固定为 BTREE - 新增 getFixedIndexType 辅助函数,切换索引类别时自动设置对应的固定方法类型 - getIndexTypeOptions 接受 kind 参数,按类别动态返回精确的选项列表 - 切换类别时若当前方法不在新选项中,自动重置为合法值 - refs #299 --- frontend/src/components/TableDesigner.tsx | 67 +++++++++++++++++------ 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 619914b..fa17af0 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -217,12 +217,6 @@ const COMMON_DEFAULTS = [ { value: "''" }, ]; -const MYSQL_INDEX_TYPE_OPTIONS = [ - { label: '默认', value: 'DEFAULT' }, - { label: 'BTREE', value: 'BTREE' }, - { label: 'FULLTEXT', value: 'FULLTEXT' }, - { label: 'SPATIAL', value: 'SPATIAL' }, -]; const PGLIKE_INDEX_TYPE_OPTIONS = [ { label: '默认', value: 'DEFAULT' }, @@ -1439,14 +1433,37 @@ ${selectedTrigger.statement}`; ]; }; - const getIndexTypeOptions = () => { + const getIndexTypeOptions = (kind?: IndexKind) => { const dbType = getDbType(); - if (isMysqlLikeDialect(dbType)) return MYSQL_INDEX_TYPE_OPTIONS; - if (isPgLikeDialect(dbType)) return PGLIKE_INDEX_TYPE_OPTIONS; + const k = kind || 'NORMAL'; + if (isMysqlLikeDialect(dbType)) { + // MySQL InnoDB: 所有索引均为固定方法类型 + if (k === 'FULLTEXT') return [{ label: 'FULLTEXT', value: 'FULLTEXT' }]; + if (k === 'SPATIAL') return [{ label: 'RTREE', value: 'RTREE' }]; + return [{ label: 'BTREE', value: 'BTREE' }]; + } + if (isPgLikeDialect(dbType)) { + if (k === 'PRIMARY' || k === 'UNIQUE') return [{ label: 'BTREE', value: 'BTREE' }]; + return PGLIKE_INDEX_TYPE_OPTIONS; + } if (isSqlServerDialect(dbType)) return SQLSERVER_INDEX_TYPE_OPTIONS; return [{ label: '默认', value: 'DEFAULT' }]; }; + /** 根据索引类别返回固定的索引方法类型,可选类别返回 undefined */ + const getFixedIndexType = (kind: IndexKind): string | undefined => { + const dbType = getDbType(); + if (isMysqlLikeDialect(dbType)) { + if (kind === 'PRIMARY') return 'BTREE'; + if (kind === 'FULLTEXT') return 'FULLTEXT'; + if (kind === 'SPATIAL') return 'RTREE'; + } + if (isPgLikeDialect(dbType)) { + if (kind === 'PRIMARY') return 'BTREE'; + } + return undefined; + }; + const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => { const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``; const colDefs = targetColumns.map(curr => { @@ -2926,20 +2943,34 @@ END;`; setIndexForm(prev => ({ ...prev, indexType: val }))} - options={getIndexTypeOptions()} + options={getIndexTypeOptions(indexForm.kind)} style={{ width: 160 }} disabled={indexForm.kind === 'PRIMARY' || indexForm.kind === 'FULLTEXT' || indexForm.kind === 'SPATIAL'} /> From 5c0f6f8ff4e91dcd124993acc3191827ac238617 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 16:21:57 +0800 Subject: [PATCH 21/22] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=95=B0=E6=8D=AE=E9=A2=84=E8=A7=88=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=97=A5=E6=9C=9F=E6=A0=BC=E5=BC=8F=E5=8C=96=E3=80=81?= =?UTF-8?q?JSON=E5=88=87=E6=8D=A2=E5=A4=B1=E6=95=88=E5=8F=8A=E5=B9=BD?= =?UTF-8?q?=E7=81=B5=E5=8F=98=E6=9B=B4=E8=AE=A1=E6=95=B0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 日期时间字段预览时通过 normalizeDateTimeString 格式化带时区的 ISO 格式 - 切换单元格时始终更新预览值,用 dataPanelOriginalRef 替代 suppress 机制判断 dirty - handleCellSave 增加根源级变更检测,与原始 data 逐字段比较后才写入 modifiedRows - 英文消息 "No changes to commit" 改为中文 "没有可提交的变更" - refs #301 --- frontend/src/components/DataGrid.tsx | 64 ++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 5357f4c..79644df 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1173,6 +1173,7 @@ const DataGrid: React.FC = ({ const [dataPanelValue, setDataPanelValue] = useState(''); const [dataPanelIsJson, setDataPanelIsJson] = useState(false); const dataPanelDirtyRef = useRef(false); + const dataPanelOriginalRef = useRef(''); const [rowEditorOpen, setRowEditorOpen] = useState(false); const [rowEditorRowKey, setRowEditorRowKey] = useState(''); const rowEditorBaseRawRef = useRef>({}); @@ -1495,14 +1496,18 @@ const DataGrid: React.FC = ({ const updateFocusedCell = useCallback((record: Item, dataIndex: string) => { if (!record || !dataIndex) return; const raw = record?.[dataIndex]; - const text = toEditableText(raw); + let text = toEditableText(raw); + // 日期时间字段格式化(处理带时区的 ISO 格式如 2026-03-22T00:00:00+08:00) + if (typeof raw === 'string') { + text = normalizeDateTimeString(raw); + } const isJson = looksLikeJsonText(text); setFocusedCellInfo({ record, dataIndex, title: dataIndex }); - // 仅在面板未被用户手动编辑时自动同步值 - if (!dataPanelDirtyRef.current) { - setDataPanelValue(text); - setDataPanelIsJson(isJson); - } + // 切换到新单元格时总是更新预览值并重置 dirty 标记 + dataPanelOriginalRef.current = text; + setDataPanelValue(text); + setDataPanelIsJson(isJson); + dataPanelDirtyRef.current = false; }, []); const handleDataPanelFormatJson = useCallback(() => { @@ -2899,28 +2904,49 @@ const DataGrid: React.FC = ({ }, []); const handleCellSave = useCallback((row: any) => { - // Optimistic update for display - // In parent-controlled data, we might need parent to update 'data', - // but here we manage 'modifiedRows' locally and overlay it. - // Since 'displayData' is derived from 'data' + 'modifiedRows', we need to update the source if it's in 'data'. - // But 'data' prop is immutable. - // So we update 'modifiedRows'. - - // Check if it's an added row const rowKey = row?.[GONAVI_ROW_KEY]; if (rowKey === undefined) return; const isAdded = addedRows.some(r => r?.[GONAVI_ROW_KEY] === rowKey); if (isAdded) { setAddedRows(prev => prev.map(r => r?.[GONAVI_ROW_KEY] === rowKey ? { ...r, ...row } : r)); } else { + // 查找原始行数据,对比是否真正有值变更 + const originalRow = data.find(r => r?.[GONAVI_ROW_KEY] === rowKey); + if (originalRow) { + const changedFields: Record = {}; + for (const col of Object.keys(row)) { + if (col === GONAVI_ROW_KEY) continue; + if (!isCellValueEqualForDiff(originalRow[col], row[col])) { + changedFields[col] = row[col]; + } + } + if (Object.keys(changedFields).length === 0) { + // 没有实际变更,从 modifiedRows 中移除该行(如有) + setModifiedRows(prev => { + const keyStr = rowKeyStr(rowKey); + if (!(keyStr in prev)) return prev; + const next = { ...prev }; + delete next[keyStr]; + return next; + }); + return; + } + } setModifiedRows(prev => ({ ...prev, [rowKeyStr(rowKey)]: row })); } - }, [addedRows]); + }, [addedRows, data]); const handleDataPanelSave = useCallback(() => { if (!focusedCellInfo) return; + // 与 updateFocusedCell 设置的原始值比较,避免幽灵变更 + if (dataPanelValue === dataPanelOriginalRef.current) { + dataPanelDirtyRef.current = false; + void message.info('数据未变更'); + return; + } const nextRow: any = { ...focusedCellInfo.record, [focusedCellInfo.dataIndex]: dataPanelValue }; handleCellSave(nextRow); + dataPanelOriginalRef.current = dataPanelValue; dataPanelDirtyRef.current = false; void message.success('已保存'); }, [focusedCellInfo, dataPanelValue, handleCellSave]); @@ -3488,7 +3514,7 @@ const DataGrid: React.FC = ({ }); if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) { - void message.info("No changes to commit"); + void message.info("没有可提交的变更"); return; } @@ -5341,8 +5367,10 @@ const DataGrid: React.FC = ({ theme={darkMode ? 'transparent-dark' : 'transparent-light'} value={dataPanelValue} onChange={(val) => { - setDataPanelValue(val || ''); - dataPanelDirtyRef.current = true; + const newVal = val || ''; + setDataPanelValue(newVal); + // 只有值真正与原始值不同时才标记 dirty + dataPanelDirtyRef.current = newVal !== dataPanelOriginalRef.current; }} options={{ minimap: { enabled: false }, From f992ad72e6d262377d72f3ce26238315ce0ed3b7 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 16:46:27 +0800 Subject: [PATCH 22/22] =?UTF-8?q?=E2=9C=A8=20feat(mongodb):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=97=A0=E8=AE=A4=E8=AF=81=E6=A8=A1=E5=BC=8F=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E4=BD=8E=E7=89=88=E6=9C=AC=20MongoDB=20=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 连接表单:验证方式新增"无认证 (None)"选项,MongoDB 用户名改为非必填 - URI 构建:当 MongoAuthMechanism 为 NONE 时跳过 user/password/authSource/authMechanism - 兼容优化:无用户名时不再默认设置 authSource=admin,避免驱动对无密码实例发起认证 - 双版本同步:mongodb_impl.go 与 mongodb_impl_v1.go 同步修改 - refs #303 --- frontend/src/components/ConnectionModal.tsx | 3 ++- internal/db/mongodb_impl.go | 18 ++++++++++++------ internal/db/mongodb_impl_v1.go | 18 ++++++++++++------ 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 037d76b..c1f17a8 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -2101,7 +2101,7 @@ const ConnectionModal: React.FC<{ @@ -2115,6 +2115,7 @@ const ConnectionModal: React.FC<{ allowClear placeholder="自动协商" options={[ + { value: 'NONE', label: '无认证 (None)' }, { value: 'SCRAM-SHA-1', label: 'SCRAM-SHA-1' }, { value: 'SCRAM-SHA-256', label: 'SCRAM-SHA-256' }, { value: 'MONGODB-AWS', label: 'MONGODB-AWS' }, diff --git a/internal/db/mongodb_impl.go b/internal/db/mongodb_impl.go index 2e5131c..ec73c46 100644 --- a/internal/db/mongodb_impl.go +++ b/internal/db/mongodb_impl.go @@ -215,7 +215,9 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string { hostText := strings.Join(seeds, ",") uri := fmt.Sprintf("%s://%s", scheme, hostText) - if config.User != "" { + noAuth := strings.EqualFold(strings.TrimSpace(config.MongoAuthMechanism), "NONE") + + if config.User != "" && !noAuth { var userinfo *url.Userinfo if config.Password != "" { userinfo = url.UserPassword(config.User, config.Password) @@ -236,11 +238,14 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string { params.Set("connectTimeoutMS", strconv.Itoa(timeout*1000)) params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000)) - authSource := strings.TrimSpace(config.AuthSource) - if authSource == "" { - authSource = "admin" + // 仅在有用户名且非 NONE 认证时设置 authSource + if config.User != "" && !noAuth { + authSource := strings.TrimSpace(config.AuthSource) + if authSource == "" { + authSource = "admin" + } + params.Set("authSource", authSource) } - params.Set("authSource", authSource) if replicaSet := strings.TrimSpace(config.ReplicaSet); replicaSet != "" { params.Set("replicaSet", replicaSet) @@ -248,7 +253,8 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string { if readPreference := strings.TrimSpace(config.ReadPreference); readPreference != "" { params.Set("readPreference", readPreference) } - if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" { + // NONE 表示无认证,不设置 authMechanism + if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" && !noAuth { params.Set("authMechanism", authMechanism) } diff --git a/internal/db/mongodb_impl_v1.go b/internal/db/mongodb_impl_v1.go index c746431..17bf146 100644 --- a/internal/db/mongodb_impl_v1.go +++ b/internal/db/mongodb_impl_v1.go @@ -216,7 +216,9 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string { hostText := strings.Join(seeds, ",") uri := fmt.Sprintf("%s://%s", scheme, hostText) - if config.User != "" { + noAuth := strings.EqualFold(strings.TrimSpace(config.MongoAuthMechanism), "NONE") + + if config.User != "" && !noAuth { var userinfo *url.Userinfo if config.Password != "" { userinfo = url.UserPassword(config.User, config.Password) @@ -237,11 +239,14 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string { params.Set("connectTimeoutMS", strconv.Itoa(timeout*1000)) params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000)) - authSource := strings.TrimSpace(config.AuthSource) - if authSource == "" { - authSource = "admin" + // 仅在有用户名且非 NONE 认证时设置 authSource + if config.User != "" && !noAuth { + authSource := strings.TrimSpace(config.AuthSource) + if authSource == "" { + authSource = "admin" + } + params.Set("authSource", authSource) } - params.Set("authSource", authSource) if replicaSet := strings.TrimSpace(config.ReplicaSet); replicaSet != "" { params.Set("replicaSet", replicaSet) @@ -249,7 +254,8 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string { if readPreference := strings.TrimSpace(config.ReadPreference); readPreference != "" { params.Set("readPreference", readPreference) } - if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" { + // NONE 表示无认证,不设置 authMechanism + if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" && !noAuth { params.Set("authMechanism", authMechanism) }