mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 20:29:43 +08:00
Compare commits
139 Commits
fix/date-f
...
v0.6.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9ac1ab9b7 | ||
|
|
65a9f4352e | ||
|
|
f3b78f9763 | ||
|
|
0bccdeed8c | ||
|
|
39f6fbbe1f | ||
|
|
8a1a9a8fb8 | ||
|
|
dca5f629b2 | ||
|
|
8eae39c2c2 | ||
|
|
9613b2a8eb | ||
|
|
4fd679ce42 | ||
|
|
e56a72eb9f | ||
|
|
0fda09a19f | ||
|
|
33b78fb583 | ||
|
|
40416fb4df | ||
|
|
651eec1617 | ||
|
|
9dc58acb39 | ||
|
|
f3193f0933 | ||
|
|
7cb46f9f69 | ||
|
|
04c4613e4d | ||
|
|
8a10519f9b | ||
|
|
d57081ecfb | ||
|
|
035f536e8d | ||
|
|
22e4299d3e | ||
|
|
384aea132c | ||
|
|
890478eb7b | ||
|
|
8c79f2af0c | ||
|
|
a2cad9f7ce | ||
|
|
af90936fcc | ||
|
|
d3a1c017da | ||
|
|
a90423c04c | ||
|
|
6e23053ac6 | ||
|
|
9b50e9c9c8 | ||
|
|
4c76202d2c | ||
|
|
9c5b1a033a | ||
|
|
c631feef91 | ||
|
|
737896627a | ||
|
|
47235e1390 | ||
|
|
b6121fe1f8 | ||
|
|
f78b132c7c | ||
|
|
1adef17366 | ||
|
|
ada9bbf03e | ||
|
|
266f217bfd | ||
|
|
797db8cd36 | ||
|
|
54d46453df | ||
|
|
c7cf9526de | ||
|
|
d849cd49af | ||
|
|
604aaad69d | ||
|
|
605e266eab | ||
|
|
bf5a9c3306 | ||
|
|
2569a3779a | ||
|
|
bb6271246b | ||
|
|
8e0d1b0a80 | ||
|
|
d150780879 | ||
|
|
52d2ee7592 | ||
|
|
7beb08c960 | ||
|
|
2410aad849 | ||
|
|
33b21cc5ee | ||
|
|
1a0ba9a499 | ||
|
|
7a2563b83b | ||
|
|
632e57ea60 | ||
|
|
ca76440981 | ||
|
|
af5e84213f | ||
|
|
fcade0f860 | ||
|
|
1c2377bc62 | ||
|
|
426ef3bcf6 | ||
|
|
fb500ee33b | ||
|
|
89d79ff10c | ||
|
|
aa1bb5b886 | ||
|
|
5038ae5c9b | ||
|
|
83fe3d4ed9 | ||
|
|
808c773134 | ||
|
|
5d86ee7c76 | ||
|
|
8297829be6 | ||
|
|
f696f52470 | ||
|
|
60b63d7a22 | ||
|
|
1f617f9d53 | ||
|
|
1751e14d20 | ||
|
|
82e06bd94d | ||
|
|
c810d999bd | ||
|
|
0009c98c7e | ||
|
|
070ff72ad8 | ||
|
|
803c33b306 | ||
|
|
1d882d089f | ||
|
|
19da7fc66c | ||
|
|
c1877ea013 | ||
|
|
60dbb8a559 | ||
|
|
67fe3e3017 | ||
|
|
1a042321d2 | ||
|
|
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 | ||
|
|
9bbdcea3fd | ||
|
|
d9cbbc6c31 | ||
|
|
6ec1072d2e | ||
|
|
9f2f8b33e8 | ||
|
|
d984a15508 | ||
|
|
bfa918cb9d | ||
|
|
4e73f6d8b5 | ||
|
|
2f4e20a34a | ||
|
|
dfabd77615 | ||
|
|
4a2dda8aa2 | ||
|
|
d1d3fa26f1 | ||
|
|
fc8e62b997 | ||
|
|
b0eb93bfa3 | ||
|
|
11b8e0f12a | ||
|
|
8c5fee1c7a | ||
|
|
ec05f518a9 | ||
|
|
2c9aa640fd | ||
|
|
9f7cc58fad | ||
|
|
97bf891df3 | ||
|
|
72a9692200 | ||
|
|
eaa45f17fd | ||
|
|
f101a59d32 | ||
|
|
6ad690cffc | ||
|
|
22bd1c4c28 | ||
|
|
89c81823bc |
81
.github/workflows/dev-build.yml
vendored
81
.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
|
||||
|
||||
@@ -316,6 +320,9 @@ jobs:
|
||||
echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。"
|
||||
|
||||
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||
if command -v xattr >/dev/null 2>&1; then
|
||||
xattr -cr "$APP_NAME" || true
|
||||
fi
|
||||
codesign --force --deep --sign - "$APP_NAME"
|
||||
|
||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||
@@ -332,6 +339,17 @@ jobs:
|
||||
--app-drop-link 600 185 \
|
||||
"$DMG_NAME" \
|
||||
"$APP_NAME"
|
||||
|
||||
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dev-verify.XXXXXX")
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
|
||||
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
|
||||
if [ -z "$PACKAGED_APP" ]; then
|
||||
echo "❌ DMG 内未找到 .app 应用包!"
|
||||
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
|
||||
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||
|
||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||
|
||||
@@ -575,14 +593,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 +666,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。
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -314,6 +314,9 @@ jobs:
|
||||
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||
# 注意:Ad-hoc + hardened runtime(--options runtime)在未配置 entitlements 时,
|
||||
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
|
||||
if command -v xattr >/dev/null 2>&1; then
|
||||
xattr -cr "$APP_NAME" || true
|
||||
fi
|
||||
codesign --force --deep --sign - "$APP_NAME"
|
||||
|
||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||
@@ -330,6 +333,17 @@ jobs:
|
||||
--app-drop-link 600 185 \
|
||||
"$DMG_NAME" \
|
||||
"$APP_NAME"
|
||||
|
||||
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-release-verify.XXXXXX")
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
|
||||
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
|
||||
if [ -z "$PACKAGED_APP" ]; then
|
||||
echo "❌ DMG 内未找到 .app 应用包!"
|
||||
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
|
||||
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||
|
||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
.gitignore
|
||||
# build / release artifacts
|
||||
frontend/release/
|
||||
**/release/
|
||||
@@ -26,3 +26,6 @@ docs/需求追踪/
|
||||
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
.worktrees
|
||||
docs
|
||||
.tmp_superpowers_edit
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
Thank you for contributing to this project.
|
||||
|
||||
This repository follows a release-first workflow: `main` is the default public branch, while releases are prepared through `release/*` branches.
|
||||
This repository uses `dev` as the default integration branch, while stable releases are published from `main` through `release/*` branches.
|
||||
|
||||
---
|
||||
|
||||
## Branch Model
|
||||
|
||||
- `main`: stable release branch and default branch
|
||||
- `dev`: day-to-day integration branch for maintainers
|
||||
- `dev`: default branch and day-to-day integration branch
|
||||
- `main`: stable release branch
|
||||
- `release/*`: release preparation branches for maintainers
|
||||
- Recommended branch names for external contributors:
|
||||
- `fix/*`: bug fixes
|
||||
@@ -25,21 +25,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
## How External Contributors Should Open Pull Requests
|
||||
|
||||
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `main`**.
|
||||
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `dev`**.
|
||||
|
||||
Reasons:
|
||||
|
||||
- `main` is the default branch, so the PR entry point is clearer
|
||||
- merged contributions are immediately visible on the default branch
|
||||
- maintainers can handle downstream sync and release preparation in one place
|
||||
- `dev` is the active integration branch, so changes can be reviewed in the same lane as ongoing work
|
||||
- contributors align with the branch that triggers day-to-day validation and dev builds
|
||||
- maintainers can cut `release/*` branches from `dev` without re-syncing external changes first
|
||||
|
||||
Recommended flow:
|
||||
|
||||
1. Fork this repository
|
||||
2. Create a branch in your fork (`fix/*` or `feature/*` is recommended)
|
||||
2. Sync your fork with `dev` and create a branch from `dev` (`fix/*` or `feature/*` is recommended)
|
||||
3. Make your changes and perform basic self-checks
|
||||
4. Push the branch to your fork
|
||||
5. Open a pull request against the `main` branch of this repository
|
||||
5. Open a pull request against the `dev` branch of this repository
|
||||
|
||||
---
|
||||
|
||||
@@ -63,33 +63,21 @@ Recommended expectations:
|
||||
|
||||
## Merge Strategy for Maintainers
|
||||
|
||||
Pull requests merged into `main` should generally use **Squash and merge**.
|
||||
Pull requests merged into `dev` should generally use **Squash and merge**.
|
||||
|
||||
Reasons:
|
||||
|
||||
- keeps `main` history clean and linear
|
||||
- maps each PR to a single commit on `main`
|
||||
- reduces release, audit, and rollback complexity
|
||||
- keeps `dev` history readable and easier to audit during active iteration
|
||||
- maps each PR to a single integration commit on `dev`
|
||||
- reduces cherry-pick and conflict cost before creating `release/*`
|
||||
|
||||
---
|
||||
|
||||
## Maintainer Sync Rules
|
||||
|
||||
Because external pull requests are merged directly into `main`, maintainers must sync `main` back to development and release branches to avoid branch drift.
|
||||
Because external pull requests are merged directly into `dev`, maintainers should treat `dev` as the source branch for daily collaboration and release preparation.
|
||||
|
||||
### 1. Sync `main` -> `dev` (required)
|
||||
|
||||
The automatic GitHub Actions sync workflow has been removed.
|
||||
Maintainers should sync `main` back to `dev` manually when needed:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. Create `release/*` from `dev`
|
||||
### 1. Create `release/*` from `dev`
|
||||
|
||||
Before a release, create a release branch from `dev`, for example:
|
||||
|
||||
@@ -100,7 +88,7 @@ git checkout -b release/v0.6.0
|
||||
git push -u origin release/v0.6.0
|
||||
```
|
||||
|
||||
### 3. Release from `release/*` back to `main`
|
||||
### 2. Release from `release/*` back to `main`
|
||||
|
||||
When release preparation is complete, merge the release branch back into `main` and create a tag:
|
||||
|
||||
@@ -113,9 +101,9 @@ git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
```
|
||||
|
||||
### 4. Sync `main` back to `dev` after release
|
||||
### 3. Sync `main` back to `dev` after release
|
||||
|
||||
After the release, the same automation still applies. If needed, you can run the workflow manually (`workflow_dispatch`) or execute the fallback commands:
|
||||
After the release, sync `main` back into `dev` so the next iteration starts from the released code line:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
感谢你对本项目的贡献。
|
||||
|
||||
本项目采用“发布优先(`main` 为默认分支)+ `release/*` 分支发版”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
|
||||
本项目当前采用“`dev` 作为默认集成分支,`main` 作为稳定发布分支,`release/*` 负责发版准备”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
|
||||
|
||||
---
|
||||
|
||||
## 分支模型
|
||||
|
||||
- `main`:稳定发布分支,也是仓库默认分支
|
||||
- `dev`:日常开发集成分支,主要供维护者使用
|
||||
- `dev`:默认分支,也是日常开发集成分支
|
||||
- `main`:稳定发布分支
|
||||
- `release/*`:发布准备分支,主要供维护者使用
|
||||
- 外部贡献者建议使用以下分支命名:
|
||||
- `fix/*`:问题修复
|
||||
@@ -25,21 +25,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
## 外部贡献者如何提 Pull Request
|
||||
|
||||
无论是 `fix/*` 还是 `feature/*`,**外部贡献者统一直接向 `main` 发起 Pull Request**。
|
||||
无论是 `fix/*` 还是 `feature/*`,**外部贡献者统一直接向 `dev` 发起 Pull Request**。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- `main` 是默认分支,PR 入口更直观
|
||||
- 合并后贡献会直接体现在默认分支
|
||||
- 便于维护者统一做后续同步与发版整理
|
||||
- `dev` 是当前日常集成分支,评审与合入路径和维护者开发流程一致
|
||||
- 外部贡献会直接进入触发日常校验和 dev 构建的分支
|
||||
- 维护者可以直接从 `dev` 切 `release/*`,减少额外同步步骤
|
||||
|
||||
建议流程:
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 从你自己的仓库创建分支(建议命名为 `fix/*` 或 `feature/*`)
|
||||
2. 先同步你 fork 中的 `dev`,再从 `dev` 创建分支(建议命名为 `fix/*` 或 `feature/*`)
|
||||
3. 完成代码修改,并进行必要自检
|
||||
4. 推送到你的远程分支
|
||||
5. 向本仓库的 `main` 分支发起 Pull Request
|
||||
5. 向本仓库的 `dev` 分支发起 Pull Request
|
||||
|
||||
---
|
||||
|
||||
@@ -63,33 +63,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
## PR 合并策略(维护者)
|
||||
|
||||
`main` 分支上的 PR 建议使用 **Squash and merge**。
|
||||
`dev` 分支上的 PR 建议使用 **Squash and merge**。
|
||||
|
||||
原因:
|
||||
|
||||
- 保持 `main` 历史干净、线性
|
||||
- 每个 PR 在 `main` 上对应一个清晰提交
|
||||
- 降低发布排查与回滚成本
|
||||
- 保持 `dev` 集成历史清晰、便于审查
|
||||
- 每个 PR 在 `dev` 上对应一个明确的集成提交
|
||||
- 降低发版前整理与冲突处理成本
|
||||
|
||||
---
|
||||
|
||||
## 维护者同步规则
|
||||
|
||||
由于外部 PR 会直接合入 `main`,维护者必须及时将 `main` 的变更同步到开发与发布分支,避免分支漂移。
|
||||
由于外部 PR 会直接合入 `dev`,维护者应将 `dev` 作为日常协作与发版准备的主线分支。
|
||||
|
||||
### 1. main → dev 同步(必做)
|
||||
|
||||
仓库已移除 GitHub Actions 自动回灌 workflow。
|
||||
当前统一采用手动方式将 `main` 同步回 `dev`:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. 发版前从 dev 切 release/*
|
||||
### 1. 发版前从 dev 切 release/*
|
||||
|
||||
发布前由维护者基于 `dev` 创建发布分支,例如:
|
||||
|
||||
@@ -100,7 +88,7 @@ git checkout -b release/v0.6.0
|
||||
git push -u origin release/v0.6.0
|
||||
```
|
||||
|
||||
### 3. release/* → main 发版
|
||||
### 2. release/* → main 发版
|
||||
|
||||
发布准备完成后,将 `release/*` 合并回 `main`,并打标签发布:
|
||||
|
||||
@@ -113,9 +101,9 @@ git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
```
|
||||
|
||||
### 4. main 回流到 dev(发版后必做)
|
||||
### 3. main 回流到 dev(发版后必做)
|
||||
|
||||
发布完成后,仍沿用同一套自动化流程;如有需要,也可以手动触发 `workflow_dispatch`,或执行以下兜底命令,确保开发线与发布线一致:
|
||||
发布完成后,需要将 `main` 回流到 `dev`,确保下一轮开发从已发布代码线继续推进:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
|
||||
@@ -212,7 +212,7 @@ For the full workflow, branch model, and maintainer sync rules, see:
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
External contributors should open pull requests directly against `main`.
|
||||
External contributors should branch from `dev` and open pull requests against `dev`.
|
||||
|
||||
## Star History
|
||||
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
|
||||
|
||||
@@ -195,7 +195,7 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
|
||||
|
||||
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
|
||||
|
||||
外部贡献者统一直接向 `main` 发起 Pull Request。
|
||||
外部贡献者应从 `dev` 拉出分支,并统一向 `dev` 发起 Pull Request。
|
||||
|
||||
## Star History (Star 增长趋势)
|
||||
|
||||
|
||||
141
build-release.sh
141
build-release.sh
@@ -84,6 +84,63 @@ try_compress_binary_with_upx() {
|
||||
fi
|
||||
}
|
||||
|
||||
clear_macos_bundle_xattrs() {
|
||||
local bundle_path="$1"
|
||||
if [ -z "$bundle_path" ] || [ ! -e "$bundle_path" ]; then
|
||||
return
|
||||
fi
|
||||
if command -v xattr >/dev/null 2>&1; then
|
||||
xattr -cr "$bundle_path" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
verify_macos_dmg_bundle_signature() {
|
||||
local dmg_path="$1"
|
||||
local mount_dir=""
|
||||
local app_path=""
|
||||
|
||||
if [ -z "$dmg_path" ] || [ ! -f "$dmg_path" ]; then
|
||||
echo -e "${RED} ❌ DMG 文件不存在,无法校验签名:$dmg_path${NC}"
|
||||
return 1
|
||||
fi
|
||||
if ! command -v hdiutil >/dev/null 2>&1 || ! command -v codesign >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW} ⚠️ 当前环境缺少 hdiutil 或 codesign,跳过 DMG 内应用签名校验。${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mount_dir=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dmg-verify.XXXXXX")
|
||||
if [ -z "$mount_dir" ] || [ ! -d "$mount_dir" ]; then
|
||||
echo -e "${RED} ❌ 创建 DMG 校验挂载目录失败。${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! hdiutil attach -nobrowse -readonly -mountpoint "$mount_dir" "$dmg_path" >/dev/null 2>&1; then
|
||||
rmdir "$mount_dir" >/dev/null 2>&1 || true
|
||||
echo -e "${RED} ❌ 挂载 DMG 失败,无法校验签名。${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
app_path=$(find "$mount_dir" -maxdepth 1 -name "*.app" -print -quit)
|
||||
if [ -z "$app_path" ] || [ ! -d "$app_path" ]; then
|
||||
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
|
||||
rmdir "$mount_dir" >/dev/null 2>&1 || true
|
||||
echo -e "${RED} ❌ DMG 内未找到 .app 应用包。${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! codesign --verify --deep --strict --verbose=4 "$app_path" >/dev/null 2>&1; then
|
||||
echo -e "${RED} ❌ DMG 内 .app 签名校验失败:$(basename "$app_path")${NC}"
|
||||
codesign --verify --deep --strict --verbose=4 "$app_path" 2>&1 | sed 's/^/ /'
|
||||
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
|
||||
rmdir "$mount_dir" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
|
||||
rmdir "$mount_dir" >/dev/null 2>&1 || true
|
||||
return 0
|
||||
}
|
||||
|
||||
MAC_VOLICON_PATH="build/darwin/icon.icns"
|
||||
if [ ! -f "$MAC_VOLICON_PATH" ]; then
|
||||
MAC_VOLICON_PATH=""
|
||||
@@ -112,19 +169,20 @@ if [ $? -eq 0 ]; then
|
||||
else
|
||||
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
|
||||
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
|
||||
clear_macos_bundle_xattrs "$DIST_DIR/$APP_DEST_NAME"
|
||||
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
# 创建 DMG
|
||||
if command -v create-dmg &> /dev/null; then
|
||||
echo " 📦 正在打包 DMG (arm64)..."
|
||||
# 移除已存在的 DMG (以防万一)
|
||||
rm -f "$DIST_DIR/$DMG_NAME"
|
||||
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
|
||||
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
|
||||
# 创建 DMG
|
||||
if command -v create-dmg &> /dev/null; then
|
||||
echo " 📦 正在打包 DMG (arm64)..."
|
||||
# 移除已存在的 DMG (以防万一)
|
||||
rm -f "$DIST_DIR/$DMG_NAME"
|
||||
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
|
||||
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
|
||||
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
|
||||
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
|
||||
else
|
||||
@@ -134,8 +192,9 @@ if [ $? -eq 0 ]; then
|
||||
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口(CI/本地静默打包更友好)。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
|
||||
# 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性,
|
||||
# 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO)
|
||||
if [ -n "$MAC_VOLICON_PATH" ]; then
|
||||
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
|
||||
else
|
||||
@@ -179,15 +238,17 @@ if [ $? -eq 0 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
|
||||
else
|
||||
# 删除中间的 .app 文件,保持目录整洁
|
||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
||||
echo " ✅ 已生成 $DMG_NAME"
|
||||
fi
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
|
||||
elif ! verify_macos_dmg_bundle_signature "$DIST_DIR/$DMG_NAME"; then
|
||||
echo -e "${RED} ❌ DMG 内应用签名校验失败,保留 .app 与 .dmg 以便排查。${NC}"
|
||||
else
|
||||
# 删除中间的 .app 文件,保持目录整洁
|
||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
||||
echo " ✅ 已生成 $DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -219,11 +280,12 @@ if [ $? -eq 0 ]; then
|
||||
else
|
||||
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ad-hoc 代码签名
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
|
||||
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# Ad-hoc 代码签名
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
|
||||
clear_macos_bundle_xattrs "$DIST_DIR/$APP_DEST_NAME"
|
||||
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
if command -v create-dmg &> /dev/null; then
|
||||
echo " 📦 正在打包 DMG (amd64)..."
|
||||
@@ -239,8 +301,9 @@ if [ $? -eq 0 ]; then
|
||||
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口(CI/本地静默打包更友好)。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
|
||||
# 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性,
|
||||
# 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO)
|
||||
if [ -n "$MAC_VOLICON_PATH" ]; then
|
||||
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
|
||||
else
|
||||
@@ -282,14 +345,16 @@ if [ $? -eq 0 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
|
||||
else
|
||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
||||
echo " ✅ 已生成 $DMG_NAME"
|
||||
fi
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
|
||||
elif ! verify_macos_dmg_bundle_signature "$DIST_DIR/$DMG_NAME"; then
|
||||
echo -e "${RED} ❌ DMG 内应用签名校验失败,保留 .app 与 .dmg 以便排查。${NC}"
|
||||
else
|
||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
||||
echo " ✅ 已生成 $DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
339
cmd/manualtestseed/main.go
Normal file
339
cmd/manualtestseed/main.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
aiservice "GoNavi-Wails/internal/ai/service"
|
||||
"GoNavi-Wails/internal/app"
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
)
|
||||
|
||||
const (
|
||||
modeSeedSecureStorage = "seed-secure-storage"
|
||||
modeSeedAIUpdate = "seed-ai-update"
|
||||
)
|
||||
|
||||
const (
|
||||
testConnectionID = "manualtest-postgres"
|
||||
testSecureProviderID = "manualtest-secure-provider"
|
||||
testPendingProviderID = "manualtest-pending-provider"
|
||||
testBackupDirName = "manual-test-backups"
|
||||
connectionsFileName = "connections.json"
|
||||
globalProxyFileName = "global_proxy.json"
|
||||
aiConfigFileName = "ai_config.json"
|
||||
securityUpdateFileName = "config-security-update.json"
|
||||
)
|
||||
|
||||
type backupManifest struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ConfigDir string `json:"configDir"`
|
||||
Files []backupManifestFile `json:"files"`
|
||||
}
|
||||
|
||||
type backupManifestFile struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
Existed bool `json:"existed"`
|
||||
}
|
||||
|
||||
type storedAIConfig struct {
|
||||
SchemaVersion int `json:"schemaVersion,omitempty"`
|
||||
Providers []ai.ProviderConfig `json:"providers"`
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
SafetyLevel string `json:"safetyLevel"`
|
||||
ContextLevel string `json:"contextLevel"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
mode := flag.String("mode", modeSeedSecureStorage, "seed mode: seed-secure-storage | seed-ai-update")
|
||||
flag.Parse()
|
||||
|
||||
configDir, err := resolveConfigDir()
|
||||
if err != nil {
|
||||
fatalf("resolve config dir failed: %v", err)
|
||||
}
|
||||
|
||||
store := secretstore.NewKeyringStore()
|
||||
if err := store.HealthCheck(); err != nil {
|
||||
fatalf("secret store unavailable: %v", err)
|
||||
}
|
||||
|
||||
backupDir, err := backupConfigFiles(configDir)
|
||||
if err != nil {
|
||||
fatalf("backup config files failed: %v", err)
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(*mode) {
|
||||
case modeSeedSecureStorage:
|
||||
if err := seedSecureStorage(configDir, store); err != nil {
|
||||
fatalf("seed secure storage failed: %v", err)
|
||||
}
|
||||
fmt.Printf("mode=%s\nbackup=%s\nconnectionId=%s\nproviderId=%s\n", modeSeedSecureStorage, backupDir, testConnectionID, testSecureProviderID)
|
||||
case modeSeedAIUpdate:
|
||||
if err := seedAIUpdate(configDir, store); err != nil {
|
||||
fatalf("seed ai update failed: %v", err)
|
||||
}
|
||||
fmt.Printf("mode=%s\nbackup=%s\npendingProviderId=%s\n", modeSeedAIUpdate, backupDir, testPendingProviderID)
|
||||
default:
|
||||
fatalf("unsupported mode: %s", *mode)
|
||||
}
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func resolveConfigDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(homeDir, ".gonavi"), nil
|
||||
}
|
||||
|
||||
func backupConfigFiles(configDir string) (string, error) {
|
||||
backupDir := filepath.Join(configDir, testBackupDirName, time.Now().Format("20060102-150405"))
|
||||
files := []string{
|
||||
connectionsFileName,
|
||||
globalProxyFileName,
|
||||
aiConfigFileName,
|
||||
filepath.Join("migrations", securityUpdateFileName),
|
||||
}
|
||||
|
||||
manifest := backupManifest{
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
ConfigDir: configDir,
|
||||
Files: make([]backupManifestFile, 0, len(files)),
|
||||
}
|
||||
|
||||
for _, relativePath := range files {
|
||||
srcPath := filepath.Join(configDir, relativePath)
|
||||
info, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
manifest.Files = append(manifest.Files, backupManifestFile{
|
||||
RelativePath: relativePath,
|
||||
Existed: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(backupDir, relativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
manifest.Files = append(manifest.Files, backupManifestFile{
|
||||
RelativePath: relativePath,
|
||||
Existed: true,
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
manifestData, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, "manifest.json"), manifestData, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return backupDir, nil
|
||||
}
|
||||
|
||||
func seedSecureStorage(configDir string, store secretstore.SecretStore) error {
|
||||
if err := cleanupKnownTestSecrets(store); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appService := app.NewAppWithSecretStore(store)
|
||||
_ = appService.DeleteConnection(testConnectionID)
|
||||
|
||||
if _, err := appService.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: testConnectionID,
|
||||
Name: "手工测试 PostgreSQL",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: testConnectionID,
|
||||
Type: "postgres",
|
||||
Host: "127.0.0.1",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "manualtest-pg-secret",
|
||||
Database: "postgres",
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := appService.SaveGlobalProxy(connection.SaveGlobalProxyInput{
|
||||
Enabled: true,
|
||||
Type: "http",
|
||||
Host: "127.0.0.1",
|
||||
Port: 7890,
|
||||
User: "manual-test",
|
||||
Password: "manualtest-proxy-secret",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storeConfig := aiservice.NewProviderConfigStore(configDir, store)
|
||||
snapshot, err := storeConfig.LoadRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot.Providers = filterProviders(snapshot.Providers, testSecureProviderID, testPendingProviderID)
|
||||
snapshot.Providers = append(snapshot.Providers, ai.ProviderConfig{
|
||||
ID: testSecureProviderID,
|
||||
Type: "custom",
|
||||
Name: "手工测试 Secure Provider",
|
||||
APIKey: "manualtest-ai-secret",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
APIFormat: "openai",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "Bearer manualtest-header-secret",
|
||||
"X-Trace-Id": "manualtest-visible",
|
||||
},
|
||||
MaxTokens: 2048,
|
||||
Temperature: 0.2,
|
||||
})
|
||||
if snapshot.SafetyLevel == "" {
|
||||
snapshot.SafetyLevel = ai.PermissionReadOnly
|
||||
}
|
||||
if snapshot.ContextLevel == "" {
|
||||
snapshot.ContextLevel = ai.ContextSchemaOnly
|
||||
}
|
||||
return storeConfig.Save(snapshot)
|
||||
}
|
||||
|
||||
func seedAIUpdate(configDir string, store secretstore.SecretStore) error {
|
||||
if err := cleanupKnownTestSecrets(store); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, aiConfigFileName)
|
||||
cfg, err := readStoredAIConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Providers = filterProviders(cfg.Providers, testSecureProviderID, testPendingProviderID)
|
||||
cfg.Providers = append(cfg.Providers, ai.ProviderConfig{
|
||||
ID: testPendingProviderID,
|
||||
Type: "custom",
|
||||
Name: "手工测试 待迁移 AI",
|
||||
APIKey: "manualtest-ai-update-secret",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
APIFormat: "openai",
|
||||
MaxTokens: 1024,
|
||||
})
|
||||
if cfg.SchemaVersion == 0 {
|
||||
cfg.SchemaVersion = 2
|
||||
}
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = []ai.ProviderConfig{}
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(configPath, data, 0o644)
|
||||
}
|
||||
|
||||
func readStoredAIConfig(configPath string) (storedAIConfig, error) {
|
||||
cfg := storedAIConfig{
|
||||
Providers: []ai.ProviderConfig{},
|
||||
SafetyLevel: string(ai.PermissionReadOnly),
|
||||
ContextLevel: string(ai.ContextSchemaOnly),
|
||||
SchemaVersion: 2,
|
||||
ActiveProvider: "",
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return storedAIConfig{}, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return storedAIConfig{}, err
|
||||
}
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = []ai.ProviderConfig{}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func filterProviders(providers []ai.ProviderConfig, excludedIDs ...string) []ai.ProviderConfig {
|
||||
excluded := make(map[string]struct{}, len(excludedIDs))
|
||||
for _, id := range excludedIDs {
|
||||
excluded[strings.TrimSpace(id)] = struct{}{}
|
||||
}
|
||||
filtered := make([]ai.ProviderConfig, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if _, skip := excluded[strings.TrimSpace(provider.ID)]; skip {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, provider)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func cleanupKnownTestSecrets(store secretstore.SecretStore) error {
|
||||
type secretRef struct {
|
||||
kind string
|
||||
id string
|
||||
}
|
||||
refs := []secretRef{
|
||||
{kind: "connection", id: testConnectionID},
|
||||
{kind: "global-proxy", id: "default"},
|
||||
{kind: "ai-provider", id: testSecureProviderID},
|
||||
{kind: "ai-provider", id: testPendingProviderID},
|
||||
}
|
||||
|
||||
for _, item := range refs {
|
||||
ref, err := secretstore.BuildRef(item.kind, item.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.Delete(ref); err != nil && !isIgnorableDeleteError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isIgnorableDeleteError(err error) bool {
|
||||
if err == nil || os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(message, "could not be found") ||
|
||||
strings.Contains(message, "not be found in the keyring") ||
|
||||
strings.Contains(message, "element not found")
|
||||
}
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gonavi-client",
|
||||
"version": "0.0.1",
|
||||
"version": "0.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gonavi-client",
|
||||
"version": "0.0.1",
|
||||
"version": "0.6.5",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gonavi-client",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.6.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1 +1 @@
|
||||
f697e821b4acd5cf614d63d46453e8a4
|
||||
26a843d5fd071d0c7e9d8022e98eb4e3
|
||||
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;
|
||||
}
|
||||
@@ -340,3 +375,47 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
|
||||
.driver-manager-hscroll-inner {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.security-update-action-btn.ant-btn,
|
||||
.security-update-action-btn.ant-btn-default,
|
||||
.security-update-action-btn.ant-btn-primary,
|
||||
.security-update-action-btn.ant-btn-text {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.security-update-action-btn.ant-btn:focus,
|
||||
.security-update-action-btn.ant-btn:focus-visible,
|
||||
.security-update-action-btn.ant-btn-default:focus,
|
||||
.security-update-action-btn.ant-btn-default:focus-visible,
|
||||
.security-update-action-btn.ant-btn-primary:focus,
|
||||
.security-update-action-btn.ant-btn-primary:focus-visible,
|
||||
.security-update-action-btn.ant-btn-text:focus,
|
||||
.security-update-action-btn.ant-btn-text:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.security-update-banner {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.security-update-result-card {
|
||||
transition: background 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
|
||||
.security-update-result-card-active {
|
||||
animation: security-update-result-pulse 1.8s ease;
|
||||
}
|
||||
|
||||
@keyframes security-update-result-pulse {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
1470
frontend/src/App.tsx
1470
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -226,6 +227,7 @@ 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 重渲染)
|
||||
@@ -259,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;
|
||||
@@ -351,7 +353,12 @@ 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);
|
||||
@@ -673,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 }));
|
||||
|
||||
@@ -783,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) {
|
||||
@@ -805,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), '...(截断)'];
|
||||
@@ -826,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), '...(截断)'];
|
||||
@@ -852,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) => {
|
||||
@@ -883,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;
|
||||
@@ -910,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);
|
||||
@@ -1020,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, {
|
||||
@@ -1057,6 +1104,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
setComposerNotice(null);
|
||||
|
||||
toolCallRoundRef.current = 0; // 重置工具调用轮次计数
|
||||
totalToolRoundRef.current = 0; // 重置总轮次计数
|
||||
nudgeCountRef.current = 0; // 重置催促计数
|
||||
|
||||
const currentImages = [...draftImages];
|
||||
@@ -1264,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),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
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';
|
||||
|
||||
@@ -26,6 +28,7 @@ interface AISettingsModalProps {
|
||||
onClose: () => void;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
focusProviderId?: string;
|
||||
}
|
||||
|
||||
// 预设配置:每个预设映射到后端 type(openai/anthropic/gemini/custom)并附带默认 URL 和 Model
|
||||
@@ -77,7 +80,7 @@ const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; ico
|
||||
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
|
||||
];
|
||||
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme }) => {
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
|
||||
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||||
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||||
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
|
||||
@@ -88,6 +91,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);
|
||||
|
||||
@@ -105,6 +109,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 {
|
||||
@@ -131,18 +136,52 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
|
||||
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !focusProviderId) {
|
||||
return;
|
||||
}
|
||||
if (!providers.some((provider) => provider.id === focusProviderId)) {
|
||||
return;
|
||||
}
|
||||
setActiveSection('providers');
|
||||
setActiveProviderId(focusProviderId);
|
||||
}, [focusProviderId, open, providers]);
|
||||
|
||||
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) => {
|
||||
@@ -153,17 +192,16 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||||
valuesApiFormat: p.apiFormat,
|
||||
});
|
||||
setEditingProvider(p);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
...p,
|
||||
type: resolvedTransport.type,
|
||||
models: p.models || [],
|
||||
presetKey: matchedPreset.key,
|
||||
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
|
||||
});
|
||||
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) => {
|
||||
@@ -217,12 +255,18 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
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,
|
||||
@@ -230,7 +274,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
};
|
||||
// 后端 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 */ }
|
||||
@@ -287,10 +331,20 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
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,
|
||||
@@ -401,7 +455,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 ? '编辑模型供应商' : '添加模型供应商'}
|
||||
@@ -492,11 +546,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 }}>
|
||||
@@ -699,7 +767,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
</div>
|
||||
}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onCancel={handleModalClose}
|
||||
footer={null}
|
||||
width={820}
|
||||
styles={{
|
||||
@@ -765,3 +833,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
};
|
||||
|
||||
export default AISettingsModal;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,16 @@ import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutl
|
||||
import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import {
|
||||
getStoredSecretPlaceholder,
|
||||
normalizeConnectionSecretErrorMessage,
|
||||
resolveConnectionTestFailureFeedback,
|
||||
} from '../utils/connectionModalPresentation';
|
||||
import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft';
|
||||
import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn';
|
||||
import { CUSTOM_CONNECTION_DRIVER_HELP } from '../utils/driverImportGuidance';
|
||||
import { applyNoAutoCapAttributes, noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
|
||||
|
||||
@@ -17,6 +26,28 @@ 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)';
|
||||
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) {
|
||||
@@ -96,7 +127,8 @@ const ConnectionModal: React.FC<{
|
||||
onClose: () => void;
|
||||
initialValues?: SavedConnection | null;
|
||||
onOpenDriverManager?: () => void;
|
||||
}> = ({ open, onClose, initialValues, onOpenDriverManager }) => {
|
||||
onSaved?: (savedConnection: SavedConnection) => void | Promise<void>;
|
||||
}> = ({ open, onClose, initialValues, onOpenDriverManager, onSaved }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useSSL, setUseSSL] = useState(false);
|
||||
@@ -122,6 +154,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);
|
||||
@@ -131,6 +164,7 @@ const ConnectionModal: React.FC<{
|
||||
const darkMode = theme === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single';
|
||||
const mongoTopology = Form.useWatch('mongoTopology', form) || 'single';
|
||||
const mongoSrv = Form.useWatch('mongoSrv', form) || false;
|
||||
@@ -161,7 +195,10 @@ const ConnectionModal: React.FC<{
|
||||
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
|
||||
const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff';
|
||||
const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff';
|
||||
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
|
||||
const overlayTheme = useMemo(
|
||||
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
|
||||
[darkMode, disableLocalBackdropFilter],
|
||||
);
|
||||
|
||||
const tunnelSectionStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
@@ -171,6 +208,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 +246,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 +848,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 +1178,7 @@ const ConnectionModal: React.FC<{
|
||||
setUriFeedback(null);
|
||||
setCustomIconType(undefined);
|
||||
setCustomIconColor(undefined);
|
||||
setClearSecrets(createEmptyConnectionSecretClearState());
|
||||
setTypeSelectWarning(null);
|
||||
setDriverStatusLoaded(false);
|
||||
void refreshDriverStatus();
|
||||
@@ -1198,6 +1311,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 +1425,28 @@ 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);
|
||||
if (onSaved) {
|
||||
void Promise.resolve(onSaved(savedConnection)).catch((error: unknown) => {
|
||||
console.warn('Failed to refresh post-save state', error);
|
||||
void message.warning('配置已保存,但安全更新状态暂未刷新,请稍后重新检查');
|
||||
});
|
||||
}
|
||||
|
||||
form.resetFields();
|
||||
setUseSSL(false);
|
||||
setUseSSH(false);
|
||||
@@ -1240,8 +1454,11 @@ const ConnectionModal: React.FC<{
|
||||
setUseHttpTunnel(false);
|
||||
setDbType('mysql');
|
||||
setStep(1);
|
||||
setClearSecrets(createEmptyConnectionSecretClearState());
|
||||
onClose();
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
message.error(normalizeConnectionSecretErrorMessage(e?.message || e, '保存失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -1271,10 +1488,38 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
|
||||
const text = String(reason ?? '').trim();
|
||||
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
|
||||
return `测试失败: ${normalized}`;
|
||||
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 applyTestFailureFeedback = (feedback: { message: string; shouldToast: boolean }) => {
|
||||
setTestResult({ type: 'error', message: feedback.message });
|
||||
if (feedback.shouldToast) {
|
||||
void message.error({
|
||||
content: feedback.message,
|
||||
key: 'connection-test-failure',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
@@ -1285,14 +1530,29 @@ const ConnectionModal: React.FC<{
|
||||
const values = form.getFieldsValue(true);
|
||||
const unavailableReason = await resolveDriverUnavailableReason(values.type);
|
||||
if (unavailableReason) {
|
||||
const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用');
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'driver_unavailable',
|
||||
reason: unavailableReason,
|
||||
fallback: '驱动未安装启用',
|
||||
}));
|
||||
promptInstallDriver(values.type, unavailableReason);
|
||||
return;
|
||||
}
|
||||
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
|
||||
if (blockingSecretClearMessage) {
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'secret_blocked',
|
||||
reason: blockingSecretClearMessage,
|
||||
fallback: '连接参数不完整',
|
||||
}));
|
||||
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)
|
||||
@@ -1310,6 +1570,7 @@ const ConnectionModal: React.FC<{
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
void message.destroy('connection-test-failure');
|
||||
setTestResult({ type: 'success', message: res.message });
|
||||
if (isRedisType) {
|
||||
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
|
||||
@@ -1333,27 +1594,33 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
} else {
|
||||
setDbList([]);
|
||||
message.warning(`连接成功,但获取数据库列表失败:${dbRes.message || '未知错误'}`);
|
||||
message.warning(`连接成功,但获取数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, '未知错误')}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const failMessage = buildTestFailureMessage(
|
||||
res?.message,
|
||||
'连接被拒绝或参数无效,请检查后重试'
|
||||
);
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'runtime',
|
||||
reason: res?.message,
|
||||
fallback: '连接被拒绝或参数无效,请检查后重试',
|
||||
}));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errorFields' in e) {
|
||||
const failMessage = '测试失败: 请先完善必填项后再测试连接';
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'validation',
|
||||
reason: '',
|
||||
fallback: '请先完善必填项后再测试连接',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const reason = e instanceof Error
|
||||
? e.message
|
||||
: (typeof e === 'string' ? e : '未知异常');
|
||||
const failMessage = buildTestFailureMessage(reason, '未知异常');
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'runtime',
|
||||
reason,
|
||||
fallback: '未知异常',
|
||||
}));
|
||||
} finally {
|
||||
testInFlightRef.current = false;
|
||||
setLoading(false);
|
||||
@@ -1368,10 +1635,18 @@ 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 || '成员发现失败');
|
||||
message.error(normalizeConnectionSecretErrorMessage(result.message, '成员发现失败'));
|
||||
return;
|
||||
}
|
||||
const data = (result.data as Record<string, any>) || {};
|
||||
@@ -1392,7 +1667,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
message.success(result.message || `发现 ${members.length} 个成员`);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '成员发现失败');
|
||||
message.error(normalizeConnectionSecretErrorMessage(error?.message || error, '成员发现失败'));
|
||||
} finally {
|
||||
setDiscoveringMembers(false);
|
||||
}
|
||||
@@ -1850,7 +2125,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 +2135,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 +2152,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" />
|
||||
<Form.Item name="driver" label="驱动名称 (Driver Name)" rules={[{ required: true, message: '请输入驱动名称' }]} help={CUSTOM_CONNECTION_DRIVER_HELP}>
|
||||
<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 +2188,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 +2216,7 @@ const ConnectionModal: React.FC<{
|
||||
label="默认连接数据库(可选)"
|
||||
help="留空会自动尝试 postgres、template1、与当前用户名同名数据库"
|
||||
>
|
||||
<Input placeholder="例如:appdb" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如:appdb" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -1937,7 +2227,7 @@ const ConnectionModal: React.FC<{
|
||||
rules={[createUriAwareRequiredRule('请输入 Oracle 服务名(例如 ORCLPDB1)')]}
|
||||
help="请填写监听器注册的 SERVICE_NAME(不是用户名)。例如:ORCLPDB1"
|
||||
>
|
||||
<Input placeholder="例如:ORCLPDB1" />
|
||||
<Input {...noAutoCapInputProps} placeholder="例如:ORCLPDB1" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -1962,12 +2252,26 @@ 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={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
|
||||
emptyPlaceholder: '留空沿用主库密码',
|
||||
retainedLabel: '已保存从库密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'mysqlReplicaPassword',
|
||||
clearKey: 'mysqlReplicaPassword',
|
||||
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
|
||||
clearLabel: '清除已保存从库密码',
|
||||
description: '当前已保存从库密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -2001,15 +2305,29 @@ 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={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
emptyPlaceholder: '留空沿用主密码',
|
||||
retainedLabel: '已保存副本集密码',
|
||||
})}
|
||||
/>
|
||||
</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 +2363,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 +2400,22 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="password" label="密码 (可选)">
|
||||
<Input.Password placeholder="Redis 密码(如果设置了 requirepass)" />
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder: 'Redis 密码(如果设置了 requirepass)',
|
||||
retainedLabel: '已保存 Redis 密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'password',
|
||||
clearKey: 'primaryPassword',
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
clearLabel: '清除已保存密码',
|
||||
description: '当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
<Form.Item
|
||||
name="includeRedisDatabases"
|
||||
label="显示数据库 (留空显示全部)"
|
||||
@@ -2097,6 +2429,7 @@ 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"
|
||||
@@ -2104,10 +2437,17 @@ const ConnectionModal: React.FC<{
|
||||
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}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder: '密码',
|
||||
retainedLabel: '已保存密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{dbType === 'mongodb' && (
|
||||
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ marginBottom: 0 }}>
|
||||
@@ -2124,6 +2464,14 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'password',
|
||||
clearKey: 'primaryPassword',
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
clearLabel: '清除已保存密码',
|
||||
description: '当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{dbType === 'mongodb' && (
|
||||
@@ -2183,10 +2531,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>
|
||||
</>
|
||||
)}
|
||||
@@ -2209,7 +2557,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%' }} />
|
||||
@@ -2217,22 +2565,36 @@ 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={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasSSHPassword,
|
||||
emptyPlaceholder: '密码',
|
||||
retainedLabel: '已保存 SSH 密码',
|
||||
})}
|
||||
/>
|
||||
</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>
|
||||
@@ -2250,7 +2612,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 }}>
|
||||
@@ -2265,12 +2627,26 @@ 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={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasProxyPassword,
|
||||
emptyPlaceholder: '留空表示无认证',
|
||||
retainedLabel: '已保存代理密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'proxyPassword',
|
||||
clearKey: 'proxyPassword',
|
||||
hasStoredSecret: initialValues?.hasProxyPassword,
|
||||
clearLabel: '清除已保存代理密码',
|
||||
description: '当前已保存代理密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2288,7 +2664,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} />
|
||||
@@ -2296,12 +2672,26 @@ 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={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
|
||||
emptyPlaceholder: '留空表示无认证',
|
||||
retainedLabel: '已保存隧道密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'httpTunnelPassword',
|
||||
clearKey: 'httpTunnelPassword',
|
||||
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
|
||||
clearLabel: '清除已保存隧道密码',
|
||||
description: '当前已保存隧道密码。留空表示继续沿用,输入新值表示替换。',
|
||||
})}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>与“使用代理”互斥,启用后将通过 HTTP CONNECT 建立独立隧道。</Text>
|
||||
</div>
|
||||
)}
|
||||
@@ -2504,7 +2894,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
|
||||
@@ -2832,3 +3222,5 @@ const ConnectionModal: React.FC<{
|
||||
};
|
||||
|
||||
export default ConnectionModal;
|
||||
|
||||
|
||||
|
||||
102
frontend/src/components/ConnectionPackagePasswordModal.tsx
Normal file
102
frontend/src/components/ConnectionPackagePasswordModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Checkbox, Input, Modal, Typography } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ConnectionPackagePasswordModalMode = 'import' | 'export';
|
||||
|
||||
export interface ConnectionPackagePasswordModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
mode?: ConnectionPackagePasswordModalMode;
|
||||
includeSecrets?: boolean;
|
||||
useFilePassword?: boolean;
|
||||
password: string;
|
||||
error?: string;
|
||||
confirmLoading?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onIncludeSecretsChange?: (value: boolean) => void;
|
||||
onUseFilePasswordChange?: (value: boolean) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConnectionPackagePasswordModal({
|
||||
open,
|
||||
title,
|
||||
mode = 'import',
|
||||
includeSecrets = true,
|
||||
useFilePassword = false,
|
||||
password,
|
||||
error,
|
||||
confirmLoading,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
onIncludeSecretsChange,
|
||||
onUseFilePasswordChange,
|
||||
onPasswordChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConnectionPackagePasswordModalProps) {
|
||||
const isExportMode = mode === 'export';
|
||||
const showFilePasswordInput = isExportMode ? useFilePassword : true;
|
||||
const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码';
|
||||
const helperText = !includeSecrets
|
||||
? '将仅导出连接配置,不包含密码。'
|
||||
: (useFilePassword
|
||||
? '请通过单独渠道将密码告知接收方,不要和文件一起发送。'
|
||||
: '密码已加密保护。如需通过公网传输,建议设置文件保护密码。');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={confirmText}
|
||||
cancelText={cancelText}
|
||||
confirmLoading={confirmLoading}
|
||||
onOk={onConfirm}
|
||||
onCancel={onCancel}
|
||||
destroyOnClose={false}
|
||||
maskClosable={false}
|
||||
>
|
||||
{isExportMode ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Checkbox
|
||||
checked={includeSecrets}
|
||||
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
|
||||
>
|
||||
导出连接密码
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={useFilePassword}
|
||||
disabled={!includeSecrets}
|
||||
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
|
||||
>
|
||||
设置文件保护密码
|
||||
</Checkbox>
|
||||
</div>
|
||||
) : null}
|
||||
{showFilePasswordInput ? (
|
||||
<Input.Password
|
||||
autoFocus
|
||||
value={password}
|
||||
placeholder={placeholder}
|
||||
disabled={isExportMode && !useFilePassword}
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
{isExportMode ? (
|
||||
<Text type={useFilePassword ? 'warning' : 'secondary'} style={{ display: 'block', marginTop: 8 }}>
|
||||
{helperText}
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text type="danger" style={{ display: 'block', marginTop: 8 }}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/DataGrid.layout.test.tsx
Normal file
80
frontend/src/components/DataGrid.layout.test.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
connections: [],
|
||||
addSqlLog: vi.fn(),
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
},
|
||||
queryOptions: {
|
||||
showColumnComment: false,
|
||||
showColumnType: false,
|
||||
},
|
||||
setQueryOptions: vi.fn(),
|
||||
tableColumnOrders: {},
|
||||
enableColumnOrderMemory: false,
|
||||
setTableColumnOrder: vi.fn(),
|
||||
setEnableColumnOrderMemory: vi.fn(),
|
||||
clearTableColumnOrder: vi.fn(),
|
||||
tableHiddenColumns: {},
|
||||
enableHiddenColumnMemory: false,
|
||||
setTableHiddenColumns: vi.fn(),
|
||||
setEnableHiddenColumnMemory: vi.fn(),
|
||||
clearTableHiddenColumns: vi.fn(),
|
||||
aiPanelVisible: false,
|
||||
setAIPanelVisible: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
ImportData: vi.fn(),
|
||||
ExportTable: vi.fn(),
|
||||
ExportData: vi.fn(),
|
||||
ExportQuery: vi.fn(),
|
||||
ApplyChanges: vi.fn(),
|
||||
DBGetColumns: vi.fn(),
|
||||
DBGetIndexes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
describe('DataGrid layout', () => {
|
||||
it('renders a secondary action strip for view switching and auxiliary actions', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
readOnly
|
||||
pagination={{
|
||||
current: 1,
|
||||
pageSize: 100,
|
||||
total: 1,
|
||||
}}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-grid-secondary-actions="true"');
|
||||
expect(markup).toContain('data-grid-view-switcher="true"');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,15 @@ import { useStore } from '../store';
|
||||
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
|
||||
import { SavedConnection } from '../types';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
|
||||
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
type SyncLogEvent = { jobId: string; level?: string; message?: string; ts?: number };
|
||||
type SyncProgressEvent = { jobId: string; percent?: number; current?: number; total?: number; table?: string; stage?: string };
|
||||
@@ -23,6 +26,7 @@ type TableDiffSummary = {
|
||||
updates?: number;
|
||||
deletes?: number;
|
||||
same?: number;
|
||||
schemaDiffCount?: number;
|
||||
message?: string;
|
||||
targetTableExists?: boolean;
|
||||
plannedAction?: string;
|
||||
@@ -122,6 +126,15 @@ const buildSqlPreview = (
|
||||
? previewData.columnTypes as Record<string, string>
|
||||
: {};
|
||||
const statements: string[] = [];
|
||||
const schemaStatements = Array.isArray(previewData.schemaStatements)
|
||||
? previewData.schemaStatements
|
||||
.map((item: any) => String(item || '').trim())
|
||||
.filter((item: string) => item.length > 0)
|
||||
: [];
|
||||
|
||||
schemaStatements.forEach((statement: string) => {
|
||||
statements.push(statement.endsWith(';') ? statement : `${statement};`);
|
||||
});
|
||||
|
||||
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
|
||||
const updateRows = Array.isArray(previewData.updates) ? previewData.updates : [];
|
||||
@@ -189,6 +202,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const darkMode = themeMode === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
|
||||
// Step 1: Config
|
||||
const [sourceConnId, setSourceConnId] = useState<string>('');
|
||||
@@ -202,6 +216,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
// Step 2: Tables
|
||||
const [allTables, setAllTables] = useState<string[]>([]);
|
||||
const [selectedTables, setSelectedTables] = useState<string[]>([]);
|
||||
const [sourceDatasetMode, setSourceDatasetMode] = useState<SourceDatasetMode>('table');
|
||||
const [sourceQuery, setSourceQuery] = useState<string>('');
|
||||
|
||||
// Options
|
||||
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
|
||||
@@ -236,14 +252,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;
|
||||
@@ -285,7 +298,10 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setTargetConnId('');
|
||||
setSourceDb('');
|
||||
setTargetDb('');
|
||||
setAllTables([]);
|
||||
setSelectedTables([]);
|
||||
setSourceDatasetMode('table');
|
||||
setSourceQuery('');
|
||||
setWorkflowType('sync');
|
||||
setSyncContent('data');
|
||||
setSyncMode('insert_update');
|
||||
@@ -333,6 +349,28 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}
|
||||
}, [workflowType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceDatasetMode !== 'query') return;
|
||||
if (workflowType !== 'sync') {
|
||||
setWorkflowType('sync');
|
||||
}
|
||||
if (syncContent !== 'data') {
|
||||
setSyncContent('data');
|
||||
}
|
||||
if (targetTableStrategy !== 'existing_only') {
|
||||
setTargetTableStrategy('existing_only');
|
||||
}
|
||||
if (createIndexes) {
|
||||
setCreateIndexes(false);
|
||||
}
|
||||
if (autoAddColumns) {
|
||||
setAutoAddColumns(false);
|
||||
}
|
||||
if (selectedTables.length > 1) {
|
||||
setSelectedTables(selectedTables.slice(0, 1));
|
||||
}
|
||||
}, [sourceDatasetMode, workflowType, syncContent, targetTableStrategy, createIndexes, autoAddColumns, selectedTables]);
|
||||
|
||||
const handleSourceConnChange = async (connId: string) => {
|
||||
setSourceConnId(connId);
|
||||
setSourceDb('');
|
||||
@@ -378,10 +416,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const conn = connections.find(c => c.id === sourceConnId);
|
||||
const connId = isSourceQueryMode ? targetConnId : sourceConnId;
|
||||
const dbName = isSourceQueryMode ? targetDb : sourceDb;
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
const config = normalizeConnConfig(conn, sourceDb);
|
||||
const res = await DBGetTables(config as any, sourceDb);
|
||||
const config = normalizeConnConfig(conn, dbName);
|
||||
const res = await DBGetTables(config as any, dbName);
|
||||
if (res.success) {
|
||||
// DBGetTables returns [{Table: "name"}, ...]
|
||||
const tableRows = Array.isArray(res.data) ? res.data : [];
|
||||
@@ -389,6 +429,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
|
||||
setAllTables(tables as string[]);
|
||||
setSelectedTables(prev => {
|
||||
const existing = prev.filter((name) => tables.includes(name));
|
||||
if (isSourceQueryMode) {
|
||||
return existing.slice(0, 1);
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
@@ -406,7 +453,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
};
|
||||
|
||||
const analyzeDiff = async () => {
|
||||
if (selectedTables.length === 0) return;
|
||||
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
|
||||
if (selectionError) return message.error(selectionError);
|
||||
if (!sourceConnId || !targetConnId) return message.error("Select connections first");
|
||||
if (!sourceDb || !targetDb) return message.error("Select databases first");
|
||||
|
||||
@@ -423,18 +471,20 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
autoScrollRef.current = true;
|
||||
setSyncProgress({ percent: 0, current: 0, total: selectedTables.length, table: '', stage: '差异分析' });
|
||||
|
||||
const config = {
|
||||
const config = buildDataSyncRequest({
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
tables: selectedTables,
|
||||
content: syncContent,
|
||||
mode: "insert_update",
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
mongoCollectionName,
|
||||
jobId,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await DataSyncAnalyze(config as any);
|
||||
@@ -476,17 +526,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setPreviewLoading(true);
|
||||
setPreviewData(null);
|
||||
|
||||
const config = {
|
||||
const config = buildDataSyncRequest({
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
tables: selectedTables,
|
||||
content: "data",
|
||||
mode: "insert_update",
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
};
|
||||
mongoCollectionName,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await DataSyncPreview(config as any, table, 200);
|
||||
@@ -503,6 +555,11 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
};
|
||||
|
||||
const runSync = async () => {
|
||||
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
|
||||
if (selectionError) {
|
||||
message.error(selectionError);
|
||||
return;
|
||||
}
|
||||
if (syncContent !== 'schema' && diffTables.length === 0) {
|
||||
message.error("请先对比差异,再开始同步");
|
||||
return;
|
||||
@@ -541,33 +598,21 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
stage: '准备开始',
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
tables: selectedTables,
|
||||
content: syncContent,
|
||||
mode: syncMode,
|
||||
const config = buildDataSyncRequest({
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode,
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
mongoCollectionName,
|
||||
tableOptions,
|
||||
jobId,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await DataSync(config as any);
|
||||
@@ -611,6 +656,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false };
|
||||
return buildSqlPreview(previewData, previewTable, targetType, ops);
|
||||
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
|
||||
const previewHasSchemaStatements = useMemo(
|
||||
() => Array.isArray(previewData?.schemaStatements) && previewData.schemaStatements.length > 0,
|
||||
[previewData],
|
||||
);
|
||||
const previewSchemaWarnings = useMemo(
|
||||
() => Array.isArray(previewData?.schemaWarnings) ? previewData.schemaWarnings as string[] : [],
|
||||
[previewData],
|
||||
);
|
||||
const previewHasDataDiff = useMemo(
|
||||
() => Number(previewData?.totalInserts || 0) + Number(previewData?.totalUpdates || 0) + Number(previewData?.totalDeletes || 0) > 0,
|
||||
[previewData],
|
||||
);
|
||||
|
||||
const analysisWarnings = useMemo(() => {
|
||||
const items: string[] = [];
|
||||
@@ -621,6 +678,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
return Array.from(new Set(items));
|
||||
}, [diffTables]);
|
||||
|
||||
const isSourceQueryMode = sourceDatasetMode === 'query';
|
||||
const isMigrationWorkflow = workflowType === 'migration';
|
||||
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
|
||||
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
|
||||
@@ -646,8 +704,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.36)' : '0 18px 44px rgba(15,23,42,0.14)',
|
||||
backdropFilter: darkMode ? 'blur(18px)' : 'none',
|
||||
}), [darkMode]);
|
||||
backdropFilter: resolveTextInputSafeBackdropFilter(darkMode ? 'blur(18px)' : 'none', disableLocalBackdropFilter),
|
||||
}), [darkMode, disableLocalBackdropFilter]);
|
||||
|
||||
const shellCardStyle = useMemo<React.CSSProperties>(() => ({
|
||||
borderRadius: 18,
|
||||
@@ -853,7 +911,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Form.Item label="功能类型">
|
||||
<Select value={workflowType} onChange={setWorkflowType}>
|
||||
<Option value="sync">数据同步(基于已有目标表做差异同步)</Option>
|
||||
<Option value="migration">跨库迁移(可自动建表后导入)</Option>
|
||||
<Option value="migration" disabled={isSourceQueryMode}>跨库迁移(可自动建表后导入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="源数据方式">
|
||||
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
|
||||
<Option value="table">按表同步</Option>
|
||||
<Option value="query">按 SQL 结果集同步</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Alert
|
||||
@@ -864,11 +928,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
|
||||
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
|
||||
/>
|
||||
{isSourceQueryMode && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="SQL 结果集同步当前只支持:源端自定义 SQL -> 单个已存在目标表;查询结果需包含目标表主键列。"
|
||||
/>
|
||||
)}
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
|
||||
<Select value={syncContent} onChange={setSyncContent}>
|
||||
<Option value="data">仅同步数据</Option>
|
||||
<Option value="schema">仅同步结构</Option>
|
||||
<Option value="both">同步结构 + 数据</Option>
|
||||
<Option value="schema" disabled={isSourceQueryMode}>仅同步结构</Option>
|
||||
<Option value="both" disabled={isSourceQueryMode}>同步结构 + 数据</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
|
||||
@@ -879,7 +951,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
|
||||
<Option value="existing_only">仅使用已有目标表</Option>
|
||||
<Option value="auto_create_if_missing">目标表不存在时自动建表后导入</Option>
|
||||
<Option value="smart">智能模式(存在则直接导入,不存在则自动建表)</Option>
|
||||
@@ -902,12 +974,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)}>
|
||||
自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase)
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
|
||||
自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase;SQL 结果集模式暂不支持)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
|
||||
自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
@@ -943,21 +1015,56 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={quietPanelStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
|
||||
显示相同表
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Transfer
|
||||
dataSource={allTables.map(t => ({ key: t, title: t }))}
|
||||
titles={['源表', '已选表']}
|
||||
targetKeys={selectedTables}
|
||||
onChange={(keys) => setSelectedTables(keys as string[])}
|
||||
render={item => item.title}
|
||||
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
|
||||
/>
|
||||
{!isSourceQueryMode && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
|
||||
显示相同表
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Transfer
|
||||
dataSource={allTables.map(t => ({ key: t, title: t }))}
|
||||
titles={['源表', '已选表']}
|
||||
targetKeys={selectedTables}
|
||||
onChange={(keys) => setSelectedTables(keys as string[])}
|
||||
render={item => item.title}
|
||||
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isSourceQueryMode && (
|
||||
<Form layout="vertical">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="请输入源查询 SQL,并选择一个目标表。差异分析会直接基于该结果集与目标表对比。"
|
||||
/>
|
||||
<Form.Item label="源查询 SQL">
|
||||
<TextArea
|
||||
value={sourceQuery}
|
||||
onChange={(e) => setSourceQuery(e.target.value)}
|
||||
rows={8}
|
||||
placeholder="例如:SELECT id, name, email FROM users WHERE status = 'active'"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="目标表">
|
||||
<Select
|
||||
value={selectedTables[0]}
|
||||
onChange={(value) => setSelectedTables(value ? [value] : [])}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="请选择一个目标表"
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{allTables.map((table) => <Option key={table} value={table}>{table}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diffTables.length > 0 && (
|
||||
@@ -1076,8 +1183,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
render: (_: any, r: any) => {
|
||||
const can = !!r.canSync;
|
||||
const hasDiff = Number(r.inserts || 0) + Number(r.updates || 0) + Number(r.deletes || 0) > 0;
|
||||
const hasSchemaDiff = Number(r.schemaDiffCount || 0) > 0;
|
||||
return (
|
||||
<Button size="small" disabled={!can || !hasDiff || analyzing} onClick={() => openPreview(r.table)}>
|
||||
<Button size="small" disabled={!can || !(hasDiff || hasSchemaDiff) || analyzing} onClick={() => openPreview(r.table)}>
|
||||
查看
|
||||
</Button>
|
||||
);
|
||||
@@ -1149,14 +1257,14 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 1 && (
|
||||
<>
|
||||
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}>上一步</Button>
|
||||
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing} style={{ marginRight: 8 }}>
|
||||
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
|
||||
对比差异
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={runSync}
|
||||
loading={loading}
|
||||
disabled={selectedTables.length === 0 || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
>
|
||||
开始同步
|
||||
</Button>
|
||||
@@ -1184,12 +1292,59 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`}
|
||||
message={
|
||||
previewHasDataDiff
|
||||
? `插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`
|
||||
: (previewData.schemaSummary || `检测到 ${previewSql.statementCount} 条结构变更语句`)
|
||||
}
|
||||
/>
|
||||
{previewSchemaWarnings.length > 0 && (
|
||||
<Alert
|
||||
style={{ marginTop: 12 }}
|
||||
type="warning"
|
||||
showIcon
|
||||
message="结构预览包含风险或降级项"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{previewSchemaWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
|
||||
{previewSchemaWarnings.length > 8 && <li>还有 {previewSchemaWarnings.length - 8} 项未展开</li>}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
...(previewHasSchemaStatements ? [{
|
||||
key: 'schema',
|
||||
label: `结构(${Array.isArray(previewData.schemaStatements) ? previewData.schemaStatements.length : 0})`,
|
||||
children: (
|
||||
<div>
|
||||
<Text type="secondary">
|
||||
{previewData.schemaSummary || '以下为本次结构同步计划执行的语句。'}
|
||||
</Text>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 8,
|
||||
marginBottom: 0,
|
||||
padding: 10,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
background: '#fafafa',
|
||||
maxHeight: 420,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{Array.isArray(previewData.schemaStatements) && previewData.schemaStatements.length > 0
|
||||
? previewData.schemaStatements.join('\n')
|
||||
: '-- 当前表结构无可执行变更'}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}] : []),
|
||||
...(previewHasDataDiff ? [{
|
||||
key: 'insert',
|
||||
label: `插入(${previewData.totalInserts || 0})`,
|
||||
children: (
|
||||
@@ -1289,7 +1444,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
key: 'sql',
|
||||
label: `SQL(${previewSql.statementCount})`,
|
||||
@@ -1298,10 +1453,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
message={
|
||||
previewHasDataDiff
|
||||
? "SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
: "SQL 预览展示将执行的结构变更语句,用于审核确认。"
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">共 {previewSql.statementCount} 条语句(预览数据最多 200 条/类型)</Text>
|
||||
<Text type="secondary">
|
||||
{previewHasDataDiff
|
||||
? `共 ${previewSql.statementCount} 条语句(预览数据最多 200 条/类型)`
|
||||
: `共 ${previewSql.statementCount} 条结构变更语句`}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!previewSql.sqlText}
|
||||
@@ -1330,7 +1493,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
|
||||
{previewSql.sqlText || (previewHasDataDiff ? '-- 当前勾选范围下无 SQL 可预览' : '-- 当前表结构无可执行变更')}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,8 +7,9 @@ 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 { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
type ViewerPaginationState = {
|
||||
current: number;
|
||||
@@ -319,7 +320,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
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);
|
||||
@@ -395,7 +396,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const dbType = config.type || '';
|
||||
const dbType = resolveDataSourceType(config);
|
||||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||||
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
|
||||
|
||||
@@ -478,7 +479,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
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(),
|
||||
@@ -514,7 +515,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
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) => {
|
||||
@@ -567,7 +568,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
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;
|
||||
@@ -680,7 +681,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
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) => {
|
||||
@@ -734,7 +735,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
|
||||
const escapedSchema = escapeSQLLiteral(schemaName);
|
||||
const escapedTable = escapeSQLLiteral(pureTableName);
|
||||
const approxConfig: any = { ...(config as any), timeout: 3 };
|
||||
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`,
|
||||
@@ -775,7 +776,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
if (approximateCountStrategy === 'oracle-num-rows' && oracleApproxKeyRef.current !== countKey) {
|
||||
oracleApproxKeyRef.current = countKey;
|
||||
const approxSeq = ++oracleApproxSeqRef.current;
|
||||
const approxConfig: any = { ...(config as any), timeout: 3 };
|
||||
const approxConfig = buildRpcConnectionConfig(config, { timeout: 3 });
|
||||
const approxSql = buildOracleApproximateTotalSql({ dbName, tableName });
|
||||
|
||||
DBQuery(approxConfig as any, dbName, approxSql)
|
||||
@@ -854,7 +855,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
|
||||
const exportSqlWithFilter = useMemo(() => {
|
||||
const tableName = String(tab.tableName || '').trim();
|
||||
const dbType = String(currentConnConfig?.type || '').trim();
|
||||
const dbType = resolveDataSourceType(currentConnConfig);
|
||||
if (!tableName || !dbType) return '';
|
||||
|
||||
const whereSQL = buildWhereSQL(dbType, filterConditions);
|
||||
@@ -868,7 +869,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
|
||||
}
|
||||
return sql;
|
||||
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
|
||||
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, sortInfo, pkColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
const action = resolveDataViewerAutoFetchAction({
|
||||
|
||||
@@ -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,11 +4,29 @@ 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;
|
||||
}
|
||||
|
||||
const normalizeMySQLViewDDL = (rawDefinition: unknown): string => {
|
||||
const text = String(rawDefinition || '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, '');
|
||||
const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i;
|
||||
if (createViewPrefixPattern.test(normalized)) {
|
||||
return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`;
|
||||
}
|
||||
|
||||
if (/^\s*(select|with)\b/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${normalized};`;
|
||||
};
|
||||
|
||||
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -201,7 +219,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 +245,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;
|
||||
}
|
||||
@@ -256,15 +274,15 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
case 'mysql': {
|
||||
const keys = Object.keys(row);
|
||||
const textDefinition = row.view_definition || row.VIEW_DEFINITION;
|
||||
if (textDefinition) return String(textDefinition);
|
||||
if (textDefinition) return normalizeMySQLViewDDL(textDefinition);
|
||||
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
|
||||
if (sqlKey) return row[sqlKey];
|
||||
if (sqlKey) return normalizeMySQLViewDDL(row[sqlKey]);
|
||||
const tableSqlKey = keys.find(k => k.toLowerCase().includes('create table'));
|
||||
if (tableSqlKey) return row[tableSqlKey];
|
||||
if (tableSqlKey) return normalizeMySQLViewDDL(row[tableSqlKey]);
|
||||
for (const key of keys) {
|
||||
const val = String(row[key] || '');
|
||||
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('VIEW') || val.toUpperCase().includes('TABLE'))) {
|
||||
return val;
|
||||
return normalizeMySQLViewDDL(val);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(row, null, 2);
|
||||
|
||||
@@ -4,6 +4,11 @@ import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutline
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { useStore } from '../store';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import {
|
||||
DRIVER_LOCAL_IMPORT_BUTTON_LABEL,
|
||||
DRIVER_LOCAL_IMPORT_DIRECTORY_HELP,
|
||||
DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP,
|
||||
} from '../utils/driverImportGuidance';
|
||||
import {
|
||||
CheckDriverNetworkStatus,
|
||||
DownloadDriverPackage,
|
||||
@@ -11,6 +16,7 @@ import {
|
||||
GetDriverVersionPackageSize,
|
||||
GetDriverStatusList,
|
||||
InstallLocalDriverPackage,
|
||||
OpenDriverDownloadDirectory,
|
||||
RemoveDriverPackage,
|
||||
SelectDriverPackageDirectory,
|
||||
SelectDriverPackageFile,
|
||||
@@ -757,6 +763,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 +836,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 +849,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 +860,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 +954,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 +1097,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>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -1140,7 +1176,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
loading={loadingLocal}
|
||||
onClick={() => installDriverFromLocalFile(row)}
|
||||
>
|
||||
本地导入
|
||||
{DRIVER_LOCAL_IMPORT_BUTTON_LABEL}
|
||||
</Button>
|
||||
<Button
|
||||
type={hasLogs ? 'default' : 'text'}
|
||||
@@ -1342,10 +1378,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">行内“本地导入”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`);批量导入请使用上方“导入驱动目录”。</Text>
|
||||
<Text type="secondary">{DRIVER_LOCAL_IMPORT_DIRECTORY_HELP}</Text>
|
||||
<Text type="secondary">{DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP}</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 +1414,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,8 @@ 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';
|
||||
import { isMacLikePlatform } from '../utils/appearance';
|
||||
|
||||
interface FindInDatabaseModalProps {
|
||||
open: boolean;
|
||||
@@ -66,14 +68,15 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
|
||||
const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]);
|
||||
const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]);
|
||||
|
||||
const wt = useMemo(() => {
|
||||
const isDark = theme === 'dark';
|
||||
return buildOverlayWorkbenchTheme(isDark);
|
||||
}, [theme]);
|
||||
return buildOverlayWorkbenchTheme(isDark, { disableBackdropFilter: disableLocalBackdropFilter });
|
||||
}, [disableLocalBackdropFilter, theme]);
|
||||
|
||||
const buildConfig = useCallback(() => {
|
||||
if (!conn) return null;
|
||||
@@ -106,7 +109,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 +127,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 +169,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,8 @@ 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 { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
const SQL_KEYWORDS = [
|
||||
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
|
||||
@@ -248,6 +250,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const setQueryOptions = useStore(state => state.setQueryOptions);
|
||||
const shortcutOptions = useStore(state => state.shortcutOptions);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
const currentSavedQuery = useMemo(() => {
|
||||
const savedId = String(tab.savedQueryId || '').trim();
|
||||
@@ -323,6 +326,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
// Fetch Database List
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchDbs = async () => {
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
if (!conn) return;
|
||||
@@ -336,7 +343,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
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);
|
||||
|
||||
@@ -366,10 +373,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
};
|
||||
void fetchDbs();
|
||||
}, [currentConnectionId, connections]);
|
||||
}, [autoFetchVisible, currentConnectionId, connections]);
|
||||
|
||||
// Fetch Metadata for Autocomplete (Cross-database)
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
if (!conn) return;
|
||||
@@ -392,7 +403,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
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 +412,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
|
||||
// 获取列 (所有数据库类型都支持 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({
|
||||
@@ -423,7 +434,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
};
|
||||
void fetchMetadata();
|
||||
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||
}, [autoFetchVisible, currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||
|
||||
// Query ID management helpers
|
||||
const setQueryId = (id: string) => {
|
||||
@@ -577,7 +588,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
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;
|
||||
@@ -1555,7 +1566,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
} 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;
|
||||
@@ -1643,7 +1654,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
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, ';');
|
||||
|
||||
@@ -1694,7 +1705,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
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}`,
|
||||
@@ -1795,7 +1806,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
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({
|
||||
@@ -1921,7 +1932,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 {
|
||||
@@ -201,7 +202,7 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
for (const cmd of commands) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisExecuteCommand(config, cmd);
|
||||
const res = await (window as any).go.app.App.RedisExecuteCommand(buildRpcConnectionConfig(config), cmd);
|
||||
newResults.push({
|
||||
command: cmd,
|
||||
result: res.success ? res.data : null,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} 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;
|
||||
@@ -61,7 +62,7 @@ const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) =>
|
||||
if (!connection) return;
|
||||
|
||||
try {
|
||||
const config = { ...connection.config, redisDB } as any;
|
||||
const config = buildRpcConnectionConfig(connection.config, { redisDB });
|
||||
const res = await RedisGetServerInfo(config);
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
@@ -6,7 +6,15 @@ import { useStore } from '../store';
|
||||
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 {
|
||||
blurToFilter,
|
||||
isMacLikePlatform,
|
||||
normalizeBlurForPlatform,
|
||||
normalizeOpacityForPlatform,
|
||||
resolveAppearanceValues,
|
||||
resolveTextInputSafeBackdropFilter,
|
||||
} from '../utils/appearance';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
applyRenamedRedisKeyState,
|
||||
applyTreeNodeCheck,
|
||||
@@ -18,6 +26,9 @@ import {
|
||||
type RedisTreeDataNode,
|
||||
} from './redisViewerTree';
|
||||
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput } from '../utils/redisSearchPattern';
|
||||
import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -38,148 +49,6 @@ interface RedisViewerProps {
|
||||
redisDB: number;
|
||||
}
|
||||
|
||||
// 尝试多种方式解码二进制数据
|
||||
const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => {
|
||||
if (!value || value.length === 0) {
|
||||
return { displayValue: '', encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
|
||||
// 统计字节分布
|
||||
let nullCount = 0;
|
||||
let printableCount = 0;
|
||||
let highByteCount = 0;
|
||||
const sampleSize = Math.min(value.length, 200);
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code === 0) {
|
||||
nullCount++;
|
||||
} else if (code >= 32 && code < 127) {
|
||||
printableCount++;
|
||||
} else if (code >= 128) {
|
||||
highByteCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果超过30%是null字节,很可能是二进制数据,显示十六进制
|
||||
if (nullCount / sampleSize > 0.3) {
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
}
|
||||
|
||||
// 如果超过70%是可打印ASCII字符,直接显示
|
||||
if (printableCount / sampleSize > 0.7) {
|
||||
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
|
||||
// 尝试UTF-8解码
|
||||
if (highByteCount > 0) {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
|
||||
// 检查解码质量
|
||||
let validChars = 0;
|
||||
let replacementChars = 0;
|
||||
let controlChars = 0;
|
||||
|
||||
for (let i = 0; i < Math.min(decoded.length, 200); i++) {
|
||||
const code = decoded.charCodeAt(i);
|
||||
if (code === 0xFFFD) {
|
||||
replacementChars++;
|
||||
} else if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
|
||||
controlChars++;
|
||||
} else if ((code >= 32 && code < 127) || (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x303F)) {
|
||||
// ASCII可打印字符、中文字符、中文标点
|
||||
validChars++;
|
||||
}
|
||||
}
|
||||
|
||||
const totalChecked = Math.min(decoded.length, 200);
|
||||
|
||||
// 如果替换字符超过10%或控制字符超过20%,说明不是有效的UTF-8文本
|
||||
if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) {
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
}
|
||||
|
||||
// 如果有效字符超过50%,使用UTF-8解码
|
||||
if (validChars / totalChecked > 0.5) {
|
||||
return { displayValue: decoded, encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
} catch (e) {
|
||||
// UTF-8解码失败
|
||||
}
|
||||
}
|
||||
|
||||
// 默认显示十六进制
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
};
|
||||
|
||||
// 检测是否为二进制数据(包含大量不可打印字符)
|
||||
const isBinaryData = (value: string): boolean => {
|
||||
if (!value || value.length === 0) return false;
|
||||
// 检查前 100 个字符中不可打印字符的比例
|
||||
const sampleSize = Math.min(value.length, 100);
|
||||
let nonPrintableCount = 0;
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
// 不可打印字符:控制字符(0-31,除了 9, 10, 13)和 DEL(127)
|
||||
if ((code < 32 && code !== 9 && code !== 10 && code !== 13) || code === 127 || code > 255) {
|
||||
nonPrintableCount++;
|
||||
}
|
||||
}
|
||||
// 如果超过 10% 是不可打印字符,认为是二进制数据
|
||||
return nonPrintableCount / sampleSize > 0.1;
|
||||
};
|
||||
|
||||
// 将字符串转换为十六进制显示
|
||||
const toHexDisplay = (value: string): string => {
|
||||
const bytes: string[] = [];
|
||||
const ascii: string[] = [];
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
bytes.push(code.toString(16).padStart(2, '0').toUpperCase());
|
||||
// 可打印 ASCII 字符显示原字符,否则显示点
|
||||
ascii.push(code >= 32 && code < 127 ? value[i] : '.');
|
||||
|
||||
if (bytes.length === 16 || i === value.length - 1) {
|
||||
const offset = (Math.floor(i / 16) * 16).toString(16).padStart(8, '0').toUpperCase();
|
||||
const hexPart = bytes.join(' ').padEnd(47, ' ');
|
||||
const asciiPart = ascii.join('');
|
||||
result += `${offset} ${hexPart} |${asciiPart}|\n`;
|
||||
bytes.length = 0;
|
||||
ascii.length = 0;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// 尝试解析并格式化 JSON
|
||||
const tryFormatJson = (value: string): { isJson: boolean; formatted: string } => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return { isJson: true, formatted: JSON.stringify(parsed, null, 2) };
|
||||
} catch {
|
||||
return { isJson: false, formatted: value };
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化字符串值 - 支持 JSON、二进制数据检测和智能解码
|
||||
const formatStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding?: string } => {
|
||||
// 先检测是否为二进制数据
|
||||
if (isBinaryData(value)) {
|
||||
const { displayValue, encoding, needsHex } = tryDecodeValue(value);
|
||||
return { displayValue, isBinary: needsHex, isJson: false, encoding };
|
||||
}
|
||||
// 尝试 JSON 格式化
|
||||
const { isJson, formatted } = tryFormatJson(value);
|
||||
return { displayValue: formatted, isBinary: false, isJson, encoding: 'UTF-8' };
|
||||
};
|
||||
|
||||
// 可拖拽分隔条组件 - 使用直接 DOM 操作避免卡顿
|
||||
const ResizableDivider: React.FC<{
|
||||
onResizeEnd: (newWidth: number) => void;
|
||||
@@ -282,8 +151,16 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const blur = normalizeBlurForPlatform(resolvedAppearance.blur);
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
const connection = connections.find(c => c.id === connectionId);
|
||||
const workbenchTheme = useMemo(() => buildRedisWorkbenchTheme({ darkMode, opacity, blur }), [blur, darkMode, opacity]);
|
||||
const workbenchTheme = useMemo(
|
||||
() => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }),
|
||||
[blur, darkMode, disableLocalBackdropFilter, opacity],
|
||||
);
|
||||
const workbenchBackdropFilter = useMemo(
|
||||
() => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter),
|
||||
[blur, disableLocalBackdropFilter],
|
||||
);
|
||||
const keyAccentColor = workbenchTheme.accent;
|
||||
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
|
||||
const valueToolbarBg = workbenchTheme.panelBgStrong;
|
||||
@@ -292,6 +169,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [searchPattern, setSearchPattern] = useState('*');
|
||||
const [cursor, setCursor] = useState<string>('0');
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
@@ -429,7 +307,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;
|
||||
}
|
||||
@@ -466,13 +344,29 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
|
||||
}, [redisDB]);
|
||||
}, [loadKeys, redisDB]);
|
||||
|
||||
const executeSearch = useCallback((value: string) => {
|
||||
const normalized = normalizeRedisSearchInput(value);
|
||||
setSearchInput(normalized.keyword);
|
||||
setSearchPattern(normalized.pattern);
|
||||
setCursor('0');
|
||||
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
|
||||
}, [loadKeys]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
const pattern = value.trim() || '*';
|
||||
setSearchPattern(pattern);
|
||||
executeSearch(value);
|
||||
};
|
||||
|
||||
const handleSearchInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const normalized = normalizeRedisSearchDraftChange(event.target.value);
|
||||
setSearchInput(normalized.keyword);
|
||||
if (!normalized.shouldSearchImmediately) {
|
||||
return;
|
||||
}
|
||||
setSearchPattern(normalized.pattern);
|
||||
setCursor('0');
|
||||
loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false));
|
||||
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
@@ -508,7 +402,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 +433,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 +461,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 +480,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 +499,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 +536,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 +546,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(
|
||||
{
|
||||
@@ -1039,6 +933,22 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderValueEditor = () => {
|
||||
const processValueForCurrentView = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
}
|
||||
|
||||
if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
}
|
||||
|
||||
if (viewMode === 'utf8') {
|
||||
return { displayValue: decodeRedisUtf8Value(value), isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
}
|
||||
|
||||
return formatRedisStringValue(value);
|
||||
};
|
||||
|
||||
if (!keyValue || !selectedKey) {
|
||||
return (
|
||||
<div
|
||||
@@ -1060,33 +970,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
const renderStringValue = () => {
|
||||
const strValue = String(keyValue.value);
|
||||
|
||||
// 根据查看模式生成显示内容
|
||||
const getDisplayContent = () => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(strValue), isBinary: true, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: strValue, isBinary: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(strValue.length);
|
||||
for (let i = 0; i < strValue.length; i++) {
|
||||
bytes[i] = strValue.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: strValue, isBinary: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
const { displayValue, isBinary, isJson, encoding } = formatStringValue(strValue);
|
||||
return { displayValue, isBinary, encoding };
|
||||
}
|
||||
};
|
||||
|
||||
const { displayValue, isBinary, encoding } = getDisplayContent();
|
||||
const isJson = viewMode === 'auto' && formatStringValue(strValue).isJson;
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(strValue);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
@@ -1145,31 +1029,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderHashValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = Object.entries(keyValue.value as Record<string, string>).map(([field, value]) => {
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(value);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
|
||||
return { field, value, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1177,7 +1038,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 +1054,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);
|
||||
@@ -1213,9 +1074,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
title: '添加字段',
|
||||
content: (
|
||||
<Form id="add-hash-field-form" layout="vertical">
|
||||
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
|
||||
<Input id="new-hash-field" />
|
||||
</Form.Item>
|
||||
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
|
||||
<Input id="new-hash-field" {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
<Form.Item label="值" name="value" rules={[{ required: true }]}>
|
||||
<Input.TextArea id="new-hash-value" rows={4} />
|
||||
</Form.Item>
|
||||
@@ -1306,31 +1167,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderListValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as string[]).map((value, index) => {
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(value);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
|
||||
return { index, value, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1338,7 +1176,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 +1192,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);
|
||||
@@ -1476,31 +1314,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderSetValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as string[]).map((member, index) => {
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(member);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(member);
|
||||
return { index, member, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1508,7 +1323,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 +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.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);
|
||||
@@ -1613,31 +1428,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderZSetValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as Array<{ member: string; score: number }>).map((item, index) => {
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(item.member);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(item.member);
|
||||
return { ...item, index, displayMember: displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1645,7 +1437,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 +1453,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);
|
||||
@@ -1778,30 +1570,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderStreamValue = () => {
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as StreamEntry[]).map((item, index) => {
|
||||
const rawFieldsText = JSON.stringify(item.fields ?? {}, null, 2);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(rawFieldsText);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(rawFieldsText);
|
||||
return {
|
||||
index,
|
||||
id: item.id,
|
||||
@@ -1841,7 +1612,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 +1630,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) {
|
||||
@@ -1887,7 +1658,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label>ID(可选,默认 *):</label>
|
||||
<Input id="new-stream-id" placeholder="例如: * 或 1723110000000-0" />
|
||||
<Input id="new-stream-id" {...noAutoCapInputProps} placeholder="例如: * 或 1723110000000-0" />
|
||||
</div>
|
||||
<div>
|
||||
<label>字段 JSON:</label>
|
||||
@@ -2049,7 +1820,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: blurToFilter(blur), WebkitBackdropFilter: blurToFilter(blur) }}>
|
||||
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
|
||||
{/* Left: Key List */}
|
||||
<div ref={leftPanelRef} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
|
||||
<div style={{ ...workbenchCardStyle, padding: 12 }}>
|
||||
@@ -2062,9 +1833,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</div>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Search
|
||||
placeholder="搜索 Key (支持 * 通配符)"
|
||||
defaultValue="*"
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="搜索 Key"
|
||||
value={searchInput}
|
||||
onChange={handleSearchInputChange}
|
||||
onSearch={handleSearch}
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
@@ -2151,7 +1925,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
>
|
||||
<Editor
|
||||
height="450px"
|
||||
language={tryFormatJson(editValue).isJson ? 'json' : 'plaintext'}
|
||||
language={formatRedisStringValue(editValue).isJson ? 'json' : 'plaintext'}
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={editValue}
|
||||
onChange={(value) => setEditValue(value || '')}
|
||||
@@ -2176,7 +1950,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
>
|
||||
<Form form={newKeyForm} layout="vertical" initialValues={{ ttl: -1 }}>
|
||||
<Form.Item name="key" label="Key" rules={[{ required: true, message: '请输入 Key' }]}>
|
||||
<Input placeholder="key name" />
|
||||
<Input {...noAutoCapInputProps} placeholder="key name" />
|
||||
</Form.Item>
|
||||
<Form.Item name="value" label="值" rules={[{ required: true, message: '请输入值' }]}>
|
||||
<Input.TextArea rows={4} placeholder="value" />
|
||||
@@ -2206,7 +1980,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
rules={[{ required: true, message: '请输入新的 Key 名称' }]}
|
||||
extra={renameTargetKey ? `原始 Key:${renameTargetKey}` : undefined}
|
||||
>
|
||||
<Input placeholder="new:key:name" />
|
||||
<Input {...noAutoCapInputProps} placeholder="new:key:name" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
154
frontend/src/components/SecurityUpdateBanner.tsx
Normal file
154
frontend/src/components/SecurityUpdateBanner.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Button } from 'antd';
|
||||
import { CloseOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { SecurityUpdateStatus } from '../types';
|
||||
import { getSecurityUpdateStatusMeta } from '../utils/securityUpdatePresentation';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_BANNER_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateBannerSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateBannerProps {
|
||||
status: SecurityUpdateStatus;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
onStart: () => void;
|
||||
onRetry: () => void;
|
||||
onRestart: () => void;
|
||||
onOpenDetails: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const resolvePrimaryAction = (
|
||||
status: SecurityUpdateStatus,
|
||||
actions: Pick<SecurityUpdateBannerProps, 'onStart' | 'onRetry' | 'onRestart' | 'onOpenDetails'>,
|
||||
) => {
|
||||
switch (status.overallStatus) {
|
||||
case 'postponed':
|
||||
return {
|
||||
label: '立即更新',
|
||||
onClick: actions.onStart,
|
||||
};
|
||||
case 'needs_attention':
|
||||
return {
|
||||
label: '查看详情',
|
||||
onClick: actions.onOpenDetails,
|
||||
};
|
||||
case 'rolled_back':
|
||||
return {
|
||||
label: '重新开始更新',
|
||||
onClick: actions.onRestart,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: '查看详情',
|
||||
onClick: actions.onOpenDetails,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSecondaryAction = (
|
||||
status: SecurityUpdateStatus,
|
||||
actions: Pick<SecurityUpdateBannerProps, 'onRetry' | 'onOpenDetails'>,
|
||||
) => {
|
||||
switch (status.overallStatus) {
|
||||
case 'needs_attention':
|
||||
return {
|
||||
label: '重新检查',
|
||||
onClick: actions.onRetry,
|
||||
};
|
||||
case 'rolled_back':
|
||||
return {
|
||||
label: '查看详情',
|
||||
onClick: actions.onOpenDetails,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const SecurityUpdateBanner = ({
|
||||
status,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
onStart,
|
||||
onRetry,
|
||||
onRestart,
|
||||
onOpenDetails,
|
||||
onDismiss,
|
||||
}: SecurityUpdateBannerProps) => {
|
||||
const statusMeta = getSecurityUpdateStatusMeta(status);
|
||||
const primaryAction = resolvePrimaryAction(status, { onStart, onRetry, onRestart, onOpenDetails });
|
||||
const secondaryAction = resolveSecondaryAction(status, { onRetry, onOpenDetails });
|
||||
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={SECURITY_UPDATE_BANNER_CLASS}
|
||||
style={{
|
||||
margin: '12px 12px 0',
|
||||
padding: '14px 16px',
|
||||
borderRadius: 16,
|
||||
...getSecurityUpdateBannerSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
flexShrink: 0,
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
已保存配置可进行安全更新
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{statusMeta.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
{secondaryAction ? (
|
||||
<Button className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={secondaryAction.onClick}>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={actionButtonStyle}
|
||||
type="primary"
|
||||
onClick={primaryAction.onClick}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
<Button
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={{ ...actionButtonStyle, width: 36, minWidth: 36, paddingInline: 0 }}
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateBannerProps };
|
||||
export default SecurityUpdateBanner;
|
||||
133
frontend/src/components/SecurityUpdateIntroModal.tsx
Normal file
133
frontend/src/components/SecurityUpdateIntroModal.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Button, Modal } from 'antd';
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateIntroModalProps {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
onStart: () => void;
|
||||
onPostpone: () => void;
|
||||
onViewDetails: () => void;
|
||||
}
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
...getSecurityUpdateActionButtonStyle(),
|
||||
height: 38,
|
||||
paddingInline: 18,
|
||||
};
|
||||
|
||||
const SecurityUpdateIntroModal = ({
|
||||
open,
|
||||
loading = false,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
onStart,
|
||||
onPostpone,
|
||||
onViewDetails,
|
||||
}: SecurityUpdateIntroModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||
title={(
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
fontSize: 18,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
|
||||
已保存配置安全更新
|
||||
</div>
|
||||
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||
使用新的安全存储方式前,需要先完成一次本地配置更新。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
open={open}
|
||||
closable={!loading}
|
||||
maskClosable={!loading}
|
||||
keyboard={!loading}
|
||||
onCancel={onPostpone}
|
||||
width={560}
|
||||
styles={{
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8 },
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="details"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
type="primary"
|
||||
ghost
|
||||
style={actionButtonStyle}
|
||||
onClick={onViewDetails}
|
||||
disabled={loading}
|
||||
>
|
||||
查看详情
|
||||
</Button>,
|
||||
<Button
|
||||
key="later"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
type="primary"
|
||||
ghost
|
||||
style={actionButtonStyle}
|
||||
onClick={onPostpone}
|
||||
disabled={loading}
|
||||
>
|
||||
稍后提醒我
|
||||
</Button>,
|
||||
<Button
|
||||
key="start"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
type="primary"
|
||||
style={actionButtonStyle}
|
||||
loading={loading}
|
||||
onClick={onStart}
|
||||
>
|
||||
立即更新
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 0 6px',
|
||||
color: darkMode ? 'rgba(255,255,255,0.82)' : '#2f3b52',
|
||||
lineHeight: 1.8,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
为了让已保存的连接、代理和相关服务配置使用新的安全存储方式,本次更新需要进行一次本地配置更新。
|
||||
更新前会自动创建本地备份;如果本次未完成,系统会保留当前可用配置,你也可以稍后继续。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateIntroModalProps };
|
||||
export default SecurityUpdateIntroModal;
|
||||
69
frontend/src/components/SecurityUpdateProgressModal.tsx
Normal file
69
frontend/src/components/SecurityUpdateProgressModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Modal, Spin } from 'antd';
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateProgressModalProps {
|
||||
open: boolean;
|
||||
stageText: string;
|
||||
detailText?: string;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
}
|
||||
|
||||
const SecurityUpdateProgressModal = ({
|
||||
open,
|
||||
stageText,
|
||||
detailText,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
}: SecurityUpdateProgressModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||
open={open}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
footer={null}
|
||||
width={420}
|
||||
centered
|
||||
styles={{
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { display: 'none' },
|
||||
body: { padding: 28 },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 18,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
fontSize: 22,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
{stageText}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{detailText ?? '更新过程中会保留当前可用配置,请稍候。'}
|
||||
</div>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateProgressModalProps };
|
||||
export default SecurityUpdateProgressModal;
|
||||
337
frontend/src/components/SecurityUpdateSettingsModal.tsx
Normal file
337
frontend/src/components/SecurityUpdateSettingsModal.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Empty, Modal, Tag } from 'antd';
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
|
||||
import {
|
||||
getSecurityUpdateIssueActionMeta,
|
||||
getSecurityUpdateIssueSeverityMeta,
|
||||
getSecurityUpdateItemStatusMeta,
|
||||
getSecurityUpdateStatusMeta,
|
||||
sortSecurityUpdateIssues,
|
||||
} from '../utils/securityUpdatePresentation';
|
||||
import {
|
||||
hasSecurityUpdateRecentResult,
|
||||
resolveSecurityUpdateFocusState,
|
||||
type SecurityUpdateFocusState,
|
||||
type SecurityUpdateSettingsFocusTarget,
|
||||
} from '../utils/securityUpdateRepairFlow';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS,
|
||||
SECURITY_UPDATE_RESULT_CARD_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateSectionSurfaceStyle,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateSettingsModalProps {
|
||||
open: boolean;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
status: SecurityUpdateStatus;
|
||||
focusTarget?: SecurityUpdateSettingsFocusTarget | null;
|
||||
focusRequest?: number;
|
||||
onClose: () => void;
|
||||
onStart: () => void;
|
||||
onRetry: () => void;
|
||||
onRestart: () => void;
|
||||
onIssueAction: (issue: SecurityUpdateIssue) => void;
|
||||
}
|
||||
|
||||
const sectionStyle = (
|
||||
overlayTheme: OverlayWorkbenchTheme,
|
||||
surfaceOpacity: number,
|
||||
options?: { emphasized?: boolean },
|
||||
) => ({
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, {
|
||||
...options,
|
||||
surfaceOpacity,
|
||||
}),
|
||||
});
|
||||
|
||||
const EMPTY_FOCUS_STATE: SecurityUpdateFocusState = {
|
||||
target: null,
|
||||
pulseKey: null,
|
||||
};
|
||||
|
||||
const SecurityUpdateSettingsModal = ({
|
||||
open,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
status,
|
||||
focusTarget = null,
|
||||
focusRequest = 0,
|
||||
onClose,
|
||||
onStart,
|
||||
onRetry,
|
||||
onRestart,
|
||||
onIssueAction,
|
||||
}: SecurityUpdateSettingsModalProps) => {
|
||||
const statusMeta = getSecurityUpdateStatusMeta(status);
|
||||
const sortedIssues = sortSecurityUpdateIssues(status.issues);
|
||||
const showRecentResult = hasSecurityUpdateRecentResult(status);
|
||||
const showStart = status.overallStatus === 'pending' || status.overallStatus === 'postponed';
|
||||
const showRetry = status.overallStatus === 'needs_attention';
|
||||
const showRestart = status.overallStatus === 'needs_attention' || status.overallStatus === 'rolled_back';
|
||||
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
|
||||
const [activeFocus, setActiveFocus] = useState<SecurityUpdateFocusState>(EMPTY_FOCUS_STATE);
|
||||
const statusSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const recentResultRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextFocus = resolveSecurityUpdateFocusState(open, focusTarget, focusRequest);
|
||||
if (!nextFocus.target || !nextFocus.pulseKey) {
|
||||
setActiveFocus(EMPTY_FOCUS_STATE);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targetNode = nextFocus.target === 'recent_result'
|
||||
? recentResultRef.current
|
||||
: statusSectionRef.current;
|
||||
if (!targetNode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setActiveFocus(EMPTY_FOCUS_STATE);
|
||||
const animationFrame = window.requestAnimationFrame(() => {
|
||||
targetNode.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
targetNode.focus({ preventScroll: true });
|
||||
setActiveFocus(nextFocus);
|
||||
});
|
||||
const highlightTimer = window.setTimeout(() => {
|
||||
setActiveFocus((current) => (
|
||||
current.pulseKey === nextFocus.pulseKey ? EMPTY_FOCUS_STATE : current
|
||||
));
|
||||
}, 1800);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
window.clearTimeout(highlightTimer);
|
||||
};
|
||||
}, [focusRequest, focusTarget, open]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||
title={(
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
fontSize: 18,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
|
||||
安全更新
|
||||
</div>
|
||||
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||
管理已保存配置的安全更新状态与待处理项。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
showRetry ? (
|
||||
<Button key="retry" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRetry}>
|
||||
重新检查
|
||||
</Button>
|
||||
) : null,
|
||||
showRestart ? (
|
||||
<Button key="restart" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRestart}>
|
||||
重新开始更新
|
||||
</Button>
|
||||
) : null,
|
||||
showStart ? (
|
||||
<Button
|
||||
key="start"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={actionButtonStyle}
|
||||
type="primary"
|
||||
onClick={onStart}
|
||||
>
|
||||
开始更新
|
||||
</Button>
|
||||
) : null,
|
||||
<Button key="close" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={760}
|
||||
styles={{
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8, maxHeight: 640, overflowY: 'auto' },
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 14, padding: '12px 0' }}>
|
||||
<div
|
||||
ref={statusSectionRef}
|
||||
tabIndex={-1}
|
||||
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'status' })}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
当前状态:{statusMeta.label}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{statusMeta.description}
|
||||
</div>
|
||||
</div>
|
||||
<Tag color={
|
||||
statusMeta.tone === 'success'
|
||||
? 'success'
|
||||
: statusMeta.tone === 'error'
|
||||
? 'error'
|
||||
: statusMeta.tone === 'processing'
|
||||
? 'processing'
|
||||
: statusMeta.tone === 'warning'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
}>
|
||||
{statusMeta.label}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
|
||||
影响范围
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
|
||||
{[
|
||||
{ label: '总计', value: status.summary.total },
|
||||
{ label: '已更新', value: status.summary.updated },
|
||||
{ label: '待处理', value: status.summary.pending },
|
||||
{ label: '已跳过', value: status.summary.skipped },
|
||||
{ label: '失败', value: status.summary.failed },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
|
||||
borderRadius: 12,
|
||||
padding: '12px 10px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}>{item.label}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 20, fontWeight: 700, color: overlayTheme.titleText }}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
|
||||
待处理清单
|
||||
</div>
|
||||
{sortedIssues.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="当前没有待处理项"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{sortedIssues.map((issue) => {
|
||||
const actionMeta = getSecurityUpdateIssueActionMeta(issue);
|
||||
const itemStatusMeta = getSecurityUpdateItemStatusMeta(issue.status);
|
||||
const issueSeverityMeta = getSecurityUpdateIssueSeverityMeta(issue.severity);
|
||||
return (
|
||||
<div
|
||||
key={issue.id}
|
||||
style={{
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
{issue.title || issue.message || issue.id}
|
||||
</div>
|
||||
<Tag color={itemStatusMeta.color}>
|
||||
状态:{itemStatusMeta.label}
|
||||
</Tag>
|
||||
<Tag color={issueSeverityMeta.color}>
|
||||
级别:{issueSeverityMeta.label}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{issue.message || '当前项需要进一步处理后才能完成安全更新。'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={actionButtonStyle}
|
||||
type={actionMeta.emphasis === 'primary' ? 'primary' : 'default'}
|
||||
onClick={() => onIssueAction(issue)}
|
||||
>
|
||||
{actionMeta.label}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRecentResult ? (
|
||||
<div
|
||||
ref={recentResultRef}
|
||||
tabIndex={-1}
|
||||
className={[
|
||||
SECURITY_UPDATE_RESULT_CARD_CLASS,
|
||||
activeFocus.target === 'recent_result' ? SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'recent_result' })}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 8 }}>
|
||||
最近一次结果
|
||||
</div>
|
||||
{status.backupPath ? (
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
备份位置:<span style={{ color: overlayTheme.titleText }}>{status.backupPath}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{status.lastError ? (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#ff7875', lineHeight: 1.7 }}>
|
||||
最近错误:{status.lastError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateSettingsModalProps };
|
||||
export default SecurityUpdateSettingsModal;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,24 +17,7 @@ import TriggerViewer from './TriggerViewer';
|
||||
import DefinitionViewer from './DefinitionViewer';
|
||||
import TableOverview from './TableOverview';
|
||||
import type { TabData } from '../types';
|
||||
|
||||
const detectConnectionEnvLabel = (connectionName: string): string | null => {
|
||||
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
|
||||
if (tokens.includes('uat')) return 'UAT';
|
||||
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
|
||||
if (tokens.includes('sit')) return 'SIT';
|
||||
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
|
||||
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
|
||||
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title;
|
||||
if (!connectionName) return tab.title;
|
||||
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
|
||||
return `[${prefix}] ${tab.title}`;
|
||||
};
|
||||
import { buildTabDisplayTitle } from '../utils/tabDisplay';
|
||||
|
||||
type SortableTabLabelProps = {
|
||||
displayTitle: string;
|
||||
@@ -50,7 +33,7 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
|
||||
<span
|
||||
className="tab-dnd-label"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
title="拖拽调整标签顺序"
|
||||
title={displayTitle}
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
@@ -198,8 +181,8 @@ 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 connection = connections.find((conn) => conn.id === tab.connectionId);
|
||||
const displayTitle = buildTabDisplayTitle(tab, connection);
|
||||
const tabIsActive = tab.id === activeTabId;
|
||||
let content;
|
||||
if (tab.type === 'query') {
|
||||
@@ -337,6 +320,10 @@ const TabManager: React.FC = () => {
|
||||
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
|
||||
background: rgba(9, 109, 217, 0.08);
|
||||
}
|
||||
body[data-theme='light'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
background: rgba(24, 144, 255, 0.10) !important;
|
||||
border-color: rgba(24, 144, 255, 0.28) !important;
|
||||
}
|
||||
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
background: rgba(255, 214, 102, 0.12) !important;
|
||||
border-color: rgba(255, 214, 102, 0.4) !important;
|
||||
|
||||
@@ -9,6 +9,9 @@ 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, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
|
||||
interface EditableColumn extends ColumnDefinition {
|
||||
_key: string;
|
||||
@@ -544,7 +547,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
key: 'name',
|
||||
width: 180,
|
||||
render: (text: string, record: EditableColumn) => readOnly ? text : (
|
||||
<Input value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
|
||||
<Input {...noAutoCapInputProps} value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -751,14 +754,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);
|
||||
@@ -848,7 +851,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';
|
||||
@@ -993,7 +996,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);
|
||||
@@ -1030,7 +1033,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);
|
||||
@@ -1039,7 +1042,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);
|
||||
@@ -1393,6 +1396,19 @@ ${selectedTrigger.statement}`;
|
||||
};
|
||||
};
|
||||
|
||||
const hasUnsavedDraftChanges = useMemo(() => {
|
||||
if (isNewTable || readOnly) {
|
||||
return false;
|
||||
}
|
||||
const tableInfo = resolveTableInfo();
|
||||
return hasAlterTableDraftChanges({
|
||||
dbType: tableInfo.dbType,
|
||||
tableName: tableInfo.qualifiedName,
|
||||
originalColumns,
|
||||
columns,
|
||||
});
|
||||
}, [columns, connections, isNewTable, originalColumns, readOnly, tab.connectionId, tab.dbName, tab.tableName]);
|
||||
|
||||
const supportsIndexSchemaOps = (): boolean => {
|
||||
const dbType = getDbType();
|
||||
if (!dbType) return false;
|
||||
@@ -1522,7 +1538,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);
|
||||
@@ -1551,7 +1567,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 {
|
||||
@@ -2117,105 +2133,62 @@ 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 handleRefreshDesigner = () => {
|
||||
if (!hasUnsavedDraftChanges) {
|
||||
void fetchData();
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '存在未保存的字段变更',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '刷新后会丢失当前尚未保存的字段调整,是否仍要刷新并覆盖当前草稿?',
|
||||
okText: '仍然刷新',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await fetchData();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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) => ({
|
||||
@@ -2551,6 +2524,7 @@ END;`;
|
||||
{isNewTable && (
|
||||
<>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="请输入表名"
|
||||
value={newTableName}
|
||||
onChange={e => setNewTableName(e.target.value)}
|
||||
@@ -2576,7 +2550,7 @@ END;`;
|
||||
</>
|
||||
)}
|
||||
{!readOnly && <Button size="small" icon={<SaveOutlined />} type="primary" onClick={generateDDL}>保存</Button>}
|
||||
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={fetchData}>刷新</Button>}
|
||||
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={handleRefreshDesigner}>刷新</Button>}
|
||||
{!isNewTable && !readOnly && supportsTableCommentOps() && (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={openTableCommentModal}>表备注</Button>
|
||||
)}
|
||||
@@ -2864,6 +2838,7 @@ END;`;
|
||||
已选择字段:{selectedColumns.length}
|
||||
</div>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="请输入目标表名"
|
||||
value={copyTableName}
|
||||
onChange={e => setCopyTableName(e.target.value)}
|
||||
@@ -2924,6 +2899,7 @@ END;`;
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={indexForm.kind === 'PRIMARY' ? '主键索引固定名称:PRIMARY' : '索引名(例如 idx_user_name)'}
|
||||
value={indexForm.name}
|
||||
onChange={(e) => setIndexForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
@@ -2993,6 +2969,7 @@ END;`;
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="外键约束名(例如 fk_order_user)"
|
||||
value={foreignKeyForm.constraintName}
|
||||
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))}
|
||||
@@ -3008,6 +2985,7 @@ END;`;
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="参考表(支持 db.table)"
|
||||
value={foreignKeyForm.refTableName}
|
||||
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))}
|
||||
@@ -3036,10 +3014,24 @@ END;`;
|
||||
okText="执行"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
<pre style={{ background: darkMode ? '#1e1e1e' : '#f5f5f5', color: darkMode ? '#d4d4d4' : 'inherit', padding: '10px', borderRadius: '4px', border: darkMode ? '1px solid #333' : '1px solid #eee', whiteSpace: 'pre-wrap' }}>
|
||||
{previewSql}
|
||||
</pre>
|
||||
<div style={{ maxHeight: '400px', overflow: 'hidden', borderRadius: 8, border: darkMode ? '1px solid #333' : '1px solid #eee' }}>
|
||||
<Editor
|
||||
height="360px"
|
||||
defaultLanguage="sql"
|
||||
language="sql"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={previewSql}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: 10, color: '#faad14' }}>请仔细检查 SQL,执行后不可撤销。</p>
|
||||
</Modal>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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, AppstoreOutlined, UnorderedListOutlined } 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 { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
|
||||
|
||||
interface TableOverviewProps {
|
||||
tab: TabData;
|
||||
@@ -52,10 +57,23 @@ const getMetadataDialect = (connType: string, driver?: string): string => {
|
||||
};
|
||||
|
||||
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
|
||||
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
|
||||
switch (dialect) {
|
||||
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``;
|
||||
return `
|
||||
SELECT
|
||||
TABLE_NAME AS table_name,
|
||||
TABLE_COMMENT AS table_comment,
|
||||
TABLE_ROWS AS table_rows,
|
||||
DATA_LENGTH AS data_length,
|
||||
INDEX_LENGTH AS index_length,
|
||||
ENGINE AS engine,
|
||||
CREATE_TIME AS create_time,
|
||||
UPDATE_TIME AS update_time
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '${escapeLiteral(dbName)}'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name`;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'vastbase':
|
||||
@@ -147,9 +165,14 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('card');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
|
||||
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
|
||||
const metadataDialect = useMemo(
|
||||
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver),
|
||||
[connection?.config?.driver, connection?.config?.type]
|
||||
);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!connection) return;
|
||||
@@ -163,11 +186,10 @@ 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 sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const res = await DBQuery(config as any, tab.dbName || '', sql);
|
||||
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setTables(parseTableStats(dialect, res.data));
|
||||
setTables(parseTableStats(metadataDialect, res.data));
|
||||
} else {
|
||||
message.error('获取表信息失败: ' + (res.message || '未知错误'));
|
||||
}
|
||||
@@ -176,9 +198,14 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connection, tab.dbName]);
|
||||
}, [connection, metadataDialect, tab.dbName]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
return;
|
||||
}
|
||||
void loadData();
|
||||
}, [autoFetchVisible, loadData]);
|
||||
|
||||
const sortedFiltered = useMemo(() => {
|
||||
let list = [...tables];
|
||||
@@ -239,7 +266,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('表结构已复制到剪贴板');
|
||||
@@ -252,7 +279,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('导出成功');
|
||||
@@ -269,7 +296,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();
|
||||
@@ -280,6 +307,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;
|
||||
@@ -288,6 +349,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
title: '重命名表',
|
||||
content: (
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
defaultValue={tableName}
|
||||
onChange={e => { newName = e.target.value; }}
|
||||
placeholder="输入新表名"
|
||||
@@ -299,7 +361,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();
|
||||
@@ -337,6 +399,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 (
|
||||
@@ -357,6 +423,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="搜索表名或注释..."
|
||||
prefix={<SearchOutlined style={{ color: textMuted }} />}
|
||||
value={searchText}
|
||||
@@ -424,7 +491,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: `SELECT * FROM ${t.name};`,
|
||||
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
@@ -432,7 +499,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') },
|
||||
@@ -483,113 +554,145 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* ========== 列表/表格视图 ========== */
|
||||
<div style={{ borderRadius: 8, border: `1px solid ${cardBorder}`, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)' }}>
|
||||
{[
|
||||
{ field: 'name' as SortField, label: '表名', width: undefined },
|
||||
{ field: null, label: '注释', width: undefined },
|
||||
{ field: 'rows' as SortField, label: '行数', width: 100 },
|
||||
{ field: 'dataSize' as SortField, label: '数据大小', width: 110 },
|
||||
{ field: null, label: '索引大小', width: 110 },
|
||||
{ field: null, label: '引擎', width: 90 },
|
||||
].map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
onClick={col.field ? () => toggleSort(col.field!) : undefined}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
textAlign: idx >= 2 ? 'right' : 'left',
|
||||
fontWeight: 600,
|
||||
color: textSecondary,
|
||||
borderBottom: `1px solid ${cardBorder}`,
|
||||
cursor: col.field ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
width: col.width,
|
||||
}}
|
||||
>
|
||||
{col.label}
|
||||
{col.field && sortField === col.field && (
|
||||
<span style={{ marginLeft: 4, fontSize: 11 }}>
|
||||
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedFiltered.map((t, rowIdx) => (
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
|
||||
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: `SELECT * FROM ${t.name};`,
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
|
||||
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
|
||||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
|
||||
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
|
||||
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
|
||||
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
|
||||
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
|
||||
]},
|
||||
],
|
||||
/* ========== 行视图 ========== */
|
||||
<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: buildTableSelectQuery(metadataDialect, 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; }}
|
||||
>
|
||||
<tr
|
||||
onDoubleClick={() => openTable(t.name)}
|
||||
<div
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s',
|
||||
borderBottom: rowIdx < sortedFiltered.length - 1 ? `1px solid ${cardBorder}` : 'none',
|
||||
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',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLTableRowElement).style.background = cardHoverBg; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLTableRowElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<td style={{ padding: '10px 14px', color: textPrimary, fontWeight: 500 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<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={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.name}</span>
|
||||
<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>
|
||||
</td>
|
||||
<td style={{ padding: '10px 14px', color: textSecondary, maxWidth: 260, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.comment ? (
|
||||
<Tooltip title={t.comment} mouseEnterDelay={0.4}><span>{t.comment}</span></Tooltip>
|
||||
) : (
|
||||
<span style={{ color: textMuted }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textMuted }}>{t.engine || '—'}</td>
|
||||
</tr>
|
||||
</Dropdown>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -124,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 {
|
||||
@@ -155,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);
|
||||
@@ -164,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 {
|
||||
@@ -201,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') {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { AIChatMessage, AIToolCall } from '../../types';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import { normalizeAiMarkdown } from '../../utils/aiMarkdown';
|
||||
|
||||
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
|
||||
const remarkPlugins = [remarkGfm];
|
||||
@@ -27,6 +28,7 @@ const MemoizedMarkdown = React.memo(({
|
||||
activeConnectionId?: string;
|
||||
activeDbName?: string;
|
||||
}) => {
|
||||
const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]);
|
||||
// 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo
|
||||
const components = React.useMemo(() => ({
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
@@ -46,7 +48,7 @@ const MemoizedMarkdown = React.memo(({
|
||||
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
||||
{content}
|
||||
{normalizedContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildCopyInsertSQL } from './dataGridCopyInsert';
|
||||
import {
|
||||
buildCopyDeleteSQL,
|
||||
buildCopyInsertSQL,
|
||||
buildCopyUpdateSQL,
|
||||
resolveUniqueKeyGroupsFromIndexes,
|
||||
} from './dataGridCopyInsert';
|
||||
|
||||
describe('buildCopyInsertSQL', () => {
|
||||
it('normalizes PostgreSQL timestamp values for copy-as-insert and uses PostgreSQL identifier quoting', () => {
|
||||
@@ -58,4 +63,100 @@ describe('buildCopyInsertSQL', () => {
|
||||
`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('全部字段');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IndexDefinition } from '../types';
|
||||
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||
|
||||
type BuildCopyInsertSQLParams = {
|
||||
@@ -8,6 +9,22 @@ type BuildCopyInsertSQLParams = {
|
||||
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;
|
||||
@@ -104,6 +121,157 @@ export const formatLocalDateTimeLiteral = (value: Date): string => {
|
||||
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,
|
||||
@@ -114,18 +282,136 @@ export const buildCopyInsertSQL = ({
|
||||
const targetTable = quoteQualifiedIdent(dbType, tableName || 'table');
|
||||
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
|
||||
const values = orderedCols.map((col) => {
|
||||
const value = record?.[col];
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
|
||||
const columnType = columnTypesByLowerName[String(col || '').toLowerCase()];
|
||||
const raw =
|
||||
typeof value === 'string'
|
||||
? normalizeTemporalLiteralText(value, columnType, true)
|
||||
: value instanceof Date
|
||||
? formatLocalDateTimeLiteral(value)
|
||||
: String(value);
|
||||
return `'${escapeLiteral(raw)}'`;
|
||||
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');
|
||||
};
|
||||
10
frontend/src/components/dataGridTemporal.test.ts
Normal file
10
frontend/src/components/dataGridTemporal.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveTemporalEditorSaveValue } from './dataGridTemporal';
|
||||
|
||||
describe('dataGridTemporal helpers', () => {
|
||||
it('prefers the picker selected date when form store has not caught up yet', () => {
|
||||
expect(resolveTemporalEditorSaveValue(undefined, dayjs('2026-04-12'), 'date')).toBe('2026-04-12');
|
||||
});
|
||||
});
|
||||
59
frontend/src/components/dataGridTemporal.ts
Normal file
59
frontend/src/components/dataGridTemporal.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
|
||||
|
||||
export const TEMPORAL_FORMATS: Record<string, string> = {
|
||||
datetime: 'YYYY-MM-DD HH:mm:ss',
|
||||
date: 'YYYY-MM-DD',
|
||||
time: 'HH:mm:ss',
|
||||
year: 'YYYY',
|
||||
};
|
||||
|
||||
export const isTemporalColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
return base === 'date' || base === 'time' || base === 'year';
|
||||
};
|
||||
|
||||
export const getTemporalPickerType = (columnType?: string): TemporalPickerType => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime';
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
if (base === 'date') return 'date';
|
||||
if (base === 'time') return 'time';
|
||||
if (base === 'year') return 'year';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => {
|
||||
if (val === null || val === undefined || val === '') return null;
|
||||
const str = String(val).trim();
|
||||
if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null;
|
||||
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||
const d = dayjs(str, fmt);
|
||||
return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null;
|
||||
};
|
||||
|
||||
export const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
|
||||
if (!val || !val.isValid()) return '';
|
||||
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||
return val.format(fmt);
|
||||
};
|
||||
|
||||
export const resolveTemporalEditorSaveValue = (
|
||||
formValue: any,
|
||||
pickerValue: dayjs.Dayjs | null | undefined,
|
||||
pickerType: TemporalPickerType,
|
||||
): string | null | any => {
|
||||
const value = pickerValue !== undefined ? pickerValue : formValue;
|
||||
if (value && dayjs.isDayjs(value)) {
|
||||
return formatFromDayjs(value as dayjs.Dayjs, pickerType);
|
||||
}
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
67
frontend/src/components/dataSyncRequest.test.ts
Normal file
67
frontend/src/components/dataSyncRequest.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDataSyncRequest, validateDataSyncSelection } from './dataSyncRequest';
|
||||
|
||||
describe('validateDataSyncSelection', () => {
|
||||
it('requires source query and single target table in query mode', () => {
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: [],
|
||||
sourceQuery: '',
|
||||
syncContent: 'data',
|
||||
})).toBe('请输入源查询 SQL');
|
||||
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: [],
|
||||
sourceQuery: 'select 1',
|
||||
syncContent: 'data',
|
||||
})).toBe('SQL 结果集同步需要选择一个目标表');
|
||||
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: ['users', 'orders'],
|
||||
sourceQuery: 'select 1',
|
||||
syncContent: 'data',
|
||||
})).toBe('SQL 结果集同步需要选择一个目标表');
|
||||
});
|
||||
|
||||
it('forces data-only in query mode', () => {
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: ['users'],
|
||||
sourceQuery: 'select 1',
|
||||
syncContent: 'both',
|
||||
})).toBe('SQL 结果集同步仅支持仅同步数据');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDataSyncRequest', () => {
|
||||
it('normalizes query mode payload for backend', () => {
|
||||
const payload = buildDataSyncRequest({
|
||||
sourceConfig: { type: 'mysql' },
|
||||
targetConfig: { type: 'mysql' },
|
||||
selectedTables: ['users'],
|
||||
sourceDatasetMode: 'query',
|
||||
sourceQuery: ' SELECT id, name FROM active_users ',
|
||||
syncContent: 'both',
|
||||
syncMode: 'insert_update',
|
||||
autoAddColumns: true,
|
||||
targetTableStrategy: 'smart',
|
||||
createIndexes: true,
|
||||
mongoCollectionName: ' ',
|
||||
jobId: 'job-1',
|
||||
tableOptions: { users: { insert: true, update: true, delete: false } },
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
tables: ['users'],
|
||||
sourceQuery: 'SELECT id, name FROM active_users',
|
||||
content: 'data',
|
||||
mode: 'insert_update',
|
||||
autoAddColumns: false,
|
||||
targetTableStrategy: 'existing_only',
|
||||
createIndexes: false,
|
||||
jobId: 'job-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
85
frontend/src/components/dataSyncRequest.ts
Normal file
85
frontend/src/components/dataSyncRequest.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export type SourceDatasetMode = 'table' | 'query';
|
||||
|
||||
type SyncContent = 'data' | 'schema' | 'both';
|
||||
type TargetTableStrategy = 'existing_only' | 'auto_create_if_missing' | 'smart';
|
||||
|
||||
type BuildDataSyncRequestParams = {
|
||||
sourceConfig: any;
|
||||
targetConfig: any;
|
||||
selectedTables: string[];
|
||||
sourceDatasetMode: SourceDatasetMode;
|
||||
sourceQuery: string;
|
||||
syncContent: SyncContent;
|
||||
syncMode: string;
|
||||
autoAddColumns: boolean;
|
||||
targetTableStrategy: TargetTableStrategy;
|
||||
createIndexes: boolean;
|
||||
mongoCollectionName: string;
|
||||
jobId?: string;
|
||||
tableOptions?: Record<string, any>;
|
||||
};
|
||||
|
||||
type ValidateDataSyncSelectionParams = {
|
||||
sourceDatasetMode: SourceDatasetMode;
|
||||
selectedTables: string[];
|
||||
sourceQuery: string;
|
||||
syncContent: SyncContent;
|
||||
};
|
||||
|
||||
export const validateDataSyncSelection = ({
|
||||
sourceDatasetMode,
|
||||
selectedTables,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
}: ValidateDataSyncSelectionParams): string | null => {
|
||||
if (sourceDatasetMode === 'query') {
|
||||
if (!String(sourceQuery || '').trim()) {
|
||||
return '请输入源查询 SQL';
|
||||
}
|
||||
if (selectedTables.length !== 1) {
|
||||
return 'SQL 结果集同步需要选择一个目标表';
|
||||
}
|
||||
if (syncContent !== 'data') {
|
||||
return 'SQL 结果集同步仅支持仅同步数据';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedTables.length === 0) {
|
||||
return '请选择至少一张表';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildDataSyncRequest = ({
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode,
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName,
|
||||
jobId,
|
||||
tableOptions,
|
||||
}: BuildDataSyncRequestParams) => {
|
||||
const isQueryMode = sourceDatasetMode === 'query';
|
||||
|
||||
return {
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
tables: selectedTables,
|
||||
sourceQuery: isQueryMode ? String(sourceQuery || '').trim() : undefined,
|
||||
content: isQueryMode ? 'data' : syncContent,
|
||||
mode: syncMode,
|
||||
autoAddColumns: isQueryMode ? false : autoAddColumns,
|
||||
targetTableStrategy: isQueryMode ? 'existing_only' : targetTableStrategy,
|
||||
createIndexes: isQueryMode ? false : createIndexes,
|
||||
mongoCollectionName: String(mongoCollectionName || '').trim(),
|
||||
...(jobId ? { jobId } : {}),
|
||||
...(tableOptions ? { tableOptions } : {}),
|
||||
};
|
||||
};
|
||||
@@ -25,4 +25,9 @@ describe('buildRedisWorkbenchTheme', () => {
|
||||
expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg);
|
||||
expect(lightTheme.backdropFilter).toBe('none');
|
||||
});
|
||||
|
||||
it('can disable redis workbench blur for macOS text-entry compatibility', () => {
|
||||
const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14, disableBackdropFilter: true });
|
||||
expect(darkTheme.backdropFilter).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { resolveTextInputSafeBackdropFilter } from '../utils/appearance';
|
||||
|
||||
type RedisWorkbenchThemeInput = {
|
||||
darkMode: boolean;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
disableBackdropFilter?: boolean;
|
||||
};
|
||||
|
||||
type RedisWorkbenchTheme = {
|
||||
@@ -43,10 +46,15 @@ export const buildRedisWorkbenchTheme = ({
|
||||
darkMode,
|
||||
opacity,
|
||||
blur,
|
||||
disableBackdropFilter,
|
||||
}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => {
|
||||
const normalizedOpacity = clamp(opacity, 0.1, 1);
|
||||
const normalizedBlur = Math.max(0, Math.round(blur));
|
||||
const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0;
|
||||
const backdropFilter = resolveTextInputSafeBackdropFilter(
|
||||
normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
disableBackdropFilter ?? false,
|
||||
);
|
||||
|
||||
if (darkMode) {
|
||||
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
|
||||
@@ -84,7 +92,7 @@ export const buildRedisWorkbenchTheme = ({
|
||||
treeSelectedBorder: 'rgba(246, 196, 83, 0.24)',
|
||||
divider: 'rgba(255, 255, 255, 0.07)',
|
||||
shadow: '0 20px 48px rgba(0, 0, 0, 0.26)',
|
||||
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
backdropFilter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,7 +130,7 @@ export const buildRedisWorkbenchTheme = ({
|
||||
treeSelectedBorder: 'rgba(22, 119, 255, 0.18)',
|
||||
divider: 'rgba(15, 23, 42, 0.08)',
|
||||
shadow: '0 22px 52px rgba(15, 23, 42, 0.08)',
|
||||
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
backdropFilter,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
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: '清空' };
|
||||
};
|
||||
79
frontend/src/components/tableDesignerSchemaSql.test.ts
Normal file
79
frontend/src/components/tableDesignerSchemaSql.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAlterTablePreviewSql,
|
||||
hasAlterTableDraftChanges,
|
||||
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('detects when alter table drafts contain unsaved column changes', () => {
|
||||
expect(hasAlterTableDraftChanges(buildInput({ dbType: 'mysql' }))).toBe(true);
|
||||
expect(
|
||||
hasAlterTableDraftChanges(
|
||||
buildInput({
|
||||
dbType: 'mysql',
|
||||
columns: [baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' })],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('uses mysql change column syntax when renaming a column', () => {
|
||||
const sql = buildAlterTablePreviewSql(buildInput({
|
||||
dbType: 'mysql',
|
||||
originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'varchar(64)', nullable: 'YES' })],
|
||||
columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'varchar(64)', nullable: 'YES' })],
|
||||
}));
|
||||
|
||||
expect(sql).toContain('CHANGE COLUMN `name` `display_name` varchar(64) NULL');
|
||||
expect(sql).toContain('FIRST');
|
||||
expect(sql).not.toContain('MODIFY COLUMN `display_name`');
|
||||
});
|
||||
});
|
||||
265
frontend/src/components/tableDesignerSchemaSql.ts
Normal file
265
frontend/src/components/tableDesignerSchemaSql.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const definitionChanged =
|
||||
curr.type !== orig.type ||
|
||||
curr.nullable !== orig.nullable ||
|
||||
curr.default !== orig.default ||
|
||||
(curr.comment || '') !== (orig.comment || '') ||
|
||||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement);
|
||||
|
||||
if (curr.name !== orig.name) {
|
||||
alters.push(
|
||||
`CHANGE COLUMN ${quoteIdentifierPart(orig.name, 'mysql')} ${colDef} ${positionSql}`.trim(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (definitionChanged) {
|
||||
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 });
|
||||
};
|
||||
|
||||
export const hasAlterTableDraftChanges = (input: BuildAlterTablePreviewInput): boolean =>
|
||||
buildAlterTablePreviewSql(input).trim().length > 0;
|
||||
99
frontend/src/main.browserMock.test.ts
Normal file
99
frontend/src/main.browserMock.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./App', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
const createRootMock = vi.fn(() => ({
|
||||
render: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-dom/client', () => ({
|
||||
default: {
|
||||
createRoot: createRootMock,
|
||||
},
|
||||
createRoot: createRootMock,
|
||||
}));
|
||||
|
||||
const dayjsLocaleMock = vi.fn();
|
||||
|
||||
vi.mock('dayjs', () => ({
|
||||
default: Object.assign(() => null, {
|
||||
locale: dayjsLocaleMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('dayjs/locale/zh-cn', () => ({}));
|
||||
|
||||
const loaderConfigMock = vi.fn();
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
loader: {
|
||||
config: loaderConfigMock,
|
||||
},
|
||||
}));
|
||||
|
||||
const defineThemeMock = vi.fn();
|
||||
|
||||
vi.mock('monaco-editor', () => ({
|
||||
editor: {
|
||||
defineTheme: defineThemeMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('monaco-editor/esm/nls.messages.zh-cn', () => ({}));
|
||||
|
||||
const importMain = async () => {
|
||||
await import('./main');
|
||||
return (globalThis as typeof globalThis & {
|
||||
window: {
|
||||
go?: {
|
||||
app?: {
|
||||
App?: {
|
||||
ImportConfigFile: () => Promise<{ success: boolean; message?: string }>;
|
||||
ImportConnectionsPayload: (raw: string, password?: string) => Promise<unknown>;
|
||||
ExportConnectionsPackage: (options?: { includeSecrets?: boolean; filePassword?: string }) => Promise<{ success: boolean; message?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}).window.go?.app?.App;
|
||||
};
|
||||
|
||||
describe('main browser mock', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('window', {});
|
||||
vi.stubGlobal('document', {
|
||||
getElementById: vi.fn(() => ({})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns explicit browser-mode messages for import picker and package export', async () => {
|
||||
const app = await importMain();
|
||||
|
||||
expect(app).toBeDefined();
|
||||
await expect(app!.ImportConfigFile()).resolves.toEqual({
|
||||
success: false,
|
||||
message: '已取消',
|
||||
});
|
||||
await expect(app!.ExportConnectionsPackage({ includeSecrets: true, filePassword: '' })).resolves.toEqual({
|
||||
success: false,
|
||||
message: '浏览器 mock 不支持恢复包导出',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-array payloads instead of treating them as successful imports', async () => {
|
||||
const app = await importMain();
|
||||
|
||||
await expect(app!.ImportConnectionsPayload('{"version":1}')).rejects.toThrow(
|
||||
'浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,17 +15,105 @@ dayjs.locale('zh-cn')
|
||||
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 () => [],
|
||||
@@ -37,16 +125,48 @@ 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 }),
|
||||
CheckForUpdatesSilently: async () => ({ success: false }),
|
||||
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
|
||||
OpenDriverDownloadDirectory: async (path: string) => ({ success: true, data: { path } }),
|
||||
OpenDataRootDirectory: async () => ({ success: true }),
|
||||
SelectSQLDirectory: async (currentPath: string) => ({ success: false, message: currentPath ? '已取消' : '已取消' }),
|
||||
ListSQLDirectory: async () => ({ success: true, data: [] }),
|
||||
ReadSQLFile: async () => ({ success: false, message: '已取消' }),
|
||||
InstallUpdateAndRestart: async () => ({ success: false }),
|
||||
ImportConfigFile: async () => ({ success: false }),
|
||||
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
|
||||
ImportConnectionsPayload: async (raw: string, _password?: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item) => saveMockConnection(item));
|
||||
}
|
||||
} catch {
|
||||
throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组');
|
||||
}
|
||||
throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组');
|
||||
},
|
||||
ExportConnectionsPackage: async (_options?: { includeSecrets?: boolean; filePassword?: string }) => ({ success: false, message: '浏览器 mock 不支持恢复包导出' }),
|
||||
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: [],
|
||||
@@ -62,3 +182,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);
|
||||
});
|
||||
});
|
||||
190
frontend/src/store.test.ts
Normal file
190
frontend/src/store.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
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');
|
||||
});
|
||||
|
||||
it('does not clear persisted legacy connections during hydration migration', async () => {
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
connections: [
|
||||
{
|
||||
id: 'legacy-1',
|
||||
name: 'Legacy',
|
||||
config: {
|
||||
id: 'legacy-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
version: 7,
|
||||
}));
|
||||
|
||||
const { useStore } = await importStore();
|
||||
|
||||
expect(useStore.getState().connections).toHaveLength(1);
|
||||
expect(useStore.getState().connections[0]?.config.password).toBe('secret');
|
||||
});
|
||||
|
||||
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
globalProxy: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
password: 'proxy-secret',
|
||||
},
|
||||
},
|
||||
version: 7,
|
||||
}));
|
||||
|
||||
const { useStore } = await importStore();
|
||||
|
||||
expect(useStore.getState().globalProxy.password).toBe('proxy-secret');
|
||||
expect(useStore.getState().globalProxy.hasPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('persists external SQL directories and restores valid items after reload', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().saveExternalSQLDirectory({
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.externalSQLDirectories).toEqual([
|
||||
{
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
externalSQLDirectories: [
|
||||
persisted.state.externalSQLDirectories[0],
|
||||
{ path: '', name: 'broken' },
|
||||
],
|
||||
},
|
||||
version: 7,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await importStore();
|
||||
expect(reloaded.useStore.getState().externalSQLDirectories).toEqual([
|
||||
{
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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, ExternalSQLDirectory } from './types';
|
||||
import {
|
||||
ShortcutAction,
|
||||
ShortcutBinding,
|
||||
@@ -9,8 +9,28 @@ import {
|
||||
cloneShortcutOptions,
|
||||
sanitizeShortcutOptions,
|
||||
} from './utils/shortcuts';
|
||||
import { buildExternalSQLDirectoryId } from './utils/externalSqlTree';
|
||||
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 +45,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 +54,7 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
port: 1080,
|
||||
user: '',
|
||||
password: '',
|
||||
hasPassword: false,
|
||||
};
|
||||
const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
'mysql',
|
||||
@@ -246,6 +267,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 +343,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 +424,6 @@ export interface QueryOptions {
|
||||
showColumnType: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalProxyConfig extends ProxyConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
connections: SavedConnection[];
|
||||
connectionTags: ConnectionTag[];
|
||||
@@ -404,8 +431,9 @@ interface AppState {
|
||||
activeTabId: string | null;
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
externalSQLDirectories: ExternalSQLDirectory[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean };
|
||||
appearance: AppearanceSettings;
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
startupFullscreen: boolean;
|
||||
@@ -440,6 +468,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;
|
||||
@@ -461,13 +490,16 @@ interface AppState {
|
||||
|
||||
saveQuery: (query: SavedQuery) => void;
|
||||
deleteQuery: (id: string) => void;
|
||||
saveExternalSQLDirectory: (directory: ExternalSQLDirectory) => void;
|
||||
deleteExternalSQLDirectory: (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;
|
||||
@@ -525,6 +557,57 @@ const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeExternalSQLDirectories = (value: unknown): ExternalSQLDirectory[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const result: ExternalSQLDirectory[] = [];
|
||||
value.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') return;
|
||||
const raw = entry as Record<string, unknown>;
|
||||
const path = toTrimmedString(raw.path);
|
||||
const connectionId = toTrimmedString(raw.connectionId);
|
||||
const dbName = toTrimmedString(raw.dbName);
|
||||
if (!path || !connectionId || !dbName) return;
|
||||
const fallbackName = path.split(/[\\/]/).filter(Boolean).pop() || `SQL目录-${index + 1}`;
|
||||
result.push({
|
||||
id: toTrimmedString(raw.id, buildExternalSQLDirectoryId(connectionId, dbName, path)) || buildExternalSQLDirectoryId(connectionId, dbName, path),
|
||||
name: toTrimmedString(raw.name, fallbackName) || fallbackName,
|
||||
path,
|
||||
connectionId,
|
||||
dbName,
|
||||
createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(),
|
||||
});
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const hasLegacyConnectionSecrets = (connections: SavedConnection[]): boolean => {
|
||||
return connections.some((connection) => {
|
||||
const config = connection?.config && typeof connection.config === 'object'
|
||||
? connection.config as unknown as Record<string, unknown>
|
||||
: {};
|
||||
const ssh = config.ssh && typeof config.ssh === 'object'
|
||||
? config.ssh as Record<string, unknown>
|
||||
: {};
|
||||
const proxy = config.proxy && typeof config.proxy === 'object'
|
||||
? config.proxy as Record<string, unknown>
|
||||
: {};
|
||||
const httpTunnel = config.httpTunnel && typeof config.httpTunnel === 'object'
|
||||
? config.httpTunnel as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
return (
|
||||
toTrimmedString(config.password) !== ''
|
||||
|| toTrimmedString(ssh.password) !== ''
|
||||
|| toTrimmedString(proxy.password) !== ''
|
||||
|| toTrimmedString(httpTunnel.password) !== ''
|
||||
|| toTrimmedString(config.mysqlReplicaPassword) !== ''
|
||||
|| toTrimmedString(config.mongoReplicaPassword) !== ''
|
||||
|| toTrimmedString(config.uri) !== ''
|
||||
|| toTrimmedString(config.dsn) !== ''
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const sanitizeTheme = (value: unknown): 'light' | 'dark' => (value === 'dark' ? 'dark' : 'light');
|
||||
|
||||
const sanitizeSqlFormatOptions = (value: unknown): { keywordCase: 'upper' | 'lower' } => {
|
||||
@@ -586,12 +669,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 +683,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 +704,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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -744,6 +836,7 @@ export const useStore = create<AppState>()(
|
||||
activeTabId: null,
|
||||
activeContext: null,
|
||||
savedQueries: [],
|
||||
externalSQLDirectories: [],
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
uiScale: DEFAULT_UI_SCALE,
|
||||
@@ -782,6 +875,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) => ({
|
||||
@@ -957,12 +1051,50 @@ export const useStore = create<AppState>()(
|
||||
|
||||
deleteQuery: (id) => set((state) => ({ savedQueries: state.savedQueries.filter(q => q.id !== id) })),
|
||||
|
||||
saveExternalSQLDirectory: (directory) => set((state) => {
|
||||
const path = toTrimmedString(directory.path);
|
||||
const connectionId = toTrimmedString(directory.connectionId);
|
||||
const dbName = toTrimmedString(directory.dbName);
|
||||
if (!path || !connectionId || !dbName) {
|
||||
return state;
|
||||
}
|
||||
const nextDirectory: ExternalSQLDirectory = {
|
||||
id: toTrimmedString(directory.id, buildExternalSQLDirectoryId(connectionId, dbName, path)) || buildExternalSQLDirectoryId(connectionId, dbName, path),
|
||||
name: toTrimmedString(directory.name, path.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录') || 'SQL目录',
|
||||
path,
|
||||
connectionId,
|
||||
dbName,
|
||||
createdAt: Number.isFinite(Number(directory.createdAt)) ? Number(directory.createdAt) : Date.now(),
|
||||
};
|
||||
const existingIndex = state.externalSQLDirectories.findIndex((item) =>
|
||||
item.id === nextDirectory.id
|
||||
|| (
|
||||
item.connectionId === nextDirectory.connectionId
|
||||
&& item.dbName === nextDirectory.dbName
|
||||
&& item.path === nextDirectory.path
|
||||
),
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
return { externalSQLDirectories: [...state.externalSQLDirectories, nextDirectory] };
|
||||
}
|
||||
return {
|
||||
externalSQLDirectories: state.externalSQLDirectories.map((item, index) =>
|
||||
index === existingIndex ? nextDirectory : item,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
deleteExternalSQLDirectory: (id) => set((state) => ({
|
||||
externalSQLDirectories: state.externalSQLDirectories.filter((item) => item.id !== id),
|
||||
})),
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
||||
setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }),
|
||||
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) => ({
|
||||
@@ -1210,6 +1342,7 @@ export const useStore = create<AppState>()(
|
||||
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
|
||||
}
|
||||
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
|
||||
nextState.externalSQLDirectories = sanitizeExternalSQLDirectories(state.externalSQLDirectories);
|
||||
nextState.theme = sanitizeTheme(state.theme);
|
||||
nextState.appearance = sanitizeAppearance(state.appearance, version);
|
||||
nextState.uiScale = sanitizeUiScale(state.uiScale);
|
||||
@@ -1245,6 +1378,7 @@ export const useStore = create<AppState>()(
|
||||
connections: sanitizeConnections(state.connections),
|
||||
connectionTags: sanitizeConnectionTags(state.connectionTags),
|
||||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||||
externalSQLDirectories: sanitizeExternalSQLDirectories(state.externalSQLDirectories),
|
||||
theme: sanitizeTheme(state.theme),
|
||||
appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION),
|
||||
uiScale: sanitizeUiScale(state.uiScale),
|
||||
@@ -1270,31 +1404,40 @@ export const useStore = create<AppState>()(
|
||||
aiChatSessions: [],
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
connections: state.connections,
|
||||
connectionTags: state.connectionTags,
|
||||
savedQueries: state.savedQueries,
|
||||
theme: state.theme,
|
||||
appearance: state.appearance,
|
||||
uiScale: state.uiScale,
|
||||
fontSize: state.fontSize,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
globalProxy: state.globalProxy,
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
queryOptions: state.queryOptions,
|
||||
shortcutOptions: state.shortcutOptions,
|
||||
tableAccessCount: state.tableAccessCount,
|
||||
tableSortPreference: state.tableSortPreference,
|
||||
tableColumnOrders: state.tableColumnOrders,
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory,
|
||||
tableHiddenColumns: state.tableHiddenColumns,
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
|
||||
windowBounds: state.windowBounds,
|
||||
windowState: state.windowState,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
partialize: (state) => {
|
||||
const partialState: Partial<AppState> = {
|
||||
connectionTags: state.connectionTags,
|
||||
savedQueries: state.savedQueries,
|
||||
externalSQLDirectories: state.externalSQLDirectories,
|
||||
theme: state.theme,
|
||||
appearance: state.appearance,
|
||||
uiScale: state.uiScale,
|
||||
fontSize: state.fontSize,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
globalProxy: toTrimmedString(state.globalProxy.password) !== ''
|
||||
? { ...state.globalProxy }
|
||||
: toPersistedGlobalProxy(state.globalProxy),
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
queryOptions: state.queryOptions,
|
||||
shortcutOptions: state.shortcutOptions,
|
||||
tableAccessCount: state.tableAccessCount,
|
||||
tableSortPreference: state.tableSortPreference,
|
||||
tableColumnOrders: state.tableColumnOrders,
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory,
|
||||
tableHiddenColumns: state.tableHiddenColumns,
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
|
||||
windowBounds: state.windowBounds,
|
||||
windowState: state.windowState,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
};
|
||||
|
||||
if (hasLegacyConnectionSecrets(state.connections)) {
|
||||
partialState.connections = state.connections;
|
||||
}
|
||||
|
||||
// AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage
|
||||
}), // Don't persist logs
|
||||
return partialState as AppState;
|
||||
}, // Don't persist logs
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -150,6 +166,22 @@ export interface SavedQuery {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ExternalSQLDirectory {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ExternalSQLTreeEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
children?: ExternalSQLTreeEntry[];
|
||||
}
|
||||
|
||||
// Redis types
|
||||
export interface RedisKeyInfo {
|
||||
key: string;
|
||||
@@ -201,6 +233,8 @@ export interface AIProviderConfig {
|
||||
type: AIProviderType;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
secretRef?: string;
|
||||
hasSecret?: boolean;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
models?: string[];
|
||||
@@ -243,3 +277,71 @@ export interface AISafetyResult {
|
||||
requiresConfirm: boolean;
|
||||
warningMessage?: string;
|
||||
}
|
||||
|
||||
export type SecurityUpdateOverallStatus =
|
||||
| 'not_detected'
|
||||
| 'pending'
|
||||
| 'postponed'
|
||||
| 'in_progress'
|
||||
| 'needs_attention'
|
||||
| 'completed'
|
||||
| 'rolled_back';
|
||||
|
||||
export type SecurityUpdateIssueScope = 'connection' | 'global_proxy' | 'ai_provider' | 'system';
|
||||
export type SecurityUpdateIssueSeverity = 'high' | 'medium' | 'low';
|
||||
export type SecurityUpdateItemStatus = 'pending' | 'updated' | 'needs_attention' | 'skipped' | 'failed';
|
||||
export type SecurityUpdateIssueReasonCode =
|
||||
| 'migration_required'
|
||||
| 'secret_missing'
|
||||
| 'field_invalid'
|
||||
| 'write_conflict'
|
||||
| 'validation_failed'
|
||||
| 'environment_blocked';
|
||||
export type SecurityUpdateIssueAction =
|
||||
| 'open_connection'
|
||||
| 'open_proxy_settings'
|
||||
| 'open_ai_settings'
|
||||
| 'retry_update'
|
||||
| 'view_details';
|
||||
|
||||
export interface SecurityUpdateSummary {
|
||||
total: number;
|
||||
updated: number;
|
||||
pending: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface SecurityUpdateIssue {
|
||||
id: string;
|
||||
scope?: SecurityUpdateIssueScope;
|
||||
refId?: string;
|
||||
title?: string;
|
||||
severity?: SecurityUpdateIssueSeverity;
|
||||
status?: SecurityUpdateItemStatus;
|
||||
reasonCode?: SecurityUpdateIssueReasonCode;
|
||||
action?: SecurityUpdateIssueAction;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SecurityUpdateStatus {
|
||||
schemaVersion?: number;
|
||||
migrationId?: string;
|
||||
overallStatus: SecurityUpdateOverallStatus;
|
||||
sourceType?: 'current_app_saved_config';
|
||||
reminderVisible?: boolean;
|
||||
canStart?: boolean;
|
||||
canPostpone?: boolean;
|
||||
canRetry?: boolean;
|
||||
backupAvailable?: boolean;
|
||||
backupPath?: string;
|
||||
startedAt?: string;
|
||||
updatedAt?: string;
|
||||
completedAt?: string;
|
||||
postponedAt?: string;
|
||||
summary: SecurityUpdateSummary;
|
||||
issues: SecurityUpdateIssue[];
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} 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('keeps the sidebar utility group compact and free of the AI entry', () => {
|
||||
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'settings']);
|
||||
});
|
||||
|
||||
it('anchors the AI entry to the content edge', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const;
|
||||
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'settings'] as const;
|
||||
|
||||
export type AIEntryPlacement = 'content-edge';
|
||||
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';
|
||||
|
||||
11
frontend/src/utils/aiMarkdown.test.ts
Normal file
11
frontend/src/utils/aiMarkdown.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeAiMarkdown } from './aiMarkdown';
|
||||
|
||||
describe('normalizeAiMarkdown', () => {
|
||||
it('inserts a missing newline after the fenced code language marker', () => {
|
||||
expect(normalizeAiMarkdown('```sqlSELECT COUNT(*) AS order_count\nFROM customer_order;\n```')).toBe(
|
||||
'```sql\nSELECT COUNT(*) AS order_count\nFROM customer_order;\n```',
|
||||
);
|
||||
});
|
||||
});
|
||||
13
frontend/src/utils/aiMarkdown.ts
Normal file
13
frontend/src/utils/aiMarkdown.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const normalizeAiMarkdown = (content: string): string => {
|
||||
let text = String(content || '').replace(/\r\n/g, '\n');
|
||||
const knownFenceLanguages = [
|
||||
'sql', 'mermaid', 'json', 'javascript', 'typescript', 'ts', 'js', 'tsx', 'jsx',
|
||||
'bash', 'sh', 'shell', 'python', 'py', 'go', 'java', 'yaml', 'yml', 'html', 'css',
|
||||
'xml', 'markdown', 'md', 'text', 'plaintext', 'vue', 'php', 'ruby', 'rust', 'toml',
|
||||
'ini', 'diff',
|
||||
];
|
||||
const fencePattern = new RegExp(`(^|\\n)\`\`\`(${knownFenceLanguages.join('|')})([^\\n])`, 'gi');
|
||||
text = text.replace(fencePattern, '$1```$2\n$3');
|
||||
text = text.replace(/([^\n])```(?=\n|$)/g, '$1\n```');
|
||||
return text;
|
||||
};
|
||||
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',
|
||||
});
|
||||
|
||||
21
frontend/src/utils/appVersionDisplay.test.ts
Normal file
21
frontend/src/utils/appVersionDisplay.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveAboutDisplayVersion } from './appVersionDisplay';
|
||||
|
||||
describe('resolveAboutDisplayVersion', () => {
|
||||
it('shows fixed dev version for development build', () => {
|
||||
expect(resolveAboutDisplayVersion('development', '0.6.5')).toBe('0.0.1-dev');
|
||||
});
|
||||
|
||||
it('shows fixed dev version for wails dev build type', () => {
|
||||
expect(resolveAboutDisplayVersion('dev', '0.6.5')).toBe('0.0.1-dev');
|
||||
});
|
||||
|
||||
it('keeps real version for non-development builds', () => {
|
||||
expect(resolveAboutDisplayVersion('production', '0.6.5')).toBe('0.6.5');
|
||||
});
|
||||
|
||||
it('falls back to unknown when version is empty outside development', () => {
|
||||
expect(resolveAboutDisplayVersion('production', '')).toBe('未知');
|
||||
});
|
||||
});
|
||||
14
frontend/src/utils/appVersionDisplay.ts
Normal file
14
frontend/src/utils/appVersionDisplay.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const DEV_ABOUT_VERSION = '0.0.1-dev';
|
||||
|
||||
export const resolveAboutDisplayVersion = (
|
||||
buildType: string,
|
||||
version: string | undefined,
|
||||
): string => {
|
||||
const normalizedBuildType = String(buildType || '').trim().toLowerCase();
|
||||
if (normalizedBuildType === 'development' || normalizedBuildType === 'dev') {
|
||||
return DEV_ABOUT_VERSION;
|
||||
}
|
||||
|
||||
const normalizedVersion = String(version || '').trim();
|
||||
return normalizedVersion || '未知';
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
|
||||
import {
|
||||
blurToFilter,
|
||||
normalizeBlurForPlatform,
|
||||
normalizeOpacityForPlatform,
|
||||
resolveAppearanceValues,
|
||||
resolveTextInputSafeBackdropFilter,
|
||||
} from './appearance';
|
||||
|
||||
describe('appearance helpers', () => {
|
||||
it('falls back to opaque non-blurred appearance when disabled', () => {
|
||||
@@ -20,4 +26,10 @@ describe('appearance helpers', () => {
|
||||
expect(blurToFilter(0)).toBeUndefined();
|
||||
expect(blurToFilter(8)).toBe('blur(8px)');
|
||||
});
|
||||
|
||||
it('disables local backdrop blur for text-entry surfaces on macOS', () => {
|
||||
expect(resolveTextInputSafeBackdropFilter('blur(18px)', true)).toBe('none');
|
||||
expect(resolveTextInputSafeBackdropFilter('blur(18px)', false)).toBe('blur(18px)');
|
||||
expect(resolveTextInputSafeBackdropFilter(undefined, true)).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,3 +80,16 @@ export const normalizeBlurForPlatform = (blur: number | undefined): number => {
|
||||
export const blurToFilter = (blur: number): string | undefined => {
|
||||
return blur > 0 ? `blur(${blur}px)` : undefined;
|
||||
};
|
||||
|
||||
// macOS WebView 下,文本输入区域祖先节点的 backdrop-filter 会和输入法候选/切换浮层叠加,
|
||||
// 造成额外的透明框。这里允许交互面板按平台降级为非模糊背景。
|
||||
export const resolveTextInputSafeBackdropFilter = (
|
||||
backdropFilter: string | undefined,
|
||||
disableForMacLike: boolean = isMacLikePlatform(),
|
||||
): string => {
|
||||
const normalized = String(backdropFilter || '').trim();
|
||||
if (!normalized || normalized === 'none') {
|
||||
return 'none';
|
||||
}
|
||||
return disableForMacLike ? 'none' : normalized;
|
||||
};
|
||||
|
||||
22
frontend/src/utils/autoFetchVisibility.test.ts
Normal file
22
frontend/src/utils/autoFetchVisibility.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isAutoFetchVisible } from './autoFetchVisibility';
|
||||
|
||||
describe('isAutoFetchVisible', () => {
|
||||
it('allows auto fetch only when the document is visible and not hidden', () => {
|
||||
expect(isAutoFetchVisible({ hidden: false, visibilityState: 'visible' })).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks auto fetch when the page is hidden even if visibilityState looks visible', () => {
|
||||
expect(isAutoFetchVisible({ hidden: true, visibilityState: 'visible' })).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks auto fetch when visibilityState is not visible', () => {
|
||||
expect(isAutoFetchVisible({ hidden: false, visibilityState: 'hidden' })).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to allowing auto fetch when document visibility APIs are unavailable', () => {
|
||||
expect(isAutoFetchVisible(undefined)).toBe(true);
|
||||
expect(isAutoFetchVisible({})).toBe(true);
|
||||
});
|
||||
});
|
||||
54
frontend/src/utils/autoFetchVisibility.ts
Normal file
54
frontend/src/utils/autoFetchVisibility.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type AutoFetchVisibilitySource = Partial<Pick<Document, 'hidden' | 'visibilityState'>> | undefined;
|
||||
|
||||
export const isAutoFetchVisible = (source?: AutoFetchVisibilitySource): boolean => {
|
||||
if (!source) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (source.hidden === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (source.visibilityState && source.visibilityState !== 'visible') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getDocumentAutoFetchVisibility = (): boolean => {
|
||||
if (typeof document === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isAutoFetchVisible(document);
|
||||
};
|
||||
|
||||
export const useAutoFetchVisibility = (): boolean => {
|
||||
const [isVisible, setIsVisible] = useState<boolean>(() => getDocumentAutoFetchVisibility());
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const syncVisibility = () => {
|
||||
setIsVisible(getDocumentAutoFetchVisibility());
|
||||
};
|
||||
|
||||
syncVisibility();
|
||||
document.addEventListener('visibilitychange', syncVisibility);
|
||||
window.addEventListener('focus', syncVisibility);
|
||||
window.addEventListener('pageshow', syncVisibility);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', syncVisibility);
|
||||
window.removeEventListener('focus', syncVisibility);
|
||||
window.removeEventListener('pageshow', syncVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isVisible;
|
||||
};
|
||||
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;
|
||||
};
|
||||
186
frontend/src/utils/connectionExport.test.ts
Normal file
186
frontend/src/utils/connectionExport.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
detectConnectionImportKind,
|
||||
isConnectionPackagePasswordRequiredError,
|
||||
isConnectionPackageExportCanceled,
|
||||
resolveConnectionPackageExportResult,
|
||||
normalizeConnectionPackagePassword,
|
||||
} from './connectionExport';
|
||||
|
||||
describe('connectionExport', () => {
|
||||
it('detects v2 app-managed packages', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
p: 1,
|
||||
exportedAt: '2026-04-11T21:00:00Z',
|
||||
connections: [],
|
||||
}))).toBe('app-managed-package');
|
||||
});
|
||||
|
||||
it('detects v2 encrypted packages', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
p: 2,
|
||||
kdf: {
|
||||
n: 'a2id',
|
||||
m: 65536,
|
||||
t: 3,
|
||||
l: 4,
|
||||
s: 'c2FsdA==',
|
||||
},
|
||||
nc: 'bm9uY2Utbm9uY2U=',
|
||||
d: 'encrypted-data',
|
||||
}))).toBe('encrypted-package');
|
||||
});
|
||||
|
||||
it('rejects malformed v2 app-managed packages without connections array', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
p: 1,
|
||||
exportedAt: '2026-04-11T21:00:00Z',
|
||||
}))).toBe('invalid');
|
||||
});
|
||||
|
||||
it('rejects malformed v2 encrypted packages without protected payload fields', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
p: 2,
|
||||
kdf: {
|
||||
n: 'a2id',
|
||||
m: 65536,
|
||||
t: 3,
|
||||
l: 4,
|
||||
},
|
||||
}))).toBe('invalid');
|
||||
});
|
||||
|
||||
it('detects v1 encrypted packages by gonavi envelope kind', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'gonavi_connection_package',
|
||||
cipher: 'AES-256-GCM',
|
||||
kdf: {
|
||||
name: 'Argon2id',
|
||||
memoryKiB: 65536,
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
salt: 'c2FsdA==',
|
||||
},
|
||||
nonce: 'bm9uY2Utbm9uY2U=',
|
||||
payload: 'encrypted-data',
|
||||
}))).toBe('encrypted-package');
|
||||
});
|
||||
|
||||
it('detects legacy imports from historical json arrays', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify([
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'Primary',
|
||||
config: {
|
||||
type: 'postgres',
|
||||
},
|
||||
},
|
||||
]))).toBe('legacy-json');
|
||||
});
|
||||
|
||||
it('returns invalid for malformed or unsupported content', () => {
|
||||
expect(detectConnectionImportKind('{not-json}')).toBe('invalid');
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
p: 0,
|
||||
}))).toBe('invalid');
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
}))).toBe('invalid');
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
kind: 'gonavi_connection_package',
|
||||
payload: 'encrypted-data',
|
||||
}))).toBe('invalid');
|
||||
expect(detectConnectionImportKind(JSON.stringify([
|
||||
{
|
||||
foo: 'bar',
|
||||
},
|
||||
]))).toBe('invalid');
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
kind: 'other_package',
|
||||
payload: 'encrypted-data',
|
||||
}))).toBe('invalid');
|
||||
expect(detectConnectionImportKind('null')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('trims package passwords before use', () => {
|
||||
expect(normalizeConnectionPackagePassword(' secret-pass ')).toBe('secret-pass');
|
||||
expect(normalizeConnectionPackagePassword('\n\t \t')).toBe('');
|
||||
});
|
||||
|
||||
it('recognizes backend password-required errors for protected packages', () => {
|
||||
expect(isConnectionPackagePasswordRequiredError(new Error('恢复包密码不能为空'))).toBe(true);
|
||||
expect(isConnectionPackagePasswordRequiredError({ message: '恢复包密码不能为空' })).toBe(true);
|
||||
expect(isConnectionPackagePasswordRequiredError('恢复包密码不能为空')).toBe(true);
|
||||
expect(isConnectionPackagePasswordRequiredError(new Error('文件密码错误或文件已损坏'))).toBe(false);
|
||||
expect(isConnectionPackagePasswordRequiredError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats export cancel as a non-error backend result', () => {
|
||||
expect(isConnectionPackageExportCanceled({ success: false, message: '已取消' })).toBe(true);
|
||||
expect(isConnectionPackageExportCanceled({ success: false, message: '导出失败' })).toBe(false);
|
||||
expect(isConnectionPackageExportCanceled({ success: true, message: '已取消' })).toBe(false);
|
||||
expect(isConnectionPackageExportCanceled(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('maps export results to dialog state transitions', () => {
|
||||
const staleDialog = {
|
||||
open: true,
|
||||
mode: 'export' as const,
|
||||
includeSecrets: true,
|
||||
useFilePassword: false,
|
||||
password: ' secret-pass ',
|
||||
error: '上一次失败',
|
||||
confirmLoading: false,
|
||||
};
|
||||
|
||||
const canceledResult = resolveConnectionPackageExportResult(staleDialog, { success: false, message: '已取消' });
|
||||
expect(canceledResult.kind).toBe('canceled');
|
||||
if (canceledResult.kind === 'canceled') {
|
||||
expect(typeof canceledResult.nextDialog).toBe('function');
|
||||
expect((canceledResult.nextDialog as (current: typeof staleDialog) => typeof staleDialog)({
|
||||
open: false,
|
||||
mode: 'export',
|
||||
includeSecrets: true,
|
||||
useFilePassword: false,
|
||||
password: 'secret-pass',
|
||||
error: '更新后的错误',
|
||||
confirmLoading: true,
|
||||
})).toEqual({
|
||||
open: false,
|
||||
mode: 'export',
|
||||
includeSecrets: true,
|
||||
useFilePassword: false,
|
||||
password: 'secret-pass',
|
||||
error: '',
|
||||
confirmLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
expect(resolveConnectionPackageExportResult(staleDialog, { success: true, message: '导出完成' })).toEqual({
|
||||
kind: 'succeeded',
|
||||
});
|
||||
|
||||
expect(resolveConnectionPackageExportResult(staleDialog, { success: false, message: '磁盘已满' })).toEqual({
|
||||
kind: 'failed',
|
||||
error: '磁盘已满',
|
||||
});
|
||||
|
||||
expect(resolveConnectionPackageExportResult(staleDialog, undefined)).toEqual({
|
||||
kind: 'failed',
|
||||
error: '导出失败',
|
||||
});
|
||||
});
|
||||
});
|
||||
197
frontend/src/utils/connectionExport.ts
Normal file
197
frontend/src/utils/connectionExport.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { ConnectionConfig, SavedConnection } from '../types';
|
||||
|
||||
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'invalid';
|
||||
export type ConnectionPackageDialogSnapshot = {
|
||||
open: boolean;
|
||||
mode: 'export' | 'import';
|
||||
includeSecrets: boolean;
|
||||
useFilePassword: boolean;
|
||||
password: string;
|
||||
error: string;
|
||||
confirmLoading: boolean;
|
||||
};
|
||||
export type ConnectionPackageDialogUpdater = (
|
||||
current: ConnectionPackageDialogSnapshot,
|
||||
) => ConnectionPackageDialogSnapshot;
|
||||
|
||||
export type ConnectionPackageExportResult =
|
||||
| { kind: 'canceled'; nextDialog: ConnectionPackageDialogUpdater }
|
||||
| { kind: 'succeeded' }
|
||||
| { kind: 'failed'; error: string };
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
const CONNECTION_PACKAGE_KIND = 'gonavi_connection_package';
|
||||
const CONNECTION_PACKAGE_SCHEMA_VERSION_V2 = 2;
|
||||
const CONNECTION_PACKAGE_PROTECTION_APP_MANAGED = 1;
|
||||
const CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD = 2;
|
||||
const CANCELED_MESSAGE = '已取消';
|
||||
const CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE = '恢复包密码不能为空';
|
||||
|
||||
const isJsonObject = (value: unknown): value is JsonObject => (
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
);
|
||||
|
||||
const isConnectionPackageKDF = (value: unknown): value is JsonObject => (
|
||||
isJsonObject(value)
|
||||
&& typeof value.name === 'string'
|
||||
&& typeof value.memoryKiB === 'number'
|
||||
&& typeof value.timeCost === 'number'
|
||||
&& typeof value.parallelism === 'number'
|
||||
&& typeof value.salt === 'string'
|
||||
);
|
||||
|
||||
const isConnectionPackageEnvelope = (value: unknown): value is JsonObject => (
|
||||
isJsonObject(value)
|
||||
&& typeof value.schemaVersion === 'number'
|
||||
&& value.kind === CONNECTION_PACKAGE_KIND
|
||||
&& typeof value.cipher === 'string'
|
||||
&& isConnectionPackageKDF(value.kdf)
|
||||
&& typeof value.nonce === 'string'
|
||||
&& typeof value.payload === 'string'
|
||||
);
|
||||
|
||||
const isConnectionPackageV2Envelope = (value: unknown): value is JsonObject => (
|
||||
isJsonObject(value)
|
||||
&& value.kind === CONNECTION_PACKAGE_KIND
|
||||
&& value.v === CONNECTION_PACKAGE_SCHEMA_VERSION_V2
|
||||
&& typeof value.p === 'number'
|
||||
);
|
||||
|
||||
const isConnectionPackageKDFV2 = (value: unknown): value is JsonObject => (
|
||||
isJsonObject(value)
|
||||
&& typeof value.n === 'string'
|
||||
&& typeof value.m === 'number'
|
||||
&& typeof value.t === 'number'
|
||||
&& typeof value.l === 'number'
|
||||
&& typeof value.s === 'string'
|
||||
);
|
||||
|
||||
const isConnectionPackageV2AppManagedEnvelope = (value: unknown): value is JsonObject => (
|
||||
isConnectionPackageV2Envelope(value)
|
||||
&& value.p === CONNECTION_PACKAGE_PROTECTION_APP_MANAGED
|
||||
&& Array.isArray(value.connections)
|
||||
);
|
||||
|
||||
const isConnectionPackageV2ProtectedEnvelope = (value: unknown): value is JsonObject => (
|
||||
isConnectionPackageV2Envelope(value)
|
||||
&& value.p === CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD
|
||||
&& isConnectionPackageKDFV2(value.kdf)
|
||||
&& typeof value.nc === 'string'
|
||||
&& typeof value.d === 'string'
|
||||
);
|
||||
|
||||
const isLegacyConnectionConfig = (value: unknown): value is JsonObject => (
|
||||
isJsonObject(value)
|
||||
&& typeof value.type === 'string'
|
||||
);
|
||||
|
||||
const isLegacyConnectionItem = (value: unknown): value is JsonObject => (
|
||||
isJsonObject(value)
|
||||
&& typeof value.id === 'string'
|
||||
&& typeof value.name === 'string'
|
||||
&& isLegacyConnectionConfig(value.config)
|
||||
);
|
||||
|
||||
const parseConnectionImportRaw = (raw: unknown): unknown => {
|
||||
if (typeof raw !== 'string') {
|
||||
return raw;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const isMySQLWorkbenchXML = (raw: string): boolean => (
|
||||
raw.includes('<data') && raw.includes('grt_format') && raw.includes('db.mgmt.Connection')
|
||||
);
|
||||
|
||||
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
|
||||
if (typeof raw === 'string' && isMySQLWorkbenchXML(raw)) {
|
||||
return 'mysql-workbench-xml';
|
||||
}
|
||||
|
||||
const parsed = parseConnectionImportRaw(raw);
|
||||
|
||||
if (isConnectionPackageV2AppManagedEnvelope(parsed)) {
|
||||
return 'app-managed-package';
|
||||
}
|
||||
|
||||
if (isConnectionPackageV2ProtectedEnvelope(parsed)) {
|
||||
return 'encrypted-package';
|
||||
}
|
||||
|
||||
if (isConnectionPackageV2Envelope(parsed)) {
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed) && parsed.every((item) => isLegacyConnectionItem(item))) {
|
||||
return 'legacy-json';
|
||||
}
|
||||
|
||||
if (isConnectionPackageEnvelope(parsed)) {
|
||||
return 'encrypted-package';
|
||||
}
|
||||
|
||||
return 'invalid';
|
||||
};
|
||||
|
||||
export const normalizeConnectionPackagePassword = (value: string): string => value.trim();
|
||||
|
||||
export const isConnectionPackagePasswordRequiredError = (value: unknown): boolean => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return value.message.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
|
||||
}
|
||||
|
||||
return isJsonObject(value)
|
||||
&& typeof value.message === 'string'
|
||||
&& value.message.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
|
||||
};
|
||||
|
||||
export const isConnectionPackageExportCanceled = (result: unknown): boolean => (
|
||||
isJsonObject(result)
|
||||
&& result.success === false
|
||||
&& result.message === CANCELED_MESSAGE
|
||||
);
|
||||
|
||||
export const resolveConnectionPackageExportResult = (
|
||||
_currentDialog: ConnectionPackageDialogSnapshot,
|
||||
result: unknown,
|
||||
): ConnectionPackageExportResult => {
|
||||
if (isConnectionPackageExportCanceled(result)) {
|
||||
return {
|
||||
kind: 'canceled',
|
||||
nextDialog: (current) => ({
|
||||
...current,
|
||||
confirmLoading: false,
|
||||
error: '',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (isJsonObject(result) && result.success === true) {
|
||||
return { kind: 'succeeded' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'failed',
|
||||
error: isJsonObject(result) && typeof result.message === 'string' && result.message.trim()
|
||||
? result.message
|
||||
: '导出失败',
|
||||
};
|
||||
};
|
||||
|
||||
const legacyExportRemovedError = (): never => {
|
||||
throw new Error('Legacy connection JSON export has been removed. Use the recovery package flow instead.');
|
||||
};
|
||||
|
||||
export const sanitizeConnectionConfigForExport = (_config: ConnectionConfig): never => legacyExportRemovedError();
|
||||
|
||||
export const buildExportableConnections = (_connections: SavedConnection[]): never => legacyExportRemovedError();
|
||||
57
frontend/src/utils/connectionModalPresentation.test.ts
Normal file
57
frontend/src/utils/connectionModalPresentation.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getStoredSecretPlaceholder,
|
||||
normalizeConnectionSecretErrorMessage,
|
||||
resolveConnectionTestFailureFeedback,
|
||||
} from './connectionModalPresentation';
|
||||
|
||||
describe('connectionModalPresentation', () => {
|
||||
it('shows an explicit stored-secret placeholder instead of an empty-looking password field', () => {
|
||||
expect(getStoredSecretPlaceholder({
|
||||
hasStoredSecret: true,
|
||||
emptyPlaceholder: '密码',
|
||||
retainedLabel: '已保存密码',
|
||||
})).toBe('••••••(留空表示继续沿用已保存密码)');
|
||||
});
|
||||
|
||||
it('keeps the original placeholder when no stored secret exists', () => {
|
||||
expect(getStoredSecretPlaceholder({
|
||||
hasStoredSecret: false,
|
||||
emptyPlaceholder: '密码',
|
||||
retainedLabel: '已保存密码',
|
||||
})).toBe('密码');
|
||||
});
|
||||
|
||||
it('maps missing saved-connection errors to a secret-specific hint', () => {
|
||||
expect(normalizeConnectionSecretErrorMessage('saved connection not found: conn-1')).toBe(
|
||||
'未找到当前连接对应的已保存密文,请重新填写密码并保存后再试',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing user-facing messages', () => {
|
||||
expect(normalizeConnectionSecretErrorMessage('连接测试超时')).toBe('连接测试超时');
|
||||
});
|
||||
|
||||
it('shows a toast-worthy failure message for saved-secret lookup errors during connection tests', () => {
|
||||
expect(resolveConnectionTestFailureFeedback({
|
||||
kind: 'runtime',
|
||||
reason: 'saved connection not found: conn-1',
|
||||
fallback: '连接失败',
|
||||
})).toEqual({
|
||||
message: '测试失败: 未找到当前连接对应的已保存密文,请重新填写密码并保存后再试',
|
||||
shouldToast: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps required-field validation failures inline without an extra toast', () => {
|
||||
expect(resolveConnectionTestFailureFeedback({
|
||||
kind: 'validation',
|
||||
reason: '',
|
||||
fallback: '连接失败',
|
||||
})).toEqual({
|
||||
message: '测试失败: 请先完善必填项后再测试连接',
|
||||
shouldToast: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
78
frontend/src/utils/connectionModalPresentation.ts
Normal file
78
frontend/src/utils/connectionModalPresentation.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
type StoredSecretPlaceholderOptions = {
|
||||
hasStoredSecret?: boolean;
|
||||
emptyPlaceholder: string;
|
||||
retainedLabel: string;
|
||||
};
|
||||
|
||||
type ConnectionTestFailureKind =
|
||||
| 'validation'
|
||||
| 'runtime'
|
||||
| 'driver_unavailable'
|
||||
| 'secret_blocked';
|
||||
|
||||
type ConnectionTestFailureFeedback = {
|
||||
message: string;
|
||||
shouldToast: boolean;
|
||||
};
|
||||
|
||||
const normalizeText = (value: unknown, fallback = ''): string => {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text || text === 'undefined' || text === 'null') {
|
||||
return fallback;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
export const getStoredSecretPlaceholder = ({
|
||||
hasStoredSecret,
|
||||
emptyPlaceholder,
|
||||
retainedLabel,
|
||||
}: StoredSecretPlaceholderOptions): string => (
|
||||
hasStoredSecret
|
||||
? `••••••(留空表示继续沿用${retainedLabel})`
|
||||
: emptyPlaceholder
|
||||
);
|
||||
|
||||
export const normalizeConnectionSecretErrorMessage = (
|
||||
value: unknown,
|
||||
fallback = '',
|
||||
): string => {
|
||||
const text = normalizeText(value, fallback);
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
if (lower.includes('saved connection not found:')) {
|
||||
return '未找到当前连接对应的已保存密文,请重新填写密码并保存后再试';
|
||||
}
|
||||
if (lower.includes('secret store unavailable')) {
|
||||
return '系统密文存储当前不可用,请检查系统钥匙串或凭据管理器后再试';
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
export const resolveConnectionTestFailureFeedback = ({
|
||||
kind,
|
||||
reason,
|
||||
fallback,
|
||||
}: {
|
||||
kind: ConnectionTestFailureKind;
|
||||
reason: unknown;
|
||||
fallback: string;
|
||||
}): ConnectionTestFailureFeedback => {
|
||||
if (kind === 'validation') {
|
||||
return {
|
||||
message: '测试失败: 请先完善必填项后再测试连接',
|
||||
shouldToast: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `测试失败: ${normalizeConnectionSecretErrorMessage(reason, fallback)}`,
|
||||
shouldToast: true,
|
||||
};
|
||||
};
|
||||
|
||||
export type {
|
||||
ConnectionTestFailureFeedback,
|
||||
ConnectionTestFailureKind,
|
||||
};
|
||||
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)';
|
||||
};
|
||||
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;
|
||||
};
|
||||
22
frontend/src/utils/driverImportGuidance.test.ts
Normal file
22
frontend/src/utils/driverImportGuidance.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CUSTOM_CONNECTION_DRIVER_HELP,
|
||||
DRIVER_LOCAL_IMPORT_BUTTON_LABEL,
|
||||
DRIVER_LOCAL_IMPORT_DIRECTORY_HELP,
|
||||
DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP,
|
||||
} from './driverImportGuidance';
|
||||
|
||||
describe('driver import guidance', () => {
|
||||
it('keeps local import copy focused on driver packages instead of JDBC jars', () => {
|
||||
expect(DRIVER_LOCAL_IMPORT_BUTTON_LABEL).toBe('导入驱动包');
|
||||
expect(DRIVER_LOCAL_IMPORT_DIRECTORY_HELP).toContain('导入驱动目录');
|
||||
expect(DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP).toContain('JDBC Jar');
|
||||
});
|
||||
|
||||
it('documents custom driver aliases for kingbase and related fallbacks', () => {
|
||||
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('kingbase8');
|
||||
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('pgx');
|
||||
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('JDBC Jar');
|
||||
});
|
||||
});
|
||||
10
frontend/src/utils/driverImportGuidance.ts
Normal file
10
frontend/src/utils/driverImportGuidance.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const DRIVER_LOCAL_IMPORT_BUTTON_LABEL = '导入驱动包';
|
||||
|
||||
export const DRIVER_LOCAL_IMPORT_DIRECTORY_HELP =
|
||||
'如果应用内下载链路失败,可先手动下载驱动包到该目录,再使用“导入驱动包”或“导入驱动目录”完成安装。';
|
||||
|
||||
export const DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP =
|
||||
'行内“导入驱动包”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`),不支持直接导入 JDBC Jar;批量导入请使用上方“导入驱动目录”。';
|
||||
|
||||
export const CUSTOM_CONNECTION_DRIVER_HELP =
|
||||
'已支持: mysql, postgres, sqlite, oracle, dm, kingbase;别名支持 postgresql/pgx、dm8、kingbase8/kingbasees/kingbasev8。当前不支持通过 JDBC Jar 扩展驱动。';
|
||||
67
frontend/src/utils/externalSqlTree.test.ts
Normal file
67
frontend/src/utils/externalSqlTree.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
|
||||
import { buildExternalSQLRootNode, buildExternalSQLTabId } from './externalSqlTree';
|
||||
|
||||
describe('externalSqlTree helpers', () => {
|
||||
it('builds external SQL root node with nested directory and file entries', () => {
|
||||
const directories: ExternalSQLDirectory[] = [
|
||||
{
|
||||
id: 'dir-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
const trees: Record<string, ExternalSQLTreeEntry[]> = {
|
||||
'dir-1': [
|
||||
{
|
||||
name: 'ddl',
|
||||
path: 'D:/sql/scripts/ddl',
|
||||
isDir: true,
|
||||
children: [
|
||||
{
|
||||
name: 'init.sql',
|
||||
path: 'D:/sql/scripts/ddl/init.sql',
|
||||
isDir: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const node = buildExternalSQLRootNode({
|
||||
dbNodeKey: 'conn-1-demo',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
directories,
|
||||
directoryTrees: trees,
|
||||
});
|
||||
|
||||
expect(node.type).toBe('external-sql-root');
|
||||
expect(node.children).toHaveLength(1);
|
||||
expect(node.children?.[0]).toMatchObject({
|
||||
title: 'scripts',
|
||||
type: 'external-sql-directory',
|
||||
});
|
||||
expect(node.children?.[0].children?.[0]).toMatchObject({
|
||||
title: 'ddl',
|
||||
type: 'external-sql-folder',
|
||||
});
|
||||
expect(node.children?.[0].children?.[0].children?.[0]).toMatchObject({
|
||||
title: 'init.sql',
|
||||
type: 'external-sql-file',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds query tab ids with connection and database isolation', () => {
|
||||
const first = buildExternalSQLTabId('conn-1', 'demo', 'D:/sql/init.sql');
|
||||
const second = buildExternalSQLTabId('conn-1', 'demo2', 'D:/sql/init.sql');
|
||||
|
||||
expect(first).toContain('conn-1');
|
||||
expect(first).toContain('demo');
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
131
frontend/src/utils/externalSqlTree.ts
Normal file
131
frontend/src/utils/externalSqlTree.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
|
||||
|
||||
export type ExternalSQLNodeType =
|
||||
| 'external-sql-root'
|
||||
| 'external-sql-directory'
|
||||
| 'external-sql-folder'
|
||||
| 'external-sql-file';
|
||||
|
||||
export interface ExternalSQLTreeNode {
|
||||
title: string;
|
||||
key: string;
|
||||
isLeaf?: boolean;
|
||||
children?: ExternalSQLTreeNode[];
|
||||
type: ExternalSQLNodeType;
|
||||
dataRef: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type BuildExternalSQLRootNodeParams = {
|
||||
dbNodeKey: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
directories: ExternalSQLDirectory[];
|
||||
directoryTrees: Record<string, ExternalSQLTreeEntry[]>;
|
||||
};
|
||||
|
||||
const normalizeExternalSQLPath = (value: string): string =>
|
||||
String(value || '').trim().replace(/\\/g, '/');
|
||||
|
||||
const resolveDirectoryDisplayName = (directory: ExternalSQLDirectory): string => {
|
||||
const explicitName = String(directory.name || '').trim();
|
||||
if (explicitName) return explicitName;
|
||||
const normalizedPath = normalizeExternalSQLPath(directory.path);
|
||||
const segments = normalizedPath.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] || 'SQL目录';
|
||||
};
|
||||
|
||||
export const buildExternalSQLDirectoryId = (connectionId: string, dbName: string, directoryPath: string): string =>
|
||||
`external-sql-dir:${String(connectionId || '').trim()}:${String(dbName || '').trim()}:${normalizeExternalSQLPath(directoryPath)}`;
|
||||
|
||||
export const buildExternalSQLTabId = (connectionId: string, dbName: string, filePath: string): string =>
|
||||
`external-sql-tab:${String(connectionId || '').trim()}:${String(dbName || '').trim()}:${normalizeExternalSQLPath(filePath)}`;
|
||||
|
||||
const buildExternalSQLNodeKey = (type: ExternalSQLNodeType, base: string): string =>
|
||||
`${type}:${normalizeExternalSQLPath(base)}`;
|
||||
|
||||
const mapExternalSQLTreeEntries = (
|
||||
entries: ExternalSQLTreeEntry[],
|
||||
context: { connectionId: string; dbName: string; dbNodeKey: string; directoryId: string },
|
||||
): ExternalSQLTreeNode[] => entries.map((entry) => {
|
||||
const entryPath = normalizeExternalSQLPath(entry.path);
|
||||
if (entry.isDir) {
|
||||
const children = mapExternalSQLTreeEntries(entry.children || [], context);
|
||||
return {
|
||||
title: entry.name,
|
||||
key: buildExternalSQLNodeKey('external-sql-folder', entryPath),
|
||||
type: 'external-sql-folder',
|
||||
isLeaf: children.length === 0,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
dataRef: {
|
||||
connectionId: context.connectionId,
|
||||
dbName: context.dbName,
|
||||
dbNodeKey: context.dbNodeKey,
|
||||
directoryId: context.directoryId,
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: entry.name,
|
||||
key: buildExternalSQLNodeKey('external-sql-file', entryPath),
|
||||
type: 'external-sql-file',
|
||||
isLeaf: true,
|
||||
dataRef: {
|
||||
connectionId: context.connectionId,
|
||||
dbName: context.dbName,
|
||||
dbNodeKey: context.dbNodeKey,
|
||||
directoryId: context.directoryId,
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const buildExternalSQLRootNode = ({
|
||||
dbNodeKey,
|
||||
connectionId,
|
||||
dbName,
|
||||
directories,
|
||||
directoryTrees,
|
||||
}: BuildExternalSQLRootNodeParams): ExternalSQLTreeNode => {
|
||||
const sortedDirectories = [...directories].sort((left, right) =>
|
||||
resolveDirectoryDisplayName(left).toLowerCase().localeCompare(resolveDirectoryDisplayName(right).toLowerCase()),
|
||||
);
|
||||
|
||||
const children = sortedDirectories.map((directory) => {
|
||||
const directoryChildren = mapExternalSQLTreeEntries(directoryTrees[directory.id] || [], {
|
||||
connectionId,
|
||||
dbName,
|
||||
dbNodeKey,
|
||||
directoryId: directory.id,
|
||||
});
|
||||
return {
|
||||
title: resolveDirectoryDisplayName(directory),
|
||||
key: buildExternalSQLNodeKey('external-sql-directory', directory.id),
|
||||
type: 'external-sql-directory' as const,
|
||||
isLeaf: directoryChildren.length === 0,
|
||||
children: directoryChildren.length > 0 ? directoryChildren : undefined,
|
||||
dataRef: {
|
||||
...directory,
|
||||
connectionId,
|
||||
dbName,
|
||||
dbNodeKey,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
title: children.length > 0 ? `外部 SQL 文件 (${children.length})` : '外部 SQL 文件',
|
||||
key: `${dbNodeKey}-external-sql`,
|
||||
type: 'external-sql-root',
|
||||
isLeaf: children.length === 0,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
dataRef: {
|
||||
connectionId,
|
||||
dbName,
|
||||
dbNodeKey,
|
||||
},
|
||||
};
|
||||
};
|
||||
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 : '',
|
||||
};
|
||||
}
|
||||
70
frontend/src/utils/inputAutoCap.test.ts
Normal file
70
frontend/src/utils/inputAutoCap.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyNoAutoCapAttributes, applyNoAutoCapAttributesWithin, noAutoCapInputProps } from './inputAutoCap';
|
||||
|
||||
describe('inputAutoCap', () => {
|
||||
it('exports input props that disable auto capitalization and correction', () => {
|
||||
expect(noAutoCapInputProps).toEqual({
|
||||
autoCapitalize: 'none',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies lowercase DOM attributes to inputs and textareas', () => {
|
||||
const inputAttributes: Record<string, string> = {};
|
||||
const textareaAttributes: Record<string, string> = {};
|
||||
const input = {
|
||||
tagName: 'INPUT',
|
||||
setAttribute: (key: string, value: string) => {
|
||||
inputAttributes[key] = value;
|
||||
},
|
||||
} as unknown as Element;
|
||||
const textarea = {
|
||||
tagName: 'TEXTAREA',
|
||||
setAttribute: (key: string, value: string) => {
|
||||
textareaAttributes[key] = value;
|
||||
},
|
||||
} as unknown as Element;
|
||||
|
||||
applyNoAutoCapAttributes(input);
|
||||
applyNoAutoCapAttributes(textarea);
|
||||
|
||||
expect(inputAttributes.autocapitalize).toBe('none');
|
||||
expect(inputAttributes.autocorrect).toBe('off');
|
||||
expect(inputAttributes.spellcheck).toBe('false');
|
||||
expect(textareaAttributes.autocapitalize).toBe('none');
|
||||
expect(textareaAttributes.autocorrect).toBe('off');
|
||||
expect(textareaAttributes.spellcheck).toBe('false');
|
||||
});
|
||||
|
||||
it('applies no-auto-cap attributes to all nested inputs and textareas within a container', () => {
|
||||
const inputAttributes: Record<string, string> = {};
|
||||
const textareaAttributes: Record<string, string> = {};
|
||||
const input = {
|
||||
tagName: 'INPUT',
|
||||
setAttribute: (key: string, value: string) => {
|
||||
inputAttributes[key] = value;
|
||||
},
|
||||
} as unknown as Element;
|
||||
const textarea = {
|
||||
tagName: 'TEXTAREA',
|
||||
setAttribute: (key: string, value: string) => {
|
||||
textareaAttributes[key] = value;
|
||||
},
|
||||
} as unknown as Element;
|
||||
const root = {
|
||||
querySelectorAll: (selector: string) => {
|
||||
expect(selector).toBe('input, textarea');
|
||||
return [input, textarea];
|
||||
},
|
||||
} as unknown as ParentNode;
|
||||
|
||||
applyNoAutoCapAttributesWithin(root);
|
||||
|
||||
expect(inputAttributes.autocapitalize).toBe('none');
|
||||
expect(inputAttributes.autocorrect).toBe('off');
|
||||
expect(textareaAttributes.autocapitalize).toBe('none');
|
||||
expect(textareaAttributes.autocorrect).toBe('off');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user