mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
Compare commits
85 Commits
feature/ai
...
codex/issu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2410aad849 | ||
|
|
33b21cc5ee | ||
|
|
1a0ba9a499 | ||
|
|
7a2563b83b | ||
|
|
632e57ea60 | ||
|
|
ca76440981 | ||
|
|
af5e84213f | ||
|
|
fcade0f860 | ||
|
|
1c2377bc62 | ||
|
|
426ef3bcf6 | ||
|
|
fb500ee33b | ||
|
|
89d79ff10c | ||
|
|
aa1bb5b886 | ||
|
|
5038ae5c9b | ||
|
|
83fe3d4ed9 | ||
|
|
808c773134 | ||
|
|
5d86ee7c76 | ||
|
|
8297829be6 | ||
|
|
f696f52470 | ||
|
|
60b63d7a22 | ||
|
|
1f617f9d53 | ||
|
|
c810d999bd | ||
|
|
0009c98c7e | ||
|
|
803c33b306 | ||
|
|
1d882d089f | ||
|
|
19da7fc66c | ||
|
|
c1877ea013 | ||
|
|
60dbb8a559 | ||
|
|
67fe3e3017 | ||
|
|
35944d58f8 | ||
|
|
5c2509c37f | ||
|
|
8e1b01b550 | ||
|
|
29fa5eb6df | ||
|
|
7c6391af3d | ||
|
|
5746796bc2 | ||
|
|
3ec7c9be9d | ||
|
|
ac6ef06413 | ||
|
|
ac0b6c05e8 | ||
|
|
37b3c78049 | ||
|
|
255cc14bf6 | ||
|
|
4718755208 | ||
|
|
91b5b85904 | ||
|
|
c842201bf4 | ||
|
|
263db6bf30 | ||
|
|
b5e8f5c022 | ||
|
|
b62d22395b | ||
|
|
f74270d585 | ||
|
|
ef64a24e01 | ||
|
|
c1266c225a | ||
|
|
acee1a06e8 | ||
|
|
eddb9f38c9 | ||
|
|
fbda6917f7 | ||
|
|
b022cd63e5 | ||
|
|
9eb42565f1 | ||
|
|
6d533167da | ||
|
|
f992ad72e6 | ||
|
|
5c0f6f8ff4 | ||
|
|
1eb517f083 | ||
|
|
02fa0aef46 | ||
|
|
f7107a1625 | ||
|
|
08ab06c038 | ||
|
|
3402b56fdb | ||
|
|
2c2baca69f | ||
|
|
e464c2cce1 | ||
|
|
15f72c013d | ||
|
|
c2c8870841 | ||
|
|
4f7ac7149a | ||
|
|
8d8af530a7 | ||
|
|
29b96719d5 | ||
|
|
9c96246320 | ||
|
|
31644dee6b | ||
|
|
aa9d8d243a | ||
|
|
6e55d63877 | ||
|
|
c126c4b731 | ||
|
|
c85de27aac | ||
|
|
eeef0f06ed | ||
|
|
fcd4d4026c | ||
|
|
a7bee7f3b6 | ||
|
|
ed4a7b96d4 | ||
|
|
09d013f27d | ||
|
|
09aa526570 | ||
|
|
5844cd7c01 | ||
|
|
4f74c44147 | ||
|
|
a5fdfefa2d | ||
|
|
37ac13b94e |
67
.github/workflows/dev-build.yml
vendored
67
.github/workflows/dev-build.yml
vendored
@@ -5,6 +5,10 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -575,14 +579,63 @@ jobs:
|
||||
DEV_VERSION="dev-${SHORT_SHA}"
|
||||
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Format Build Time
|
||||
id: build_time
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY' >> "$GITHUB_OUTPUT"
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
raw = "${{ github.event.head_commit.timestamp }}"
|
||||
dt = datetime.fromisoformat(raw)
|
||||
china_tz = timezone(timedelta(hours=8))
|
||||
formatted = dt.astimezone(china_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"display={formatted}")
|
||||
PY
|
||||
|
||||
# 删除旧的 dev pre-release(保持只有最新一个)
|
||||
- name: Delete Previous Dev Release
|
||||
uses: dev-drprasad/delete-tag-and-release@v1.1
|
||||
continue-on-error: true
|
||||
- name: Reset Previous Dev Release
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
tag_name: dev-latest
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const tag = 'dev-latest';
|
||||
const ref = `tags/${tag}`;
|
||||
const { owner, repo } = context.repo;
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const matchedReleases = releases.filter((release) => release.tag_name === tag);
|
||||
if (matchedReleases.length === 0) {
|
||||
core.info(`No existing releases found for tag ${tag}`);
|
||||
} else {
|
||||
for (const release of matchedReleases) {
|
||||
core.info(`Deleting release ${release.id} (${release.name || 'unnamed'}) for tag ${tag}`);
|
||||
await github.rest.repos.deleteRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
});
|
||||
core.info(`Deleted ref ${ref}`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info(`No existing ref found for ${ref}`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
- name: Create Dev Pre-release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -599,7 +652,7 @@ jobs:
|
||||
**版本**: `${{ steps.version.outputs.version }}`
|
||||
**分支**: `dev`
|
||||
**提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
|
||||
**构建时间**: ${{ github.event.head_commit.timestamp }}
|
||||
**构建时间**: ${{ steps.build_time.outputs.display }}
|
||||
|
||||
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
|
||||
> 每次 push 到 `dev` 分支会自动覆盖此 release。
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,6 +21,10 @@ GoNavi-Wails.exe
|
||||
.claude/
|
||||
.gemini/
|
||||
**/tmpclaude-*
|
||||
docs/superpowers/
|
||||
docs/需求追踪/
|
||||
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
.worktrees
|
||||
docs
|
||||
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
|
||||
111
docs/issues/2026-04-11-issue-backlog-tracking.md
Normal file
111
docs/issues/2026-04-11-issue-backlog-tracking.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 2026-04-11 Issue Backlog Tracking
|
||||
|
||||
## Scope
|
||||
|
||||
- 分支:`codex/issue-242-data-root`
|
||||
- 策略:按 GitHub issue 创建时间从早到晚逐条处理
|
||||
- 提交要求:每条 issue 单独本地提交,提交信息使用 `Fixes #<issue>`
|
||||
|
||||
## Progress
|
||||
|
||||
| Issue | Title | Status | Commit |
|
||||
| --- | --- | --- | --- |
|
||||
| #242 | 希望有自定义数据存储位置功能 | Fixed | `1f617f9` |
|
||||
| #287 | 建议补充 Sql Server 数据库图标 | Fixed | `60b63d7` |
|
||||
| #305 | 金仓数据库设计表新增字段保存失败 | Fixed | `f696f52` |
|
||||
| #306 | 驱动下载 | Fixed | `8297829` |
|
||||
| #308 | clickhouse 获取数据库列表失败 | Fixed | `5d86ee7` |
|
||||
| #310 | 选择库后,右侧行显示各个表 | Fixed | `808c773` |
|
||||
| #311 | WIN 系统的执行 500 多条 insert 语句要几分钟 | Fixed | `83fe3d4` |
|
||||
| #315 | 窗体内缩放异常 | Fixed | `5038ae5` |
|
||||
| #316 | 人大金仓数据库驱动版本过低 | Fixed | `aa1bb5b` |
|
||||
| #317 | 驱动管理增加导入 jar 功能 | Blocked | - |
|
||||
| #318 | mysql,bit 列,修改成 1 失败 | Fixed | `89d79ff` |
|
||||
| #319 | 关于运行外部 sql 文件的一些建议 | Deferred | - |
|
||||
| #320 | 无法连接达梦数据库 | Fixed | `1c2377b` |
|
||||
| #322 | 【拖选复制】希望添加 查询结果表格可以拖选复制,效果就如操作excel表格的选择复制一样 | Fixed | Pending |
|
||||
| #325 | 有没有考虑对数据库的驱动版本进行选择或者自定义? | Fixed | `af5e842` |
|
||||
| #327 | SHOW DATABASES 报错 | Fixed | `fb500ee` |
|
||||
| #328 | [Bug] 安装更新失败 | Fixed | `426ef3b` |
|
||||
| #329 | 如果调整了左侧导航栏的宽度后,建议左侧导航栏内增加横向滚动查看 | Fixed | `fcade0f` |
|
||||
| #330 | 建议在查询结果表格中增加自适应内容列宽的功能 | Fixed | `632e57e` |
|
||||
| #331 | 重复连接 DB,一分钟重试了 60 多次 | Fixed | `ca76440` |
|
||||
| #351 | 为什么没有截断和清空表的功能呀? | Fixed | Pending |
|
||||
|
||||
## Notes
|
||||
|
||||
### #317
|
||||
|
||||
- 当前驱动管理只支持内置 Go 驱动和可选 Go 驱动代理包。
|
||||
- 仓库内不存在 JDBC/JAR 装载、Java 运行时探测、classpath 管理或桥接执行链路。
|
||||
- 在现有架构下直接增加 “导入 jar” 入口会形成假功能,因此暂记为架构阻塞,不做伪实现。
|
||||
|
||||
### #318
|
||||
|
||||
- 根因:MySQL 写入归一化只覆盖时间列,`bit` 列提交时会把前端传来的 `"1"`/`"0"` 原样透传给驱动。
|
||||
- 处理:为 MySQL `bit` 列补充写入值归一化,将常见文本/布尔/数值输入转换为驱动可接受的 `[]byte`。
|
||||
- 验证:补充 `internal/db/mysql_value_test.go` 回归测试,覆盖 `bit(1)` 的 insert/update 写入路径。
|
||||
|
||||
### #319
|
||||
|
||||
- 现有应用已支持“运行外部 SQL 文件”,但 issue 诉求包含目录树、目录加载、双击文件打开等整组工作区能力。
|
||||
- 该项已超出单点缺陷修复范围,暂按功能增强项顺延,避免在逐条修 bug 流程中引入大范围 UI/状态管理重构。
|
||||
|
||||
### #320
|
||||
|
||||
- 达梦当前走可选 Go 驱动代理安装链路,不支持 JAR 导入属于既有架构边界。
|
||||
- 根因:驱动 release 资产缓存把 `GoNavi-DriverAgents.zip` 里的 bundle 条目也混进了“顶层已发布 asset”集合,导致安装链路误以为存在单独的 `dameng-driver-agent-*.exe` 下载地址。
|
||||
- 处理:缓存层区分真实 release 顶层 asset 与 bundle index 条目,安装 URL 解析仅在真实顶层 asset 存在时才走直链;bundle-only 驱动改为直接进入总包提取回退,不再先卡在 20% 试无效 URL。
|
||||
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖 bundle-only 达梦驱动跳过伪直链,并回归 Mongo 历史版本与本地导入链路。
|
||||
|
||||
### #327
|
||||
|
||||
- 根因:低权限 MySQL 账号执行 `SHOW DATABASES` 会直接报错,当前实现没有回退路径。
|
||||
- 处理:为数据库列表查询增加 `SELECT DATABASE()` 回退,仅保留当前连接库时也能正常展示。
|
||||
- 验证:补充 `internal/db/mysql_metadata_test.go` 回归测试,覆盖有权限、多库和低权限回退场景。
|
||||
|
||||
### #328
|
||||
|
||||
- 根因:Windows 更新脚本在批处理执行、错误码读取和重启命令上不够稳,`cmd /C start`、LF 行尾和块内 `%ERRORLEVEL%` 在实际环境下容易引发安装失败。
|
||||
- 处理:更新脚本统一输出为 CRLF,块内错误码改为延迟展开,旧文件回退路径统一为 `TARGET_OLD`,并将脚本启动方式收敛为 `cmd.exe /D /C call <script>`。
|
||||
- 验证:补充 `internal/app/methods_update_windows_script_test.go`,覆盖批处理语法、Win10 回退路径、CRLF 行尾、延迟展开和启动命令构造。
|
||||
|
||||
### #325
|
||||
|
||||
- 根因:TDengine 的版本列表虽然支持下拉选择,但后端在抓取与缓存 Go 模块版本时只保留最近 5 个版本,导致 `3.5.x / 3.3.x / 3.0.x` 这类旧版根本不会进入选择列表。
|
||||
- 处理:放宽 TDengine 的历史版本窗口,并补充离线 fallback 版本矩阵;同时扩大模块版本缓存上限,确保旧版不会在抓取阶段就被截断。
|
||||
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖缓存命中与 fallback 两条路径,并回归 Mongo 版本约束逻辑。
|
||||
|
||||
### #329
|
||||
|
||||
- 根因:侧边栏连接树被全局 Tree 样式固定为 `width: 100%`,标题同时启用了省略截断,导致缩窄侧栏后长节点无法形成横向溢出。
|
||||
- 处理:为 Sidebar 树增加专用横向滚动容器,并在 Sidebar 作用域内覆写 Tree 宽度与标题截断规则,让节点宽度随内容扩展且保留最小占满。
|
||||
- 验证:执行 `frontend` 下 `npm run build`,确认 TS/CSS 改动编译通过且仅作用于 Sidebar 树。
|
||||
|
||||
### #331
|
||||
|
||||
- 根因:连接失败时存在双层重试叠加。`DBGetDatabases / DBGetTables / DBQuery` 在缓存失效后本来就会主动重建连接一次,而 `connectDatabaseWithStartupRetry` 在稳定期仍会额外放行一次瞬时错误自动重试,导致一次后台探测会被放大成多次真实建连。
|
||||
- 处理:将连接自动重试范围收敛到应用启动保护窗口内;稳定期下所有连接探测与重建都只执行一次,避免后台挂起场景持续放大失败流量。
|
||||
- 验证:补充并更新 `internal/app/app_startup_connect_retry_test.go`,覆盖稳定期瞬时失败不重试、不再输出重试提示,以及启动期仍保留完整重试预算。
|
||||
|
||||
### #330
|
||||
|
||||
- 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。
|
||||
- 处理:为 `DataGrid` 的列宽拖拽手柄增加双击入口,按当前表头与已加载结果集内容估算目标宽度,并直接复用现有 `columnWidths` 状态更新布局。
|
||||
- 验证:新增 `frontend/src/components/dataGridAutoWidth.test.ts` 覆盖列宽估算规则,并执行 `frontend` 下 `npm run build` 确认 TS 与打包通过。
|
||||
|
||||
### #322
|
||||
|
||||
- 根因:`DataGrid` 已经具备拖选单元格和选区状态维护能力,但当前复制能力只支持把同一行选中的列值暂存为内部 patch,用于“粘贴到选中行”,没有把矩形选区真正导出到系统剪贴板。
|
||||
- 处理:新增选区复制 helper,将矩形选区按当前可见行列顺序导出为制表符文本;同时补上工具栏“复制选区”按钮和 `Ctrl/Cmd+C` 快捷键,让拖选后的复制行为更接近 Excel。
|
||||
- 验证:新增 `frontend/src/components/dataGridSelectionCopy.test.ts` 覆盖选区排序与剪贴板文本规整规则,并执行 `frontend` 下 `npm run build` 确认功能接线通过。
|
||||
|
||||
### #351
|
||||
|
||||
- 根因:后端已有批量清空表能力,但前端单表危险操作菜单只暴露了“删除表”,没有把“截断表 / 清空表”作为显式入口提供给用户;同时批量“清空”动作底层语义也混用了 `TRUNCATE/DELETE`。
|
||||
- 处理:后端将“截断表”和“清空表”拆分为显式能力,统一通过 helper 生成多数据库 SQL;前端为 Sidebar 和 TableOverview 的表菜单补上两个危险操作入口,并仅在明确支持 `TRUNCATE TABLE` 的数据库类型上显示“截断表”。
|
||||
- 验证:新增 `internal/app/methods_file_clear_test.go` 与 `frontend/src/components/tableDataDangerActions.test.ts`,并执行 `go test ./...`、`frontend` 下 `npm run build` 确认全量通过。
|
||||
|
||||
## Next
|
||||
|
||||
- 继续处理下一个最早且可直接落地的开放 issue。
|
||||
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
|
||||
6
frontend/public/db-icons/sqlserver.svg
Normal file
6
frontend/public/db-icons/sqlserver.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>SQL Server</title>
|
||||
<path fill="#A91D22" d="M4.2 7.25c1.05-1.56 4.53-2.69 8.24-2.69 3.34 0 6.13.91 7.25 2.15.57.64.63 1.29.16 1.87-1 1.27-3.81 2.09-7.18 2.09-3.85 0-7.1-1.03-8.29-2.52-.32-.4-.38-.61-.18-.9Z"/>
|
||||
<path fill="#D63539" d="M5.07 11.11c1.27-1.2 4.24-2.04 7.42-2.04 3.59 0 6.58 1.04 7.34 2.54.27.54.16 1.07-.34 1.55-1.18 1.12-3.89 1.81-7.12 1.81-3.56 0-6.56-.91-7.6-2.25-.4-.52-.31-1.02.3-1.61Z"/>
|
||||
<path fill="#F15F5C" d="M7.2 16.12c1.12-.75 3.11-1.18 5.38-1.18 2.43 0 4.59.52 5.71 1.39.84.65 1 1.42.42 2.05-.92 1-3.09 1.63-5.74 1.63-2.87 0-5.34-.75-6.22-1.88-.53-.68-.36-1.37.45-2.01Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
@@ -7,7 +7,7 @@ html, body, #root {
|
||||
}
|
||||
|
||||
body, #root {
|
||||
border-radius: 14px; /* Slightly rounded app window corners */
|
||||
border-radius: var(--gonavi-border-radius); /* Slightly rounded app window corners */
|
||||
}
|
||||
|
||||
/* 侧边栏 Tree 样式优化 */
|
||||
@@ -37,6 +37,41 @@ body, #root {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-content {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-list-holder,
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-list-holder-inner {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-treenode {
|
||||
width: auto;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-node-content-wrapper {
|
||||
width: auto !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-title {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-tree {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Tooltip } from 'antd';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
@@ -11,12 +11,16 @@ import DriverManagerModal from './components/DriverManagerModal';
|
||||
import LogPanel from './components/LogPanel';
|
||||
import AIChatPanel from './components/AIChatPanel';
|
||||
import AISettingsModal from './components/AISettingsModal';
|
||||
import { useStore } from './store';
|
||||
import { DEFAULT_APPEARANCE, useStore } from './store';
|
||||
import { SavedConnection } from './types';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
|
||||
import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
|
||||
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
|
||||
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
|
||||
import { getConnectionWorkbenchState } from './utils/startupReadiness';
|
||||
import { createGlobalProxyDraft, toSaveGlobalProxyInput } from './utils/globalProxyDraft';
|
||||
import { LEGACY_PERSIST_KEY, readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './utils/legacyConnectionStorage';
|
||||
import { getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift } from './utils/windowsScaleFix';
|
||||
import {
|
||||
SHORTCUT_ACTION_META,
|
||||
SHORTCUT_ACTION_ORDER,
|
||||
@@ -28,7 +32,14 @@ import {
|
||||
isShortcutMatch,
|
||||
normalizeShortcutCombo,
|
||||
} from './utils/shortcuts';
|
||||
import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import {
|
||||
SIDEBAR_UTILITY_ITEM_KEYS,
|
||||
resolveAIEntryPlacement,
|
||||
resolveAIEdgeHandleAttachment,
|
||||
resolveAIEdgeHandleDockStyle,
|
||||
resolveAIEdgeHandleStyle,
|
||||
} from './utils/aiEntryLayout';
|
||||
import { ApplyDataRootDirectory, GetDataRootDirectoryInfo, OpenDataRootDirectory, SelectDataRootDirectory, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import './App.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
@@ -52,11 +63,30 @@ const detectNavigatorPlatform = (): string => {
|
||||
return navigator.userAgent || '';
|
||||
};
|
||||
|
||||
|
||||
const toLegacySavedConnectionInput = (item: any) => ({
|
||||
id: typeof item?.id === 'string' ? item.id : '',
|
||||
name: typeof item?.name === 'string' ? item.name : '',
|
||||
config: (item?.config && typeof item.config === 'object') ? item.config : {},
|
||||
includeDatabases: Array.isArray(item?.includeDatabases) ? item.includeDatabases : undefined,
|
||||
includeRedisDatabases: Array.isArray(item?.includeRedisDatabases) ? item.includeRedisDatabases : undefined,
|
||||
iconType: typeof item?.iconType === 'string' ? item.iconType : '',
|
||||
iconColor: typeof item?.iconColor === 'string' ? item.iconColor : '',
|
||||
});
|
||||
|
||||
const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnection[]): SavedConnection[] => {
|
||||
const merged = new Map<string, SavedConnection>();
|
||||
current.forEach((conn) => merged.set(conn.id, conn));
|
||||
imported.forEach((conn) => merged.set(conn.id, conn));
|
||||
return Array.from(merged.values());
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
|
||||
const [isDriverModalOpen, setIsDriverModalOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
|
||||
const windowState = useStore(state => state.windowState);
|
||||
const themeMode = useStore(state => state.theme);
|
||||
const setTheme = useStore(state => state.setTheme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
@@ -69,6 +99,8 @@ function App() {
|
||||
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
|
||||
const globalProxy = useStore(state => state.globalProxy);
|
||||
const setGlobalProxy = useStore(state => state.setGlobalProxy);
|
||||
const replaceConnections = useStore(state => state.replaceConnections);
|
||||
const replaceGlobalProxy = useStore(state => state.replaceGlobalProxy);
|
||||
const shortcutOptions = useStore(state => state.shortcutOptions);
|
||||
const updateShortcut = useStore(state => state.updateShortcut);
|
||||
const resetShortcutOptions = useStore(state => state.resetShortcutOptions);
|
||||
@@ -89,18 +121,33 @@ function App() {
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const effectiveBlur = normalizeBlurForPlatform(resolvedAppearance.blur);
|
||||
const blurFilter = blurToFilter(effectiveBlur);
|
||||
const windowCornerRadius = 14;
|
||||
const [runtimePlatform, setRuntimePlatform] = useState('');
|
||||
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
|
||||
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
|
||||
const [hasAppliedInitialGlobalProxy, setHasAppliedInitialGlobalProxy] = useState(false);
|
||||
const [hasLoadedSecureConfig, setHasLoadedSecureConfig] = useState(false);
|
||||
const sidebarWidth = useStore(state => state.sidebarWidth);
|
||||
const setSidebarWidth = useStore(state => state.setSidebarWidth);
|
||||
const aiPanelVisible = useStore(state => state.aiPanelVisible);
|
||||
const toggleAIPanel = useStore(state => state.toggleAIPanel);
|
||||
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
|
||||
const globalProxyInvalidHintShownRef = React.useRef(false);
|
||||
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasAppliedInitialGlobalProxy);
|
||||
const windowDiagSequenceRef = React.useRef(0);
|
||||
const windowDiagLastSignatureRef = React.useRef('');
|
||||
const windowDiagLastAtRef = React.useRef(0);
|
||||
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig);
|
||||
|
||||
const windowCornerRadius = 14;
|
||||
useEffect(()=>{
|
||||
switch(windowState){
|
||||
case 'fullscreen':
|
||||
case 'maximized':
|
||||
document.body.setAttribute('--gonavi-border-radius', '0px');
|
||||
break;
|
||||
default:
|
||||
document.body.setAttribute('--gonavi-border-radius', `${windowCornerRadius}px`);
|
||||
break;
|
||||
}
|
||||
}, [windowState]);
|
||||
|
||||
// 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView,
|
||||
// 避免 GPU 持续计算窗口背后的模糊合成
|
||||
@@ -160,6 +207,90 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadSecureConfig = async () => {
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
const persistedPayload = typeof window !== 'undefined'
|
||||
? window.localStorage.getItem(LEGACY_PERSIST_KEY)
|
||||
: null;
|
||||
const legacy = readLegacyPersistedSecrets(persistedPayload);
|
||||
|
||||
let importedLegacyConnections = false;
|
||||
let importedLegacyGlobalProxy = false;
|
||||
|
||||
if (legacy.connections.length > 0) {
|
||||
if (typeof backendApp?.ImportLegacyConnections === 'function') {
|
||||
try {
|
||||
await backendApp.ImportLegacyConnections(
|
||||
legacy.connections.map(toLegacySavedConnectionInput)
|
||||
);
|
||||
importedLegacyConnections = true;
|
||||
} catch (err) {
|
||||
console.warn('Failed to import legacy saved connections', err);
|
||||
}
|
||||
} else {
|
||||
replaceConnections(legacy.connections);
|
||||
}
|
||||
}
|
||||
|
||||
if (legacy.globalProxy) {
|
||||
if (typeof backendApp?.ImportLegacyGlobalProxy === 'function') {
|
||||
try {
|
||||
await backendApp.ImportLegacyGlobalProxy(toSaveGlobalProxyInput(legacy.globalProxy));
|
||||
importedLegacyGlobalProxy = true;
|
||||
} catch (err) {
|
||||
console.warn('Failed to import legacy global proxy', err);
|
||||
}
|
||||
} else {
|
||||
replaceGlobalProxy(createGlobalProxyDraft(legacy.globalProxy));
|
||||
}
|
||||
}
|
||||
|
||||
if ((importedLegacyConnections || importedLegacyGlobalProxy) && persistedPayload && typeof window !== 'undefined') {
|
||||
const sanitizedPayload = stripLegacyPersistedSecrets(persistedPayload);
|
||||
if (sanitizedPayload && sanitizedPayload !== persistedPayload) {
|
||||
window.localStorage.setItem(LEGACY_PERSIST_KEY, sanitizedPayload);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof backendApp?.GetSavedConnections === 'function') {
|
||||
try {
|
||||
const savedConnections = await backendApp.GetSavedConnections();
|
||||
if (!cancelled && Array.isArray(savedConnections)) {
|
||||
replaceConnections(savedConnections);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load saved connections from backend', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof backendApp?.GetGlobalProxyConfig === 'function') {
|
||||
try {
|
||||
const proxyResult = await backendApp.GetGlobalProxyConfig();
|
||||
if (!cancelled && proxyResult?.success && proxyResult.data) {
|
||||
replaceGlobalProxy(createGlobalProxyDraft(proxyResult.data));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load global proxy from backend', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setHasLoadedSecureConfig(true);
|
||||
}
|
||||
};
|
||||
|
||||
void loadSecureConfig();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isStoreHydrated, replaceConnections, replaceGlobalProxy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStoreHydrated || !hasLoadedSecureConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const host = String(globalProxy.host || '').trim();
|
||||
const port = Number(globalProxy.port);
|
||||
const portValid = Number.isFinite(port) && port > 0 && port <= 65535;
|
||||
@@ -173,57 +304,44 @@ function App() {
|
||||
});
|
||||
globalProxyInvalidHintShownRef.current = true;
|
||||
}
|
||||
} else {
|
||||
globalProxyInvalidHintShownRef.current = false;
|
||||
void message.destroy('global-proxy-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled;
|
||||
let cancelled = false;
|
||||
try {
|
||||
ConfigureGlobalProxy(enabledForBackend, {
|
||||
type: globalProxy.type,
|
||||
host,
|
||||
port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080),
|
||||
user: String(globalProxy.user || '').trim(),
|
||||
password: globalProxy.password || '',
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled || res?.success) {
|
||||
return;
|
||||
}
|
||||
void message.error({
|
||||
content: '全局代理配置失败: ' + (res?.message || '未知错误'),
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
|
||||
void message.error({
|
||||
content: '全局代理配置失败: ' + errMsg,
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setHasAppliedInitialGlobalProxy(true);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setHasAppliedInitialGlobalProxy(true);
|
||||
}
|
||||
console.warn("Wails API: ConfigureGlobalProxy unavailable", e);
|
||||
globalProxyInvalidHintShownRef.current = false;
|
||||
void message.destroy('global-proxy-invalid');
|
||||
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.SaveGlobalProxy !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Promise.resolve(
|
||||
backendApp.SaveGlobalProxy(
|
||||
toSaveGlobalProxyInput({
|
||||
...globalProxy,
|
||||
host,
|
||||
port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080),
|
||||
})
|
||||
)
|
||||
)
|
||||
.catch((err) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
|
||||
void message.error({
|
||||
content: '全局代理配置失败: ' + errMsg,
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isStoreHydrated,
|
||||
hasLoadedSecureConfig,
|
||||
globalProxy.enabled,
|
||||
globalProxy.type,
|
||||
globalProxy.host,
|
||||
@@ -367,6 +485,10 @@ function App() {
|
||||
const store = useStore.getState();
|
||||
const newState = isFs ? 'fullscreen' : (isMax ? 'maximized' : 'normal');
|
||||
if (store.windowState !== newState) {
|
||||
void emitWindowDiagnostic('transition:windowState', {
|
||||
from: store.windowState,
|
||||
to: newState,
|
||||
});
|
||||
store.setWindowState(newState);
|
||||
}
|
||||
|
||||
@@ -382,15 +504,18 @@ function App() {
|
||||
const h = Math.trunc(Number(size.h || 0));
|
||||
const x = Math.trunc(Number(pos.x || 0));
|
||||
const y = Math.trunc(Number(pos.y || 0));
|
||||
if (w < 400 || h < 300) return;
|
||||
if (w < 400 || h < 300) return;
|
||||
|
||||
const key = `${w},${h},${x},${y}`;
|
||||
if (key === lastSaved) return;
|
||||
lastSaved = key;
|
||||
store.setWindowBounds({ width: w, height: h, x, y });
|
||||
} catch (e) {
|
||||
// 静默忽略
|
||||
}
|
||||
const key = `${w},${h},${x},${y}`;
|
||||
if (key === lastSaved) return;
|
||||
lastSaved = key;
|
||||
if (Math.abs(x) > 5000 || Math.abs(y) > 5000) {
|
||||
void emitWindowDiagnostic('anomaly:windowBounds', { width: w, height: h, x, y });
|
||||
}
|
||||
store.setWindowBounds({ width: w, height: h, x, y });
|
||||
} catch (e) {
|
||||
// 静默忽略
|
||||
}
|
||||
};
|
||||
|
||||
const timer = window.setInterval(saveWindowState, SAVE_INTERVAL_MS);
|
||||
@@ -410,7 +535,7 @@ function App() {
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
const fixWindowScaleIfNeeded = async () => {
|
||||
const fixWindowScaleIfNeeded = async (reason: 'activation' | 'ratio-change') => {
|
||||
if (cancelled || inFlight) return;
|
||||
const now = Date.now();
|
||||
if (now - lastFixAt < 700) return;
|
||||
@@ -421,8 +546,8 @@ function App() {
|
||||
WindowIsMaximised().catch(() => false),
|
||||
]);
|
||||
|
||||
// 避免在全屏/最大化状态下强制改尺寸;这两种状态通常能自行保持 DPI 同步。
|
||||
if (isFullscreen || isMaximised) {
|
||||
// 全屏状态下只广播 resize,避免破坏用户的全屏上下文。
|
||||
if (isFullscreen) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
lastFixAt = Date.now();
|
||||
return;
|
||||
@@ -431,13 +556,46 @@ function App() {
|
||||
const size = await WindowGetSize().catch(() => null);
|
||||
const width = Math.trunc(Number(size?.w || 0));
|
||||
const height = Math.trunc(Number(size?.h || 0));
|
||||
const hasViewportScaleDrift = hasWindowsViewportScaleDrift({
|
||||
windowWidth: width,
|
||||
innerWidth: window.innerWidth,
|
||||
devicePixelRatio: Number(window.devicePixelRatio) || 1,
|
||||
visualViewportScale: window.visualViewport?.scale,
|
||||
});
|
||||
|
||||
if (isMaximised) {
|
||||
if (reason !== 'ratio-change' && !hasViewportScaleDrift) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
lastFixAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
WindowToggleMaximise();
|
||||
await wait(48);
|
||||
WindowToggleMaximise();
|
||||
await wait(64);
|
||||
} catch (e) {
|
||||
console.warn("Wails Window maximise toggle unavailable in fixWindowScaleIfNeeded", e);
|
||||
}
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
lastFixAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
lastFixAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const nudgedWidth = width > 480 ? width - 1 : width + 1;
|
||||
if (reason !== 'ratio-change' && !hasViewportScaleDrift) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
lastFixAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const nudgedWidth = getWindowsScaleFixNudgedWidth(width);
|
||||
try {
|
||||
WindowSetSize(nudgedWidth, height);
|
||||
await wait(28);
|
||||
@@ -459,7 +617,7 @@ function App() {
|
||||
return;
|
||||
}
|
||||
lastRatio = currentRatio;
|
||||
void fixWindowScaleIfNeeded();
|
||||
void fixWindowScaleIfNeeded('ratio-change');
|
||||
};
|
||||
|
||||
const scheduleActivationFix = () => {
|
||||
@@ -470,7 +628,7 @@ function App() {
|
||||
activationTimer = window.setTimeout(() => {
|
||||
activationTimer = null;
|
||||
if (cancelled) return;
|
||||
void fixWindowScaleIfNeeded();
|
||||
void fixWindowScaleIfNeeded('activation');
|
||||
}, 80);
|
||||
};
|
||||
|
||||
@@ -669,7 +827,6 @@ function App() {
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const activeContext = useStore(state => state.activeContext);
|
||||
const connections = useStore(state => state.connections);
|
||||
const addConnection = useStore(state => state.addConnection);
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const updateCheckInFlightRef = React.useRef(false);
|
||||
@@ -741,6 +898,63 @@ function App() {
|
||||
|| (runtimePlatform === '' && isWindowsPlatform());
|
||||
const useNativeMacWindowControls = isMacRuntime && appearance.useNativeMacWindowControls === true;
|
||||
|
||||
const emitWindowDiagnostic = useCallback(async (stage: string, extra: Record<string, unknown> = {}) => {
|
||||
if (!isMacRuntime) {
|
||||
return;
|
||||
}
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.LogWindowDiagnostic !== 'function') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [isFullscreen, isMaximised, isMinimised, isNormal, size, position] = await Promise.all([
|
||||
WindowIsFullscreen().catch(() => false),
|
||||
WindowIsMaximised().catch(() => false),
|
||||
WindowIsMinimised().catch(() => false),
|
||||
WindowIsNormal().catch(() => false),
|
||||
WindowGetSize().catch(() => null),
|
||||
WindowGetPosition().catch(() => null),
|
||||
]);
|
||||
const payload = {
|
||||
seq: ++windowDiagSequenceRef.current,
|
||||
ts: new Date().toISOString(),
|
||||
stage,
|
||||
nativeControls: useNativeMacWindowControls,
|
||||
documentVisible: document.visibilityState,
|
||||
documentHasFocus: document.hasFocus(),
|
||||
devicePixelRatio: Number(window.devicePixelRatio) || 1,
|
||||
windowState: {
|
||||
isFullscreen,
|
||||
isMaximised,
|
||||
isMinimised,
|
||||
isNormal,
|
||||
},
|
||||
size: size ? { w: Math.trunc(Number(size.w || 0)), h: Math.trunc(Number(size.h || 0)) } : null,
|
||||
position: position ? { x: Math.trunc(Number(position.x || 0)), y: Math.trunc(Number(position.y || 0)) } : null,
|
||||
extra,
|
||||
};
|
||||
const signature = JSON.stringify({
|
||||
stage,
|
||||
nativeControls: payload.nativeControls,
|
||||
visible: payload.documentVisible,
|
||||
focus: payload.documentHasFocus,
|
||||
state: payload.windowState,
|
||||
size: payload.size,
|
||||
position: payload.position,
|
||||
extra,
|
||||
});
|
||||
const now = Date.now();
|
||||
if (signature === windowDiagLastSignatureRef.current && now-windowDiagLastAtRef.current < 250) {
|
||||
return;
|
||||
}
|
||||
windowDiagLastSignatureRef.current = signature;
|
||||
windowDiagLastAtRef.current = now;
|
||||
await backendApp.LogWindowDiagnostic(stage, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.warn('Failed to emit window diagnostic', error);
|
||||
}
|
||||
}, [isMacRuntime, useNativeMacWindowControls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStoreHydrated || !isMacRuntime) {
|
||||
return;
|
||||
@@ -753,6 +967,104 @@ function App() {
|
||||
}
|
||||
}, [isMacRuntime, isStoreHydrated, useNativeMacWindowControls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMacRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let pollTimer: number | null = null;
|
||||
let burstTimer: number | null = null;
|
||||
|
||||
const stopBurst = () => {
|
||||
if (pollTimer !== null) {
|
||||
window.clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (burstTimer !== null) {
|
||||
window.clearTimeout(burstTimer);
|
||||
burstTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startBurst = (reason: string, extra: Record<string, unknown> = {}) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
void emitWindowDiagnostic(`burst:start:${reason}`, extra);
|
||||
if (pollTimer === null) {
|
||||
pollTimer = window.setInterval(() => {
|
||||
void emitWindowDiagnostic(`burst:tick:${reason}`);
|
||||
}, 250);
|
||||
}
|
||||
if (burstTimer !== null) {
|
||||
window.clearTimeout(burstTimer);
|
||||
}
|
||||
burstTimer = window.setTimeout(() => {
|
||||
stopBurst();
|
||||
void emitWindowDiagnostic(`burst:stop:${reason}`);
|
||||
}, 6000);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
void emitWindowDiagnostic('event:focus');
|
||||
};
|
||||
const handleBlur = () => {
|
||||
void emitWindowDiagnostic('event:blur');
|
||||
};
|
||||
const handleResize = () => {
|
||||
void emitWindowDiagnostic('event:resize');
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
void emitWindowDiagnostic('event:visibilitychange', { visibility: document.visibilityState });
|
||||
};
|
||||
const handleEditableKeydown = (event: KeyboardEvent) => {
|
||||
if (!isEditableElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
const key = String(event.key || '');
|
||||
const maybeFullscreenKey = key === 'Escape' || key.toLowerCase() === 'f' || key === 'Process';
|
||||
const hasModifier = event.ctrlKey || event.metaKey || event.altKey;
|
||||
startBurst('editable-keydown', {
|
||||
key,
|
||||
code: String(event.code || ''),
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
altKey: event.altKey,
|
||||
shiftKey: event.shiftKey,
|
||||
maybeFullscreenKey,
|
||||
hasModifier,
|
||||
});
|
||||
};
|
||||
const handleCompositionStart = () => {
|
||||
startBurst('compositionstart');
|
||||
};
|
||||
const handleCompositionEnd = () => {
|
||||
startBurst('compositionend');
|
||||
};
|
||||
|
||||
void emitWindowDiagnostic('session:start');
|
||||
window.addEventListener('focus', handleFocus);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('keydown', handleEditableKeydown, true);
|
||||
window.addEventListener('compositionstart', handleCompositionStart, true);
|
||||
window.addEventListener('compositionend', handleCompositionEnd, true);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopBurst();
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('keydown', handleEditableKeydown, true);
|
||||
window.removeEventListener('compositionstart', handleCompositionStart, true);
|
||||
window.removeEventListener('compositionend', handleCompositionEnd, true);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [emitWindowDiagnostic, isMacRuntime]);
|
||||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
@@ -1084,20 +1396,29 @@ function App() {
|
||||
if (res.success) {
|
||||
try {
|
||||
const imported = JSON.parse(res.data);
|
||||
if (Array.isArray(imported)) {
|
||||
let count = 0;
|
||||
imported.forEach((conn: any) => {
|
||||
if (!connections.some(c => c.id === conn.id)) {
|
||||
addConnection(conn);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
void message.success(`成功导入 ${count} 个连接`);
|
||||
} else {
|
||||
if (!Array.isArray(imported)) {
|
||||
void message.error("文件格式错误:需要 JSON 数组");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
void message.error("解析 JSON 失败");
|
||||
|
||||
const normalizedItems = imported.map(toLegacySavedConnectionInput);
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
|
||||
if (typeof backendApp?.ImportLegacyConnections === 'function') {
|
||||
const importedViews = await backendApp.ImportLegacyConnections(normalizedItems);
|
||||
if (!Array.isArray(importedViews)) {
|
||||
throw new Error('导入失败:后端未返回连接列表');
|
||||
}
|
||||
replaceConnections(mergeSavedConnections(connections, importedViews));
|
||||
void message.success(`成功导入 ${importedViews.length} 个连接`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackItems = normalizedItems as SavedConnection[];
|
||||
replaceConnections(mergeSavedConnections(connections, fallbackItems));
|
||||
void message.success(`成功导入 ${fallbackItems.length} 个连接`);
|
||||
} catch (e: any) {
|
||||
void message.error(e?.message || "解析 JSON 失败");
|
||||
}
|
||||
} else if (res.message !== "已取消") {
|
||||
void message.error("导入失败: " + res.message);
|
||||
@@ -1109,7 +1430,7 @@ function App() {
|
||||
void message.warning("没有连接可导出");
|
||||
return;
|
||||
}
|
||||
const res = await (window as any).go.app.App.ExportData(connections, ['id','name','config','includeDatabases','includeRedisDatabases'], "connections", "json");
|
||||
const res = await (window as any).go.app.App.ExportData(connections, ['id','name','config','includeDatabases','includeRedisDatabases','iconType','iconColor'], "connections", "json");
|
||||
if (res.success) {
|
||||
void message.success("导出成功");
|
||||
} else if (res.message !== "已取消") {
|
||||
@@ -1124,7 +1445,145 @@ function App() {
|
||||
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
|
||||
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
|
||||
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
|
||||
const [isDataRootModalOpen, setIsDataRootModalOpen] = useState(false);
|
||||
const [dataRootInfo, setDataRootInfo] = useState<any>(null);
|
||||
const [selectedDataRootPath, setSelectedDataRootPath] = useState('');
|
||||
const [dataRootLoading, setDataRootLoading] = useState(false);
|
||||
const [dataRootApplying, setDataRootApplying] = 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>
|
||||
);
|
||||
|
||||
const loadDataRootInfo = useCallback(async () => {
|
||||
setDataRootLoading(true);
|
||||
try {
|
||||
const res = await GetDataRootDirectoryInfo();
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.message || '加载数据目录信息失败');
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
setDataRootInfo(data);
|
||||
setSelectedDataRootPath(String(data.path || ''));
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
|
||||
void message.error(`加载数据目录信息失败: ${errMsg}`);
|
||||
} finally {
|
||||
setDataRootLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDataRootModalOpen) {
|
||||
return;
|
||||
}
|
||||
void loadDataRootInfo();
|
||||
}, [isDataRootModalOpen, loadDataRootInfo]);
|
||||
|
||||
const handleSelectDataRoot = useCallback(async () => {
|
||||
try {
|
||||
const res = await SelectDataRootDirectory(selectedDataRootPath || dataRootInfo?.path || '');
|
||||
if (!res?.success) {
|
||||
if (String(res?.message || '') !== '已取消') {
|
||||
throw new Error(res?.message || '选择数据目录失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
setSelectedDataRootPath(String(data.path || ''));
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
|
||||
void message.error(`选择数据目录失败: ${errMsg}`);
|
||||
}
|
||||
}, [dataRootInfo?.path, selectedDataRootPath]);
|
||||
|
||||
const handleApplyDataRoot = useCallback(async (migrate: boolean, useDefaultPath = false) => {
|
||||
const nextPath = useDefaultPath ? String(dataRootInfo?.defaultPath || '') : String(selectedDataRootPath || '').trim();
|
||||
if (!nextPath) {
|
||||
void message.warning('请先选择有效的数据目录');
|
||||
return;
|
||||
}
|
||||
setDataRootApplying(true);
|
||||
try {
|
||||
const res = await ApplyDataRootDirectory(nextPath, migrate);
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.message || '应用数据目录失败');
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
setDataRootInfo(data);
|
||||
setSelectedDataRootPath(String(data.path || nextPath));
|
||||
void message.success(res?.message || '数据目录已更新');
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
|
||||
void message.error(`应用数据目录失败: ${errMsg}`);
|
||||
} finally {
|
||||
setDataRootApplying(false);
|
||||
}
|
||||
}, [dataRootInfo?.defaultPath, selectedDataRootPath]);
|
||||
|
||||
const handleOpenDataRoot = useCallback(async () => {
|
||||
try {
|
||||
const res = await OpenDataRootDirectory();
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.message || '打开数据目录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
|
||||
void message.error(`打开数据目录失败: ${errMsg}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
|
||||
@@ -1196,15 +1655,19 @@ function App() {
|
||||
|
||||
const handleTitleBarWindowToggle = async () => {
|
||||
try {
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:before');
|
||||
if (await WindowIsFullscreen()) {
|
||||
await WindowUnfullscreen();
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-unfullscreen');
|
||||
return;
|
||||
}
|
||||
if (useNativeMacWindowControls && isMacRuntime) {
|
||||
await WindowFullscreen();
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-fullscreen');
|
||||
return;
|
||||
}
|
||||
await WindowToggleMaximise();
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-toggle-maximise');
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
@@ -1563,8 +2026,8 @@ function App() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
borderRadius: showLinuxResizeHandles ? 0 : windowCornerRadius,
|
||||
clipPath: showLinuxResizeHandles ? 'none' : `inset(0 round ${windowCornerRadius}px)`,
|
||||
borderRadius: showLinuxResizeHandles ? 0 : 'var(--gonavi-border-radius)',
|
||||
clipPath: showLinuxResizeHandles ? 'none' : 'inset(0 round var(--gonavi-border-radius))',
|
||||
backdropFilter: blurFilter,
|
||||
WebkitBackdropFilter: blurFilter,
|
||||
}}>
|
||||
@@ -1634,24 +2097,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 +2211,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: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
|
||||
<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 ? 'var(--gonavi-border-radius)' : 0, clipPath: isLogPanelOpen ? 'inset(0 round var(--gonavi-border-radius))' : '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 && (
|
||||
@@ -1833,6 +2296,16 @@ function App() {
|
||||
setIsDriverModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'data-root',
|
||||
icon: <HddOutlined />,
|
||||
title: '数据目录',
|
||||
description: '查看、切换或迁移本地数据存储位置。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setIsDataRootModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'shortcut-settings',
|
||||
icon: <LinkOutlined />,
|
||||
@@ -1856,6 +2329,74 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<HddOutlined />, '数据存储位置', '统一管理连接、代理、AI 配置与驱动等文件型数据的根目录。')}
|
||||
open={isDataRootModalOpen}
|
||||
onCancel={() => setIsDataRootModalOpen(false)}
|
||||
footer={null}
|
||||
width={720}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
>
|
||||
{dataRootLoading ? (
|
||||
<div style={{ padding: '16px 0', textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>当前目录</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<Input readOnly value={dataRootInfo?.path || ''} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontWeight: 500 }}>默认目录</div>
|
||||
<div style={utilityMutedTextStyle}>{dataRootInfo?.defaultPath || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontWeight: 500 }}>驱动目录</div>
|
||||
<div style={utilityMutedTextStyle}>{dataRootInfo?.driverPath || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>切换目标</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<Input
|
||||
readOnly
|
||||
value={selectedDataRootPath}
|
||||
placeholder="选择新的数据目录"
|
||||
/>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
<Button icon={<FolderOpenOutlined />} onClick={() => void handleSelectDataRoot()}>
|
||||
选择目录
|
||||
</Button>
|
||||
<Button onClick={() => void handleOpenDataRoot()}>
|
||||
打开当前目录
|
||||
</Button>
|
||||
<Button loading={dataRootApplying} onClick={() => void handleApplyDataRoot(false, true)}>
|
||||
恢复默认目录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>应用方式</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
<Button loading={dataRootApplying} onClick={() => void handleApplyDataRoot(false)}>
|
||||
仅切换到所选目录
|
||||
</Button>
|
||||
<Button type="primary" loading={dataRootApplying} onClick={() => void handleApplyDataRoot(true)}>
|
||||
迁移现有数据并切换
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>
|
||||
切换后建议重启应用,以确保 AI 与其他长生命周期模块完全切换到新目录。敏感密码仍保存在系统 secret store,不会随文件目录迁移。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<DataSyncModal
|
||||
open={isSyncModalOpen}
|
||||
onClose={() => setIsSyncModalOpen(false)}
|
||||
@@ -2132,6 +2673,33 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 500 }}>数据表显示</div>
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>显示数据表竖向分隔线</div>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>仅作用于数据表页面 DataGrid,不影响其他表格组件。</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={appearance.showDataTableVerticalBorders === true}
|
||||
onChange={(checked) => setAppearance({ showDataTableVerticalBorders: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>数据表列宽模式</div>
|
||||
<Segmented
|
||||
block
|
||||
options={DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS}
|
||||
value={appearance.dataTableColumnWidthMode}
|
||||
onChange={(value) => setAppearance({ dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value) })}
|
||||
/>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 8 }}>
|
||||
标准模式默认列宽 200px;紧凑模式默认列宽 140px。已手动拖拽调整的列宽优先保留。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMacRuntime ? (
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>macOS 窗口控制</div>
|
||||
@@ -2165,7 +2733,7 @@ function App() {
|
||||
onClick={() => {
|
||||
setUiScale(DEFAULT_UI_SCALE);
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
setAppearance({ enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false });
|
||||
setAppearance({ ...DEFAULT_APPEARANCE });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
|
||||
@@ -6,7 +6,6 @@ import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { AIChatMessage, AIToolCall } from '../types';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
import { message as antdMessage } from 'antd';
|
||||
import './AIChatPanel.css';
|
||||
|
||||
import { AIChatHeader } from './ai/AIChatHeader';
|
||||
@@ -14,6 +13,13 @@ import { AIChatWelcome } from './ai/AIChatWelcome';
|
||||
import { AIMessageBubble } from './ai/AIMessageBubble';
|
||||
import { AIChatInput } from './ai/AIChatInput';
|
||||
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
|
||||
import type { AIComposerNotice } from '../utils/aiComposerNotice';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
buildMissingModelNotice,
|
||||
buildMissingProviderNotice,
|
||||
buildModelFetchFailedNotice,
|
||||
} from '../utils/aiComposerNotice';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
@@ -211,6 +217,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const [dynamicModels, setDynamicModels] = useState<string[]>([]);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [composerNotice, setComposerNotice] = useState<AIComposerNotice | null>(null);
|
||||
const [panelWidth, setPanelWidth] = useState(width);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
@@ -220,13 +227,11 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const resizeStartX = useRef(0);
|
||||
const resizeStartWidth = useRef(0);
|
||||
const toolCallRoundRef = useRef(0); // 连续失败轮次计数
|
||||
const totalToolRoundRef = useRef(0); // 全局工具调用总轮次计数(防止无限循环)
|
||||
const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数
|
||||
const panelRef = useRef<HTMLDivElement>(null); // 面板 DOM ref,用于拖拽时直接操作宽度
|
||||
const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染)
|
||||
|
||||
// 面板内部 toast 通知(不在屏幕顶部,而在面板容器内显示)
|
||||
const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => panelRef.current || document.body });
|
||||
|
||||
const aiChatHistory = useStore(state => state.aiChatHistory);
|
||||
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
|
||||
const createNewAISession = useStore(state => state.createNewAISession);
|
||||
@@ -256,7 +261,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const conn = useStore.getState().connections.find(c => c.id === connectionId);
|
||||
if (conn) {
|
||||
import('../../wailsjs/go/app/App').then(({ DBShowCreateTable }) => {
|
||||
DBShowCreateTable(conn.config as any, dbName, tableName).then(res => {
|
||||
DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName).then(res => {
|
||||
if (res.success && res.data) {
|
||||
let createSql = '';
|
||||
if (typeof res.data === 'string') createSql = res.data;
|
||||
@@ -336,6 +341,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setDynamicModels([]);
|
||||
setComposerNotice(null);
|
||||
activeProviderIdRef.current = null;
|
||||
loadActiveProvider();
|
||||
};
|
||||
@@ -347,9 +353,15 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
if (!activeProvider) return;
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const payload = { ...activeProvider, model: val };
|
||||
const payload = {
|
||||
...activeProvider,
|
||||
model: val,
|
||||
apiKey: activeProvider.apiKey || '',
|
||||
hasSecret: activeProvider.hasSecret ?? Boolean(activeProvider.secretRef),
|
||||
};
|
||||
await Service?.AISaveProvider?.(payload);
|
||||
setActiveProvider(payload);
|
||||
setComposerNotice(null);
|
||||
} catch (e) { console.warn('Failed to update provider model', e); }
|
||||
};
|
||||
|
||||
@@ -358,33 +370,45 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
useEffect(() => {
|
||||
if (activeProvider?.id && activeProvider.id !== activeProviderIdRef.current) {
|
||||
setDynamicModels([]);
|
||||
setComposerNotice(null);
|
||||
activeProviderIdRef.current = activeProvider.id;
|
||||
}
|
||||
// 供应商被删除后 activeProvider 变为 null,此时也必须清空残留模型
|
||||
if (!activeProvider) {
|
||||
setDynamicModels([]);
|
||||
setComposerNotice(null);
|
||||
activeProviderIdRef.current = null;
|
||||
}
|
||||
}, [activeProvider?.id, activeProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProvider?.model && String(activeProvider.model).trim()) {
|
||||
setComposerNotice(null);
|
||||
}
|
||||
}, [activeProvider?.model]);
|
||||
|
||||
|
||||
// dynamicModels 仅在内存中使用,不再写回供应商配置,避免污染静态 models 列表
|
||||
|
||||
const fetchDynamicModels = useCallback(async () => {
|
||||
try {
|
||||
setLoadingModels(true);
|
||||
setComposerNotice(null);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service) return;
|
||||
const result = await Service.AIListModels?.();
|
||||
if (result?.success && Array.isArray(result.models) && result.models.length > 0) {
|
||||
const sortedModels = [...result.models].sort((a, b) => a.localeCompare(b));
|
||||
setDynamicModels(sortedModels);
|
||||
setComposerNotice(null);
|
||||
} else if (result && !result.success) {
|
||||
messageApi.warning(result.error || '获取模型列表失败,可手动输入模型名称');
|
||||
setDynamicModels([]);
|
||||
setComposerNotice(buildModelFetchFailedNotice(result.error));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Failed to fetch models', e);
|
||||
messageApi.warning('获取模型列表失败: ' + (e?.message || '未知错误'));
|
||||
setDynamicModels([]);
|
||||
setComposerNotice(buildModelFetchFailedNotice('获取模型列表失败:' + (e?.message || '未知错误')));
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
@@ -656,7 +680,21 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
if (lastUserMsgIndex >= 0) {
|
||||
const userMsg = historyLocal[lastUserMsgIndex];
|
||||
truncateAIChatMessages(sid, userMsg.id);
|
||||
|
||||
// 重置计数器(与 handleSend 保持一致)
|
||||
toolCallRoundRef.current = 0;
|
||||
totalToolRoundRef.current = 0;
|
||||
nudgeCountRef.current = 0;
|
||||
|
||||
setSending(true);
|
||||
|
||||
// 插入 connecting 过渡消息(波纹动画),与 handleSend 保持一致
|
||||
const connectingMsg: AIChatMessage = {
|
||||
id: genId(), role: 'assistant', phase: 'connecting', content: '',
|
||||
timestamp: Date.now(), loading: true
|
||||
};
|
||||
addAIChatMessage(sid, connectingMsg);
|
||||
|
||||
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
|
||||
const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content, images: m.images }));
|
||||
|
||||
@@ -766,6 +804,20 @@ SELECT * FROM users WHERE status = 1;
|
||||
const toolContextMapRef = useRef<Map<string, { connectionId: string; dbName: string; tables: string[] }>>(new Map());
|
||||
|
||||
const executeLocalTools = useCallback(async (toolCalls: AIToolCall[], currentAsstMsgId: string) => {
|
||||
// 【全局轮次熔断】防止模型(如 DeepSeek)在已生成答案后仍无限循环调用工具
|
||||
const MAX_TOOL_CALL_ROUNDS = 15;
|
||||
totalToolRoundRef.current += 1;
|
||||
if (totalToolRoundRef.current > MAX_TOOL_CALL_ROUNDS) {
|
||||
updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' });
|
||||
useStore.getState().addAIChatMessage(sid, {
|
||||
id: genId(), role: 'assistant',
|
||||
content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: AIChatMessage[] = [];
|
||||
// 【串行逐条执行 + 实时写入 store】
|
||||
for (const tc of toolCalls) {
|
||||
@@ -788,7 +840,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
|
||||
if (conn) {
|
||||
try {
|
||||
const dbRes = await DBGetDatabases(conn.config as any);
|
||||
const dbRes = await DBGetDatabases(buildRpcConnectionConfig(conn.config) as any);
|
||||
if (dbRes?.success && Array.isArray(dbRes.data)) {
|
||||
let dNames = dbRes.data.map((r: any) => r.Database || r.database || Object.values(r)[0]);
|
||||
if (dNames.length > 50) dNames = [...dNames.slice(0, 50), '...(截断)'];
|
||||
@@ -809,7 +861,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
try {
|
||||
const rawDbName = args.dbName || args.database;
|
||||
const safeDbName = rawDbName ? String(rawDbName).trim() : '';
|
||||
const tbRes = await DBGetTables(conn.config as any, safeDbName);
|
||||
const tbRes = await DBGetTables(buildRpcConnectionConfig(conn.config) as any, safeDbName);
|
||||
if (tbRes?.success && Array.isArray(tbRes.data)) {
|
||||
let tNames = tbRes.data.map((r: any) => r.Table || r.table || Object.values(r)[0] as string);
|
||||
if (tNames.length > 150) tNames = [...tNames.slice(0, 150), '...(截断)'];
|
||||
@@ -835,7 +887,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
|
||||
const safeTable = args.tableName ? String(args.tableName).trim() : '';
|
||||
const { DBGetColumns } = await import('../../wailsjs/go/app/App');
|
||||
const colRes = await DBGetColumns(conn.config as any, safeDbName, safeTable);
|
||||
const colRes = await DBGetColumns(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
|
||||
if (colRes?.success && Array.isArray(colRes.data)) {
|
||||
// 只保留关键字段信息,减少 token 占用
|
||||
const cols = colRes.data.map((c: any) => {
|
||||
@@ -866,7 +918,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
|
||||
const safeTable = args.tableName ? String(args.tableName).trim() : '';
|
||||
const { DBShowCreateTable } = await import('../../wailsjs/go/app/App');
|
||||
const ddlRes = await DBShowCreateTable(conn.config as any, safeDbName, safeTable);
|
||||
const ddlRes = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
|
||||
if (ddlRes?.success) {
|
||||
resStr = typeof ddlRes.data === 'string' ? ddlRes.data : JSON.stringify(ddlRes.data);
|
||||
success = true;
|
||||
@@ -893,7 +945,14 @@ SELECT * FROM users WHERE status = 1;
|
||||
}
|
||||
}
|
||||
const { DBQuery } = await import('../../wailsjs/go/app/App');
|
||||
const qRes = await DBQuery(conn.config as any, safeDbName, safeSql + (safeSql.toLowerCase().includes('limit') ? '' : ' LIMIT 50'));
|
||||
// 只对只读查询自动追加 LIMIT,写操作(UPDATE/DELETE/INSERT等)不追加
|
||||
const sqlTrimmed = safeSql.replace(/;\s*$/, ''); // 去掉末尾分号防止拼接出 "; LIMIT 50"
|
||||
const sqlFirstWord = sqlTrimmed.trimStart().split(/\s/)[0]?.toLowerCase() || '';
|
||||
const isReadQuery = ['select', 'show', 'describe', 'desc', 'explain', 'with'].includes(sqlFirstWord);
|
||||
const finalSql = (isReadQuery && !sqlTrimmed.toLowerCase().includes('limit'))
|
||||
? sqlTrimmed + ' LIMIT 50'
|
||||
: sqlTrimmed;
|
||||
const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeSql + (safeSql.toLowerCase().includes('limit') ? '' : ' LIMIT 50'));
|
||||
if (qRes?.success) {
|
||||
const rows = Array.isArray(qRes.data) ? qRes.data : [];
|
||||
const limitedRows = rows.slice(0, 50);
|
||||
@@ -1003,11 +1062,16 @@ SELECT * FROM users WHERE status = 1;
|
||||
}
|
||||
|
||||
const allMessages = [...sysMessages, ...finalMessagesPayload];
|
||||
|
||||
// 【软收敛】超过 10 轮工具调用后,不再传递 tools 参数,从物理层面强制模型只能用文本回答
|
||||
const SOFT_LIMIT_ROUNDS = 10;
|
||||
const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : LOCAL_TOOLS;
|
||||
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatStream) {
|
||||
await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS);
|
||||
await Service.AIChatStream(sid, allMessages, chainTools);
|
||||
} else if (Service?.AIChatSend) {
|
||||
const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS);
|
||||
const result = await Service.AIChatSend(allMessages, chainTools);
|
||||
const errR = result?.error || '未知错误';
|
||||
const errC = sanitizeErrorMsg(errR);
|
||||
useStore.getState().addAIChatMessage(sid, {
|
||||
@@ -1030,15 +1094,17 @@ SELECT * FROM users WHERE status = 1;
|
||||
|
||||
// 前置校验:必须配置供应商且选择模型后才能发送
|
||||
if (!activeProvider) {
|
||||
messageApi.warning('请先在 AI 设置中配置供应商');
|
||||
setComposerNotice(buildMissingProviderNotice());
|
||||
return;
|
||||
}
|
||||
if (!activeProvider.model || !activeProvider.model.trim()) {
|
||||
messageApi.warning('请先选择模型 ID(点击工具栏的模型下拉框选择)');
|
||||
setComposerNotice(buildMissingModelNotice());
|
||||
return;
|
||||
}
|
||||
setComposerNotice(null);
|
||||
|
||||
toolCallRoundRef.current = 0; // 重置工具调用轮次计数
|
||||
totalToolRoundRef.current = 0; // 重置总轮次计数
|
||||
nudgeCountRef.current = 0; // 重置催促计数
|
||||
|
||||
const currentImages = [...draftImages];
|
||||
@@ -1246,7 +1312,8 @@ SELECT * FROM users WHERE status = 1;
|
||||
const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]);
|
||||
const activeConnectionConfig = useMemo(() => {
|
||||
if (!inferredConnectionId) return undefined;
|
||||
return connections.find(c => c.id === inferredConnectionId)?.config;
|
||||
const connection = connections.find(c => c.id === inferredConnectionId);
|
||||
return connection ? buildRpcConnectionConfig(connection.config) : undefined;
|
||||
}, [inferredConnectionId, connections]);
|
||||
const contextUsageChars = useMemo(() =>
|
||||
messages.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0),
|
||||
@@ -1258,7 +1325,6 @@ SELECT * FROM users WHERE status = 1;
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="ai-chat-panel" style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
|
||||
{messageContextHolder}
|
||||
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
|
||||
|
||||
{isResizing && panelRect.current && createPortal(
|
||||
@@ -1366,6 +1432,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
activeProvider={activeProvider}
|
||||
dynamicModels={dynamicModels}
|
||||
loadingModels={loadingModels}
|
||||
composerNotice={composerNotice}
|
||||
onModelChange={handleModelChange}
|
||||
onFetchModels={fetchDynamicModels}
|
||||
textareaRef={textareaRef}
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
|
||||
import { Modal, Button, Input, Select, Form, Checkbox, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
|
||||
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 {
|
||||
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_MODELS,
|
||||
resolveProviderPresetKey,
|
||||
resolvePresetBaseURL,
|
||||
resolvePresetModelSelection,
|
||||
resolvePresetTransport,
|
||||
} from '../utils/aiProviderPresets';
|
||||
import {
|
||||
PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||
PROVIDER_PRESET_CARD_CONTENT_STYLE,
|
||||
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
|
||||
PROVIDER_PRESET_GRID_STYLE,
|
||||
PROVIDER_PRESET_CARD_TITLE_STYLE,
|
||||
} from '../utils/aiSettingsPresetLayout';
|
||||
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
|
||||
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
|
||||
@@ -20,6 +38,7 @@ interface ProviderPreset {
|
||||
desc: string;
|
||||
color: string;
|
||||
backendType: AIProviderType;
|
||||
fixedApiFormat?: string;
|
||||
defaultBaseUrl: string;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
@@ -28,12 +47,14 @@ interface ProviderPreset {
|
||||
const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{ key: 'openai', label: 'OpenAI', icon: <ApiOutlined />, desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] },
|
||||
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
|
||||
{ key: 'qwen', label: '通义千问', icon: <CloudOutlined />, desc: 'Qwen3.5 / Qwen3 系列', color: '#6366f1', backendType: 'openai', defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', defaultModel: 'qwen-max', models: [] },
|
||||
{ key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: <CloudOutlined />, desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },
|
||||
{ key: 'qwen-coding-plan', label: '通义千问(Coding Plan)', icon: <CloudOutlined />, desc: 'Claude Code CLI 代理链路 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS },
|
||||
{ key: 'zhipu', label: '智谱 GLM', icon: <ExperimentOutlined />, desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] },
|
||||
{ key: 'moonshot', label: 'Kimi', icon: <ExperimentOutlined />, desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] },
|
||||
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] },
|
||||
{ key: 'gemini', label: 'Gemini', icon: <CloudOutlined />, desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] },
|
||||
{ key: 'volcengine', label: '火山引擎', icon: <CloudOutlined />, desc: '火山方舟 / 豆包大模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: 'ep-xxxxxx', models: [] },
|
||||
{ key: 'volcengine-ark', label: '火山方舟', icon: <CloudOutlined />, desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
|
||||
{ key: 'volcengine-coding', label: '火山 Coding Plan', icon: <CloudOutlined />, desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] },
|
||||
{ key: 'minimax', label: 'MiniMax', icon: <ExperimentOutlined />, desc: 'M2.7 / M2.5 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] },
|
||||
{ key: 'ollama', label: 'Ollama', icon: <AppstoreOutlined />, desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
|
||||
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
|
||||
@@ -41,23 +62,9 @@ const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
|
||||
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
|
||||
|
||||
const getProviderHostname = (raw?: string): string => {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return new URL(raw).hostname.toLowerCase();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl'>): ProviderPreset => {
|
||||
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 }[] = [
|
||||
@@ -83,6 +90,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
|
||||
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
|
||||
const [clearProviderSecret, setClearProviderSecret] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const modalBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -100,6 +108,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const watchedType = Form.useWatch('type', form);
|
||||
const watchedPresetKey = Form.useWatch('presetKey', form);
|
||||
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
|
||||
const watchedApiKeyInput = Form.useWatch('apiKey', form);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
@@ -126,28 +135,61 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
|
||||
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
|
||||
|
||||
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
|
||||
setEditingProvider(session.editingProvider as AIProviderConfig | null);
|
||||
setIsEditing(session.isEditing);
|
||||
setTestStatus(session.testStatus);
|
||||
setClearProviderSecret(session.clearProviderSecret);
|
||||
form.resetFields();
|
||||
if (session.formValues) {
|
||||
form.setFieldsValue(session.formValues);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
const resetProviderEditorSession = useCallback(() => {
|
||||
applyProviderEditorSession(buildClosedProviderEditorSession());
|
||||
}, [applyProviderEditorSession]);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
resetProviderEditorSession();
|
||||
onClose();
|
||||
}, [onClose, resetProviderEditorSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
resetProviderEditorSession();
|
||||
}
|
||||
}, [open, resetProviderEditorSession]);
|
||||
const handleAddProvider = () => {
|
||||
const preset = findPreset('openai');
|
||||
const newProvider: AIProviderConfig = {
|
||||
id: '', type: preset.backendType, name: '', apiKey: '',
|
||||
baseUrl: preset.defaultBaseUrl, model: preset.defaultModel,
|
||||
models: [], maxTokens: 4096, temperature: 0.7,
|
||||
};
|
||||
setEditingProvider({ ...newProvider, presetKey: 'openai' } as any);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ ...newProvider, presetKey: 'openai', apiFormat: 'openai' });
|
||||
applyProviderEditorSession(buildAddProviderEditorSession({
|
||||
presetKey: 'openai',
|
||||
presetBackendType: preset.backendType,
|
||||
presetBaseUrl: preset.defaultBaseUrl,
|
||||
presetModel: preset.defaultModel,
|
||||
presetModels: preset.models,
|
||||
apiFormat: 'openai',
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditProvider = (p: AIProviderConfig) => {
|
||||
// 尝试根据 baseUrl 和 type 推断 preset
|
||||
const matchedPreset = matchProviderPreset(p);
|
||||
setEditingProvider(p);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ ...p, type: matchedPreset.backendType, models: p.models || [], presetKey: matchedPreset.key, apiFormat: p.apiFormat || 'openai' });
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: matchedPreset.backendType,
|
||||
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||||
valuesApiFormat: p.apiFormat,
|
||||
});
|
||||
applyProviderEditorSession(buildEditProviderEditorSession({
|
||||
provider: { ...p, presetKey: matchedPreset.key } as any,
|
||||
formValues: {
|
||||
...p,
|
||||
type: resolvedTransport.type,
|
||||
models: p.models || [],
|
||||
presetKey: matchedPreset.key,
|
||||
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async (id: string) => {
|
||||
@@ -179,28 +221,48 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
|
||||
// 构建 payload,处理 model/models 逻辑
|
||||
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
|
||||
const preset = findPreset(values.presetKey);
|
||||
const resolvedModels = isCustomLike ? (values.models || []) : preset.models;
|
||||
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
|
||||
const finalModel = isCustomLike ? fallbackModel : (values.model || fallbackModel);
|
||||
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
|
||||
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||||
presetKey: values.presetKey,
|
||||
presetDefaultModel: preset.defaultModel,
|
||||
presetModels: preset.models,
|
||||
valuesModel: values.model,
|
||||
customModels: values.models,
|
||||
});
|
||||
// 内置供应商自动使用 preset label 作为名称
|
||||
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
|
||||
|
||||
const finalBaseUrl = values.baseUrl || preset.defaultBaseUrl;
|
||||
|
||||
const finalBaseUrl = resolvePresetBaseURL({
|
||||
presetKey: values.presetKey,
|
||||
presetDefaultBaseUrl: preset.defaultBaseUrl,
|
||||
valuesBaseUrl: values.baseUrl,
|
||||
});
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: preset.backendType,
|
||||
presetFixedApiFormat: preset.fixedApiFormat,
|
||||
valuesApiFormat: values.apiFormat,
|
||||
});
|
||||
const secretDraft = resolveProviderSecretDraft({
|
||||
hasSecret: editingProvider?.hasSecret,
|
||||
apiKeyInput: values.apiKey,
|
||||
clearSecret: clearProviderSecret,
|
||||
});
|
||||
const payload = {
|
||||
...editingProvider,
|
||||
...values,
|
||||
...resolvedTransport,
|
||||
name: finalName,
|
||||
apiKey: secretDraft.apiKey,
|
||||
hasSecret: secretDraft.hasSecret,
|
||||
model: finalModel,
|
||||
models: resolvedModels,
|
||||
baseUrl: finalBaseUrl,
|
||||
apiFormat: values.apiFormat || 'openai',
|
||||
apiFormat: resolvedTransport.apiFormat,
|
||||
};
|
||||
// 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常
|
||||
await Service?.AISaveProvider?.(payload);
|
||||
void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); void loadConfig();
|
||||
void messageApi.success('已保存'); resetProviderEditorSession(); void loadConfig();
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) { /* antd form validation error, ignore */ }
|
||||
@@ -240,8 +302,44 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
setTestStatus('idle');
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const preset = findPreset(values.presetKey || 'openai');
|
||||
const finalBaseUrl = values.baseUrl || preset.defaultBaseUrl;
|
||||
const res = await Service?.AITestProvider?.({ ...values, baseUrl: finalBaseUrl, maxTokens: Number(values.maxTokens) || 4096, temperature: Number(values.temperature) ?? 0.7 });
|
||||
const finalBaseUrl = resolvePresetBaseURL({
|
||||
presetKey: values.presetKey || 'openai',
|
||||
presetDefaultBaseUrl: preset.defaultBaseUrl,
|
||||
valuesBaseUrl: values.baseUrl,
|
||||
});
|
||||
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||||
presetKey: values.presetKey || 'openai',
|
||||
presetDefaultModel: preset.defaultModel,
|
||||
presetModels: preset.models,
|
||||
valuesModel: values.model,
|
||||
customModels: values.models,
|
||||
});
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: preset.backendType,
|
||||
presetFixedApiFormat: preset.fixedApiFormat,
|
||||
valuesApiFormat: values.apiFormat,
|
||||
});
|
||||
const secretDraft = resolveProviderSecretDraft({
|
||||
hasSecret: editingProvider?.hasSecret,
|
||||
apiKeyInput: values.apiKey,
|
||||
clearSecret: clearProviderSecret,
|
||||
});
|
||||
if (secretDraft.mode === 'clear') {
|
||||
throw new Error('测试连接前请填写新的 API Key,或取消清除已保存密钥');
|
||||
}
|
||||
const res = await Service?.AITestProvider?.({
|
||||
...editingProvider,
|
||||
...values,
|
||||
...resolvedTransport,
|
||||
apiKey: secretDraft.apiKey,
|
||||
hasSecret: secretDraft.hasSecret,
|
||||
baseUrl: finalBaseUrl,
|
||||
model: finalModel,
|
||||
models: resolvedModels,
|
||||
maxTokens: Number(values.maxTokens) || 4096,
|
||||
temperature: Number(values.temperature) ?? 0.7,
|
||||
apiFormat: resolvedTransport.apiFormat,
|
||||
});
|
||||
if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); }
|
||||
else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); }
|
||||
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); }
|
||||
@@ -250,9 +348,15 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
|
||||
const handlePresetChange = (presetKey: string) => {
|
||||
const preset = findPreset(presetKey);
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: preset.backendType,
|
||||
presetFixedApiFormat: preset.fixedApiFormat,
|
||||
valuesApiFormat: form.getFieldValue('apiFormat'),
|
||||
});
|
||||
form.setFieldsValue({
|
||||
presetKey,
|
||||
type: preset.backendType,
|
||||
type: resolvedTransport.type,
|
||||
apiFormat: resolvedTransport.apiFormat || 'openai',
|
||||
baseUrl: preset.defaultBaseUrl,
|
||||
model: preset.defaultModel,
|
||||
});
|
||||
@@ -307,7 +411,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{matchedPreset.label}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.model}</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.model || '未选择模型'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Space size={2}>
|
||||
@@ -339,7 +443,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<div>
|
||||
{/* 顶部返回 */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Button size="small" onClick={() => { setIsEditing(false); setEditingProvider(null); }}
|
||||
<Button size="small" onClick={resetProviderEditorSession}
|
||||
style={{ borderRadius: 8 }}>← 返回</Button>
|
||||
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
|
||||
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
|
||||
@@ -353,25 +457,24 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<AppstoreOutlined style={{ fontSize: 14 }} /> 服务类型
|
||||
</div>
|
||||
<Form.Item name="presetKey" noStyle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
|
||||
<div style={PROVIDER_PRESET_GRID_STYLE}>
|
||||
{PROVIDER_PRESETS.map(pt => (
|
||||
<div key={pt.key} onClick={() => { form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }}
|
||||
style={{
|
||||
padding: '12px 14px', borderRadius: 12, cursor: 'pointer', transition: 'all 0.2s ease',
|
||||
...PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||
border: `1.5px solid ${presetKeyFromForm === pt.key ? overlayTheme.selectedText : 'transparent'}`,
|
||||
background: presetKeyFromForm === pt.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
|
||||
boxShadow: presetKeyFromForm === pt.key ? 'none' : (darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)'),
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
color: presetKeyFromForm === pt.key ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease',
|
||||
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease', flexShrink: 0,
|
||||
}}>
|
||||
{pt.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, lineHeight: 1.4 }}>{pt.desc}</div>
|
||||
<div style={PROVIDER_PRESET_CARD_CONTENT_STYLE}>
|
||||
<div style={{ ...PROVIDER_PRESET_CARD_TITLE_STYLE, fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
|
||||
<div style={{ ...PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.4 }}>{pt.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -431,11 +534,25 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<div style={fieldLabelStyle}>
|
||||
<KeyOutlined style={{ fontSize: 14 }} /> 认证 & 连接
|
||||
</div>
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ required: true, message: '请输入 API Key' }]} style={{ marginBottom: 16 }}>
|
||||
<Input.Password placeholder="sk-... / 你的 API Key"
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || clearProviderSecret || editingProvider?.hasSecret) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: editingProvider?.hasSecret ? 8 : 16 }}>
|
||||
<Input.Password placeholder={editingProvider?.hasSecret ? '留空表示继续沿用已保存密钥' : 'sk-... / 你的 API Key'}
|
||||
size="middle"
|
||||
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
|
||||
</Form.Item>
|
||||
{editingProvider?.hasSecret && (
|
||||
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: cardBg }}>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
|
||||
当前已保存 API Key。留空表示继续沿用,输入新值表示替换。
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={clearProviderSecret}
|
||||
disabled={String(watchedApiKeyInput || '').trim() !== ''}
|
||||
onChange={(event) => setClearProviderSecret(event.target.checked)}
|
||||
>
|
||||
清除已保存 API Key
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
|
||||
@@ -638,7 +755,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
</div>
|
||||
}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onCancel={handleModalClose}
|
||||
footer={null}
|
||||
width={820}
|
||||
styles={{
|
||||
@@ -704,3 +821,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
};
|
||||
|
||||
export default AISettingsModal;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICO
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft';
|
||||
import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
|
||||
|
||||
@@ -17,6 +19,43 @@ const CONNECTION_MODAL_WIDTH = 960;
|
||||
const CONNECTION_MODAL_BODY_HEIGHT = 620;
|
||||
const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)';
|
||||
const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)';
|
||||
const noAutoCapInputProps = {
|
||||
autoCapitalize: 'none' as const,
|
||||
autoCorrect: 'off' as const,
|
||||
spellCheck: false,
|
||||
};
|
||||
|
||||
const applyNoAutoCapAttributes = (element: Element) => {
|
||||
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
|
||||
return;
|
||||
}
|
||||
element.setAttribute('autocapitalize', 'none');
|
||||
element.setAttribute('autocorrect', 'off');
|
||||
element.setAttribute('spellcheck', 'false');
|
||||
};
|
||||
|
||||
type ConnectionSecretKey =
|
||||
| 'primaryPassword'
|
||||
| 'sshPassword'
|
||||
| 'proxyPassword'
|
||||
| 'httpTunnelPassword'
|
||||
| 'mysqlReplicaPassword'
|
||||
| 'mongoReplicaPassword'
|
||||
| 'opaqueURI'
|
||||
| 'opaqueDSN';
|
||||
|
||||
type ConnectionSecretClearState = Record<ConnectionSecretKey, boolean>;
|
||||
|
||||
const createEmptyConnectionSecretClearState = (): ConnectionSecretClearState => ({
|
||||
primaryPassword: false,
|
||||
sshPassword: false,
|
||||
proxyPassword: false,
|
||||
httpTunnelPassword: false,
|
||||
mysqlReplicaPassword: false,
|
||||
mongoReplicaPassword: false,
|
||||
opaqueURI: false,
|
||||
opaqueDSN: false,
|
||||
});
|
||||
|
||||
const getDefaultPortByType = (type: string) => {
|
||||
switch (type) {
|
||||
@@ -122,6 +161,7 @@ const ConnectionModal: React.FC<{
|
||||
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
|
||||
const [selectingDbFile, setSelectingDbFile] = useState(false);
|
||||
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
|
||||
const [clearSecrets, setClearSecrets] = useState<ConnectionSecretClearState>(createEmptyConnectionSecretClearState);
|
||||
const testInFlightRef = useRef(false);
|
||||
const testTimerRef = useRef<number | null>(null);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
@@ -171,6 +211,23 @@ const ConnectionModal: React.FC<{
|
||||
border: darkMode ? '1px solid rgba(255, 255, 255, 0.16)' : '1px solid rgba(0, 0, 0, 0.06)',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const applyForConnectionModal = () => {
|
||||
document
|
||||
.querySelectorAll('.connection-modal-wrap input, .connection-modal-wrap textarea')
|
||||
.forEach(applyNoAutoCapAttributes);
|
||||
};
|
||||
applyForConnectionModal();
|
||||
const observer = new MutationObserver(() => {
|
||||
applyForConnectionModal();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
|
||||
const modalShellStyle = useMemo(() => ({
|
||||
background: overlayTheme.shellBg,
|
||||
@@ -192,6 +249,51 @@ const ConnectionModal: React.FC<{
|
||||
lineHeight: 1.6,
|
||||
}), [overlayTheme]);
|
||||
|
||||
const renderStoredSecretControls = ({
|
||||
fieldName,
|
||||
clearKey,
|
||||
hasStoredSecret,
|
||||
clearLabel,
|
||||
description,
|
||||
}: {
|
||||
fieldName: string;
|
||||
clearKey: ConnectionSecretKey;
|
||||
hasStoredSecret?: boolean;
|
||||
clearLabel: string;
|
||||
description: string;
|
||||
}) => {
|
||||
if (!initialValues || !hasStoredSecret) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev[fieldName] !== next[fieldName]}>
|
||||
{({ getFieldValue }) => {
|
||||
const draftValue = getFieldValue(fieldName);
|
||||
const hasDraftValue = String(draftValue ?? '') !== '';
|
||||
const cardBorder = darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(16,24,40,0.08)';
|
||||
const cardBg = darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)';
|
||||
const effectiveChecked = clearSecrets[clearKey] && !hasDraftValue;
|
||||
return (
|
||||
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: cardBorder, background: cardBg }}>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
|
||||
{hasDraftValue ? '已输入新值,保存时会替换当前已保存内容。' : description}
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={effectiveChecked}
|
||||
disabled={hasDraftValue}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
setClearSecrets((prev) => ({ ...prev, [clearKey]: checked }));
|
||||
}}
|
||||
>
|
||||
{clearLabel}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
|
||||
@@ -749,6 +851,19 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
});
|
||||
|
||||
const createCustomDsnRule = () => ({
|
||||
validator(_: unknown, value: unknown) {
|
||||
const validationMessage = getCustomConnectionDsnValidationMessage({
|
||||
dsnInput: value,
|
||||
hasStoredSecret: initialValues?.hasOpaqueDSN,
|
||||
clearStoredSecret: clearSecrets.opaqueDSN,
|
||||
});
|
||||
return validationMessage
|
||||
? Promise.reject(new Error(validationMessage))
|
||||
: Promise.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const getUriPlaceholder = () => {
|
||||
if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') {
|
||||
const defaultPort = getDefaultPortByType(dbType);
|
||||
@@ -1066,6 +1181,7 @@ const ConnectionModal: React.FC<{
|
||||
setUriFeedback(null);
|
||||
setCustomIconType(undefined);
|
||||
setCustomIconColor(undefined);
|
||||
setClearSecrets(createEmptyConnectionSecretClearState());
|
||||
setTypeSelectWarning(null);
|
||||
setDriverStatusLoaded(false);
|
||||
void refreshDriverStatus();
|
||||
@@ -1198,6 +1314,107 @@ const ConnectionModal: React.FC<{
|
||||
};
|
||||
}, []);
|
||||
|
||||
const buildSavedConnectionInput = (config: ConnectionConfig, values: any) => {
|
||||
const connectionId = initialValues?.id || config.id || Date.now().toString();
|
||||
const primaryDraft = resolveConnectionSecretDraft({
|
||||
hasSecret: initialValues?.hasPrimaryPassword,
|
||||
valueInput: config.password,
|
||||
clearSecret: clearSecrets.primaryPassword,
|
||||
forceClear: values.type === 'mongodb' && values.savePassword === false,
|
||||
});
|
||||
const sshDraft = resolveConnectionSecretDraft({
|
||||
hasSecret: initialValues?.hasSSHPassword,
|
||||
valueInput: config.ssh?.password,
|
||||
clearSecret: clearSecrets.sshPassword,
|
||||
forceClear: !config.useSSH,
|
||||
});
|
||||
const proxyDraft = resolveConnectionSecretDraft({
|
||||
hasSecret: initialValues?.hasProxyPassword,
|
||||
valueInput: config.proxy?.password,
|
||||
clearSecret: clearSecrets.proxyPassword,
|
||||
forceClear: !config.useProxy,
|
||||
});
|
||||
const httpTunnelDraft = resolveConnectionSecretDraft({
|
||||
hasSecret: initialValues?.hasHttpTunnelPassword,
|
||||
valueInput: config.httpTunnel?.password,
|
||||
clearSecret: clearSecrets.httpTunnelPassword,
|
||||
forceClear: !config.useHttpTunnel,
|
||||
});
|
||||
const mysqlReplicaEnabled = (config.type === 'mysql' || config.type === 'mariadb' || config.type === 'diros' || config.type === 'sphinx')
|
||||
&& config.topology === 'replica';
|
||||
const mysqlReplicaDraft = resolveConnectionSecretDraft({
|
||||
hasSecret: initialValues?.hasMySQLReplicaPassword,
|
||||
valueInput: config.mysqlReplicaPassword,
|
||||
clearSecret: clearSecrets.mysqlReplicaPassword,
|
||||
forceClear: !mysqlReplicaEnabled,
|
||||
});
|
||||
const mongoReplicaEnabled = config.type === 'mongodb'
|
||||
&& config.topology === 'replica'
|
||||
&& values.savePassword !== false;
|
||||
const mongoReplicaDraft = resolveConnectionSecretDraft({
|
||||
hasSecret: initialValues?.hasMongoReplicaPassword,
|
||||
valueInput: config.mongoReplicaPassword,
|
||||
clearSecret: clearSecrets.mongoReplicaPassword,
|
||||
forceClear: !mongoReplicaEnabled,
|
||||
});
|
||||
const opaqueUriDraft = resolveConnectionSecretDraft({
|
||||
hasSecret: initialValues?.hasOpaqueURI,
|
||||
valueInput: config.uri,
|
||||
clearSecret: clearSecrets.opaqueURI,
|
||||
forceClear: values.type === 'custom',
|
||||
trimInput: true,
|
||||
});
|
||||
const opaqueDsnDraft = resolveConnectionSecretDraft({
|
||||
hasSecret: initialValues?.hasOpaqueDSN,
|
||||
valueInput: config.dsn,
|
||||
clearSecret: clearSecrets.opaqueDSN,
|
||||
forceClear: values.type !== 'custom',
|
||||
trimInput: true,
|
||||
});
|
||||
const isRedisType = values.type === 'redis';
|
||||
const displayHost = String((config as any).host || values.host || '').trim();
|
||||
const nextName = values.name || (isFileDatabaseType(values.type)
|
||||
? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB')
|
||||
: (values.type === 'redis' ? `Redis ${displayHost}` : displayHost));
|
||||
|
||||
return {
|
||||
id: connectionId,
|
||||
name: nextName,
|
||||
config: {
|
||||
...config,
|
||||
id: connectionId,
|
||||
password: primaryDraft.value,
|
||||
ssh: {
|
||||
...(config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }),
|
||||
password: sshDraft.value,
|
||||
},
|
||||
proxy: {
|
||||
...(config.proxy || { type: 'socks5', host: '', port: 1080, user: '', password: '' }),
|
||||
password: proxyDraft.value,
|
||||
},
|
||||
httpTunnel: {
|
||||
...(config.httpTunnel || { host: '', port: 8080, user: '', password: '' }),
|
||||
password: httpTunnelDraft.value,
|
||||
},
|
||||
uri: opaqueUriDraft.value,
|
||||
dsn: opaqueDsnDraft.value,
|
||||
mysqlReplicaPassword: mysqlReplicaDraft.value,
|
||||
mongoReplicaPassword: mongoReplicaDraft.value,
|
||||
},
|
||||
includeDatabases: values.includeDatabases,
|
||||
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined,
|
||||
iconType: customIconType || '',
|
||||
iconColor: customIconColor || '',
|
||||
clearPrimaryPassword: primaryDraft.clearStoredSecret,
|
||||
clearSSHPassword: sshDraft.clearStoredSecret,
|
||||
clearProxyPassword: proxyDraft.clearStoredSecret,
|
||||
clearHttpTunnelPassword: httpTunnelDraft.clearStoredSecret,
|
||||
clearMySQLReplicaPassword: mysqlReplicaDraft.clearStoredSecret,
|
||||
clearMongoReplicaPassword: mongoReplicaDraft.clearStoredSecret,
|
||||
clearOpaqueURI: opaqueUriDraft.clearStoredSecret,
|
||||
clearOpaqueDSN: opaqueDsnDraft.clearStoredSecret,
|
||||
};
|
||||
};
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
@@ -1211,28 +1428,21 @@ const ConnectionModal: React.FC<{
|
||||
setLoading(true);
|
||||
|
||||
const config = await buildConfig(values, true);
|
||||
const displayHost = String((config as any).host || values.host || '').trim();
|
||||
|
||||
const isRedisType = values.type === 'redis';
|
||||
const newConn = {
|
||||
id: initialValues ? initialValues.id : Date.now().toString(),
|
||||
name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)),
|
||||
config: config,
|
||||
includeDatabases: values.includeDatabases,
|
||||
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined,
|
||||
iconType: customIconType,
|
||||
iconColor: customIconColor,
|
||||
};
|
||||
const payload = buildSavedConnectionInput(config, values);
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
const savedConnection = await backendApp?.SaveConnection?.(payload);
|
||||
if (!savedConnection) {
|
||||
throw new Error('保存连接失败:后端接口不可用');
|
||||
}
|
||||
|
||||
if (initialValues) {
|
||||
updateConnection(newConn);
|
||||
updateConnection(savedConnection);
|
||||
message.success('配置已更新(未连接)');
|
||||
} else {
|
||||
addConnection(newConn);
|
||||
addConnection(savedConnection);
|
||||
message.success('配置已保存(未连接)');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
form.resetFields();
|
||||
setUseSSL(false);
|
||||
setUseSSH(false);
|
||||
@@ -1240,8 +1450,11 @@ const ConnectionModal: React.FC<{
|
||||
setUseHttpTunnel(false);
|
||||
setDbType('mysql');
|
||||
setStep(1);
|
||||
setClearSecrets(createEmptyConnectionSecretClearState());
|
||||
onClose();
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -1271,6 +1484,30 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const getBlockingSecretClearMessage = (values: any): string | null => {
|
||||
if (clearSecrets.primaryPassword && values.type !== 'custom' && !isFileDatabaseType(values.type) && String(values.password ?? '') === '') {
|
||||
return '测试连接前请填写新的密码,或取消清除已保存密码';
|
||||
}
|
||||
if (clearSecrets.sshPassword && values.useSSH && String(values.sshPassword ?? '') === '') {
|
||||
return '测试连接前请填写新的 SSH 密码,或取消清除已保存 SSH 密码';
|
||||
}
|
||||
if (clearSecrets.proxyPassword && values.useProxy && !values.useHttpTunnel && String(values.proxyPassword ?? '') === '') {
|
||||
return '测试连接前请填写新的代理密码,或取消清除已保存代理密码';
|
||||
}
|
||||
if (clearSecrets.httpTunnelPassword && values.useHttpTunnel && String(values.httpTunnelPassword ?? '') === '') {
|
||||
return '测试连接前请填写新的隧道密码,或取消清除已保存隧道密码';
|
||||
}
|
||||
if (clearSecrets.mysqlReplicaPassword && (values.type === 'mysql' || values.type === 'mariadb' || values.type === 'diros' || values.type === 'sphinx') && values.mysqlTopology === 'replica' && String(values.mysqlReplicaPassword ?? '') === '') {
|
||||
return '测试连接前请填写新的从库密码,或取消清除已保存从库密码';
|
||||
}
|
||||
if (clearSecrets.mongoReplicaPassword && values.type === 'mongodb' && values.mongoTopology === 'replica' && String(values.mongoReplicaPassword ?? '') === '') {
|
||||
return '测试连接前请填写新的副本集密码,或取消清除已保存副本集密码';
|
||||
}
|
||||
if (values.type === 'mongodb' && values.savePassword === false && initialValues?.hasPrimaryPassword && String(values.password ?? '') === '') {
|
||||
return '测试连接前请填写新的 MongoDB 密码,或重新勾选保存密码';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
|
||||
const text = String(reason ?? '').trim();
|
||||
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
|
||||
@@ -1290,9 +1527,17 @@ const ConnectionModal: React.FC<{
|
||||
promptInstallDriver(values.type, unavailableReason);
|
||||
return;
|
||||
}
|
||||
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
|
||||
if (blockingSecretClearMessage) {
|
||||
setTestResult({ type: 'error', message: blockingSecretClearMessage });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setTestResult(null);
|
||||
const config = await buildConfig(values, false);
|
||||
if (initialValues?.id) {
|
||||
config.id = initialValues.id;
|
||||
}
|
||||
const timeoutSecondsRaw = Number(values.timeout);
|
||||
const timeoutSeconds = Number.isFinite(timeoutSecondsRaw) && timeoutSecondsRaw > 0
|
||||
? Math.min(timeoutSecondsRaw, MAX_TIMEOUT_SECONDS)
|
||||
@@ -1368,7 +1613,15 @@ const ConnectionModal: React.FC<{
|
||||
await form.validateFields();
|
||||
const values = form.getFieldsValue(true);
|
||||
setDiscoveringMembers(true);
|
||||
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
|
||||
if (blockingSecretClearMessage) {
|
||||
message.error(blockingSecretClearMessage);
|
||||
return;
|
||||
}
|
||||
const config = await buildConfig(values, false);
|
||||
if (initialValues?.id) {
|
||||
config.id = initialValues.id;
|
||||
}
|
||||
const result = await MongoDiscoverMembers(config as any);
|
||||
if (!result.success) {
|
||||
message.error(result.message || '成员发现失败');
|
||||
@@ -1850,7 +2103,7 @@ const ConnectionModal: React.FC<{
|
||||
<div style={{ ...modalMutedTextStyle, marginBottom: 16 }}>常用参数集中在左侧,优先完成连接建立所需的最小输入。</div>
|
||||
|
||||
<Form.Item name="name" label="连接名称">
|
||||
<Input placeholder="例如:本地测试库" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如:本地测试库" />
|
||||
</Form.Item>
|
||||
|
||||
{!isCustom && (
|
||||
@@ -1860,7 +2113,7 @@ const ConnectionModal: React.FC<{
|
||||
label="连接 URI(可复制粘贴)"
|
||||
help="支持从参数生成、复制到剪贴板,或粘贴后一键解析回填参数"
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder={getUriPlaceholder()} />
|
||||
<Input.TextArea {...noAutoCapInputProps} rows={3} placeholder={getUriPlaceholder()} />
|
||||
</Form.Item>
|
||||
<Space size={8} style={{ marginBottom: uriFeedback ? 12 : 16 }} wrap>
|
||||
<Button onClick={handleGenerateURI}>生成 URI</Button>
|
||||
@@ -1877,17 +2130,31 @@ const ConnectionModal: React.FC<{
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'uri',
|
||||
clearKey: 'opaqueURI',
|
||||
hasStoredSecret: initialValues?.hasOpaqueURI,
|
||||
clearLabel: '清除已保存 URI',
|
||||
description: '当前已保存连接 URI。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCustom ? (
|
||||
<>
|
||||
<Form.Item name="driver" label="驱动名称 (Driver Name)" rules={[{ required: true, message: '请输入驱动名称' }]} help="已支持: mysql, postgres, sqlite, oracle, dm, kingbase">
|
||||
<Input placeholder="例如: mysql, postgres" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如: mysql, postgres" />
|
||||
</Form.Item>
|
||||
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[{ required: true, message: '请输入连接字符串' }]}>
|
||||
<Input.TextArea rows={4} placeholder="例如: user:pass@tcp(localhost:3306)/dbname?charset=utf8" />
|
||||
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[createCustomDsnRule()]}>
|
||||
<Input.TextArea {...noAutoCapInputProps} rows={4} placeholder="例如: user:pass@tcp(localhost:3306)/dbname?charset=utf8" />
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'dsn',
|
||||
clearKey: 'opaqueDSN',
|
||||
hasStoredSecret: initialValues?.hasOpaqueDSN,
|
||||
clearLabel: '清除已保存 DSN',
|
||||
description: '当前已保存连接字符串。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -1899,6 +2166,7 @@ const ConnectionModal: React.FC<{
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={isFileDb ? (dbType === 'duckdb' ? '/path/to/db.duckdb' : '/path/to/db.sqlite') : 'localhost'}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -1926,7 +2194,7 @@ const ConnectionModal: React.FC<{
|
||||
label="默认连接数据库(可选)"
|
||||
help="留空会自动尝试 postgres、template1、与当前用户名同名数据库"
|
||||
>
|
||||
<Input placeholder="例如:appdb" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如:appdb" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -1937,7 +2205,7 @@ const ConnectionModal: React.FC<{
|
||||
rules={[createUriAwareRequiredRule('请输入 Oracle 服务名(例如 ORCLPDB1)')]}
|
||||
help="请填写监听器注册的 SERVICE_NAME(不是用户名)。例如:ORCLPDB1"
|
||||
>
|
||||
<Input placeholder="例如:ORCLPDB1" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如:ORCLPDB1" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -1962,12 +2230,19 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
|
||||
<Form.Item name="mysqlReplicaUser" label="从库用户名(可选)" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="留空沿用主库用户名" />
|
||||
<Input {...noAutoCapInputProps} placeholder="留空沿用主库用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="mysqlReplicaPassword" label="从库密码(可选)" style={{ marginBottom: 0 }}>
|
||||
<Input.Password placeholder="留空沿用主库密码" />
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主库密码" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'mysqlReplicaPassword',
|
||||
clearKey: 'mysqlReplicaPassword',
|
||||
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
|
||||
clearLabel: '清除已保存从库密码',
|
||||
description: '当前已保存从库密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -2001,15 +2276,22 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
|
||||
<Form.Item name="mongoReplicaSet" label="副本集名称(可选)" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="例如:rs0" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如:rs0" />
|
||||
</Form.Item>
|
||||
<Form.Item name="mongoReplicaUser" label="副本集用户名(可选)" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="留空沿用主用户名" />
|
||||
<Input {...noAutoCapInputProps} placeholder="留空沿用主用户名" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="mongoReplicaPassword" label="副本集密码(可选)" style={{ marginBottom: 0 }}>
|
||||
<Input.Password placeholder="留空沿用主密码" />
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主密码" />
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'mongoReplicaPassword',
|
||||
clearKey: 'mongoReplicaPassword',
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
clearLabel: '清除已保存副本集密码',
|
||||
description: '当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
<Space size={8} style={{ marginTop: 12, marginBottom: 12 }}>
|
||||
<Button onClick={handleDiscoverMongoMembers} loading={discoveringMembers}>自动发现成员</Button>
|
||||
</Space>
|
||||
@@ -2045,7 +2327,7 @@ const ConnectionModal: React.FC<{
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
|
||||
<Form.Item name="mongoAuthSource" label="认证库 (authSource)" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="默认使用 database 或 admin" />
|
||||
<Input {...noAutoCapInputProps} placeholder="默认使用 database 或 admin" />
|
||||
</Form.Item>
|
||||
<Form.Item name="mongoReadPreference" label="读偏好 (readPreference)" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
@@ -2082,8 +2364,15 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="password" label="密码 (可选)">
|
||||
<Input.Password placeholder="Redis 密码(如果设置了 requirepass)" />
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="Redis 密码(如果设置了 requirepass)" />
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'password',
|
||||
clearKey: 'primaryPassword',
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
clearLabel: '清除已保存密码',
|
||||
description: '当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
<Form.Item
|
||||
name="includeRedisDatabases"
|
||||
label="显示数据库 (留空显示全部)"
|
||||
@@ -2097,17 +2386,18 @@ const ConnectionModal: React.FC<{
|
||||
)}
|
||||
|
||||
{!isFileDb && !isRedis && (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: dbType === 'mongodb' ? 'minmax(0, 1fr) minmax(0, 1fr) 180px' : 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="user"
|
||||
label="用户名"
|
||||
rules={[createUriAwareRequiredRule('请输入用户名')]}
|
||||
rules={dbType === 'mongodb' ? [] : [createUriAwareRequiredRule('请输入用户名')]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input />
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" style={{ marginBottom: 0 }}>
|
||||
<Input.Password />
|
||||
<Input.Password {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
{dbType === 'mongodb' && (
|
||||
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ marginBottom: 0 }}>
|
||||
@@ -2115,6 +2405,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' },
|
||||
@@ -2123,6 +2414,14 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'password',
|
||||
clearKey: 'primaryPassword',
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
clearLabel: '清除已保存密码',
|
||||
description: '当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{dbType === 'mongodb' && (
|
||||
@@ -2182,10 +2481,10 @@ const ConnectionModal: React.FC<{
|
||||
{dbType === 'dameng' && (
|
||||
<>
|
||||
<Form.Item name="sslCertPath" label="客户端证书路径 (SSL_CERT_PATH)" rules={[{ required: true, message: '达梦 SSL 需要证书路径' }]} style={{ marginBottom: 8 }}>
|
||||
<Input placeholder="例如: C:\certs\client-cert.pem" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如: C:\certs\client-cert.pem" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sslKeyPath" label="客户端私钥路径 (SSL_KEY_PATH)" rules={[{ required: true, message: '达梦 SSL 需要私钥路径' }]} style={{ marginBottom: 8 }}>
|
||||
<Input placeholder="例如: C:\certs\client-key.pem" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如: C:\certs\client-key.pem" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
@@ -2208,7 +2507,7 @@ const ConnectionModal: React.FC<{
|
||||
<div style={tunnelSectionStyle}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 120px', gap: 16 }}>
|
||||
<Form.Item name="sshHost" label="SSH 主机 (域名或IP)" rules={[{ required: useSSH, message: '请输入SSH主机' }]} style={{ flex: 1 }}>
|
||||
<Input placeholder="例如: ssh.example.com 或 192.168.1.100" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如: ssh.example.com 或 192.168.1.100" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sshPort" label="端口" rules={[{ required: useSSH, message: '请输入SSH端口' }]} style={{ width: 100 }}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
@@ -2216,22 +2515,29 @@ const ConnectionModal: React.FC<{
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
|
||||
<Form.Item name="sshUser" label="SSH 用户" rules={[{ required: useSSH, message: '请输入SSH用户' }]} style={{ flex: 1 }}>
|
||||
<Input placeholder="root" />
|
||||
<Input {...noAutoCapInputProps} placeholder="root" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sshPassword" label="SSH 密码" style={{ flex: 1 }}>
|
||||
<Input.Password placeholder="密码" />
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="密码" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name="sshKeyPath" noStyle>
|
||||
<Input placeholder="绝对路径" />
|
||||
<Input {...noAutoCapInputProps} placeholder="绝对路径" />
|
||||
</Form.Item>
|
||||
<Button onClick={handleSelectSSHKeyFile} loading={selectingSSHKey}>
|
||||
浏览...
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'sshPassword',
|
||||
clearKey: 'sshPassword',
|
||||
hasStoredSecret: initialValues?.hasSSHPassword,
|
||||
clearLabel: '清除已保存 SSH 密码',
|
||||
description: '当前已保存 SSH 密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2249,7 +2555,7 @@ const ConnectionModal: React.FC<{
|
||||
) : (
|
||||
<div style={tunnelSectionStyle}>
|
||||
<Form.Item name="proxyHost" label="代理主机" rules={[{ required: useProxy, message: '请输入代理主机' }]}>
|
||||
<Input placeholder="例如: 127.0.0.1 或 proxy.company.com" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如: 127.0.0.1 或 proxy.company.com" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 120px', gap: 16 }}>
|
||||
<Form.Item name="proxyType" label="代理类型" rules={[{ required: useProxy, message: '请选择代理类型' }]} style={{ marginBottom: 0 }}>
|
||||
@@ -2264,12 +2570,19 @@ const ConnectionModal: React.FC<{
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
|
||||
<Form.Item name="proxyUser" label="代理用户名(可选)" style={{ flex: 1 }}>
|
||||
<Input placeholder="留空表示无认证" />
|
||||
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
|
||||
<Input.Password placeholder="留空表示无认证" />
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'proxyPassword',
|
||||
clearKey: 'proxyPassword',
|
||||
hasStoredSecret: initialValues?.hasProxyPassword,
|
||||
clearLabel: '清除已保存代理密码',
|
||||
description: '当前已保存代理密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2287,7 +2600,7 @@ const ConnectionModal: React.FC<{
|
||||
<div style={tunnelSectionStyle}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 120px', gap: 16 }}>
|
||||
<Form.Item name="httpTunnelHost" label="隧道主机" rules={[{ required: useHttpTunnel, message: '请输入隧道主机' }]} style={{ flex: 1 }}>
|
||||
<Input placeholder="例如: tunnel.company.com 或 127.0.0.1" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如: tunnel.company.com 或 127.0.0.1" />
|
||||
</Form.Item>
|
||||
<Form.Item name="httpTunnelPort" label="端口" rules={[{ required: useHttpTunnel, message: '请输入隧道端口' }]} style={{ width: 120 }}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||
@@ -2295,12 +2608,19 @@ const ConnectionModal: React.FC<{
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
|
||||
<Form.Item name="httpTunnelUser" label="隧道用户名(可选)" style={{ flex: 1 }}>
|
||||
<Input placeholder="留空表示无认证" />
|
||||
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
<Form.Item name="httpTunnelPassword" label="隧道密码(可选)" style={{ flex: 1 }}>
|
||||
<Input.Password placeholder="留空表示无认证" />
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'httpTunnelPassword',
|
||||
clearKey: 'httpTunnelPassword',
|
||||
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
|
||||
clearLabel: '清除已保存隧道密码',
|
||||
description: '当前已保存隧道密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>与“使用代理”互斥,启用后将通过 HTTP CONNECT 建立独立隧道。</Text>
|
||||
</div>
|
||||
)}
|
||||
@@ -2503,7 +2823,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item name="type" hidden><Input /></Form.Item>
|
||||
<Form.Item name="type" hidden><Input {...noAutoCapInputProps} /></Form.Item>
|
||||
{currentDriverUnavailableReason && (
|
||||
<Alert
|
||||
showIcon
|
||||
@@ -2831,3 +3151,7 @@ const ConnectionModal: React.FC<{
|
||||
};
|
||||
|
||||
export default ConnectionModal;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview
|
||||
import { SavedConnection } from '../types';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
@@ -74,7 +76,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 +91,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 +119,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 +140,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 +156,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 +169,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()])};`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -215,14 +237,11 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const logBoxRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
const normalizeConnConfig = (conn: SavedConnection, database?: string) => ({
|
||||
...conn.config,
|
||||
port: Number((conn.config as any).port),
|
||||
password: conn.config.password || "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
|
||||
database: typeof database === 'string' ? database : (conn.config.database || ""),
|
||||
});
|
||||
const normalizeConnConfig = (conn: SavedConnection, database?: string) => (
|
||||
buildRpcConnectionConfig(conn.config, {
|
||||
database: typeof database === 'string' ? database : (conn.config.database || ''),
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -521,22 +540,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
});
|
||||
|
||||
const config = {
|
||||
sourceConfig: {
|
||||
...sConn.config,
|
||||
port: Number((sConn.config as any).port),
|
||||
password: sConn.config.password || "",
|
||||
useSSH: sConn.config.useSSH || false,
|
||||
ssh: sConn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
|
||||
database: sourceDb,
|
||||
},
|
||||
targetConfig: {
|
||||
...tConn.config,
|
||||
port: Number((tConn.config as any).port),
|
||||
password: tConn.config.password || "",
|
||||
useSSH: tConn.config.useSSH || false,
|
||||
ssh: tConn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
|
||||
database: targetDb,
|
||||
},
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
tables: selectedTables,
|
||||
content: syncContent,
|
||||
mode: syncMode,
|
||||
|
||||
@@ -6,7 +6,10 @@ 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';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
type ViewerPaginationState = {
|
||||
current: number;
|
||||
@@ -14,6 +17,7 @@ type ViewerPaginationState = {
|
||||
total: number;
|
||||
totalKnown: boolean;
|
||||
totalApprox: boolean;
|
||||
approximateTotal?: number;
|
||||
totalCountLoading: boolean;
|
||||
totalCountCancelled: boolean;
|
||||
};
|
||||
@@ -70,30 +74,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 +181,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 +194,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 +210,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 +229,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 +273,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 +283,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 +291,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total: 0,
|
||||
totalKnown: false,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
@@ -317,10 +305,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;
|
||||
@@ -335,13 +320,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const countSeq = ++manualCountSeqRef.current;
|
||||
const countStart = Date.now();
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: true, totalCountCancelled: false }));
|
||||
const countConfig: any = { ...(config as any), timeout: 120 };
|
||||
const countConfig = buildRpcConnectionConfig(config, { timeout: 120 });
|
||||
|
||||
try {
|
||||
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 +360,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
@@ -386,7 +372,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 +424,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';
|
||||
@@ -485,7 +479,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, querySql);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, querySql);
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-data`,
|
||||
timestamp: Date.now(),
|
||||
@@ -521,7 +515,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
let safeSelect = duckdbSafeSelectCacheRef.current[cacheKey] || '';
|
||||
if (!safeSelect) {
|
||||
try {
|
||||
const resCols = await DBGetColumns(config as any, dbName, tableName);
|
||||
const resCols = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName);
|
||||
if (resCols?.success && Array.isArray(resCols.data)) {
|
||||
const columnDefs = resCols.data as ColumnDefinition[];
|
||||
const selectParts = columnDefs.map((col) => {
|
||||
@@ -574,7 +568,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
if (pkKeyRef.current !== pkKey) {
|
||||
pkKeyRef.current = pkKey;
|
||||
const pkSeq = ++pkSeqRef.current;
|
||||
DBGetColumns(config as any, dbName, tableName)
|
||||
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName)
|
||||
.then((resCols: any) => {
|
||||
if (pkSeqRef.current !== pkSeq) return;
|
||||
if (pkKeyRef.current !== pkKey) return;
|
||||
@@ -632,6 +626,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total: derivedTotal,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
@@ -647,13 +642,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 +667,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;
|
||||
@@ -678,7 +681,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const countStart = Date.now();
|
||||
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
|
||||
// DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT。
|
||||
const countConfig: any = { ...(config as any), timeout: 5 };
|
||||
const countConfig = buildRpcConnectionConfig(config, { timeout: 5 });
|
||||
|
||||
DBQuery(countConfig, dbName, countSql)
|
||||
.then((resCount: any) => {
|
||||
@@ -695,7 +698,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 +711,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
@@ -720,48 +724,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 = buildRpcConnectionConfig(config, { 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 = buildRpcConnectionConfig(config, { 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 +824,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 +872,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 +903,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}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const getDbDefaultColor = (type: string): string =>
|
||||
|
||||
const BRAND_SVG_TYPES = new Set([
|
||||
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite',
|
||||
'diros', 'sphinx', 'duckdb',
|
||||
'diros', 'sphinx', 'duckdb', 'sqlserver',
|
||||
]);
|
||||
|
||||
/** 品牌 SVG 图标:用 <img> 加载 /db-icons/*.svg */
|
||||
@@ -110,7 +110,7 @@ const OracleIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oracle} label="Or" />
|
||||
);
|
||||
const SQLServerIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sqlserver} label="SS" />
|
||||
<BrandSvgIcon type="sqlserver" size={size} color={color} />
|
||||
);
|
||||
const DorisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="diros" size={size} color={color} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Spin, Alert } from 'antd';
|
||||
import { TabData } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery } from '../../wailsjs/go/app/App';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
interface DefinitionViewerProps {
|
||||
tab: TabData;
|
||||
@@ -201,7 +202,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
const sql = String(query || '').trim();
|
||||
if (!sql) continue;
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, sql);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
|
||||
if (!result.success || !Array.isArray(result.data)) {
|
||||
lastMessage = result.message || lastMessage;
|
||||
continue;
|
||||
@@ -227,7 +228,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
];
|
||||
for (const query of candidates) {
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, query);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
|
||||
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GetDriverVersionPackageSize,
|
||||
GetDriverStatusList,
|
||||
InstallLocalDriverPackage,
|
||||
OpenDriverDownloadDirectory,
|
||||
RemoveDriverPackage,
|
||||
SelectDriverPackageDirectory,
|
||||
SelectDriverPackageFile,
|
||||
@@ -757,6 +758,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
};
|
||||
}, [appendOperationLog, open]);
|
||||
|
||||
const resolveLocalImportVersion = useCallback((row: DriverStatusRow) => {
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectedOption =
|
||||
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
options.find((item) => item.recommended) ||
|
||||
options[0];
|
||||
return selectedOption?.version || row.pinnedVersion || '';
|
||||
}, [selectedVersionMap, versionMap]);
|
||||
|
||||
const installDriver = useCallback(async (row: DriverStatusRow) => {
|
||||
setActionState({ driverType: row.type, kind: 'install' });
|
||||
setProgressMap((prev) => ({
|
||||
@@ -820,9 +831,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
percent: 0,
|
||||
},
|
||||
}));
|
||||
appendOperationLog(row.type, `[START] 开始本地导入(${sourceLabel}):${pathText}`);
|
||||
const selectedVersion = resolveLocalImportVersion(row);
|
||||
const versionTip = selectedVersion ? `(${selectedVersion})` : '';
|
||||
appendOperationLog(row.type, `[START] 开始本地导入${versionTip}(${sourceLabel}):${pathText}`);
|
||||
try {
|
||||
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir);
|
||||
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir, selectedVersion);
|
||||
if (!result?.success) {
|
||||
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
|
||||
appendOperationLog(row.type, `[ERROR] ${errText}`);
|
||||
@@ -831,9 +844,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
}
|
||||
return false;
|
||||
}
|
||||
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
|
||||
appendOperationLog(row.type, `[DONE] 本地导入安装完成 ${versionTip}`.trim());
|
||||
if (!options?.silentToast) {
|
||||
message.success(`${row.name} 本地驱动包已安装启用`);
|
||||
message.success(`${row.name}${versionTip} 本地驱动包已安装启用`);
|
||||
}
|
||||
if (!options?.skipRefresh) {
|
||||
await refreshStatus(false);
|
||||
@@ -842,7 +855,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
} finally {
|
||||
setActionState({ driverType: '', kind: '' });
|
||||
}
|
||||
}, [appendOperationLog, downloadDir, refreshStatus]);
|
||||
}, [appendOperationLog, downloadDir, refreshStatus, resolveLocalImportVersion]);
|
||||
|
||||
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
|
||||
const fileRes = await SelectDriverPackageFile(downloadDir);
|
||||
@@ -936,6 +949,18 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
message.error(`目录导入失败${forceTip}:失败 ${failCount}${skipTip}`);
|
||||
}, [appendOperationLog, downloadDir, forceOverwriteInstalled, installDriverFromLocalPath, refreshStatus, rows]);
|
||||
|
||||
const openDriverDirectory = useCallback(async () => {
|
||||
try {
|
||||
const res = await OpenDriverDownloadDirectory(downloadDir);
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.message || '打开驱动目录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
|
||||
message.error(`打开驱动目录失败: ${errMsg}`);
|
||||
}
|
||||
}, [downloadDir]);
|
||||
|
||||
const openDriverLog = useCallback((driverType: string) => {
|
||||
const normalized = String(driverType || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
@@ -1067,29 +1092,35 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectOptions = buildVersionSelectOptions(options);
|
||||
const mongoHint = row.type === 'mongodb'
|
||||
? '当前仅支持 MongoDB 1.17.x 和 2.x;更老 1.x 暂不提供安装。'
|
||||
: '';
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
loading={!!versionLoadingMap[row.type]}
|
||||
disabled={actionState.driverType === row.type}
|
||||
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
|
||||
value={selectedKey}
|
||||
options={selectOptions as any}
|
||||
onOpenChange={(open) => {
|
||||
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
|
||||
void loadVersionOptions(row, true);
|
||||
return;
|
||||
}
|
||||
if (open && selectedKey) {
|
||||
void loadVersionPackageSize(row, selectedKey);
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
|
||||
void loadVersionPackageSize(row, value);
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
loading={!!versionLoadingMap[row.type]}
|
||||
disabled={actionState.driverType === row.type}
|
||||
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
|
||||
value={selectedKey}
|
||||
options={selectOptions as any}
|
||||
onOpenChange={(open) => {
|
||||
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
|
||||
void loadVersionOptions(row, true);
|
||||
return;
|
||||
}
|
||||
if (open && selectedKey) {
|
||||
void loadVersionPackageSize(row, selectedKey);
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
|
||||
void loadVersionPackageSize(row, value);
|
||||
}}
|
||||
/>
|
||||
{mongoHint ? <Text type="secondary" style={{ fontSize: 12 }}>{mongoHint}</Text> : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -1342,10 +1373,14 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
children: (
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary">自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。</Text>
|
||||
<Text type="secondary">如果应用内下载链路失败,可先手动下载驱动包到该目录,再使用“本地导入”或“导入驱动目录”完成安装。</Text>
|
||||
<Text type="secondary">行内“本地导入”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`);批量导入请使用上方“导入驱动目录”。</Text>
|
||||
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
|
||||
驱动根目录:{downloadDir || '-'}
|
||||
</Paragraph>
|
||||
<Button icon={<FolderOpenOutlined />} onClick={() => void openDriverDirectory()}>
|
||||
打开驱动目录
|
||||
</Button>
|
||||
{networkStatus?.logPath ? (
|
||||
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
|
||||
运行日志文件:{networkStatus.logPath}
|
||||
@@ -1374,6 +1409,12 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
onChange={(checked) => setForceOverwriteInstalled(checked)}
|
||||
disabled={batchDirectoryImporting}
|
||||
/>
|
||||
<Button
|
||||
icon={<FolderOpenOutlined />}
|
||||
onClick={() => void openDriverDirectory()}
|
||||
>
|
||||
打开驱动目录
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FolderOpenOutlined />}
|
||||
loading={batchDirectoryImporting}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DBQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/app/App'
|
||||
import { quoteIdentPart, escapeLiteral } from '../utils/sql';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
interface FindInDatabaseModalProps {
|
||||
open: boolean;
|
||||
@@ -106,7 +107,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
|
||||
try {
|
||||
// 1. 获取所有表
|
||||
const tablesRes = await DBGetTables(config as any, dbName);
|
||||
const tablesRes = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
|
||||
if (!tablesRes.success) {
|
||||
message.error('获取表列表失败: ' + tablesRes.message);
|
||||
setSearching(false);
|
||||
@@ -124,7 +125,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
setProgress({ current: 0, total: tableNames.length, tableName: '' });
|
||||
|
||||
// 2. 获取所有列信息(返回 any[],含 tableName/name/type 字段)
|
||||
const allColsRes = await DBGetAllColumns(config as any, dbName);
|
||||
const allColsRes = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName);
|
||||
const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : [];
|
||||
|
||||
// 按表名分组
|
||||
@@ -166,7 +167,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
const sql = buildLimitedSelectSQL(dbType, baseSql, MAX_MATCH_ROWS_PER_TABLE);
|
||||
|
||||
try {
|
||||
const res = await DBQuery(config as any, dbName, sql);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
|
||||
if (res.success && Array.isArray(res.data) && res.data.length > 0) {
|
||||
// 检查哪些列实际匹配了
|
||||
const matchedCols = new Set<string>();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App';
|
||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
|
||||
import { useStore } from '../store';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
interface ImportPreviewModalProps {
|
||||
visible: boolean;
|
||||
@@ -107,7 +108,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
|
||||
};
|
||||
|
||||
const res = await ImportDataWithProgress(config as any, dbName, tableName, filePath);
|
||||
const res = await ImportDataWithProgress(buildRpcConnectionConfig(config) as any, dbName, tableName, filePath);
|
||||
|
||||
if (res.success && res.data) {
|
||||
setImportResult(res.data);
|
||||
|
||||
@@ -11,6 +11,7 @@ import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
|
||||
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
const SQL_KEYWORDS = [
|
||||
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
|
||||
@@ -183,7 +184,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 = {
|
||||
@@ -336,7 +337,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await DBGetDatabases(config as any);
|
||||
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
let dbs = res.data.map((row: any) => row.Database || row.database);
|
||||
|
||||
@@ -392,7 +393,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
for (const dbName of visibleDbs) {
|
||||
// 获取表
|
||||
const resTables = await DBGetTables(config as any, dbName);
|
||||
const resTables = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
|
||||
if (resTables.success && Array.isArray(resTables.data)) {
|
||||
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
|
||||
tableNames.forEach((tableName: string) => {
|
||||
@@ -401,7 +402,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
|
||||
// 获取列 (所有数据库类型都支持 DBGetAllColumns)
|
||||
const resCols = await DBGetAllColumns(config as any, dbName);
|
||||
const resCols = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName);
|
||||
if (resCols.success && Array.isArray(resCols.data)) {
|
||||
resCols.data.forEach((col: any) => {
|
||||
allColumns.push({
|
||||
@@ -577,7 +578,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const config = buildConnConfig();
|
||||
if (!config) return [] as ColumnDefinition[];
|
||||
|
||||
const res = await DBGetColumns(config as any, dbName, tableIdent);
|
||||
const res = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableIdent);
|
||||
if (res?.success && Array.isArray(res.data)) {
|
||||
const cols = res.data as ColumnDefinition[];
|
||||
sharedColumnsCacheData[key] = cols;
|
||||
@@ -716,11 +717,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 +779,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 +797,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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1510,7 +1556,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
} catch {
|
||||
queryId = 'reload-' + Date.now();
|
||||
}
|
||||
const res = await DBQueryMulti(config as any, currentDb, sql, queryId);
|
||||
const res = await DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, sql, queryId);
|
||||
if (!res?.success) {
|
||||
message.error('刷新失败: ' + (res?.message || '未知错误'));
|
||||
return;
|
||||
@@ -1586,18 +1632,19 @@ 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 {
|
||||
const rawSQL = getSelectedSQL() || currentQuery;
|
||||
const dbType = String((config as any).type || 'mysql');
|
||||
const dbType = String((buildRpcConnectionConfig(config) as any).type || 'mysql');
|
||||
const normalizedDbType = dbType.trim().toLowerCase();
|
||||
const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';');
|
||||
|
||||
@@ -1648,7 +1695,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
setQueryId(queryId);
|
||||
|
||||
const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId);
|
||||
const res = await DBQueryWithCancel(buildRpcConnectionConfig(config) as any, currentDb, executedSql, queryId);
|
||||
const duration = Date.now() - startTime;
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-query-${idx + 1}`,
|
||||
@@ -1749,7 +1796,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
setQueryId(queryId);
|
||||
|
||||
const res = await DBQueryMulti(config as any, currentDb, fullSQL, queryId);
|
||||
const res = await DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, fullSQL, queryId);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
addSqlLog({
|
||||
@@ -1842,8 +1889,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) {
|
||||
@@ -1871,7 +1922,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
setActiveResultKey(nextResultSets[0]?.key || '');
|
||||
|
||||
pendingPk.forEach(({ resultKey, tableName }) => {
|
||||
DBGetColumns(config as any, currentDb, tableName)
|
||||
DBGetColumns(buildRpcConnectionConfig(config) as any, currentDb, tableName)
|
||||
.then((resCols: any) => {
|
||||
if (runSeqRef.current !== runSeq) return;
|
||||
if (!resCols?.success) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
|
||||
interface RedisCommandEditorProps {
|
||||
@@ -14,6 +15,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 +85,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 +106,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(buildRpcConnectionConfig(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 +280,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 +338,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>
|
||||
);
|
||||
|
||||
379
frontend/src/components/RedisMonitor.tsx
Normal file
379
frontend/src/components/RedisMonitor.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
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 { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
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 = buildRpcConnectionConfig(connection.config, { redisDB });
|
||||
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;
|
||||
@@ -7,6 +7,7 @@ import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
applyRenamedRedisKeyState,
|
||||
applyTreeNodeCheck,
|
||||
@@ -429,7 +430,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisScanKeys(config, normalizedPattern, fromCursor, effectiveTargetCount);
|
||||
const res = await (window as any).go.app.App.RedisScanKeys(buildRpcConnectionConfig(config), normalizedPattern, fromCursor, effectiveTargetCount);
|
||||
if (requestId !== latestLoadRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -508,7 +509,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
setValueLoading(true);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisGetValue(config, key);
|
||||
const res = await (window as any).go.app.App.RedisGetValue(buildRpcConnectionConfig(config), key);
|
||||
if (res.success) {
|
||||
setKeyValue(res.data);
|
||||
setSelectedKey(key);
|
||||
@@ -539,7 +540,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisDeleteKeys(config, keysToDelete);
|
||||
const res = await (window as any).go.app.App.RedisDeleteKeys(buildRpcConnectionConfig(config), keysToDelete);
|
||||
if (res.success) {
|
||||
message.success(`已删除 ${res.data.deleted} 个 Key`);
|
||||
setKeys(prev => prev.filter(k => !keysToDelete.includes(k.key)));
|
||||
@@ -567,7 +568,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
try {
|
||||
const values = await ttlForm.validateFields();
|
||||
const res = await (window as any).go.app.App.RedisSetTTL(config, selectedKey, values.ttl);
|
||||
const res = await (window as any).go.app.App.RedisSetTTL(buildRpcConnectionConfig(config), selectedKey, values.ttl);
|
||||
if (res.success) {
|
||||
message.success('TTL 设置成功');
|
||||
setTtlModalOpen(false);
|
||||
@@ -586,7 +587,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
if (!config || !selectedKey) return;
|
||||
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisSetString(config, selectedKey, editValue, keyValue?.ttl || -1);
|
||||
const res = await (window as any).go.app.App.RedisSetString(buildRpcConnectionConfig(config), selectedKey, editValue, keyValue?.ttl || -1);
|
||||
if (res.success) {
|
||||
message.success('保存成功');
|
||||
setEditModalOpen(false);
|
||||
@@ -605,7 +606,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
try {
|
||||
const values = await newKeyForm.validateFields();
|
||||
const res = await (window as any).go.app.App.RedisSetString(config, values.key, values.value, values.ttl || -1);
|
||||
const res = await (window as any).go.app.App.RedisSetString(buildRpcConnectionConfig(config), values.key, values.value, values.ttl || -1);
|
||||
if (res.success) {
|
||||
message.success('创建成功');
|
||||
setNewKeyModalOpen(false);
|
||||
@@ -642,7 +643,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const existsRes = await (window as any).go.app.App.RedisKeyExists(config, nextKey);
|
||||
const existsRes = await (window as any).go.app.App.RedisKeyExists(buildRpcConnectionConfig(config), nextKey);
|
||||
if (!existsRes?.success) {
|
||||
message.error('校验目标 Key 失败: ' + (existsRes?.message || '未知错误'));
|
||||
return;
|
||||
@@ -652,7 +653,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await (window as any).go.app.App.RedisRenameKey(config, renameTargetKey, nextKey);
|
||||
const res = await (window as any).go.app.App.RedisRenameKey(buildRpcConnectionConfig(config), renameTargetKey, nextKey);
|
||||
if (res.success) {
|
||||
const nextState = applyRenamedRedisKeyState(
|
||||
{
|
||||
@@ -1177,7 +1178,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisSetHashField(config, selectedKey, field, newValue);
|
||||
const res = await (window as any).go.app.App.RedisSetHashField(buildRpcConnectionConfig(config), selectedKey, field, newValue);
|
||||
if (res.success) {
|
||||
message.success('修改成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1193,7 +1194,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisDeleteHashField(config, selectedKey, field);
|
||||
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, field);
|
||||
if (res.success) {
|
||||
message.success('删除成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1338,7 +1339,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisListSet(config, selectedKey, index, newValue);
|
||||
const res = await (window as any).go.app.App.RedisListSet(buildRpcConnectionConfig(config), selectedKey, index, newValue);
|
||||
if (res.success) {
|
||||
message.success('修改成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1354,7 +1355,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisListPush(config, selectedKey, { values: [value], position });
|
||||
const res = await (window as any).go.app.App.RedisListPush(buildRpcConnectionConfig(config), selectedKey, { values: [value], position });
|
||||
if (res.success) {
|
||||
message.success('添加成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1508,7 +1509,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisSetAdd(config, selectedKey, [member]);
|
||||
const res = await (window as any).go.app.App.RedisSetAdd(buildRpcConnectionConfig(config), selectedKey, [member]);
|
||||
if (res.success) {
|
||||
message.success('添加成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1524,7 +1525,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisSetRemove(config, selectedKey, [member]);
|
||||
const res = await (window as any).go.app.App.RedisSetRemove(buildRpcConnectionConfig(config), selectedKey, [member]);
|
||||
if (res.success) {
|
||||
message.success('删除成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1645,7 +1646,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisZSetAdd(config, selectedKey, [{ member, score }]);
|
||||
const res = await (window as any).go.app.App.RedisZSetAdd(buildRpcConnectionConfig(config), selectedKey, [{ member, score }]);
|
||||
if (res.success) {
|
||||
message.success('添加成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1661,7 +1662,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisZSetRemove(config, selectedKey, [member]);
|
||||
const res = await (window as any).go.app.App.RedisZSetRemove(buildRpcConnectionConfig(config), selectedKey, [member]);
|
||||
if (res.success) {
|
||||
message.success('删除成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1841,7 +1842,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisStreamAdd(config, selectedKey, fieldMap, id || '*');
|
||||
const res = await (window as any).go.app.App.RedisStreamAdd(buildRpcConnectionConfig(config), selectedKey, fieldMap, id || '*');
|
||||
if (res.success) {
|
||||
const newID = res.data?.id ? ` (${res.data.id})` : '';
|
||||
message.success(`添加成功${newID}`);
|
||||
@@ -1859,7 +1860,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisStreamDelete(config, selectedKey, [id]);
|
||||
const res = await (window as any).go.app.App.RedisStreamDelete(buildRpcConnectionConfig(config), selectedKey, [id]);
|
||||
if (res.success) {
|
||||
const deleted = Number(res.data?.deleted ?? 0);
|
||||
if (deleted > 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress } from 'antd';
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
@@ -30,16 +30,20 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
||||
CodeOutlined,
|
||||
TagOutlined,
|
||||
CheckOutlined,
|
||||
FilterOutlined
|
||||
FilterOutlined,
|
||||
DashboardOutlined,
|
||||
WarningOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { SavedConnection } from '../types';
|
||||
import { getDbIcon } from './DatabaseIcons';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import FindInDatabaseModal from './FindInDatabaseModal';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -174,6 +178,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
|
||||
@@ -363,129 +368,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
}, [connections, connectionTags]);
|
||||
|
||||
const buildDuplicateConnectionName = (rawName: string): string => {
|
||||
const baseName = String(rawName || '').trim() || '连接';
|
||||
const suffix = ' - 副本';
|
||||
const usedNames = new Set(connections.map(conn => String(conn.name || '').trim()));
|
||||
let candidate = `${baseName}${suffix}`;
|
||||
let counter = 2;
|
||||
while (usedNames.has(candidate)) {
|
||||
candidate = `${baseName}${suffix} ${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
const handleDuplicateConnection = async (conn: SavedConnection) => {
|
||||
if (!conn?.id) return;
|
||||
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.DuplicateConnection !== 'function') {
|
||||
message.error('复制连接失败:后端接口不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
const cloneConnectionConfig = (config: SavedConnection['config']): SavedConnection['config'] => {
|
||||
const raw: any = config || {};
|
||||
let cloned: any = {};
|
||||
try {
|
||||
cloned = typeof structuredClone === 'function'
|
||||
? structuredClone(raw)
|
||||
: JSON.parse(JSON.stringify(raw));
|
||||
} catch {
|
||||
cloned = { ...raw };
|
||||
const duplicatedConnection = await backendApp.DuplicateConnection(conn.id);
|
||||
if (!duplicatedConnection) {
|
||||
throw new Error('复制连接失败:后端未返回结果');
|
||||
}
|
||||
addConnection(duplicatedConnection);
|
||||
message.success(`已复制连接: ${duplicatedConnection.name}`);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '复制连接失败');
|
||||
}
|
||||
|
||||
const readString = (...values: unknown[]): string => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const readBool = (fallback: boolean, ...values: unknown[]): boolean => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const readNumber = (fallback: number, ...values: unknown[]): number => {
|
||||
for (const value of values) {
|
||||
const num = Number(value);
|
||||
if (Number.isFinite(num)) {
|
||||
return num;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const rawSSH = (cloned.ssh ?? cloned.SSH ?? {}) as Record<string, unknown>;
|
||||
const normalizedSSH = {
|
||||
host: readString(rawSSH.host, rawSSH.Host, cloned.sshHost, cloned.SSHHost),
|
||||
port: readNumber(22, rawSSH.port, rawSSH.Port, cloned.sshPort, cloned.SSHPort),
|
||||
user: readString(rawSSH.user, rawSSH.User, cloned.sshUser, cloned.SSHUser),
|
||||
password: readString(rawSSH.password, rawSSH.Password, cloned.sshPassword, cloned.SSHPassword),
|
||||
keyPath: readString(rawSSH.keyPath, rawSSH.KeyPath, cloned.sshKeyPath, cloned.SSHKeyPath),
|
||||
};
|
||||
const hasSSHDetail = Boolean(
|
||||
normalizedSSH.host
|
||||
|| normalizedSSH.user
|
||||
|| normalizedSSH.password
|
||||
|| normalizedSSH.keyPath
|
||||
);
|
||||
|
||||
const rawProxy = (cloned.proxy ?? cloned.Proxy ?? {}) as Record<string, unknown>;
|
||||
const proxyTypeRaw = readString(rawProxy.type, rawProxy.Type, cloned.proxyType, cloned.ProxyType).toLowerCase();
|
||||
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
|
||||
const normalizedProxy = {
|
||||
type: proxyType,
|
||||
host: readString(rawProxy.host, rawProxy.Host, cloned.proxyHost, cloned.ProxyHost),
|
||||
port: readNumber(proxyType === 'http' ? 8080 : 1080, rawProxy.port, rawProxy.Port, cloned.proxyPort, cloned.ProxyPort),
|
||||
user: readString(rawProxy.user, rawProxy.User, cloned.proxyUser, cloned.ProxyUser),
|
||||
password: readString(rawProxy.password, rawProxy.Password, cloned.proxyPassword, cloned.ProxyPassword),
|
||||
};
|
||||
const hasProxyDetail = Boolean(normalizedProxy.host || normalizedProxy.user || normalizedProxy.password);
|
||||
const rawHttpTunnel = (cloned.httpTunnel ?? cloned.HTTPTunnel ?? {}) as Record<string, unknown>;
|
||||
const normalizedHttpTunnel = {
|
||||
host: readString(rawHttpTunnel.host, rawHttpTunnel.Host, cloned.httpTunnelHost, cloned.HttpTunnelHost),
|
||||
port: readNumber(8080, rawHttpTunnel.port, rawHttpTunnel.Port, cloned.httpTunnelPort, cloned.HttpTunnelPort),
|
||||
user: readString(rawHttpTunnel.user, rawHttpTunnel.User, cloned.httpTunnelUser, cloned.HttpTunnelUser),
|
||||
password: readString(rawHttpTunnel.password, rawHttpTunnel.Password, cloned.httpTunnelPassword, cloned.HttpTunnelPassword),
|
||||
};
|
||||
const hasHttpTunnelDetail = Boolean(normalizedHttpTunnel.host || normalizedHttpTunnel.user || normalizedHttpTunnel.password);
|
||||
const normalizedUseHttpTunnel = readBool(hasHttpTunnelDetail, cloned.useHttpTunnel, cloned.UseHTTPTunnel);
|
||||
const normalizedUseProxy = !normalizedUseHttpTunnel && readBool(hasProxyDetail, cloned.useProxy, cloned.UseProxy);
|
||||
|
||||
const rawHosts = Array.isArray(cloned.hosts)
|
||||
? cloned.hosts
|
||||
: (Array.isArray(cloned.Hosts) ? cloned.Hosts : []);
|
||||
const normalizedHosts = rawHosts
|
||||
.map((entry: unknown) => String(entry || '').trim())
|
||||
.filter((entry: string) => !!entry);
|
||||
|
||||
return {
|
||||
...(cloned as SavedConnection['config']),
|
||||
useSSH: readBool(hasSSHDetail, cloned.useSSH, cloned.UseSSH),
|
||||
ssh: normalizedSSH,
|
||||
useProxy: normalizedUseProxy,
|
||||
proxy: normalizedProxy,
|
||||
useHttpTunnel: normalizedUseHttpTunnel,
|
||||
httpTunnel: normalizedHttpTunnel,
|
||||
hosts: normalizedHosts,
|
||||
timeout: readNumber(30, cloned.timeout, cloned.Timeout),
|
||||
};
|
||||
};
|
||||
|
||||
const handleDuplicateConnection = (conn: SavedConnection) => {
|
||||
if (!conn) return;
|
||||
|
||||
const duplicatedConnection: SavedConnection = {
|
||||
...conn,
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: buildDuplicateConnectionName(conn.name),
|
||||
config: cloneConnectionConfig(conn.config),
|
||||
includeDatabases: conn.includeDatabases ? [...conn.includeDatabases] : undefined,
|
||||
includeRedisDatabases: conn.includeRedisDatabases ? [...conn.includeRedisDatabases] : undefined,
|
||||
};
|
||||
|
||||
addConnection(duplicatedConnection);
|
||||
message.success(`已复制连接: ${duplicatedConnection.name}`);
|
||||
};
|
||||
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
|
||||
return list.map(node => {
|
||||
@@ -524,7 +425,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
if (SIDEBAR_SCHEMA_DB_TYPES.has(dbType)) return true;
|
||||
if (dbType !== 'custom') return false;
|
||||
|
||||
const customDriver = String((conn?.config as any)?.driver || '').trim().toLowerCase();
|
||||
const customDriver = String(conn?.config?.driver || '').trim().toLowerCase();
|
||||
return SIDEBAR_SCHEMA_CUSTOM_DRIVERS.has(customDriver);
|
||||
};
|
||||
|
||||
@@ -540,7 +441,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const getMetadataDialect = (conn: SavedConnection | undefined): string => {
|
||||
const type = String(conn?.config?.type || '').trim().toLowerCase();
|
||||
if (type === 'custom') {
|
||||
const driver = String((conn?.config as any)?.driver || '').trim().toLowerCase();
|
||||
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
|
||||
if (driver === 'diros' || driver === 'doris') return 'mysql';
|
||||
return driver;
|
||||
}
|
||||
@@ -566,7 +467,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const type = String(conn?.config?.type || '').trim().toLowerCase();
|
||||
if (type === 'sphinx') return true;
|
||||
if (type !== 'custom') return false;
|
||||
const driver = String((conn?.config as any)?.driver || '').trim().toLowerCase();
|
||||
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
|
||||
return driver === 'sphinx' || driver === 'sphinxql';
|
||||
};
|
||||
|
||||
@@ -854,7 +755,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
for (const spec of normalizedSpecs) {
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, spec.sql);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, spec.sql);
|
||||
if (!result.success || !Array.isArray(result.data)) {
|
||||
continue;
|
||||
}
|
||||
@@ -985,7 +886,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
// Handle Redis connections differently
|
||||
if (conn.config.type === 'redis') {
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisGetDatabases(config);
|
||||
const res = await (window as any).go.app.App.RedisGetDatabases(buildRpcConnectionConfig(config));
|
||||
if (res.success) {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
|
||||
const redisRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||||
@@ -1017,7 +918,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await DBGetDatabases(config as any);
|
||||
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
|
||||
if (res.success) {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
|
||||
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||||
@@ -1035,13 +936,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);
|
||||
@@ -1083,7 +992,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
try {
|
||||
const res = await DBGetTables(config as any, conn.dbName);
|
||||
const res = await DBGetTables(buildRpcConnectionConfig(config) as any, conn.dbName);
|
||||
if (res.success) {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
|
||||
|
||||
@@ -1447,6 +1356,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 +1380,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 +1392,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;
|
||||
// 记录表访问
|
||||
@@ -1559,14 +1476,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
const handleCopyStructure = async (node: any) => {
|
||||
const { config, dbName, tableName } = node.dataRef;
|
||||
const res = await DBShowCreateTable({
|
||||
...config,
|
||||
port: Number(config.port),
|
||||
password: config.password || "",
|
||||
database: config.database || "",
|
||||
useSSH: config.useSSH || false,
|
||||
ssh: config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
} as any, dbName, tableName);
|
||||
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, dbName, tableName);
|
||||
if (res.success) {
|
||||
navigator.clipboard.writeText(res.data as string);
|
||||
message.success('表结构已复制到剪贴板');
|
||||
@@ -1578,14 +1488,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const handleExport = async (node: any, format: string) => {
|
||||
const { config, dbName, tableName } = node.dataRef;
|
||||
const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0);
|
||||
const res = await ExportTable({
|
||||
...config,
|
||||
port: Number(config.port),
|
||||
password: config.password || "",
|
||||
database: config.database || "",
|
||||
useSSH: config.useSSH || false,
|
||||
ssh: config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
} as any, dbName, tableName, format);
|
||||
const res = await ExportTable(buildRpcConnectionConfig(config) as any, dbName, tableName, format);
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
@@ -1594,14 +1497,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeConnConfig = (raw: any) => ({
|
||||
...raw,
|
||||
port: Number(raw.port),
|
||||
password: raw.password || "",
|
||||
database: raw.database || "",
|
||||
useSSH: raw.useSSH || false,
|
||||
ssh: raw.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
});
|
||||
const normalizeConnConfig = (raw: any) => (
|
||||
buildRpcConnectionConfig(raw)
|
||||
);
|
||||
|
||||
const handleExportDatabaseSQL = async (node: any, includeData: boolean) => {
|
||||
const conn = node.dataRef;
|
||||
@@ -1696,7 +1594,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await DBGetDatabases(config as any);
|
||||
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
|
||||
if (res.success) {
|
||||
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||||
let dbs = dbRows.map((row: any) => {
|
||||
@@ -1731,7 +1629,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
|
||||
const [res, viewResult] = await Promise.all([
|
||||
DBGetTables(config as any, dbName),
|
||||
DBGetTables(buildRpcConnectionConfig(config) as any, dbName),
|
||||
loadViews(conn, dbName).catch(() => ({ views: [], supported: false })),
|
||||
]);
|
||||
|
||||
@@ -1864,13 +1762,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const app = (window as any).go.app.App;
|
||||
const res = await app.TruncateTables(normalizeConnConfig(conn.config), dbName, objectNames);
|
||||
const res = await app.ClearTables(normalizeConnConfig(conn.config), dbName, objectNames);
|
||||
hide();
|
||||
const duration = Date.now() - startTime;
|
||||
if (res.success) {
|
||||
message.success('清空成功');
|
||||
// 构造 SQL 日志
|
||||
let logSql = `/* Truncate Tables (${objectNames.length} tables) */\n`;
|
||||
let logSql = `/* Clear Tables (${objectNames.length} tables) */\n`;
|
||||
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
|
||||
logSql += res.data.executedSQLs.join(';\n') + ';';
|
||||
} else {
|
||||
@@ -1889,7 +1787,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error('清空失败: ' + res.message);
|
||||
// 记录失败的日志
|
||||
let logSql = `/* Truncate Tables (${objectNames.length} tables) - FAILED */\n`;
|
||||
let logSql = `/* Clear Tables (${objectNames.length} tables) - FAILED */\n`;
|
||||
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
|
||||
logSql += res.data.executedSQLs.join(';\n') + ';';
|
||||
} else {
|
||||
@@ -1911,7 +1809,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const errMsg = e?.message || String(e);
|
||||
message.error('清空失败: ' + errMsg);
|
||||
// 记录异常的日志
|
||||
let logSql = `/* Truncate Tables (${objectNames.length} tables) - ERROR */\n`;
|
||||
let logSql = `/* Clear Tables (${objectNames.length} tables) - ERROR */\n`;
|
||||
logSql += objectNames.map(name => name).join('; ');
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
@@ -2007,7 +1905,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await DBGetDatabases(config as any);
|
||||
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
|
||||
if (res.success) {
|
||||
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||||
let dbs = dbRows.map((row: any) => {
|
||||
@@ -2219,7 +2117,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await CreateDatabase(config as any, values.name);
|
||||
const res = await CreateDatabase(buildRpcConnectionConfig(config) as any, values.name);
|
||||
if (res.success) {
|
||||
message.success("数据库创建成功");
|
||||
setIsCreateDbModalOpen(false);
|
||||
@@ -2235,14 +2133,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
|
||||
const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {
|
||||
return {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: clearDatabase ? "" : ((overrideDatabase ?? conn.config.database) || ""),
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
return buildRpcConnectionConfig(conn.config, {
|
||||
database: clearDatabase ? '' : ((overrideDatabase ?? conn.config.database) || ''),
|
||||
});
|
||||
};
|
||||
|
||||
const getConnectionNodeRef = (connRef: any) => {
|
||||
@@ -2284,7 +2177,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await RenameDatabase(config as any, oldDbName, newDbName);
|
||||
const res = await RenameDatabase(buildRpcConnectionConfig(config) as any, oldDbName, newDbName);
|
||||
if (res.success) {
|
||||
message.success("数据库重命名成功");
|
||||
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`)));
|
||||
@@ -2311,7 +2204,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await DropDatabase(config as any, dbName);
|
||||
const res = await DropDatabase(buildRpcConnectionConfig(config) as any, dbName);
|
||||
if (res.success) {
|
||||
message.success("数据库删除成功");
|
||||
closeTabsByDatabase(conn.id, dbName);
|
||||
@@ -2341,7 +2234,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return;
|
||||
}
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await RenameTable(config as any, conn.dbName, oldTableName, newTableName);
|
||||
const res = await RenameTable(buildRpcConnectionConfig(config) as any, conn.dbName, oldTableName, newTableName);
|
||||
if (res.success) {
|
||||
message.success("表重命名成功");
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
@@ -2366,7 +2259,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await DropTable(config as any, conn.dbName, tableName);
|
||||
const res = await DropTable(buildRpcConnectionConfig(config) as any, conn.dbName, tableName);
|
||||
if (res.success) {
|
||||
message.success("表删除成功");
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
@@ -2377,6 +2270,84 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
};
|
||||
|
||||
const handleTableDataDangerAction = async (node: any, action: TableDataDangerActionKind) => {
|
||||
const conn = node.dataRef;
|
||||
const tableName = String(conn.tableName || '').trim();
|
||||
if (!tableName) return;
|
||||
|
||||
const { label, progressLabel } = getTableDataDangerActionMeta(action);
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: `确认${label}`,
|
||||
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
|
||||
okText: '继续',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const app = (window as any).go.app.App;
|
||||
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
|
||||
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const res = await app[methodName](buildRpcConnectionConfig(config) as any, conn.dbName, [tableName]);
|
||||
hide();
|
||||
const duration = Date.now() - startTime;
|
||||
const executedSQLs = Array.isArray(res.data?.executedSQLs) ? res.data.executedSQLs : [];
|
||||
const logSql = executedSQLs.length > 0
|
||||
? executedSQLs.join(';\n') + ';'
|
||||
: `/* ${label} ${tableName} */`;
|
||||
|
||||
if (res.success) {
|
||||
message.success(`${progressLabel}成功`);
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
sql: logSql,
|
||||
status: 'success',
|
||||
duration,
|
||||
message: res.message,
|
||||
dbName: conn.dbName,
|
||||
affectedRows: res.data?.count || 0,
|
||||
});
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
return;
|
||||
}
|
||||
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
sql: logSql,
|
||||
status: 'error',
|
||||
duration,
|
||||
message: res.message,
|
||||
dbName: conn.dbName,
|
||||
});
|
||||
if (res.message !== '已取消') {
|
||||
message.error(`${progressLabel}失败: ${res.message}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errMsg = e?.message || String(e);
|
||||
hide();
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
sql: `/* ${label} ${tableName} - ERROR */`,
|
||||
status: 'error',
|
||||
duration,
|
||||
message: errMsg,
|
||||
dbName: conn.dbName,
|
||||
});
|
||||
message.error(`${progressLabel}失败: ${errMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 视图操作 ---
|
||||
const openViewDefinition = (node: any) => {
|
||||
const { viewName, dbName, id } = node.dataRef;
|
||||
@@ -2426,7 +2397,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
}
|
||||
if (query) {
|
||||
const result = await DBQuery(config as any, dbName, query);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
|
||||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||||
const row = result.data[0] as Record<string, any>;
|
||||
const def = row.view_definition || row.VIEW_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
|
||||
@@ -2492,7 +2463,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await DropView(config as any, conn.dbName, viewName);
|
||||
const res = await DropView(buildRpcConnectionConfig(config) as any, conn.dbName, viewName);
|
||||
if (res.success) {
|
||||
message.success("视图删除成功");
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
@@ -2519,7 +2490,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return;
|
||||
}
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await RenameView(config as any, conn.dbName, oldViewName, newViewName);
|
||||
const res = await RenameView(buildRpcConnectionConfig(config) as any, conn.dbName, oldViewName, newViewName);
|
||||
if (res.success) {
|
||||
message.success("视图重命名成功");
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
@@ -2591,7 +2562,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
}
|
||||
if (query) {
|
||||
const result = await DBQuery(config as any, dbName, query);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
|
||||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||||
if (dialect === 'oracle' || dialect === 'dm') {
|
||||
const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join('');
|
||||
@@ -2685,7 +2656,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await DropFunction(config as any, conn.dbName, routineName, routineType);
|
||||
const res = await DropFunction(buildRpcConnectionConfig(config) as any, conn.dbName, routineName, routineType);
|
||||
if (res.success) {
|
||||
message.success(`${typeLabel}删除成功`);
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
@@ -3081,7 +3052,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 +3079,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',
|
||||
@@ -3143,9 +3138,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除连接 "${node.title}" 吗?`,
|
||||
onOk: () => {
|
||||
closeTabsByConnection(String(node.key));
|
||||
removeConnection(node.key);
|
||||
onOk: async () => {
|
||||
const connId = String(node.key);
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.DeleteConnection !== 'function') {
|
||||
message.error('删除连接失败:后端接口不可用');
|
||||
throw new Error('DeleteConnection unavailable');
|
||||
}
|
||||
try {
|
||||
await backendApp.DeleteConnection(connId);
|
||||
closeTabsByConnection(connId);
|
||||
removeConnection(connId);
|
||||
message.success('已删除连接');
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '删除连接失败');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3184,7 +3192,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' },
|
||||
{
|
||||
@@ -3270,9 +3288,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除连接 "${node.title}" 吗?`,
|
||||
onOk: () => {
|
||||
closeTabsByConnection(String(node.key));
|
||||
removeConnection(node.key);
|
||||
onOk: async () => {
|
||||
const connId = String(node.key);
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.DeleteConnection !== 'function') {
|
||||
message.error('删除连接失败:后端接口不可用');
|
||||
throw new Error('DeleteConnection unavailable');
|
||||
}
|
||||
try {
|
||||
await backendApp.DeleteConnection(connId);
|
||||
closeTabsByConnection(connId);
|
||||
removeConnection(connId);
|
||||
message.success('已删除连接');
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '删除连接失败');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3309,6 +3340,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') {
|
||||
@@ -3330,11 +3375,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'drop-db',
|
||||
label: '删除数据库',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDeleteDatabase(node)
|
||||
key: 'danger-zone',
|
||||
label: '危险操作',
|
||||
icon: <WarningOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: 'drop-db',
|
||||
label: '删除数据库',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDeleteDatabase(node)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'refresh',
|
||||
@@ -3447,11 +3499,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'drop-view',
|
||||
label: '删除视图',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDropView(node)
|
||||
key: 'danger-zone',
|
||||
label: '危险操作',
|
||||
icon: <WarningOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: 'drop-view',
|
||||
label: '删除视图',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDropView(node)
|
||||
}
|
||||
]
|
||||
},
|
||||
];
|
||||
} else if (node.type === 'routine') {
|
||||
@@ -3472,11 +3531,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'drop-routine',
|
||||
label: `删除${typeLabel}`,
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDropRoutine(node)
|
||||
key: 'danger-zone',
|
||||
label: '危险操作',
|
||||
icon: <WarningOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: 'drop-routine',
|
||||
label: `删除${typeLabel}`,
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDropRoutine(node)
|
||||
}
|
||||
]
|
||||
},
|
||||
];
|
||||
} else if (node.type === 'table') {
|
||||
@@ -3528,11 +3594,30 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'drop-table',
|
||||
label: '删除表',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDeleteTable(node)
|
||||
key: 'danger-zone',
|
||||
label: '危险操作',
|
||||
icon: <WarningOutlined />,
|
||||
children: [
|
||||
...(supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver) ? [{
|
||||
key: 'truncate-table',
|
||||
label: '截断表',
|
||||
danger: true,
|
||||
onClick: () => handleTableDataDangerAction(node, 'truncate')
|
||||
}] : []),
|
||||
{
|
||||
key: 'clear-table',
|
||||
label: '清空表',
|
||||
danger: true,
|
||||
onClick: () => handleTableDataDangerAction(node, 'clear')
|
||||
},
|
||||
{
|
||||
key: 'drop-table',
|
||||
label: '删除表',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDeleteTable(node)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
@@ -3793,29 +3878,31 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<Tree
|
||||
showIcon
|
||||
draggable={{
|
||||
icon: false,
|
||||
nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag'
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
loadData={onLoadData}
|
||||
treeData={displayTreeData}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onSelect={onSelect}
|
||||
titleRender={titleRender}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={onExpand}
|
||||
loadedKeys={loadedKeys}
|
||||
onLoad={setLoadedKeys}
|
||||
autoExpandParent={autoExpandParent}
|
||||
selectedKeys={selectedKeys}
|
||||
blockNode
|
||||
height={treeHeight}
|
||||
onRightClick={onRightClick}
|
||||
/>
|
||||
<div ref={treeContainerRef} className="sidebar-tree-scroll-shell" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className="sidebar-tree-scroll-content">
|
||||
<Tree
|
||||
showIcon
|
||||
draggable={{
|
||||
icon: false,
|
||||
nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag'
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
loadData={onLoadData}
|
||||
treeData={displayTreeData}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onSelect={onSelect}
|
||||
titleRender={titleRender}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={onExpand}
|
||||
loadedKeys={loadedKeys}
|
||||
onLoad={setLoadedKeys}
|
||||
autoExpandParent={autoExpandParent}
|
||||
selectedKeys={selectedKeys}
|
||||
blockNode
|
||||
height={treeHeight}
|
||||
onRightClick={onRightClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg
|
||||
import { useStore } from '../store';
|
||||
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
|
||||
import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
interface EditableColumn extends ColumnDefinition {
|
||||
_key: string;
|
||||
@@ -217,14 +219,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' },
|
||||
@@ -759,14 +753,14 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
};
|
||||
|
||||
const promises: Promise<any>[] = [
|
||||
DBGetColumns(config as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetIndexes(config as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetForeignKeys(config as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetTriggers(config as any, tab.dbName || '', tab.tableName || '')
|
||||
DBGetColumns(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetIndexes(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetForeignKeys(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetTriggers(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || '')
|
||||
];
|
||||
|
||||
if (!isNewTable) {
|
||||
promises.push(DBShowCreateTable(config as any, tab.dbName || '', tab.tableName || ''));
|
||||
promises.push(DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
@@ -856,7 +850,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
if (!type) return '';
|
||||
|
||||
if (type === 'custom') {
|
||||
return inferDialectFromCustomDriver(String((conn?.config as any)?.driver || ''));
|
||||
return inferDialectFromCustomDriver(String(conn?.config?.driver || ''));
|
||||
}
|
||||
|
||||
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
@@ -1001,7 +995,7 @@ ${selectedTrigger.statement}`;
|
||||
const dropSql = buildDropTriggerSql(selectedTrigger.name);
|
||||
|
||||
try {
|
||||
const res = await DBQuery(config as any, tab.dbName || '', dropSql);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql);
|
||||
if (res.success) {
|
||||
message.success('触发器删除成功');
|
||||
setSelectedTrigger(null);
|
||||
@@ -1038,7 +1032,7 @@ ${selectedTrigger.statement}`;
|
||||
// 如果是编辑模式,先删除旧触发器
|
||||
if (triggerEditMode === 'edit' && selectedTrigger) {
|
||||
const dropSql = buildDropTriggerSql(selectedTrigger.name);
|
||||
const dropRes = await DBQuery(config as any, tab.dbName || '', dropSql);
|
||||
const dropRes = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql);
|
||||
if (!dropRes.success) {
|
||||
message.error('删除旧触发器失败: ' + dropRes.message);
|
||||
setTriggerExecuting(false);
|
||||
@@ -1047,7 +1041,7 @@ ${selectedTrigger.statement}`;
|
||||
}
|
||||
|
||||
// 执行创建语句
|
||||
const res = await DBQuery(config as any, tab.dbName || '', triggerEditSql);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', triggerEditSql);
|
||||
if (res.success) {
|
||||
message.success(triggerEditMode === 'create' ? '触发器创建成功' : '触发器修改成功');
|
||||
setIsTriggerEditModalOpen(false);
|
||||
@@ -1441,14 +1435,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 => {
|
||||
@@ -1507,7 +1524,7 @@ ${selectedTrigger.statement}`;
|
||||
const sql = buildCreateTableSql(copyTableName.trim(), selectedColumns, copyCharset, copyCollation);
|
||||
setCopyExecuting(true);
|
||||
try {
|
||||
const res = await DBQuery(config as any, tab.dbName || '', sql);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
|
||||
if (res.success) {
|
||||
message.success(`已将 ${selectedColumns.length} 个字段复制到新表 ${copyTableName.trim()}`);
|
||||
setIsCopyColumnsModalOpen(false);
|
||||
@@ -1536,7 +1553,7 @@ ${selectedTrigger.statement}`;
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
let stmt = statements[i];
|
||||
if (!stmt.endsWith(';')) stmt += ';';
|
||||
const res = await DBQuery(config as any, tab.dbName || '', stmt);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', stmt);
|
||||
if (!res.success) {
|
||||
const prefix = statements.length > 1 ? `第 ${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: ';
|
||||
return {
|
||||
@@ -2102,105 +2119,44 @@ END;`;
|
||||
return;
|
||||
}
|
||||
|
||||
const tableName = `\`${isNewTable ? newTableName : tab.tableName}\``;
|
||||
|
||||
if (isNewTable) {
|
||||
// CREATE TABLE
|
||||
const sql = buildCreateTableSql(isNewTable ? newTableName : tab.tableName || '', columns, charset, collation);
|
||||
setPreviewSql(sql);
|
||||
setIsPreviewOpen(true);
|
||||
} else {
|
||||
// ALTER TABLE (Existing logic)
|
||||
const alters: string[] = [];
|
||||
|
||||
originalColumns.forEach(orig => {
|
||||
if (!columns.find(c => c._key === orig._key)) {
|
||||
alters.push(`DROP COLUMN \`${orig.name}\``);
|
||||
}
|
||||
const tableInfo = resolveTableInfo();
|
||||
const sql = buildAlterTablePreviewSql({
|
||||
dbType: tableInfo.dbType,
|
||||
tableName: tableInfo.qualifiedName,
|
||||
originalColumns,
|
||||
columns,
|
||||
});
|
||||
|
||||
columns.forEach((curr, index) => {
|
||||
const orig = originalColumns.find(c => c._key === curr._key);
|
||||
const prevCol = index > 0 ? columns[index - 1] : null;
|
||||
const positionSql = prevCol ? `AFTER \`${prevCol.name}\`` : 'FIRST';
|
||||
|
||||
let extra = curr.extra || "";
|
||||
if (curr.isAutoIncrement) {
|
||||
if (!extra.toLowerCase().includes('auto_increment')) extra += " AUTO_INCREMENT";
|
||||
} else {
|
||||
extra = extra.replace(/auto_increment/gi, "").trim();
|
||||
}
|
||||
|
||||
const colDef = `\`${curr.name}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${curr.default}'` : ''} ${extra} COMMENT '${curr.comment}'`;
|
||||
|
||||
if (!orig) {
|
||||
alters.push(`ADD COLUMN ${colDef} ${positionSql}`);
|
||||
} else {
|
||||
const origIndex = originalColumns.findIndex(c => c._key === curr._key);
|
||||
const origPrevCol = origIndex > 0 ? originalColumns[origIndex - 1] : null;
|
||||
|
||||
let positionChanged = false;
|
||||
if (index === 0 && origIndex !== 0) positionChanged = true;
|
||||
if (index > 0 && (!origPrevCol || origPrevCol._key !== prevCol?._key)) positionChanged = true;
|
||||
|
||||
const isNameChanged = orig.name !== curr.name;
|
||||
const isTypeChanged = orig.type !== curr.type;
|
||||
const isNullableChanged = orig.nullable !== curr.nullable;
|
||||
const isDefaultChanged = orig.default !== curr.default;
|
||||
const isCommentChanged = orig.comment !== curr.comment;
|
||||
const isAIChanged = orig.isAutoIncrement !== curr.isAutoIncrement;
|
||||
|
||||
if (isNameChanged || isTypeChanged || isNullableChanged || isDefaultChanged || isCommentChanged || positionChanged || isAIChanged) {
|
||||
if (isNameChanged) {
|
||||
alters.push(`CHANGE COLUMN \`${orig.name}\` ${colDef} ${positionSql}`);
|
||||
} else {
|
||||
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const origPKKeys = originalColumns.filter(c => c.key === 'PRI').map(c => c._key);
|
||||
const newPKKeys = columns.filter(c => c.key === 'PRI').map(c => c._key);
|
||||
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every(k => newPKKeys.includes(k));
|
||||
|
||||
if (keysChanged) {
|
||||
if (origPKKeys.length > 0) alters.push(`DROP PRIMARY KEY`);
|
||||
if (newPKKeys.length > 0) {
|
||||
const pkNames = columns.filter(c => c.key === 'PRI').map(c => `\`${c.name}\``).join(', ');
|
||||
alters.push(`ADD PRIMARY KEY (${pkNames})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (alters.length === 0) {
|
||||
if (!sql.trim()) {
|
||||
message.info("没有检测到变更");
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = `ALTER TABLE ${tableName}\n` + alters.join(",\n");
|
||||
setPreviewSql(sql);
|
||||
setIsPreviewOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteSave = async () => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) return;
|
||||
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: "" } };
|
||||
const res = await DBQuery(config as any, tab.dbName || '', previewSql);
|
||||
if (res.success) {
|
||||
message.success(isNewTable ? "表创建成功!" : "表结构修改成功!");
|
||||
setIsPreviewOpen(false);
|
||||
if (!isNewTable) {
|
||||
const result = await executeSchemaStatements(previewSql);
|
||||
if (!result.ok) {
|
||||
message.error(result.message || "执行失败");
|
||||
return;
|
||||
}
|
||||
message.success(isNewTable ? "表创建成功!" : "表结构修改成功!");
|
||||
setIsPreviewOpen(false);
|
||||
if (!isNewTable) {
|
||||
fetchData();
|
||||
} else {
|
||||
// TODO: Close tab or reload sidebar?
|
||||
// Ideally, refresh sidebar node.
|
||||
}
|
||||
} else {
|
||||
message.error("执行失败: " + res.message);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Merge columns with resize handler
|
||||
const resizableColumns = useMemo(() => tableColumns.map((col, index) => ({
|
||||
@@ -2928,20 +2884,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,9 +1,11 @@
|
||||
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, WarningOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
|
||||
import type { TabData } from '../types';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
|
||||
interface TableOverviewProps {
|
||||
tab: TabData;
|
||||
@@ -22,6 +24,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 +149,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>('list');
|
||||
|
||||
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
|
||||
|
||||
@@ -161,9 +165,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
useSSH: connection.config.useSSH || false,
|
||||
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
|
||||
};
|
||||
const dialect = getMetadataDialect(connection.config.type, (connection.config as any)?.driver);
|
||||
const dialect = getMetadataDialect(connection.config.type, connection.config.driver);
|
||||
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const res = await DBQuery(config as any, tab.dbName || '', sql);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setTables(parseTableStats(dialect, res.data));
|
||||
} else {
|
||||
@@ -237,7 +241,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const handleCopyStructure = useCallback(async (tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
const res = await DBShowCreateTable(config as any, tab.dbName || '', tableName);
|
||||
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
|
||||
if (res.success) {
|
||||
navigator.clipboard.writeText(res.data as string);
|
||||
message.success('表结构已复制到剪贴板');
|
||||
@@ -250,7 +254,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0);
|
||||
const res = await ExportTable(config as any, tab.dbName || '', tableName, format);
|
||||
const res = await ExportTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, format);
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
@@ -267,7 +271,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const res = await DropTable(config as any, tab.dbName || '', tableName);
|
||||
const res = await DropTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
|
||||
if (res.success) {
|
||||
message.success('表删除成功');
|
||||
loadData();
|
||||
@@ -278,6 +282,40 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
});
|
||||
}, [buildConfig, tab.dbName, loadData]);
|
||||
|
||||
const handleTableDataDangerAction = useCallback((tableName: string, action: TableDataDangerActionKind) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
|
||||
const { label, progressLabel } = getTableDataDangerActionMeta(action);
|
||||
Modal.confirm({
|
||||
title: `确认${label}`,
|
||||
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
|
||||
okText: '继续',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const app = (window as any).go.app.App;
|
||||
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
|
||||
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
|
||||
try {
|
||||
const res = await app[methodName](buildRpcConnectionConfig(config) as any, tab.dbName || '', [tableName]);
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success(`${progressLabel}成功`);
|
||||
loadData();
|
||||
} else {
|
||||
message.error(`${progressLabel}失败: ${res.message}`);
|
||||
return Promise.reject();
|
||||
}
|
||||
} catch (e: any) {
|
||||
hide();
|
||||
message.error(`${progressLabel}失败: ${e?.message || String(e)}`);
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [buildConfig, tab.dbName, loadData]);
|
||||
|
||||
const handleRenameTable = useCallback((tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
@@ -297,7 +335,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); }
|
||||
if (trimmed === tableName) { message.warning('新旧表名相同'); return; }
|
||||
const res = await RenameTable(config as any, tab.dbName || '', tableName, trimmed);
|
||||
const res = await RenameTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, trimmed);
|
||||
if (res.success) {
|
||||
message.success('表重命名成功');
|
||||
loadData();
|
||||
@@ -335,6 +373,10 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
|
||||
const totalRows = tables.reduce((s, t) => s + t.rows, 0);
|
||||
const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0);
|
||||
const maxCombinedSize = sortedFiltered.reduce((max, table) => {
|
||||
return Math.max(max, table.dataSize + table.indexSize);
|
||||
}, 0);
|
||||
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -366,14 +408,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))',
|
||||
@@ -401,7 +472,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
{ 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) },
|
||||
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
|
||||
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
|
||||
{ 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') },
|
||||
@@ -451,6 +526,147 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</Dropdown>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* ========== 行视图 ========== */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{sortedFiltered.map(t => {
|
||||
const combinedSize = t.dataSize + t.indexSize;
|
||||
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
|
||||
const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%';
|
||||
const fillColor = darkMode ? 'rgba(22,119,255,0.18)' : 'rgba(22,119,255,0.12)';
|
||||
const rowSecondary = t.comment || (t.engine ? `${t.engine} 表` : '双击打开数据,右键查看更多操作');
|
||||
|
||||
return (
|
||||
<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: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
|
||||
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
|
||||
{ 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') },
|
||||
]},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onDoubleClick={() => openTable(t.name)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: cardBg,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: fillWidth,
|
||||
background: fillColor,
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '14px 16px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: '1 1 320px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
|
||||
<Tooltip title={t.name} mouseEnterDelay={0.4}>
|
||||
<span style={{ color: textPrimary, fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{t.engine && (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
color: textMuted,
|
||||
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
{t.engine}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip title={rowSecondary} mouseEnterDelay={0.4}>
|
||||
<div style={{ marginTop: 6, color: textSecondary, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{rowSecondary}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap', fontSize: 12 }}>
|
||||
<div style={{ minWidth: 96, textAlign: 'right' }}>
|
||||
<div style={{ color: textMuted }}>行数</div>
|
||||
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 110, textAlign: 'right' }}>
|
||||
<div style={{ color: textMuted }}>数据大小</div>
|
||||
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 110, textAlign: 'right' }}>
|
||||
<div style={{ color: textMuted }}>索引大小</div>
|
||||
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 96, textAlign: 'right' }}>
|
||||
<div style={{ color: textMuted }}>相对大小</div>
|
||||
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Spin, Alert } from 'antd';
|
||||
import { TabData } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery } from '../../wailsjs/go/app/App';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
interface TriggerViewerProps {
|
||||
tab: TabData;
|
||||
@@ -100,7 +101,7 @@ LIMIT 1`];
|
||||
const sql = String(query || '').trim();
|
||||
if (!sql) continue;
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, sql);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
|
||||
if (!result.success || !Array.isArray(result.data)) {
|
||||
lastMessage = result.message || lastMessage;
|
||||
continue;
|
||||
@@ -126,7 +127,7 @@ LIMIT 1`];
|
||||
];
|
||||
for (const query of candidates) {
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, query);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
|
||||
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
61
frontend/src/components/ai/AIChatInput.notice.test.tsx
Normal file
61
frontend/src/components/ai/AIChatInput.notice.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
aiContexts: {},
|
||||
addAIContext: vi.fn(),
|
||||
removeAIContext: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../wailsjs/go/app/App', () => ({
|
||||
DBGetTables: vi.fn(),
|
||||
DBShowCreateTable: vi.fn(),
|
||||
DBGetDatabases: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('AIChatInput notice layout', () => {
|
||||
it('renders the composer notice above the input editor', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIChatInput
|
||||
input=""
|
||||
setInput={() => {}}
|
||||
draftImages={[]}
|
||||
setDraftImages={() => {}}
|
||||
sending={false}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: '', models: [] }}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
composerNotice={{
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
description: '请检查供应商入口和 API Key。',
|
||||
}}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
darkMode={false}
|
||||
textColor="#162033"
|
||||
mutedColor="rgba(16,24,40,0.55)"
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
const noticeIndex = markup.indexOf('data-ai-chat-composer-notice="true"');
|
||||
const inputIndex = markup.indexOf('data-ai-chat-composer-input="true"');
|
||||
|
||||
expect(noticeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(inputIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(noticeIndex).toBeLessThan(inputIndex);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
|
||||
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { useStore } from '../../store';
|
||||
import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
|
||||
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
|
||||
|
||||
interface AIChatInputProps {
|
||||
input: string;
|
||||
@@ -19,6 +21,7 @@ interface AIChatInputProps {
|
||||
activeProvider: any;
|
||||
dynamicModels: string[];
|
||||
loadingModels: boolean;
|
||||
composerNotice?: AIComposerNotice | null;
|
||||
onModelChange: (val: string) => void;
|
||||
onFetchModels: () => void;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
@@ -33,6 +36,7 @@ interface AIChatInputProps {
|
||||
export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
|
||||
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
|
||||
composerNotice,
|
||||
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
|
||||
contextUsageChars, maxContextChars
|
||||
}) => {
|
||||
@@ -67,6 +71,33 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
|
||||
const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase()));
|
||||
const [contextExpanded, setContextExpanded] = React.useState(false);
|
||||
const composerNoticePalette = React.useMemo(() => {
|
||||
if (composerNotice?.tone === 'error') {
|
||||
return darkMode
|
||||
? {
|
||||
background: 'rgba(255,120,117,0.12)',
|
||||
borderColor: 'rgba(255,120,117,0.24)',
|
||||
iconColor: '#ff7875',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(255,77,79,0.08)',
|
||||
borderColor: 'rgba(255,77,79,0.16)',
|
||||
iconColor: '#ff4d4f',
|
||||
};
|
||||
}
|
||||
|
||||
return darkMode
|
||||
? {
|
||||
background: 'rgba(250,173,20,0.12)',
|
||||
borderColor: 'rgba(250,173,20,0.22)',
|
||||
iconColor: '#ffd666',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(250,173,20,0.08)',
|
||||
borderColor: 'rgba(250,173,20,0.18)',
|
||||
iconColor: '#d48806',
|
||||
};
|
||||
}, [composerNotice, darkMode]);
|
||||
|
||||
// Slash commands
|
||||
const [showSlashMenu, setShowSlashMenu] = React.useState(false);
|
||||
@@ -94,7 +125,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
setContextLoading(true);
|
||||
setSelectedDbName(dbName);
|
||||
try {
|
||||
const res = await DBGetTables(connConfig, dbName);
|
||||
const res = await DBGetTables(buildRpcConnectionConfig(connConfig), dbName);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setContextTables(res.data.map(r => ({ name: Object.values(r)[0] as string })));
|
||||
} else {
|
||||
@@ -125,7 +156,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
|
||||
try {
|
||||
// Fetch databases
|
||||
const dbRes = await DBGetDatabases(conn.config as any);
|
||||
const dbRes = await DBGetDatabases(buildRpcConnectionConfig(conn.config) as any);
|
||||
if (dbRes.success && Array.isArray(dbRes.data)) {
|
||||
const databases = dbRes.data.map((r: any) => Object.values(r)[0] as string);
|
||||
setDbList(databases);
|
||||
@@ -134,7 +165,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
// Fetch tables for the active contextual database
|
||||
const initDbName = activeContext.dbName || '';
|
||||
setSelectedDbName(initDbName);
|
||||
const tablesRes = await DBGetTables(conn.config as any, initDbName);
|
||||
const tablesRes = await DBGetTables(buildRpcConnectionConfig(conn.config) as any, initDbName);
|
||||
if (tablesRes.success && Array.isArray(tablesRes.data)) {
|
||||
setContextTables(tablesRes.data.map((r: any) => ({ name: Object.values(r)[0] as string })));
|
||||
} else {
|
||||
@@ -171,7 +202,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
|
||||
continue;
|
||||
}
|
||||
const res = await DBShowCreateTable(conn.config as any, dbName, tableName);
|
||||
const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName);
|
||||
let createSql = '';
|
||||
if (res.success && res.data) {
|
||||
if (typeof res.data === 'string') {
|
||||
@@ -258,7 +289,31 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
{composerNotice && (
|
||||
<div
|
||||
data-ai-chat-composer-notice="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 12,
|
||||
background: composerNoticePalette.background,
|
||||
border: `1px solid ${composerNoticePalette.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
|
||||
{composerNotice.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
|
||||
{composerNotice.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||
{showSlashMenu && filteredSlashCmds.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
|
||||
@@ -354,9 +409,13 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
<Select
|
||||
size="small"
|
||||
variant="filled"
|
||||
value={activeProvider.model || (dynamicModels.length > 0 ? dynamicModels[0] : activeProvider.models?.[0])}
|
||||
value={activeProvider.model || undefined}
|
||||
onChange={onModelChange}
|
||||
onDropdownVisibleChange={(open) => { if (open && dynamicModels.length === 0) onFetchModels(); }}
|
||||
onDropdownVisibleChange={(open) => {
|
||||
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
|
||||
onFetchModels();
|
||||
}
|
||||
}}
|
||||
loading={loadingModels}
|
||||
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
|
||||
style={{ width: 130, fontSize: 11, background: 'transparent' }}
|
||||
|
||||
46
frontend/src/components/dataGridAutoWidth.test.ts
Normal file
46
frontend/src/components/dataGridAutoWidth.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateAutoFitColumnWidth,
|
||||
normalizeAutoFitCellText,
|
||||
} from './dataGridAutoWidth';
|
||||
|
||||
const measure = (text: string) => text.length * 8;
|
||||
|
||||
describe('dataGridAutoWidth helpers', () => {
|
||||
it('prefers the widest header or sampled value and adds padding', () => {
|
||||
const width = calculateAutoFitColumnWidth({
|
||||
headerTexts: ['user_name'],
|
||||
valueTexts: ['alice', 'very_long_username_value'],
|
||||
measureHeaderText: measure,
|
||||
measureCellText: measure,
|
||||
padding: 32,
|
||||
minWidth: 80,
|
||||
maxWidth: 720,
|
||||
defaultWidth: 140,
|
||||
});
|
||||
|
||||
expect(width).toBe('very_long_username_value'.length * 8 + 32);
|
||||
});
|
||||
|
||||
it('measures multiline content by the longest visible line and clamps to max width', () => {
|
||||
const width = calculateAutoFitColumnWidth({
|
||||
headerTexts: ['notes'],
|
||||
valueTexts: ['short\nmuch much longer line here'],
|
||||
measureHeaderText: measure,
|
||||
measureCellText: measure,
|
||||
padding: 24,
|
||||
minWidth: 80,
|
||||
maxWidth: 160,
|
||||
defaultWidth: 140,
|
||||
});
|
||||
|
||||
expect(width).toBe(160);
|
||||
});
|
||||
|
||||
it('normalizes null and oversized object values into stable preview text', () => {
|
||||
expect(normalizeAutoFitCellText(null)).toBe('NULL');
|
||||
expect(normalizeAutoFitCellText({ a: 1, b: 2 })).toBe('{"a":1,"b":2}');
|
||||
expect(normalizeAutoFitCellText(Array.from({ length: 81 }, (_, index) => index))).toBe('[Array(81)]');
|
||||
});
|
||||
});
|
||||
108
frontend/src/components/dataGridAutoWidth.ts
Normal file
108
frontend/src/components/dataGridAutoWidth.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
const AUTO_FIT_DEFAULT_MIN_WIDTH = 80;
|
||||
const AUTO_FIT_DEFAULT_MAX_WIDTH = 720;
|
||||
const AUTO_FIT_DEFAULT_PADDING = 40;
|
||||
const AUTO_FIT_DEFAULT_SAMPLE_LIMIT = 200;
|
||||
const AUTO_FIT_MAX_PREVIEW_CHARS = 120;
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
return Object.prototype.toString.call(value) === '[object Object]';
|
||||
};
|
||||
|
||||
const clampWidth = (value: number, minWidth: number, maxWidth: number) => {
|
||||
const safeMin = Math.max(1, Math.floor(minWidth));
|
||||
const safeMax = Math.max(safeMin, Math.floor(maxWidth));
|
||||
return Math.min(safeMax, Math.max(safeMin, Math.ceil(value)));
|
||||
};
|
||||
|
||||
const normalizePreviewLine = (value: string): string => {
|
||||
const normalized = String(value ?? '').replace(/\r\n/g, '\n');
|
||||
if (normalized.length <= AUTO_FIT_MAX_PREVIEW_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, AUTO_FIT_MAX_PREVIEW_CHARS)}…`;
|
||||
};
|
||||
|
||||
const splitPreviewLines = (value: string): string[] => {
|
||||
return normalizePreviewLine(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.length > 0);
|
||||
};
|
||||
|
||||
export const normalizeAutoFitCellText = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return normalizePreviewLine(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 80) {
|
||||
return `[Array(${value.length})]`;
|
||||
}
|
||||
try {
|
||||
return normalizePreviewLine(JSON.stringify(value));
|
||||
} catch {
|
||||
return '[Array]';
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
const topLevelSize = Object.keys(value).length;
|
||||
if (topLevelSize > 80) {
|
||||
return `{Object(${topLevelSize})}`;
|
||||
}
|
||||
try {
|
||||
return normalizePreviewLine(JSON.stringify(value));
|
||||
} catch {
|
||||
return '[Object]';
|
||||
}
|
||||
}
|
||||
|
||||
return normalizePreviewLine(String(value));
|
||||
};
|
||||
|
||||
export const calculateAutoFitColumnWidth = ({
|
||||
headerTexts,
|
||||
valueTexts,
|
||||
measureHeaderText,
|
||||
measureCellText,
|
||||
minWidth = AUTO_FIT_DEFAULT_MIN_WIDTH,
|
||||
maxWidth = AUTO_FIT_DEFAULT_MAX_WIDTH,
|
||||
padding = AUTO_FIT_DEFAULT_PADDING,
|
||||
sampleLimit = AUTO_FIT_DEFAULT_SAMPLE_LIMIT,
|
||||
defaultWidth,
|
||||
}: {
|
||||
headerTexts: Array<string | null | undefined>;
|
||||
valueTexts: unknown[];
|
||||
measureHeaderText: (text: string) => number;
|
||||
measureCellText: (text: string) => number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
padding?: number;
|
||||
sampleLimit?: number;
|
||||
defaultWidth: number;
|
||||
}): number => {
|
||||
const safePadding = Math.max(0, Math.ceil(padding));
|
||||
let widestTextWidth = Math.max(0, Number(defaultWidth) - safePadding);
|
||||
|
||||
headerTexts.forEach((text) => {
|
||||
splitPreviewLines(normalizeAutoFitCellText(text ?? '')).forEach((line) => {
|
||||
widestTextWidth = Math.max(widestTextWidth, measureHeaderText(line));
|
||||
});
|
||||
});
|
||||
|
||||
valueTexts.slice(0, Math.max(1, sampleLimit)).forEach((value) => {
|
||||
splitPreviewLines(normalizeAutoFitCellText(value)).forEach((line) => {
|
||||
widestTextWidth = Math.max(widestTextWidth, measureCellText(line));
|
||||
});
|
||||
});
|
||||
|
||||
return clampWidth(widestTextWidth + safePadding, minWidth, maxWidth);
|
||||
};
|
||||
162
frontend/src/components/dataGridCopyInsert.test.ts
Normal file
162
frontend/src/components/dataGridCopyInsert.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCopyDeleteSQL,
|
||||
buildCopyInsertSQL,
|
||||
buildCopyUpdateSQL,
|
||||
resolveUniqueKeyGroupsFromIndexes,
|
||||
} 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');`,
|
||||
);
|
||||
});
|
||||
|
||||
it('groups composite unique indexes by name and sequence order', () => {
|
||||
expect(resolveUniqueKeyGroupsFromIndexes([
|
||||
{ name: 'PRIMARY', columnName: 'id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
|
||||
{ name: 'uk_order_code', columnName: 'code', nonUnique: 0, seqInIndex: 2, indexType: 'BTREE' },
|
||||
{ name: 'uk_order_code', columnName: 'tenant_id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
|
||||
{ name: 'idx_note', columnName: 'note', nonUnique: 1, seqInIndex: 1, indexType: 'BTREE' },
|
||||
])).toEqual([
|
||||
['id'],
|
||||
['tenant_id', 'code'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds UPDATE SQL with a primary-key WHERE clause and keeps literal formatting aligned with INSERT', () => {
|
||||
const result = buildCopyUpdateSQL({
|
||||
dbType: 'mysql',
|
||||
tableName: 'orders',
|
||||
orderedCols: ['id', 'note', 'deleted_at'],
|
||||
record: {
|
||||
id: 7,
|
||||
note: "O'Brien",
|
||||
deleted_at: null,
|
||||
},
|
||||
pkColumns: ['id'],
|
||||
columnTypesByLowerName: {
|
||||
deleted_at: 'datetime',
|
||||
},
|
||||
allTableColumns: ['id', 'note', 'deleted_at'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
whereStrategy: 'primary-key',
|
||||
sql: `UPDATE \`orders\` SET \`id\` = '7', \`note\` = 'O''Brien', \`deleted_at\` = NULL WHERE (\`id\` = '7');`,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds DELETE SQL with a composite unique-key WHERE clause when no primary key is available', () => {
|
||||
const result = buildCopyDeleteSQL({
|
||||
dbType: 'postgres',
|
||||
tableName: 'public.audit_log',
|
||||
orderedCols: ['tenant_id', 'code', 'payload'],
|
||||
record: {
|
||||
tenant_id: 'acme',
|
||||
code: 'evt-7',
|
||||
payload: '{"ok":true}',
|
||||
},
|
||||
uniqueKeyGroups: [['tenant_id', 'code']],
|
||||
allTableColumns: ['tenant_id', 'code', 'payload'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
whereStrategy: 'unique-key',
|
||||
sql: `DELETE FROM public.audit_log WHERE (tenant_id = 'acme' AND code = 'evt-7');`,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to all-column matching and uses IS NULL for null values', () => {
|
||||
const result = buildCopyDeleteSQL({
|
||||
dbType: 'sqlserver',
|
||||
tableName: 'dbo.OrderLog',
|
||||
orderedCols: ['id', 'deleted_at', 'flag'],
|
||||
allTableColumns: ['id', 'deleted_at', 'flag'],
|
||||
record: {
|
||||
id: 5,
|
||||
deleted_at: null,
|
||||
flag: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
whereStrategy: 'all-columns',
|
||||
sql: `DELETE FROM [dbo].[OrderLog] WHERE ([id] = '5' AND [deleted_at] IS NULL AND [flag] = 'true');`,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to build UPDATE/DELETE SQL when the result set lacks keys and does not cover all table columns', () => {
|
||||
const result = buildCopyDeleteSQL({
|
||||
dbType: 'mysql',
|
||||
tableName: 'orders',
|
||||
orderedCols: ['note'],
|
||||
allTableColumns: ['id', 'note', 'created_at'],
|
||||
record: {
|
||||
note: 'partial row',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error('expected buildCopyDeleteSQL to fail');
|
||||
}
|
||||
expect(result.error).toContain('主键');
|
||||
expect(result.error).toContain('全部字段');
|
||||
});
|
||||
});
|
||||
417
frontend/src/components/dataGridCopyInsert.ts
Normal file
417
frontend/src/components/dataGridCopyInsert.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import type { IndexDefinition } from '../types';
|
||||
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||
|
||||
type BuildCopyInsertSQLParams = {
|
||||
dbType: string;
|
||||
tableName?: string;
|
||||
orderedCols: string[];
|
||||
record: Record<string, any>;
|
||||
columnTypesByLowerName?: Record<string, string>;
|
||||
};
|
||||
|
||||
type BuildCopyMutationSQLParams = BuildCopyInsertSQLParams & {
|
||||
pkColumns?: string[];
|
||||
uniqueKeyGroups?: string[][];
|
||||
allTableColumns?: string[];
|
||||
};
|
||||
|
||||
type CopySqlWhereStrategy = 'primary-key' | 'unique-key' | 'all-columns';
|
||||
|
||||
export type CopyMutationSQLResult =
|
||||
| { ok: true; sql: string; whereStrategy: CopySqlWhereStrategy }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type CopyMutationWhereClauseResult =
|
||||
| { ok: true; clause: string; whereStrategy: CopySqlWhereStrategy }
|
||||
| { ok: false; error: 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}`;
|
||||
};
|
||||
|
||||
const getColumnType = (columnTypesByLowerName: Record<string, string>, columnName: string): string | undefined => (
|
||||
columnTypesByLowerName[String(columnName || '').toLowerCase()]
|
||||
);
|
||||
|
||||
const getRecordValue = (
|
||||
record: Record<string, any>,
|
||||
columnName: string,
|
||||
): { exists: boolean; value: any } => {
|
||||
if (Object.prototype.hasOwnProperty.call(record || {}, columnName)) {
|
||||
return { exists: true, value: record?.[columnName] };
|
||||
}
|
||||
const loweredColumnName = String(columnName || '').toLowerCase();
|
||||
const matchedKey = Object.keys(record || {}).find((key) => key.toLowerCase() === loweredColumnName);
|
||||
if (!matchedKey) {
|
||||
return { exists: false, value: undefined };
|
||||
}
|
||||
return { exists: true, value: record?.[matchedKey] };
|
||||
};
|
||||
|
||||
const normalizeColumnList = (columns: string[] | undefined): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
(columns || []).forEach((column) => {
|
||||
const normalized = String(column || '').trim();
|
||||
if (!normalized) return;
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (seen.has(lowered)) return;
|
||||
seen.add(lowered);
|
||||
result.push(normalized);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const toNormalizedLiteralText = (value: any, columnType?: string): string => {
|
||||
if (typeof value === 'string') {
|
||||
return normalizeTemporalLiteralText(value, columnType, true);
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return formatLocalDateTimeLiteral(value);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const formatCopySqlLiteral = (value: any, columnType?: string): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'NULL';
|
||||
}
|
||||
return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`;
|
||||
};
|
||||
|
||||
const doesResultCoverAllTableColumns = (orderedCols: string[], allTableColumns: string[]): boolean => {
|
||||
const normalizedOrderedCols = normalizeColumnList(orderedCols);
|
||||
const normalizedAllTableColumns = normalizeColumnList(allTableColumns);
|
||||
if (normalizedOrderedCols.length === 0 || normalizedOrderedCols.length !== normalizedAllTableColumns.length) {
|
||||
return false;
|
||||
}
|
||||
const orderedSet = new Set(normalizedOrderedCols.map((column) => column.toLowerCase()));
|
||||
return normalizedAllTableColumns.every((column) => orderedSet.has(column.toLowerCase()));
|
||||
};
|
||||
|
||||
const buildWhereClauseForColumns = ({
|
||||
dbType,
|
||||
columns,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues,
|
||||
}: {
|
||||
dbType: string;
|
||||
columns: string[];
|
||||
record: Record<string, any>;
|
||||
columnTypesByLowerName: Record<string, string>;
|
||||
requireNonNullValues: boolean;
|
||||
}): string | null => {
|
||||
const predicates: string[] = [];
|
||||
for (const columnName of columns) {
|
||||
const { exists, value } = getRecordValue(record, columnName);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
const quotedColumn = quoteIdentPart(dbType, columnName);
|
||||
if (value === null || value === undefined) {
|
||||
if (requireNonNullValues) {
|
||||
return null;
|
||||
}
|
||||
predicates.push(`${quotedColumn} IS NULL`);
|
||||
continue;
|
||||
}
|
||||
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`);
|
||||
}
|
||||
if (predicates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return `(${predicates.join(' AND ')})`;
|
||||
};
|
||||
|
||||
const resolveMutationWhereClause = ({
|
||||
dbType,
|
||||
orderedCols,
|
||||
record,
|
||||
pkColumns = [],
|
||||
uniqueKeyGroups = [],
|
||||
allTableColumns = [],
|
||||
columnTypesByLowerName = {},
|
||||
}: BuildCopyMutationSQLParams): CopyMutationWhereClauseResult => {
|
||||
const normalizedPkColumns = normalizeColumnList(pkColumns);
|
||||
const pkWhereClause = buildWhereClauseForColumns({
|
||||
dbType,
|
||||
columns: normalizedPkColumns,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues: true,
|
||||
});
|
||||
if (pkWhereClause) {
|
||||
return { ok: true, clause: pkWhereClause, whereStrategy: 'primary-key' };
|
||||
}
|
||||
|
||||
const normalizedUniqueKeyGroups = (uniqueKeyGroups || [])
|
||||
.map((group) => normalizeColumnList(group))
|
||||
.filter((group) => group.length > 0);
|
||||
for (const group of normalizedUniqueKeyGroups) {
|
||||
const uniqueWhereClause = buildWhereClauseForColumns({
|
||||
dbType,
|
||||
columns: group,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues: true,
|
||||
});
|
||||
if (uniqueWhereClause) {
|
||||
return { ok: true, clause: uniqueWhereClause, whereStrategy: 'unique-key' };
|
||||
}
|
||||
}
|
||||
|
||||
if (doesResultCoverAllTableColumns(orderedCols, allTableColumns)) {
|
||||
const fullRowWhereClause = buildWhereClauseForColumns({
|
||||
dbType,
|
||||
columns: orderedCols,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues: false,
|
||||
});
|
||||
if (fullRowWhereClause) {
|
||||
return { ok: true, clause: fullRowWhereClause, whereStrategy: 'all-columns' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: '当前结果集缺少可安全定位行数据的主键/唯一键,且未覆盖表的全部字段,无法生成 WHERE 条件。',
|
||||
};
|
||||
};
|
||||
|
||||
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 } = getRecordValue(record, col);
|
||||
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col));
|
||||
});
|
||||
|
||||
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
|
||||
};
|
||||
|
||||
const buildCopyMutationSQL = (
|
||||
mode: 'update' | 'delete',
|
||||
{
|
||||
dbType,
|
||||
tableName,
|
||||
orderedCols,
|
||||
record,
|
||||
pkColumns = [],
|
||||
uniqueKeyGroups = [],
|
||||
allTableColumns = [],
|
||||
columnTypesByLowerName = {},
|
||||
}: BuildCopyMutationSQLParams,
|
||||
): CopyMutationSQLResult => {
|
||||
const normalizedTableName = String(tableName || '').trim();
|
||||
const normalizedOrderedCols = normalizeColumnList(orderedCols);
|
||||
if (!normalizedTableName) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `当前结果集未关联明确表名,无法生成 ${mode.toUpperCase()} SQL。`,
|
||||
};
|
||||
}
|
||||
if (normalizedOrderedCols.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: '当前结果集没有可复制的字段,无法生成 SQL。',
|
||||
};
|
||||
}
|
||||
|
||||
const whereClause = resolveMutationWhereClause({
|
||||
dbType,
|
||||
orderedCols: normalizedOrderedCols,
|
||||
record,
|
||||
pkColumns,
|
||||
uniqueKeyGroups,
|
||||
allTableColumns,
|
||||
columnTypesByLowerName,
|
||||
});
|
||||
if (whereClause.ok === false) {
|
||||
return { ok: false, error: whereClause.error };
|
||||
}
|
||||
|
||||
const targetTable = quoteQualifiedIdent(dbType, normalizedTableName);
|
||||
if (mode === 'delete') {
|
||||
return {
|
||||
ok: true,
|
||||
sql: `DELETE FROM ${targetTable} WHERE ${whereClause.clause};`,
|
||||
whereStrategy: whereClause.whereStrategy,
|
||||
};
|
||||
}
|
||||
|
||||
const assignments = normalizedOrderedCols.map((columnName) => {
|
||||
const { value } = getRecordValue(record, columnName);
|
||||
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`;
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sql: `UPDATE ${targetTable} SET ${assignments.join(', ')} WHERE ${whereClause.clause};`,
|
||||
whereStrategy: whereClause.whereStrategy,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCopyUpdateSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
|
||||
buildCopyMutationSQL('update', params)
|
||||
);
|
||||
|
||||
export const buildCopyDeleteSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
|
||||
buildCopyMutationSQL('delete', params)
|
||||
);
|
||||
|
||||
export const resolveUniqueKeyGroupsFromIndexes = (indexes: IndexDefinition[] | undefined): string[][] => {
|
||||
type IndexBucket = {
|
||||
order: number;
|
||||
columns: Array<{ columnName: string; seqInIndex: number; order: number }>;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, IndexBucket>();
|
||||
(indexes || []).forEach((index, order) => {
|
||||
if (index?.nonUnique !== 0) {
|
||||
return;
|
||||
}
|
||||
const name = String(index?.name || '').trim();
|
||||
const columnName = String(index?.columnName || '').trim();
|
||||
if (!name || !columnName) {
|
||||
return;
|
||||
}
|
||||
if (!buckets.has(name)) {
|
||||
buckets.set(name, { order, columns: [] });
|
||||
}
|
||||
const bucket = buckets.get(name);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.columns.push({
|
||||
columnName,
|
||||
seqInIndex: Number.isFinite(Number(index?.seqInIndex)) ? Number(index.seqInIndex) : 0,
|
||||
order,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(buckets.values())
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((bucket) => {
|
||||
const seen = new Set<string>();
|
||||
return bucket.columns
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftSeq = left.seqInIndex > 0 ? left.seqInIndex : Number.MAX_SAFE_INTEGER;
|
||||
const rightSeq = right.seqInIndex > 0 ? right.seqInIndex : Number.MAX_SAFE_INTEGER;
|
||||
if (leftSeq !== rightSeq) {
|
||||
return leftSeq - rightSeq;
|
||||
}
|
||||
return left.order - right.order;
|
||||
})
|
||||
.map((item) => item.columnName)
|
||||
.filter((columnName) => {
|
||||
const lowered = columnName.toLowerCase();
|
||||
if (seen.has(lowered)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(lowered);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.filter((group) => group.length > 0);
|
||||
};
|
||||
43
frontend/src/components/dataGridSelectionCopy.test.ts
Normal file
43
frontend/src/components/dataGridSelectionCopy.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
|
||||
|
||||
describe('dataGridSelectionCopy helpers', () => {
|
||||
it('builds clipboard text in visible row and column order', () => {
|
||||
const text = buildSelectedCellClipboardText({
|
||||
selectedCells: [
|
||||
{ rowKey: 'row-2', colName: 'name' },
|
||||
{ rowKey: 'row-1', colName: 'id' },
|
||||
{ rowKey: 'row-1', colName: 'name' },
|
||||
{ rowKey: 'row-2', colName: 'id' },
|
||||
],
|
||||
rows: [
|
||||
{ __rowKey: 'row-1', id: 1, name: 'Alice' },
|
||||
{ __rowKey: 'row-2', id: 2, name: 'Bob' },
|
||||
],
|
||||
columnOrder: ['id', 'name', 'email'],
|
||||
rowKeyField: '__rowKey',
|
||||
});
|
||||
|
||||
expect(text).toBe('1\tAlice\n2\tBob');
|
||||
});
|
||||
|
||||
it('normalizes null, objects and multiline text for clipboard safety', () => {
|
||||
const text = buildSelectedCellClipboardText({
|
||||
selectedCells: [
|
||||
{ rowKey: 'row-1', colName: 'notes' },
|
||||
{ rowKey: 'row-1', colName: 'meta' },
|
||||
{ rowKey: 'row-2', colName: 'notes' },
|
||||
{ rowKey: 'row-2', colName: 'meta' },
|
||||
],
|
||||
rows: [
|
||||
{ __rowKey: 'row-1', notes: null, meta: { a: 1 } },
|
||||
{ __rowKey: 'row-2', notes: 'line1\nline2\tvalue', meta: [1, 2] },
|
||||
],
|
||||
columnOrder: ['notes', 'meta'],
|
||||
rowKeyField: '__rowKey',
|
||||
});
|
||||
|
||||
expect(text).toBe('NULL\t{"a":1}\nline1 line2 value\t[1,2]');
|
||||
});
|
||||
});
|
||||
65
frontend/src/components/dataGridSelectionCopy.ts
Normal file
65
frontend/src/components/dataGridSelectionCopy.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface SelectedGridCell {
|
||||
rowKey: string;
|
||||
colName: string;
|
||||
}
|
||||
|
||||
const normalizeClipboardCellValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(/\r\n/g, '\n').replace(/[\t\n\r]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value).replace(/[\t\n\r]+/g, ' ').trim();
|
||||
} catch {
|
||||
return String(value).replace(/[\t\n\r]+/g, ' ').trim();
|
||||
}
|
||||
};
|
||||
|
||||
export const buildSelectedCellClipboardText = ({
|
||||
selectedCells,
|
||||
rows,
|
||||
columnOrder,
|
||||
rowKeyField,
|
||||
}: {
|
||||
selectedCells: SelectedGridCell[];
|
||||
rows: Array<Record<string, any>>;
|
||||
columnOrder: string[];
|
||||
rowKeyField: string;
|
||||
}): string => {
|
||||
if (!selectedCells.length || !rows.length || !columnOrder.length || !rowKeyField) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const selectedRowKeys = new Set(selectedCells.map((cell) => cell.rowKey));
|
||||
const selectedColumnKeys = new Set(selectedCells.map((cell) => cell.colName));
|
||||
const orderedRows = rows.filter((row) => selectedRowKeys.has(String(row?.[rowKeyField] ?? '')));
|
||||
const orderedColumns = columnOrder.filter((columnName) => selectedColumnKeys.has(columnName));
|
||||
|
||||
if (!orderedRows.length || !orderedColumns.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const selectedCellKeySet = new Set(selectedCells.map((cell) => `${cell.rowKey}::${cell.colName}`));
|
||||
|
||||
return orderedRows
|
||||
.map((row) => {
|
||||
const rowKey = String(row?.[rowKeyField] ?? '');
|
||||
return orderedColumns
|
||||
.map((columnName) => {
|
||||
if (!selectedCellKeySet.has(`${rowKey}::${columnName}`)) {
|
||||
return '';
|
||||
}
|
||||
return normalizeClipboardCellValue(row?.[columnName]);
|
||||
})
|
||||
.join('\t');
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
18
frontend/src/components/tableDataDangerActions.test.ts
Normal file
18
frontend/src/components/tableDataDangerActions.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { supportsTableTruncateAction } from './tableDataDangerActions';
|
||||
|
||||
describe('tableDataDangerActions', () => {
|
||||
it('supports native truncate for known relational dialects', () => {
|
||||
expect(supportsTableTruncateAction('mysql')).toBe(true);
|
||||
expect(supportsTableTruncateAction('postgres')).toBe(true);
|
||||
expect(supportsTableTruncateAction('custom', 'postgresql')).toBe(true);
|
||||
expect(supportsTableTruncateAction('custom', 'kingbase8')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects truncate for unsupported or document-style backends', () => {
|
||||
expect(supportsTableTruncateAction('sqlite')).toBe(false);
|
||||
expect(supportsTableTruncateAction('mongodb')).toBe(false);
|
||||
expect(supportsTableTruncateAction('custom', 'sqlite3')).toBe(false);
|
||||
});
|
||||
});
|
||||
82
frontend/src/components/tableDataDangerActions.ts
Normal file
82
frontend/src/components/tableDataDangerActions.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export type TableDataDangerActionKind = 'truncate' | 'clear';
|
||||
|
||||
const resolveCustomDriverDialect = (driver: string): string => {
|
||||
const normalized = String(driver || '').trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'postgresql':
|
||||
case 'postgres':
|
||||
case 'pg':
|
||||
case 'pq':
|
||||
case 'pgx':
|
||||
return 'postgres';
|
||||
case 'dm':
|
||||
case 'dameng':
|
||||
case 'dm8':
|
||||
return 'dameng';
|
||||
case 'sqlite3':
|
||||
case 'sqlite':
|
||||
return 'sqlite';
|
||||
case 'sphinxql':
|
||||
return 'sphinx';
|
||||
case 'diros':
|
||||
case 'doris':
|
||||
return 'diros';
|
||||
case 'kingbase':
|
||||
case 'kingbase8':
|
||||
case 'kingbasees':
|
||||
case 'kingbasev8':
|
||||
return 'kingbase';
|
||||
case 'highgo':
|
||||
return 'highgo';
|
||||
case 'vastbase':
|
||||
return 'vastbase';
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (normalized.includes('postgres')) return 'postgres';
|
||||
if (normalized.includes('kingbase')) return 'kingbase';
|
||||
if (normalized.includes('highgo')) return 'highgo';
|
||||
if (normalized.includes('vastbase')) return 'vastbase';
|
||||
if (normalized.includes('sqlite')) return 'sqlite';
|
||||
if (normalized.includes('sphinx')) return 'sphinx';
|
||||
if (normalized.includes('diros') || normalized.includes('doris')) return 'diros';
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const resolveTableDataActionDBType = (type: string, driver?: string): string => {
|
||||
const normalizedType = String(type || '').trim().toLowerCase();
|
||||
if (normalizedType !== 'custom') {
|
||||
return normalizedType;
|
||||
}
|
||||
return resolveCustomDriverDialect(driver || '');
|
||||
};
|
||||
|
||||
export const supportsTableTruncateAction = (type: string, driver?: string): boolean => {
|
||||
switch (resolveTableDataActionDBType(type, driver)) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
case 'sqlserver':
|
||||
case 'oracle':
|
||||
case 'dameng':
|
||||
case 'clickhouse':
|
||||
case 'duckdb':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTableDataDangerActionMeta = (action: TableDataDangerActionKind): {
|
||||
label: string;
|
||||
progressLabel: string;
|
||||
} => {
|
||||
if (action === 'truncate') {
|
||||
return { label: '截断表', progressLabel: '截断' };
|
||||
}
|
||||
return { label: '清空表', progressLabel: '清空' };
|
||||
};
|
||||
54
frontend/src/components/tableDesignerSchemaSql.test.ts
Normal file
54
frontend/src/components/tableDesignerSchemaSql.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAlterTablePreviewSql,
|
||||
type BuildAlterTablePreviewInput,
|
||||
type EditableColumnSnapshot,
|
||||
} from './tableDesignerSchemaSql';
|
||||
|
||||
const baseColumn = (overrides: Partial<EditableColumnSnapshot>): EditableColumnSnapshot => ({
|
||||
_key: overrides._key || 'col',
|
||||
name: overrides.name || 'id',
|
||||
type: overrides.type || 'int',
|
||||
nullable: overrides.nullable || 'NO',
|
||||
default: overrides.default || '',
|
||||
extra: overrides.extra || '',
|
||||
comment: overrides.comment || '',
|
||||
key: overrides.key || '',
|
||||
isAutoIncrement: overrides.isAutoIncrement || false,
|
||||
});
|
||||
|
||||
const buildInput = (overrides: Partial<BuildAlterTablePreviewInput>): BuildAlterTablePreviewInput => ({
|
||||
dbType: overrides.dbType || 'mysql',
|
||||
tableName: overrides.tableName || 'users',
|
||||
originalColumns: overrides.originalColumns || [baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' })],
|
||||
columns: overrides.columns || [
|
||||
baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' }),
|
||||
baseColumn({ _key: 'age', name: 'age', nullable: 'YES', comment: '年龄' }),
|
||||
],
|
||||
});
|
||||
|
||||
describe('tableDesignerSchemaSql', () => {
|
||||
it('keeps mysql alter preview syntax with column position clauses', () => {
|
||||
const sql = buildAlterTablePreviewSql(buildInput({ dbType: 'mysql' }));
|
||||
|
||||
expect(sql).toContain('ALTER TABLE `users`');
|
||||
expect(sql).toContain('ADD COLUMN `age` int NULL');
|
||||
expect(sql).toContain("COMMENT '年龄'");
|
||||
expect(sql).toContain('AFTER `id`');
|
||||
});
|
||||
|
||||
it('builds kingbase alter preview without mysql-only syntax', () => {
|
||||
const sql = buildAlterTablePreviewSql(buildInput({
|
||||
dbType: 'kingbase',
|
||||
tableName: 'public.users',
|
||||
}));
|
||||
|
||||
expect(sql).toContain('ALTER TABLE public.users');
|
||||
expect(sql).toContain('ADD COLUMN age int');
|
||||
expect(sql).toContain("COMMENT ON COLUMN public.users.age IS '年龄';");
|
||||
expect(sql).not.toContain('`');
|
||||
expect(sql).not.toContain('AFTER');
|
||||
expect(sql).not.toContain(' FIRST');
|
||||
});
|
||||
});
|
||||
255
frontend/src/components/tableDesignerSchemaSql.ts
Normal file
255
frontend/src/components/tableDesignerSchemaSql.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
export interface EditableColumnSnapshot {
|
||||
_key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: string;
|
||||
default?: string | null;
|
||||
extra?: string;
|
||||
comment?: string;
|
||||
key?: string;
|
||||
isAutoIncrement?: boolean;
|
||||
}
|
||||
|
||||
export interface BuildAlterTablePreviewInput {
|
||||
dbType: string;
|
||||
tableName: string;
|
||||
originalColumns: EditableColumnSnapshot[];
|
||||
columns: EditableColumnSnapshot[];
|
||||
}
|
||||
|
||||
const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
|
||||
const escapeBacktickIdentifier = (value: string) => String(value || '').replace(/`/g, '``');
|
||||
const escapeDoubleQuoteIdentifier = (value: string) => String(value || '').replace(/"/g, '""');
|
||||
|
||||
const stripIdentifierQuotes = (part: string): string => {
|
||||
const text = String(part || '').trim();
|
||||
if (!text) return '';
|
||||
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
|
||||
return text.slice(1, -1).trim();
|
||||
}
|
||||
if (text.startsWith('[') && text.endsWith(']')) {
|
||||
return text.slice(1, -1).replace(/]]/g, ']').trim();
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
|
||||
const raw = String(qualifiedName || '').trim();
|
||||
if (!raw) return { schemaName: '', objectName: '' };
|
||||
const idx = raw.lastIndexOf('.');
|
||||
if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw };
|
||||
return {
|
||||
schemaName: stripIdentifierQuotes(raw.substring(0, idx)),
|
||||
objectName: stripIdentifierQuotes(raw.substring(idx + 1)),
|
||||
};
|
||||
};
|
||||
|
||||
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
|
||||
const isPgLikeDialect = (dbType: string): boolean =>
|
||||
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase';
|
||||
|
||||
const needsPgLikeQuote = (ident: string): boolean => !/^[a-z_][a-z0-9_]*$/.test(ident);
|
||||
|
||||
const quoteIdentifierPart = (part: string, dbType: string): string => {
|
||||
const ident = stripIdentifierQuotes(part);
|
||||
if (!ident) return '';
|
||||
if (isMysqlLikeDialect(dbType)) {
|
||||
return `\`${escapeBacktickIdentifier(ident)}\``;
|
||||
}
|
||||
if (isPgLikeDialect(dbType)) {
|
||||
if (!needsPgLikeQuote(ident)) {
|
||||
return ident;
|
||||
}
|
||||
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
|
||||
}
|
||||
return ident;
|
||||
};
|
||||
|
||||
const quoteIdentifierPath = (path: string, dbType: string): string =>
|
||||
String(path || '')
|
||||
.trim()
|
||||
.split('.')
|
||||
.map((part) => stripIdentifierQuotes(part))
|
||||
.filter(Boolean)
|
||||
.map((part) => quoteIdentifierPart(part, dbType))
|
||||
.join('.');
|
||||
|
||||
const formatPgLikeDefault = (value: string): string => {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (/^'.*'$/.test(trimmed)) return trimmed;
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
||||
if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase() === 'NULL' ? 'NULL' : trimmed.toUpperCase();
|
||||
if (/^(current_timestamp|current_date|current_time)$/i.test(trimmed)) return trimmed.toUpperCase();
|
||||
if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return trimmed;
|
||||
return `'${escapeSqlString(trimmed)}'`;
|
||||
};
|
||||
|
||||
const buildMySqlColumnDefinition = (column: EditableColumnSnapshot): string => {
|
||||
let extra = String(column.extra || '');
|
||||
if (column.isAutoIncrement) {
|
||||
if (!extra.toLowerCase().includes('auto_increment')) {
|
||||
extra += ' AUTO_INCREMENT';
|
||||
}
|
||||
} else {
|
||||
extra = extra.replace(/auto_increment/gi, '').trim();
|
||||
}
|
||||
const defaultSql = column.default ? `DEFAULT '${escapeSqlString(String(column.default))}'` : '';
|
||||
return `${quoteIdentifierPart(column.name, 'mysql')} ${column.type} ${column.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${defaultSql} ${extra} COMMENT '${escapeSqlString(column.comment || '')}'`.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
const buildPgLikeColumnDefinition = (column: EditableColumnSnapshot): string => {
|
||||
const parts = [quoteIdentifierPart(column.name, 'postgres'), String(column.type || '').trim()];
|
||||
const defaultValue = String(column.default || '').trim();
|
||||
if (defaultValue) {
|
||||
parts.push(`DEFAULT ${formatPgLikeDefault(defaultValue)}`);
|
||||
}
|
||||
if (column.nullable === 'NO') {
|
||||
parts.push('NOT NULL');
|
||||
}
|
||||
return parts.join(' ').trim();
|
||||
};
|
||||
|
||||
const buildPgLikeCommentSql = (tableRef: string, columnName: string, comment: string): string => {
|
||||
const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, 'postgres')}`;
|
||||
const trimmed = String(comment || '').trim();
|
||||
if (!trimmed) {
|
||||
return `COMMENT ON COLUMN ${columnRef} IS NULL;`;
|
||||
}
|
||||
return `COMMENT ON COLUMN ${columnRef} IS '${escapeSqlString(trimmed)}';`;
|
||||
};
|
||||
|
||||
const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
|
||||
const tableName = quoteIdentifierPath(input.tableName, 'mysql');
|
||||
const alters: string[] = [];
|
||||
|
||||
input.originalColumns.forEach((orig) => {
|
||||
if (!input.columns.find((col) => col._key === orig._key)) {
|
||||
alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, 'mysql')}`);
|
||||
}
|
||||
});
|
||||
|
||||
input.columns.forEach((curr, index) => {
|
||||
const orig = input.originalColumns.find((col) => col._key === curr._key);
|
||||
const prevCol = index > 0 ? input.columns[index - 1] : null;
|
||||
const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, 'mysql')}` : 'FIRST';
|
||||
const colDef = buildMySqlColumnDefinition(curr);
|
||||
|
||||
if (!orig) {
|
||||
alters.push(`ADD COLUMN ${colDef} ${positionSql}`.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
curr.name !== orig.name ||
|
||||
curr.type !== orig.type ||
|
||||
curr.nullable !== orig.nullable ||
|
||||
curr.default !== orig.default ||
|
||||
(curr.comment || '') !== (orig.comment || '') ||
|
||||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
|
||||
) {
|
||||
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim());
|
||||
}
|
||||
});
|
||||
|
||||
const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key);
|
||||
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
|
||||
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
|
||||
if (keysChanged) {
|
||||
if (origPKKeys.length > 0) {
|
||||
alters.push('DROP PRIMARY KEY');
|
||||
}
|
||||
if (newPKKeys.length > 0) {
|
||||
const pkNames = input.columns
|
||||
.filter((col) => col.key === 'PRI')
|
||||
.map((col) => quoteIdentifierPart(col.name, 'mysql'))
|
||||
.join(', ');
|
||||
alters.push(`ADD PRIMARY KEY (${pkNames})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (alters.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
|
||||
};
|
||||
|
||||
const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
|
||||
const tableParts = splitQualifiedName(input.tableName);
|
||||
const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName);
|
||||
const tableRef = quoteIdentifierPath(input.tableName, 'postgres');
|
||||
const statements: string[] = [];
|
||||
|
||||
input.originalColumns.forEach((orig) => {
|
||||
if (!input.columns.find((col) => col._key === orig._key)) {
|
||||
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, 'postgres')};`);
|
||||
}
|
||||
});
|
||||
|
||||
input.columns.forEach((curr) => {
|
||||
const orig = input.originalColumns.find((col) => col._key === curr._key);
|
||||
if (!orig) {
|
||||
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr)};`);
|
||||
if (String(curr.comment || '').trim()) {
|
||||
statements.push(buildPgLikeCommentSql(tableRef, curr.name, curr.comment || ''));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let currentName = orig.name;
|
||||
if (curr.name !== orig.name) {
|
||||
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, 'postgres')} TO ${quoteIdentifierPart(curr.name, 'postgres')};`);
|
||||
currentName = curr.name;
|
||||
}
|
||||
|
||||
if (curr.type !== orig.type) {
|
||||
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} TYPE ${curr.type};`);
|
||||
}
|
||||
|
||||
const currDefault = String(curr.default || '').trim();
|
||||
const origDefault = String(orig.default || '').trim();
|
||||
if (currDefault !== origDefault) {
|
||||
if (currDefault) {
|
||||
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} SET DEFAULT ${formatPgLikeDefault(currDefault)};`);
|
||||
} else {
|
||||
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} DROP DEFAULT;`);
|
||||
}
|
||||
}
|
||||
|
||||
if (curr.nullable !== orig.nullable) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`,
|
||||
);
|
||||
}
|
||||
|
||||
if ((curr.comment || '') !== (orig.comment || '')) {
|
||||
statements.push(buildPgLikeCommentSql(tableRef, currentName, curr.comment || ''));
|
||||
}
|
||||
});
|
||||
|
||||
const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key);
|
||||
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
|
||||
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
|
||||
if (keysChanged) {
|
||||
if (origPKKeys.length > 0) {
|
||||
statements.push(`ALTER TABLE ${tableRef}\nDROP CONSTRAINT IF EXISTS ${quoteIdentifierPart(`${baseTableName}_pkey`, 'postgres')};`);
|
||||
}
|
||||
if (newPKKeys.length > 0) {
|
||||
const pkNames = input.columns
|
||||
.filter((col) => col.key === 'PRI')
|
||||
.map((col) => quoteIdentifierPart(col.name, 'postgres'))
|
||||
.join(', ');
|
||||
statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`);
|
||||
}
|
||||
}
|
||||
|
||||
return statements.join('\n');
|
||||
};
|
||||
|
||||
export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => {
|
||||
const dbType = String(input.dbType || '').trim().toLowerCase();
|
||||
if (isPgLikeDialect(dbType)) {
|
||||
return buildPgLikeAlterPreviewSql({ ...input, dbType });
|
||||
}
|
||||
return buildMySqlAlterPreviewSql({ ...input, dbType });
|
||||
};
|
||||
@@ -3,23 +3,117 @@ 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 仍为英文。
|
||||
import 'monaco-editor/esm/nls.messages.zh-cn'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { cloneBrowserMockValue, duplicateBrowserMockConnection, resolveBrowserMockSecretFlag } from './utils/browserMockConnections'
|
||||
loader.config({ monaco })
|
||||
|
||||
if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
const mockConnections: any[] = [];
|
||||
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
|
||||
let mockDataRootInfo: any = {
|
||||
path: 'C:/mock/.gonavi',
|
||||
defaultPath: 'C:/mock/.gonavi',
|
||||
driverPath: 'C:/mock/.gonavi/drivers',
|
||||
isDefaultPath: true,
|
||||
bootstrapPath: 'C:/mock/.gonavi/storage_root.json',
|
||||
};
|
||||
|
||||
const upsertMockConnection = (view: any) => {
|
||||
const index = mockConnections.findIndex((item) => item.id === view.id);
|
||||
if (index >= 0) {
|
||||
mockConnections[index] = view;
|
||||
return;
|
||||
}
|
||||
mockConnections.push(view);
|
||||
};
|
||||
|
||||
const saveMockConnection = (input: any) => {
|
||||
const existing = mockConnections.find((item) => item.id === input?.id);
|
||||
const config = (input?.config && typeof input.config === 'object') ? input.config : {};
|
||||
const ssh = (config.ssh && typeof config.ssh === 'object') ? config.ssh : {};
|
||||
const proxy = (config.proxy && typeof config.proxy === 'object') ? config.proxy : {};
|
||||
const httpTunnel = (config.httpTunnel && typeof config.httpTunnel === 'object') ? config.httpTunnel : {};
|
||||
const nextId = String(input?.id || existing?.id || `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
const view = {
|
||||
id: nextId,
|
||||
name: String(input?.name || existing?.name || '未命名连接'),
|
||||
config: {
|
||||
...config,
|
||||
id: nextId,
|
||||
password: '',
|
||||
ssh: { ...ssh, password: '' },
|
||||
proxy: { ...proxy, password: '' },
|
||||
httpTunnel: { ...httpTunnel, password: '' },
|
||||
uri: '',
|
||||
dsn: '',
|
||||
mysqlReplicaPassword: '',
|
||||
mongoReplicaPassword: '',
|
||||
},
|
||||
includeDatabases: Array.isArray(input?.includeDatabases) ? [...input.includeDatabases] : existing?.includeDatabases,
|
||||
includeRedisDatabases: Array.isArray(input?.includeRedisDatabases) ? [...input.includeRedisDatabases] : existing?.includeRedisDatabases,
|
||||
iconType: typeof input?.iconType === 'string' ? input.iconType : (existing?.iconType || ''),
|
||||
iconColor: typeof input?.iconColor === 'string' ? input.iconColor : (existing?.iconColor || ''),
|
||||
hasPrimaryPassword: resolveBrowserMockSecretFlag(config.password, !!input?.clearPrimaryPassword, existing?.hasPrimaryPassword),
|
||||
hasSSHPassword: resolveBrowserMockSecretFlag(ssh.password, !!input?.clearSSHPassword, existing?.hasSSHPassword),
|
||||
hasProxyPassword: resolveBrowserMockSecretFlag(proxy.password, !!input?.clearProxyPassword, existing?.hasProxyPassword),
|
||||
hasHttpTunnelPassword: resolveBrowserMockSecretFlag(httpTunnel.password, !!input?.clearHttpTunnelPassword, existing?.hasHttpTunnelPassword),
|
||||
hasMySQLReplicaPassword: resolveBrowserMockSecretFlag(config.mysqlReplicaPassword, !!input?.clearMySQLReplicaPassword, existing?.hasMySQLReplicaPassword),
|
||||
hasMongoReplicaPassword: resolveBrowserMockSecretFlag(config.mongoReplicaPassword, !!input?.clearMongoReplicaPassword, existing?.hasMongoReplicaPassword),
|
||||
hasOpaqueURI: resolveBrowserMockSecretFlag(config.uri, !!input?.clearOpaqueURI, existing?.hasOpaqueURI),
|
||||
hasOpaqueDSN: resolveBrowserMockSecretFlag(config.dsn, !!input?.clearOpaqueDSN, existing?.hasOpaqueDSN),
|
||||
};
|
||||
upsertMockConnection(view);
|
||||
return cloneBrowserMockValue(view);
|
||||
};
|
||||
|
||||
const saveMockGlobalProxy = (input: any) => {
|
||||
const nextPassword = String(input?.password ?? '');
|
||||
mockGlobalProxy = {
|
||||
...mockGlobalProxy,
|
||||
...input,
|
||||
password: '',
|
||||
hasPassword: nextPassword !== '' ? true : !!mockGlobalProxy.hasPassword,
|
||||
};
|
||||
return cloneBrowserMockValue(mockGlobalProxy);
|
||||
};
|
||||
|
||||
(window as any).go = {
|
||||
app: {
|
||||
App: {
|
||||
CheckUpdate: async () => ({ success: false }),
|
||||
DownloadUpdate: async () => ({ success: false }),
|
||||
GetSavedConnections: async () => [],
|
||||
SaveConnection: async () => null,
|
||||
DeleteConnection: async () => null,
|
||||
GetSavedConnections: async () => cloneBrowserMockValue(mockConnections),
|
||||
SaveConnection: async (input: any) => saveMockConnection(input),
|
||||
DeleteConnection: async (id: string) => {
|
||||
const index = mockConnections.findIndex((item) => item.id === id);
|
||||
if (index >= 0) {
|
||||
mockConnections.splice(index, 1);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
DuplicateConnection: async (id: string) => {
|
||||
const existing = mockConnections.find((item) => item.id === id);
|
||||
if (!existing) return null;
|
||||
const duplicated = duplicateBrowserMockConnection({
|
||||
existing,
|
||||
items: mockConnections,
|
||||
nextId: `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
});
|
||||
mockConnections.push(duplicated);
|
||||
return cloneBrowserMockValue(duplicated);
|
||||
},
|
||||
ImportLegacyConnections: async (items: any[]) => items.map((item) => saveMockConnection(item)),
|
||||
OpenConnection: async () => null,
|
||||
CloseConnection: async () => null,
|
||||
GetDatabases: async () => [],
|
||||
@@ -31,16 +125,32 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
SaveQuery: async () => null,
|
||||
DeleteQuery: async () => null,
|
||||
GetAppInfo: async () => ({}),
|
||||
GetDataRootDirectoryInfo: async () => ({ success: true, data: cloneBrowserMockValue(mockDataRootInfo) }),
|
||||
CheckForUpdates: async () => ({ success: false }),
|
||||
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
|
||||
OpenDriverDownloadDirectory: async (path: string) => ({ success: true, data: { path } }),
|
||||
OpenDataRootDirectory: async () => ({ success: true }),
|
||||
InstallUpdateAndRestart: async () => ({ success: false }),
|
||||
ImportConfigFile: async () => ({ success: false }),
|
||||
ExportData: async () => ({ success: false }),
|
||||
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
|
||||
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
|
||||
ImportLegacyGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
|
||||
SelectDataRootDirectory: async (currentPath: string) => ({ success: true, data: { ...mockDataRootInfo, path: currentPath || mockDataRootInfo.path } }),
|
||||
ApplyDataRootDirectory: async (path: string) => {
|
||||
const nextPath = String(path || mockDataRootInfo.defaultPath);
|
||||
mockDataRootInfo = {
|
||||
...mockDataRootInfo,
|
||||
path: nextPath,
|
||||
driverPath: `${nextPath}/drivers`,
|
||||
isDefaultPath: nextPath === mockDataRootInfo.defaultPath,
|
||||
};
|
||||
return { success: true, message: '数据目录已更新', data: cloneBrowserMockValue(mockDataRootInfo) };
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
|
||||
monaco.editor.defineTheme('transparent-dark', {
|
||||
base: 'vs-dark', inherit: true, rules: [],
|
||||
@@ -56,3 +166,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
17
frontend/src/node-test-shims.d.ts
vendored
Normal file
17
frontend/src/node-test-shims.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
declare module 'node:fs' {
|
||||
export function readFileSync(path: string | URL, encoding: string): string;
|
||||
}
|
||||
|
||||
declare module 'node:path' {
|
||||
interface PathModule {
|
||||
dirname(path: string): string;
|
||||
resolve(...paths: string[]): string;
|
||||
}
|
||||
|
||||
const path: PathModule;
|
||||
export default path;
|
||||
}
|
||||
|
||||
declare module 'node:url' {
|
||||
export function fileURLToPath(url: string | URL): string;
|
||||
}
|
||||
24
frontend/src/sidebarTreeScrollCss.test.ts
Normal file
24
frontend/src/sidebarTreeScrollCss.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const appCss = readFileSync(path.resolve(__dirname, './App.css'), 'utf8');
|
||||
|
||||
describe('sidebar tree horizontal scroll css', () => {
|
||||
it('keeps the virtual tree width anchored to the sidebar by default', () => {
|
||||
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-list-holder,\s*\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-list-holder-inner\s*\{[^}]*min-width:\s*100%;/s);
|
||||
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-list-holder,\s*\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-list-holder-inner\s*\{[^}]*max-content/s);
|
||||
|
||||
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-treenode\s*\{[^}]*width:\s*auto;[^}]*min-width:\s*100%;/s);
|
||||
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-treenode\s*\{[^}]*width:\s*max-content/s);
|
||||
|
||||
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-node-content-wrapper\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*0;/s);
|
||||
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-node-content-wrapper\s*\{[^}]*max-content/s);
|
||||
|
||||
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-title\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*visible;/s);
|
||||
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-title\s*\{[^}]*max-content/s);
|
||||
});
|
||||
});
|
||||
94
frontend/src/store.test.ts
Normal file
94
frontend/src/store.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private data = new Map<string, string>();
|
||||
|
||||
get length(): number {
|
||||
return this.data.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.data.clear();
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.data.has(key) ? this.data.get(key)! : null;
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.data.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.data.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.data.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const importStore = async () => {
|
||||
const store = await import('./store');
|
||||
await store.useStore.persist.rehydrate();
|
||||
return store;
|
||||
};
|
||||
|
||||
describe('store appearance persistence', () => {
|
||||
let storage: MemoryStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('fills missing DataGrid appearance settings with defaults during hydration', async () => {
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
appearance: {
|
||||
enabled: false,
|
||||
opacity: 0.75,
|
||||
blur: 6,
|
||||
useNativeMacWindowControls: true,
|
||||
},
|
||||
},
|
||||
version: 7,
|
||||
}));
|
||||
|
||||
const { useStore } = await importStore();
|
||||
const appearance = useStore.getState().appearance;
|
||||
|
||||
expect(appearance.enabled).toBe(false);
|
||||
expect(appearance.opacity).toBe(0.75);
|
||||
expect(appearance.blur).toBe(6);
|
||||
expect(appearance.useNativeMacWindowControls).toBe(true);
|
||||
expect(appearance.showDataTableVerticalBorders).toBe(false);
|
||||
expect(appearance.dataTableColumnWidthMode).toBe('standard');
|
||||
});
|
||||
|
||||
it('persists DataGrid appearance settings and restores them after reload', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().setAppearance({
|
||||
showDataTableVerticalBorders: true,
|
||||
dataTableColumnWidthMode: 'compact',
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.appearance.showDataTableVerticalBorders).toBe(true);
|
||||
expect(persisted.state.appearance.dataTableColumnWidthMode).toBe('compact');
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await importStore();
|
||||
const appearance = reloaded.useStore.getState().appearance;
|
||||
|
||||
expect(appearance.showDataTableVerticalBorders).toBe(true);
|
||||
expect(appearance.dataTableColumnWidthMode).toBe('compact');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem } from './types';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem, GlobalProxyConfig } from './types';
|
||||
import {
|
||||
ShortcutAction,
|
||||
ShortcutBinding,
|
||||
@@ -9,8 +9,27 @@ import {
|
||||
cloneShortcutOptions,
|
||||
sanitizeShortcutOptions,
|
||||
} from './utils/shortcuts';
|
||||
import { toPersistedGlobalProxy } from './utils/globalProxyDraft';
|
||||
import {
|
||||
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||||
sanitizeDataGridDisplaySettings,
|
||||
type DataGridDisplaySettings,
|
||||
} from './utils/dataGridDisplay';
|
||||
|
||||
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false };
|
||||
export interface AppearanceSettings extends DataGridDisplaySettings {
|
||||
enabled: boolean;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
useNativeMacWindowControls: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_APPEARANCE: AppearanceSettings = {
|
||||
enabled: true,
|
||||
opacity: 1.0,
|
||||
blur: 0,
|
||||
useNativeMacWindowControls: false,
|
||||
...DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||||
};
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
@@ -25,7 +44,7 @@ const MAX_HOST_ENTRY_LENGTH = 512;
|
||||
const MAX_HOST_ENTRIES = 64;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const PERSIST_VERSION = 7;
|
||||
const PERSIST_VERSION = 8;
|
||||
const DEFAULT_CONNECTION_TYPE = 'mysql';
|
||||
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
enabled: false,
|
||||
@@ -34,6 +53,7 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
port: 1080,
|
||||
user: '',
|
||||
password: '',
|
||||
hasPassword: false,
|
||||
};
|
||||
const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
'mysql',
|
||||
@@ -246,6 +266,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
|
||||
const safeConfig: ConnectionConfig & Record<string, unknown> = {
|
||||
...raw,
|
||||
id: toTrimmedString(raw.id ?? raw.ID),
|
||||
type,
|
||||
host: toTrimmedString(raw.host, 'localhost') || 'localhost',
|
||||
port: normalizePort(raw.port, defaultPort),
|
||||
@@ -321,7 +342,16 @@ const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
config,
|
||||
config: { ...config, id: config.id || id },
|
||||
secretRef: toTrimmedString(raw.secretRef) || undefined,
|
||||
hasPrimaryPassword: raw.hasPrimaryPassword === true,
|
||||
hasSSHPassword: raw.hasSSHPassword === true,
|
||||
hasProxyPassword: raw.hasProxyPassword === true,
|
||||
hasHttpTunnelPassword: raw.hasHttpTunnelPassword === true,
|
||||
hasMySQLReplicaPassword: raw.hasMySQLReplicaPassword === true,
|
||||
hasMongoReplicaPassword: raw.hasMongoReplicaPassword === true,
|
||||
hasOpaqueURI: raw.hasOpaqueURI === true,
|
||||
hasOpaqueDSN: raw.hasOpaqueDSN === true,
|
||||
includeDatabases: includeDatabases.length > 0 ? includeDatabases : undefined,
|
||||
includeRedisDatabases: includeRedisDatabases.length > 0 ? includeRedisDatabases : undefined,
|
||||
};
|
||||
@@ -393,10 +423,6 @@ export interface QueryOptions {
|
||||
showColumnType: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalProxyConfig extends ProxyConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
connections: SavedConnection[];
|
||||
connectionTags: ConnectionTag[];
|
||||
@@ -405,7 +431,7 @@ interface AppState {
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean };
|
||||
appearance: AppearanceSettings;
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
startupFullscreen: boolean;
|
||||
@@ -440,6 +466,7 @@ interface AppState {
|
||||
addConnection: (conn: SavedConnection) => void;
|
||||
updateConnection: (conn: SavedConnection) => void;
|
||||
removeConnection: (id: string) => void;
|
||||
replaceConnections: (connections: SavedConnection[]) => void;
|
||||
|
||||
addConnectionTag: (tag: ConnectionTag) => void;
|
||||
updateConnectionTag: (tag: ConnectionTag) => void;
|
||||
@@ -463,11 +490,12 @@ interface AppState {
|
||||
deleteQuery: (id: string) => void;
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }>) => void;
|
||||
setAppearance: (appearance: Partial<AppearanceSettings>) => void;
|
||||
setUiScale: (scale: number) => void;
|
||||
setFontSize: (size: number) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
setGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
|
||||
replaceGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
||||
updateShortcut: (action: ShortcutAction, binding: Partial<ShortcutBinding>) => void;
|
||||
@@ -586,12 +614,13 @@ const sanitizeTableHiddenColumns = (value: unknown): Record<string, string[]> =>
|
||||
};
|
||||
|
||||
const sanitizeAppearance = (
|
||||
appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }> | undefined,
|
||||
appearance: Partial<AppearanceSettings> | undefined,
|
||||
version: number
|
||||
): { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean } => {
|
||||
): AppearanceSettings => {
|
||||
if (!appearance || typeof appearance !== 'object') {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
}
|
||||
const dataGridDisplaySettings = sanitizeDataGridDisplaySettings(appearance);
|
||||
const nextAppearance = {
|
||||
enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled,
|
||||
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
|
||||
@@ -599,6 +628,8 @@ const sanitizeAppearance = (
|
||||
useNativeMacWindowControls: typeof appearance.useNativeMacWindowControls === 'boolean'
|
||||
? appearance.useNativeMacWindowControls
|
||||
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
|
||||
showDataTableVerticalBorders: dataGridDisplaySettings.showDataTableVerticalBorders,
|
||||
dataTableColumnWidthMode: dataGridDisplaySettings.dataTableColumnWidthMode,
|
||||
};
|
||||
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
@@ -618,18 +649,24 @@ const sanitizeFontSize = (value: unknown): number => {
|
||||
return normalizeIntegerInRange(value, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
};
|
||||
|
||||
const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
|
||||
const sanitizeGlobalProxy = (
|
||||
value: unknown,
|
||||
options: { allowPassword?: boolean } = {}
|
||||
): GlobalProxyConfig => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase();
|
||||
const type: 'socks5' | 'http' = typeRaw === 'http' ? 'http' : 'socks5';
|
||||
const fallbackPort = type === 'http' ? 8080 : 1080;
|
||||
const password = toTrimmedString(raw.password);
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
type,
|
||||
host: toTrimmedString(raw.host),
|
||||
port: normalizePort(raw.port, fallbackPort),
|
||||
user: toTrimmedString(raw.user),
|
||||
password: toTrimmedString(raw.password),
|
||||
password: options.allowPassword === false ? '' : password,
|
||||
hasPassword: raw.hasPassword === true || password !== '',
|
||||
secretRef: toTrimmedString(raw.secretRef) || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -782,6 +819,7 @@ export const useStore = create<AppState>()(
|
||||
connectionIds: tag.connectionIds.filter(cid => cid !== id)
|
||||
}))
|
||||
})),
|
||||
replaceConnections: (connections) => set({ connections: sanitizeConnections(connections) }),
|
||||
|
||||
addConnectionTag: (tag) => set((state) => ({ connectionTags: [...state.connectionTags, tag] })),
|
||||
updateConnectionTag: (tag) => set((state) => ({
|
||||
@@ -963,6 +1001,7 @@ export const useStore = create<AppState>()(
|
||||
setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }),
|
||||
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
|
||||
setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })),
|
||||
replaceGlobalProxy: (proxy) => set({ globalProxy: sanitizeGlobalProxy({ ...DEFAULT_GLOBAL_PROXY, ...proxy }) }),
|
||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
|
||||
updateShortcut: (action, binding) => set((state) => ({
|
||||
@@ -1203,7 +1242,7 @@ export const useStore = create<AppState>()(
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||
const nextState: Partial<AppState> = { ...state };
|
||||
nextState.connections = sanitizeConnections(state.connections);
|
||||
nextState.connections = [];
|
||||
if (version < 5) {
|
||||
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
|
||||
} else {
|
||||
@@ -1215,7 +1254,7 @@ export const useStore = create<AppState>()(
|
||||
nextState.uiScale = sanitizeUiScale(state.uiScale);
|
||||
nextState.fontSize = sanitizeFontSize(state.fontSize);
|
||||
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
|
||||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
|
||||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy, { allowPassword: false });
|
||||
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
|
||||
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
|
||||
nextState.shortcutOptions = sanitizeShortcutOptions(state.shortcutOptions);
|
||||
@@ -1242,7 +1281,7 @@ export const useStore = create<AppState>()(
|
||||
return {
|
||||
...currentState,
|
||||
...state,
|
||||
connections: sanitizeConnections(state.connections),
|
||||
connections: currentState.connections,
|
||||
connectionTags: sanitizeConnectionTags(state.connectionTags),
|
||||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||||
theme: sanitizeTheme(state.theme),
|
||||
@@ -1250,7 +1289,7 @@ export const useStore = create<AppState>()(
|
||||
uiScale: sanitizeUiScale(state.uiScale),
|
||||
fontSize: sanitizeFontSize(state.fontSize),
|
||||
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
|
||||
globalProxy: sanitizeGlobalProxy(state.globalProxy),
|
||||
globalProxy: sanitizeGlobalProxy(state.globalProxy, { allowPassword: false }),
|
||||
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
|
||||
tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders),
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
|
||||
@@ -1271,7 +1310,6 @@ export const useStore = create<AppState>()(
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
connections: state.connections,
|
||||
connectionTags: state.connectionTags,
|
||||
savedQueries: state.savedQueries,
|
||||
theme: state.theme,
|
||||
@@ -1279,7 +1317,7 @@ export const useStore = create<AppState>()(
|
||||
uiScale: state.uiScale,
|
||||
fontSize: state.fontSize,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
globalProxy: state.globalProxy,
|
||||
globalProxy: toPersistedGlobalProxy(state.globalProxy),
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
queryOptions: state.queryOptions,
|
||||
shortcutOptions: state.shortcutOptions,
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface HTTPTunnelConfig {
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
id?: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -70,12 +71,27 @@ export interface SavedConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
config: ConnectionConfig;
|
||||
secretRef?: string;
|
||||
hasPrimaryPassword?: boolean;
|
||||
hasSSHPassword?: boolean;
|
||||
hasProxyPassword?: boolean;
|
||||
hasHttpTunnelPassword?: boolean;
|
||||
hasMySQLReplicaPassword?: boolean;
|
||||
hasMongoReplicaPassword?: boolean;
|
||||
hasOpaqueURI?: boolean;
|
||||
hasOpaqueDSN?: boolean;
|
||||
includeDatabases?: string[];
|
||||
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
|
||||
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
|
||||
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
|
||||
}
|
||||
|
||||
export interface GlobalProxyConfig extends ProxyConfig {
|
||||
enabled: boolean;
|
||||
hasPassword?: boolean;
|
||||
secretRef?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionTag {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -118,7 +134,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;
|
||||
@@ -201,10 +217,12 @@ export interface AIProviderConfig {
|
||||
type: AIProviderType;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
secretRef?: string;
|
||||
hasSecret?: boolean;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
models?: string[];
|
||||
apiFormat?: string; // custom 专用: openai | anthropic | gemini
|
||||
apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli
|
||||
headers?: Record<string, string>;
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
@@ -244,3 +262,4 @@ export interface AISafetyResult {
|
||||
warningMessage?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
33
frontend/src/utils/aiComposerNotice.test.ts
Normal file
33
frontend/src/utils/aiComposerNotice.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildModelFetchFailedNotice,
|
||||
buildMissingModelNotice,
|
||||
buildMissingProviderNotice,
|
||||
} from './aiComposerNotice';
|
||||
|
||||
describe('ai composer notice helpers', () => {
|
||||
it('builds a compact notice for missing provider', () => {
|
||||
expect(buildMissingProviderNotice()).toEqual({
|
||||
tone: 'warning',
|
||||
title: '还没有可用供应商',
|
||||
description: '先在 AI 设置里添加并启用一个模型供应商。',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a compact notice for missing model selection', () => {
|
||||
expect(buildMissingModelNotice()).toEqual({
|
||||
tone: 'warning',
|
||||
title: '先选择一个模型',
|
||||
description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a readable inline notice for model fetch failures', () => {
|
||||
expect(buildModelFetchFailedNotice('当前接口未返回可用模型')).toEqual({
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
description: '当前接口未返回可用模型',
|
||||
});
|
||||
});
|
||||
});
|
||||
27
frontend/src/utils/aiComposerNotice.ts
Normal file
27
frontend/src/utils/aiComposerNotice.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type AIComposerNoticeTone = 'warning' | 'error';
|
||||
|
||||
export interface AIComposerNotice {
|
||||
tone: AIComposerNoticeTone;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const defaultModelFetchFailedDescription = '请检查供应商入口、API Key 或账号权限,然后重新打开模型下拉。';
|
||||
|
||||
export const buildMissingProviderNotice = (): AIComposerNotice => ({
|
||||
tone: 'warning',
|
||||
title: '还没有可用供应商',
|
||||
description: '先在 AI 设置里添加并启用一个模型供应商。',
|
||||
});
|
||||
|
||||
export const buildMissingModelNotice = (): AIComposerNotice => ({
|
||||
tone: 'warning',
|
||||
title: '先选择一个模型',
|
||||
description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
|
||||
});
|
||||
|
||||
export const buildModelFetchFailedNotice = (error?: string): AIComposerNotice => ({
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
description: String(error || '').trim() || defaultModelFetchFailedDescription,
|
||||
});
|
||||
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,
|
||||
};
|
||||
};
|
||||
49
frontend/src/utils/aiProviderEditorState.test.ts
Normal file
49
frontend/src/utils/aiProviderEditorState.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAddProviderEditorSession,
|
||||
buildClosedProviderEditorSession,
|
||||
buildEditProviderEditorSession,
|
||||
} from './aiProviderEditorState';
|
||||
|
||||
describe('aiProviderEditorState', () => {
|
||||
it('resets clearProviderSecret when starting add flow', () => {
|
||||
const session = buildAddProviderEditorSession({
|
||||
previousClearProviderSecret: true,
|
||||
presetBackendType: 'openai',
|
||||
presetBaseUrl: 'https://api.openai.com/v1',
|
||||
presetModel: 'gpt-4.1',
|
||||
});
|
||||
|
||||
expect(session.clearProviderSecret).toBe(false);
|
||||
expect(session.isEditing).toBe(true);
|
||||
expect(session.testStatus).toBe('idle');
|
||||
});
|
||||
|
||||
it('resets clearProviderSecret when starting edit flow', () => {
|
||||
const session = buildEditProviderEditorSession({
|
||||
previousClearProviderSecret: true,
|
||||
provider: {
|
||||
id: 'provider-1',
|
||||
type: 'openai',
|
||||
name: 'OpenAI',
|
||||
apiKey: '',
|
||||
hasSecret: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(session.clearProviderSecret).toBe(false);
|
||||
expect(session.isEditing).toBe(true);
|
||||
expect(session.editingProvider?.id).toBe('provider-1');
|
||||
});
|
||||
|
||||
it('resets clearProviderSecret when the modal closes', () => {
|
||||
const session = buildClosedProviderEditorSession({
|
||||
previousClearProviderSecret: true,
|
||||
});
|
||||
|
||||
expect(session.clearProviderSecret).toBe(false);
|
||||
expect(session.isEditing).toBe(false);
|
||||
expect(session.editingProvider).toBeNull();
|
||||
});
|
||||
});
|
||||
92
frontend/src/utils/aiProviderEditorState.ts
Normal file
92
frontend/src/utils/aiProviderEditorState.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { AIProviderConfig, AIProviderType } from '../types';
|
||||
|
||||
type ProviderEditorStatus = 'idle' | 'success' | 'error';
|
||||
|
||||
type ProviderEditorConfig = Partial<AIProviderConfig> & Pick<AIProviderConfig, 'id' | 'type' | 'name' | 'apiKey'> & { presetKey?: string };
|
||||
|
||||
export interface ProviderEditorSession {
|
||||
editingProvider: ProviderEditorConfig | null;
|
||||
formValues: Record<string, unknown> | null;
|
||||
isEditing: boolean;
|
||||
clearProviderSecret: boolean;
|
||||
testStatus: ProviderEditorStatus;
|
||||
}
|
||||
|
||||
interface BuildAddProviderEditorSessionInput {
|
||||
previousClearProviderSecret?: boolean;
|
||||
presetKey?: string;
|
||||
presetBackendType: AIProviderType;
|
||||
presetBaseUrl: string;
|
||||
presetModel: string;
|
||||
presetModels?: string[];
|
||||
apiFormat?: string;
|
||||
}
|
||||
|
||||
interface BuildEditProviderEditorSessionInput {
|
||||
previousClearProviderSecret?: boolean;
|
||||
provider: ProviderEditorConfig;
|
||||
formValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface BuildClosedProviderEditorSessionInput {
|
||||
previousClearProviderSecret?: boolean;
|
||||
}
|
||||
|
||||
export const buildAddProviderEditorSession = ({
|
||||
presetKey = 'openai',
|
||||
presetBackendType,
|
||||
presetBaseUrl,
|
||||
presetModel,
|
||||
presetModels = [],
|
||||
apiFormat = 'openai',
|
||||
}: BuildAddProviderEditorSessionInput): ProviderEditorSession => {
|
||||
const editingProvider: ProviderEditorConfig = {
|
||||
id: '',
|
||||
type: presetBackendType,
|
||||
name: '',
|
||||
apiKey: '',
|
||||
baseUrl: presetBaseUrl,
|
||||
model: presetModel,
|
||||
models: [...presetModels],
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
presetKey,
|
||||
};
|
||||
|
||||
return {
|
||||
editingProvider,
|
||||
formValues: {
|
||||
...editingProvider,
|
||||
presetKey,
|
||||
apiFormat,
|
||||
},
|
||||
isEditing: true,
|
||||
clearProviderSecret: false,
|
||||
testStatus: 'idle',
|
||||
};
|
||||
};
|
||||
|
||||
export const buildEditProviderEditorSession = ({
|
||||
provider,
|
||||
formValues,
|
||||
}: BuildEditProviderEditorSessionInput): ProviderEditorSession => ({
|
||||
editingProvider: provider,
|
||||
formValues: formValues || {
|
||||
...provider,
|
||||
models: provider.models || [],
|
||||
presetKey: provider.presetKey,
|
||||
apiFormat: provider.apiFormat || 'openai',
|
||||
},
|
||||
isEditing: true,
|
||||
clearProviderSecret: false,
|
||||
testStatus: 'idle',
|
||||
});
|
||||
|
||||
export const buildClosedProviderEditorSession = (_input?: BuildClosedProviderEditorSessionInput): ProviderEditorSession => ({
|
||||
editingProvider: null,
|
||||
formValues: null,
|
||||
isEditing: false,
|
||||
clearProviderSecret: false,
|
||||
testStatus: 'idle',
|
||||
});
|
||||
|
||||
185
frontend/src/utils/aiProviderPresets.test.ts
Normal file
185
frontend/src/utils/aiProviderPresets.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AIProviderType } from '../types';
|
||||
import {
|
||||
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({
|
||||
type: 'openai',
|
||||
baseUrl: QWEN_BAILIAN_MODELS_BASE_URL,
|
||||
})).toBe('qwen-bailian');
|
||||
});
|
||||
|
||||
it('maps Coding Plan Claude CLI config back to the dedicated Coding Plan preset', () => {
|
||||
expect(matchQwenPresetKey({
|
||||
type: 'custom',
|
||||
apiFormat: 'claude-cli',
|
||||
baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
})).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',
|
||||
'kimi-k2.5',
|
||||
'glm-5',
|
||||
'MiniMax-M2.5',
|
||||
'qwen3-max-2026-01-23',
|
||||
'qwen3-coder-next',
|
||||
'qwen3-coder-plus',
|
||||
'glm-4.7',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps built-in preset model empty when the preset intentionally requires an explicit selection', () => {
|
||||
expect(resolvePresetModelSelection({
|
||||
presetKey: 'qwen-coding-plan',
|
||||
presetDefaultModel: '',
|
||||
presetModels: QWEN_CODING_PLAN_MODELS,
|
||||
valuesModel: '',
|
||||
customModels: [],
|
||||
})).toEqual({
|
||||
model: '',
|
||||
models: QWEN_CODING_PLAN_MODELS,
|
||||
});
|
||||
});
|
||||
|
||||
it('still falls back to the first configured model for custom-like presets', () => {
|
||||
expect(resolvePresetModelSelection({
|
||||
presetKey: 'custom',
|
||||
presetDefaultModel: '',
|
||||
presetModels: [],
|
||||
valuesModel: '',
|
||||
customModels: ['foo-model', 'bar-model'],
|
||||
})).toEqual({
|
||||
model: 'foo-model',
|
||||
models: ['foo-model', 'bar-model'],
|
||||
});
|
||||
});
|
||||
|
||||
it('forces built-in presets back to their standard base URL when saving or testing', () => {
|
||||
expect(resolvePresetBaseURL({
|
||||
presetKey: 'qwen-bailian',
|
||||
presetDefaultBaseUrl: 'https://dashscope.aliyuncs.com/apps/anthropic',
|
||||
valuesBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
})).toBe('https://dashscope.aliyuncs.com/apps/anthropic');
|
||||
});
|
||||
|
||||
it('keeps the user-entered base URL for custom-like presets', () => {
|
||||
expect(resolvePresetBaseURL({
|
||||
presetKey: 'custom',
|
||||
presetDefaultBaseUrl: '',
|
||||
valuesBaseUrl: 'https://example-proxy.internal/v1',
|
||||
})).toBe('https://example-proxy.internal/v1');
|
||||
});
|
||||
|
||||
it('forces qwen coding plan to save as custom plus claude-cli', () => {
|
||||
expect(resolvePresetTransport({
|
||||
presetBackendType: 'custom',
|
||||
presetFixedApiFormat: 'claude-cli',
|
||||
valuesApiFormat: 'anthropic',
|
||||
})).toEqual({
|
||||
type: 'custom',
|
||||
apiFormat: 'claude-cli',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps custom preset transport editable', () => {
|
||||
expect(resolvePresetTransport({
|
||||
presetBackendType: 'custom',
|
||||
valuesApiFormat: 'gemini',
|
||||
})).toEqual({
|
||||
type: 'custom',
|
||||
apiFormat: 'gemini',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
216
frontend/src/utils/aiProviderPresets.ts
Normal file
216
frontend/src/utils/aiProviderPresets.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import type { AIProviderConfig, AIProviderType } from '../types';
|
||||
|
||||
export const LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
export const LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL = 'https://coding.dashscope.aliyuncs.com/v1';
|
||||
export const QWEN_BAILIAN_ANTHROPIC_BASE_URL = 'https://dashscope.aliyuncs.com/apps/anthropic';
|
||||
export const QWEN_CODING_PLAN_ANTHROPIC_BASE_URL = 'https://coding.dashscope.aliyuncs.com/apps/anthropic';
|
||||
export const QWEN_BAILIAN_MODELS_BASE_URL = LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL;
|
||||
|
||||
export const QWEN_CODING_PLAN_MODELS = [
|
||||
'qwen3.5-plus',
|
||||
'kimi-k2.5',
|
||||
'glm-5',
|
||||
'MiniMax-M2.5',
|
||||
'qwen3-max-2026-01-23',
|
||||
'qwen3-coder-next',
|
||||
'qwen3-coder-plus',
|
||||
'glm-4.7',
|
||||
];
|
||||
|
||||
const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama']);
|
||||
|
||||
export interface ResolvePresetModelSelectionInput {
|
||||
presetKey: string;
|
||||
presetDefaultModel: string;
|
||||
presetModels: string[];
|
||||
valuesModel?: string;
|
||||
customModels?: string[];
|
||||
}
|
||||
|
||||
export interface ResolvePresetModelSelectionResult {
|
||||
model: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export interface ResolvePresetBaseURLInput {
|
||||
presetKey: string;
|
||||
presetDefaultBaseUrl: string;
|
||||
valuesBaseUrl?: string;
|
||||
}
|
||||
|
||||
export interface ResolvePresetTransportInput {
|
||||
presetBackendType: AIProviderType;
|
||||
presetFixedApiFormat?: string;
|
||||
valuesApiFormat?: string;
|
||||
}
|
||||
|
||||
export interface ResolvePresetTransportResult {
|
||||
type: AIProviderType;
|
||||
apiFormat?: string;
|
||||
}
|
||||
|
||||
export interface ProviderPresetMatcher {
|
||||
key: string;
|
||||
backendType: AIProviderType;
|
||||
defaultBaseUrl: string;
|
||||
fixedApiFormat?: string;
|
||||
}
|
||||
|
||||
export const getProviderHostname = (raw?: string): string => {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return new URL(raw).hostname.toLowerCase();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getProviderFingerprint = (raw?: string): string => {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, '').toLowerCase();
|
||||
return `${url.hostname.toLowerCase()}${normalizedPath}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const matchQwenPresetKey = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): string | null => {
|
||||
const fingerprint = getProviderFingerprint(provider.baseUrl);
|
||||
|
||||
if (
|
||||
fingerprint !== ''
|
||||
&& fingerprint === getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL)
|
||||
&& provider.type === 'anthropic'
|
||||
) {
|
||||
return 'qwen-bailian';
|
||||
}
|
||||
|
||||
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,
|
||||
presetModels,
|
||||
valuesModel,
|
||||
customModels,
|
||||
}: ResolvePresetModelSelectionInput): ResolvePresetModelSelectionResult => {
|
||||
const isCustomLike = CUSTOM_LIKE_PRESET_KEYS.has(presetKey);
|
||||
const resolvedModels = isCustomLike ? (customModels || []) : presetModels;
|
||||
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
|
||||
return {
|
||||
models: resolvedModels,
|
||||
model: isCustomLike ? (valuesModel || fallbackModel) : (valuesModel || presetDefaultModel),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolvePresetBaseURL = ({
|
||||
presetKey,
|
||||
presetDefaultBaseUrl,
|
||||
valuesBaseUrl,
|
||||
}: ResolvePresetBaseURLInput): string => {
|
||||
if (CUSTOM_LIKE_PRESET_KEYS.has(presetKey)) {
|
||||
return valuesBaseUrl || presetDefaultBaseUrl;
|
||||
}
|
||||
return presetDefaultBaseUrl;
|
||||
};
|
||||
|
||||
export const resolvePresetTransport = ({
|
||||
presetBackendType,
|
||||
presetFixedApiFormat,
|
||||
valuesApiFormat,
|
||||
}: ResolvePresetTransportInput): ResolvePresetTransportResult => {
|
||||
if (presetFixedApiFormat) {
|
||||
return {
|
||||
type: presetBackendType,
|
||||
apiFormat: presetFixedApiFormat,
|
||||
};
|
||||
}
|
||||
|
||||
if (presetBackendType === 'custom') {
|
||||
return {
|
||||
type: presetBackendType,
|
||||
apiFormat: valuesApiFormat || 'openai',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: presetBackendType,
|
||||
apiFormat: undefined,
|
||||
};
|
||||
};
|
||||
56
frontend/src/utils/aiSettingsPresetLayout.test.ts
Normal file
56
frontend/src/utils/aiSettingsPresetLayout.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||
PROVIDER_PRESET_CARD_CONTENT_STYLE,
|
||||
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
|
||||
PROVIDER_PRESET_GRID_STYLE,
|
||||
PROVIDER_PRESET_CARD_TITLE_STYLE,
|
||||
} from './aiSettingsPresetLayout';
|
||||
|
||||
describe('ai settings preset layout', () => {
|
||||
it('uses a fixed grid auto row height so provider bubbles stay visually consistent across rows', () => {
|
||||
expect(PROVIDER_PRESET_GRID_STYLE).toMatchObject({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: 6,
|
||||
gridAutoRows: '96px',
|
||||
alignItems: 'stretch',
|
||||
});
|
||||
});
|
||||
|
||||
it('stretches each provider card to fill the row height', () => {
|
||||
expect(PROVIDER_PRESET_CARD_BASE_STYLE).toMatchObject({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
height: '100%',
|
||||
minHeight: '96px',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the text column compact instead of pinning the description to the bottom', () => {
|
||||
expect(PROVIDER_PRESET_CARD_CONTENT_STYLE).toMatchObject({
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
expect(PROVIDER_PRESET_CARD_DESCRIPTION_STYLE).toMatchObject({
|
||||
marginTop: 4,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
expect(PROVIDER_PRESET_CARD_TITLE_STYLE).toMatchObject({
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
});
|
||||
});
|
||||
47
frontend/src/utils/aiSettingsPresetLayout.ts
Normal file
47
frontend/src/utils/aiSettingsPresetLayout.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const PROVIDER_PRESET_CARD_HEIGHT = 96;
|
||||
|
||||
export const PROVIDER_PRESET_GRID_STYLE: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: 6,
|
||||
gridAutoRows: `${PROVIDER_PRESET_CARD_HEIGHT}px`,
|
||||
alignItems: 'stretch',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_BASE_STYLE: CSSProperties = {
|
||||
padding: '12px 14px',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
height: '100%',
|
||||
minHeight: `${PROVIDER_PRESET_CARD_HEIGHT}px`,
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_CONTENT_STYLE: CSSProperties = {
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_DESCRIPTION_STYLE: CSSProperties = {
|
||||
marginTop: 4,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_TITLE_STYLE: CSSProperties = {
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
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;
|
||||
};
|
||||
26
frontend/src/utils/browserMockConnections.test.ts
Normal file
26
frontend/src/utils/browserMockConnections.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { duplicateBrowserMockConnection } from './browserMockConnections';
|
||||
|
||||
describe('duplicateBrowserMockConnection', () => {
|
||||
it('rewrites config.id to match the duplicated top-level id', () => {
|
||||
const duplicated = duplicateBrowserMockConnection({
|
||||
existing: {
|
||||
id: 'conn-1',
|
||||
name: 'Primary',
|
||||
config: {
|
||||
id: 'conn-1',
|
||||
type: 'postgres',
|
||||
},
|
||||
includeDatabases: ['appdb'],
|
||||
},
|
||||
items: [],
|
||||
nextId: 'conn-2',
|
||||
});
|
||||
|
||||
expect(duplicated.id).toBe('conn-2');
|
||||
expect(duplicated.config.id).toBe('conn-2');
|
||||
expect(duplicated.name).toBe('Primary - 副本');
|
||||
expect(duplicated.includeDatabases).toEqual(['appdb']);
|
||||
});
|
||||
});
|
||||
47
frontend/src/utils/browserMockConnections.ts
Normal file
47
frontend/src/utils/browserMockConnections.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const cloneBrowserMockValue = <T,>(value: T): T => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveBrowserMockSecretFlag = (nextValue: unknown, clearFlag: boolean, existingFlag?: boolean) => {
|
||||
if (String(nextValue ?? '') !== '') return true;
|
||||
if (clearFlag) return false;
|
||||
return !!existingFlag;
|
||||
};
|
||||
|
||||
export const buildBrowserMockDuplicateName = (rawName: string, items: any[]): string => {
|
||||
const baseName = String(rawName || '').trim() || '连接';
|
||||
const suffix = ' - 副本';
|
||||
const usedNames = new Set(items.map((item) => String(item?.name || '').trim()));
|
||||
let candidate = `${baseName}${suffix}`;
|
||||
let counter = 2;
|
||||
while (usedNames.has(candidate)) {
|
||||
candidate = `${baseName}${suffix} ${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
interface DuplicateBrowserMockConnectionInput {
|
||||
existing: any;
|
||||
items: any[];
|
||||
nextId: string;
|
||||
}
|
||||
|
||||
export const duplicateBrowserMockConnection = ({ existing, items, nextId }: DuplicateBrowserMockConnectionInput) => {
|
||||
const duplicated = cloneBrowserMockValue({
|
||||
...existing,
|
||||
id: nextId,
|
||||
name: buildBrowserMockDuplicateName(existing?.name, items),
|
||||
config: {
|
||||
...cloneBrowserMockValue(existing?.config),
|
||||
id: nextId,
|
||||
},
|
||||
includeDatabases: Array.isArray(existing?.includeDatabases) ? [...existing.includeDatabases] : undefined,
|
||||
includeRedisDatabases: Array.isArray(existing?.includeRedisDatabases) ? [...existing.includeRedisDatabases] : undefined,
|
||||
});
|
||||
return duplicated;
|
||||
};
|
||||
104
frontend/src/utils/connectionRpcConfig.test.ts
Normal file
104
frontend/src/utils/connectionRpcConfig.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { connection } from '../../wailsjs/go/models';
|
||||
import { buildRpcConnectionConfig } from './connectionRpcConfig';
|
||||
|
||||
describe('buildRpcConnectionConfig', () => {
|
||||
it('preserves the saved connection id while normalizing numeric fields', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: '5432' as unknown as number,
|
||||
user: 'postgres',
|
||||
useSSH: true,
|
||||
ssh: {
|
||||
host: 'bastion.local',
|
||||
port: '2222' as unknown as number,
|
||||
user: 'ops',
|
||||
},
|
||||
useProxy: true,
|
||||
proxy: {
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: '8080' as unknown as number,
|
||||
},
|
||||
} as any, {
|
||||
id: 'conn-2',
|
||||
timeout: '120' as unknown as number,
|
||||
redisDB: '6' as unknown as number,
|
||||
database: 'app',
|
||||
});
|
||||
|
||||
expect(result.id).toBe('conn-1');
|
||||
expect(result.port).toBe(5432);
|
||||
expect(result.ssh?.port).toBe(2222);
|
||||
expect(result.proxy?.port).toBe(8080);
|
||||
expect(result.timeout).toBe(120);
|
||||
expect(result.redisDB).toBe(6);
|
||||
expect(result.database).toBe('app');
|
||||
});
|
||||
|
||||
it('fills default nested config blocks needed by RPC calls', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-redis',
|
||||
type: 'redis',
|
||||
host: '127.0.0.1',
|
||||
port: 6379,
|
||||
user: '',
|
||||
} as any, {
|
||||
useSSH: true,
|
||||
useHttpTunnel: true,
|
||||
redisDB: '4' as unknown as number,
|
||||
});
|
||||
|
||||
expect(result.id).toBe('conn-redis');
|
||||
expect(result.redisDB).toBe(4);
|
||||
expect(result.ssh).toEqual({
|
||||
host: '',
|
||||
port: 22,
|
||||
user: '',
|
||||
password: '',
|
||||
keyPath: '',
|
||||
});
|
||||
expect(result.httpTunnel).toEqual({
|
||||
host: '',
|
||||
port: 8080,
|
||||
user: '',
|
||||
password: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a Wails connection model instance for RPC compatibility', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-model',
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: '3306' as unknown as number,
|
||||
user: 'root',
|
||||
useSSH: true,
|
||||
ssh: {
|
||||
host: 'jump.local',
|
||||
port: '2222' as unknown as number,
|
||||
user: 'ops',
|
||||
},
|
||||
useProxy: true,
|
||||
proxy: {
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: '8080' as unknown as number,
|
||||
},
|
||||
useHttpTunnel: true,
|
||||
httpTunnel: {
|
||||
host: '127.0.0.1',
|
||||
port: '9000' as unknown as number,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).toBeInstanceOf(connection.ConnectionConfig);
|
||||
expect(result.ssh).toBeInstanceOf(connection.SSHConfig);
|
||||
expect(result.proxy).toBeInstanceOf(connection.ProxyConfig);
|
||||
expect(result.httpTunnel).toBeInstanceOf(connection.HTTPTunnelConfig);
|
||||
expect(typeof (result as any).convertValues).toBe('function');
|
||||
});
|
||||
});
|
||||
122
frontend/src/utils/connectionRpcConfig.ts
Normal file
122
frontend/src/utils/connectionRpcConfig.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { connection } from '../../wailsjs/go/models';
|
||||
|
||||
export type RpcConnectionConfig = connection.ConnectionConfig & { id?: string };
|
||||
type ConnectionConfigInput = {
|
||||
id?: string;
|
||||
ssh?: Record<string, any>;
|
||||
proxy?: Record<string, any>;
|
||||
httpTunnel?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
};
|
||||
type SSHConfigInput = Record<string, any>;
|
||||
type ProxyConfigInput = Record<string, any>;
|
||||
type HttpTunnelConfigInput = Record<string, any>;
|
||||
|
||||
const toStringValue = (value: unknown, fallback = ''): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const toOptionalInteger = (value: unknown, fallback?: number): number | undefined => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
};
|
||||
|
||||
const normalizeProxyType = (value: unknown): 'socks5' | 'http' => {
|
||||
return toStringValue(value).toLowerCase() === 'http' ? 'http' : 'socks5';
|
||||
};
|
||||
|
||||
const normalizeSSHConfig = (value: unknown): connection.SSHConfig => {
|
||||
const raw = (value ?? {}) as SSHConfigInput;
|
||||
return new connection.SSHConfig({
|
||||
host: toStringValue(raw.host),
|
||||
port: toOptionalInteger(raw.port, 22) ?? 22,
|
||||
user: toStringValue(raw.user),
|
||||
password: toStringValue(raw.password),
|
||||
keyPath: toStringValue(raw.keyPath),
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeProxyConfig = (value: unknown): connection.ProxyConfig => {
|
||||
const raw = (value ?? {}) as ProxyConfigInput;
|
||||
const type = normalizeProxyType(raw.type);
|
||||
return new connection.ProxyConfig({
|
||||
type,
|
||||
host: toStringValue(raw.host),
|
||||
port: toOptionalInteger(raw.port, type === 'http' ? 8080 : 1080) ?? (type === 'http' ? 8080 : 1080),
|
||||
user: toStringValue(raw.user),
|
||||
password: toStringValue(raw.password),
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeHttpTunnelConfig = (value: unknown): connection.HTTPTunnelConfig => {
|
||||
const raw = (value ?? {}) as HttpTunnelConfigInput;
|
||||
return new connection.HTTPTunnelConfig({
|
||||
host: toStringValue(raw.host),
|
||||
port: toOptionalInteger(raw.port, 8080) ?? 8080,
|
||||
user: toStringValue(raw.user),
|
||||
password: toStringValue(raw.password),
|
||||
});
|
||||
};
|
||||
|
||||
export function buildRpcConnectionConfig(
|
||||
config: ConnectionConfigInput,
|
||||
overrides: ConnectionConfigInput = {},
|
||||
): RpcConnectionConfig {
|
||||
const mergedSSH = {
|
||||
...(config.ssh ?? {}),
|
||||
...(overrides.ssh ?? {}),
|
||||
};
|
||||
const mergedProxy = {
|
||||
...(config.proxy ?? {}),
|
||||
...(overrides.proxy ?? {}),
|
||||
};
|
||||
const mergedHttpTunnel = {
|
||||
...(config.httpTunnel ?? {}),
|
||||
...(overrides.httpTunnel ?? {}),
|
||||
};
|
||||
const merged: ConnectionConfigInput = {
|
||||
...config,
|
||||
...overrides,
|
||||
ssh: mergedSSH,
|
||||
proxy: mergedProxy,
|
||||
httpTunnel: mergedHttpTunnel,
|
||||
};
|
||||
|
||||
const baseId = toStringValue(config.id).trim() || toStringValue(overrides.id).trim() || undefined;
|
||||
const timeout = toOptionalInteger(merged.timeout, toOptionalInteger(config.timeout));
|
||||
const redisDB = toOptionalInteger(merged.redisDB, toOptionalInteger(config.redisDB));
|
||||
|
||||
const rpcConfig = new connection.ConnectionConfig({
|
||||
...merged,
|
||||
type: toStringValue(merged.type),
|
||||
host: toStringValue(merged.host),
|
||||
port: toOptionalInteger(merged.port, toOptionalInteger(config.port, 0)) ?? 0,
|
||||
user: toStringValue(merged.user),
|
||||
password: toStringValue(merged.password),
|
||||
database: toStringValue(merged.database),
|
||||
useSSH: merged.useSSH === true,
|
||||
ssh: normalizeSSHConfig(merged.ssh),
|
||||
useProxy: merged.useProxy === true,
|
||||
proxy: normalizeProxyConfig(merged.proxy),
|
||||
useHttpTunnel: merged.useHttpTunnel === true,
|
||||
httpTunnel: normalizeHttpTunnelConfig(merged.httpTunnel),
|
||||
timeout,
|
||||
redisDB,
|
||||
}) as RpcConnectionConfig;
|
||||
|
||||
rpcConfig.id = baseId;
|
||||
return rpcConfig;
|
||||
}
|
||||
|
||||
86
frontend/src/utils/connectionSecretDraft.test.ts
Normal file
86
frontend/src/utils/connectionSecretDraft.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveConnectionSecretDraft } from './connectionSecretDraft';
|
||||
|
||||
describe('resolveConnectionSecretDraft', () => {
|
||||
it('keeps an existing stored secret when edit form leaves the field blank', () => {
|
||||
const result = resolveConnectionSecretDraft({
|
||||
hasSecret: true,
|
||||
valueInput: '',
|
||||
clearSecret: false,
|
||||
});
|
||||
|
||||
expect(result.value).toBe('');
|
||||
expect(result.clearStoredSecret).toBe(false);
|
||||
expect(result.keepsStoredSecret).toBe(true);
|
||||
expect(result.hasSecretAfterSave).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces the stored secret when a new value is entered', () => {
|
||||
const result = resolveConnectionSecretDraft({
|
||||
hasSecret: true,
|
||||
valueInput: ' mongodb://demo ',
|
||||
clearSecret: false,
|
||||
trimInput: true,
|
||||
});
|
||||
|
||||
expect(result.value).toBe('mongodb://demo');
|
||||
expect(result.clearStoredSecret).toBe(false);
|
||||
expect(result.keepsStoredSecret).toBe(false);
|
||||
expect(result.hasSecretAfterSave).toBe(true);
|
||||
});
|
||||
|
||||
it('clears the stored secret when explicitly requested', () => {
|
||||
const result = resolveConnectionSecretDraft({
|
||||
hasSecret: true,
|
||||
valueInput: '',
|
||||
clearSecret: true,
|
||||
});
|
||||
|
||||
expect(result.value).toBe('');
|
||||
expect(result.clearStoredSecret).toBe(true);
|
||||
expect(result.keepsStoredSecret).toBe(false);
|
||||
expect(result.hasSecretAfterSave).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers a newly entered value over a stale clear toggle', () => {
|
||||
const result = resolveConnectionSecretDraft({
|
||||
hasSecret: true,
|
||||
valueInput: 'new-password',
|
||||
clearSecret: true,
|
||||
});
|
||||
|
||||
expect(result.value).toBe('new-password');
|
||||
expect(result.clearStoredSecret).toBe(false);
|
||||
expect(result.keepsStoredSecret).toBe(false);
|
||||
expect(result.hasSecretAfterSave).toBe(true);
|
||||
});
|
||||
|
||||
it('does not emit a clear flag for a brand new blank field', () => {
|
||||
const result = resolveConnectionSecretDraft({
|
||||
hasSecret: false,
|
||||
valueInput: '',
|
||||
clearSecret: false,
|
||||
});
|
||||
|
||||
expect(result.value).toBe('');
|
||||
expect(result.clearStoredSecret).toBe(false);
|
||||
expect(result.keepsStoredSecret).toBe(false);
|
||||
expect(result.hasSecretAfterSave).toBe(false);
|
||||
});
|
||||
|
||||
it('supports force clearing stored secrets', () => {
|
||||
const result = resolveConnectionSecretDraft({
|
||||
hasSecret: true,
|
||||
valueInput: 'temporary',
|
||||
clearSecret: false,
|
||||
forceClear: true,
|
||||
});
|
||||
|
||||
expect(result.value).toBe('');
|
||||
expect(result.clearStoredSecret).toBe(true);
|
||||
expect(result.keepsStoredSecret).toBe(false);
|
||||
expect(result.hasSecretAfterSave).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
63
frontend/src/utils/connectionSecretDraft.ts
Normal file
63
frontend/src/utils/connectionSecretDraft.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface ConnectionSecretDraftInput {
|
||||
valueInput?: string;
|
||||
hasSecret?: boolean;
|
||||
clearSecret?: boolean;
|
||||
forceClear?: boolean;
|
||||
trimInput?: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionSecretDraftResult {
|
||||
value: string;
|
||||
clearStoredSecret: boolean;
|
||||
keepsStoredSecret: boolean;
|
||||
hasSecretAfterSave: boolean;
|
||||
}
|
||||
|
||||
export function resolveConnectionSecretDraft(input: ConnectionSecretDraftInput): ConnectionSecretDraftResult {
|
||||
const rawValue = input.valueInput ?? '';
|
||||
const value = input.trimInput ? String(rawValue).trim() : String(rawValue);
|
||||
|
||||
if (input.forceClear) {
|
||||
return {
|
||||
value: '',
|
||||
clearStoredSecret: true,
|
||||
keepsStoredSecret: false,
|
||||
hasSecretAfterSave: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (value !== '') {
|
||||
return {
|
||||
value,
|
||||
clearStoredSecret: false,
|
||||
keepsStoredSecret: false,
|
||||
hasSecretAfterSave: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.clearSecret) {
|
||||
return {
|
||||
value: '',
|
||||
clearStoredSecret: true,
|
||||
keepsStoredSecret: false,
|
||||
hasSecretAfterSave: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.hasSecret) {
|
||||
return {
|
||||
value: '',
|
||||
clearStoredSecret: false,
|
||||
keepsStoredSecret: true,
|
||||
hasSecretAfterSave: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: '',
|
||||
clearStoredSecret: false,
|
||||
keepsStoredSecret: false,
|
||||
hasSecretAfterSave: false,
|
||||
};
|
||||
}
|
||||
|
||||
37
frontend/src/utils/customConnectionDsn.test.ts
Normal file
37
frontend/src/utils/customConnectionDsn.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { shouldAllowBlankCustomDsn } from './customConnectionDsn';
|
||||
|
||||
describe('shouldAllowBlankCustomDsn', () => {
|
||||
it('allows a blank DSN when editing a connection that already has a stored opaque DSN', () => {
|
||||
expect(shouldAllowBlankCustomDsn({
|
||||
dsnInput: '',
|
||||
hasStoredSecret: true,
|
||||
clearStoredSecret: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a new DSN when the user chooses to clear the stored opaque DSN', () => {
|
||||
expect(shouldAllowBlankCustomDsn({
|
||||
dsnInput: '',
|
||||
hasStoredSecret: true,
|
||||
clearStoredSecret: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a DSN for brand new custom connections', () => {
|
||||
expect(shouldAllowBlankCustomDsn({
|
||||
dsnInput: '',
|
||||
hasStoredSecret: false,
|
||||
clearStoredSecret: false,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a newly entered DSN even when a stored secret already exists', () => {
|
||||
expect(shouldAllowBlankCustomDsn({
|
||||
dsnInput: 'driver://demo',
|
||||
hasStoredSecret: true,
|
||||
clearStoredSecret: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
27
frontend/src/utils/customConnectionDsn.ts
Normal file
27
frontend/src/utils/customConnectionDsn.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface CustomConnectionDsnState {
|
||||
dsnInput: unknown;
|
||||
hasStoredSecret?: boolean;
|
||||
clearStoredSecret?: boolean;
|
||||
}
|
||||
|
||||
export const getCustomConnectionDsnValidationMessage = ({
|
||||
dsnInput,
|
||||
hasStoredSecret,
|
||||
clearStoredSecret,
|
||||
}: CustomConnectionDsnState): string | null => {
|
||||
const dsnText = String(dsnInput ?? '').trim();
|
||||
if (dsnText !== '') {
|
||||
return null;
|
||||
}
|
||||
if (hasStoredSecret && !clearStoredSecret) {
|
||||
return null;
|
||||
}
|
||||
if (hasStoredSecret && clearStoredSecret) {
|
||||
return '请输入新的连接字符串,或取消清除已保存 DSN';
|
||||
}
|
||||
return '请输入连接字符串';
|
||||
};
|
||||
|
||||
export const shouldAllowBlankCustomDsn = (state: CustomConnectionDsnState): boolean => (
|
||||
getCustomConnectionDsnValidationMessage(state) === null
|
||||
);
|
||||
32
frontend/src/utils/dataGridDisplay.test.ts
Normal file
32
frontend/src/utils/dataGridDisplay.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||||
resolveDataTableColumnWidth,
|
||||
resolveDataTableDefaultColumnWidth,
|
||||
resolveDataTableVerticalBorderColor,
|
||||
sanitizeDataGridDisplaySettings,
|
||||
} from './dataGridDisplay';
|
||||
|
||||
describe('dataGridDisplay helpers', () => {
|
||||
it('sanitizes missing display settings to safe defaults', () => {
|
||||
expect(sanitizeDataGridDisplaySettings(undefined)).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
|
||||
expect(sanitizeDataGridDisplaySettings({ dataTableColumnWidthMode: 'invalid' as never })).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
|
||||
});
|
||||
|
||||
it('resolves standard and compact default column widths', () => {
|
||||
expect(resolveDataTableDefaultColumnWidth('standard')).toBe(200);
|
||||
expect(resolveDataTableDefaultColumnWidth('compact')).toBe(140);
|
||||
});
|
||||
|
||||
it('keeps manual column widths ahead of mode defaults', () => {
|
||||
expect(resolveDataTableColumnWidth({ manualWidth: 320, widthMode: 'compact' })).toBe(320);
|
||||
expect(resolveDataTableColumnWidth({ manualWidth: undefined, widthMode: 'compact' })).toBe(140);
|
||||
});
|
||||
|
||||
it('uses subtle themed vertical border colors and transparent when disabled', () => {
|
||||
expect(resolveDataTableVerticalBorderColor({ darkMode: true, visible: true })).toBe('rgba(255, 255, 255, 0.08)');
|
||||
expect(resolveDataTableVerticalBorderColor({ darkMode: false, visible: true })).toBe('rgba(15, 23, 42, 0.08)');
|
||||
expect(resolveDataTableVerticalBorderColor({ darkMode: false, visible: false })).toBe('transparent');
|
||||
});
|
||||
});
|
||||
72
frontend/src/utils/dataGridDisplay.ts
Normal file
72
frontend/src/utils/dataGridDisplay.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type DataTableColumnWidthMode = 'standard' | 'compact';
|
||||
|
||||
export interface DataGridDisplaySettings {
|
||||
showDataTableVerticalBorders: boolean;
|
||||
dataTableColumnWidthMode: DataTableColumnWidthMode;
|
||||
}
|
||||
|
||||
export const DEFAULT_DATA_GRID_DISPLAY_SETTINGS: DataGridDisplaySettings = {
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
};
|
||||
|
||||
export const DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS = [
|
||||
{ label: '标准 200px', value: 'standard' as const },
|
||||
{ label: '紧凑 140px', value: 'compact' as const },
|
||||
];
|
||||
|
||||
const STANDARD_DATA_TABLE_COLUMN_WIDTH = 200;
|
||||
const COMPACT_DATA_TABLE_COLUMN_WIDTH = 140;
|
||||
|
||||
export const sanitizeDataTableColumnWidthMode = (value: unknown): DataTableColumnWidthMode => {
|
||||
return value === 'compact' ? 'compact' : 'standard';
|
||||
};
|
||||
|
||||
export const sanitizeDataGridDisplaySettings = (
|
||||
value: Partial<DataGridDisplaySettings> | undefined
|
||||
): DataGridDisplaySettings => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return { ...DEFAULT_DATA_GRID_DISPLAY_SETTINGS };
|
||||
}
|
||||
|
||||
return {
|
||||
showDataTableVerticalBorders: value.showDataTableVerticalBorders === true,
|
||||
dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value.dataTableColumnWidthMode),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveDataTableDefaultColumnWidth = (
|
||||
widthMode: DataTableColumnWidthMode | null | undefined
|
||||
): number => {
|
||||
return sanitizeDataTableColumnWidthMode(widthMode) === 'compact'
|
||||
? COMPACT_DATA_TABLE_COLUMN_WIDTH
|
||||
: STANDARD_DATA_TABLE_COLUMN_WIDTH;
|
||||
};
|
||||
|
||||
export const resolveDataTableColumnWidth = ({
|
||||
manualWidth,
|
||||
widthMode,
|
||||
}: {
|
||||
manualWidth: number | null | undefined;
|
||||
widthMode: DataTableColumnWidthMode | null | undefined;
|
||||
}): number => {
|
||||
if (typeof manualWidth === 'number' && Number.isFinite(manualWidth) && manualWidth > 0) {
|
||||
return manualWidth;
|
||||
}
|
||||
|
||||
return resolveDataTableDefaultColumnWidth(widthMode);
|
||||
};
|
||||
|
||||
export const resolveDataTableVerticalBorderColor = ({
|
||||
darkMode,
|
||||
visible,
|
||||
}: {
|
||||
darkMode: boolean;
|
||||
visible: boolean;
|
||||
}): string => {
|
||||
if (!visible) {
|
||||
return 'transparent';
|
||||
}
|
||||
|
||||
return darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.08)';
|
||||
};
|
||||
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;
|
||||
};
|
||||
43
frontend/src/utils/dataGridSort.ts
Normal file
43
frontend/src/utils/dataGridSort.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type GridSortInfoItem = {
|
||||
columnKey: string;
|
||||
order: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type TableSorterLike = {
|
||||
field?: unknown;
|
||||
columnKey?: unknown;
|
||||
order?: unknown;
|
||||
};
|
||||
|
||||
export const resolveGridSortInfoFromTableSorter = ({
|
||||
sorter,
|
||||
}: {
|
||||
sorter: TableSorterLike | TableSorterLike[] | null | undefined;
|
||||
}): GridSortInfoItem[] => {
|
||||
const sorters = Array.isArray(sorter)
|
||||
? sorter
|
||||
: ((sorter?.field || sorter?.columnKey) ? [sorter] : []);
|
||||
|
||||
if (sorters.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const next: GridSortInfoItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const item of sorters) {
|
||||
const field = String(item?.field || item?.columnKey || '').trim();
|
||||
if (!field) continue;
|
||||
|
||||
const order = item?.order as string;
|
||||
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
|
||||
if (!normalizedOrder) continue;
|
||||
const dedupeKey = field.toLowerCase();
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
next.push({ columnKey: field, order: normalizedOrder, enabled: true });
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
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';
|
||||
};
|
||||
35
frontend/src/utils/globalProxyDraft.test.ts
Normal file
35
frontend/src/utils/globalProxyDraft.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGlobalProxyDraft, toPersistedGlobalProxy } from './globalProxyDraft';
|
||||
|
||||
describe('global proxy draft', () => {
|
||||
it('hydrates a secretless draft from backend metadata while keeping password input blank', () => {
|
||||
const draft = createGlobalProxyDraft({
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
hasPassword: true,
|
||||
password: 'should-be-ignored',
|
||||
});
|
||||
|
||||
expect(draft.password).toBe('');
|
||||
expect(draft.hasPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('drops password from persisted metadata but preserves hasPassword', () => {
|
||||
const persisted = toPersistedGlobalProxy({
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
password: 'proxy-secret',
|
||||
hasPassword: true,
|
||||
});
|
||||
|
||||
expect('password' in persisted).toBe(false);
|
||||
expect(persisted.hasPassword).toBe(true);
|
||||
});
|
||||
});
|
||||
62
frontend/src/utils/globalProxyDraft.ts
Normal file
62
frontend/src/utils/globalProxyDraft.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { GlobalProxyConfig } from '../types';
|
||||
|
||||
const toTrimmedString = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value).trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeProxyType = (value: unknown): 'socks5' | 'http' => {
|
||||
return toTrimmedString(value).toLowerCase() === 'http' ? 'http' : 'socks5';
|
||||
};
|
||||
|
||||
const normalizePort = (value: unknown, fallbackPort: number): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallbackPort;
|
||||
}
|
||||
const port = Math.trunc(parsed);
|
||||
if (port <= 0 || port > 65535) {
|
||||
return fallbackPort;
|
||||
}
|
||||
return port;
|
||||
};
|
||||
|
||||
export function createGlobalProxyDraft(value: Partial<GlobalProxyConfig> = {}): GlobalProxyConfig {
|
||||
const type = normalizeProxyType(value.type);
|
||||
return {
|
||||
enabled: value.enabled === true,
|
||||
type,
|
||||
host: toTrimmedString(value.host),
|
||||
port: normalizePort(value.port, type === 'http' ? 8080 : 1080),
|
||||
user: toTrimmedString(value.user),
|
||||
password: '',
|
||||
hasPassword: value.hasPassword === true,
|
||||
secretRef: toTrimmedString(value.secretRef) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPersistedGlobalProxy(value: Partial<GlobalProxyConfig> = {}): Omit<GlobalProxyConfig, 'password'> {
|
||||
const draft = createGlobalProxyDraft(value);
|
||||
return {
|
||||
enabled: draft.enabled,
|
||||
type: draft.type,
|
||||
host: draft.host,
|
||||
port: draft.port,
|
||||
user: draft.user,
|
||||
hasPassword: draft.hasPassword,
|
||||
secretRef: draft.secretRef,
|
||||
};
|
||||
}
|
||||
|
||||
export function toSaveGlobalProxyInput(value: Partial<GlobalProxyConfig> = {}): GlobalProxyConfig {
|
||||
const draft = createGlobalProxyDraft(value);
|
||||
return {
|
||||
...draft,
|
||||
password: typeof value.password === 'string' ? value.password : '',
|
||||
};
|
||||
}
|
||||
75
frontend/src/utils/legacyConnectionStorage.test.ts
Normal file
75
frontend/src/utils/legacyConnectionStorage.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './legacyConnectionStorage';
|
||||
|
||||
describe('legacy connection storage', () => {
|
||||
it('extracts legacy saved connections and global proxy password from lite-db-storage', () => {
|
||||
const payload = JSON.stringify({
|
||||
state: {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'Primary',
|
||||
config: {
|
||||
id: 'conn-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
globalProxy: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
password: 'proxy-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = readLegacyPersistedSecrets(payload);
|
||||
expect(result.connections).toHaveLength(1);
|
||||
expect(result.connections[0]?.config.password).toBe('secret');
|
||||
expect(result.globalProxy?.password).toBe('proxy-secret');
|
||||
});
|
||||
|
||||
it('strips persisted connection secrets but keeps secretless proxy metadata', () => {
|
||||
const payload = JSON.stringify({
|
||||
state: {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'Primary',
|
||||
config: {
|
||||
id: 'conn-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
globalProxy: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
password: 'proxy-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sanitized = stripLegacyPersistedSecrets(payload);
|
||||
const parsed = JSON.parse(sanitized);
|
||||
|
||||
expect(parsed.state.connections).toEqual([]);
|
||||
expect(parsed.state.globalProxy.password).toBeUndefined();
|
||||
expect(parsed.state.globalProxy.hasPassword).toBe(true);
|
||||
});
|
||||
});
|
||||
110
frontend/src/utils/legacyConnectionStorage.ts
Normal file
110
frontend/src/utils/legacyConnectionStorage.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { GlobalProxyConfig, SavedConnection } from '../types';
|
||||
|
||||
export const LEGACY_PERSIST_KEY = 'lite-db-storage';
|
||||
|
||||
const toTrimmedString = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value).trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeProxyType = (value: unknown): 'socks5' | 'http' => {
|
||||
return toTrimmedString(value).toLowerCase() === 'http' ? 'http' : 'socks5';
|
||||
};
|
||||
|
||||
const normalizePort = (value: unknown, fallbackPort: number): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallbackPort;
|
||||
}
|
||||
const port = Math.trunc(parsed);
|
||||
if (port <= 0 || port > 65535) {
|
||||
return fallbackPort;
|
||||
}
|
||||
return port;
|
||||
};
|
||||
|
||||
const parsePersistedEnvelope = (payload: string | null | undefined): Record<string, unknown> => {
|
||||
if (!payload || typeof payload !== 'string') {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as Record<string, unknown>;
|
||||
if (parsed.state && typeof parsed.state === 'object') {
|
||||
return parsed.state as Record<string, unknown>;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export function readLegacyPersistedSecrets(payload: string | null | undefined): {
|
||||
connections: SavedConnection[];
|
||||
globalProxy: GlobalProxyConfig | null;
|
||||
} {
|
||||
const state = parsePersistedEnvelope(payload);
|
||||
const connections = Array.isArray(state.connections)
|
||||
? state.connections.filter((item): item is SavedConnection => !!item && typeof item === 'object')
|
||||
: [];
|
||||
|
||||
const proxyRaw = state.globalProxy && typeof state.globalProxy === 'object'
|
||||
? state.globalProxy as Record<string, unknown>
|
||||
: null;
|
||||
if (!proxyRaw) {
|
||||
return { connections, globalProxy: null };
|
||||
}
|
||||
|
||||
const type = normalizeProxyType(proxyRaw.type);
|
||||
const password = toTrimmedString(proxyRaw.password);
|
||||
const globalProxy: GlobalProxyConfig = {
|
||||
enabled: proxyRaw.enabled === true,
|
||||
type,
|
||||
host: toTrimmedString(proxyRaw.host),
|
||||
port: normalizePort(proxyRaw.port, type === 'http' ? 8080 : 1080),
|
||||
user: toTrimmedString(proxyRaw.user),
|
||||
password,
|
||||
hasPassword: proxyRaw.hasPassword === true || password !== '',
|
||||
secretRef: toTrimmedString(proxyRaw.secretRef) || undefined,
|
||||
};
|
||||
|
||||
const hasMeaningfulProxyState = globalProxy.enabled || globalProxy.host !== '' || globalProxy.user !== '' || globalProxy.password !== '' || globalProxy.hasPassword === true;
|
||||
return {
|
||||
connections,
|
||||
globalProxy: hasMeaningfulProxyState ? globalProxy : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function stripLegacyPersistedSecrets(payload: string | null | undefined): string {
|
||||
if (!payload || typeof payload !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(payload) as Record<string, unknown>;
|
||||
} catch {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const state = parsed.state && typeof parsed.state === 'object'
|
||||
? parsed.state as Record<string, unknown>
|
||||
: parsed;
|
||||
state.connections = [];
|
||||
|
||||
if (state.globalProxy && typeof state.globalProxy === 'object') {
|
||||
const proxy = { ...(state.globalProxy as Record<string, unknown>) };
|
||||
const password = toTrimmedString(proxy.password);
|
||||
delete proxy.password;
|
||||
if (password !== '') {
|
||||
proxy.hasPassword = true;
|
||||
}
|
||||
state.globalProxy = proxy;
|
||||
}
|
||||
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
41
frontend/src/utils/providerSecretDraft.test.ts
Normal file
41
frontend/src/utils/providerSecretDraft.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveProviderSecretDraft } from './providerSecretDraft';
|
||||
|
||||
describe('resolveProviderSecretDraft', () => {
|
||||
it('keeps existing provider secret when edit form leaves apiKey blank', () => {
|
||||
const result = resolveProviderSecretDraft({
|
||||
hasSecret: true,
|
||||
apiKeyInput: '',
|
||||
clearSecret: false,
|
||||
});
|
||||
|
||||
expect(result.mode).toBe('keep');
|
||||
expect(result.apiKey).toBe('');
|
||||
expect(result.hasSecret).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces the provider secret when a new apiKey is entered', () => {
|
||||
const result = resolveProviderSecretDraft({
|
||||
hasSecret: true,
|
||||
apiKeyInput: ' sk-new ',
|
||||
clearSecret: false,
|
||||
});
|
||||
|
||||
expect(result.mode).toBe('replace');
|
||||
expect(result.apiKey).toBe('sk-new');
|
||||
expect(result.hasSecret).toBe(true);
|
||||
});
|
||||
|
||||
it('clears the stored provider secret when requested', () => {
|
||||
const result = resolveProviderSecretDraft({
|
||||
hasSecret: true,
|
||||
apiKeyInput: '',
|
||||
clearSecret: true,
|
||||
});
|
||||
|
||||
expect(result.mode).toBe('clear');
|
||||
expect(result.apiKey).toBe('');
|
||||
expect(result.hasSecret).toBe(false);
|
||||
});
|
||||
});
|
||||
47
frontend/src/utils/providerSecretDraft.ts
Normal file
47
frontend/src/utils/providerSecretDraft.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type ProviderSecretDraftMode = 'keep' | 'replace' | 'clear';
|
||||
|
||||
export interface ProviderSecretDraftInput {
|
||||
hasSecret?: boolean;
|
||||
apiKeyInput?: string;
|
||||
clearSecret?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderSecretDraftResult {
|
||||
mode: ProviderSecretDraftMode;
|
||||
apiKey: string;
|
||||
hasSecret: boolean;
|
||||
}
|
||||
|
||||
export function resolveProviderSecretDraft(input: ProviderSecretDraftInput): ProviderSecretDraftResult {
|
||||
const apiKey = String(input.apiKeyInput || '').trim();
|
||||
|
||||
if (input.clearSecret) {
|
||||
return {
|
||||
mode: 'clear',
|
||||
apiKey: '',
|
||||
hasSecret: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
return {
|
||||
mode: 'replace',
|
||||
apiKey,
|
||||
hasSecret: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.hasSecret) {
|
||||
return {
|
||||
mode: 'keep',
|
||||
apiKey: '',
|
||||
hasSecret: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'clear',
|
||||
apiKey: '',
|
||||
hasSecret: false,
|
||||
};
|
||||
}
|
||||
@@ -10,10 +10,10 @@ describe('startup readiness helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps sidebar blocked until initial global proxy sync finishes', () => {
|
||||
it('keeps sidebar blocked until secure config bootstrap finishes', () => {
|
||||
expect(getConnectionWorkbenchState(true, false)).toEqual({
|
||||
ready: false,
|
||||
message: '正在同步全局代理配置...',
|
||||
message: '正在加载安全配置...',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,3 +24,4 @@ describe('startup readiness helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export function getConnectionWorkbenchState(
|
||||
if (!hasAppliedInitialGlobalProxy) {
|
||||
return {
|
||||
ready: false,
|
||||
message: '正在同步全局代理配置...',
|
||||
message: '正在加载安全配置...',
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -24,3 +24,4 @@ export function getConnectionWorkbenchState(
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
45
frontend/src/utils/windowsScaleFix.test.ts
Normal file
45
frontend/src/utils/windowsScaleFix.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
computeWindowsViewportScaleRatio,
|
||||
getWindowsScaleFixNudgedWidth,
|
||||
hasWindowsViewportScaleDrift,
|
||||
} from './windowsScaleFix';
|
||||
|
||||
describe('windowsScaleFix', () => {
|
||||
it('treats matching window and viewport metrics as stable', () => {
|
||||
const ratio = computeWindowsViewportScaleRatio({
|
||||
windowWidth: 1920,
|
||||
innerWidth: 1280,
|
||||
devicePixelRatio: 1.5,
|
||||
});
|
||||
|
||||
expect(ratio).toBeCloseTo(1, 5);
|
||||
expect(hasWindowsViewportScaleDrift({
|
||||
windowWidth: 1920,
|
||||
innerWidth: 1280,
|
||||
devicePixelRatio: 1.5,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('detects zoom drift from viewport width mismatch', () => {
|
||||
expect(hasWindowsViewportScaleDrift({
|
||||
windowWidth: 1920,
|
||||
innerWidth: 1100,
|
||||
devicePixelRatio: 1.5,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('detects zoom drift from visual viewport scale', () => {
|
||||
expect(hasWindowsViewportScaleDrift({
|
||||
windowWidth: 1600,
|
||||
innerWidth: 1600,
|
||||
devicePixelRatio: 1,
|
||||
visualViewportScale: 1.12,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a one-pixel nudge width for normal windows', () => {
|
||||
expect(getWindowsScaleFixNudgedWidth(960)).toBe(959);
|
||||
expect(getWindowsScaleFixNudgedWidth(420)).toBe(421);
|
||||
});
|
||||
});
|
||||
46
frontend/src/utils/windowsScaleFix.ts
Normal file
46
frontend/src/utils/windowsScaleFix.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
type WindowsViewportScaleInput = {
|
||||
windowWidth: number;
|
||||
innerWidth: number;
|
||||
devicePixelRatio: number;
|
||||
visualViewportScale?: number | null;
|
||||
};
|
||||
|
||||
export const computeWindowsViewportScaleRatio = ({
|
||||
windowWidth,
|
||||
innerWidth,
|
||||
devicePixelRatio,
|
||||
}: WindowsViewportScaleInput): number => {
|
||||
const normalizedWindowWidth = Number(windowWidth);
|
||||
const normalizedInnerWidth = Number(innerWidth);
|
||||
const normalizedDevicePixelRatio = Number(devicePixelRatio);
|
||||
if (
|
||||
!Number.isFinite(normalizedWindowWidth) || normalizedWindowWidth <= 0 ||
|
||||
!Number.isFinite(normalizedInnerWidth) || normalizedInnerWidth <= 0 ||
|
||||
!Number.isFinite(normalizedDevicePixelRatio) || normalizedDevicePixelRatio <= 0
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
return (normalizedWindowWidth / normalizedDevicePixelRatio) / normalizedInnerWidth;
|
||||
};
|
||||
|
||||
export const hasWindowsViewportScaleDrift = (
|
||||
metrics: WindowsViewportScaleInput,
|
||||
tolerance = 0.08,
|
||||
): boolean => {
|
||||
const normalizedTolerance = Math.max(0.01, Number(tolerance) || 0.08);
|
||||
const visualViewportScale = Number(metrics.visualViewportScale);
|
||||
if (Number.isFinite(visualViewportScale) && Math.abs(visualViewportScale - 1) > normalizedTolerance) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const viewportScaleRatio = computeWindowsViewportScaleRatio(metrics);
|
||||
return Math.abs(viewportScaleRatio - 1) > normalizedTolerance;
|
||||
};
|
||||
|
||||
export const getWindowsScaleFixNudgedWidth = (width: number): number => {
|
||||
const normalizedWidth = Math.trunc(Number(width) || 0);
|
||||
if (normalizedWidth <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return normalizedWidth > 480 ? normalizedWidth - 1 : normalizedWidth + 1;
|
||||
};
|
||||
3
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
3
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
@@ -1,7 +1,6 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {ai} from '../models';
|
||||
import {context} from '../models';
|
||||
|
||||
export function AIChatCancel(arg1:string):Promise<void>;
|
||||
|
||||
@@ -42,5 +41,3 @@ export function AISetContextLevel(arg1:string):Promise<void>;
|
||||
export function AISetSafetyLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function AITestProvider(arg1:ai.ProviderConfig):Promise<Record<string, any>>;
|
||||
|
||||
export function Startup(arg1:context.Context):Promise<void>;
|
||||
|
||||
@@ -81,7 +81,3 @@ export function AISetSafetyLevel(arg1) {
|
||||
export function AITestProvider(arg1) {
|
||||
return window['go']['aiservice']['Service']['AITestProvider'](arg1);
|
||||
}
|
||||
|
||||
export function Startup(arg1) {
|
||||
return window['go']['aiservice']['Service']['Startup'](arg1);
|
||||
}
|
||||
|
||||
34
frontend/wailsjs/go/app/App.d.ts
vendored
34
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -1,13 +1,13 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {connection} from '../models';
|
||||
import {time} from '../models';
|
||||
import {sync} from '../models';
|
||||
import {redis} from '../models';
|
||||
import {context} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
|
||||
export function ApplyDataRootDirectory(arg1:string,arg2:boolean):Promise<connection.QueryResult>;
|
||||
|
||||
export function CancelQuery(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function CancelSQLFileExecution(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -16,7 +16,7 @@ export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function CleanupStaleQueries(arg1:time.Duration):Promise<void>;
|
||||
export function ClearTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -56,6 +56,8 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryRe
|
||||
|
||||
export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DeleteConnection(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DownloadUpdate():Promise<connection.QueryResult>;
|
||||
@@ -68,6 +70,8 @@ export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:stri
|
||||
|
||||
export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DuplicateConnection(arg1:string):Promise<connection.SavedConnectionView>;
|
||||
|
||||
export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
@@ -86,6 +90,8 @@ export function GenerateQueryID():Promise<string>;
|
||||
|
||||
export function GetAppInfo():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDataRootDirectoryInfo():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverVersionList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
@@ -94,16 +100,24 @@ export function GetDriverVersionPackageSize(arg1:string,arg2:string):Promise<con
|
||||
|
||||
export function GetGlobalProxyConfig():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetSavedConnections():Promise<Array<connection.SavedConnectionView>>;
|
||||
|
||||
export function ImportConfigFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
export function ImportLegacyConnections(arg1:Array<connection.SavedConnectionInput>):Promise<Array<connection.SavedConnectionView>>;
|
||||
|
||||
export function ImportLegacyGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
|
||||
|
||||
export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
|
||||
|
||||
export function LogWindowDiagnostic(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function MySQLConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
@@ -116,8 +130,12 @@ export function MySQLQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:str
|
||||
|
||||
export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenDataRootDirectory():Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenDownloadedUpdateDirectory():Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -184,6 +202,12 @@ export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise
|
||||
|
||||
export function ResolveDriverRepositoryURL(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SaveConnection(arg1:connection.SavedConnectionInput):Promise<connection.SavedConnectionView>;
|
||||
|
||||
export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
|
||||
|
||||
export function SelectDataRootDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDatabaseFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -198,8 +222,6 @@ export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function Startup(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ApplyDataRootDirectory(arg1, arg2) {
|
||||
return window['go']['app']['App']['ApplyDataRootDirectory'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CancelQuery(arg1) {
|
||||
return window['go']['app']['App']['CancelQuery'](arg1);
|
||||
}
|
||||
@@ -22,8 +26,8 @@ export function CheckForUpdates() {
|
||||
return window['go']['app']['App']['CheckForUpdates']();
|
||||
}
|
||||
|
||||
export function CleanupStaleQueries(arg1) {
|
||||
return window['go']['app']['App']['CleanupStaleQueries'](arg1);
|
||||
export function ClearTables(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['ClearTables'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1) {
|
||||
@@ -102,6 +106,10 @@ export function DataSyncPreview(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DeleteConnection(arg1) {
|
||||
return window['go']['app']['App']['DeleteConnection'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadDriverPackage(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -126,6 +134,10 @@ export function DropView(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DropView'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DuplicateConnection(arg1) {
|
||||
return window['go']['app']['App']['DuplicateConnection'](arg1);
|
||||
}
|
||||
|
||||
export function ExecuteSQLFile(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExecuteSQLFile'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -162,6 +174,10 @@ export function GetAppInfo() {
|
||||
return window['go']['app']['App']['GetAppInfo']();
|
||||
}
|
||||
|
||||
export function GetDataRootDirectoryInfo() {
|
||||
return window['go']['app']['App']['GetDataRootDirectoryInfo']();
|
||||
}
|
||||
|
||||
export function GetDriverStatusList(arg1, arg2) {
|
||||
return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2);
|
||||
}
|
||||
@@ -178,6 +194,10 @@ export function GetGlobalProxyConfig() {
|
||||
return window['go']['app']['App']['GetGlobalProxyConfig']();
|
||||
}
|
||||
|
||||
export function GetSavedConnections() {
|
||||
return window['go']['app']['App']['GetSavedConnections']();
|
||||
}
|
||||
|
||||
export function ImportConfigFile() {
|
||||
return window['go']['app']['App']['ImportConfigFile']();
|
||||
}
|
||||
@@ -190,14 +210,26 @@ export function ImportDataWithProgress(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ImportDataWithProgress'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function InstallLocalDriverPackage(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3);
|
||||
export function ImportLegacyConnections(arg1) {
|
||||
return window['go']['app']['App']['ImportLegacyConnections'](arg1);
|
||||
}
|
||||
|
||||
export function ImportLegacyGlobalProxy(arg1) {
|
||||
return window['go']['app']['App']['ImportLegacyGlobalProxy'](arg1);
|
||||
}
|
||||
|
||||
export function InstallLocalDriverPackage(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function InstallUpdateAndRestart() {
|
||||
return window['go']['app']['App']['InstallUpdateAndRestart']();
|
||||
}
|
||||
|
||||
export function LogWindowDiagnostic(arg1, arg2) {
|
||||
return window['go']['app']['App']['LogWindowDiagnostic'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function MongoDiscoverMembers(arg1) {
|
||||
return window['go']['app']['App']['MongoDiscoverMembers'](arg1);
|
||||
}
|
||||
@@ -222,10 +254,18 @@ export function MySQLShowCreateTable(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['MySQLShowCreateTable'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function OpenDataRootDirectory() {
|
||||
return window['go']['app']['App']['OpenDataRootDirectory']();
|
||||
}
|
||||
|
||||
export function OpenDownloadedUpdateDirectory() {
|
||||
return window['go']['app']['App']['OpenDownloadedUpdateDirectory']();
|
||||
}
|
||||
|
||||
export function OpenDriverDownloadDirectory(arg1) {
|
||||
return window['go']['app']['App']['OpenDriverDownloadDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function OpenSQLFile() {
|
||||
return window['go']['app']['App']['OpenSQLFile']();
|
||||
}
|
||||
@@ -358,6 +398,18 @@ export function ResolveDriverRepositoryURL(arg1) {
|
||||
return window['go']['app']['App']['ResolveDriverRepositoryURL'](arg1);
|
||||
}
|
||||
|
||||
export function SaveConnection(arg1) {
|
||||
return window['go']['app']['App']['SaveConnection'](arg1);
|
||||
}
|
||||
|
||||
export function SaveGlobalProxy(arg1) {
|
||||
return window['go']['app']['App']['SaveGlobalProxy'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDataRootDirectory(arg1) {
|
||||
return window['go']['app']['App']['SelectDataRootDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDatabaseFile(arg1, arg2) {
|
||||
return window['go']['app']['App']['SelectDatabaseFile'](arg1, arg2);
|
||||
}
|
||||
@@ -386,10 +438,6 @@ export function SetWindowTranslucency(arg1, arg2) {
|
||||
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function Startup(arg1) {
|
||||
return window['go']['app']['App']['Startup'](arg1);
|
||||
}
|
||||
|
||||
export function TestConnection(arg1) {
|
||||
return window['go']['app']['App']['TestConnection'](arg1);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ export namespace ai {
|
||||
type: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
secretRef?: string;
|
||||
hasSecret?: boolean;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
models?: string[];
|
||||
@@ -96,6 +98,8 @@ export namespace ai {
|
||||
this.type = source["type"];
|
||||
this.name = source["name"];
|
||||
this.apiKey = source["apiKey"];
|
||||
this.secretRef = source["secretRef"];
|
||||
this.hasSecret = source["hasSecret"];
|
||||
this.baseUrl = source["baseUrl"];
|
||||
this.model = source["model"];
|
||||
this.models = source["models"];
|
||||
@@ -284,6 +288,7 @@ export namespace connection {
|
||||
}
|
||||
}
|
||||
export class ConnectionConfig {
|
||||
id?: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -324,6 +329,7 @@ export namespace connection {
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
@@ -377,6 +383,32 @@ export namespace connection {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class GlobalProxyView {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
hasPassword?: boolean;
|
||||
secretRef?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new GlobalProxyView(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.user = source["user"];
|
||||
this.password = source["password"];
|
||||
this.hasPassword = source["hasPassword"];
|
||||
this.secretRef = source["secretRef"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class QueryResult {
|
||||
@@ -400,6 +432,146 @@ export namespace connection {
|
||||
}
|
||||
}
|
||||
|
||||
export class SaveGlobalProxyInput {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SaveGlobalProxyInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.user = source["user"];
|
||||
this.password = source["password"];
|
||||
}
|
||||
}
|
||||
export class SavedConnectionInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
config: ConnectionConfig;
|
||||
includeDatabases?: string[];
|
||||
includeRedisDatabases?: number[];
|
||||
iconType?: string;
|
||||
iconColor?: string;
|
||||
clearPrimaryPassword?: boolean;
|
||||
clearSSHPassword?: boolean;
|
||||
clearProxyPassword?: boolean;
|
||||
clearHttpTunnelPassword?: boolean;
|
||||
clearMySQLReplicaPassword?: boolean;
|
||||
clearMongoReplicaPassword?: boolean;
|
||||
clearOpaqueURI?: boolean;
|
||||
clearOpaqueDSN?: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SavedConnectionInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.config = this.convertValues(source["config"], ConnectionConfig);
|
||||
this.includeDatabases = source["includeDatabases"];
|
||||
this.includeRedisDatabases = source["includeRedisDatabases"];
|
||||
this.iconType = source["iconType"];
|
||||
this.iconColor = source["iconColor"];
|
||||
this.clearPrimaryPassword = source["clearPrimaryPassword"];
|
||||
this.clearSSHPassword = source["clearSSHPassword"];
|
||||
this.clearProxyPassword = source["clearProxyPassword"];
|
||||
this.clearHttpTunnelPassword = source["clearHttpTunnelPassword"];
|
||||
this.clearMySQLReplicaPassword = source["clearMySQLReplicaPassword"];
|
||||
this.clearMongoReplicaPassword = source["clearMongoReplicaPassword"];
|
||||
this.clearOpaqueURI = source["clearOpaqueURI"];
|
||||
this.clearOpaqueDSN = source["clearOpaqueDSN"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SavedConnectionView {
|
||||
id: string;
|
||||
name: string;
|
||||
config: ConnectionConfig;
|
||||
includeDatabases?: string[];
|
||||
includeRedisDatabases?: number[];
|
||||
iconType?: string;
|
||||
iconColor?: string;
|
||||
secretRef?: string;
|
||||
hasPrimaryPassword?: boolean;
|
||||
hasSSHPassword?: boolean;
|
||||
hasProxyPassword?: boolean;
|
||||
hasHttpTunnelPassword?: boolean;
|
||||
hasMySQLReplicaPassword?: boolean;
|
||||
hasMongoReplicaPassword?: boolean;
|
||||
hasOpaqueURI?: boolean;
|
||||
hasOpaqueDSN?: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SavedConnectionView(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.config = this.convertValues(source["config"], ConnectionConfig);
|
||||
this.includeDatabases = source["includeDatabases"];
|
||||
this.includeRedisDatabases = source["includeRedisDatabases"];
|
||||
this.iconType = source["iconType"];
|
||||
this.iconColor = source["iconColor"];
|
||||
this.secretRef = source["secretRef"];
|
||||
this.hasPrimaryPassword = source["hasPrimaryPassword"];
|
||||
this.hasSSHPassword = source["hasSSHPassword"];
|
||||
this.hasProxyPassword = source["hasProxyPassword"];
|
||||
this.hasHttpTunnelPassword = source["hasHttpTunnelPassword"];
|
||||
this.hasMySQLReplicaPassword = source["hasMySQLReplicaPassword"];
|
||||
this.hasMongoReplicaPassword = source["hasMongoReplicaPassword"];
|
||||
this.hasOpaqueURI = source["hasOpaqueURI"];
|
||||
this.hasOpaqueDSN = source["hasOpaqueDSN"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
8
go.mod
8
go.mod
@@ -28,11 +28,14 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
|
||||
github.com/99designs/keyring v1.2.2
|
||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.5.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/danieljoos/wincred v1.1.2 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/duckdb/duckdb-go-bindings v0.3.3 // indirect
|
||||
github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.3.3 // indirect
|
||||
@@ -41,17 +44,20 @@ require (
|
||||
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3 // indirect
|
||||
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -68,6 +74,7 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/mtibben/percent v0.2.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
@@ -100,6 +107,7 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect
|
||||
golang.org/x/term v0.39.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
|
||||
17
go.sum
17
go.sum
@@ -4,6 +4,10 @@ gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3 h1:QjslQNaH5Nuap5i4ni
|
||||
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3/go.mod h1:7lH5A1jzCXD9Nl16DzaBUOfDAT8NPrDmZwKu1p5wf94=
|
||||
gitee.com/chunanyong/dm v1.8.22 h1:H7fsrnUIvEA0jlDWew7vwELry1ff+tLMIu2Fk2cIBSg=
|
||||
gitee.com/chunanyong/dm v1.8.22/go.mod h1:EPRJnuPFgbyOFgJ0TRYCTGzhq+ZT4wdyaj/GW/LLcNg=
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
|
||||
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
|
||||
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
@@ -34,6 +38,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
|
||||
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -56,6 +62,8 @@ github.com/duckdb/duckdb-go/v2 v2.5.5 h1:TlK8ipnzoKW2aNrjGqRkFWLCDpJDxR/VwH8ezEc
|
||||
github.com/duckdb/duckdb-go/v2 v2.5.5/go.mod h1:6uIbC3gz36NCEygECzboygOo/Z9TeVwox/puG+ohWV0=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
@@ -68,6 +76,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
@@ -95,6 +105,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
@@ -158,6 +170,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
|
||||
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
@@ -201,6 +215,7 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
@@ -300,6 +315,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -342,6 +358,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
@@ -32,6 +33,25 @@ func normalizeAnthropicMessagesURL(baseURL string) string {
|
||||
return url + "/v1/messages"
|
||||
}
|
||||
|
||||
func IsDashScopeAnthropicCompatibleBaseURL(baseURL string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
return host == "dashscope.aliyuncs.com" || host == "coding.dashscope.aliyuncs.com"
|
||||
}
|
||||
|
||||
func ApplyAnthropicAuthHeaders(headers http.Header, baseURL string, apiKey string) {
|
||||
headers.Set("x-api-key", apiKey)
|
||||
if IsDashScopeAnthropicCompatibleBaseURL(baseURL) {
|
||||
headers.Set("Authorization", "Bearer "+apiKey)
|
||||
headers.Del("anthropic-version")
|
||||
return
|
||||
}
|
||||
headers.Set("anthropic-version", anthropicAPIVersion)
|
||||
}
|
||||
|
||||
// AnthropicProvider 实现 Anthropic Claude API 的 Provider
|
||||
type AnthropicProvider struct {
|
||||
config ai.ProviderConfig
|
||||
@@ -446,8 +466,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("x-api-key", p.config.APIKey)
|
||||
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
|
||||
ApplyAnthropicAuthHeaders(httpReq.Header, p.baseURL, p.config.APIKey)
|
||||
|
||||
if strings.Contains(string(jsonBody), `"stream":true`) || strings.Contains(string(jsonBody), `"stream": true`) {
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package provider
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeAnthropicMessagesURL_AppendsMessagesSuffix(t *testing.T) {
|
||||
url := normalizeAnthropicMessagesURL("https://api.anthropic.com")
|
||||
@@ -22,3 +25,33 @@ func TestNormalizeAnthropicMessagesURL_PreservesExplicitMessagesPath(t *testing.
|
||||
t.Fatalf("expected explicit messages path to be preserved, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAnthropicAuthHeaders_UsesOfficialAnthropicHeadersForAnthropicAPI(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
ApplyAnthropicAuthHeaders(headers, "https://api.anthropic.com", "sk-test")
|
||||
|
||||
if got := headers.Get("x-api-key"); got != "sk-test" {
|
||||
t.Fatalf("expected x-api-key header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("anthropic-version"); got != anthropicAPIVersion {
|
||||
t.Fatalf("expected anthropic-version header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("Authorization"); got != "" {
|
||||
t.Fatalf("expected no authorization header for official anthropic, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAnthropicAuthHeaders_UsesBearerForDashScopeCompatibleAnthropic(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
ApplyAnthropicAuthHeaders(headers, "https://coding.dashscope.aliyuncs.com/apps/anthropic", "sk-sp-test")
|
||||
|
||||
if got := headers.Get("Authorization"); got != "Bearer sk-sp-test" {
|
||||
t.Fatalf("expected bearer authorization header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("x-api-key"); got != "sk-sp-test" {
|
||||
t.Fatalf("expected x-api-key header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("anthropic-version"); got != "" {
|
||||
t.Fatalf("expected no anthropic-version header for DashScope, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,20 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ai "GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
var claudeLookPath = exec.LookPath
|
||||
var claudeCommandContext = exec.CommandContext
|
||||
var claudeCLIRequestTimeout = 90 * time.Second
|
||||
|
||||
// ClaudeCLIProvider 通过 Claude Code CLI 发送聊天请求
|
||||
// 适用于 anyrouter/newapi 等只支持 Claude Code 协议的代理服务
|
||||
@@ -48,19 +52,25 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := ensureClaudeCLITimeout(ctx, claudeCLIRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
prompt := buildPrompt(req.Messages)
|
||||
args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"}
|
||||
if p.config.Model != "" {
|
||||
args = append(args, "--model", p.config.Model)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "claude", args...)
|
||||
cmd := claudeCommandContext(ctx, "claude", args...)
|
||||
if err := p.setEnv(cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if isClaudeCLITimeout(ctx, err) {
|
||||
return nil, fmt.Errorf("claude CLI 执行超时(%s),当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout)
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return nil, fmt.Errorf("claude CLI 执行失败: %s", string(exitErr.Stderr))
|
||||
}
|
||||
@@ -68,13 +78,14 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
|
||||
}
|
||||
|
||||
// 解析 JSON 输出
|
||||
var result struct {
|
||||
Result string `json:"result"`
|
||||
}
|
||||
var result cliStreamEvent
|
||||
if err := json.Unmarshal(output, &result); err != nil {
|
||||
// 如果 JSON 解析失败,直接返回原始文本
|
||||
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil
|
||||
}
|
||||
if errMsg, hasError := extractClaudeCLIEventError(result); hasError {
|
||||
return nil, fmt.Errorf("claude CLI 返回错误: %s", errMsg)
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{Content: result.Result}, nil
|
||||
}
|
||||
@@ -85,6 +96,9 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := ensureClaudeCLITimeout(ctx, claudeCLIRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
prompt := buildPrompt(req.Messages)
|
||||
args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"}
|
||||
if p.config.Model != "" {
|
||||
@@ -93,7 +107,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "claude", args...)
|
||||
cmd := claudeCommandContext(ctx, "claude", args...)
|
||||
if err := p.setEnv(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -137,7 +151,23 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "system":
|
||||
if isClaudeCLISystemRetryEvent(event) {
|
||||
if errMsg, hasError := extractClaudeCLISystemRetryError(event); hasError {
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
case "assistant":
|
||||
if errMsg, hasError := extractClaudeCLIEventError(event); hasError {
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
// 助手消息开始或文本内容
|
||||
if event.Message.Content != nil {
|
||||
for _, block := range event.Message.Content {
|
||||
@@ -156,12 +186,18 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
callback(ai.StreamChunk{Content: event.Delta.Text})
|
||||
}
|
||||
case "result":
|
||||
if errMsg, hasError := extractClaudeCLIEventError(event); hasError {
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
// 最终结果事件 — 不发送 content(assistant 事件已包含),只标记完成
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
case "error":
|
||||
callback(ai.StreamChunk{Error: event.Error.Message, Done: true})
|
||||
errMsg, _ := extractClaudeCLIEventError(event)
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
@@ -171,6 +207,14 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
stderrStr := strings.TrimSpace(stderrBuf.String())
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Process exited. stderr: %s\n", stderrStr)
|
||||
|
||||
if isClaudeCLITimeout(ctx, waitErr) {
|
||||
callback(ai.StreamChunk{
|
||||
Error: fmt.Sprintf("claude CLI 执行超时(%s),当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout),
|
||||
Done: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if waitErr != nil {
|
||||
errMsg := fmt.Sprintf("claude CLI 异常退出: %v", waitErr)
|
||||
if stderrStr != "" {
|
||||
@@ -184,6 +228,20 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureClaudeCLITimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
if _, hasDeadline := ctx.Deadline(); hasDeadline || timeout <= 0 {
|
||||
return ctx, func() {}
|
||||
}
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
|
||||
func isClaudeCLITimeout(ctx context.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// setEnv 设置 Claude CLI 的环境变量
|
||||
func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) error {
|
||||
env, err := buildClaudeCLIEnv(p.config, cmd.Environ(), runtime.GOOS, claudeLookPath, fileExists)
|
||||
@@ -200,6 +258,7 @@ func buildClaudeCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string,
|
||||
env = upsertEnv(env, "ANTHROPIC_BASE_URL", strings.TrimRight(config.BaseURL, "/"))
|
||||
}
|
||||
if config.APIKey != "" {
|
||||
env = upsertEnv(env, "ANTHROPIC_AUTH_TOKEN", config.APIKey)
|
||||
env = upsertEnv(env, "ANTHROPIC_API_KEY", config.APIKey)
|
||||
}
|
||||
|
||||
@@ -354,8 +413,15 @@ func buildPrompt(messages []ai.Message) string {
|
||||
|
||||
// cliStreamEvent Claude CLI stream-json 输出的事件结构
|
||||
type cliStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Message struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
MaxRetries int `json:"max_retries,omitempty"`
|
||||
RetryDelayMS float64 `json:"retry_delay_ms,omitempty"`
|
||||
ErrorStatus int `json:"error_status,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
@@ -367,8 +433,79 @@ type cliStreamEvent struct {
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
} `json:"delta,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error cliStreamEventError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type cliStreamEventError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *cliStreamEventError) UnmarshalJSON(data []byte) error {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
e.Message = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err == nil {
|
||||
e.Message = strings.TrimSpace(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Message = strings.TrimSpace(payload.Message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractClaudeCLIEventError(event cliStreamEvent) (string, bool) {
|
||||
if event.Type != "error" && !event.IsError {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if msg := strings.TrimSpace(event.Result); msg != "" {
|
||||
return msg, true
|
||||
}
|
||||
|
||||
for _, block := range event.Message.Content {
|
||||
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
||||
return strings.TrimSpace(block.Text), true
|
||||
}
|
||||
}
|
||||
|
||||
if msg := strings.TrimSpace(event.Error.Message); msg != "" {
|
||||
return msg, true
|
||||
}
|
||||
|
||||
return "claude CLI 返回未知错误", true
|
||||
}
|
||||
|
||||
func isClaudeCLISystemRetryEvent(event cliStreamEvent) bool {
|
||||
return event.Type == "system" && event.Subtype == "api_retry"
|
||||
}
|
||||
|
||||
func extractClaudeCLISystemRetryError(event cliStreamEvent) (string, bool) {
|
||||
if !isClaudeCLISystemRetryEvent(event) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
errText := strings.TrimSpace(event.Error.Message)
|
||||
if event.ErrorStatus != 401 && event.ErrorStatus != 403 && !strings.EqualFold(errText, "authentication_failed") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if errText == "" {
|
||||
errText = "authentication_failed"
|
||||
}
|
||||
|
||||
if event.ErrorStatus > 0 {
|
||||
return fmt.Sprintf("claude CLI 鉴权失败 (HTTP %d): %s", event.ErrorStatus, errText), true
|
||||
}
|
||||
return fmt.Sprintf("claude CLI 鉴权失败: %s", errText), true
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user