mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 20:29:43 +08:00
Compare commits
26 Commits
release/0.
...
v0.6.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7beb08c960 | ||
|
|
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 |
14
.github/workflows/dev-build.yml
vendored
14
.github/workflows/dev-build.yml
vendored
@@ -320,9 +320,6 @@ 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"
|
||||
@@ -339,17 +336,6 @@ 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"
|
||||
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -314,9 +314,6 @@ 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"
|
||||
@@ -333,17 +330,6 @@ 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/
|
||||
@@ -27,5 +27,4 @@ docs/需求追踪/
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
.worktrees
|
||||
docs
|
||||
.tmp_superpowers_edit
|
||||
docs
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
Thank you for contributing to this project.
|
||||
|
||||
This repository uses `dev` as the default integration branch, while stable releases are published from `main` through `release/*` branches.
|
||||
This repository follows a release-first workflow: `main` is the default public branch, while releases are prepared through `release/*` branches.
|
||||
|
||||
---
|
||||
|
||||
## Branch Model
|
||||
|
||||
- `dev`: default branch and day-to-day integration branch
|
||||
- `main`: stable release branch
|
||||
- `main`: stable release branch and default branch
|
||||
- `dev`: day-to-day integration branch for maintainers
|
||||
- `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 `dev`**.
|
||||
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `main`**.
|
||||
|
||||
Reasons:
|
||||
|
||||
- `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
|
||||
- `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
|
||||
|
||||
Recommended flow:
|
||||
|
||||
1. Fork this repository
|
||||
2. Sync your fork with `dev` and create a branch from `dev` (`fix/*` or `feature/*` is recommended)
|
||||
2. Create a branch in your fork (`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 `dev` branch of this repository
|
||||
5. Open a pull request against the `main` branch of this repository
|
||||
|
||||
---
|
||||
|
||||
@@ -63,21 +63,33 @@ Recommended expectations:
|
||||
|
||||
## Merge Strategy for Maintainers
|
||||
|
||||
Pull requests merged into `dev` should generally use **Squash and merge**.
|
||||
Pull requests merged into `main` should generally use **Squash and merge**.
|
||||
|
||||
Reasons:
|
||||
|
||||
- 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/*`
|
||||
- keeps `main` history clean and linear
|
||||
- maps each PR to a single commit on `main`
|
||||
- reduces release, audit, and rollback complexity
|
||||
|
||||
---
|
||||
|
||||
## Maintainer Sync Rules
|
||||
|
||||
Because external pull requests are merged directly into `dev`, maintainers should treat `dev` as the source branch for daily collaboration and release preparation.
|
||||
Because external pull requests are merged directly into `main`, maintainers must sync `main` back to development and release branches to avoid branch drift.
|
||||
|
||||
### 1. Create `release/*` from `dev`
|
||||
### 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`
|
||||
|
||||
Before a release, create a release branch from `dev`, for example:
|
||||
|
||||
@@ -88,7 +100,7 @@ git checkout -b release/v0.6.0
|
||||
git push -u origin release/v0.6.0
|
||||
```
|
||||
|
||||
### 2. Release from `release/*` back to `main`
|
||||
### 3. Release from `release/*` back to `main`
|
||||
|
||||
When release preparation is complete, merge the release branch back into `main` and create a tag:
|
||||
|
||||
@@ -101,9 +113,9 @@ git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
```
|
||||
|
||||
### 3. Sync `main` back to `dev` after release
|
||||
### 4. Sync `main` back to `dev` after release
|
||||
|
||||
After the release, sync `main` back into `dev` so the next iteration starts from the released code line:
|
||||
After the release, the same automation still applies. If needed, you can run the workflow manually (`workflow_dispatch`) or execute the fallback commands:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
感谢你对本项目的贡献。
|
||||
|
||||
本项目当前采用“`dev` 作为默认集成分支,`main` 作为稳定发布分支,`release/*` 负责发版准备”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
|
||||
本项目采用“发布优先(`main` 为默认分支)+ `release/*` 分支发版”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
|
||||
|
||||
---
|
||||
|
||||
## 分支模型
|
||||
|
||||
- `dev`:默认分支,也是日常开发集成分支
|
||||
- `main`:稳定发布分支
|
||||
- `main`:稳定发布分支,也是仓库默认分支
|
||||
- `dev`:日常开发集成分支,主要供维护者使用
|
||||
- `release/*`:发布准备分支,主要供维护者使用
|
||||
- 外部贡献者建议使用以下分支命名:
|
||||
- `fix/*`:问题修复
|
||||
@@ -25,21 +25,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
## 外部贡献者如何提 Pull Request
|
||||
|
||||
无论是 `fix/*` 还是 `feature/*`,**外部贡献者统一直接向 `dev` 发起 Pull Request**。
|
||||
无论是 `fix/*` 还是 `feature/*`,**外部贡献者统一直接向 `main` 发起 Pull Request**。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- `dev` 是当前日常集成分支,评审与合入路径和维护者开发流程一致
|
||||
- 外部贡献会直接进入触发日常校验和 dev 构建的分支
|
||||
- 维护者可以直接从 `dev` 切 `release/*`,减少额外同步步骤
|
||||
- `main` 是默认分支,PR 入口更直观
|
||||
- 合并后贡献会直接体现在默认分支
|
||||
- 便于维护者统一做后续同步与发版整理
|
||||
|
||||
建议流程:
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 先同步你 fork 中的 `dev`,再从 `dev` 创建分支(建议命名为 `fix/*` 或 `feature/*`)
|
||||
2. 从你自己的仓库创建分支(建议命名为 `fix/*` 或 `feature/*`)
|
||||
3. 完成代码修改,并进行必要自检
|
||||
4. 推送到你的远程分支
|
||||
5. 向本仓库的 `dev` 分支发起 Pull Request
|
||||
5. 向本仓库的 `main` 分支发起 Pull Request
|
||||
|
||||
---
|
||||
|
||||
@@ -63,21 +63,33 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
## PR 合并策略(维护者)
|
||||
|
||||
`dev` 分支上的 PR 建议使用 **Squash and merge**。
|
||||
`main` 分支上的 PR 建议使用 **Squash and merge**。
|
||||
|
||||
原因:
|
||||
|
||||
- 保持 `dev` 集成历史清晰、便于审查
|
||||
- 每个 PR 在 `dev` 上对应一个明确的集成提交
|
||||
- 降低发版前整理与冲突处理成本
|
||||
- 保持 `main` 历史干净、线性
|
||||
- 每个 PR 在 `main` 上对应一个清晰提交
|
||||
- 降低发布排查与回滚成本
|
||||
|
||||
---
|
||||
|
||||
## 维护者同步规则
|
||||
|
||||
由于外部 PR 会直接合入 `dev`,维护者应将 `dev` 作为日常协作与发版准备的主线分支。
|
||||
由于外部 PR 会直接合入 `main`,维护者必须及时将 `main` 的变更同步到开发与发布分支,避免分支漂移。
|
||||
|
||||
### 1. 发版前从 dev 切 release/*
|
||||
### 1. main → dev 同步(必做)
|
||||
|
||||
仓库已移除 GitHub Actions 自动回灌 workflow。
|
||||
当前统一采用手动方式将 `main` 同步回 `dev`:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. 发版前从 dev 切 release/*
|
||||
|
||||
发布前由维护者基于 `dev` 创建发布分支,例如:
|
||||
|
||||
@@ -88,7 +100,7 @@ git checkout -b release/v0.6.0
|
||||
git push -u origin release/v0.6.0
|
||||
```
|
||||
|
||||
### 2. release/* → main 发版
|
||||
### 3. release/* → main 发版
|
||||
|
||||
发布准备完成后,将 `release/*` 合并回 `main`,并打标签发布:
|
||||
|
||||
@@ -101,9 +113,9 @@ git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
```
|
||||
|
||||
### 3. main 回流到 dev(发版后必做)
|
||||
### 4. main 回流到 dev(发版后必做)
|
||||
|
||||
发布完成后,需要将 `main` 回流到 `dev`,确保下一轮开发从已发布代码线继续推进:
|
||||
发布完成后,仍沿用同一套自动化流程;如有需要,也可以手动触发 `workflow_dispatch`,或执行以下兜底命令,确保开发线与发布线一致:
|
||||
|
||||
```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 branch from `dev` and open pull requests against `dev`.
|
||||
External contributors should open pull requests directly against `main`.
|
||||
|
||||
## Star History
|
||||
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
|
||||
|
||||
@@ -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)
|
||||
|
||||
外部贡献者应从 `dev` 拉出分支,并统一向 `dev` 发起 Pull Request。
|
||||
外部贡献者统一直接向 `main` 发起 Pull Request。
|
||||
|
||||
## Star History (Star 增长趋势)
|
||||
|
||||
|
||||
141
build-release.sh
141
build-release.sh
@@ -84,63 +84,6 @@ 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=""
|
||||
@@ -169,20 +112,19 @@ if [ $? -eq 0 ]; then
|
||||
else
|
||||
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
|
||||
exit 1
|
||||
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"
|
||||
fi
|
||||
|
||||
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
|
||||
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
|
||||
@@ -192,9 +134,8 @@ if [ $? -eq 0 ]; then
|
||||
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性,
|
||||
# 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO)
|
||||
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口(CI/本地静默打包更友好)。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
|
||||
if [ -n "$MAC_VOLICON_PATH" ]; then
|
||||
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
|
||||
else
|
||||
@@ -238,17 +179,15 @@ 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}"
|
||||
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
|
||||
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
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -280,12 +219,11 @@ if [ $? -eq 0 ]; then
|
||||
else
|
||||
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}"
|
||||
exit 1
|
||||
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"
|
||||
fi
|
||||
|
||||
# Ad-hoc 代码签名
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
|
||||
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
if command -v create-dmg &> /dev/null; then
|
||||
echo " 📦 正在打包 DMG (amd64)..."
|
||||
@@ -301,9 +239,8 @@ if [ $? -eq 0 ]; then
|
||||
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性,
|
||||
# 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO)
|
||||
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口(CI/本地静默打包更友好)。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
|
||||
if [ -n "$MAC_VOLICON_PATH" ]; then
|
||||
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
|
||||
else
|
||||
@@ -345,16 +282,14 @@ 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}"
|
||||
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
|
||||
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
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
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")
|
||||
}
|
||||
111
docs/issues/2026-04-11-issue-backlog-tracking.md
Normal file
111
docs/issues/2026-04-11-issue-backlog-tracking.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 2026-04-11 Issue Backlog Tracking
|
||||
|
||||
## Scope
|
||||
|
||||
- 分支:`codex/issue-242-data-root`
|
||||
- 策略:按 GitHub issue 创建时间从早到晚逐条处理
|
||||
- 提交要求:每条 issue 单独本地提交,提交信息使用 `Fixes #<issue>`
|
||||
|
||||
## Progress
|
||||
|
||||
| Issue | Title | Status | Commit |
|
||||
| --- | --- | --- | --- |
|
||||
| #242 | 希望有自定义数据存储位置功能 | Fixed | `1f617f9` |
|
||||
| #287 | 建议补充 Sql Server 数据库图标 | Fixed | `60b63d7` |
|
||||
| #305 | 金仓数据库设计表新增字段保存失败 | Fixed | `f696f52` |
|
||||
| #306 | 驱动下载 | Fixed | `8297829` |
|
||||
| #308 | clickhouse 获取数据库列表失败 | Fixed | `5d86ee7` |
|
||||
| #310 | 选择库后,右侧行显示各个表 | Fixed | `808c773` |
|
||||
| #311 | WIN 系统的执行 500 多条 insert 语句要几分钟 | Fixed | `83fe3d4` |
|
||||
| #315 | 窗体内缩放异常 | Fixed | `5038ae5` |
|
||||
| #316 | 人大金仓数据库驱动版本过低 | Fixed | `aa1bb5b` |
|
||||
| #317 | 驱动管理增加导入 jar 功能 | Blocked | - |
|
||||
| #318 | mysql,bit 列,修改成 1 失败 | Fixed | `89d79ff` |
|
||||
| #319 | 关于运行外部 sql 文件的一些建议 | Deferred | - |
|
||||
| #320 | 无法连接达梦数据库 | Fixed | `1c2377b` |
|
||||
| #322 | 【拖选复制】希望添加 查询结果表格可以拖选复制,效果就如操作excel表格的选择复制一样 | Fixed | Pending |
|
||||
| #325 | 有没有考虑对数据库的驱动版本进行选择或者自定义? | Fixed | `af5e842` |
|
||||
| #327 | SHOW DATABASES 报错 | Fixed | `fb500ee` |
|
||||
| #328 | [Bug] 安装更新失败 | Fixed | `426ef3b` |
|
||||
| #329 | 如果调整了左侧导航栏的宽度后,建议左侧导航栏内增加横向滚动查看 | Fixed | `fcade0f` |
|
||||
| #330 | 建议在查询结果表格中增加自适应内容列宽的功能 | Fixed | `632e57e` |
|
||||
| #331 | 重复连接 DB,一分钟重试了 60 多次 | Fixed | `ca76440` |
|
||||
| #351 | 为什么没有截断和清空表的功能呀? | Fixed | Pending |
|
||||
|
||||
## Notes
|
||||
|
||||
### #317
|
||||
|
||||
- 当前驱动管理只支持内置 Go 驱动和可选 Go 驱动代理包。
|
||||
- 仓库内不存在 JDBC/JAR 装载、Java 运行时探测、classpath 管理或桥接执行链路。
|
||||
- 在现有架构下直接增加 “导入 jar” 入口会形成假功能,因此暂记为架构阻塞,不做伪实现。
|
||||
|
||||
### #318
|
||||
|
||||
- 根因:MySQL 写入归一化只覆盖时间列,`bit` 列提交时会把前端传来的 `"1"`/`"0"` 原样透传给驱动。
|
||||
- 处理:为 MySQL `bit` 列补充写入值归一化,将常见文本/布尔/数值输入转换为驱动可接受的 `[]byte`。
|
||||
- 验证:补充 `internal/db/mysql_value_test.go` 回归测试,覆盖 `bit(1)` 的 insert/update 写入路径。
|
||||
|
||||
### #319
|
||||
|
||||
- 现有应用已支持“运行外部 SQL 文件”,但 issue 诉求包含目录树、目录加载、双击文件打开等整组工作区能力。
|
||||
- 该项已超出单点缺陷修复范围,暂按功能增强项顺延,避免在逐条修 bug 流程中引入大范围 UI/状态管理重构。
|
||||
|
||||
### #320
|
||||
|
||||
- 达梦当前走可选 Go 驱动代理安装链路,不支持 JAR 导入属于既有架构边界。
|
||||
- 根因:驱动 release 资产缓存把 `GoNavi-DriverAgents.zip` 里的 bundle 条目也混进了“顶层已发布 asset”集合,导致安装链路误以为存在单独的 `dameng-driver-agent-*.exe` 下载地址。
|
||||
- 处理:缓存层区分真实 release 顶层 asset 与 bundle index 条目,安装 URL 解析仅在真实顶层 asset 存在时才走直链;bundle-only 驱动改为直接进入总包提取回退,不再先卡在 20% 试无效 URL。
|
||||
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖 bundle-only 达梦驱动跳过伪直链,并回归 Mongo 历史版本与本地导入链路。
|
||||
|
||||
### #327
|
||||
|
||||
- 根因:低权限 MySQL 账号执行 `SHOW DATABASES` 会直接报错,当前实现没有回退路径。
|
||||
- 处理:为数据库列表查询增加 `SELECT DATABASE()` 回退,仅保留当前连接库时也能正常展示。
|
||||
- 验证:补充 `internal/db/mysql_metadata_test.go` 回归测试,覆盖有权限、多库和低权限回退场景。
|
||||
|
||||
### #328
|
||||
|
||||
- 根因:Windows 更新脚本在批处理执行、错误码读取和重启命令上不够稳,`cmd /C start`、LF 行尾和块内 `%ERRORLEVEL%` 在实际环境下容易引发安装失败。
|
||||
- 处理:更新脚本统一输出为 CRLF,块内错误码改为延迟展开,旧文件回退路径统一为 `TARGET_OLD`,并将脚本启动方式收敛为 `cmd.exe /D /C call <script>`。
|
||||
- 验证:补充 `internal/app/methods_update_windows_script_test.go`,覆盖批处理语法、Win10 回退路径、CRLF 行尾、延迟展开和启动命令构造。
|
||||
|
||||
### #325
|
||||
|
||||
- 根因:TDengine 的版本列表虽然支持下拉选择,但后端在抓取与缓存 Go 模块版本时只保留最近 5 个版本,导致 `3.5.x / 3.3.x / 3.0.x` 这类旧版根本不会进入选择列表。
|
||||
- 处理:放宽 TDengine 的历史版本窗口,并补充离线 fallback 版本矩阵;同时扩大模块版本缓存上限,确保旧版不会在抓取阶段就被截断。
|
||||
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖缓存命中与 fallback 两条路径,并回归 Mongo 版本约束逻辑。
|
||||
|
||||
### #329
|
||||
|
||||
- 根因:侧边栏连接树被全局 Tree 样式固定为 `width: 100%`,标题同时启用了省略截断,导致缩窄侧栏后长节点无法形成横向溢出。
|
||||
- 处理:为 Sidebar 树增加专用横向滚动容器,并在 Sidebar 作用域内覆写 Tree 宽度与标题截断规则,让节点宽度随内容扩展且保留最小占满。
|
||||
- 验证:执行 `frontend` 下 `npm run build`,确认 TS/CSS 改动编译通过且仅作用于 Sidebar 树。
|
||||
|
||||
### #331
|
||||
|
||||
- 根因:连接失败时存在双层重试叠加。`DBGetDatabases / DBGetTables / DBQuery` 在缓存失效后本来就会主动重建连接一次,而 `connectDatabaseWithStartupRetry` 在稳定期仍会额外放行一次瞬时错误自动重试,导致一次后台探测会被放大成多次真实建连。
|
||||
- 处理:将连接自动重试范围收敛到应用启动保护窗口内;稳定期下所有连接探测与重建都只执行一次,避免后台挂起场景持续放大失败流量。
|
||||
- 验证:补充并更新 `internal/app/app_startup_connect_retry_test.go`,覆盖稳定期瞬时失败不重试、不再输出重试提示,以及启动期仍保留完整重试预算。
|
||||
|
||||
### #330
|
||||
|
||||
- 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。
|
||||
- 处理:为 `DataGrid` 的列宽拖拽手柄增加双击入口,按当前表头与已加载结果集内容估算目标宽度,并直接复用现有 `columnWidths` 状态更新布局。
|
||||
- 验证:新增 `frontend/src/components/dataGridAutoWidth.test.ts` 覆盖列宽估算规则,并执行 `frontend` 下 `npm run build` 确认 TS 与打包通过。
|
||||
|
||||
### #322
|
||||
|
||||
- 根因:`DataGrid` 已经具备拖选单元格和选区状态维护能力,但当前复制能力只支持把同一行选中的列值暂存为内部 patch,用于“粘贴到选中行”,没有把矩形选区真正导出到系统剪贴板。
|
||||
- 处理:新增选区复制 helper,将矩形选区按当前可见行列顺序导出为制表符文本;同时补上工具栏“复制选区”按钮和 `Ctrl/Cmd+C` 快捷键,让拖选后的复制行为更接近 Excel。
|
||||
- 验证:新增 `frontend/src/components/dataGridSelectionCopy.test.ts` 覆盖选区排序与剪贴板文本规整规则,并执行 `frontend` 下 `npm run build` 确认功能接线通过。
|
||||
|
||||
### #351
|
||||
|
||||
- 根因:后端已有批量清空表能力,但前端单表危险操作菜单只暴露了“删除表”,没有把“截断表 / 清空表”作为显式入口提供给用户;同时批量“清空”动作底层语义也混用了 `TRUNCATE/DELETE`。
|
||||
- 处理:后端将“截断表”和“清空表”拆分为显式能力,统一通过 helper 生成多数据库 SQL;前端为 Sidebar 和 TableOverview 的表菜单补上两个危险操作入口,并仅在明确支持 `TRUNCATE TABLE` 的数据库类型上显示“截断表”。
|
||||
- 验证:新增 `internal/app/methods_file_clear_test.go` 与 `frontend/src/components/tableDataDangerActions.test.ts`,并执行 `go test ./...`、`frontend` 下 `npm run build` 确认全量通过。
|
||||
|
||||
## Next
|
||||
|
||||
- 继续处理下一个最早且可直接落地的开放 issue。
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gonavi-client",
|
||||
"version": "0.6.5",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gonavi-client",
|
||||
"version": "0.6.5",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gonavi-client",
|
||||
"private": true,
|
||||
"version": "0.6.5",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1 +1 @@
|
||||
26a843d5fd071d0c7e9d8022e98eb4e3
|
||||
f697e821b4acd5cf614d63d46453e8a4
|
||||
@@ -375,47 +375,3 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
1012
frontend/src/App.tsx
1012
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@ interface AISettingsModalProps {
|
||||
onClose: () => void;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
focusProviderId?: string;
|
||||
}
|
||||
|
||||
// 预设配置:每个预设映射到后端 type(openai/anthropic/gemini/custom)并附带默认 URL 和 Model
|
||||
@@ -80,7 +79,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, focusProviderId }) => {
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme }) => {
|
||||
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||||
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||||
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
|
||||
@@ -136,17 +135,6 @@ 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);
|
||||
|
||||
@@ -4,16 +4,9 @@ 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 { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import {
|
||||
getStoredSecretPlaceholder,
|
||||
normalizeConnectionSecretErrorMessage,
|
||||
resolveConnectionTestFailureFeedback,
|
||||
} from '../utils/connectionModalPresentation';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
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';
|
||||
|
||||
@@ -26,6 +19,21 @@ const CONNECTION_MODAL_WIDTH = 960;
|
||||
const CONNECTION_MODAL_BODY_HEIGHT = 620;
|
||||
const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)';
|
||||
const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)';
|
||||
const noAutoCapInputProps = {
|
||||
autoCapitalize: 'none' as const,
|
||||
autoCorrect: 'off' as const,
|
||||
spellCheck: false,
|
||||
};
|
||||
|
||||
const applyNoAutoCapAttributes = (element: Element) => {
|
||||
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
|
||||
return;
|
||||
}
|
||||
element.setAttribute('autocapitalize', 'none');
|
||||
element.setAttribute('autocorrect', 'off');
|
||||
element.setAttribute('spellcheck', 'false');
|
||||
};
|
||||
|
||||
type ConnectionSecretKey =
|
||||
| 'primaryPassword'
|
||||
| 'sshPassword'
|
||||
@@ -127,8 +135,7 @@ const ConnectionModal: React.FC<{
|
||||
onClose: () => void;
|
||||
initialValues?: SavedConnection | null;
|
||||
onOpenDriverManager?: () => void;
|
||||
onSaved?: (savedConnection: SavedConnection) => void | Promise<void>;
|
||||
}> = ({ open, onClose, initialValues, onOpenDriverManager, onSaved }) => {
|
||||
}> = ({ open, onClose, initialValues, onOpenDriverManager }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useSSL, setUseSSL] = useState(false);
|
||||
@@ -164,7 +171,6 @@ 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;
|
||||
@@ -195,10 +201,7 @@ 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, { disableBackdropFilter: disableLocalBackdropFilter }),
|
||||
[darkMode, disableLocalBackdropFilter],
|
||||
);
|
||||
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
|
||||
|
||||
const tunnelSectionStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
@@ -1440,13 +1443,6 @@ const ConnectionModal: React.FC<{
|
||||
message.success('配置已保存(未连接)');
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1457,7 +1453,7 @@ const ConnectionModal: React.FC<{
|
||||
setClearSecrets(createEmptyConnectionSecretClearState());
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
message.error(normalizeConnectionSecretErrorMessage(e?.message || e, '保存失败'));
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -1512,14 +1508,10 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
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 buildTestFailureMessage = (reason: unknown, fallback: string) => {
|
||||
const text = String(reason ?? '').trim();
|
||||
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
|
||||
return `测试失败: ${normalized}`;
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
@@ -1530,21 +1522,14 @@ const ConnectionModal: React.FC<{
|
||||
const values = form.getFieldsValue(true);
|
||||
const unavailableReason = await resolveDriverUnavailableReason(values.type);
|
||||
if (unavailableReason) {
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'driver_unavailable',
|
||||
reason: unavailableReason,
|
||||
fallback: '驱动未安装启用',
|
||||
}));
|
||||
const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用');
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
promptInstallDriver(values.type, unavailableReason);
|
||||
return;
|
||||
}
|
||||
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
|
||||
if (blockingSecretClearMessage) {
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'secret_blocked',
|
||||
reason: blockingSecretClearMessage,
|
||||
fallback: '连接参数不完整',
|
||||
}));
|
||||
setTestResult({ type: 'error', message: blockingSecretClearMessage });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -1570,7 +1555,6 @@ 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));
|
||||
@@ -1594,33 +1578,27 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
} else {
|
||||
setDbList([]);
|
||||
message.warning(`连接成功,但获取数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, '未知错误')}`);
|
||||
message.warning(`连接成功,但获取数据库列表失败:${dbRes.message || '未知错误'}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'runtime',
|
||||
reason: res?.message,
|
||||
fallback: '连接被拒绝或参数无效,请检查后重试',
|
||||
}));
|
||||
const failMessage = buildTestFailureMessage(
|
||||
res?.message,
|
||||
'连接被拒绝或参数无效,请检查后重试'
|
||||
);
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errorFields' in e) {
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'validation',
|
||||
reason: '',
|
||||
fallback: '请先完善必填项后再测试连接',
|
||||
}));
|
||||
const failMessage = '测试失败: 请先完善必填项后再测试连接';
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
return;
|
||||
}
|
||||
const reason = e instanceof Error
|
||||
? e.message
|
||||
: (typeof e === 'string' ? e : '未知异常');
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'runtime',
|
||||
reason,
|
||||
fallback: '未知异常',
|
||||
}));
|
||||
const failMessage = buildTestFailureMessage(reason, '未知异常');
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
} finally {
|
||||
testInFlightRef.current = false;
|
||||
setLoading(false);
|
||||
@@ -1646,7 +1624,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
const result = await MongoDiscoverMembers(config as any);
|
||||
if (!result.success) {
|
||||
message.error(normalizeConnectionSecretErrorMessage(result.message, '成员发现失败'));
|
||||
message.error(result.message || '成员发现失败');
|
||||
return;
|
||||
}
|
||||
const data = (result.data as Record<string, any>) || {};
|
||||
@@ -1667,7 +1645,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
message.success(result.message || `发现 ${members.length} 个成员`);
|
||||
} catch (error: any) {
|
||||
message.error(normalizeConnectionSecretErrorMessage(error?.message || error, '成员发现失败'));
|
||||
message.error(error?.message || '成员发现失败');
|
||||
} finally {
|
||||
setDiscoveringMembers(false);
|
||||
}
|
||||
@@ -2164,7 +2142,7 @@ const ConnectionModal: React.FC<{
|
||||
|
||||
{isCustom ? (
|
||||
<>
|
||||
<Form.Item name="driver" label="驱动名称 (Driver Name)" rules={[{ required: true, message: '请输入驱动名称' }]} help={CUSTOM_CONNECTION_DRIVER_HELP}>
|
||||
<Form.Item name="driver" label="驱动名称 (Driver Name)" rules={[{ required: true, message: '请输入驱动名称' }]} help="已支持: mysql, postgres, sqlite, oracle, dm, kingbase">
|
||||
<Input {...noAutoCapInputProps} placeholder="例如: mysql, postgres" />
|
||||
</Form.Item>
|
||||
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[createCustomDsnRule()]}>
|
||||
@@ -2255,14 +2233,7 @@ const ConnectionModal: React.FC<{
|
||||
<Input {...noAutoCapInputProps} placeholder="留空沿用主库用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="mysqlReplicaPassword" label="从库密码(可选)" style={{ marginBottom: 0 }}>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
|
||||
emptyPlaceholder: '留空沿用主库密码',
|
||||
retainedLabel: '已保存从库密码',
|
||||
})}
|
||||
/>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主库密码" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
@@ -2312,14 +2283,7 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="mongoReplicaPassword" label="副本集密码(可选)" style={{ marginBottom: 0 }}>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
emptyPlaceholder: '留空沿用主密码',
|
||||
retainedLabel: '已保存副本集密码',
|
||||
})}
|
||||
/>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主密码" />
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'mongoReplicaPassword',
|
||||
@@ -2400,14 +2364,7 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="password" label="密码 (可选)">
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder: 'Redis 密码(如果设置了 requirepass)',
|
||||
retainedLabel: '已保存 Redis 密码',
|
||||
})}
|
||||
/>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="Redis 密码(如果设置了 requirepass)" />
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'password',
|
||||
@@ -2440,14 +2397,7 @@ const ConnectionModal: React.FC<{
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" style={{ marginBottom: 0 }}>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder: '密码',
|
||||
retainedLabel: '已保存密码',
|
||||
})}
|
||||
/>
|
||||
<Input.Password {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
{dbType === 'mongodb' && (
|
||||
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ marginBottom: 0 }}>
|
||||
@@ -2568,14 +2518,7 @@ const ConnectionModal: React.FC<{
|
||||
<Input {...noAutoCapInputProps} placeholder="root" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sshPassword" label="SSH 密码" style={{ flex: 1 }}>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasSSHPassword,
|
||||
emptyPlaceholder: '密码',
|
||||
retainedLabel: '已保存 SSH 密码',
|
||||
})}
|
||||
/>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="密码" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
|
||||
@@ -2630,14 +2573,7 @@ const ConnectionModal: React.FC<{
|
||||
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasProxyPassword,
|
||||
emptyPlaceholder: '留空表示无认证',
|
||||
retainedLabel: '已保存代理密码',
|
||||
})}
|
||||
/>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
@@ -2675,14 +2611,7 @@ const ConnectionModal: React.FC<{
|
||||
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
<Form.Item name="httpTunnelPassword" label="隧道密码(可选)" style={{ flex: 1 }}>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
|
||||
emptyPlaceholder: '留空表示无认证',
|
||||
retainedLabel: '已保存隧道密码',
|
||||
})}
|
||||
/>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
@@ -3224,3 +3153,5 @@ const ConnectionModal: React.FC<{
|
||||
export default ConnectionModal;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ import { v4 as generateUuid } from 'uuid';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
resolveDataTableColumnWidth,
|
||||
@@ -50,16 +50,6 @@ import {
|
||||
} from './dataGridCopyInsert';
|
||||
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
|
||||
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
|
||||
import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import {
|
||||
TEMPORAL_FORMATS,
|
||||
formatFromDayjs,
|
||||
getTemporalPickerType,
|
||||
isTemporalColumnType,
|
||||
parseToDayjs,
|
||||
resolveTemporalEditorSaveValue,
|
||||
type TemporalPickerType,
|
||||
} from './dataGridTemporal';
|
||||
|
||||
// --- Error Boundary ---
|
||||
interface DataGridErrorBoundaryState {
|
||||
@@ -176,6 +166,51 @@ const normalizeDateTimeString = (val: string) => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
// 根据列类型返回 DatePicker 的 picker 模式
|
||||
type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
|
||||
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;
|
||||
};
|
||||
|
||||
const TEMPORAL_FORMATS: Record<string, string> = {
|
||||
datetime: 'YYYY-MM-DD HH:mm:ss',
|
||||
date: 'YYYY-MM-DD',
|
||||
time: 'HH:mm:ss',
|
||||
year: 'YYYY',
|
||||
};
|
||||
|
||||
// 将字符串值转为 dayjs 对象(用于 DatePicker),无效值返回 null
|
||||
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;
|
||||
};
|
||||
|
||||
// 将 dayjs 对象格式化为对应格式字符串
|
||||
const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
|
||||
if (!val || !val.isValid()) return '';
|
||||
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||
return val.format(fmt);
|
||||
};
|
||||
|
||||
// --- Helper: Format Value ---
|
||||
const formatCellValue = (val: any) => {
|
||||
try {
|
||||
@@ -604,14 +639,17 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
setEditing(!editing);
|
||||
};
|
||||
|
||||
const save = async (pickerValue?: dayjs.Dayjs | null) => {
|
||||
const save = async () => {
|
||||
try {
|
||||
if (!form || !editing) return;
|
||||
const fieldName = getCellFieldName(record, dataIndex);
|
||||
await form.validateFields([fieldName]);
|
||||
let nextValue = form.getFieldValue(fieldName);
|
||||
if (isDateTimeField) {
|
||||
nextValue = resolveTemporalEditorSaveValue(nextValue, pickerValue, pickerType);
|
||||
// 日期时间类型: 将 dayjs 对象转回格式化字符串
|
||||
if (isDateTimeField && nextValue && dayjs.isDayjs(nextValue)) {
|
||||
nextValue = formatFromDayjs(nextValue as dayjs.Dayjs, pickerType);
|
||||
} else if (isDateTimeField && !nextValue) {
|
||||
nextValue = null;
|
||||
}
|
||||
toggleEdit();
|
||||
// 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。
|
||||
@@ -650,9 +688,9 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
ref={inputRef}
|
||||
style={{ width: '100%' }}
|
||||
format={TEMPORAL_FORMATS[pickerType]}
|
||||
onChange={(value) => setTimeout(() => { void save(value); }, 0)}
|
||||
onChange={() => setTimeout(save, 0)}
|
||||
onOpenChange={lockTableScroll}
|
||||
onBlur={() => setTimeout(() => { void save(); }, 0)}
|
||||
onBlur={() => setTimeout(save, 0)}
|
||||
needConfirm={false}
|
||||
/>
|
||||
) : pickerType === 'datetime' ? (
|
||||
@@ -673,7 +711,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
}}
|
||||
>此刻</a>
|
||||
)}
|
||||
onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)}
|
||||
onOk={() => setTimeout(save, 0)}
|
||||
onOpenChange={(open) => {
|
||||
pickerOpenRef.current = open;
|
||||
lockTableScroll(open);
|
||||
@@ -693,17 +731,17 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
style={{ width: '100%' }}
|
||||
format={TEMPORAL_FORMATS[pickerType]}
|
||||
picker={pickerType as any}
|
||||
onChange={(value) => setTimeout(() => { void save(value); }, 0)}
|
||||
onChange={() => setTimeout(save, 0)}
|
||||
onOpenChange={lockTableScroll}
|
||||
onBlur={() => setTimeout(() => { void save(); }, 0)}
|
||||
onBlur={() => setTimeout(save, 0)}
|
||||
needConfirm={false}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onPressEnter={() => { void save(); }}
|
||||
onBlur={() => { void save(); }}
|
||||
onPressEnter={save}
|
||||
onBlur={save}
|
||||
onFocus={(e) => {
|
||||
try {
|
||||
(e.target as HTMLInputElement)?.select?.();
|
||||
@@ -2196,7 +2234,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
// Filter State
|
||||
const [filterConditions, setFilterConditions] = useState<GridFilterCondition[]>([]);
|
||||
const [nextFilterId, setNextFilterId] = useState(1);
|
||||
const filterPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextConditions = normalizeGridFilterConditions(appliedFilterConditions);
|
||||
@@ -2205,30 +2242,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setNextFilterId(Math.max(1, maxId + 1));
|
||||
}, [appliedFilterConditions, normalizeGridFilterConditions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showFilter) {
|
||||
return;
|
||||
}
|
||||
const root = filterPanelRef.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
const apply = () => {
|
||||
applyNoAutoCapAttributesWithin(root);
|
||||
};
|
||||
apply();
|
||||
if (typeof MutationObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
apply();
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [showFilter]);
|
||||
|
||||
const selectedRowKeysRef = useRef(selectedRowKeys);
|
||||
const displayDataRef = useRef<any[]>([]);
|
||||
|
||||
@@ -3247,6 +3260,20 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setRowEditorOpen(true);
|
||||
}, [canModifyData, mergedDisplayData, data, addedRows, displayColumnNames, rowEditorForm, rowKeyStr, columnMetaMap, columnMetaMapByLowerName]);
|
||||
|
||||
const openRowEditor = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
if (selectedRowKeys.length > 1) {
|
||||
void message.info('一次只能编辑一行,请仅选择一行');
|
||||
return;
|
||||
}
|
||||
const keyStr = selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined;
|
||||
if (!keyStr) {
|
||||
void message.info('请先选择一行(勾选复选框)');
|
||||
return;
|
||||
}
|
||||
openRowEditorByKey(keyStr);
|
||||
}, [canModifyData, selectedRowKeys, rowKeyStr, openRowEditorByKey]);
|
||||
|
||||
const openCurrentViewRowEditor = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
const currentRow = mergedDisplayData[textRecordIndex];
|
||||
@@ -3264,50 +3291,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setJsonEditorOpen(true);
|
||||
}, [canModifyData, jsonViewText]);
|
||||
|
||||
const handleViewModeChange = useCallback((nextMode: GridViewMode) => {
|
||||
if (nextMode === 'json' && cellEditMode) {
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
cellSelectionRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||
cellSelectionScrollRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
|
||||
if (nextMode === 'text') {
|
||||
const selectedKey = selectedRowKeys[0];
|
||||
if (selectedKey !== undefined) {
|
||||
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
|
||||
if (idx >= 0) {
|
||||
setTextRecordIndex(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setViewMode(nextMode);
|
||||
}, [cellEditMode, mergedDisplayData, selectedRowKeys, rowKeyStr, updateCellSelection]);
|
||||
|
||||
const handleOpenContextMenuRowEditor = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
const rowKey = cellContextMenu.record?.[GONAVI_ROW_KEY];
|
||||
if (rowKey === undefined || rowKey === null) return;
|
||||
openRowEditorByKey(rowKeyStr(rowKey));
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}, [canModifyData, cellContextMenu.record, openRowEditorByKey, rowKeyStr]);
|
||||
|
||||
const handleFormatJsonEditor = useCallback(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonEditorValue);
|
||||
@@ -4019,7 +4002,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const dbType = resolveDataSourceType(config);
|
||||
const dbType = config.type || '';
|
||||
const pkWhere = buildPkWhereSql(records, dbType);
|
||||
if (!pkWhere) {
|
||||
await exportData(records, format);
|
||||
@@ -4088,7 +4071,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = buildCurrentPageSql(resolveDataSourceType(config));
|
||||
const sql = buildCurrentPageSql(config.type || '');
|
||||
if (!sql) {
|
||||
await exportData(displayData, format);
|
||||
return;
|
||||
@@ -4898,7 +4881,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''} data-grid-root`} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0, background: 'transparent' }}>
|
||||
{/* Toolbar + Filter Panel */}
|
||||
<div style={{ margin: `${panelOuterGap}px 0 ${panelOuterGap}px 0`, border: `1px solid ${panelFrameColor}`, borderRadius: `${panelRadius}px`, background: bgFilter, overflow: 'hidden', boxSizing: 'border-box' }}>
|
||||
<div className="data-grid-toolbar-scroll" data-grid-primary-actions="true" style={{ padding: showFilter ? `${panelPaddingY}px ${panelPaddingX}px ${toolbarBottomPadding}px ${panelPaddingX}px` : `${panelPaddingY}px ${panelPaddingX}px`, border: 'none', borderRadius: 0, background: 'transparent', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarGutter: 'stable', WebkitOverflowScrolling: 'touch', boxSizing: 'border-box' }}>
|
||||
<div className="data-grid-toolbar-scroll" style={{ padding: showFilter ? `${panelPaddingY}px ${panelPaddingX}px ${toolbarBottomPadding}px ${panelPaddingX}px` : `${panelPaddingY}px ${panelPaddingX}px`, border: 'none', borderRadius: 0, background: 'transparent', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarGutter: 'stable', WebkitOverflowScrolling: 'touch', boxSizing: 'border-box' }}>
|
||||
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
@@ -4921,6 +4904,13 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<>
|
||||
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
disabled={selectedRowKeys.length !== 1}
|
||||
onClick={openRowEditor}
|
||||
>
|
||||
编辑行
|
||||
</Button>
|
||||
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}>删除选中</Button>
|
||||
{selectedRowKeys.length > 0 && <span style={{ fontSize: '12px', color: '#888' }}>已选 {selectedRowKeys.length}</span>}
|
||||
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
||||
@@ -5070,10 +5060,82 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
)}
|
||||
|
||||
<div style={{ marginLeft: 'auto' }} />
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
type={dataPanelOpen ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
const next = !dataPanelOpen;
|
||||
setDataPanelOpen(next);
|
||||
if (!next) {
|
||||
setFocusedCellInfo(null);
|
||||
setDataPanelValue('');
|
||||
setDataPanelIsJson(false);
|
||||
dataPanelDirtyRef.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
数据预览
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={columnInfoSettingContent}
|
||||
>
|
||||
<Button icon={<FileTextOutlined />}>字段信息</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
options={[
|
||||
{ label: '表格', value: 'table' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: '文本', value: 'text' }
|
||||
]}
|
||||
onChange={(val) => {
|
||||
const nextMode = String(val) as GridViewMode;
|
||||
if (nextMode === 'json' && cellEditMode) {
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
cellSelectionRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||
cellSelectionScrollRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
if (nextMode === 'text') {
|
||||
const selectedKey = selectedRowKeys[0];
|
||||
if (selectedKey !== undefined) {
|
||||
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
|
||||
if (idx >= 0) {
|
||||
setTextRecordIndex(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
setViewMode(nextMode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilter && (
|
||||
<div ref={filterPanelRef} style={{
|
||||
<div style={{
|
||||
padding: `${filterTopPadding}px ${panelPaddingX}px ${panelPaddingY}px ${panelPaddingX}px`,
|
||||
background: 'transparent',
|
||||
boxSizing: 'border-box',
|
||||
@@ -5122,7 +5184,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
{cond.op === 'CUSTOM' ? (
|
||||
<Input.TextArea
|
||||
{...noAutoCapInputProps}
|
||||
style={{ flex: 1 }}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
value={cond.value}
|
||||
@@ -5131,7 +5192,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
/>
|
||||
) : isListOp(cond.op) ? (
|
||||
<Input.TextArea
|
||||
{...noAutoCapInputProps}
|
||||
style={{ flex: 1 }}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
value={cond.value}
|
||||
@@ -5141,14 +5201,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
) : isBetweenOp(cond.op) ? (
|
||||
<>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
style={{ width: 220 }}
|
||||
value={cond.value}
|
||||
onChange={e => updateFilter(cond.id, 'value', e.target.value)}
|
||||
placeholder="开始值"
|
||||
/>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
style={{ width: 220 }}
|
||||
value={cond.value2 || ''}
|
||||
onChange={e => updateFilter(cond.id, 'value2', e.target.value)}
|
||||
@@ -5156,10 +5214,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
/>
|
||||
</>
|
||||
) : isNoValueOp(cond.op) ? (
|
||||
<Input {...noAutoCapInputProps} style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
|
||||
<Input style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
|
||||
) : (
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
style={{ width: 280 }}
|
||||
value={cond.value}
|
||||
onChange={e => updateFilter(cond.id, 'value', e.target.value)}
|
||||
@@ -5661,19 +5718,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
>
|
||||
设置为 NULL
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={handleOpenContextMenuRowEditor}
|
||||
>
|
||||
<EditOutlined style={{ marginRight: 8 }} />
|
||||
编辑本行
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
@@ -5883,58 +5927,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-grid-secondary-actions="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
flexWrap: 'wrap',
|
||||
padding: '4px 0 0',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
type={dataPanelOpen ? 'primary' : 'default'}
|
||||
disabled={viewMode !== 'table'}
|
||||
onClick={() => {
|
||||
const next = !dataPanelOpen;
|
||||
setDataPanelOpen(next);
|
||||
if (!next) {
|
||||
setFocusedCellInfo(null);
|
||||
setDataPanelValue('');
|
||||
setDataPanelIsJson(false);
|
||||
dataPanelDirtyRef.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
数据预览
|
||||
</Button>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={columnInfoSettingContent}
|
||||
>
|
||||
<Button icon={<FileTextOutlined />}>字段信息</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
<div data-grid-view-switcher="true" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>结果视图</span>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
options={[
|
||||
{ label: '表格', value: 'table' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: '文本', value: 'text' }
|
||||
]}
|
||||
onChange={(val) => handleViewModeChange(String(val) as GridViewMode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="data-grid-pagination-wrap" style={{ padding: '12px 0 0', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
|
||||
@@ -5,15 +5,13 @@ 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 { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } 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 };
|
||||
@@ -26,7 +24,6 @@ type TableDiffSummary = {
|
||||
updates?: number;
|
||||
deletes?: number;
|
||||
same?: number;
|
||||
schemaDiffCount?: number;
|
||||
message?: string;
|
||||
targetTableExists?: boolean;
|
||||
plannedAction?: string;
|
||||
@@ -126,15 +123,6 @@ 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 : [];
|
||||
@@ -202,7 +190,6 @@ 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>('');
|
||||
@@ -216,8 +203,6 @@ 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');
|
||||
@@ -298,10 +283,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setTargetConnId('');
|
||||
setSourceDb('');
|
||||
setTargetDb('');
|
||||
setAllTables([]);
|
||||
setSelectedTables([]);
|
||||
setSourceDatasetMode('table');
|
||||
setSourceQuery('');
|
||||
setWorkflowType('sync');
|
||||
setSyncContent('data');
|
||||
setSyncMode('insert_update');
|
||||
@@ -349,28 +331,6 @@ 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('');
|
||||
@@ -416,12 +376,10 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const connId = isSourceQueryMode ? targetConnId : sourceConnId;
|
||||
const dbName = isSourceQueryMode ? targetDb : sourceDb;
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
const conn = connections.find(c => c.id === sourceConnId);
|
||||
if (conn) {
|
||||
const config = normalizeConnConfig(conn, dbName);
|
||||
const res = await DBGetTables(config as any, dbName);
|
||||
const config = normalizeConnConfig(conn, sourceDb);
|
||||
const res = await DBGetTables(config as any, sourceDb);
|
||||
if (res.success) {
|
||||
// DBGetTables returns [{Table: "name"}, ...]
|
||||
const tableRows = Array.isArray(res.data) ? res.data : [];
|
||||
@@ -429,13 +387,6 @@ 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);
|
||||
@@ -453,8 +404,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
};
|
||||
|
||||
const analyzeDiff = async () => {
|
||||
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
|
||||
if (selectionError) return message.error(selectionError);
|
||||
if (selectedTables.length === 0) return;
|
||||
if (!sourceConnId || !targetConnId) return message.error("Select connections first");
|
||||
if (!sourceDb || !targetDb) return message.error("Select databases first");
|
||||
|
||||
@@ -471,20 +421,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
autoScrollRef.current = true;
|
||||
setSyncProgress({ percent: 0, current: 0, total: selectedTables.length, table: '', stage: '差异分析' });
|
||||
|
||||
const config = buildDataSyncRequest({
|
||||
const config = {
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode: "insert_update",
|
||||
tables: selectedTables,
|
||||
content: syncContent,
|
||||
mode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
jobId,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await DataSyncAnalyze(config as any);
|
||||
@@ -526,19 +474,17 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setPreviewLoading(true);
|
||||
setPreviewData(null);
|
||||
|
||||
const config = buildDataSyncRequest({
|
||||
const config = {
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode: "insert_update",
|
||||
tables: selectedTables,
|
||||
content: "data",
|
||||
mode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName,
|
||||
});
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await DataSyncPreview(config as any, table, 200);
|
||||
@@ -555,11 +501,6 @@ 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;
|
||||
@@ -598,21 +539,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
stage: '准备开始',
|
||||
});
|
||||
|
||||
const config = buildDataSyncRequest({
|
||||
const config = {
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode,
|
||||
tables: selectedTables,
|
||||
content: syncContent,
|
||||
mode: syncMode,
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
tableOptions,
|
||||
jobId,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await DataSync(config as any);
|
||||
@@ -656,18 +595,6 @@ 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[] = [];
|
||||
@@ -678,7 +605,6 @@ 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]);
|
||||
@@ -704,8 +630,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: resolveTextInputSafeBackdropFilter(darkMode ? 'blur(18px)' : 'none', disableLocalBackdropFilter),
|
||||
}), [darkMode, disableLocalBackdropFilter]);
|
||||
backdropFilter: darkMode ? 'blur(18px)' : 'none',
|
||||
}), [darkMode]);
|
||||
|
||||
const shellCardStyle = useMemo<React.CSSProperties>(() => ({
|
||||
borderRadius: 18,
|
||||
@@ -911,13 +837,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Form.Item label="功能类型">
|
||||
<Select value={workflowType} onChange={setWorkflowType}>
|
||||
<Option value="sync">数据同步(基于已有目标表做差异同步)</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>
|
||||
<Option value="migration">跨库迁移(可自动建表后导入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Alert
|
||||
@@ -928,19 +848,11 @@ 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" disabled={isSourceQueryMode}>仅同步结构</Option>
|
||||
<Option value="both" disabled={isSourceQueryMode}>同步结构 + 数据</Option>
|
||||
<Option value="schema">仅同步结构</Option>
|
||||
<Option value="both">同步结构 + 数据</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
|
||||
@@ -951,7 +863,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow}>
|
||||
<Option value="existing_only">仅使用已有目标表</Option>
|
||||
<Option value="auto_create_if_missing">目标表不存在时自动建表后导入</Option>
|
||||
<Option value="smart">智能模式(存在则直接导入,不存在则自动建表)</Option>
|
||||
@@ -974,12 +886,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
|
||||
自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase;SQL 结果集模式暂不支持)
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)}>
|
||||
自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}>
|
||||
自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
@@ -1015,56 +927,21 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={quietPanelStyle}>
|
||||
{!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 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: '暂无数据' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{diffTables.length > 0 && (
|
||||
@@ -1183,9 +1060,8 @@ 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 || hasSchemaDiff) || analyzing} onClick={() => openPreview(r.table)}>
|
||||
<Button size="small" disabled={!can || !hasDiff || analyzing} onClick={() => openPreview(r.table)}>
|
||||
查看
|
||||
</Button>
|
||||
);
|
||||
@@ -1257,14 +1133,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 || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
|
||||
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing} style={{ marginRight: 8 }}>
|
||||
对比差异
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={runSync}
|
||||
loading={loading}
|
||||
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
disabled={selectedTables.length === 0 || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
>
|
||||
开始同步
|
||||
</Button>
|
||||
@@ -1292,59 +1168,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={
|
||||
previewHasDataDiff
|
||||
? `插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`
|
||||
: (previewData.schemaSummary || `检测到 ${previewSql.statementCount} 条结构变更语句`)
|
||||
}
|
||||
message={`插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`}
|
||||
/>
|
||||
{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: (
|
||||
@@ -1444,7 +1273,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}] : []),
|
||||
},
|
||||
{
|
||||
key: 'sql',
|
||||
label: `SQL(${previewSql.statementCount})`,
|
||||
@@ -1453,18 +1282,10 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={
|
||||
previewHasDataDiff
|
||||
? "SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
: "SQL 预览展示将执行的结构变更语句,用于审核确认。"
|
||||
}
|
||||
message="SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
/>
|
||||
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">
|
||||
{previewHasDataDiff
|
||||
? `共 ${previewSql.statementCount} 条语句(预览数据最多 200 条/类型)`
|
||||
: `共 ${previewSql.statementCount} 条结构变更语句`}
|
||||
</Text>
|
||||
<Text type="secondary">共 {previewSql.statementCount} 条语句(预览数据最多 200 条/类型)</Text>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!previewSql.sqlText}
|
||||
@@ -1493,7 +1314,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{previewSql.sqlText || (previewHasDataDiff ? '-- 当前勾选范围下无 SQL 可预览' : '-- 当前表结构无可执行变更')}
|
||||
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ 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, resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
@@ -396,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 = resolveDataSourceType(config);
|
||||
const dbType = config.type || '';
|
||||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||||
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
|
||||
|
||||
@@ -855,7 +855,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
|
||||
const exportSqlWithFilter = useMemo(() => {
|
||||
const tableName = String(tab.tableName || '').trim();
|
||||
const dbType = resolveDataSourceType(currentConnConfig);
|
||||
const dbType = String(currentConnConfig?.type || '').trim();
|
||||
if (!tableName || !dbType) return '';
|
||||
|
||||
const whereSQL = buildWhereSQL(dbType, filterConditions);
|
||||
@@ -869,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, currentConnConfig?.driver, filterConditions, sortInfo, pkColumns]);
|
||||
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
const action = resolveDataViewerAutoFetchAction({
|
||||
|
||||
@@ -10,23 +10,6 @@ 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);
|
||||
@@ -274,15 +257,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 normalizeMySQLViewDDL(textDefinition);
|
||||
if (textDefinition) return String(textDefinition);
|
||||
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
|
||||
if (sqlKey) return normalizeMySQLViewDDL(row[sqlKey]);
|
||||
if (sqlKey) return row[sqlKey];
|
||||
const tableSqlKey = keys.find(k => k.toLowerCase().includes('create table'));
|
||||
if (tableSqlKey) return normalizeMySQLViewDDL(row[tableSqlKey]);
|
||||
if (tableSqlKey) return 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 normalizeMySQLViewDDL(val);
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(row, null, 2);
|
||||
|
||||
@@ -4,11 +4,6 @@ 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,
|
||||
@@ -1176,7 +1171,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'}
|
||||
@@ -1378,8 +1373,8 @@ 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">{DRIVER_LOCAL_IMPORT_DIRECTORY_HELP}</Text>
|
||||
<Text type="secondary">{DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP}</Text>
|
||||
<Text type="secondary">如果应用内下载链路失败,可先手动下载驱动包到该目录,再使用“本地导入”或“导入驱动目录”完成安装。</Text>
|
||||
<Text type="secondary">行内“本地导入”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`);批量导入请使用上方“导入驱动目录”。</Text>
|
||||
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
|
||||
驱动根目录:{downloadDir || '-'}
|
||||
</Paragraph>
|
||||
|
||||
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -68,15 +67,14 @@ 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, { disableBackdropFilter: disableLocalBackdropFilter });
|
||||
}, [disableLocalBackdropFilter, theme]);
|
||||
return buildOverlayWorkbenchTheme(isDark);
|
||||
}, [theme]);
|
||||
|
||||
const buildConfig = useCallback(() => {
|
||||
if (!conn) return null;
|
||||
|
||||
@@ -11,7 +11,6 @@ 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 = [
|
||||
@@ -250,7 +249,6 @@ 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();
|
||||
@@ -326,10 +324,6 @@ 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;
|
||||
@@ -373,14 +367,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
};
|
||||
void fetchDbs();
|
||||
}, [autoFetchVisible, currentConnectionId, connections]);
|
||||
}, [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;
|
||||
@@ -434,7 +424,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
};
|
||||
void fetchMetadata();
|
||||
}, [autoFetchVisible, currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||
|
||||
// Query ID management helpers
|
||||
const setQueryId = (id: string) => {
|
||||
|
||||
@@ -6,14 +6,7 @@ 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,
|
||||
isMacLikePlatform,
|
||||
normalizeBlurForPlatform,
|
||||
normalizeOpacityForPlatform,
|
||||
resolveAppearanceValues,
|
||||
resolveTextInputSafeBackdropFilter,
|
||||
} from '../utils/appearance';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
applyRenamedRedisKeyState,
|
||||
@@ -26,9 +19,6 @@ 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;
|
||||
|
||||
@@ -49,6 +39,148 @@ 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;
|
||||
@@ -151,16 +283,8 @@ 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, disableBackdropFilter: disableLocalBackdropFilter }),
|
||||
[blur, darkMode, disableLocalBackdropFilter, opacity],
|
||||
);
|
||||
const workbenchBackdropFilter = useMemo(
|
||||
() => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter),
|
||||
[blur, disableLocalBackdropFilter],
|
||||
);
|
||||
const workbenchTheme = useMemo(() => buildRedisWorkbenchTheme({ darkMode, opacity, blur }), [blur, darkMode, opacity]);
|
||||
const keyAccentColor = workbenchTheme.accent;
|
||||
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
|
||||
const valueToolbarBg = workbenchTheme.panelBgStrong;
|
||||
@@ -169,7 +293,6 @@ 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);
|
||||
@@ -344,29 +467,13 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
|
||||
}, [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]);
|
||||
}, [redisDB]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
executeSearch(value);
|
||||
};
|
||||
|
||||
const handleSearchInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const normalized = normalizeRedisSearchDraftChange(event.target.value);
|
||||
setSearchInput(normalized.keyword);
|
||||
if (!normalized.shouldSearchImmediately) {
|
||||
return;
|
||||
}
|
||||
setSearchPattern(normalized.pattern);
|
||||
const pattern = value.trim() || '*';
|
||||
setSearchPattern(pattern);
|
||||
setCursor('0');
|
||||
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
|
||||
loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false));
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
@@ -933,22 +1040,6 @@ 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
|
||||
@@ -970,7 +1061,33 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
const renderStringValue = () => {
|
||||
const strValue = String(keyValue.value);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(strValue);
|
||||
|
||||
// 根据查看模式生成显示内容
|
||||
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;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
@@ -1029,8 +1146,31 @@ 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 } = processValueForCurrentView(value);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(value);
|
||||
return { field, value, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1054,7 +1194,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, [field]);
|
||||
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, field);
|
||||
if (res.success) {
|
||||
message.success('删除成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1074,9 +1214,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" {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
|
||||
<Input id="new-hash-field" />
|
||||
</Form.Item>
|
||||
<Form.Item label="值" name="value" rules={[{ required: true }]}>
|
||||
<Input.TextArea id="new-hash-value" rows={4} />
|
||||
</Form.Item>
|
||||
@@ -1167,8 +1307,31 @@ 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 } = processValueForCurrentView(value);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(value);
|
||||
return { index, value, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1314,8 +1477,31 @@ 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 } = processValueForCurrentView(member);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(member);
|
||||
return { index, member, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1428,8 +1614,31 @@ 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 } = processValueForCurrentView(item.member);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(item.member);
|
||||
return { ...item, index, displayMember: displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1570,9 +1779,30 @@ 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 } = processValueForCurrentView(rawFieldsText);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(rawFieldsText);
|
||||
return {
|
||||
index,
|
||||
id: item.id,
|
||||
@@ -1658,7 +1888,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label>ID(可选,默认 *):</label>
|
||||
<Input id="new-stream-id" {...noAutoCapInputProps} placeholder="例如: * 或 1723110000000-0" />
|
||||
<Input id="new-stream-id" placeholder="例如: * 或 1723110000000-0" />
|
||||
</div>
|
||||
<div>
|
||||
<label>字段 JSON:</label>
|
||||
@@ -1820,7 +2050,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: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
|
||||
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: blurToFilter(blur), WebkitBackdropFilter: blurToFilter(blur) }}>
|
||||
{/* Left: Key List */}
|
||||
<div ref={leftPanelRef} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
|
||||
<div style={{ ...workbenchCardStyle, padding: 12 }}>
|
||||
@@ -1833,12 +2063,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</div>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Search
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="搜索 Key"
|
||||
value={searchInput}
|
||||
onChange={handleSearchInputChange}
|
||||
placeholder="搜索 Key (支持 * 通配符)"
|
||||
defaultValue="*"
|
||||
onSearch={handleSearch}
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
@@ -1925,7 +2152,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
>
|
||||
<Editor
|
||||
height="450px"
|
||||
language={formatRedisStringValue(editValue).isJson ? 'json' : 'plaintext'}
|
||||
language={tryFormatJson(editValue).isJson ? 'json' : 'plaintext'}
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={editValue}
|
||||
onChange={(value) => setEditValue(value || '')}
|
||||
@@ -1950,7 +2177,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 {...noAutoCapInputProps} placeholder="key name" />
|
||||
<Input placeholder="key name" />
|
||||
</Form.Item>
|
||||
<Form.Item name="value" label="值" rules={[{ required: true, message: '请输入值' }]}>
|
||||
<Input.TextArea rows={4} placeholder="value" />
|
||||
@@ -1980,7 +2207,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
rules={[{ required: true, message: '请输入新的 Key 名称' }]}
|
||||
extra={renameTargetKey ? `原始 Key:${renameTargetKey}` : undefined}
|
||||
>
|
||||
<Input {...noAutoCapInputProps} placeholder="new:key:name" />
|
||||
<Input placeholder="new:key:name" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
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;
|
||||
@@ -1,133 +0,0 @@
|
||||
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;
|
||||
@@ -1,69 +0,0 @@
|
||||
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;
|
||||
@@ -1,337 +0,0 @@
|
||||
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;
|
||||
@@ -36,20 +36,14 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { SavedConnection, ExternalSQLTreeEntry } from '../types';
|
||||
import { SavedConnection } from '../types';
|
||||
import { getDbIcon } from './DatabaseIcons';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile } from '../../wailsjs/go/app/App';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import FindInDatabaseModal from './FindInDatabaseModal';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
|
||||
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
|
||||
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
|
||||
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -60,7 +54,7 @@ interface TreeNode {
|
||||
children?: TreeNode[];
|
||||
icon?: React.ReactNode;
|
||||
dataRef?: any;
|
||||
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag';
|
||||
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag';
|
||||
}
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
@@ -99,30 +93,10 @@ const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
|
||||
tag: <TagOutlined />,
|
||||
};
|
||||
|
||||
const normalizeMySQLViewDDLForEditing = (viewName: string, 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 `CREATE OR REPLACE VIEW ${viewName} AS\n${normalized};`;
|
||||
}
|
||||
|
||||
return `${normalized};`;
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const savedQueries = useStore(state => state.savedQueries);
|
||||
const externalSQLDirectories = useStore(state => state.externalSQLDirectories);
|
||||
const deleteQuery = useStore(state => state.deleteQuery);
|
||||
const saveExternalSQLDirectory = useStore(state => state.saveExternalSQLDirectory);
|
||||
const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
|
||||
const addConnection = useStore(state => state.addConnection);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
@@ -145,8 +119,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const darkMode = theme === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
|
||||
// Background Helper (Duplicate logic for now, ideally shared)
|
||||
@@ -159,10 +131,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
};
|
||||
const bgMain = getBg('#141414');
|
||||
const overlayTheme = useMemo(
|
||||
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
|
||||
[darkMode, disableLocalBackdropFilter],
|
||||
);
|
||||
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
|
||||
const modalPanelStyle = useMemo(() => ({
|
||||
background: overlayTheme.shellBg,
|
||||
border: overlayTheme.shellBorder,
|
||||
@@ -324,17 +293,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
return;
|
||||
}
|
||||
// Refresh queries for expanded databases
|
||||
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.key === k) return node;
|
||||
if (node.children) {
|
||||
const res = findNode(node.children, k);
|
||||
if (res) return res;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
expandedKeys.forEach(key => {
|
||||
const node = findTreeNodeByKey(treeData, key);
|
||||
const node = findNode(treeData, key);
|
||||
if (node && node.type === 'database') {
|
||||
loadTables(node);
|
||||
}
|
||||
});
|
||||
}, [autoFetchVisible, externalSQLDirectories, savedQueries]);
|
||||
}, [savedQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
setTreeData((prev) => {
|
||||
@@ -423,68 +400,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
};
|
||||
|
||||
const findTreeNodeByKey = (nodes: TreeNode[], targetKey: React.Key): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.key === targetKey) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const child = findTreeNodeByKey(node.children, targetKey);
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const decorateExternalSQLTreeNode = (node: ExternalSQLTreeNode): TreeNode => {
|
||||
const icon = (() => {
|
||||
switch (node.type) {
|
||||
case 'external-sql-root':
|
||||
return <FolderOpenOutlined />;
|
||||
case 'external-sql-directory':
|
||||
return <HddOutlined />;
|
||||
case 'external-sql-folder':
|
||||
return <FolderOutlined />;
|
||||
default:
|
||||
return <FileTextOutlined />;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
...node,
|
||||
icon,
|
||||
children: node.children?.map((child) => decorateExternalSQLTreeNode(child)),
|
||||
};
|
||||
};
|
||||
|
||||
const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => {
|
||||
if (!node) return null;
|
||||
if (node.type === 'database') {
|
||||
return {
|
||||
connectionId: String(node?.dataRef?.id || '').trim(),
|
||||
dbName: String(node?.dataRef?.dbName || '').trim(),
|
||||
dbNodeKey: String(node.key || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
node.type === 'external-sql-root'
|
||||
|| node.type === 'external-sql-directory'
|
||||
|| node.type === 'external-sql-folder'
|
||||
|| node.type === 'external-sql-file'
|
||||
) {
|
||||
return {
|
||||
connectionId: String(node?.dataRef?.connectionId || '').trim(),
|
||||
dbName: String(node?.dataRef?.dbName || '').trim(),
|
||||
dbNodeKey: String(node?.dataRef?.dbNodeKey || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const SIDEBAR_SCHEMA_DB_TYPES = new Set([
|
||||
'postgres',
|
||||
'kingbase',
|
||||
@@ -873,7 +788,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name'])
|
||||
|| getMySQLShowTablesName(row)
|
||||
|| getFirstRowValue(row);
|
||||
const fullName = normalizeSidebarViewName(dialect, dbName, schemaName, viewName);
|
||||
const fullName = buildQualifiedName(schemaName, viewName);
|
||||
if (!fullName || seen.has(fullName)) return;
|
||||
seen.add(fullName);
|
||||
views.push(fullName);
|
||||
@@ -1051,7 +966,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
loadingNodesRef.current.add(loadKey);
|
||||
|
||||
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
|
||||
const dbExternalSQLDirectories = useStore.getState().externalSQLDirectories.filter(directory => directory.connectionId === conn.id && directory.dbName === dbName);
|
||||
|
||||
const queriesNode: TreeNode = {
|
||||
title: '已存查询',
|
||||
@@ -1093,38 +1007,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
});
|
||||
|
||||
const [viewsResult, triggersResult, routinesResult] = await Promise.all([
|
||||
loadViews(conn, conn.dbName),
|
||||
loadDatabaseTriggers(conn, conn.dbName),
|
||||
loadFunctions(conn, conn.dbName),
|
||||
]);
|
||||
const externalSQLDirectoryResults = await Promise.all(
|
||||
dbExternalSQLDirectories.map(async (directory) => {
|
||||
const directoryRes = await ListSQLDirectory(directory.path);
|
||||
if (!directoryRes.success) {
|
||||
message.warning({
|
||||
key: `external-sql-${directory.id}`,
|
||||
content: `SQL 目录读取失败: ${directory.name} (${directoryRes.message})`,
|
||||
});
|
||||
return { id: directory.id, entries: [] as ExternalSQLTreeEntry[] };
|
||||
}
|
||||
return {
|
||||
id: directory.id,
|
||||
entries: Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : [],
|
||||
};
|
||||
}),
|
||||
);
|
||||
const externalSQLTrees = externalSQLDirectoryResults.reduce<Record<string, ExternalSQLTreeEntry[]>>((accumulator, item) => {
|
||||
accumulator[item.id] = item.entries;
|
||||
return accumulator;
|
||||
}, {});
|
||||
const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({
|
||||
dbNodeKey: String(key),
|
||||
connectionId: String(conn.id),
|
||||
dbName: String(conn.dbName),
|
||||
directories: dbExternalSQLDirectories,
|
||||
directoryTrees: externalSQLTrees,
|
||||
}));
|
||||
const [viewsResult, triggersResult, routinesResult] = await Promise.all([
|
||||
loadViews(conn, conn.dbName),
|
||||
loadDatabaseTriggers(conn, conn.dbName),
|
||||
loadFunctions(conn, conn.dbName),
|
||||
]);
|
||||
|
||||
const viewRows: string[] = Array.isArray(viewsResult.views) ? viewsResult.views : [];
|
||||
const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : [];
|
||||
@@ -1308,11 +1195,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry)));
|
||||
triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry)));
|
||||
|
||||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||
const isOracleLike = (dialect === 'oracle' || dialect === 'dm');
|
||||
|
||||
const schemaNodes: TreeNode[] = Array.from(schemaMap.values())
|
||||
.filter((bucket) => !(isOracleLike && !bucket.schemaName))
|
||||
.sort((a, b) => {
|
||||
if (!a.schemaName && !b.schemaName) return 0;
|
||||
if (!a.schemaName) return -1;
|
||||
@@ -1320,8 +1203,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase());
|
||||
})
|
||||
.map((bucket) => {
|
||||
const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`;
|
||||
const schemaTitle = bucket.schemaName || '默认模式';
|
||||
const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`;
|
||||
const schemaTitle = bucket.schemaName || '默认模式';
|
||||
const groupedNodes: TreeNode[] = [
|
||||
buildObjectGroup(schemaNodeKey, 'tables', '表', <TableOutlined />, bucket.tables, { schemaName: bucket.schemaName }),
|
||||
buildObjectGroup(schemaNodeKey, 'views', '视图', <EyeOutlined />, bucket.views, { schemaName: bucket.schemaName }),
|
||||
@@ -1340,7 +1223,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
});
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...schemaNodes]));
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...schemaNodes]));
|
||||
} else {
|
||||
const groupedNodes: TreeNode[] = [
|
||||
buildObjectGroup(key as string, 'tables', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
|
||||
@@ -1349,7 +1232,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
|
||||
];
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...groupedNodes]));
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes]));
|
||||
}
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||||
@@ -1465,8 +1348,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||
} else if (type === 'saved-query') {
|
||||
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||||
} else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') {
|
||||
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||||
} else if (type === 'redis-db') {
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
|
||||
}
|
||||
@@ -1509,7 +1390,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||
else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||||
else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||||
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
|
||||
|
||||
if (node.type === 'table') {
|
||||
@@ -1548,9 +1428,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
savedQueryId: q.id,
|
||||
});
|
||||
return;
|
||||
} else if (node.type === 'external-sql-file') {
|
||||
void openExternalSQLFile(node);
|
||||
return;
|
||||
} else if (node.type === 'redis-db') {
|
||||
const { id, redisDB } = node.dataRef;
|
||||
addTab({
|
||||
@@ -2227,119 +2104,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
};
|
||||
|
||||
const refreshDatabaseNode = async (dbNodeKey: string) => {
|
||||
if (!dbNodeKey) {
|
||||
return;
|
||||
}
|
||||
const dbNode = findTreeNodeByKey(treeData, dbNodeKey);
|
||||
if (dbNode && dbNode.type === 'database') {
|
||||
await loadTables(dbNode);
|
||||
}
|
||||
};
|
||||
|
||||
const openExternalSQLFile = async (fileNode: any) => {
|
||||
const connectionId = String(fileNode?.dataRef?.connectionId || '').trim();
|
||||
const dbName = String(fileNode?.dataRef?.dbName || '').trim();
|
||||
const filePath = String(fileNode?.dataRef?.path || '').trim();
|
||||
const fileName = String(fileNode?.dataRef?.name || fileNode?.title || 'SQL文件').trim() || 'SQL文件';
|
||||
if (!connectionId || !dbName || !filePath) {
|
||||
message.error('SQL 文件上下文不完整,无法打开');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await ReadSQLFile(filePath);
|
||||
if (!res.success) {
|
||||
if (res.message !== '已取消') {
|
||||
message.error('读取 SQL 文件失败: ' + res.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
if (data && typeof data === 'object' && data.isLargeFile) {
|
||||
const conn = connections.find((item) => item.id === connectionId);
|
||||
if (!conn) {
|
||||
message.error('未找到对应的连接配置');
|
||||
return;
|
||||
}
|
||||
startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB);
|
||||
return;
|
||||
}
|
||||
|
||||
addTab({
|
||||
id: buildExternalSQLTabId(connectionId, dbName, filePath),
|
||||
title: fileName,
|
||||
type: 'query',
|
||||
connectionId,
|
||||
dbName,
|
||||
query: String(data || ''),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddExternalSQLDirectory = async (node: any) => {
|
||||
const context = getNodeDatabaseContext(node);
|
||||
if (!context?.connectionId || !context?.dbName || !context?.dbNodeKey) {
|
||||
message.warning('请在具体数据库下添加外部 SQL 目录');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDirectory = externalSQLDirectories.find((item) =>
|
||||
item.connectionId === context.connectionId && item.dbName === context.dbName,
|
||||
)?.path || '';
|
||||
const selection = await SelectSQLDirectory(currentDirectory);
|
||||
if (!selection.success) {
|
||||
if (selection.message !== '已取消') {
|
||||
message.error('选择 SQL 目录失败: ' + selection.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (selection.data && typeof selection.data === 'object') ? selection.data as Record<string, unknown> : {};
|
||||
const path = String(payload.path || '').trim();
|
||||
const name = String(payload.name || '').trim();
|
||||
if (!path) {
|
||||
message.error('未获取到有效的 SQL 目录路径');
|
||||
return;
|
||||
}
|
||||
|
||||
const directoryId = buildExternalSQLDirectoryId(context.connectionId, context.dbName, path);
|
||||
saveExternalSQLDirectory({
|
||||
id: directoryId,
|
||||
name: name || path.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录',
|
||||
path,
|
||||
connectionId: context.connectionId,
|
||||
dbName: context.dbName,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
setExpandedKeys((prev) => Array.from(new Set([...prev, context.dbNodeKey, `${context.dbNodeKey}-external-sql`])));
|
||||
setAutoExpandParent(false);
|
||||
await refreshDatabaseNode(context.dbNodeKey);
|
||||
message.success('外部 SQL 目录已添加');
|
||||
};
|
||||
|
||||
const handleRemoveExternalSQLDirectory = async (node: any) => {
|
||||
const directoryId = String(node?.dataRef?.id || '').trim();
|
||||
const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim();
|
||||
if (!directoryId) {
|
||||
message.error('未找到可移除的 SQL 目录');
|
||||
return;
|
||||
}
|
||||
deleteExternalSQLDirectory(directoryId);
|
||||
await refreshDatabaseNode(dbNodeKey);
|
||||
message.success('外部 SQL 目录已移除');
|
||||
};
|
||||
|
||||
const handleRefreshExternalSQLDirectory = async (node: any) => {
|
||||
const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim();
|
||||
if (!dbNodeKey) {
|
||||
message.warning('当前目录缺少数据库上下文,无法刷新');
|
||||
return;
|
||||
}
|
||||
await refreshDatabaseNode(dbNodeKey);
|
||||
message.success('外部 SQL 目录已刷新');
|
||||
};
|
||||
|
||||
const handleCreateDatabase = async () => {
|
||||
try {
|
||||
const values = await createDbForm.validateFields();
|
||||
@@ -2370,13 +2134,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {
|
||||
return buildRpcConnectionConfig(conn.config, {
|
||||
database: resolveSidebarRuntimeDatabase(
|
||||
conn?.config?.type,
|
||||
conn?.config?.driver,
|
||||
conn?.config?.database,
|
||||
overrideDatabase,
|
||||
clearDatabase,
|
||||
),
|
||||
database: clearDatabase ? '' : ((overrideDatabase ?? conn.config.database) || ''),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2644,11 +2402,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const row = result.data[0] as Record<string, any>;
|
||||
const def = row.view_definition || row.VIEW_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
|
||||
if (def) {
|
||||
if (dialect === 'mysql') {
|
||||
template = `-- 编辑视图 ${viewName}\n${normalizeMySQLViewDDLForEditing(viewName, def)}`;
|
||||
} else {
|
||||
template = `-- 编辑视图 ${viewName}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`;
|
||||
}
|
||||
template = `-- 编辑视图 ${viewName}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3066,10 +2820,51 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
);
|
||||
}, [darkMode, overlayTheme, searchScopes]);
|
||||
|
||||
const parseHostOnlyToken = (value: unknown): string[] => {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
let text = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
||||
if (text.includes('/')) {
|
||||
text = text.split('/')[0];
|
||||
}
|
||||
if (text.includes('?')) {
|
||||
text = text.split('?')[0];
|
||||
}
|
||||
if (text.includes('@')) {
|
||||
text = text.split('@').pop() || '';
|
||||
}
|
||||
return text
|
||||
.split(',')
|
||||
.map((entry) => {
|
||||
const token = entry.trim();
|
||||
if (!token) return '';
|
||||
if (token.startsWith('[')) {
|
||||
const rightBracketIndex = token.indexOf(']');
|
||||
if (rightBracketIndex > 0) {
|
||||
return token.slice(0, rightBracketIndex + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
const colonIndex = token.lastIndexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
return token.slice(0, colonIndex).toLowerCase();
|
||||
}
|
||||
return token.toLowerCase();
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const getConnectionHostSearchText = (node: TreeNode): string => {
|
||||
if (node.type !== 'connection') return '';
|
||||
const config = node.dataRef?.config || {};
|
||||
return resolveConnectionHostTokens(config).join(' ');
|
||||
const hostTokens = [
|
||||
...parseHostOnlyToken(config.host),
|
||||
...(Array.isArray(config.hosts) ? config.hosts.flatMap((entry: string) => parseHostOnlyToken(entry)) : []),
|
||||
...parseHostOnlyToken(config.uri),
|
||||
];
|
||||
const uniqueHosts = Array.from(new Set(hostTokens));
|
||||
return uniqueHosts.join(' ');
|
||||
};
|
||||
|
||||
const getConnectionNameSearchText = (node: TreeNode): string => {
|
||||
@@ -3277,7 +3072,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `redis-cmd-${node.key}-${Date.now()}`,
|
||||
title: '命令 - db0',
|
||||
title: `命令 - ${node.title}`,
|
||||
type: 'redis-command',
|
||||
connectionId: node.key,
|
||||
redisDB: 0
|
||||
@@ -3291,7 +3086,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `redis-monitor-${node.key}-${Date.now()}`,
|
||||
title: '监控 - db0',
|
||||
title: `监控: ${node.title}`,
|
||||
type: 'redis-monitor',
|
||||
connectionId: node.key,
|
||||
redisDB: 0
|
||||
@@ -3553,7 +3348,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`,
|
||||
title: `监控 - db${redisDB}`,
|
||||
title: `监控: ${connections.find(c => c.id === id)?.name || id}`,
|
||||
type: 'redis-monitor',
|
||||
connectionId: id,
|
||||
redisDB: redisDB
|
||||
@@ -3758,7 +3553,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
const tableName = String(node.dataRef?.tableName || '').trim();
|
||||
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
|
||||
const queryTemplate = tableName ? `SELECT * FROM ${tableName};` : 'SELECT * FROM ';
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `新建查询`,
|
||||
@@ -3891,55 +3686,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
];
|
||||
}
|
||||
|
||||
if (node.type === 'external-sql-root') {
|
||||
return [
|
||||
{
|
||||
key: 'add-external-sql-directory',
|
||||
label: '添加 SQL 目录',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => {
|
||||
void handleAddExternalSQLDirectory(node);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (node.type === 'external-sql-directory') {
|
||||
return [
|
||||
{
|
||||
key: 'refresh-external-sql-directory',
|
||||
label: '刷新目录',
|
||||
icon: <ReloadOutlined />,
|
||||
onClick: () => {
|
||||
void handleRefreshExternalSQLDirectory(node);
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'remove-external-sql-directory',
|
||||
label: '移除目录',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
void handleRemoveExternalSQLDirectory(node);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (node.type === 'external-sql-file') {
|
||||
return [
|
||||
{
|
||||
key: 'open-external-sql-file',
|
||||
label: '打开 SQL 文件',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
void openExternalSQLFile(node);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -3965,33 +3711,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
hoverTitle = rawTableName;
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') {
|
||||
hoverTitle = String(node?.dataRef?.path || displayTitle);
|
||||
}
|
||||
|
||||
if (node.type === 'external-sql-root') {
|
||||
return (
|
||||
<span
|
||||
title={hoverTitle}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%' }}
|
||||
>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{statusBadge}
|
||||
{displayTitle}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleAddExternalSQLDirectory(node);
|
||||
}}
|
||||
style={{ paddingInline: 4, height: 20 }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;
|
||||
@@ -4078,7 +3797,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
ref={searchInputRef}
|
||||
placeholder="搜索..."
|
||||
onChange={onSearch}
|
||||
@@ -4270,7 +3988,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={createDbForm} layout="vertical">
|
||||
<Form.Item name="name" label="数据库名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input {...noAutoCapInputProps} />
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{/* Charset option could be added here */}
|
||||
</Form>
|
||||
@@ -4288,7 +4006,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={renameDbForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新数据库名称" rules={[{ required: true, message: '请输入新数据库名称' }]}>
|
||||
<Input {...noAutoCapInputProps} />
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -4305,7 +4023,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={renameTableForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新表名" rules={[{ required: true, message: '请输入新表名' }]}>
|
||||
<Input {...noAutoCapInputProps} />
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -4322,7 +4040,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={renameViewForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新视图名" rules={[{ required: true, message: '请输入新视图名' }]}>
|
||||
<Input {...noAutoCapInputProps} />
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -17,7 +17,24 @@ import TriggerViewer from './TriggerViewer';
|
||||
import DefinitionViewer from './DefinitionViewer';
|
||||
import TableOverview from './TableOverview';
|
||||
import type { TabData } from '../types';
|
||||
import { buildTabDisplayTitle } from '../utils/tabDisplay';
|
||||
|
||||
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}`;
|
||||
};
|
||||
|
||||
type SortableTabLabelProps = {
|
||||
displayTitle: string;
|
||||
@@ -33,7 +50,7 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
|
||||
<span
|
||||
className="tab-dnd-label"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
title={displayTitle}
|
||||
title="拖拽调整标签顺序"
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
@@ -181,8 +198,8 @@ const TabManager: React.FC = () => {
|
||||
);
|
||||
|
||||
const items = useMemo(() => tabs.map((tab, index) => {
|
||||
const connection = connections.find((conn) => conn.id === tab.connectionId);
|
||||
const displayTitle = buildTabDisplayTitle(tab, connection);
|
||||
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
|
||||
const displayTitle = buildTabDisplayTitle(tab, connectionName);
|
||||
const tabIsActive = tab.id === activeTabId;
|
||||
let content;
|
||||
if (tab.type === 'query') {
|
||||
@@ -320,10 +337,6 @@ 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,9 +9,8 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg
|
||||
import { useStore } from '../store';
|
||||
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
|
||||
import { buildAlterTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
|
||||
import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
|
||||
interface EditableColumn extends ColumnDefinition {
|
||||
_key: string;
|
||||
@@ -547,7 +546,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
key: 'name',
|
||||
width: 180,
|
||||
render: (text: string, record: EditableColumn) => readOnly ? text : (
|
||||
<Input {...noAutoCapInputProps} value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
|
||||
<Input value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -1396,19 +1395,6 @@ ${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;
|
||||
@@ -2156,24 +2142,6 @@ END;`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshDesigner = () => {
|
||||
if (!hasUnsavedDraftChanges) {
|
||||
void fetchData();
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '存在未保存的字段变更',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '刷新后会丢失当前尚未保存的字段调整,是否仍要刷新并覆盖当前草稿?',
|
||||
okText: '仍然刷新',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await fetchData();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecuteSave = async () => {
|
||||
const result = await executeSchemaStatements(previewSql);
|
||||
if (!result.ok) {
|
||||
@@ -2524,7 +2492,6 @@ END;`;
|
||||
{isNewTable && (
|
||||
<>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="请输入表名"
|
||||
value={newTableName}
|
||||
onChange={e => setNewTableName(e.target.value)}
|
||||
@@ -2550,7 +2517,7 @@ END;`;
|
||||
</>
|
||||
)}
|
||||
{!readOnly && <Button size="small" icon={<SaveOutlined />} type="primary" onClick={generateDDL}>保存</Button>}
|
||||
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={handleRefreshDesigner}>刷新</Button>}
|
||||
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={fetchData}>刷新</Button>}
|
||||
{!isNewTable && !readOnly && supportsTableCommentOps() && (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={openTableCommentModal}>表备注</Button>
|
||||
)}
|
||||
@@ -2838,7 +2805,6 @@ END;`;
|
||||
已选择字段:{selectedColumns.length}
|
||||
</div>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="请输入目标表名"
|
||||
value={copyTableName}
|
||||
onChange={e => setCopyTableName(e.target.value)}
|
||||
@@ -2899,7 +2865,6 @@ 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 }))}
|
||||
@@ -2969,7 +2934,6 @@ 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 }))}
|
||||
@@ -2985,7 +2949,6 @@ END;`;
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="参考表(支持 db.table)"
|
||||
value={foreignKeyForm.refTableName}
|
||||
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))}
|
||||
@@ -3014,24 +2977,10 @@ END;`;
|
||||
okText="执行"
|
||||
cancelText="取消"
|
||||
>
|
||||
<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 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>
|
||||
<p style={{ marginTop: 10, color: '#faad14' }}>请仔细检查 SQL,执行后不可撤销。</p>
|
||||
</Modal>
|
||||
|
||||
@@ -4,11 +4,8 @@ import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, D
|
||||
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;
|
||||
@@ -57,23 +54,10 @@ 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 `
|
||||
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`;
|
||||
return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'vastbase':
|
||||
@@ -168,11 +152,6 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
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;
|
||||
@@ -186,10 +165,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
useSSH: connection.config.useSSH || false,
|
||||
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
|
||||
};
|
||||
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const dialect = getMetadataDialect(connection.config.type, connection.config.driver);
|
||||
const sql = buildTableStatusSQL(dialect, 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(metadataDialect, res.data));
|
||||
setTables(parseTableStats(dialect, res.data));
|
||||
} else {
|
||||
message.error('获取表信息失败: ' + (res.message || '未知错误'));
|
||||
}
|
||||
@@ -198,14 +178,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connection, metadataDialect, tab.dbName]);
|
||||
}, [connection, tab.dbName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
return;
|
||||
}
|
||||
void loadData();
|
||||
}, [autoFetchVisible, loadData]);
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const sortedFiltered = useMemo(() => {
|
||||
let list = [...tables];
|
||||
@@ -349,7 +324,6 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
title: '重命名表',
|
||||
content: (
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
defaultValue={tableName}
|
||||
onChange={e => { newName = e.target.value; }}
|
||||
placeholder="输入新表名"
|
||||
@@ -423,7 +397,6 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="搜索表名或注释..."
|
||||
prefix={<SearchOutlined style={{ color: textMuted }} />}
|
||||
value={searchText}
|
||||
@@ -491,7 +464,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||
query: `SELECT * FROM ${t.name};`,
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
@@ -577,7 +550,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||
query: `SELECT * FROM ${t.name};`,
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
|
||||
@@ -8,7 +8,6 @@ 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];
|
||||
@@ -28,7 +27,6 @@ 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) {
|
||||
@@ -48,7 +46,7 @@ const MemoizedMarkdown = React.memo(({
|
||||
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
||||
{normalizedContent}
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
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,9 +25,4 @@ 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,10 +1,7 @@
|
||||
import { resolveTextInputSafeBackdropFilter } from '../utils/appearance';
|
||||
|
||||
type RedisWorkbenchThemeInput = {
|
||||
darkMode: boolean;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
disableBackdropFilter?: boolean;
|
||||
};
|
||||
|
||||
type RedisWorkbenchTheme = {
|
||||
@@ -46,15 +43,10 @@ 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;
|
||||
@@ -92,7 +84,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,
|
||||
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,7 +122,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,
|
||||
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAlterTablePreviewSql,
|
||||
hasAlterTableDraftChanges,
|
||||
type BuildAlterTablePreviewInput,
|
||||
type EditableColumnSnapshot,
|
||||
} from './tableDesignerSchemaSql';
|
||||
@@ -30,18 +29,6 @@ const buildInput = (overrides: Partial<BuildAlterTablePreviewInput>): BuildAlter
|
||||
});
|
||||
|
||||
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' }));
|
||||
|
||||
@@ -64,16 +51,4 @@ describe('tableDesignerSchemaSql', () => {
|
||||
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`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,21 +140,14 @@ const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string =
|
||||
return;
|
||||
}
|
||||
|
||||
const definitionChanged =
|
||||
if (
|
||||
curr.name !== orig.name ||
|
||||
curr.type !== orig.type ||
|
||||
curr.nullable !== orig.nullable ||
|
||||
curr.default !== orig.default ||
|
||||
(curr.comment || '') !== (orig.comment || '') ||
|
||||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement);
|
||||
|
||||
if (curr.name !== orig.name) {
|
||||
alters.push(
|
||||
`CHANGE COLUMN ${quoteIdentifierPart(orig.name, 'mysql')} ${colDef} ${positionSql}`.trim(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (definitionChanged) {
|
||||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
|
||||
) {
|
||||
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim());
|
||||
}
|
||||
});
|
||||
@@ -260,6 +253,3 @@ export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): s
|
||||
}
|
||||
return buildMySqlAlterPreviewSql({ ...input, dbType });
|
||||
};
|
||||
|
||||
export const hasAlterTableDraftChanges = (input: BuildAlterTablePreviewInput): boolean =>
|
||||
buildAlterTablePreviewSql(input).trim().length > 0;
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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 连接数组',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -127,27 +127,11 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
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, 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 不支持恢复包导出' }),
|
||||
ImportConfigFile: async () => ({ success: false }),
|
||||
ExportData: async () => ({ success: false }),
|
||||
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
|
||||
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
|
||||
|
||||
@@ -91,100 +91,4 @@ describe('store appearance persistence', () => {
|
||||
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, GlobalProxyConfig, ExternalSQLDirectory } from './types';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem, GlobalProxyConfig } from './types';
|
||||
import {
|
||||
ShortcutAction,
|
||||
ShortcutBinding,
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
cloneShortcutOptions,
|
||||
sanitizeShortcutOptions,
|
||||
} from './utils/shortcuts';
|
||||
import { buildExternalSQLDirectoryId } from './utils/externalSqlTree';
|
||||
import { toPersistedGlobalProxy } from './utils/globalProxyDraft';
|
||||
import {
|
||||
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||||
@@ -431,7 +430,6 @@ interface AppState {
|
||||
activeTabId: string | null;
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
externalSQLDirectories: ExternalSQLDirectory[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: AppearanceSettings;
|
||||
uiScale: number;
|
||||
@@ -490,8 +488,6 @@ 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<AppearanceSettings>) => void;
|
||||
@@ -557,57 +553,6 @@ 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' } => {
|
||||
@@ -836,7 +781,6 @@ export const useStore = create<AppState>()(
|
||||
activeTabId: null,
|
||||
activeContext: null,
|
||||
savedQueries: [],
|
||||
externalSQLDirectories: [],
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
uiScale: DEFAULT_UI_SCALE,
|
||||
@@ -1051,43 +995,6 @@ 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) }),
|
||||
@@ -1335,20 +1242,19 @@ export const useStore = create<AppState>()(
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||
const nextState: Partial<AppState> = { ...state };
|
||||
nextState.connections = sanitizeConnections(state.connections);
|
||||
nextState.connections = [];
|
||||
if (version < 5) {
|
||||
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
|
||||
} else {
|
||||
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);
|
||||
nextState.fontSize = sanitizeFontSize(state.fontSize);
|
||||
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
|
||||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
|
||||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy, { allowPassword: false });
|
||||
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
|
||||
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
|
||||
nextState.shortcutOptions = sanitizeShortcutOptions(state.shortcutOptions);
|
||||
@@ -1375,16 +1281,15 @@ export const useStore = create<AppState>()(
|
||||
return {
|
||||
...currentState,
|
||||
...state,
|
||||
connections: sanitizeConnections(state.connections),
|
||||
connections: currentState.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),
|
||||
fontSize: sanitizeFontSize(state.fontSize),
|
||||
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
|
||||
globalProxy: sanitizeGlobalProxy(state.globalProxy),
|
||||
globalProxy: sanitizeGlobalProxy(state.globalProxy, { allowPassword: false }),
|
||||
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
|
||||
tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders),
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
|
||||
@@ -1404,40 +1309,30 @@ export const useStore = create<AppState>()(
|
||||
aiChatSessions: [],
|
||||
};
|
||||
},
|
||||
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;
|
||||
}
|
||||
partialize: (state) => ({
|
||||
connectionTags: state.connectionTags,
|
||||
savedQueries: state.savedQueries,
|
||||
theme: state.theme,
|
||||
appearance: state.appearance,
|
||||
uiScale: state.uiScale,
|
||||
fontSize: state.fontSize,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
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,
|
||||
|
||||
// AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage
|
||||
return partialState as AppState;
|
||||
}, // Don't persist logs
|
||||
}), // Don't persist logs
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -166,22 +166,6 @@ 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;
|
||||
@@ -278,70 +262,4 @@ export interface AISafetyResult {
|
||||
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 compact and free of the AI entry', () => {
|
||||
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'settings']);
|
||||
it('keeps the sidebar utility group free of the AI entry', () => {
|
||||
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']);
|
||||
});
|
||||
|
||||
it('anchors the AI entry to the content edge', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'settings'] as const;
|
||||
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const;
|
||||
|
||||
export type AIEntryPlacement = 'content-edge';
|
||||
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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```',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
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('未知');
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
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,12 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
blurToFilter,
|
||||
normalizeBlurForPlatform,
|
||||
normalizeOpacityForPlatform,
|
||||
resolveAppearanceValues,
|
||||
resolveTextInputSafeBackdropFilter,
|
||||
} from './appearance';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
|
||||
|
||||
describe('appearance helpers', () => {
|
||||
it('falls back to opaque non-blurred appearance when disabled', () => {
|
||||
@@ -26,10 +20,4 @@ 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,16 +80,3 @@ 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;
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,186 +0,0 @@
|
||||
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: '导出失败',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
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();
|
||||
@@ -1,57 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
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 扩展驱动。';
|
||||
@@ -1,67 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
export const noAutoCapInputProps = {
|
||||
autoCapitalize: 'none' as const,
|
||||
autoCorrect: 'off' as const,
|
||||
spellCheck: false,
|
||||
};
|
||||
|
||||
export const applyNoAutoCapAttributes = (element: Element) => {
|
||||
const tagName = String((element as Element | null)?.tagName || '').toUpperCase();
|
||||
if (tagName !== 'INPUT' && tagName !== 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
element.setAttribute('autocapitalize', 'none');
|
||||
element.setAttribute('autocorrect', 'off');
|
||||
element.setAttribute('spellcheck', 'false');
|
||||
};
|
||||
|
||||
export const applyNoAutoCapAttributesWithin = (root: ParentNode | null | undefined) => {
|
||||
if (!root || typeof root.querySelectorAll !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
root.querySelectorAll('input, textarea').forEach((element) => {
|
||||
applyNoAutoCapAttributes(element);
|
||||
});
|
||||
};
|
||||
@@ -1,11 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasLegacyMigratableSensitiveItems,
|
||||
readLegacyPersistedSecrets,
|
||||
stripLegacyPersistedConnectionById,
|
||||
stripLegacyPersistedSecrets,
|
||||
} from './legacyConnectionStorage';
|
||||
import { readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './legacyConnectionStorage';
|
||||
|
||||
describe('legacy connection storage', () => {
|
||||
it('extracts legacy saved connections and global proxy password from lite-db-storage', () => {
|
||||
@@ -42,7 +37,7 @@ describe('legacy connection storage', () => {
|
||||
expect(result.globalProxy?.password).toBe('proxy-secret');
|
||||
});
|
||||
|
||||
it('clears legacy connection and proxy source data after cleanup', () => {
|
||||
it('strips persisted connection secrets but keeps secretless proxy metadata', () => {
|
||||
const payload = JSON.stringify({
|
||||
state: {
|
||||
connections: [
|
||||
@@ -74,110 +69,7 @@ describe('legacy connection storage', () => {
|
||||
const parsed = JSON.parse(sanitized);
|
||||
|
||||
expect(parsed.state.connections).toEqual([]);
|
||||
expect(parsed.state.globalProxy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats a meaningful legacy global proxy as migratable even when it has no password', () => {
|
||||
const payload = JSON.stringify({
|
||||
state: {
|
||||
globalProxy: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasLegacyMigratableSensitiveItems(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects migratable sensitive items before cleanup and clears the signal after cleanup', () => {
|
||||
const payload = JSON.stringify({
|
||||
state: {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'Primary',
|
||||
config: {
|
||||
id: 'conn-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
globalProxy: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
password: 'proxy-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasLegacyMigratableSensitiveItems(payload)).toBe(true);
|
||||
expect(hasLegacyMigratableSensitiveItems(stripLegacyPersistedSecrets(payload))).toBe(false);
|
||||
});
|
||||
|
||||
it('removes only the repaired legacy connection while preserving other source data', () => {
|
||||
const payload = JSON.stringify({
|
||||
state: {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'Primary',
|
||||
config: {
|
||||
id: 'conn-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'conn-2',
|
||||
name: 'Replica',
|
||||
config: {
|
||||
id: 'conn-2',
|
||||
type: 'mysql',
|
||||
host: 'replica.local',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
password: 'replica-secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
globalProxy: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
password: 'proxy-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sanitized = stripLegacyPersistedConnectionById(payload, 'conn-1');
|
||||
const parsed = JSON.parse(sanitized);
|
||||
|
||||
expect(parsed.state.connections).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'conn-2',
|
||||
config: expect.objectContaining({
|
||||
password: 'replica-secret',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(parsed.state.globalProxy).toEqual(expect.objectContaining({
|
||||
password: 'proxy-secret',
|
||||
}));
|
||||
expect(parsed.state.globalProxy.password).toBeUndefined();
|
||||
expect(parsed.state.globalProxy.hasPassword).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,11 +79,6 @@ export function readLegacyPersistedSecrets(payload: string | null | undefined):
|
||||
};
|
||||
}
|
||||
|
||||
export function hasLegacyMigratableSensitiveItems(payload: string | null | undefined): boolean {
|
||||
const legacy = readLegacyPersistedSecrets(payload);
|
||||
return legacy.connections.length > 0 || legacy.globalProxy !== null;
|
||||
}
|
||||
|
||||
export function stripLegacyPersistedSecrets(payload: string | null | undefined): string {
|
||||
if (!payload || typeof payload !== 'string') {
|
||||
return '';
|
||||
@@ -101,42 +96,15 @@ export function stripLegacyPersistedSecrets(payload: string | null | undefined):
|
||||
: parsed;
|
||||
state.connections = [];
|
||||
|
||||
if (state.globalProxy !== undefined) {
|
||||
delete state.globalProxy;
|
||||
}
|
||||
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
export function stripLegacyPersistedConnectionById(
|
||||
payload: string | null | undefined,
|
||||
connectionId: string,
|
||||
): string {
|
||||
if (!payload || typeof payload !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(payload) as Record<string, unknown>;
|
||||
} catch {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const state = parsed.state && typeof parsed.state === 'object'
|
||||
? parsed.state as Record<string, unknown>
|
||||
: parsed;
|
||||
const targetId = toTrimmedString(connectionId);
|
||||
if (!targetId || !Array.isArray(state.connections)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
state.connections = state.connections.filter((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return true;
|
||||
if (state.globalProxy && typeof state.globalProxy === 'object') {
|
||||
const proxy = { ...(state.globalProxy as Record<string, unknown>) };
|
||||
const password = toTrimmedString(proxy.password);
|
||||
delete proxy.password;
|
||||
if (password !== '') {
|
||||
proxy.hasPassword = true;
|
||||
}
|
||||
return toTrimmedString((item as { id?: unknown }).id) !== targetId;
|
||||
});
|
||||
state.globalProxy = proxy;
|
||||
}
|
||||
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { shouldEnableMacWindowDiagnostics } from './macWindowDiagnostics';
|
||||
|
||||
describe('macWindowDiagnostics', () => {
|
||||
it('stays disabled outside macOS runtime', () => {
|
||||
expect(shouldEnableMacWindowDiagnostics(false, true, 'true')).toBe(false);
|
||||
});
|
||||
|
||||
it('stays disabled for production builds on macOS', () => {
|
||||
expect(shouldEnableMacWindowDiagnostics(true, false, 'true')).toBe(false);
|
||||
});
|
||||
|
||||
it('stays disabled by default for macOS development builds', () => {
|
||||
expect(shouldEnableMacWindowDiagnostics(true, true)).toBe(false);
|
||||
expect(shouldEnableMacWindowDiagnostics(true, true, '')).toBe(false);
|
||||
expect(shouldEnableMacWindowDiagnostics(true, true, '0')).toBe(false);
|
||||
});
|
||||
|
||||
it('enables diagnostics only when explicitly opted in on macOS development builds', () => {
|
||||
expect(shouldEnableMacWindowDiagnostics(true, true, '1')).toBe(true);
|
||||
expect(shouldEnableMacWindowDiagnostics(true, true, 'true')).toBe(true);
|
||||
expect(shouldEnableMacWindowDiagnostics(true, true, 'yes')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
const isTruthyFlag = (value: string | undefined): boolean => {
|
||||
switch (String(value || '').trim().toLowerCase()) {
|
||||
case '1':
|
||||
case 'true':
|
||||
case 'yes':
|
||||
case 'on':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldEnableMacWindowDiagnostics = (
|
||||
isMacRuntime: boolean,
|
||||
isDevBuild: boolean,
|
||||
envValue?: string,
|
||||
): boolean => {
|
||||
if (!isMacRuntime || !isDevBuild) {
|
||||
return false;
|
||||
}
|
||||
return isTruthyFlag(envValue);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { convertMongoShellToJsonCommand } from './mongodb';
|
||||
|
||||
describe('convertMongoShellToJsonCommand', () => {
|
||||
it('converts show dbs shell shortcut to listDatabases command', () => {
|
||||
expect(convertMongoShellToJsonCommand('show dbs;')).toEqual({
|
||||
recognized: true,
|
||||
command: JSON.stringify({ listDatabases: 1, nameOnly: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it('converts show collections shell shortcut to listCollections command', () => {
|
||||
expect(convertMongoShellToJsonCommand(' show collections ')).toEqual({
|
||||
recognized: true,
|
||||
command: JSON.stringify({ listCollections: 1, filter: {}, nameOnly: true }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -752,42 +752,10 @@ const buildMongoDeleteCommand = (
|
||||
return JSON.stringify(command);
|
||||
};
|
||||
|
||||
const convertMongoShellShortcutCommand = (raw: string): ShellConvertResult | null => {
|
||||
const normalized = String(raw || '')
|
||||
.replace(/[;;]+\s*$/, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized === 'show dbs' || normalized === 'show databases') {
|
||||
return {
|
||||
recognized: true,
|
||||
command: JSON.stringify({ listDatabases: 1, nameOnly: true }),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized === 'show collections' || normalized === 'show tables') {
|
||||
return {
|
||||
recognized: true,
|
||||
command: JSON.stringify({ listCollections: 1, filter: {}, nameOnly: true }),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const convertMongoShellToJsonCommand = (raw: string): ShellConvertResult => {
|
||||
let input = String(raw || '').trim();
|
||||
input = input.replace(/^[\s]*(\/\/[^\n]*\n)+/g, '').trim();
|
||||
input = input.replace(/[;;]+\s*$/, '');
|
||||
const shortcut = convertMongoShellShortcutCommand(input);
|
||||
if (shortcut) {
|
||||
return shortcut;
|
||||
}
|
||||
if (!/^db\./i.test(input)) {
|
||||
return { recognized: false };
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTableSelectQuery } from './objectQueryTemplates';
|
||||
|
||||
describe('buildTableSelectQuery', () => {
|
||||
it('quotes uppercase postgres table names in new query templates', () => {
|
||||
expect(buildTableSelectQuery('postgres', 'public.MyTable')).toBe('SELECT * FROM public."MyTable";');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import { quoteQualifiedIdent } from './sql';
|
||||
|
||||
export const buildTableSelectQuery = (dbType: string, tableName: string): string => {
|
||||
const normalizedTableName = String(tableName || '').trim();
|
||||
if (!normalizedTableName) {
|
||||
return 'SELECT * FROM ';
|
||||
}
|
||||
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)};`;
|
||||
};
|
||||
@@ -18,9 +18,4 @@ describe('buildOverlayWorkbenchTheme', () => {
|
||||
expect(lightTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/);
|
||||
expect(lightTheme.iconColor).toBe('#1677ff');
|
||||
});
|
||||
|
||||
it('can disable shell blur for macOS text-entry compatibility', () => {
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true, { disableBackdropFilter: true });
|
||||
expect(darkTheme.shellBackdropFilter).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { resolveTextInputSafeBackdropFilter } from './appearance';
|
||||
|
||||
type OverlayWorkbenchTheme = {
|
||||
isDark: boolean;
|
||||
shellBg: string;
|
||||
@@ -18,22 +16,14 @@ type OverlayWorkbenchTheme = {
|
||||
divider: string;
|
||||
};
|
||||
|
||||
export const buildOverlayWorkbenchTheme = (
|
||||
darkMode: boolean,
|
||||
options?: { disableBackdropFilter?: boolean },
|
||||
): OverlayWorkbenchTheme => {
|
||||
const shellBackdropFilter = resolveTextInputSafeBackdropFilter(
|
||||
darkMode ? 'blur(18px)' : 'none',
|
||||
options?.disableBackdropFilter ?? false,
|
||||
);
|
||||
|
||||
export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchTheme => {
|
||||
if (darkMode) {
|
||||
return {
|
||||
isDark: true,
|
||||
shellBg: 'linear-gradient(180deg, rgba(15, 15, 17, 0.96) 0%, rgba(11, 11, 13, 0.98) 100%)',
|
||||
shellBorder: '1px solid rgba(255,255,255,0.08)',
|
||||
shellShadow: '0 24px 56px rgba(0,0,0,0.34)',
|
||||
shellBackdropFilter,
|
||||
shellBackdropFilter: 'blur(18px)',
|
||||
sectionBg: 'rgba(255,255,255,0.03)',
|
||||
sectionBorder: '1px solid rgba(255,255,255,0.08)',
|
||||
mutedText: 'rgba(255,255,255,0.5)',
|
||||
@@ -52,7 +42,7 @@ export const buildOverlayWorkbenchTheme = (
|
||||
shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
shellBorder: '1px solid rgba(16,24,40,0.08)',
|
||||
shellShadow: '0 18px 42px rgba(15,23,42,0.12)',
|
||||
shellBackdropFilter,
|
||||
shellBackdropFilter: 'none',
|
||||
sectionBg: 'rgba(255,255,255,0.84)',
|
||||
sectionBorder: '1px solid rgba(16,24,40,0.08)',
|
||||
mutedText: 'rgba(16,24,40,0.55)',
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput } from './redisSearchPattern';
|
||||
|
||||
describe('normalizeRedisSearchInput', () => {
|
||||
it('returns wildcard for empty input', () => {
|
||||
expect(normalizeRedisSearchInput('')).toEqual({
|
||||
keyword: '',
|
||||
pattern: '*',
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps plain keywords with wildcard for contains matching', () => {
|
||||
expect(normalizeRedisSearchInput('order')).toEqual({
|
||||
keyword: 'order',
|
||||
pattern: '*[oO][rR][dD][eE][rR]*',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds ascii case-insensitive patterns for letter keywords', () => {
|
||||
expect(normalizeRedisSearchInput('agent')).toEqual({
|
||||
keyword: 'agent',
|
||||
pattern: '*[aA][gG][eE][nN][tT]*',
|
||||
});
|
||||
});
|
||||
|
||||
it('escapes redis glob special characters as literals', () => {
|
||||
expect(normalizeRedisSearchInput('user:*:[id]?')).toEqual({
|
||||
keyword: 'user:*:[id]?',
|
||||
pattern: '*[uU][sS][eE][rR]:\\*:\\[[iI][dD]\\]\\?*',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks empty draft changes for immediate reset search', () => {
|
||||
expect(normalizeRedisSearchDraftChange('')).toEqual({
|
||||
keyword: '',
|
||||
pattern: '*',
|
||||
shouldSearchImmediately: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
const REDIS_GLOB_SPECIAL_CHARS = /([*?\[\]\\])/g;
|
||||
const ASCII_LETTER = /^[A-Za-z]$/;
|
||||
|
||||
const escapeRedisGlobLiteral = (value: string): string => {
|
||||
return value.replace(REDIS_GLOB_SPECIAL_CHARS, '\\$1');
|
||||
};
|
||||
|
||||
const toCaseInsensitiveRedisGlobLiteral = (value: string): string => {
|
||||
return Array.from(value).map((char) => {
|
||||
if (!ASCII_LETTER.test(char)) {
|
||||
return escapeRedisGlobLiteral(char);
|
||||
}
|
||||
|
||||
const lower = char.toLowerCase();
|
||||
const upper = char.toUpperCase();
|
||||
return `[${lower}${upper}]`;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
export const normalizeRedisSearchInput = (rawValue: string): { keyword: string; pattern: string } => {
|
||||
const keyword = String(rawValue || '').trim();
|
||||
if (!keyword) {
|
||||
return { keyword: '', pattern: '*' };
|
||||
}
|
||||
return {
|
||||
keyword,
|
||||
pattern: `*${toCaseInsensitiveRedisGlobLiteral(keyword)}*`,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeRedisSearchDraftChange = (rawValue: string): {
|
||||
keyword: string;
|
||||
pattern: string;
|
||||
shouldSearchImmediately: boolean;
|
||||
} => {
|
||||
const normalized = normalizeRedisSearchInput(rawValue);
|
||||
return {
|
||||
...normalized,
|
||||
shouldSearchImmediately: normalized.keyword === '',
|
||||
};
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { decodeRedisUtf8Value, formatRedisStringValue } from './redisValueDisplay';
|
||||
|
||||
const toRedisByteString = (text: string): string => (
|
||||
Array.from(new TextEncoder().encode(text), (byte) => String.fromCharCode(byte)).join('')
|
||||
);
|
||||
|
||||
describe('redisValueDisplay', () => {
|
||||
it('keeps already decoded unicode text in utf8 mode', () => {
|
||||
expect(decodeRedisUtf8Value('中文内容')).toBe('中文内容');
|
||||
});
|
||||
|
||||
it('decodes utf8 byte strings in auto mode', () => {
|
||||
expect(formatRedisStringValue(toRedisByteString('中文内容'))).toMatchObject({
|
||||
displayValue: '中文内容',
|
||||
isBinary: false,
|
||||
isJson: false,
|
||||
encoding: 'UTF-8',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to hex for obvious binary values', () => {
|
||||
expect(formatRedisStringValue('\u0000\u0001\u0002abc')).toMatchObject({
|
||||
isBinary: true,
|
||||
encoding: 'HEX',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
const hasDecodedUnicodeText = (value: string): boolean => {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) > 0xFF) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const toByteArray = (value: string): Uint8Array => {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const decodeUtf8Bytes = (value: string): string => (
|
||||
new TextDecoder('utf-8', { fatal: false }).decode(toByteArray(value))
|
||||
);
|
||||
|
||||
const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => {
|
||||
if (!value || value.length === 0) {
|
||||
return { displayValue: '', encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
|
||||
if (hasDecodedUnicodeText(value)) {
|
||||
return { displayValue: value, 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++;
|
||||
}
|
||||
}
|
||||
|
||||
if (nullCount / sampleSize > 0.3) {
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
}
|
||||
|
||||
if (highByteCount === 0 && printableCount / sampleSize > 0.7) {
|
||||
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
|
||||
if (highByteCount > 0) {
|
||||
try {
|
||||
const decoded = decodeUtf8Bytes(value);
|
||||
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)) {
|
||||
validChars++;
|
||||
}
|
||||
}
|
||||
|
||||
const totalChecked = Math.max(1, Math.min(decoded.length, 200));
|
||||
if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) {
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
}
|
||||
|
||||
if (validChars / totalChecked > 0.5) {
|
||||
return { displayValue: decoded, encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
} catch {
|
||||
// ignore decode failure
|
||||
}
|
||||
}
|
||||
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
export 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.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;
|
||||
};
|
||||
|
||||
export const decodeRedisUtf8Value = (value: string): string => {
|
||||
if (!value || value.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (hasDecodedUnicodeText(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) > 0x7F) {
|
||||
return decodeUtf8Bytes(value);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatRedisStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding: string } => {
|
||||
const { displayValue, encoding, needsHex } = tryDecodeValue(value);
|
||||
if (needsHex) {
|
||||
return { displayValue, isBinary: true, isJson: false, encoding };
|
||||
}
|
||||
|
||||
const { isJson, formatted } = tryFormatJson(displayValue);
|
||||
return { displayValue: formatted, isBinary: false, isJson, encoding };
|
||||
};
|
||||
@@ -1,698 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
|
||||
import {
|
||||
bootstrapSecureConfig,
|
||||
finalizeSecurityUpdateStatus,
|
||||
mergeSecurityUpdateStatusWithLegacySource,
|
||||
startSecurityUpdateFromBootstrap,
|
||||
} from './secureConfigBootstrap';
|
||||
import { stripLegacyPersistedConnectionById } from './legacyConnectionStorage';
|
||||
|
||||
const legacyPayload = JSON.stringify({
|
||||
state: {
|
||||
connections: [
|
||||
{
|
||||
id: 'legacy-1',
|
||||
name: 'Legacy',
|
||||
config: {
|
||||
id: 'legacy-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
globalProxy: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
password: 'proxy-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createMemoryStorage = () => {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
data.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
data.delete(key);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createBaseArgs = (storage = createMemoryStorage()) => {
|
||||
const replaceConnections = vi.fn();
|
||||
const replaceGlobalProxy = vi.fn();
|
||||
|
||||
storage.setItem(LEGACY_PERSIST_KEY, legacyPayload);
|
||||
|
||||
return {
|
||||
storage,
|
||||
replaceConnections,
|
||||
replaceGlobalProxy,
|
||||
};
|
||||
};
|
||||
|
||||
describe('secureConfigBootstrap', () => {
|
||||
it('builds legacy pending summary and issue list before the first round starts', async () => {
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await bootstrapSecureConfig({
|
||||
...args,
|
||||
backend: {
|
||||
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
|
||||
overallStatus: 'not_detected',
|
||||
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status.overallStatus).toBe('pending');
|
||||
expect(result.status.summary).toEqual({
|
||||
total: 2,
|
||||
updated: 0,
|
||||
pending: 2,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
});
|
||||
expect(result.status.issues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
scope: 'connection',
|
||||
refId: 'legacy-1',
|
||||
action: 'open_connection',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: 'global_proxy',
|
||||
action: 'open_proxy_settings',
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it('shows intro when legacy sensitive items exist and backend status is pending', async () => {
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await bootstrapSecureConfig({
|
||||
...args,
|
||||
backend: {
|
||||
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
|
||||
overallStatus: 'pending',
|
||||
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status.overallStatus).toBe('pending');
|
||||
expect(result.shouldShowIntro).toBe(true);
|
||||
expect(result.shouldShowBanner).toBe(false);
|
||||
expect(args.replaceConnections).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'legacy-1' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps banner flow without intro when backend status is postponed', async () => {
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await bootstrapSecureConfig({
|
||||
...args,
|
||||
backend: {
|
||||
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
|
||||
overallStatus: 'postponed',
|
||||
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.shouldShowIntro).toBe(false);
|
||||
expect(result.shouldShowBanner).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps legacy pending summary and issues when a pre-start round is postponed', async () => {
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await bootstrapSecureConfig({
|
||||
...args,
|
||||
backend: {
|
||||
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
|
||||
overallStatus: 'postponed',
|
||||
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status.overallStatus).toBe('postponed');
|
||||
expect(result.status.summary.total).toBe(2);
|
||||
expect(result.status.summary.pending).toBe(2);
|
||||
expect(result.status.issues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ scope: 'connection', refId: 'legacy-1' }),
|
||||
expect.objectContaining({ scope: 'global_proxy' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('merges backend pending issues with legacy source items before the first round starts', async () => {
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await bootstrapSecureConfig({
|
||||
...args,
|
||||
backend: {
|
||||
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
|
||||
overallStatus: 'pending',
|
||||
summary: { total: 1, updated: 0, pending: 1, skipped: 0, failed: 0 },
|
||||
issues: [
|
||||
{
|
||||
id: 'ai-provider-openai-main',
|
||||
scope: 'ai_provider',
|
||||
refId: 'openai-main',
|
||||
title: 'OpenAI',
|
||||
severity: 'medium',
|
||||
status: 'pending',
|
||||
reasonCode: 'secret_missing',
|
||||
action: 'open_ai_settings',
|
||||
message: 'AI 提供商配置仍需完成安全更新',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status.overallStatus).toBe('pending');
|
||||
expect(result.status.summary).toEqual({
|
||||
total: 3,
|
||||
updated: 0,
|
||||
pending: 3,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
});
|
||||
expect(result.status.issues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ scope: 'ai_provider', refId: 'openai-main' }),
|
||||
expect.objectContaining({ scope: 'connection', refId: 'legacy-1' }),
|
||||
expect.objectContaining({ scope: 'global_proxy' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('keeps banner flow without intro when backend status is rolled_back', async () => {
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await bootstrapSecureConfig({
|
||||
...args,
|
||||
backend: {
|
||||
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
|
||||
overallStatus: 'rolled_back',
|
||||
summary: { total: 1, updated: 0, pending: 0, skipped: 0, failed: 1 },
|
||||
issues: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.shouldShowIntro).toBe(false);
|
||||
expect(result.shouldShowBanner).toBe(true);
|
||||
});
|
||||
|
||||
it('merges legacy pending items into rolled_back status without overwriting backend system issues', () => {
|
||||
const status = mergeSecurityUpdateStatusWithLegacySource({
|
||||
overallStatus: 'rolled_back',
|
||||
summary: { total: 1, updated: 0, pending: 0, skipped: 0, failed: 1 },
|
||||
issues: [
|
||||
{
|
||||
id: 'system-blocked',
|
||||
scope: 'system',
|
||||
title: '系统回滚',
|
||||
severity: 'high',
|
||||
status: 'failed',
|
||||
reasonCode: 'environment_blocked',
|
||||
action: 'view_details',
|
||||
message: '后端已回滚本轮更新,需要处理后重试。',
|
||||
},
|
||||
],
|
||||
}, legacyPayload);
|
||||
|
||||
expect(status.overallStatus).toBe('rolled_back');
|
||||
expect(status.summary).toEqual({
|
||||
total: 3,
|
||||
updated: 0,
|
||||
pending: 2,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
});
|
||||
expect(status.issues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'system-blocked', scope: 'system' }),
|
||||
expect.objectContaining({ id: 'legacy-connection-legacy-1', scope: 'connection', refId: 'legacy-1' }),
|
||||
expect.objectContaining({ id: 'legacy-global-proxy-default', scope: 'global_proxy' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('merges legacy pending items into needs_attention status without overwriting backend system issues', () => {
|
||||
const status = mergeSecurityUpdateStatusWithLegacySource({
|
||||
overallStatus: 'needs_attention',
|
||||
summary: { total: 2, updated: 1, pending: 0, skipped: 0, failed: 1 },
|
||||
issues: [
|
||||
{
|
||||
id: 'system-partial-failure',
|
||||
scope: 'system',
|
||||
title: '部分失败',
|
||||
severity: 'high',
|
||||
status: 'failed',
|
||||
reasonCode: 'environment_blocked',
|
||||
action: 'view_details',
|
||||
message: '部分项目迁移失败,需要人工处理。',
|
||||
},
|
||||
{
|
||||
id: 'ai-provider-openai-main',
|
||||
scope: 'ai_provider',
|
||||
refId: 'openai-main',
|
||||
title: 'OpenAI',
|
||||
severity: 'medium',
|
||||
status: 'updated',
|
||||
action: 'open_ai_settings',
|
||||
message: 'AI 提供商配置已完成安全更新。',
|
||||
},
|
||||
],
|
||||
}, legacyPayload);
|
||||
|
||||
expect(status.overallStatus).toBe('needs_attention');
|
||||
expect(status.summary).toEqual({
|
||||
total: 4,
|
||||
updated: 1,
|
||||
pending: 2,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
});
|
||||
expect(status.issues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'system-partial-failure', scope: 'system' }),
|
||||
expect.objectContaining({ id: 'ai-provider-openai-main', scope: 'ai_provider', refId: 'openai-main' }),
|
||||
expect.objectContaining({ id: 'legacy-connection-legacy-1', scope: 'connection', refId: 'legacy-1' }),
|
||||
expect.objectContaining({ id: 'legacy-global-proxy-default', scope: 'global_proxy' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('does not merge local legacy pending items back into an active migration round that already reports needs_attention', () => {
|
||||
const status = mergeSecurityUpdateStatusWithLegacySource({
|
||||
migrationId: 'migration-active-1',
|
||||
overallStatus: 'needs_attention',
|
||||
summary: { total: 3, updated: 2, pending: 1, skipped: 0, failed: 0 },
|
||||
issues: [
|
||||
{
|
||||
id: 'ai-provider-openai-main',
|
||||
scope: 'ai_provider',
|
||||
refId: 'openai-main',
|
||||
title: 'OpenAI',
|
||||
severity: 'medium',
|
||||
status: 'needs_attention',
|
||||
reasonCode: 'secret_missing',
|
||||
action: 'open_ai_settings',
|
||||
message: 'AI 提供商配置需要补充后才能完成安全更新。',
|
||||
},
|
||||
],
|
||||
}, legacyPayload);
|
||||
|
||||
expect(status.overallStatus).toBe('needs_attention');
|
||||
expect(status.summary).toEqual({
|
||||
total: 3,
|
||||
updated: 2,
|
||||
pending: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
});
|
||||
expect(status.issues).toEqual([
|
||||
expect.objectContaining({ id: 'ai-provider-openai-main', scope: 'ai_provider', refId: 'openai-main' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not merge local legacy pending items back into a rolled_back migration round', () => {
|
||||
const status = mergeSecurityUpdateStatusWithLegacySource({
|
||||
migrationId: 'migration-active-2',
|
||||
overallStatus: 'rolled_back',
|
||||
summary: { total: 3, updated: 1, pending: 0, skipped: 0, failed: 2 },
|
||||
issues: [
|
||||
{
|
||||
id: 'system-blocked',
|
||||
scope: 'system',
|
||||
title: '系统回滚',
|
||||
severity: 'high',
|
||||
status: 'failed',
|
||||
reasonCode: 'environment_blocked',
|
||||
action: 'view_details',
|
||||
message: '后端已回滚本轮更新,需要处理后重试。',
|
||||
},
|
||||
],
|
||||
}, legacyPayload);
|
||||
|
||||
expect(status.overallStatus).toBe('rolled_back');
|
||||
expect(status.summary).toEqual({
|
||||
total: 3,
|
||||
updated: 1,
|
||||
pending: 0,
|
||||
skipped: 0,
|
||||
failed: 2,
|
||||
});
|
||||
expect(status.issues).toEqual([
|
||||
expect.objectContaining({ id: 'system-blocked', scope: 'system' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads backend secure config directly when no legacy source exists', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const replaceConnections = vi.fn();
|
||||
const replaceGlobalProxy = vi.fn();
|
||||
|
||||
const result = await bootstrapSecureConfig({
|
||||
storage,
|
||||
replaceConnections,
|
||||
replaceGlobalProxy,
|
||||
backend: {
|
||||
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
|
||||
overallStatus: 'not_detected',
|
||||
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
}),
|
||||
GetSavedConnections: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'secure-1',
|
||||
name: 'Secure',
|
||||
config: {
|
||||
id: 'secure-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status.overallStatus).toBe('not_detected');
|
||||
expect(replaceConnections).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'secure-1' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows intro when backend status is pending even without legacy local source', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const replaceConnections = vi.fn();
|
||||
const replaceGlobalProxy = vi.fn();
|
||||
|
||||
const result = await bootstrapSecureConfig({
|
||||
storage,
|
||||
replaceConnections,
|
||||
replaceGlobalProxy,
|
||||
backend: {
|
||||
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
|
||||
overallStatus: 'pending',
|
||||
summary: { total: 1, updated: 0, pending: 1, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status.overallStatus).toBe('pending');
|
||||
expect(result.shouldShowIntro).toBe(true);
|
||||
expect(result.shouldShowBanner).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to legacy visible config when StartSecurityUpdate throws', async () => {
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await startSecurityUpdateFromBootstrap({
|
||||
...args,
|
||||
backend: {
|
||||
StartSecurityUpdate: vi.fn().mockRejectedValue(new Error('boom')),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBeNull();
|
||||
expect(result.error?.message).toContain('boom');
|
||||
expect(args.replaceConnections).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'legacy-1' })]),
|
||||
);
|
||||
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).toContain('"password":"secret"');
|
||||
});
|
||||
|
||||
it('starts security update even when rawPayload is empty but backend supports AI-only update', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const replaceConnections = vi.fn();
|
||||
const replaceGlobalProxy = vi.fn();
|
||||
const StartSecurityUpdate = vi.fn().mockResolvedValue({
|
||||
overallStatus: 'completed',
|
||||
summary: { total: 1, updated: 1, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
});
|
||||
|
||||
const result = await startSecurityUpdateFromBootstrap({
|
||||
storage,
|
||||
replaceConnections,
|
||||
replaceGlobalProxy,
|
||||
backend: {
|
||||
StartSecurityUpdate,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.status?.overallStatus).toBe('completed');
|
||||
expect(StartSecurityUpdate).toHaveBeenCalledWith({
|
||||
sourceType: 'current_app_saved_config',
|
||||
rawPayload: '',
|
||||
options: {
|
||||
allowPartial: true,
|
||||
writeBackup: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps source-side secrets when update ends in needs_attention', async () => {
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await startSecurityUpdateFromBootstrap({
|
||||
...args,
|
||||
backend: {
|
||||
StartSecurityUpdate: vi.fn().mockResolvedValue({
|
||||
overallStatus: 'needs_attention',
|
||||
summary: { total: 3, updated: 2, pending: 1, skipped: 0, failed: 0 },
|
||||
issues: [{ id: 'ai-1' }],
|
||||
}),
|
||||
GetSavedConnections: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status?.overallStatus).toBe('needs_attention');
|
||||
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).toContain('"password":"secret"');
|
||||
});
|
||||
|
||||
it('cleans source-side secrets only after completed update and backend refresh', async () => {
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await startSecurityUpdateFromBootstrap({
|
||||
...args,
|
||||
backend: {
|
||||
StartSecurityUpdate: vi.fn().mockResolvedValue({
|
||||
overallStatus: 'completed',
|
||||
summary: { total: 3, updated: 3, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
}),
|
||||
GetSavedConnections: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'secure-1',
|
||||
name: 'Secure',
|
||||
config: {
|
||||
id: 'secure-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
},
|
||||
hasPrimaryPassword: true,
|
||||
},
|
||||
]),
|
||||
GetGlobalProxyConfig: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
hasPassword: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status?.overallStatus).toBe('completed');
|
||||
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).not.toContain('"password":"secret"');
|
||||
expect(args.replaceConnections).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'secure-1' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes backend config and strips source-side secrets when a later round finishes as completed', async () => {
|
||||
const args = createBaseArgs();
|
||||
|
||||
const status = await finalizeSecurityUpdateStatus({
|
||||
...args,
|
||||
backend: {
|
||||
GetSavedConnections: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'secure-1',
|
||||
name: 'Secure',
|
||||
config: {
|
||||
id: 'secure-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
},
|
||||
hasPrimaryPassword: true,
|
||||
},
|
||||
]),
|
||||
GetGlobalProxyConfig: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
hasPassword: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}, {
|
||||
overallStatus: 'completed',
|
||||
summary: { total: 3, updated: 3, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
});
|
||||
|
||||
expect(status.overallStatus).toBe('completed');
|
||||
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).not.toContain('"password":"secret"');
|
||||
expect(args.replaceConnections).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'secure-1' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('reduces legacy pending issues after a single connection is repaired before the first round starts', () => {
|
||||
const initialStatus = mergeSecurityUpdateStatusWithLegacySource({
|
||||
overallStatus: 'not_detected',
|
||||
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
}, legacyPayload);
|
||||
const nextPayload = stripLegacyPersistedConnectionById(legacyPayload, 'legacy-1');
|
||||
|
||||
const status = mergeSecurityUpdateStatusWithLegacySource({
|
||||
overallStatus: 'not_detected',
|
||||
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
}, nextPayload, {
|
||||
previousStatus: initialStatus,
|
||||
});
|
||||
|
||||
expect(status.overallStatus).toBe('pending');
|
||||
expect(status.summary).toEqual({
|
||||
total: 2,
|
||||
updated: 1,
|
||||
pending: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
});
|
||||
expect(status.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
scope: 'global_proxy',
|
||||
action: 'open_proxy_settings',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('accumulates pre-start repaired progress across multiple connection saves in the same round-free session', () => {
|
||||
const multiConnectionPayload = JSON.stringify({
|
||||
state: {
|
||||
connections: [
|
||||
{
|
||||
id: 'legacy-1',
|
||||
name: 'Legacy 1',
|
||||
config: {
|
||||
id: 'legacy-1',
|
||||
type: 'postgres',
|
||||
host: 'db-1.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'legacy-2',
|
||||
name: 'Legacy 2',
|
||||
config: {
|
||||
id: 'legacy-2',
|
||||
type: 'postgres',
|
||||
host: 'db-2.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret-2',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'legacy-3',
|
||||
name: 'Legacy 3',
|
||||
config: {
|
||||
id: 'legacy-3',
|
||||
type: 'postgres',
|
||||
host: 'db-3.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret-3',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const backendStatus = {
|
||||
overallStatus: 'not_detected' as const,
|
||||
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
|
||||
issues: [],
|
||||
};
|
||||
const initialStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, multiConnectionPayload);
|
||||
const afterFirstRepairPayload = stripLegacyPersistedConnectionById(multiConnectionPayload, 'legacy-1');
|
||||
const afterFirstRepairStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, afterFirstRepairPayload, {
|
||||
previousStatus: initialStatus,
|
||||
});
|
||||
const afterSecondRepairPayload = stripLegacyPersistedConnectionById(afterFirstRepairPayload, 'legacy-2');
|
||||
|
||||
const afterSecondRepairStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, afterSecondRepairPayload, {
|
||||
previousStatus: afterFirstRepairStatus,
|
||||
});
|
||||
|
||||
expect(afterFirstRepairStatus.summary).toEqual({
|
||||
total: 3,
|
||||
updated: 1,
|
||||
pending: 2,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
});
|
||||
expect(afterSecondRepairStatus.summary).toEqual({
|
||||
total: 3,
|
||||
updated: 2,
|
||||
pending: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
});
|
||||
expect(afterSecondRepairStatus.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'legacy-connection-legacy-3',
|
||||
scope: 'connection',
|
||||
refId: 'legacy-3',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,412 +0,0 @@
|
||||
import {
|
||||
GlobalProxyConfig,
|
||||
SavedConnection,
|
||||
SecurityUpdateIssue,
|
||||
SecurityUpdateStatus,
|
||||
SecurityUpdateSummary,
|
||||
} from '../types';
|
||||
import { createGlobalProxyDraft } from './globalProxyDraft';
|
||||
import {
|
||||
LEGACY_PERSIST_KEY,
|
||||
hasLegacyMigratableSensitiveItems,
|
||||
readLegacyPersistedSecrets,
|
||||
stripLegacyPersistedSecrets,
|
||||
} from './legacyConnectionStorage';
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
|
||||
type BackendGlobalProxyResult = {
|
||||
success?: boolean;
|
||||
data?: Partial<GlobalProxyConfig>;
|
||||
};
|
||||
|
||||
type SecurityUpdateBackend = {
|
||||
GetSecurityUpdateStatus?: () => Promise<Partial<SecurityUpdateStatus> | undefined>;
|
||||
StartSecurityUpdate?: (request: {
|
||||
sourceType: 'current_app_saved_config';
|
||||
rawPayload: string;
|
||||
options?: {
|
||||
allowPartial?: boolean;
|
||||
writeBackup?: boolean;
|
||||
};
|
||||
}) => Promise<Partial<SecurityUpdateStatus> | undefined>;
|
||||
GetSavedConnections?: () => Promise<SavedConnection[]>;
|
||||
GetGlobalProxyConfig?: () => Promise<BackendGlobalProxyResult | undefined>;
|
||||
};
|
||||
|
||||
type SecureConfigBootstrapArgs = {
|
||||
backend?: SecurityUpdateBackend;
|
||||
storage?: StorageLike;
|
||||
replaceConnections: (connections: SavedConnection[]) => void;
|
||||
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void;
|
||||
};
|
||||
|
||||
type SecureConfigBootstrapResult = {
|
||||
status: SecurityUpdateStatus;
|
||||
rawPayload: string | null;
|
||||
hasLegacySensitiveItems: boolean;
|
||||
shouldShowIntro: boolean;
|
||||
shouldShowBanner: boolean;
|
||||
};
|
||||
|
||||
type StartSecurityUpdateResult = {
|
||||
status: SecurityUpdateStatus | null;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
type MergeSecurityUpdateStatusOptions = {
|
||||
previousStatus?: Partial<SecurityUpdateStatus> | null;
|
||||
};
|
||||
|
||||
const defaultSummary = () => ({
|
||||
total: 0,
|
||||
updated: 0,
|
||||
pending: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
const hasMeaningfulSummary = (summary: SecurityUpdateSummary): boolean => (
|
||||
summary.total > 0
|
||||
|| summary.updated > 0
|
||||
|| summary.pending > 0
|
||||
|| summary.skipped > 0
|
||||
|| summary.failed > 0
|
||||
);
|
||||
|
||||
const buildLegacyPendingDetails = (rawPayload: string | null): {
|
||||
hasLegacyItems: boolean;
|
||||
summary: SecurityUpdateSummary;
|
||||
issues: SecurityUpdateIssue[];
|
||||
} => {
|
||||
const legacy = readLegacyPersistedSecrets(rawPayload);
|
||||
const issues: SecurityUpdateIssue[] = legacy.connections.map((connection) => ({
|
||||
id: `legacy-connection-${connection.id}`,
|
||||
scope: 'connection',
|
||||
refId: connection.id,
|
||||
title: connection.name || connection.id,
|
||||
severity: 'medium',
|
||||
status: 'pending',
|
||||
reasonCode: 'migration_required',
|
||||
action: 'open_connection',
|
||||
message: '该连接仍保存在当前应用的本地配置中,完成安全更新后会迁入新的安全存储。',
|
||||
}));
|
||||
|
||||
if (legacy.globalProxy) {
|
||||
issues.push({
|
||||
id: 'legacy-global-proxy-default',
|
||||
scope: 'global_proxy',
|
||||
title: '全局代理',
|
||||
severity: 'medium',
|
||||
status: 'pending',
|
||||
reasonCode: 'migration_required',
|
||||
action: 'open_proxy_settings',
|
||||
message: '全局代理仍保存在当前应用的本地配置中,完成安全更新后会迁入新的安全存储。',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasLegacyItems: issues.length > 0,
|
||||
summary: {
|
||||
total: issues.length,
|
||||
updated: 0,
|
||||
pending: issues.length,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
issues,
|
||||
};
|
||||
};
|
||||
|
||||
const mergeSecurityUpdateIssues = (
|
||||
baseIssues: SecurityUpdateIssue[],
|
||||
legacyIssues: SecurityUpdateIssue[],
|
||||
): {
|
||||
issues: SecurityUpdateIssue[];
|
||||
addedCount: number;
|
||||
} => {
|
||||
const issueIds = new Set(baseIssues.map((issue) => issue.id));
|
||||
const additions = legacyIssues.filter((issue) => !issueIds.has(issue.id));
|
||||
return {
|
||||
issues: [...baseIssues, ...additions],
|
||||
addedCount: additions.length,
|
||||
};
|
||||
};
|
||||
|
||||
const isLocalLegacyIssue = (issue: Partial<SecurityUpdateIssue> | null | undefined): boolean => {
|
||||
const issueId = String(issue?.id || '').trim();
|
||||
return issueId.startsWith('legacy-connection-') || issueId === 'legacy-global-proxy-default';
|
||||
};
|
||||
|
||||
const countLocalLegacyIssues = (issues: SecurityUpdateIssue[]): number => (
|
||||
issues.filter((issue) => isLocalLegacyIssue(issue)).length
|
||||
);
|
||||
|
||||
const deriveLegacySummary = (
|
||||
base: SecurityUpdateStatus,
|
||||
currentLegacyCount: number,
|
||||
previousStatus?: Partial<SecurityUpdateStatus> | null,
|
||||
): {
|
||||
summary: SecurityUpdateSummary;
|
||||
hasContribution: boolean;
|
||||
} => {
|
||||
const previousSummary = previousStatus?.summary ?? defaultSummary();
|
||||
const previousIssues = Array.isArray(previousStatus?.issues) ? previousStatus.issues : [];
|
||||
const previousLegacyCount = countLocalLegacyIssues(previousIssues);
|
||||
const previousLegacyTotal = Math.max(
|
||||
0,
|
||||
previousSummary.total - base.summary.total,
|
||||
previousSummary.updated - base.summary.updated + previousLegacyCount,
|
||||
previousLegacyCount,
|
||||
);
|
||||
const previousLegacyUpdated = Math.max(
|
||||
0,
|
||||
Math.min(previousLegacyTotal, previousSummary.updated - base.summary.updated),
|
||||
);
|
||||
const repairedSincePrevious = Math.max(0, previousLegacyCount - currentLegacyCount);
|
||||
const nextLegacyUpdated = Math.min(previousLegacyTotal, previousLegacyUpdated + repairedSincePrevious);
|
||||
const nextLegacyTotal = Math.max(previousLegacyTotal, nextLegacyUpdated + currentLegacyCount);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
total: base.summary.total + nextLegacyTotal,
|
||||
updated: base.summary.updated + nextLegacyUpdated,
|
||||
pending: base.summary.pending + currentLegacyCount,
|
||||
skipped: base.summary.skipped,
|
||||
failed: base.summary.failed,
|
||||
},
|
||||
hasContribution: nextLegacyTotal > 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const mergeSecurityUpdateStatusWithLegacySource = (
|
||||
status: Partial<SecurityUpdateStatus> | undefined,
|
||||
rawPayload: string | null,
|
||||
options?: MergeSecurityUpdateStatusOptions,
|
||||
): SecurityUpdateStatus => {
|
||||
const base: SecurityUpdateStatus = {
|
||||
...defaultStatus(),
|
||||
...status,
|
||||
summary: {
|
||||
...defaultSummary(),
|
||||
...(status?.summary ?? {}),
|
||||
},
|
||||
issues: Array.isArray(status?.issues) ? status.issues : [],
|
||||
};
|
||||
const hasActiveMigrationRound = String(base.migrationId || '').trim() !== '';
|
||||
const baseNonLegacyIssues = base.issues.filter((issue) => !isLocalLegacyIssue(issue));
|
||||
|
||||
const legacy = buildLegacyPendingDetails(rawPayload);
|
||||
const legacySummary = deriveLegacySummary(base, legacy.issues.length, options?.previousStatus);
|
||||
|
||||
if (!legacySummary.hasContribution) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const mergedIssues = mergeSecurityUpdateIssues(baseNonLegacyIssues, legacy.issues).issues;
|
||||
|
||||
if (base.overallStatus === 'not_detected') {
|
||||
if (!legacy.hasLegacyItems) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
overallStatus: 'pending',
|
||||
reminderVisible: true,
|
||||
canStart: true,
|
||||
canPostpone: true,
|
||||
summary: legacySummary.summary,
|
||||
issues: mergedIssues,
|
||||
};
|
||||
}
|
||||
|
||||
if (base.overallStatus === 'pending' || base.overallStatus === 'postponed') {
|
||||
return {
|
||||
...base,
|
||||
summary: hasMeaningfulSummary(base.summary) || legacy.hasLegacyItems ? legacySummary.summary : legacy.summary,
|
||||
issues: mergedIssues,
|
||||
canStart: true,
|
||||
canPostpone: true,
|
||||
reminderVisible: base.overallStatus === 'pending' ? true : base.reminderVisible,
|
||||
};
|
||||
}
|
||||
|
||||
if (base.overallStatus === 'rolled_back' || base.overallStatus === 'needs_attention') {
|
||||
if (hasActiveMigrationRound) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
summary: hasMeaningfulSummary(base.summary) || legacy.hasLegacyItems ? legacySummary.summary : legacy.summary,
|
||||
issues: mergedIssues,
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
const defaultStatus = (): SecurityUpdateStatus => ({
|
||||
overallStatus: 'not_detected',
|
||||
summary: defaultSummary(),
|
||||
issues: [],
|
||||
});
|
||||
|
||||
const resolveStorage = (storage?: StorageLike): StorageLike | undefined => {
|
||||
if (storage) {
|
||||
return storage;
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
return window.localStorage;
|
||||
};
|
||||
|
||||
const applyLegacyVisibleConfig = (
|
||||
rawPayload: string | null,
|
||||
replaceConnections: (connections: SavedConnection[]) => void,
|
||||
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void,
|
||||
) => {
|
||||
const legacy = readLegacyPersistedSecrets(rawPayload);
|
||||
if (legacy.connections.length > 0) {
|
||||
replaceConnections(legacy.connections);
|
||||
}
|
||||
if (legacy.globalProxy) {
|
||||
replaceGlobalProxy(createGlobalProxyDraft(legacy.globalProxy));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshVisibleConfigFromBackend = async (
|
||||
backend: SecurityUpdateBackend | undefined,
|
||||
replaceConnections: (connections: SavedConnection[]) => void,
|
||||
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void,
|
||||
allowEmptyConnections: boolean,
|
||||
) => {
|
||||
if (typeof backend?.GetSavedConnections === 'function') {
|
||||
try {
|
||||
const connections = await backend.GetSavedConnections();
|
||||
if (Array.isArray(connections) && (allowEmptyConnections || connections.length > 0)) {
|
||||
replaceConnections(connections);
|
||||
}
|
||||
} catch {
|
||||
// Keep current visible state as fallback.
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof backend?.GetGlobalProxyConfig === 'function') {
|
||||
try {
|
||||
const proxyResult = await backend.GetGlobalProxyConfig();
|
||||
if (proxyResult?.success && proxyResult.data) {
|
||||
replaceGlobalProxy(createGlobalProxyDraft(proxyResult.data));
|
||||
}
|
||||
} catch {
|
||||
// Keep current visible state as fallback.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupLegacySourceIfCompleted = (
|
||||
storage: StorageLike | undefined,
|
||||
rawPayload: string | null,
|
||||
status: SecurityUpdateStatus,
|
||||
) => {
|
||||
if (!storage || !rawPayload || status.overallStatus !== 'completed') {
|
||||
return;
|
||||
}
|
||||
const sanitizedPayload = stripLegacyPersistedSecrets(rawPayload);
|
||||
if (sanitizedPayload && sanitizedPayload !== rawPayload) {
|
||||
storage.setItem(LEGACY_PERSIST_KEY, sanitizedPayload);
|
||||
}
|
||||
};
|
||||
|
||||
export async function finalizeSecurityUpdateStatus(
|
||||
args: SecureConfigBootstrapArgs,
|
||||
rawStatus: Partial<SecurityUpdateStatus> | undefined,
|
||||
): Promise<SecurityUpdateStatus> {
|
||||
const storage = resolveStorage(args.storage);
|
||||
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
|
||||
const status = mergeSecurityUpdateStatusWithLegacySource(rawStatus, rawPayload);
|
||||
|
||||
if (status.overallStatus === 'completed') {
|
||||
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
|
||||
cleanupLegacySourceIfCompleted(storage, rawPayload, status);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function bootstrapSecureConfig(args: SecureConfigBootstrapArgs): Promise<SecureConfigBootstrapResult> {
|
||||
const storage = resolveStorage(args.storage);
|
||||
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
|
||||
const hasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(rawPayload);
|
||||
|
||||
applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy);
|
||||
|
||||
const backendStatus = typeof args.backend?.GetSecurityUpdateStatus === 'function'
|
||||
? await args.backend.GetSecurityUpdateStatus()
|
||||
: undefined;
|
||||
const status = mergeSecurityUpdateStatusWithLegacySource(backendStatus, rawPayload);
|
||||
|
||||
if (!hasLegacySensitiveItems) {
|
||||
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
|
||||
} else if (status.overallStatus === 'completed') {
|
||||
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
|
||||
cleanupLegacySourceIfCompleted(storage, rawPayload, status);
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
rawPayload,
|
||||
hasLegacySensitiveItems,
|
||||
shouldShowIntro: status.overallStatus === 'pending',
|
||||
shouldShowBanner: ['postponed', 'rolled_back', 'needs_attention'].includes(status.overallStatus),
|
||||
};
|
||||
}
|
||||
|
||||
export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstrapArgs): Promise<StartSecurityUpdateResult> {
|
||||
const storage = resolveStorage(args.storage);
|
||||
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
|
||||
const startPayload = rawPayload ?? '';
|
||||
|
||||
applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy);
|
||||
|
||||
if (typeof args.backend?.StartSecurityUpdate !== 'function') {
|
||||
return {
|
||||
status: null,
|
||||
error: new Error('安全更新能力不可用'),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const rawStatus = await args.backend.StartSecurityUpdate({
|
||||
sourceType: 'current_app_saved_config',
|
||||
rawPayload: startPayload,
|
||||
options: {
|
||||
allowPartial: true,
|
||||
writeBackup: true,
|
||||
},
|
||||
});
|
||||
const status = mergeSecurityUpdateStatusWithLegacySource(rawStatus, rawPayload);
|
||||
|
||||
if (status.overallStatus === 'completed') {
|
||||
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
|
||||
cleanupLegacySourceIfCompleted(storage, rawPayload, status);
|
||||
}
|
||||
|
||||
return { status, error: null };
|
||||
} catch (error) {
|
||||
applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy);
|
||||
return {
|
||||
status: null,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
BackendGlobalProxyResult,
|
||||
MergeSecurityUpdateStatusOptions,
|
||||
SecurityUpdateBackend,
|
||||
SecureConfigBootstrapArgs,
|
||||
SecureConfigBootstrapResult,
|
||||
StartSecurityUpdateResult,
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
|
||||
import {
|
||||
getSecurityUpdateIssueSeverityMeta,
|
||||
getSecurityUpdateItemStatusMeta,
|
||||
getSecurityUpdateIssueActionMeta,
|
||||
getSecurityUpdateStatusMeta,
|
||||
resolveSecurityUpdateEntryVisibility,
|
||||
sortSecurityUpdateIssues,
|
||||
} from './securityUpdatePresentation';
|
||||
|
||||
const createStatus = (overallStatus: SecurityUpdateStatus['overallStatus']): SecurityUpdateStatus => ({
|
||||
overallStatus,
|
||||
summary: {
|
||||
total: 0,
|
||||
updated: 0,
|
||||
pending: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
issues: [],
|
||||
});
|
||||
|
||||
describe('securityUpdatePresentation', () => {
|
||||
it('sorts issues by severity from high to low', () => {
|
||||
const issues: SecurityUpdateIssue[] = [
|
||||
{ id: 'medium-1', severity: 'medium' },
|
||||
{ id: 'low-1', severity: 'low' },
|
||||
{ id: 'high-1', severity: 'high' },
|
||||
{ id: 'medium-2', severity: 'medium' },
|
||||
];
|
||||
|
||||
expect(sortSecurityUpdateIssues(issues).map((issue) => issue.id)).toEqual([
|
||||
'high-1',
|
||||
'medium-1',
|
||||
'medium-2',
|
||||
'low-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps needs_attention, rolled_back and completed to stable display labels', () => {
|
||||
expect(getSecurityUpdateStatusMeta(createStatus('needs_attention')).label).toBe('待处理');
|
||||
expect(getSecurityUpdateStatusMeta(createStatus('rolled_back')).label).toBe('已回退');
|
||||
expect(getSecurityUpdateStatusMeta(createStatus('completed')).label).toBe('已完成');
|
||||
});
|
||||
|
||||
it('resolves intro, banner and detail entry visibility for key overall states', () => {
|
||||
expect(resolveSecurityUpdateEntryVisibility(createStatus('pending'))).toEqual({
|
||||
showIntro: true,
|
||||
showBanner: false,
|
||||
showDetailEntry: true,
|
||||
});
|
||||
|
||||
expect(resolveSecurityUpdateEntryVisibility(createStatus('postponed'))).toEqual({
|
||||
showIntro: false,
|
||||
showBanner: true,
|
||||
showDetailEntry: true,
|
||||
});
|
||||
|
||||
expect(resolveSecurityUpdateEntryVisibility(createStatus('rolled_back'))).toEqual({
|
||||
showIntro: false,
|
||||
showBanner: true,
|
||||
showDetailEntry: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps issue scope actions to existing repair entry labels', () => {
|
||||
expect(getSecurityUpdateIssueActionMeta({ id: 'conn', scope: 'connection', action: 'open_connection' }).label).toBe('打开连接');
|
||||
expect(getSecurityUpdateIssueActionMeta({ id: 'proxy', scope: 'global_proxy', action: 'open_proxy_settings' }).label).toBe('代理设置');
|
||||
expect(getSecurityUpdateIssueActionMeta({ id: 'ai', scope: 'ai_provider', action: 'open_ai_settings' }).label).toBe('AI 设置');
|
||||
expect(getSecurityUpdateIssueActionMeta({ id: 'system', scope: 'system', action: 'view_details' }).label).toBe('查看详情');
|
||||
});
|
||||
|
||||
it('maps item status to explicit Chinese labels instead of reusing severity wording', () => {
|
||||
expect(getSecurityUpdateItemStatusMeta('needs_attention')).toEqual({
|
||||
label: '待处理',
|
||||
color: 'warning',
|
||||
});
|
||||
expect(getSecurityUpdateItemStatusMeta('updated')).toEqual({
|
||||
label: '已更新',
|
||||
color: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps issue severity to dedicated risk labels', () => {
|
||||
expect(getSecurityUpdateIssueSeverityMeta('medium')).toEqual({
|
||||
label: '中风险',
|
||||
color: 'warning',
|
||||
});
|
||||
expect(getSecurityUpdateIssueSeverityMeta('high')).toEqual({
|
||||
label: '高风险',
|
||||
color: 'error',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
import type {
|
||||
SecurityUpdateIssue,
|
||||
SecurityUpdateIssueAction,
|
||||
SecurityUpdateIssueSeverity,
|
||||
SecurityUpdateItemStatus,
|
||||
SecurityUpdateStatus,
|
||||
} from '../types';
|
||||
|
||||
type SecurityUpdateTone = 'default' | 'warning' | 'processing' | 'success' | 'error';
|
||||
|
||||
type SecurityUpdateStatusMeta = {
|
||||
label: string;
|
||||
description: string;
|
||||
tone: SecurityUpdateTone;
|
||||
};
|
||||
|
||||
type SecurityUpdateEntryVisibility = {
|
||||
showIntro: boolean;
|
||||
showBanner: boolean;
|
||||
showDetailEntry: boolean;
|
||||
};
|
||||
|
||||
type SecurityUpdateIssueActionMeta = {
|
||||
label: string;
|
||||
emphasis: 'primary' | 'default';
|
||||
};
|
||||
|
||||
type SecurityUpdateBadgeMeta = {
|
||||
label: string;
|
||||
color: SecurityUpdateTone;
|
||||
};
|
||||
|
||||
const severityWeight: Record<SecurityUpdateIssueSeverity, number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
};
|
||||
|
||||
const actionMetaMap: Record<SecurityUpdateIssueAction, SecurityUpdateIssueActionMeta> = {
|
||||
open_connection: {
|
||||
label: '打开连接',
|
||||
emphasis: 'primary',
|
||||
},
|
||||
open_proxy_settings: {
|
||||
label: '代理设置',
|
||||
emphasis: 'primary',
|
||||
},
|
||||
open_ai_settings: {
|
||||
label: 'AI 设置',
|
||||
emphasis: 'primary',
|
||||
},
|
||||
retry_update: {
|
||||
label: '重新检查',
|
||||
emphasis: 'primary',
|
||||
},
|
||||
view_details: {
|
||||
label: '查看详情',
|
||||
emphasis: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
const itemStatusMetaMap: Record<SecurityUpdateItemStatus, SecurityUpdateBadgeMeta> = {
|
||||
pending: {
|
||||
label: '待更新',
|
||||
color: 'processing',
|
||||
},
|
||||
updated: {
|
||||
label: '已更新',
|
||||
color: 'success',
|
||||
},
|
||||
needs_attention: {
|
||||
label: '待处理',
|
||||
color: 'warning',
|
||||
},
|
||||
skipped: {
|
||||
label: '已跳过',
|
||||
color: 'default',
|
||||
},
|
||||
failed: {
|
||||
label: '失败',
|
||||
color: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
const issueSeverityMetaMap: Record<SecurityUpdateIssueSeverity, SecurityUpdateBadgeMeta> = {
|
||||
high: {
|
||||
label: '高风险',
|
||||
color: 'error',
|
||||
},
|
||||
medium: {
|
||||
label: '中风险',
|
||||
color: 'warning',
|
||||
},
|
||||
low: {
|
||||
label: '低风险',
|
||||
color: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
export function sortSecurityUpdateIssues(issues: SecurityUpdateIssue[]): SecurityUpdateIssue[] {
|
||||
return [...issues].sort((left, right) => {
|
||||
const leftWeight = severityWeight[left.severity ?? 'low'];
|
||||
const rightWeight = severityWeight[right.severity ?? 'low'];
|
||||
if (leftWeight !== rightWeight) {
|
||||
return leftWeight - rightWeight;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function getSecurityUpdateStatusMeta(status: SecurityUpdateStatus): SecurityUpdateStatusMeta {
|
||||
switch (status.overallStatus) {
|
||||
case 'pending':
|
||||
return {
|
||||
label: '待更新',
|
||||
description: '检测到可进行的安全更新,你可以现在开始或稍后继续。',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'postponed':
|
||||
return {
|
||||
label: '待更新',
|
||||
description: '本次安全更新已延后,当前可用配置会继续保留。',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'in_progress':
|
||||
return {
|
||||
label: '更新中',
|
||||
description: '正在检查并更新已保存配置的安全存储。',
|
||||
tone: 'processing',
|
||||
};
|
||||
case 'needs_attention':
|
||||
return {
|
||||
label: '待处理',
|
||||
description: '更新尚未完成,有少量配置需要你处理。',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
label: '已完成',
|
||||
description: '已保存配置已完成安全更新。',
|
||||
tone: 'success',
|
||||
};
|
||||
case 'rolled_back':
|
||||
return {
|
||||
label: '已回退',
|
||||
description: '本次更新未完成,系统已保留当前可用配置。',
|
||||
tone: 'error',
|
||||
};
|
||||
case 'not_detected':
|
||||
default:
|
||||
return {
|
||||
label: '未检测到',
|
||||
description: '当前没有需要处理的安全更新。',
|
||||
tone: 'default',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSecurityUpdateEntryVisibility(status: SecurityUpdateStatus): SecurityUpdateEntryVisibility {
|
||||
switch (status.overallStatus) {
|
||||
case 'pending':
|
||||
return {
|
||||
showIntro: true,
|
||||
showBanner: false,
|
||||
showDetailEntry: true,
|
||||
};
|
||||
case 'postponed':
|
||||
case 'needs_attention':
|
||||
case 'rolled_back':
|
||||
return {
|
||||
showIntro: false,
|
||||
showBanner: true,
|
||||
showDetailEntry: true,
|
||||
};
|
||||
case 'completed':
|
||||
case 'in_progress':
|
||||
return {
|
||||
showIntro: false,
|
||||
showBanner: false,
|
||||
showDetailEntry: true,
|
||||
};
|
||||
case 'not_detected':
|
||||
default:
|
||||
return {
|
||||
showIntro: false,
|
||||
showBanner: false,
|
||||
showDetailEntry: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getSecurityUpdateIssueActionMeta(issue: Partial<SecurityUpdateIssue>): SecurityUpdateIssueActionMeta {
|
||||
return actionMetaMap[issue.action ?? 'view_details'] ?? actionMetaMap.view_details;
|
||||
}
|
||||
|
||||
export function getSecurityUpdateItemStatusMeta(status?: SecurityUpdateItemStatus): SecurityUpdateBadgeMeta {
|
||||
return itemStatusMetaMap[status ?? 'pending'] ?? itemStatusMetaMap.pending;
|
||||
}
|
||||
|
||||
export function getSecurityUpdateIssueSeverityMeta(severity?: SecurityUpdateIssueSeverity): SecurityUpdateBadgeMeta {
|
||||
return issueSeverityMetaMap[severity ?? 'low'] ?? issueSeverityMetaMap.low;
|
||||
}
|
||||
|
||||
export type {
|
||||
SecurityUpdateBadgeMeta,
|
||||
SecurityUpdateEntryVisibility,
|
||||
SecurityUpdateIssueActionMeta,
|
||||
SecurityUpdateStatusMeta,
|
||||
SecurityUpdateTone,
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
|
||||
import {
|
||||
hasSecurityUpdateRecentResult,
|
||||
resolveSecurityUpdateFocusState,
|
||||
resolveSecurityUpdateRepairEntry,
|
||||
resolveSecurityUpdateSettingsFocusTarget,
|
||||
shouldRefreshSecurityUpdateDetailsFocus,
|
||||
shouldReopenSecurityUpdateDetails,
|
||||
shouldRetrySecurityUpdateAfterRepairSave,
|
||||
} from './securityUpdateRepairFlow';
|
||||
|
||||
const createConnection = (id: string): SavedConnection => ({
|
||||
id,
|
||||
name: `连接-${id}`,
|
||||
config: {
|
||||
id,
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
},
|
||||
});
|
||||
|
||||
const createStatus = (overrides: Partial<SecurityUpdateStatus> = {}): SecurityUpdateStatus => ({
|
||||
overallStatus: 'needs_attention',
|
||||
summary: {
|
||||
total: 1,
|
||||
updated: 0,
|
||||
pending: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
issues: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('securityUpdateRepairFlow', () => {
|
||||
it('opens the matching connection and preserves the return source for security update repairs', () => {
|
||||
const target = createConnection('conn-1');
|
||||
const issue: SecurityUpdateIssue = {
|
||||
id: 'issue-1',
|
||||
action: 'open_connection',
|
||||
refId: 'conn-1',
|
||||
};
|
||||
|
||||
expect(resolveSecurityUpdateRepairEntry(issue, [target])).toEqual({
|
||||
type: 'connection',
|
||||
connection: target,
|
||||
repairSource: 'connection',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a user-facing warning when the target connection no longer exists', () => {
|
||||
const issue: SecurityUpdateIssue = {
|
||||
id: 'issue-1',
|
||||
action: 'open_connection',
|
||||
refId: 'missing-conn',
|
||||
};
|
||||
|
||||
expect(resolveSecurityUpdateRepairEntry(issue, [createConnection('conn-1')])).toEqual({
|
||||
type: 'warning',
|
||||
message: '未找到对应连接,请先重新检查最新状态',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps proxy, ai and retry actions to the expected repair entry', () => {
|
||||
expect(resolveSecurityUpdateRepairEntry({ id: 'proxy', action: 'open_proxy_settings' }, [])).toEqual({
|
||||
type: 'proxy',
|
||||
repairSource: 'proxy',
|
||||
});
|
||||
expect(resolveSecurityUpdateRepairEntry({ id: 'ai', action: 'open_ai_settings', refId: 'provider-1' }, [])).toEqual({
|
||||
type: 'ai',
|
||||
providerId: 'provider-1',
|
||||
repairSource: 'ai',
|
||||
});
|
||||
expect(resolveSecurityUpdateRepairEntry({ id: 'retry', action: 'retry_update' }, [])).toEqual({
|
||||
type: 'retry',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes view_details actions to the latest result section when a recent result exists', () => {
|
||||
const status = createStatus({
|
||||
backupPath: '/tmp/gonavi-backup.json',
|
||||
lastError: '写入新密钥失败',
|
||||
});
|
||||
|
||||
expect(hasSecurityUpdateRecentResult(status)).toBe(true);
|
||||
expect(resolveSecurityUpdateSettingsFocusTarget(status)).toBe('recent_result');
|
||||
expect(resolveSecurityUpdateRepairEntry({ id: 'details', action: 'view_details' }, [], status)).toEqual({
|
||||
type: 'details',
|
||||
focusTarget: 'recent_result',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the status section when no recent result is available yet', () => {
|
||||
const status = createStatus();
|
||||
|
||||
expect(hasSecurityUpdateRecentResult(status)).toBe(false);
|
||||
expect(resolveSecurityUpdateSettingsFocusTarget(status)).toBe('status');
|
||||
expect(resolveSecurityUpdateRepairEntry({ id: 'details', action: 'view_details' }, [], status)).toEqual({
|
||||
type: 'details',
|
||||
focusTarget: 'status',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a fresh focus pulse for repeated details clicks and clears it when the modal closes', () => {
|
||||
expect(resolveSecurityUpdateFocusState(true, 'status', 1)).toEqual({
|
||||
target: 'status',
|
||||
pulseKey: 'status:1',
|
||||
});
|
||||
expect(resolveSecurityUpdateFocusState(true, 'status', 2)).toEqual({
|
||||
target: 'status',
|
||||
pulseKey: 'status:2',
|
||||
});
|
||||
expect(resolveSecurityUpdateFocusState(false, 'status', 2)).toEqual({
|
||||
target: null,
|
||||
pulseKey: null,
|
||||
});
|
||||
expect(resolveSecurityUpdateFocusState(true, null, 3)).toEqual({
|
||||
target: null,
|
||||
pulseKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reopens security update details after closing a repair entry opened from that page', () => {
|
||||
expect(shouldReopenSecurityUpdateDetails('connection')).toBe(true);
|
||||
expect(shouldReopenSecurityUpdateDetails('proxy')).toBe(true);
|
||||
expect(shouldReopenSecurityUpdateDetails('ai')).toBe(true);
|
||||
expect(shouldReopenSecurityUpdateDetails(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('retries the current round automatically after saving a connection from the repair flow', () => {
|
||||
expect(shouldRetrySecurityUpdateAfterRepairSave('connection')).toBe(true);
|
||||
expect(shouldRetrySecurityUpdateAfterRepairSave('proxy')).toBe(false);
|
||||
expect(shouldRetrySecurityUpdateAfterRepairSave('ai')).toBe(false);
|
||||
expect(shouldRetrySecurityUpdateAfterRepairSave(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not force a new focus pulse when the details modal is already open and only the round result is refreshing', () => {
|
||||
expect(shouldRefreshSecurityUpdateDetailsFocus({
|
||||
requestedOpen: true,
|
||||
wasOpen: true,
|
||||
})).toBe(false);
|
||||
expect(shouldRefreshSecurityUpdateDetailsFocus({
|
||||
requestedOpen: true,
|
||||
wasOpen: false,
|
||||
})).toBe(true);
|
||||
expect(shouldRefreshSecurityUpdateDetailsFocus({
|
||||
requestedOpen: false,
|
||||
wasOpen: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
|
||||
|
||||
export type SecurityUpdateRepairSource = 'connection' | 'proxy' | 'ai';
|
||||
export type SecurityUpdateSettingsFocusTarget = 'recent_result' | 'status';
|
||||
export type SecurityUpdateFocusState = {
|
||||
target: SecurityUpdateSettingsFocusTarget | null;
|
||||
pulseKey: string | null;
|
||||
};
|
||||
|
||||
export type SecurityUpdateRepairEntry =
|
||||
| {
|
||||
type: 'connection';
|
||||
connection: SavedConnection;
|
||||
repairSource: 'connection';
|
||||
}
|
||||
| {
|
||||
type: 'proxy';
|
||||
repairSource: 'proxy';
|
||||
}
|
||||
| {
|
||||
type: 'ai';
|
||||
providerId?: string;
|
||||
repairSource: 'ai';
|
||||
}
|
||||
| {
|
||||
type: 'retry';
|
||||
}
|
||||
| {
|
||||
type: 'details';
|
||||
focusTarget: SecurityUpdateSettingsFocusTarget;
|
||||
}
|
||||
| {
|
||||
type: 'warning';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const hasSecurityUpdateRecentResult = (
|
||||
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
|
||||
): boolean => Boolean(status?.backupPath || status?.lastError);
|
||||
|
||||
export const resolveSecurityUpdateSettingsFocusTarget = (
|
||||
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
|
||||
): SecurityUpdateSettingsFocusTarget => (
|
||||
hasSecurityUpdateRecentResult(status) ? 'recent_result' : 'status'
|
||||
);
|
||||
|
||||
export const resolveSecurityUpdateFocusState = (
|
||||
open: boolean,
|
||||
focusTarget: SecurityUpdateSettingsFocusTarget | null | undefined,
|
||||
focusRequest: number,
|
||||
): SecurityUpdateFocusState => {
|
||||
if (!open || !focusTarget) {
|
||||
return {
|
||||
target: null,
|
||||
pulseKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
target: focusTarget,
|
||||
pulseKey: `${focusTarget}:${focusRequest}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveSecurityUpdateRepairEntry = (
|
||||
issue: SecurityUpdateIssue,
|
||||
connections: SavedConnection[],
|
||||
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
|
||||
): SecurityUpdateRepairEntry => {
|
||||
if (issue.action === 'open_connection') {
|
||||
const target = connections.find((connection) => connection.id === issue.refId);
|
||||
if (!target) {
|
||||
return {
|
||||
type: 'warning',
|
||||
message: '未找到对应连接,请先重新检查最新状态',
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'connection',
|
||||
connection: target,
|
||||
repairSource: 'connection',
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.action === 'open_proxy_settings') {
|
||||
return {
|
||||
type: 'proxy',
|
||||
repairSource: 'proxy',
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.action === 'open_ai_settings') {
|
||||
return {
|
||||
type: 'ai',
|
||||
providerId: issue.refId || undefined,
|
||||
repairSource: 'ai',
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.action === 'retry_update') {
|
||||
return {
|
||||
type: 'retry',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'details',
|
||||
focusTarget: resolveSecurityUpdateSettingsFocusTarget(status),
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldReopenSecurityUpdateDetails = (
|
||||
repairSource: SecurityUpdateRepairSource | null | undefined,
|
||||
): boolean => repairSource === 'connection' || repairSource === 'proxy' || repairSource === 'ai';
|
||||
|
||||
export const shouldRefreshSecurityUpdateDetailsFocus = ({
|
||||
requestedOpen,
|
||||
wasOpen,
|
||||
}: {
|
||||
requestedOpen: boolean;
|
||||
wasOpen: boolean;
|
||||
}): boolean => requestedOpen && !wasOpen;
|
||||
|
||||
export const shouldRetrySecurityUpdateAfterRepairSave = (
|
||||
repairSource: SecurityUpdateRepairSource | null | undefined,
|
||||
): boolean => repairSource === 'connection';
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_BANNER_CLASS,
|
||||
SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS,
|
||||
SECURITY_UPDATE_RESULT_CARD_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateBannerSurfaceStyle,
|
||||
getSecurityUpdateSectionSurfaceStyle,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from './securityUpdateVisuals';
|
||||
|
||||
describe('securityUpdateVisuals', () => {
|
||||
it('builds action buttons without default ant focus glow shadow', () => {
|
||||
expect(SECURITY_UPDATE_ACTION_BUTTON_CLASS).toBe('security-update-action-btn');
|
||||
expect(SECURITY_UPDATE_BANNER_CLASS).toBe('security-update-banner');
|
||||
expect(SECURITY_UPDATE_RESULT_CARD_CLASS).toBe('security-update-result-card');
|
||||
expect(SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS).toBe('security-update-result-card-active');
|
||||
expect(getSecurityUpdateActionButtonStyle()).toMatchObject({
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
boxShadow: 'none',
|
||||
fontWeight: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the shell surface aligned with overlay shell tokens in light and dark mode', () => {
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true);
|
||||
|
||||
expect(getSecurityUpdateShellSurfaceStyle(lightTheme)).toMatchObject({
|
||||
border: lightTheme.shellBorder,
|
||||
background: lightTheme.shellBg,
|
||||
boxShadow: lightTheme.shellShadow,
|
||||
backdropFilter: lightTheme.shellBackdropFilter,
|
||||
});
|
||||
expect(getSecurityUpdateShellSurfaceStyle(darkTheme)).toMatchObject({
|
||||
border: darkTheme.shellBorder,
|
||||
background: darkTheme.shellBg,
|
||||
boxShadow: darkTheme.shellShadow,
|
||||
backdropFilter: darkTheme.shellBackdropFilter,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the banner surface aligned with overlay shell tokens instead of translucent section tokens', () => {
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true);
|
||||
|
||||
expect(getSecurityUpdateBannerSurfaceStyle(lightTheme)).toMatchObject({
|
||||
border: lightTheme.shellBorder,
|
||||
background: lightTheme.shellBg,
|
||||
boxShadow: 'none',
|
||||
backdropFilter: lightTheme.shellBackdropFilter,
|
||||
});
|
||||
expect(getSecurityUpdateBannerSurfaceStyle(darkTheme)).toMatchObject({
|
||||
border: darkTheme.shellBorder,
|
||||
background: darkTheme.shellBg,
|
||||
boxShadow: 'none',
|
||||
backdropFilter: darkTheme.shellBackdropFilter,
|
||||
});
|
||||
});
|
||||
|
||||
it('can scale shell surface alpha with the current appearance opacity so reminder layers stay visually consistent', () => {
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
const fadedShell = getSecurityUpdateShellSurfaceStyle(lightTheme, 0.5);
|
||||
const fadedBanner = getSecurityUpdateBannerSurfaceStyle(lightTheme, 0.5);
|
||||
|
||||
expect(fadedShell.background).not.toBe(lightTheme.shellBg);
|
||||
expect(fadedShell.border).not.toBe(lightTheme.shellBorder);
|
||||
expect(fadedShell.background).toContain('0.49');
|
||||
expect(fadedBanner.background).toContain('0.49');
|
||||
});
|
||||
|
||||
it('can emphasize a section surface for transient focus and recent-result highlighting', () => {
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true);
|
||||
|
||||
expect(getSecurityUpdateSectionSurfaceStyle(lightTheme)).toMatchObject({
|
||||
border: lightTheme.sectionBorder,
|
||||
background: lightTheme.sectionBg,
|
||||
boxShadow: 'none',
|
||||
});
|
||||
expect(getSecurityUpdateSectionSurfaceStyle(darkTheme)).toMatchObject({
|
||||
border: darkTheme.sectionBorder,
|
||||
background: darkTheme.sectionBg,
|
||||
boxShadow: 'none',
|
||||
});
|
||||
|
||||
const emphasizedLight = getSecurityUpdateSectionSurfaceStyle(lightTheme, { emphasized: true });
|
||||
const emphasizedDark = getSecurityUpdateSectionSurfaceStyle(darkTheme, { emphasized: true });
|
||||
|
||||
expect(emphasizedLight.background).not.toBe(lightTheme.sectionBg);
|
||||
expect(emphasizedLight.boxShadow).not.toBe('none');
|
||||
expect(emphasizedDark.background).not.toBe(darkTheme.sectionBg);
|
||||
expect(emphasizedDark.boxShadow).not.toBe('none');
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from './overlayWorkbenchTheme';
|
||||
|
||||
export const SECURITY_UPDATE_ACTION_BUTTON_CLASS = 'security-update-action-btn';
|
||||
export const SECURITY_UPDATE_BANNER_CLASS = 'security-update-banner';
|
||||
export const SECURITY_UPDATE_MODAL_CLASS = 'security-update-modal';
|
||||
export const SECURITY_UPDATE_RESULT_CARD_CLASS = 'security-update-result-card';
|
||||
export const SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS = 'security-update-result-card-active';
|
||||
|
||||
type SecurityUpdateSectionSurfaceOptions = {
|
||||
emphasized?: boolean;
|
||||
surfaceOpacity?: number;
|
||||
};
|
||||
|
||||
const clampOpacity = (value: number): number => Math.min(1, Math.max(0.1, value));
|
||||
|
||||
const formatAlpha = (value: number): string => (
|
||||
Number(value.toFixed(3)).toString()
|
||||
);
|
||||
|
||||
const applySurfaceOpacity = (token: string, surfaceOpacity = 1): string => {
|
||||
const normalizedOpacity = clampOpacity(surfaceOpacity);
|
||||
if (normalizedOpacity >= 0.999) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return token.replace(
|
||||
/rgba\(\s*([^)]+?)\s*,\s*([0-9]*\.?[0-9]+)\s*\)/g,
|
||||
(_, channels: string, alpha: string) => `rgba(${channels}, ${formatAlpha(Number(alpha) * normalizedOpacity)})`,
|
||||
);
|
||||
};
|
||||
|
||||
const getSecurityUpdateHighlightBorder = (overlayTheme: OverlayWorkbenchTheme): string => (
|
||||
overlayTheme.isDark
|
||||
? '1px solid rgba(255,214,102,0.26)'
|
||||
: '1px solid rgba(22,119,255,0.22)'
|
||||
);
|
||||
|
||||
const getSecurityUpdateHighlightBackground = (overlayTheme: OverlayWorkbenchTheme): string => (
|
||||
overlayTheme.isDark
|
||||
? 'linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,255,255,0.05) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(22,119,255,0.12) 0%, rgba(255,255,255,0.96) 100%)'
|
||||
);
|
||||
|
||||
const getSecurityUpdateHighlightShadow = (overlayTheme: OverlayWorkbenchTheme): string => (
|
||||
overlayTheme.isDark
|
||||
? '0 0 0 1px rgba(255,214,102,0.12), 0 12px 24px rgba(0,0,0,0.16)'
|
||||
: '0 0 0 1px rgba(22,119,255,0.08), 0 10px 22px rgba(15,23,42,0.08)'
|
||||
);
|
||||
|
||||
export const getSecurityUpdateActionButtonStyle = (): CSSProperties => ({
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
paddingInline: 16,
|
||||
boxShadow: 'none',
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const getSecurityUpdateShellSurfaceStyle = (
|
||||
overlayTheme: OverlayWorkbenchTheme,
|
||||
surfaceOpacity = 1,
|
||||
): CSSProperties => ({
|
||||
border: applySurfaceOpacity(overlayTheme.shellBorder, surfaceOpacity),
|
||||
background: applySurfaceOpacity(overlayTheme.shellBg, surfaceOpacity),
|
||||
boxShadow: applySurfaceOpacity(overlayTheme.shellShadow, surfaceOpacity),
|
||||
backdropFilter: overlayTheme.shellBackdropFilter,
|
||||
});
|
||||
|
||||
export const getSecurityUpdateBannerSurfaceStyle = (
|
||||
overlayTheme: OverlayWorkbenchTheme,
|
||||
surfaceOpacity = 1,
|
||||
): CSSProperties => ({
|
||||
...getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
boxShadow: 'none',
|
||||
});
|
||||
|
||||
export const getSecurityUpdateSectionSurfaceStyle = (
|
||||
overlayTheme: OverlayWorkbenchTheme,
|
||||
options: SecurityUpdateSectionSurfaceOptions = {},
|
||||
): CSSProperties => ({
|
||||
border: applySurfaceOpacity(
|
||||
options.emphasized ? getSecurityUpdateHighlightBorder(overlayTheme) : overlayTheme.sectionBorder,
|
||||
options.surfaceOpacity,
|
||||
),
|
||||
background: applySurfaceOpacity(
|
||||
options.emphasized ? getSecurityUpdateHighlightBackground(overlayTheme) : overlayTheme.sectionBg,
|
||||
options.surfaceOpacity,
|
||||
),
|
||||
boxShadow: options.emphasized
|
||||
? applySurfaceOpacity(getSecurityUpdateHighlightShadow(overlayTheme), options.surfaceOpacity)
|
||||
: 'none',
|
||||
transition: 'background 180ms ease, border-color 180ms ease, box-shadow 180ms ease',
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
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: raw.substring(0, idx),
|
||||
objectName: raw.substring(idx + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSidebarConnectionDialect = (type: string, driver: string): string => {
|
||||
const normalizedType = String(type || '').trim().toLowerCase();
|
||||
if (normalizedType === 'custom') {
|
||||
const normalizedDriver = String(driver || '').trim().toLowerCase();
|
||||
if (normalizedDriver === 'postgresql' || normalizedDriver === 'postgres' || normalizedDriver === 'pg') return 'postgres';
|
||||
if (normalizedDriver === 'dameng' || normalizedDriver === 'dm' || normalizedDriver === 'dm8') return 'dm';
|
||||
if (normalizedDriver.includes('oracle')) return 'oracle';
|
||||
return normalizedDriver;
|
||||
}
|
||||
if (normalizedType === 'dameng') return 'dm';
|
||||
return normalizedType;
|
||||
};
|
||||
|
||||
export const normalizeSidebarViewName = (dialect: string, dbName: string, schemaName: string, viewName: string): string => {
|
||||
const normalizedDialect = String(dialect || '').trim().toLowerCase();
|
||||
const normalizedDbName = String(dbName || '').trim();
|
||||
const normalizedSchemaName = String(schemaName || '').trim();
|
||||
const normalizedViewName = String(viewName || '').trim();
|
||||
|
||||
if (!normalizedViewName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalizedDialect === 'mysql') {
|
||||
const parsed = splitQualifiedName(normalizedViewName);
|
||||
if (parsed.objectName) {
|
||||
return parsed.objectName;
|
||||
}
|
||||
return normalizedViewName;
|
||||
}
|
||||
|
||||
if (!normalizedSchemaName || normalizedViewName.includes('.')) {
|
||||
return normalizedViewName;
|
||||
}
|
||||
|
||||
return `${normalizedSchemaName}.${normalizedViewName}`;
|
||||
};
|
||||
|
||||
export const resolveSidebarRuntimeDatabase = (
|
||||
type: string,
|
||||
driver: string,
|
||||
savedDatabase: string,
|
||||
overrideDatabase?: string,
|
||||
clearDatabase: boolean = false,
|
||||
): string => {
|
||||
if (clearDatabase) return '';
|
||||
|
||||
const normalizedSavedDatabase = String(savedDatabase || '').trim();
|
||||
const normalizedOverrideDatabase = String(overrideDatabase || '').trim();
|
||||
if (!normalizedOverrideDatabase) {
|
||||
return normalizedSavedDatabase;
|
||||
}
|
||||
|
||||
const dialect = normalizeSidebarConnectionDialect(type, driver);
|
||||
if (dialect === 'oracle' || dialect === 'dm') {
|
||||
return normalizedSavedDatabase || normalizedOverrideDatabase;
|
||||
}
|
||||
|
||||
return normalizedOverrideDatabase;
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { SavedConnection, TabData } from '../types';
|
||||
import { buildTabDisplayTitle, resolveConnectionHostSummary } from './tabDisplay';
|
||||
|
||||
const redisConnection: SavedConnection = {
|
||||
id: 'redis-1',
|
||||
name: '订单缓存',
|
||||
config: {
|
||||
type: 'redis',
|
||||
host: '10.10.0.12',
|
||||
port: 6379,
|
||||
user: '',
|
||||
database: '',
|
||||
hosts: ['10.10.0.13:6379', '10.10.0.14:6379'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('tabDisplay', () => {
|
||||
it('builds compact host summary for multi-host redis connections', () => {
|
||||
expect(resolveConnectionHostSummary(redisConnection.config)).toBe('10.10.0.12 +2');
|
||||
});
|
||||
|
||||
it('adds connection and host identity to redis key tabs', () => {
|
||||
const redisKeysTab: TabData = {
|
||||
id: 'redis-keys-redis-1-db0',
|
||||
title: 'db0',
|
||||
type: 'redis-keys',
|
||||
connectionId: 'redis-1',
|
||||
redisDB: 0,
|
||||
};
|
||||
|
||||
expect(buildTabDisplayTitle(redisKeysTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] db0');
|
||||
});
|
||||
|
||||
it('normalizes redis command and monitor tabs to db-scoped labels', () => {
|
||||
const commandTab: TabData = {
|
||||
id: 'cmd-1',
|
||||
title: '命令 - db1',
|
||||
type: 'redis-command',
|
||||
connectionId: 'redis-1',
|
||||
redisDB: 1,
|
||||
};
|
||||
const monitorTab: TabData = {
|
||||
id: 'monitor-1',
|
||||
title: '监控: 订单缓存',
|
||||
type: 'redis-monitor',
|
||||
connectionId: 'redis-1',
|
||||
redisDB: 1,
|
||||
};
|
||||
|
||||
expect(buildTabDisplayTitle(commandTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] 命令 - db1');
|
||||
expect(buildTabDisplayTitle(monitorTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] 监控 - db1');
|
||||
});
|
||||
|
||||
it('keeps table tabs on the existing prefix strategy', () => {
|
||||
const tableTab: TabData = {
|
||||
id: 'table-1',
|
||||
title: 'orders',
|
||||
type: 'table',
|
||||
connectionId: 'redis-1',
|
||||
dbName: 'app',
|
||||
tableName: 'orders',
|
||||
};
|
||||
|
||||
expect(buildTabDisplayTitle(tableTab, redisConnection)).toBe('[订单缓存] orders');
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { ConnectionConfig, SavedConnection, TabData } from '../types';
|
||||
|
||||
export 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 parseHostOnlyToken = (value: unknown): string[] => {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let text = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
||||
if (text.includes('/')) {
|
||||
text = text.split('/')[0];
|
||||
}
|
||||
if (text.includes('?')) {
|
||||
text = text.split('?')[0];
|
||||
}
|
||||
if (text.includes('@')) {
|
||||
text = text.split('@').pop() || '';
|
||||
}
|
||||
|
||||
return text
|
||||
.split(',')
|
||||
.map((entry) => {
|
||||
const token = entry.trim();
|
||||
if (!token) return '';
|
||||
if (token.startsWith('[')) {
|
||||
const rightBracketIndex = token.indexOf(']');
|
||||
if (rightBracketIndex > 0) {
|
||||
return token.slice(0, rightBracketIndex + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
const colonIndex = token.lastIndexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
return token.slice(0, colonIndex).toLowerCase();
|
||||
}
|
||||
return token.toLowerCase();
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const resolveConnectionHostTokens = (config?: ConnectionConfig): string[] => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(new Set([
|
||||
...parseHostOnlyToken(config.host),
|
||||
...(Array.isArray(config.hosts) ? config.hosts.flatMap((entry) => parseHostOnlyToken(entry)) : []),
|
||||
...parseHostOnlyToken(config.uri),
|
||||
]));
|
||||
};
|
||||
|
||||
export const resolveConnectionHostSummary = (config?: ConnectionConfig): string => {
|
||||
const hosts = resolveConnectionHostTokens(config);
|
||||
if (hosts.length === 0) return '';
|
||||
if (hosts.length === 1) return hosts[0];
|
||||
return `${hosts[0]} +${hosts.length - 1}`;
|
||||
};
|
||||
|
||||
const isRedisTab = (tab: TabData): boolean => {
|
||||
return tab.type === 'redis-keys' || tab.type === 'redis-command' || tab.type === 'redis-monitor';
|
||||
};
|
||||
|
||||
const buildRedisBaseTitle = (tab: TabData): string => {
|
||||
const dbLabel = `db${tab.redisDB ?? 0}`;
|
||||
if (tab.type === 'redis-command') return `命令 - ${dbLabel}`;
|
||||
if (tab.type === 'redis-monitor') return `监控 - ${dbLabel}`;
|
||||
return dbLabel;
|
||||
};
|
||||
|
||||
export const buildTabDisplayTitle = (tab: TabData, connection?: SavedConnection): string => {
|
||||
const connectionName = String(connection?.name || '').trim();
|
||||
|
||||
if (isRedisTab(tab)) {
|
||||
const hostSummary = resolveConnectionHostSummary(connection?.config);
|
||||
const identity = [connectionName, hostSummary].filter(Boolean).join(' | ');
|
||||
return identity ? `[${identity}] ${buildRedisBaseTitle(tab)}` : buildRedisBaseTitle(tab);
|
||||
}
|
||||
|
||||
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}`;
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveVisibleStartupWindowBounds } from './windowRestoreBounds';
|
||||
|
||||
describe('windowRestoreBounds', () => {
|
||||
it('keeps existing bounds when the window still overlaps the visible area', () => {
|
||||
expect(resolveVisibleStartupWindowBounds(
|
||||
{ width: 1280, height: 820, x: -120, y: 40 },
|
||||
{ availWidth: 1920, availHeight: 1080, availLeft: 0, availTop: 0 },
|
||||
)).toEqual({ width: 1280, height: 820, x: -120, y: 40 });
|
||||
});
|
||||
|
||||
it('recenters bounds when the saved window is fully outside the visible area', () => {
|
||||
expect(resolveVisibleStartupWindowBounds(
|
||||
{ width: 1280, height: 820, x: 3200, y: 1800 },
|
||||
{ availWidth: 1920, availHeight: 1080, availLeft: 0, availTop: 0 },
|
||||
)).toEqual({ width: 1280, height: 820, x: 320, y: 130 });
|
||||
});
|
||||
|
||||
it('recenters bounds when the saved window is fully above and left of the visible area', () => {
|
||||
expect(resolveVisibleStartupWindowBounds(
|
||||
{ width: 900, height: 640, x: -1600, y: -900 },
|
||||
{ availWidth: 1600, availHeight: 900, availLeft: 0, availTop: 0 },
|
||||
)).toEqual({ width: 900, height: 640, x: 350, y: 130 });
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
export type WindowRestoreBounds = {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type VisibleViewport = {
|
||||
availWidth: number;
|
||||
availHeight: number;
|
||||
availLeft?: number;
|
||||
availTop?: number;
|
||||
};
|
||||
|
||||
const MIN_VISIBLE_WIDTH = 160;
|
||||
const MIN_VISIBLE_HEIGHT = 120;
|
||||
|
||||
export const resolveVisibleStartupWindowBounds = (
|
||||
bounds: WindowRestoreBounds,
|
||||
viewport: VisibleViewport,
|
||||
): WindowRestoreBounds => {
|
||||
const visibleWidth = Math.trunc(Number(viewport.availWidth) || 0);
|
||||
const visibleHeight = Math.trunc(Number(viewport.availHeight) || 0);
|
||||
if (visibleWidth <= 0 || visibleHeight <= 0) {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
const visibleLeft = Math.trunc(Number(viewport.availLeft) || 0);
|
||||
const visibleTop = Math.trunc(Number(viewport.availTop) || 0);
|
||||
const visibleRight = visibleLeft + visibleWidth;
|
||||
const visibleBottom = visibleTop + visibleHeight;
|
||||
|
||||
const overlapWidth = Math.min(bounds.x + bounds.width, visibleRight) - Math.max(bounds.x, visibleLeft);
|
||||
const overlapHeight = Math.min(bounds.y + bounds.height, visibleBottom) - Math.max(bounds.y, visibleTop);
|
||||
if (
|
||||
overlapWidth >= Math.min(MIN_VISIBLE_WIDTH, bounds.width) &&
|
||||
overlapHeight >= Math.min(MIN_VISIBLE_HEIGHT, bounds.height)
|
||||
) {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
return {
|
||||
...bounds,
|
||||
x: visibleLeft + Math.max(0, Math.trunc((visibleWidth - bounds.width) / 2)),
|
||||
y: visibleTop + Math.max(0, Math.trunc((visibleHeight - bounds.height) / 2)),
|
||||
};
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './windowStateUi';
|
||||
|
||||
describe('windowStateUi', () => {
|
||||
it('does not re-toggle a maximized window on activation when focus returns', () => {
|
||||
expect(shouldToggleMaximisedWindowForScaleFix('activation', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('switches the titlebar toggle icon to restore when the window is maximized', () => {
|
||||
expect(resolveTitleBarToggleIconKey('maximized')).toBe('restore');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
export type WindowVisualState = 'normal' | 'maximized' | 'fullscreen';
|
||||
export type WindowScaleFixReason = 'activation' | 'ratio-change';
|
||||
export type TitleBarToggleIconKey = 'maximize' | 'restore';
|
||||
|
||||
export const shouldToggleMaximisedWindowForScaleFix = (
|
||||
reason: WindowScaleFixReason,
|
||||
hasViewportScaleDrift: boolean,
|
||||
): boolean => reason === 'ratio-change' && hasViewportScaleDrift;
|
||||
|
||||
export const resolveTitleBarToggleIconKey = (windowState: WindowVisualState): TitleBarToggleIconKey =>
|
||||
windowState === 'maximized' ? 'restore' : 'maximize';
|
||||
7
frontend/src/vite-env.d.ts
vendored
7
frontend/src/vite-env.d.ts
vendored
@@ -1,9 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
25
frontend/wailsjs/go/app/App.d.ts
vendored
25
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -2,7 +2,6 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {connection} from '../models';
|
||||
import {sync} from '../models';
|
||||
import {app} from '../models';
|
||||
import {redis} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
@@ -17,8 +16,6 @@ export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdatesSilently():Promise<connection.QueryResult>;
|
||||
|
||||
export function ClearTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -61,8 +58,6 @@ export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Pr
|
||||
|
||||
export function DeleteConnection(arg1:string):Promise<void>;
|
||||
|
||||
export function DismissSecurityUpdateReminder():Promise<app.SecurityUpdateStatus>;
|
||||
|
||||
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DownloadUpdate():Promise<connection.QueryResult>;
|
||||
@@ -79,8 +74,6 @@ export function DuplicateConnection(arg1:string):Promise<connection.SavedConnect
|
||||
|
||||
export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportConnectionsPackage(arg1:app.ConnectionExportOptions):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
|
||||
@@ -109,12 +102,8 @@ export function GetGlobalProxyConfig():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetSavedConnections():Promise<Array<connection.SavedConnectionView>>;
|
||||
|
||||
export function GetSecurityUpdateStatus():Promise<app.SecurityUpdateStatus>;
|
||||
|
||||
export function ImportConfigFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportConnectionsPayload(arg1:string,arg2:string):Promise<Array<connection.SavedConnectionView>>;
|
||||
|
||||
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
@@ -149,15 +138,11 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise<connection.Quer
|
||||
|
||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ListSQLDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ReadSQLFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:any):Promise<connection.QueryResult>;
|
||||
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisDeleteKeys(arg1:connection.ConnectionConfig,arg2:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -217,14 +202,8 @@ export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise
|
||||
|
||||
export function ResolveDriverRepositoryURL(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RestartSecurityUpdate(arg1:app.RestartSecurityUpdateRequest):Promise<app.SecurityUpdateStatus>;
|
||||
|
||||
export function RetrySecurityUpdateCurrentRound(arg1:app.RetrySecurityUpdateRequest):Promise<app.SecurityUpdateStatus>;
|
||||
|
||||
export function SaveConnection(arg1:connection.SavedConnectionInput):Promise<connection.SavedConnectionView>;
|
||||
|
||||
export function SelectSQLDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
|
||||
|
||||
export function SelectDataRootDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -243,8 +222,6 @@ export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function StartSecurityUpdate(arg1:app.StartSecurityUpdateRequest):Promise<app.SecurityUpdateStatus>;
|
||||
|
||||
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user