mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-03 23:51:24 +08:00
Release/0.6.5
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,6 +21,8 @@ GoNavi-Wails.exe
|
||||
.claude/
|
||||
.gemini/
|
||||
**/tmpclaude-*
|
||||
docs/superpowers/
|
||||
docs/需求追踪/
|
||||
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
|
||||
33
README.md
33
README.md
@@ -5,6 +5,8 @@
|
||||
[](https://reactjs.org/)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Syngnat/GoNavi/actions)
|
||||
[](https://github.com/Syngnat/GoNavi/stargazers)
|
||||
[](https://github.com/Syngnat/GoNavi/releases)
|
||||
|
||||
**Language**: English | [简体中文](README.zh-CN.md)
|
||||
|
||||
@@ -53,19 +55,24 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
<h2 align="center">📸 Screenshots</h2>
|
||||
|
||||
<div align="center">
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
|
||||
<br />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 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 +214,20 @@ For the full workflow, branch model, and maintainer sync rules, see:
|
||||
|
||||
External contributors should open pull requests directly against `main`.
|
||||
|
||||
## Star History
|
||||
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Links
|
||||
|
||||
- [linux.do](https://linux.do/)
|
||||
- [AIBook](https://aibook.ren/)
|
||||
|
||||
## License
|
||||
|
||||
Licensed under [Apache-2.0](LICENSE).
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
[](https://reactjs.org/)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Syngnat/GoNavi/actions)
|
||||
[](https://github.com/Syngnat/GoNavi/stargazers)
|
||||
[](https://github.com/Syngnat/GoNavi/releases)
|
||||
|
||||
**语言**: [English](README.md) | 简体中文
|
||||
|
||||
@@ -52,19 +54,24 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
<h2 align="center">📸 项目截图</h2>
|
||||
|
||||
<div align="center">
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
|
||||
<br />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
### AI 智能助手 (New)
|
||||
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude,同时支持任意自定义兼容 OpenAI 格式的 API。
|
||||
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI,让 SQL 生成、分析变得更精准。
|
||||
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等)。
|
||||
|
||||
### 性能与交互
|
||||
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
|
||||
- 虚拟滚动渲染,降低大结果集卡顿风险。
|
||||
@@ -190,6 +197,21 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
|
||||
|
||||
外部贡献者统一直接向 `main` 发起 Pull Request。
|
||||
|
||||
## Star History (Star 增长趋势)
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 友情链接
|
||||
|
||||
- [linux.do](https://linux.do/)
|
||||
- [AI全书](https://aibook.ren/)
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [Apache-2.0 协议](LICENSE)。
|
||||
|
||||
9
assets_dev.go
Normal file
9
assets_dev.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build dev
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
// 开发模式下由 Wails DevServer 提供前端资源,这里只提供一个稳定的占位 FS,
|
||||
// 避免编译时依赖 frontend/dist 被并发重建。
|
||||
var assets = os.DirFS(".")
|
||||
13
assets_prod.go
Normal file
13
assets_prod.go
Normal file
@@ -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
|
||||
189
frontend/package-lock.json
generated
189
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
dcb87159cf0f1f6f750d1c4870911d3f
|
||||
f697e821b4acd5cf614d63d46453e8a4
|
||||
@@ -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<ShortcutAction | null>(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: <ToolOutlined />,
|
||||
onClick: () => setIsToolsModalOpen(true),
|
||||
},
|
||||
proxy: {
|
||||
key: 'proxy',
|
||||
title: '代理',
|
||||
icon: <GlobalOutlined />,
|
||||
onClick: () => setIsProxyModalOpen(true),
|
||||
},
|
||||
theme: {
|
||||
key: 'theme',
|
||||
title: '主题',
|
||||
icon: <SkinOutlined />,
|
||||
onClick: () => setIsThemeModalOpen(true),
|
||||
},
|
||||
about: {
|
||||
key: 'about',
|
||||
title: '关于',
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => setIsAboutOpen(true),
|
||||
},
|
||||
} as const;
|
||||
|
||||
return SIDEBAR_UTILITY_ITEM_KEYS.map((key) => itemMap[key]);
|
||||
}, []);
|
||||
const renderAIEdgeHandle = () => (
|
||||
<Tooltip title="AI 助手">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={toggleAIPanel}
|
||||
style={aiEdgeHandleStyle}
|
||||
>
|
||||
AI
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
|
||||
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
|
||||
@@ -1634,24 +1696,12 @@ function App() {
|
||||
>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<Tooltip title="工具"><Button type="text" icon={<ToolOutlined />} style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)} /></Tooltip>
|
||||
<Tooltip title="代理"><Button type="text" icon={<GlobalOutlined />} style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)} /></Tooltip>
|
||||
<Tooltip title="主题"><Button type="text" icon={<SkinOutlined />} style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)} /></Tooltip>
|
||||
<Tooltip title="关于"><Button type="text" icon={<InfoCircleOutlined />} style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)} /></Tooltip>
|
||||
<div style={{ width: 1, height: 16, background: 'rgba(128,128,128,0.2)', margin: '0 4px' }} />
|
||||
<Tooltip title="AI 助手">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={toggleAIPanel}
|
||||
style={{
|
||||
...utilityButtonStyle,
|
||||
color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : utilityButtonStyle.color,
|
||||
background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.12)') : 'transparent'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
|
||||
{sidebarUtilityItems.map((item) => (
|
||||
<Tooltip key={item.key} title={item.title}>
|
||||
<Button type="text" icon={item.icon} style={utilityButtonStyle} onClick={item.onClick} />
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
@@ -1760,12 +1810,24 @@ function App() {
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row', position: 'relative' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
|
||||
<TabManager />
|
||||
</div>
|
||||
{aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'content-shell' && (
|
||||
<div style={aiEdgeHandleDockStyle}>
|
||||
{renderAIEdgeHandle()}
|
||||
</div>
|
||||
)}
|
||||
{aiPanelVisible && (
|
||||
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} />
|
||||
<div style={{ position: 'relative', display: 'flex', flexShrink: 0, overflow: 'visible' }}>
|
||||
{aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'panel-shell' && (
|
||||
<div style={aiEdgeHandleDockStyle}>
|
||||
{renderAIEdgeHandle()}
|
||||
</div>
|
||||
)}
|
||||
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isLogPanelOpen && (
|
||||
|
||||
@@ -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<AIProviderConfig, 'type' | 'baseUrl'>): 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<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): ProviderPreset => {
|
||||
const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom');
|
||||
return findPreset(presetKey);
|
||||
};
|
||||
|
||||
const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [
|
||||
|
||||
@@ -2101,7 +2101,7 @@ const ConnectionModal: React.FC<{
|
||||
<Form.Item
|
||||
name="user"
|
||||
label="用户名"
|
||||
rules={[createUriAwareRequiredRule('请输入用户名')]}
|
||||
rules={dbType === 'mongodb' ? [] : [createUriAwareRequiredRule('请输入用户名')]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input />
|
||||
@@ -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' },
|
||||
|
||||
@@ -32,7 +32,9 @@ 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';
|
||||
import { buildCopyInsertSQL, normalizeTemporalLiteralText } from './dataGridCopyInsert';
|
||||
|
||||
// --- Error Boundary ---
|
||||
interface DataGridErrorBoundaryState {
|
||||
@@ -569,32 +571,52 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
}) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<any>(null);
|
||||
const cellRef = useRef<HTMLElement>(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 () => {
|
||||
try {
|
||||
if (!form) return;
|
||||
if (!form || !editing) return;
|
||||
const fieldName = getCellFieldName(record, dataIndex);
|
||||
await form.validateFields([fieldName]);
|
||||
let nextValue = form.getFieldValue(fieldName);
|
||||
@@ -615,6 +637,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
}
|
||||
} catch (errInfo) {
|
||||
console.log('Save failed:', errInfo);
|
||||
// 日期时间类型保存失败时兜底退出编辑,避免 DatePicker 卡在编辑态
|
||||
if (isDateTimeField && editing) setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -640,6 +664,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
style={{ width: '100%' }}
|
||||
format={TEMPORAL_FORMATS[pickerType]}
|
||||
onChange={() => setTimeout(save, 0)}
|
||||
onOpenChange={lockTableScroll}
|
||||
onBlur={() => setTimeout(save, 0)}
|
||||
needConfirm={false}
|
||||
/>
|
||||
) : pickerType === 'datetime' ? (
|
||||
@@ -647,12 +673,31 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
ref={inputRef}
|
||||
style={{ width: '100%' }}
|
||||
showTime
|
||||
showNow={false}
|
||||
format={TEMPORAL_FORMATS[pickerType]}
|
||||
renderExtraFooter={() => (
|
||||
<a
|
||||
style={{ padding: '0 2px' }}
|
||||
onClick={() => {
|
||||
// 自定义"此刻":仅将当前时间填入表单字段,面板保持打开。
|
||||
// 用户需点击"确定"才真正保存,替代内置 showNow 的自动提交行为。
|
||||
const fieldName = getCellFieldName(record, dataIndex);
|
||||
setCellFieldValue(form, fieldName, dayjs());
|
||||
}}
|
||||
>此刻</a>
|
||||
)}
|
||||
onOk={() => setTimeout(save, 0)}
|
||||
onOpenChange={(open) => {
|
||||
// 面板关闭(点击外部)且非通过"确定"按钮触发时退出编辑,不保存
|
||||
pickerOpenRef.current = open;
|
||||
lockTableScroll(open);
|
||||
// 面板关闭(点击外部)时退出编辑,不保存;仅"确定"按钮(onOk)触发保存
|
||||
if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0);
|
||||
}}
|
||||
onBlur={() => {
|
||||
// 兜底:面板未打开或已关闭时,点击外部通过 blur 退出编辑。
|
||||
// 延迟检查面板状态,避免点击自定义"此刻"按钮时误退出(此时面板仍打开)。
|
||||
setTimeout(() => { if (editing && !pickerOpenRef.current) setEditing(false); }, 150);
|
||||
}}
|
||||
needConfirm
|
||||
/>
|
||||
) : (
|
||||
@@ -662,6 +707,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
format={TEMPORAL_FORMATS[pickerType]}
|
||||
picker={pickerType as any}
|
||||
onChange={() => setTimeout(save, 0)}
|
||||
onOpenChange={lockTableScroll}
|
||||
onBlur={() => setTimeout(save, 0)}
|
||||
needConfirm={false}
|
||||
/>
|
||||
)
|
||||
@@ -720,6 +767,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={cellRef}
|
||||
{...restProps}
|
||||
data-row-key={record ? String(record?.[GONAVI_ROW_KEY]) : undefined}
|
||||
data-col-name={dataIndex || undefined}
|
||||
@@ -818,6 +866,7 @@ interface DataGridProps {
|
||||
total: number,
|
||||
totalKnown?: boolean,
|
||||
totalApprox?: boolean,
|
||||
approximateTotal?: number,
|
||||
totalCountLoading?: boolean,
|
||||
totalCountCancelled?: boolean,
|
||||
};
|
||||
@@ -995,6 +1044,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const selectionColumnWidth = 46;
|
||||
const currentConnConfig = connections.find(c => c.id === connectionId)?.config;
|
||||
const dataSourceCaps = getDataSourceCapabilities(currentConnConfig);
|
||||
const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount;
|
||||
const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount;
|
||||
const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages;
|
||||
const dbType = dataSourceCaps.type;
|
||||
const isDuckDBConnection = dataSourceCaps.type === 'duckdb';
|
||||
const supportsCopyInsert = dataSourceCaps.supportsCopyInsert;
|
||||
const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport;
|
||||
@@ -1120,6 +1173,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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<string>('');
|
||||
const rowEditorBaseRawRef = useRef<Record<string, any>>({});
|
||||
@@ -1336,6 +1390,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return next;
|
||||
}, [columnMetaMap]);
|
||||
|
||||
const columnTypeMapByLowerName = useMemo(() => {
|
||||
const next: Record<string, string> = {};
|
||||
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 +1421,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
// INSERT 空时间值直接忽略字段,让数据库默认值生效;UPDATE 空时间值转 NULL。
|
||||
return mode === 'insert' ? undefined : null;
|
||||
}
|
||||
return normalizeDateTimeString(value);
|
||||
return normalizeTemporalLiteralText(value, meta?.type, true);
|
||||
}
|
||||
|
||||
return value;
|
||||
@@ -1432,14 +1496,18 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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(() => {
|
||||
@@ -2836,28 +2904,49 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}, []);
|
||||
|
||||
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<string, any> = {};
|
||||
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]);
|
||||
@@ -3425,7 +3514,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
});
|
||||
|
||||
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
||||
void message.info("No changes to commit");
|
||||
void message.info("没有可提交的变更");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3501,16 +3590,15 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
// 使用 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 escaped = String(v).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);
|
||||
@@ -4482,37 +4570,20 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
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;
|
||||
@@ -4679,7 +4750,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
{isDuckDBConnection && onRequestTotalCount && (
|
||||
{prefersManualTotalCount && onRequestTotalCount && (
|
||||
<>
|
||||
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
||||
<Tooltip title={pagination?.totalCountLoading ? '取消本次精确总数统计(不会影响当前浏览)' : '按当前筛选统计精确总数'}>
|
||||
@@ -4779,7 +4850,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
padding: `${filterTopPadding}px ${panelPaddingX}px ${panelPaddingY}px ${panelPaddingX}px`,
|
||||
background: 'transparent',
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
{/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */}
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', overflowX: 'hidden', flex: '0 1 auto' }}>
|
||||
{filterConditions.map((cond, condIndex) => (
|
||||
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start', opacity: cond.enabled === false ? 0.58 : 1 }}>
|
||||
<Checkbox
|
||||
@@ -4922,14 +4997,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}} />
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => {
|
||||
const next = [...sortInfo, { columnKey: displayColumnNames.find(c => !sortInfo.some(s => s.columnKey === c)) || displayColumnNames[0] || '', order: 'ascend', enabled: true }];
|
||||
onSort(JSON.stringify(next), '');
|
||||
}} disabled={sortInfo.length >= displayColumnNames.length} style={{ marginBottom: 4 }}>添加排序</Button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginTop: (onSort && sortInfo.length > 0) ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) ? `1px dashed ${panelFrameColor}` : 'none' }}>
|
||||
<Button type="dashed" onClick={addFilter} size="small" icon={<PlusOutlined />}>添加条件</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', flex: '0 0 auto', marginTop: (onSort && sortInfo.length > 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' }}>
|
||||
<Button type="primary" ghost onClick={addFilter} size="small" icon={<PlusOutlined />}>添加条件</Button>
|
||||
{onSort && (
|
||||
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => {
|
||||
const next = [...sortInfo, { columnKey: displayColumnNames.find(c => !sortInfo.some(s => s.columnKey === c)) || displayColumnNames[0] || '', order: 'ascend', enabled: true }];
|
||||
onSort(JSON.stringify(next), '');
|
||||
}} disabled={sortInfo.length >= displayColumnNames.length}>添加排序</Button>
|
||||
)}
|
||||
<div style={{ width: 1, height: 16, background: panelFrameColor, margin: '0 2px', flexShrink: 0 }} />
|
||||
<Button size="small" onClick={() => setFilterConditions(prev => prev.map(c => ({ ...c, enabled: true })))}>全启用</Button>
|
||||
<Button size="small" onClick={() => setFilterConditions(prev => prev.map(c => ({ ...c, enabled: false })))}>全停用</Button>
|
||||
@@ -5289,8 +5367,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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 },
|
||||
@@ -5538,7 +5618,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
total={resolvePaginationTotalForControl({
|
||||
pagination,
|
||||
supportsApproximateTotalPages,
|
||||
})}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
showTitle={false}
|
||||
|
||||
@@ -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<string, string>
|
||||
: {};
|
||||
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()])};`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
|
||||
import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveApproximateTableCountStrategy } from '../utils/approximateTableCount';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
|
||||
|
||||
type ViewerPaginationState = {
|
||||
current: number;
|
||||
@@ -14,6 +16,7 @@ type ViewerPaginationState = {
|
||||
total: number;
|
||||
totalKnown: boolean;
|
||||
totalApprox: boolean;
|
||||
approximateTotal?: number;
|
||||
totalCountLoading: boolean;
|
||||
totalCountCancelled: boolean;
|
||||
};
|
||||
@@ -70,30 +73,6 @@ const parseTotalFromCountRow = (row: any): number | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseDuckDBApproxTotalRow = (row: any): number | null => {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const entries = Object.entries(row as Record<string, unknown>);
|
||||
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<any[]>([]);
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
@@ -214,6 +193,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const countKeyRef = useRef<string>('');
|
||||
const duckdbApproxSeqRef = useRef(0);
|
||||
const duckdbApproxKeyRef = useRef<string>('');
|
||||
const oracleApproxSeqRef = useRef(0);
|
||||
const oracleApproxKeyRef = useRef<string>('');
|
||||
const manualCountSeqRef = useRef(0);
|
||||
const manualCountKeyRef = useRef<string>('');
|
||||
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<ViewerPaginationState>({
|
||||
current: initialViewerSnapshot.currentPage,
|
||||
@@ -246,8 +228,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
||||
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<ViewerFilterSnapshot>) => {
|
||||
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}
|
||||
|
||||
@@ -183,7 +183,7 @@ let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type
|
||||
let sharedVisibleDbs: string[] = [];
|
||||
let sharedColumnsCacheData: Record<string, any[]> = {};
|
||||
|
||||
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 = {
|
||||
@@ -716,11 +716,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
// 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 }> = ({ tab }) => {
|
||||
.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 }> = ({ tab }) => {
|
||||
};
|
||||
});
|
||||
|
||||
// 表提示:当前库显示表名,其他库显示 db.table 格式
|
||||
// 表提示:当前库智能处理 schema.table 格式
|
||||
// 1. 构建纯表名到 schema 列表的映射,检测同名表
|
||||
const currentDbTables = sharedTablesData.filter(t =>
|
||||
(t.dbName || '').toLowerCase() === currentDatabase.toLowerCase()
|
||||
);
|
||||
const tableNameToSchemas = new Map<string, string[]>();
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1586,13 +1631,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 +1888,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) {
|
||||
|
||||
@@ -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<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
|
||||
@@ -23,6 +84,13 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
const [command, setCommand] = useState('');
|
||||
const [results, setResults] = useState<CommandResult[]>([]);
|
||||
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<HTMLDivElement>(null);
|
||||
const resultsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
const getConfig = useCallback(() => {
|
||||
@@ -37,77 +105,173 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ 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 <span style={{ color: '#569cd6' }}>(nil)</span>;
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
return `"${result}"`;
|
||||
// 尝试美化 JSON 字符串
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return (
|
||||
<div style={{ marginTop: 4, padding: 8, background: 'rgba(0,0,0,0.2)', borderRadius: 4 }}>
|
||||
{JSON.stringify(parsed, null, 2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// not a valid json, just return string
|
||||
}
|
||||
return <span style={{ color: '#ce9178' }}>"{result}"</span>;
|
||||
}
|
||||
if (typeof result === 'number') {
|
||||
return `(integer) ${result}`;
|
||||
return <span style={{ color: '#b5cea8' }}>(integer) {result}</span>;
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
if (result.length === 0) {
|
||||
return '(empty array)';
|
||||
}
|
||||
return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n');
|
||||
return (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
{result.map((item, index) => (
|
||||
<div key={index} style={{ display: 'flex' }}>
|
||||
<span style={{ color: '#608b4e', marginRight: 8, userSelect: 'none' }}>{index + 1})</span>
|
||||
<div>{formatResult(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof result === 'object') {
|
||||
return JSON.stringify(result, null, 2);
|
||||
@@ -115,18 +279,56 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ 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 <div style={{ padding: 20 }}>连接不存在</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Command Input */}
|
||||
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', background: '#fff' }}>
|
||||
{/* Editor Top Pane */}
|
||||
<div style={{ height: editorHeight, minHeight: 100, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fdfdfd' }}>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>Redis 命令</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
|
||||
<span style={{ fontWeight: 600 }}>Redis Console</span>
|
||||
<span style={{ color: '#888', fontSize: 13, background: '#f0f0f0', padding: '2px 8px', borderRadius: 12 }}>db{redisDB}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
@@ -135,68 +337,89 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
onClick={handleExecute}
|
||||
loading={loading}
|
||||
>
|
||||
执行 (Ctrl+Enter)
|
||||
执行 (Cmd+Enter)
|
||||
</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={handleClear}>清空结果</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Editor
|
||||
height="150px"
|
||||
defaultLanguage="plaintext"
|
||||
value={command}
|
||||
onChange={(value) => setCommand(value || '')}
|
||||
onMount={handleEditorMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'on',
|
||||
fontSize: 14,
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<Editor
|
||||
defaultLanguage="redis"
|
||||
language="redis"
|
||||
value={command}
|
||||
onChange={(value) => 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 }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
|
||||
{results.length === 0 ? (
|
||||
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
|
||||
输入 Redis 命令并按 Ctrl+Enter 执行
|
||||
<br />
|
||||
<span style={{ fontSize: 12 }}>支持多行命令,每行一个命令</span>
|
||||
</div>
|
||||
) : (
|
||||
results.map((item, index) => (
|
||||
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
|
||||
<div style={{ color: '#569cd6', marginBottom: 4 }}>
|
||||
> {item.command}
|
||||
{/* Resizer Handle */}
|
||||
<div
|
||||
className="horizontal-resizer"
|
||||
onMouseDown={handleDragStart}
|
||||
style={{
|
||||
height: 8,
|
||||
cursor: 'row-resize',
|
||||
background: '#f0f0f0',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 40, height: 4, background: '#ccc', borderRadius: 2 }} />
|
||||
</div>
|
||||
|
||||
{/* Results Terminal Bottom Pane */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '4px 12px', background: '#252526', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #333' }}>
|
||||
<span style={{ color: '#ccc', fontSize: 12 }}>Execution Output</span>
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={handleClear} style={{ color: '#aaa' }}>清空控制台</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: '"Consolas", "Courier New", monospace', fontSize: 13, padding: 12 }}>
|
||||
{results.length === 0 ? (
|
||||
<div style={{ color: '#666', textAlign: 'center', marginTop: 40 }}>
|
||||
<div>在此终端执行命令,结果会以原样输出</div>
|
||||
<div style={{ fontSize: 12, marginTop: 12 }}>
|
||||
Tips: <code>选中任意行</code> 按 <code style={{ color: '#999' }}>Ctrl + Enter</code> 仅执行选中段落
|
||||
</div>
|
||||
{item.error ? (
|
||||
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
|
||||
(error) {item.error}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#ce9178', whiteSpace: 'pre-wrap' }}>
|
||||
{formatResult(item.result)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Common Commands Help */}
|
||||
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
|
||||
常用命令:
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<code>KEYS *</code> |
|
||||
<code style={{ marginLeft: 8 }}>GET key</code> |
|
||||
<code style={{ marginLeft: 8 }}>SET key value</code> |
|
||||
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
|
||||
<code style={{ marginLeft: 8 }}>INFO</code> |
|
||||
<code style={{ marginLeft: 8 }}>DBSIZE</code>
|
||||
</span>
|
||||
) : (
|
||||
results.map((item, index) => (
|
||||
<div key={item.timestamp + index} style={{ marginBottom: 16 }}>
|
||||
<div style={{ color: '#569cd6', marginBottom: 6, fontWeight: 'bold' }}>
|
||||
<span style={{ color: '#4CAF50', marginRight: 8 }}>➜</span>
|
||||
{item.command}
|
||||
<span style={{ color: '#666', fontSize: 11, marginLeft: 12, fontWeight: 'normal' }}>[{item.durationMs}ms]</span>
|
||||
</div>
|
||||
|
||||
<div style={{ paddingLeft: 20 }}>
|
||||
{item.error ? (
|
||||
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
|
||||
(error) {item.error}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{formatResult(item.result)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={resultsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
378
frontend/src/components/RedisMonitor.tsx
Normal file
378
frontend/src/components/RedisMonitor.tsx
Normal file
@@ -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<RedisMonitorProps> = ({ 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<string | null>(null);
|
||||
|
||||
const [history, setHistory] = useState<MetricPoint[]>([]);
|
||||
const [currentInfo, setCurrentInfo] = useState<Record<string, string>>({});
|
||||
|
||||
// Ref to track if component is mounted to prevent state updates after unmount
|
||||
const mountedRef = useRef(true);
|
||||
// Interval ref
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(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<string, string>;
|
||||
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 <div style={{ padding: 20 }}>Connection not found.</div>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ height: '100%', overflow: 'auto', padding: '16px 24px', backgroundColor: darkMode ? '#141414' : '#f0f2f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0, fontWeight: 600 }}>
|
||||
<DashboardOutlined style={{ marginRight: 8, color: '#1677ff' }} />
|
||||
Redis 实例监控
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
{connection.name}
|
||||
{currentInfo.redis_version && ` • Redis ${currentInfo.redis_version}`}
|
||||
{currentInfo.os && ` • ${currentInfo.os}`}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{error && <Tag color="error" style={{ height: 32, lineHeight: '30px', fontSize: 13 }}>{error}</Tag>}
|
||||
{loading && !error && <Spin style={{ alignSelf: 'center', marginRight: 16 }} />}
|
||||
|
||||
<Button
|
||||
type={isRunning ? "default" : "primary"}
|
||||
icon={isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => setIsRunning(!isRunning)}
|
||||
>
|
||||
{isRunning ? '暂停刷新' : '恢复刷新'}
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchMetrics}>
|
||||
立即刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}><DesktopOutlined /> 已用内存 (Used)</span>}
|
||||
value={getFormatMemoryString(currentInfo.used_memory || '0')}
|
||||
valueStyle={{ color: '#eb2f96', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Peak: {getFormatMemoryString(currentInfo.used_memory_peak || '0')}</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}><ApiOutlined /> 客户端数量 (Clients)</span>}
|
||||
value={currentInfo.connected_clients || '0'}
|
||||
valueStyle={{ color: '#1677ff', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Blocked: {currentInfo.blocked_clients || '0'}</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}><HddOutlined /> 吞吐量 (OPS)</span>}
|
||||
value={currentInfo.instantaneous_ops_per_sec || '0'}
|
||||
valueStyle={{ color: '#52c41a', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>cmds/s</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}>启动时长 (Uptime)</span>}
|
||||
value={getUptimeString(currentInfo.uptime_in_seconds || '0')}
|
||||
valueStyle={{ color: '#fa8c16', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Days: {currentInfo.uptime_in_days || '0'}</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="请求吞吐量 (QPS)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorQps" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#52c41a" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#52c41a" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="qps" name="QPS" stroke="#52c41a" strokeWidth={2} fillOpacity={1} fill="url(#colorQps)" isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="内存开销 (Memory)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} domain={['auto', 'auto']} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
formatter={(value: any) => [`${value} MB`]}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36}/>
|
||||
<Line type="monotone" dataKey="memory" name="Used Memory" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="memory_rss" name="RSS Memory" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="CPU 使用率 (CPU Usage)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
formatter={(value: any) => [`${value} s`]}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36}/>
|
||||
<Line type="monotone" dataKey="cpuSys" name="System" stroke="#cf1322" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="cpuUser" name="User" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="连接信息 (Clients & Keys)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis yAxisId="left" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36}/>
|
||||
<Line yAxisId="left" type="stepAfter" dataKey="clients" name="Clients" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="right" type="stepAfter" dataKey="keys" name="Total Keys" stroke="#fa8c16" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Card bordered={false} title="详细服务器参数" style={{ background: cardBgColor, borderRadius: 8 }}>
|
||||
<div style={{ columnCount: 3, columnGap: 40 }}>
|
||||
{['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] ? (
|
||||
<div key={key} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8, borderBottom: `1px dashed ${chartGridColor}` }}>
|
||||
<Text type="secondary">{key}</Text>
|
||||
<Text strong>{currentInfo[key]}</Text>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedisMonitor;
|
||||
@@ -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';
|
||||
@@ -174,6 +175,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const selectedNodesRef = useRef<any[]>([]);
|
||||
const loadingNodesRef = useRef<Set<string>>(new Set());
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
|
||||
|
||||
// Virtual Scroll State
|
||||
@@ -1035,13 +1037,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);
|
||||
@@ -1447,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[]) => {
|
||||
@@ -1455,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 });
|
||||
@@ -1463,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;
|
||||
// 记录表访问
|
||||
@@ -3081,7 +3099,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
key: 'refresh',
|
||||
label: '刷新',
|
||||
icon: <ReloadOutlined />,
|
||||
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' },
|
||||
{
|
||||
@@ -3098,6 +3126,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'open-monitor',
|
||||
label: 'Redis 实例监控',
|
||||
icon: <DashboardOutlined />,
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `redis-monitor-${node.key}-${Date.now()}`,
|
||||
title: `监控: ${node.title}`,
|
||||
type: 'redis-monitor',
|
||||
connectionId: node.key,
|
||||
redisDB: 0
|
||||
});
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'edit',
|
||||
@@ -3184,7 +3226,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
key: 'refresh',
|
||||
label: '刷新',
|
||||
icon: <ReloadOutlined />,
|
||||
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' },
|
||||
{
|
||||
@@ -3309,6 +3361,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
redisDB: redisDB
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'open-monitor',
|
||||
label: 'Redis 实例监控',
|
||||
icon: <DashboardOutlined />,
|
||||
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') {
|
||||
|
||||
@@ -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 = <QueryEditor tab={tab} />;
|
||||
content = <QueryEditor tab={tab} isActive={tabIsActive} />;
|
||||
} else if (tab.type === 'table') {
|
||||
content = <DataViewer tab={tab} />;
|
||||
content = <DataViewer tab={tab} isActive={tabIsActive} />;
|
||||
} else if (tab.type === 'design') {
|
||||
content = <TableDesigner tab={tab} />;
|
||||
} else if (tab.type === 'redis-keys') {
|
||||
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'redis-command') {
|
||||
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'redis-monitor') {
|
||||
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'trigger') {
|
||||
content = <TriggerViewer tab={tab} />;
|
||||
} 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 (
|
||||
<>
|
||||
|
||||
@@ -217,14 +217,6 @@ const COMMON_DEFAULTS = [
|
||||
{ value: "''" },
|
||||
];
|
||||
|
||||
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 = [
|
||||
{ label: '默认', value: 'DEFAULT' },
|
||||
@@ -1441,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 => {
|
||||
@@ -2928,20 +2943,34 @@ END;`;
|
||||
<Select
|
||||
value={indexForm.kind}
|
||||
options={getIndexKindOptions()}
|
||||
onChange={(val: IndexKind) =>
|
||||
setIndexForm(prev => ({
|
||||
...prev,
|
||||
kind: val,
|
||||
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
|
||||
indexType: val === 'NORMAL' || val === 'UNIQUE' ? (prev.indexType || 'DEFAULT') : 'DEFAULT',
|
||||
}))
|
||||
}
|
||||
onChange={(val: IndexKind) => {
|
||||
const fixedType = getFixedIndexType(val);
|
||||
if (fixedType) {
|
||||
// 固定类型(PRIMARY/FULLTEXT/SPATIAL)直接设置对应的索引方法
|
||||
setIndexForm(prev => ({
|
||||
...prev,
|
||||
kind: val,
|
||||
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
|
||||
indexType: fixedType,
|
||||
}));
|
||||
} else {
|
||||
const nextTypeOptions = getIndexTypeOptions(val);
|
||||
const currentType = indexForm.indexType || 'DEFAULT';
|
||||
const isCurrentTypeValid = nextTypeOptions.some(opt => opt.value === currentType);
|
||||
setIndexForm(prev => ({
|
||||
...prev,
|
||||
kind: val,
|
||||
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
|
||||
indexType: isCurrentTypeValid ? currentType : 'DEFAULT',
|
||||
}));
|
||||
}
|
||||
}}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={indexForm.indexType}
|
||||
onChange={(val) => setIndexForm(prev => ({ ...prev, indexType: val }))}
|
||||
options={getIndexTypeOptions()}
|
||||
options={getIndexTypeOptions(indexForm.kind)}
|
||||
style={{ width: 160 }}
|
||||
disabled={indexForm.kind === 'PRIMARY' || indexForm.kind === 'FULLTEXT' || indexForm.kind === 'SPATIAL'}
|
||||
/>
|
||||
|
||||
@@ -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<TableOverviewProps> = ({ tab }) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('card');
|
||||
|
||||
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
|
||||
|
||||
@@ -366,14 +368,43 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
|
||||
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||
</Dropdown>
|
||||
<div style={{ display: 'flex', gap: 2, padding: 2, borderRadius: 6, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
|
||||
<Tooltip title="卡片视图">
|
||||
<div
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<AppstoreOutlined style={{ fontSize: 14 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="列表视图">
|
||||
<div
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<UnorderedListOutlined style={{ fontSize: 14 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
{/* Content Area */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
|
||||
{sortedFiltered.length === 0 ? (
|
||||
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
|
||||
) : (
|
||||
) : viewMode === 'card' ? (
|
||||
/* ========== 卡片视图 ========== */
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
@@ -451,6 +482,115 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</Dropdown>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* ========== 列表/表格视图 ========== */
|
||||
<div style={{ borderRadius: 8, border: `1px solid ${cardBorder}`, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)' }}>
|
||||
{[
|
||||
{ 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) => (
|
||||
<th
|
||||
key={idx}
|
||||
onClick={col.field ? () => 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 && (
|
||||
<span style={{ marginLeft: 4, fontSize: 11 }}>
|
||||
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedFiltered.map((t, rowIdx) => (
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, 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: <EditOutlined />, onClick: () => openDesign(t.name) },
|
||||
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, 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') },
|
||||
]},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<tr
|
||||
onDoubleClick={() => 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'; }}
|
||||
>
|
||||
<td style={{ padding: '10px 14px', color: textPrimary, fontWeight: 500 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
|
||||
<Tooltip title={t.name} mouseEnterDelay={0.4}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 14px', color: textSecondary, maxWidth: 260, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.comment ? (
|
||||
<Tooltip title={t.comment} mouseEnterDelay={0.4}><span>{t.comment}</span></Tooltip>
|
||||
) : (
|
||||
<span style={{ color: textMuted }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textMuted }}>{t.engine || '—'}</td>
|
||||
</tr>
|
||||
</Dropdown>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
61
frontend/src/components/dataGridCopyInsert.test.ts
Normal file
61
frontend/src/components/dataGridCopyInsert.test.ts
Normal file
@@ -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');`,
|
||||
);
|
||||
});
|
||||
});
|
||||
131
frontend/src/components/dataGridCopyInsert.ts
Normal file
131
frontend/src/components/dataGridCopyInsert.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||
|
||||
type BuildCopyInsertSQLParams = {
|
||||
dbType: string;
|
||||
tableName?: string;
|
||||
orderedCols: string[];
|
||||
record: Record<string, any>;
|
||||
columnTypesByLowerName?: Record<string, string>;
|
||||
};
|
||||
|
||||
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(', ')});`;
|
||||
};
|
||||
@@ -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 仍为英文。
|
||||
|
||||
@@ -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;
|
||||
|
||||
71
frontend/src/utils/aiEntryLayout.test.ts
Normal file
71
frontend/src/utils/aiEntryLayout.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
59
frontend/src/utils/aiEntryLayout.ts
Normal file
59
frontend/src/utils/aiEntryLayout.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): 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<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>,
|
||||
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,
|
||||
|
||||
28
frontend/src/utils/approximateTableCount.test.ts
Normal file
28
frontend/src/utils/approximateTableCount.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
106
frontend/src/utils/approximateTableCount.ts
Normal file
106
frontend/src/utils/approximateTableCount.ts
Normal file
@@ -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<string, unknown>);
|
||||
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;
|
||||
};
|
||||
57
frontend/src/utils/dataGridPagination.test.ts
Normal file
57
frontend/src/utils/dataGridPagination.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
92
frontend/src/utils/dataGridPagination.ts
Normal file
92
frontend/src/utils/dataGridPagination.ts
Normal file
@@ -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;
|
||||
};
|
||||
32
frontend/src/utils/dataSourceCapabilities.test.ts
Normal file
32
frontend/src/utils/dataSourceCapabilities.test.ts
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
26
frontend/src/utils/dataViewerAutoFetch.test.ts
Normal file
26
frontend/src/utils/dataViewerAutoFetch.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
16
frontend/src/utils/dataViewerAutoFetch.ts
Normal file
16
frontend/src/utils/dataViewerAutoFetch.ts
Normal file
@@ -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';
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("连接未打开")
|
||||
|
||||
@@ -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("连接未打开")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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("连接未打开")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -233,6 +236,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("连接未打开")
|
||||
@@ -600,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("连接未打开")
|
||||
|
||||
@@ -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("连接未打开")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
4
main.go
4
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()
|
||||
|
||||
Reference in New Issue
Block a user