Compare commits

..

24 Commits

Author SHA1 Message Date
tianqijiuyun-latiao
4a830c7a82 chore(ci): 删除旧的 macOS 单平台测试工作流 2026-03-07 13:32:55 +08:00
tianqijiuyun-latiao
adab24085e fix(db-query-value): 清理 query_value 合并冲突并保持零日期处理 2026-03-07 13:07:00 +08:00
tianqijiuyun-latiao
cc201f3df6 Merge branch 'refs/heads/main' into feature/kingbase_opt
# Conflicts:
#	.github/workflows/test-build-all-platforms.yml
#	internal/db/query_value.go
#	internal/db/query_value_test.go
2026-03-07 12:58:02 +08:00
tianqijiuyun-latiao
57d6ace2f8 fix(data-grid-scroll): 修复数据区触摸板横向滚动失效 refs #175 2026-03-06 22:39:36 +08:00
tianqijiuyun-latiao
0f5dc2184d fix(window-scale): 修复任务栏切换后字体异常放大 refs #193 2026-03-06 20:27:45 +08:00
tianqijiuyun-latiao
0b95908ae9 fix(datetime-display): 修复零日期显示被错误转换 refs #189 2026-03-06 19:35:33 +08:00
tianqijiuyun-latiao
4c322818bc fix(data-viewer): 保持切换标签后的表格滚动位置 2026-03-06 19:22:07 +08:00
tianqijiuyun-latiao
3fefa13023 fix(ci): 修复全平台测试包 artifact 命名冲突 2026-03-06 18:24:28 +08:00
tianqijiuyun-latiao
1be003b0a2 chore(ci): 新增全平台测试包手动构建工作流 2026-03-06 16:26:29 +08:00
tianqijiuyun-latiao
0a0609c459 fix(query-execution): 支持带前置注释的读查询结果识别 2026-03-06 16:05:34 +08:00
tianqijiuyun-latiao
0682baa14d fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178 2026-03-06 15:39:51 +08:00
tianqijiuyun-latiao
6c41e15e99 fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157 2026-03-06 12:37:10 +08:00
tianqijiuyun-latiao
ba51fa658c Merge branch 'refs/heads/main' into feature/kingbase_opt 2026-03-06 09:57:37 +08:00
tianqijiuyun-latiao
18ed4ca50c fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154 2026-03-06 08:01:40 +08:00
tianqijiuyun-latiao
37704e2b3b fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155 2026-03-06 07:54:51 +08:00
tianqijiuyun-latiao
6b9104fae8 fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181 2026-03-06 07:49:41 +08:00
tianqijiuyun-latiao
647768221e feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174 2026-03-06 00:53:40 +08:00
tianqijiuyun-latiao
251e1b22d7 fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185 2026-03-06 00:42:12 +08:00
tianqijiuyun-latiao
c99d4b1fa6 chore(ci): 允许测试工作流在当前分支自动触发 2026-03-06 00:02:47 +08:00
tianqijiuyun-latiao
ff98ec79a4 chore(ci): 新增手动触发的 macOS 测试构建工作流 2026-03-05 23:58:19 +08:00
tianqijiuyun-latiao
072b4e6e78 fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败
refs #177
2026-03-05 23:36:46 +08:00
tianqijiuyun-latiao
2449184ad3 fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误
refs #176
2026-03-05 21:10:36 +08:00
tianqijiuyun-latiao
6ae49d4b84 fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销
refs #178
2026-03-05 20:42:33 +08:00
tianqijiuyun-latiao
5c23722ad8 feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源
refs #168
2026-03-05 19:23:00 +08:00
499 changed files with 7598 additions and 109657 deletions

View File

@@ -1,674 +0,0 @@
name: Dev Build
on:
push:
branches:
- dev
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: darwin/amd64
os_name: MacOS
arch_name: Amd64
build_name: gonavi-build-darwin-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: macos-latest
platform: darwin/arm64
os_name: MacOS
arch_name: Arm64
build_name: gonavi-build-darwin-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/amd64
os_name: Windows
arch_name: Amd64
build_name: gonavi-build-windows-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/arm64
os_name: Windows
arch_name: Arm64
build_name: gonavi-build-windows-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: ubuntu-22.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-build-linux-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-24.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-build-linux-amd64-webkit41
wails_tags: "webkit2_41"
artifact_suffix: "-WebKit41"
build_optional_agents: false
linux_webkit: "4.1"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
check-latest: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$UPX_VERSION = "4.2.4"
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
$zipPath = "$env:RUNNER_TEMP\upx.zip"
$extractPath = "$env:RUNNER_TEMP\upx"
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
if (!(Test-Path $upxCmd)) {
Write-Error "❌ 未检测到 upx无法保证 Windows 产物经过压缩"
exit 1
}
& $upxCmd --version
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
else
sudo apt-get install -y libwebkit2gtk-4.0-dev
fi
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
upx --version
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
echo "📥 下载 linuxdeploy..."
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
-O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
echo "⚠️ linuxdeploy 下载失败AppImage 打包将跳过"
touch /tmp/skip-appimage
}
echo "📥 下载 linuxdeploy-plugin-gtk..."
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
-O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
echo "⚠️ linuxdeploy-plugin-gtk 下载失败AppImage 打包将跳过"
touch /tmp/skip-appimage
}
if [ ! -f /tmp/skip-appimage ]; then
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
echo "✅ AppImage 工具准备完成"
fi
- name: Install Wails
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
id: msys2_duckdb
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
continue-on-error: true
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
shell: pwsh
run: |
function Find-MingwBin([string[]]$candidates) {
foreach ($bin in $candidates) {
if ([string]::IsNullOrWhiteSpace($bin)) {
continue
}
$gcc = Join-Path $bin 'gcc.exe'
$gxx = Join-Path $bin 'g++.exe'
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
return $bin
}
}
return $null
}
$msys2Outcome = "${{ steps.msys2_duckdb.outcome }}"
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
$candidateBins = @()
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
}
$candidateBins += @(
'C:\msys64\ucrt64\bin',
'D:\a\_temp\msys64\ucrt64\bin'
)
$candidateBins = @($candidateBins | Select-Object -Unique)
$mingwBin = Find-MingwBin $candidateBins
if (-not $mingwBin) {
if ($msys2Outcome -ne 'success') {
Write-Warning "⚠️ MSYS2 安装步骤结果为 $msys2Outcome回退到 UCRT64 本机路径探测"
} else {
Write-Warning "⚠️ MSYS2 已执行,但未找到 UCRT64 gcc/g++,回退到本机路径探测"
}
$mingwBin = Find-MingwBin $candidateBins
}
if (-not $mingwBin) {
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。已检查:$($candidateBins -join ', ')"
exit 1
}
$gcc = (Join-Path $mingwBin 'gcc.exe')
$gxx = (Join-Path $mingwBin 'g++.exe')
if (!(Test-Path $gcc) -or !(Test-Path $gxx)) {
Write-Error "❌ DuckDB 编译器缺失gcc=$gcc g++=$gxx"
exit 1
}
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx"
- name: Verify DuckDB CGO Toolchain (Windows AMD64)
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
shell: pwsh
run: |
& "$env:CC" --version
& "$env:CXX" --version
# ---- 生成 dev 版本号 ----
- name: Generate Dev Version
id: version
shell: bash
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
DEV_VERSION="dev-${SHORT_SHA}"
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
echo "📌 Dev 版本号: ${DEV_VERSION}"
- name: Build
shell: bash
run: |
set -euo pipefail
DEV_VERSION="${{ steps.version.outputs.version }}"
if [ -n "${{ matrix.wails_tags }}" ]; then
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
else
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
fi
- name: Build Optional Driver Agents
if: ${{ matrix.build_optional_agents }}
shell: bash
run: |
set -euo pipefail
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
fi
TAG="gonavi_${BUILD_DRIVER}_driver"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
if [ "$DRIVER" = "duckdb" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
fi
done
# macOS Packaging
- name: Package macOS DMG
if: contains(matrix.platform, 'darwin')
run: |
brew install create-dmg
VERSION="${{ steps.version.outputs.version }}"
cd build/bin
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$APP_PATH" ]; then
echo "❌ 未找到 .app 应用包!"
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
echo "❌ 未找到 macOS 应用主程序!"
exit 1
fi
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"
FINAL_NAME="GoNavi-${VERSION}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
echo "📦 正在生成 DMG: $DMG_NAME..."
create-dmg \
--volname "GoNavi Dev Build" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_NAME" 200 190 \
--hide-extension "$APP_NAME" \
--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"
# Windows Packaging
- name: Package Windows EXE
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
Set-Location build/bin
$version = "${{ steps.version.outputs.version }}"
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
} elseif (Test-Path "$target") {
Rename-Item -Path "$target" -NewName "$target.exe"
$finalExe = "$target.exe"
} else {
Write-Error "❌ 未找到构建产物 '$target'!"
exit 1
}
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
if ($isArm64Target) {
Write-Warning "⚠️ UPX 当前不支持 win64/arm64跳过压缩并保留原始 EXE。"
$LASTEXITCODE = 0
} else {
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 压缩失败($LASTEXITCODE"
exit 1
}
& upx -t $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 校验失败($LASTEXITCODE"
exit 1
}
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
}
}
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
# Linux Packaging
- name: Package Linux
if: contains(matrix.platform, 'linux')
run: |
VERSION="${{ steps.version.outputs.version }}"
cd build/bin
TARGET="${{ matrix.build_name }}"
TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.tar.gz"
APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.AppImage"
if [ ! -f "$TARGET" ]; then
echo "❌ 未找到构建产物 '$TARGET'!"
exit 1
fi
chmod +x "$TARGET"
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
echo "🗜️ 正在使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
upx --best --lzma --force "$TARGET"
upx -t "$TARGET"
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
echo "📦 正在打包 $TAR_NAME..."
tar -czvf "$TAR_NAME" "$TARGET"
mv "$TAR_NAME" ../../
if [ -f /tmp/skip-appimage ]; then
echo "⚠️ 跳过 AppImage 打包"
exit 0
fi
echo "📦 正在生成 AppImage..."
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
cp "$TARGET" AppDir/usr/bin/gonavi
printf '%s\n' \
'[Desktop Entry]' \
'Name=GoNavi' \
'Exec=gonavi' \
'Icon=gonavi' \
'Type=Application' \
'Categories=Development;Database;' \
'Comment=Database Management Tool' \
> AppDir/usr/share/applications/gonavi.desktop
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
if [ -f "../../build/appicon.png" ]; then
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
cp "../../build/appicon.png" AppDir/gonavi.png
else
convert -size 256x256 xc:#336791 -fill white -gravity center -pointsize 48 -annotate 0 "GoNavi" AppDir/gonavi.png || \
wget -q "https://via.placeholder.com/256/336791/FFFFFF?text=GoNavi" -O AppDir/gonavi.png || \
touch AppDir/gonavi.png
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
fi
export DEPLOY_GTK_VERSION=3
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || {
echo "⚠️ AppImage 生成失败,但 tar.gz 已成功生成"
exit 0
}
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || {
echo "⚠️ AppImage 重命名失败"
exit 0
}
if [ -f "$APPIMAGE_NAME" ]; then
mv "$APPIMAGE_NAME" ../../
echo "✅ AppImage 生成成功"
fi
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: dev-build-artifacts-${{ strategy.job-index }}
path: |
GoNavi-*.dmg
GoNavi-*.exe
GoNavi-*.tar.gz
GoNavi-*.AppImage
drivers/**
retention-days: 7
# 汇总所有产物并发布为 Pre-release
release:
name: Publish Dev Pre-release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download All Artifacts
uses: actions/download-artifact@v4
with:
path: release-assets
pattern: dev-build-artifacts-*
merge-multiple: true
- name: List Assets
run: ls -R release-assets
- name: Package Driver Agents Bundle
shell: bash
run: |
set -euo pipefail
cd release-assets
if [ ! -d drivers ]; then
echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包"
exit 0
fi
if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then
echo "⚠️ drivers 目录为空,跳过驱动总包打包"
rm -rf drivers
exit 0
fi
echo "📦 打包驱动总包GoNavi-DriverAgents.zip"
python3 - <<'PY'
import json
import os
import zipfile
from pathlib import Path
out_name = "GoNavi-DriverAgents.zip"
index_name = "GoNavi-DriverAgents-Index.json"
base = Path("drivers")
out_path = Path(out_name)
index_path = Path(index_name)
if out_path.exists():
out_path.unlink()
if index_path.exists():
index_path.unlink()
size_index = {}
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in base.rglob("*"):
if not p.is_file():
continue
arcname = p.relative_to(base).as_posix()
zf.write(p, arcname)
size_index[p.name] = p.stat().st_size
index_path.write_text(
json.dumps({"assets": size_index}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"created {out_name} size={out_path.stat().st_size} bytes")
print(f"created {index_name} entries={len(size_index)}")
PY
rm -rf drivers
- name: Generate SHA256SUMS
shell: bash
run: |
cd release-assets
FILES=()
while IFS= read -r file; do
if [ -n "$file" ]; then
FILES+=("$file")
fi
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
if [ ${#FILES[@]} -eq 0 ]; then
echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS"
: > SHA256SUMS
else
sha256sum "${FILES[@]}" > SHA256SUMS
fi
- name: Generate Dev Version
id: version
run: |
SHORT_SHA="${GITHUB_SHA:0:7}"
DEV_VERSION="dev-${SHORT_SHA}"
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
- name: Format Build Time
id: build_time
shell: bash
run: |
python3 - <<'PY' >> "$GITHUB_OUTPUT"
from datetime import datetime, timezone, timedelta
raw = "${{ github.event.head_commit.timestamp }}"
dt = datetime.fromisoformat(raw)
china_tz = timezone(timedelta(hours=8))
formatted = dt.astimezone(china_tz).strftime("%Y-%m-%d %H:%M:%S")
print(f"display={formatted}")
PY
# 删除旧的 dev pre-release保持只有最新一个
- name: Reset Previous Dev Release
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const tag = 'dev-latest';
const ref = `tags/${tag}`;
const { owner, repo } = context.repo;
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const matchedReleases = releases.filter((release) => release.tag_name === tag);
if (matchedReleases.length === 0) {
core.info(`No existing releases found for tag ${tag}`);
} else {
for (const release of matchedReleases) {
core.info(`Deleting release ${release.id} (${release.name || 'unnamed'}) for tag ${tag}`);
await github.rest.repos.deleteRelease({
owner,
repo,
release_id: release.id,
});
}
}
try {
await github.rest.git.deleteRef({
owner,
repo,
ref,
});
core.info(`Deleted ref ${ref}`);
} catch (error) {
if (error.status === 404) {
core.info(`No existing ref found for ${ref}`);
} else {
throw error;
}
}
- name: Create Dev Pre-release
uses: softprops/action-gh-release@v2
with:
tag_name: dev-latest
name: "🧪 Dev Build (${{ steps.version.outputs.version }})"
target_commitish: ${{ github.sha }}
files: release-assets/*
prerelease: true
draft: false
body: |
## 🧪 测试版本 (Dev Build)
**版本**: `${{ steps.version.outputs.version }}`
**分支**: `dev`
**提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
**构建时间**: ${{ steps.build_time.outputs.display }}
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
> 每次 push 到 `dev` 分支会自动覆盖此 release。
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -10,9 +10,6 @@ on:
description: 'Tag of release you want to publish'
type: string
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
publish:
runs-on: windows-latest

View File

@@ -8,9 +8,6 @@ on:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# Phase 1: Build in parallel and output artifacts
build:
@@ -91,26 +88,6 @@ jobs:
with:
node-version: '20'
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$UPX_VERSION = "4.2.4"
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
$zipPath = "$env:RUNNER_TEMP\upx.zip"
$extractPath = "$env:RUNNER_TEMP\upx"
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
if (!(Test-Path $upxCmd)) {
Write-Error "❌ 未检测到 upx无法保证 Windows 产物经过压缩"
exit 1
}
& $upxCmd --version
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
@@ -125,9 +102,6 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.0-dev
fi
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
upx --version
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
@@ -303,20 +277,10 @@ jobs:
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
echo "❌ 未找到 macOS 应用主程序!"
exit 1
fi
echo " macOS 产物不执行 UPX 压缩,保留原始主程序。"
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,22 +297,11 @@ 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"
# Windows Packaging
- name: Package Windows EXE
- name: Package Windows Portable Zip
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
@@ -359,6 +312,7 @@ jobs:
}
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
$finalZipName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.zip"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
@@ -370,40 +324,12 @@ jobs:
exit 1
}
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
if ($isArm64Target) {
Write-Warning "⚠️ UPX 当前不支持 win64/arm64跳过压缩并保留原始 EXE。"
$LASTEXITCODE = 0
} else {
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 压缩失败($LASTEXITCODE"
exit 1
}
& upx -t $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 校验失败($LASTEXITCODE"
exit 1
}
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
}
}
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
Write-Host "📦 生成 Windows 可执行文件 $finalExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
Write-Host "📦 生成 Windows 压缩包 $finalZipName..."
Compress-Archive -LiteralPath $finalExe -DestinationPath "..\\..\\$finalZipName" -Force
# Linux Packaging (tar.gz and AppImage)
- name: Package Linux
if: contains(matrix.platform, 'linux')
@@ -421,17 +347,6 @@ jobs:
fi
chmod +x "$TARGET"
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
echo "🗜️ 正在使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
upx --best --lzma --force "$TARGET"
upx -t "$TARGET"
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
# 1. Create tar.gz
echo "📦 正在打包 $TAR_NAME..."
@@ -504,6 +419,7 @@ jobs:
path: |
GoNavi-*.dmg
GoNavi-*.exe
GoNavi-*.zip
GoNavi-*.tar.gz
GoNavi-*.AppImage
drivers/**
@@ -627,74 +543,6 @@ jobs:
sha256sum "${FILES[@]}" > SHA256SUMS
fi
- name: Checkout code for changelog
uses: actions/checkout@v4
with:
fetch-depth: 0
path: repo-for-changelog
- name: Generate Changelog
id: changelog
shell: bash
run: |
set -euo pipefail
cd repo-for-changelog
TAG="${{ github.ref_name }}"
# 获取上一个 tag
PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true)
if [ -z "$PREV_TAG" ]; then
echo "⚠️ 未找到上一个 tag使用全部 commit"
RANGE="$TAG"
else
RANGE="${PREV_TAG}..${TAG}"
fi
echo "📋 生成更新日志:$RANGE"
# 提取 commit 消息(排除 merge commit
COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true)
if [ -z "$COMMITS" ]; then
BODY="暂无提交记录。"
else
CAT_FEAT=""
CAT_FIX=""
CAT_PERF=""
CAT_REFACTOR=""
CAT_I18N=""
CAT_OTHER=""
while IFS= read -r line; do
[ -z "$line" ] && continue
case "$line" in
✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;;
🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;;
⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;;
♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;;
🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;;
🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
esac
done <<< "$COMMITS"
BODY=""
[ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n"
[ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n"
[ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n"
[ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n"
[ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n"
[ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n"
# 附加 compare 链接
if [ -n "$PREV_TAG" ]; then
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n"
fi
fi
# 写入到文件避免多行环境变量问题
printf '%b' "$BODY" > /tmp/changelog.md
echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT"
- name: Create Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
@@ -702,6 +550,6 @@ jobs:
files: release-assets/*
draft: true
make_latest: true
body_path: ${{ steps.changelog.outputs.changelog_file }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

159
.github/workflows/sync-main-to-dev.yml vendored Normal file
View File

@@ -0,0 +1,159 @@
name: main 回灌 dev
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: sync-main-to-dev
cancel-in-progress: true
jobs:
sync-main-to-dev:
name: 执行回灌同步
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 检查是否需要同步
id: diff_check
shell: bash
run: |
set -euo pipefail
echo "开始检查 main 与 dev 的分支差异..."
git fetch origin main dev
ahead_count="$(git rev-list --count origin/dev..origin/main)"
echo "ahead_count=${ahead_count}" >> "$GITHUB_OUTPUT"
if [ "${ahead_count}" -eq 0 ]; then
echo "无需同步dev 已包含 main 的最新提交。"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "检测到 ${ahead_count} 个待同步提交,准备创建或复用同步 PR。"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
- name: 创建或复用同步 PR
id: sync_pr
if: steps.diff_check.outputs.has_changes == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
echo "permission_blocked=false" >> "$GITHUB_OUTPUT"
existing_number="$(gh pr list --base dev --head main --state open --json number --jq '.[0].number // empty')"
if [ -n "${existing_number}" ]; then
pr_number="${existing_number}"
pr_url="$(gh pr view "${pr_number}" --json url --jq '.url')"
echo "复用已有同步 PR#${pr_number}"
echo "created=false" >> "$GITHUB_OUTPUT"
else
body_file="$(mktemp)"
error_file="$(mktemp)"
{
echo "## 自动回灌:\`main -> dev\`"
echo
echo "- 触发条件:\`main\` 分支出现新提交(含贡献者直接合并到 \`main\` 的 PR"
echo "- 目标:让 \`dev\` 持续吸收 \`main\` 的更新,避免发布前集中冲突"
echo
echo "### 合并建议"
echo "- 无冲突:直接合并该 PR建议 \`Merge commit\`"
echo "- 有冲突:在该 PR 内解决冲突后再合并"
} > "${body_file}"
if pr_url="$(gh pr create \
--base dev \
--head main \
--title "🔁 chore(sync): 回灌 main 到 dev" \
--body-file "${body_file}" 2>"${error_file}")"; then
pr_number="${pr_url##*/}"
echo "已创建同步 PR#${pr_number}"
echo "created=true" >> "$GITHUB_OUTPUT"
else
error_message="$(tr '\n' ' ' < "${error_file}")"
if printf '%s' "${error_message}" | grep -Fq "GitHub Actions is not permitted to create or approve pull requests"; then
echo "::warning::仓库未开启“Allow GitHub Actions to create and approve pull requests”已跳过自动创建同步 PR。"
echo "permission_blocked=true" >> "$GITHUB_OUTPUT"
echo "created=false" >> "$GITHUB_OUTPUT"
echo "pr_number=" >> "$GITHUB_OUTPUT"
echo "pr_url=" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::error::创建同步 PR 失败:${error_message}"
exit 1
fi
fi
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
echo "pr_url=${pr_url}" >> "$GITHUB_OUTPUT"
- name: 检查合并状态
id: merge_state
if: steps.diff_check.outputs.has_changes == 'true' && steps.sync_pr.outputs.permission_blocked != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
pr_number="${{ steps.sync_pr.outputs.pr_number }}"
mergeable="$(gh pr view "${pr_number}" --json mergeable --jq '.mergeable')"
merge_state_status="$(gh pr view "${pr_number}" --json mergeStateStatus --jq '.mergeStateStatus')"
echo "PR #${pr_number} 合并状态mergeable=${mergeable}, mergeStateStatus=${merge_state_status}"
echo "mergeable=${mergeable}" >> "$GITHUB_OUTPUT"
echo "merge_state_status=${merge_state_status}" >> "$GITHUB_OUTPUT"
- name: 可合并时开启自动合并
id: auto_merge
if: steps.diff_check.outputs.has_changes == 'true' && steps.sync_pr.outputs.permission_blocked != 'true' && steps.merge_state.outputs.mergeable == 'MERGEABLE'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
pr_number="${{ steps.sync_pr.outputs.pr_number }}"
if gh pr merge "${pr_number}" --merge --auto; then
echo "已为 PR #${pr_number} 开启自动合并。"
echo "result=enabled" >> "$GITHUB_OUTPUT"
else
echo "::warning::自动合并开启失败,请手动处理并合并该 PR。"
echo "result=failed" >> "$GITHUB_OUTPUT"
fi
- name: 写入执行摘要
if: always()
shell: bash
run: |
{
echo "## main 回灌 dev 执行结果"
if [ "${{ steps.diff_check.outputs.has_changes }}" != "true" ]; then
echo "- 状态无需同步dev 已包含 main 最新提交)"
exit 0
fi
if [ "${{ steps.sync_pr.outputs.permission_blocked }}" = "true" ]; then
echo "- 状态:已跳过自动创建同步 PR"
echo "- 原因:仓库未开启 GitHub Actions 创建与审批 Pull Request 权限"
echo "- 处理:前往 Settings -> Actions -> General -> Workflow permissions开启 Allow GitHub Actions to create and approve pull requests"
echo "- 兜底:由维护者手动执行 main 到 dev 合并,或开启该设置后重新运行 workflow"
exit 0
fi
echo "- PR${{ steps.sync_pr.outputs.pr_url }}"
echo "- 可合并状态:${{ steps.merge_state.outputs.mergeable }}"
echo "- 合并状态详情:${{ steps.merge_state.outputs.merge_state_status }}"
if [ "${{ steps.merge_state.outputs.mergeable }}" = "CONFLICTING" ]; then
echo "- 结论:检测到冲突,需要手动处理后合并"
elif [ "${{ steps.auto_merge.outputs.result }}" = "enabled" ]; then
echo "- 结论:已启用自动合并(满足保护规则后将自动入 dev"
else
echo "- 结论PR 已创建/复用,请按分支策略人工合并"
fi
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -0,0 +1,342 @@
name: Test Build All Platforms (Manual)
on:
workflow_dispatch:
inputs:
build_label:
description: "测试包标识(仅用于文件名)"
required: false
default: "test"
permissions:
contents: read
concurrency:
group: test-build-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: darwin/amd64
os_name: MacOS
arch_name: Amd64
build_name: gonavi-test-darwin-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: macos-latest
platform: darwin/arm64
os_name: MacOS
arch_name: Arm64
build_name: gonavi-test-darwin-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/amd64
os_name: Windows
arch_name: Amd64
build_name: gonavi-test-windows-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/arm64
os_name: Windows
arch_name: Arm64
build_name: gonavi-test-windows-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: ubuntu-22.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-test-linux-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-24.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-test-linux-amd64-webkit41
wails_tags: "webkit2_41"
artifact_suffix: "-WebKit41"
build_optional_agents: false
linux_webkit: "4.1"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
check-latest: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
else
sudo apt-get install -y libwebkit2gtk-4.0-dev
fi
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
echo "skip-appimage=true" >> "$GITHUB_ENV"
}
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
echo "skip-appimage=true" >> "$GITHUB_ENV"
}
if [ "${skip-appimage:-false}" != "true" ]; then
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
fi
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
id: msys2_duckdb
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
continue-on-error: true
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
shell: pwsh
run: |
function Find-MingwBin([string[]]$candidates) {
foreach ($bin in $candidates) {
if ([string]::IsNullOrWhiteSpace($bin)) {
continue
}
$gcc = Join-Path $bin 'gcc.exe'
$gxx = Join-Path $bin 'g++.exe'
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
return $bin
}
}
return $null
}
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
$candidateBins = @()
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
}
$candidateBins += @(
'C:\msys64\ucrt64\bin',
'D:\a\_temp\msys64\ucrt64\bin'
)
$candidateBins = @($candidateBins | Select-Object -Unique)
$mingwBin = Find-MingwBin $candidateBins
if (-not $mingwBin) {
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。"
exit 1
}
$gcc = Join-Path $mingwBin 'gcc.exe'
$gxx = Join-Path $mingwBin 'g++.exe'
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build App
shell: bash
run: |
set -euo pipefail
BUILD_LABEL="${{ inputs.build_label }}"
if [ -z "$BUILD_LABEL" ]; then
BUILD_LABEL="test"
fi
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
if [ -n "${{ matrix.wails_tags }}" ]; then
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
else
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
fi
- name: Build Optional Driver Agents
if: ${{ matrix.build_optional_agents }}
shell: bash
run: |
set -euo pipefail
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "跳过 DuckDB driver: ${GOOS}/${GOARCH}"
continue
fi
TAG="gonavi_${BUILD_DRIVER}_driver"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
if [ "$DRIVER" = "duckdb" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
fi
done
- name: Package macOS
if: contains(matrix.platform, 'darwin')
shell: bash
run: |
set -euo pipefail
brew install create-dmg
LABEL="${{ inputs.build_label }}"
if [ -z "$LABEL" ]; then
LABEL="test"
fi
cd build/bin
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$APP_PATH" ]; then
echo "未找到 .app 应用包"
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
codesign --force --deep --sign - "$APP_NAME"
ZIP_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.zip"
DMG_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.dmg"
mkdir -p ../../artifacts
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "../../artifacts/$ZIP_NAME"
create-dmg \
--volname "GoNavi Test Installer" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_NAME" 200 190 \
--hide-extension "$APP_NAME" \
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
mv "$DMG_NAME" "../../artifacts/$DMG_NAME"
shasum -a 256 "../../artifacts/$ZIP_NAME" > "../../artifacts/$ZIP_NAME.sha256"
shasum -a 256 "../../artifacts/$DMG_NAME" > "../../artifacts/$DMG_NAME.sha256"
- name: Package Windows
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$label = "${{ inputs.build_label }}"
if ([string]::IsNullOrWhiteSpace($label)) { $label = 'test' }
Set-Location build/bin
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.exe"
$finalZipName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.zip"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
} elseif (Test-Path "$target") {
Rename-Item -Path "$target" -NewName "$target.exe"
$finalExe = "$target.exe"
} else {
Write-Error "未找到构建产物 '$target'"
exit 1
}
New-Item -ItemType Directory -Force -Path ..\..\artifacts | Out-Null
Copy-Item -LiteralPath $finalExe -Destination "..\..\artifacts\$finalExeName" -Force
Compress-Archive -LiteralPath $finalExe -DestinationPath "..\..\artifacts\$finalZipName" -Force
Get-FileHash "..\..\artifacts\$finalExeName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalExeName.sha256" -Encoding ascii
Get-FileHash "..\..\artifacts\$finalZipName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalZipName.sha256" -Encoding ascii
- name: Package Linux
if: contains(matrix.platform, 'linux')
shell: bash
run: |
set -euo pipefail
LABEL="${{ inputs.build_label }}"
if [ -z "$LABEL" ]; then
LABEL="test"
fi
cd build/bin
TARGET="${{ matrix.build_name }}"
TAR_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.tar.gz"
APPIMAGE_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.AppImage"
mkdir -p ../../artifacts
if [ ! -f "$TARGET" ]; then
echo "未找到构建产物 '$TARGET'"
exit 1
fi
chmod +x "$TARGET"
tar -czvf "../../artifacts/$TAR_NAME" "$TARGET"
sha256sum "../../artifacts/$TAR_NAME" > "../../artifacts/$TAR_NAME.sha256"
if [ "${skip-appimage:-false}" = "true" ]; then
echo "跳过 AppImage 打包"
exit 0
fi
mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps
cp "$TARGET" AppDir/usr/bin/gonavi
printf '%s\n' '[Desktop Entry]' 'Name=GoNavi' 'Exec=gonavi' 'Icon=gonavi' 'Type=Application' 'Categories=Development;Database;' 'Comment=Database Management Tool' > AppDir/usr/share/applications/gonavi.desktop
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
if [ -f "../../build/appicon.png" ]; then
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
cp "../../build/appicon.png" AppDir/gonavi.png
else
touch AppDir/gonavi.png
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
fi
export DEPLOY_GTK_VERSION=3
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || exit 0
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || exit 0
mv "$APPIMAGE_NAME" "../../artifacts/$APPIMAGE_NAME"
sha256sum "../../artifacts/$APPIMAGE_NAME" > "../../artifacts/$APPIMAGE_NAME.sha256"
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: test-build-${{ matrix.build_name }}-run${{ github.run_number }}
path: |
artifacts/*
drivers/**
if-no-files-found: error
retention-days: 7

12
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# IDE
.idea/
*.iml
.gitignore
# build / release artifacts
frontend/release/
**/release/
@@ -17,16 +17,8 @@ dist/
GoNavi-Wails
GoNavi-Wails.exe
.ace-tool/
.superpowers/
.claude/
.gemini/
.playwright-mcp/
**/tmpclaude-*
docs/superpowers/
docs/需求追踪/
tmpclaude-*
CLAUDE.md
**/CLAUDE.md
.worktrees
docs
.tmp_superpowers_edit

View File

@@ -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,39 @@ 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)
This repository provides automatic sync via GitHub Actions workflow:
- `.github/workflows/sync-main-to-dev.yml`
- Trigger: every push to `main`
- Behavior: create/reuse a PR from `main` to `dev`; if mergeable, it tries to enable auto-merge
- Prerequisite: in `Settings -> Actions -> General -> Workflow permissions`, enable `Allow GitHub Actions to create and approve pull requests`; otherwise the workflow will skip PR creation and only emit a warning summary
Manual fallback (when conflicts or automation is unavailable):
```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 +106,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 +119,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

View File

@@ -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,39 @@ 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 自动同步机制:
- `.github/workflows/sync-main-to-dev.yml`
- 触发时机:每次 `main` 分支有新的 push
- 行为:自动创建或复用 `main``dev` 的同步 PR若可合并则尝试开启自动合并
- 前置条件:需在 `Settings -> Actions -> General -> Workflow permissions` 中开启 `Allow GitHub Actions to create and approve pull requests`,否则 workflow 只会输出告警摘要并跳过建 PR
当出现冲突,或自动化暂不可用时,使用以下手动兜底方式:
```bash
git checkout dev
git pull
git merge main
git push
```
### 2. 发版前从 dev 切 release/*
发布前由维护者基于 `dev` 创建发布分支,例如:
@@ -88,7 +106,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 +119,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

View File

@@ -5,8 +5,6 @@
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
**Language**: English | [简体中文](README.zh-CN.md)
@@ -55,24 +53,19 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
<h2 align="center">📸 Screenshots</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
</div>
---
## Key Features
### AI Assistant (New)
- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support.
- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance.
- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs.
### Performance
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
- **Virtualized rendering**: keeps large result sets responsive.
@@ -212,21 +205,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`.
## Star History
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
</picture>
</a>
## Links
- [linux.do](https://linux.do/)
- [AIBook](https://aibook.ren/)
External contributors should open pull requests directly against `main`.
## License

View File

@@ -5,8 +5,6 @@
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
**语言**: [English](README.md) | 简体中文
@@ -54,24 +52,19 @@ GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做
<h2 align="center">📸 项目截图</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
</div>
---
## 核心特性
### AI 智能助手 (New)
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude同时支持任意自定义兼容 OpenAI 格式的 API。
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI让 SQL 生成、分析变得更精准。
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等
### 性能与交互
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
- 虚拟滚动渲染,降低大结果集卡顿风险。
@@ -195,22 +188,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。
## Star History (Star 增长趋势)
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
</picture>
</a>
## 友情链接
- [linux.do](https://linux.do/)
- [AI全书](https://aibook.ren/)
外部贡献者统一直接向 `main` 发起 Pull Request。
## 开源协议

View File

@@ -1,9 +0,0 @@
//go:build dev
package main
import "os"
// 开发模式下由 Wails DevServer 提供前端资源,这里只提供一个稳定的占位 FS
// 避免编译时依赖 frontend/dist 被并发重建。
var assets = os.DirFS(".")

View File

@@ -1,13 +0,0 @@
//go:build !dev
package main
import (
"embed"
"io/fs"
)
//go:embed all:frontend/dist
var embeddedAssets embed.FS
var assets fs.FS = embeddedAssets

View File

@@ -1,228 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
usage() {
cat <<'EOF'
用法:
./build-driver-agents.sh [选项]
选项:
--drivers <列表> 指定驱动列表逗号分隔例如kingbase,mongodb
--platform <GOOS/GOARCH>
目标平台,默认使用当前 Go 环境go env GOOS/GOARCH
--out-dir <目录> 输出目录根路径默认dist/driver-agents
--bundle-name <文件名> 驱动总包 zip 名称默认GoNavi-DriverAgents.zip
--strict 任一驱动构建失败即中断(默认失败后继续,最后汇总)
-h, --help 显示帮助
示例:
./build-driver-agents.sh
./build-driver-agents.sh --drivers kingbase
./build-driver-agents.sh --platform windows/amd64 --drivers kingbase,mongodb
EOF
}
normalize_driver() {
local name
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)"
case "$name" in
doris|diros) echo "doris" ;;
mariadb|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|mongodb|tdengine|clickhouse)
echo "$name"
;;
*)
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
platform_dir_name() {
case "$1" in
windows) echo "Windows" ;;
darwin) echo "MacOS" ;;
linux) echo "Linux" ;;
*) echo "Unknown" ;;
esac
}
driver_csv=""
target_platform=""
out_root="dist/driver-agents"
bundle_name="GoNavi-DriverAgents.zip"
strict_mode="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--drivers)
driver_csv="${2:-}"
shift 2
;;
--platform)
target_platform="${2:-}"
shift 2
;;
--out-dir)
out_root="${2:-}"
shift 2
;;
--bundle-name)
bundle_name="${2:-}"
shift 2
;;
--strict)
strict_mode="true"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "❌ 未知参数:$1"
usage
exit 1
;;
esac
done
if ! command -v go >/dev/null 2>&1; then
echo "❌ 未找到 Go请先安装 Go 并确保 go 在 PATH 中。"
exit 1
fi
if [[ -z "$target_platform" ]]; then
target_platform="$(go env GOOS)/$(go env GOARCH)"
fi
if [[ "$target_platform" != */* ]]; then
echo "❌ --platform 参数格式错误,应为 GOOS/GOARCH例如 darwin/arm64"
exit 1
fi
goos="${target_platform%%/*}"
goarch="${target_platform##*/}"
platform_key="${goos}-${goarch}"
platform_dir="$(platform_dir_name "$goos")"
declare -a drivers=()
if [[ -n "$driver_csv" ]]; then
IFS=',' read -r -a raw_drivers <<<"$driver_csv"
for item in "${raw_drivers[@]}"; do
normalized="$(normalize_driver "$item")" || {
echo "❌ 不支持的驱动:$item"
exit 1
}
drivers+=("$normalized")
done
else
drivers=("${DEFAULT_DRIVERS[@]}")
fi
output_dir="${out_root%/}/${platform_key}"
bundle_stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-driver-bundle.XXXXXX")"
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
cleanup() {
rm -rf "$bundle_stage_dir"
}
trap cleanup EXIT
mkdir -p "$output_dir" "$bundle_platform_dir"
output_dir_abs="$(cd "$output_dir" && pwd)"
bundle_zip_path="$output_dir_abs/$bundle_name"
declare -a built_assets=()
declare -a failed_drivers=()
declare -a skipped_drivers=()
echo "🚀 开始构建 optional-driver-agent"
echo " 平台:$goos/$goarch"
echo " 输出目录:$output_dir_abs"
echo " 驱动列表:${drivers[*]}"
for driver in "${drivers[@]}"; do
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
echo "⚠️ 跳过 duckdb仅支持 windows/amd64"
skipped_drivers+=("$driver")
continue
fi
build_driver="$(build_driver_name "$driver")"
tag="gonavi_${build_driver}_driver"
asset_name="${driver}-driver-agent-${goos}-${goarch}"
if [[ "$goos" == "windows" ]]; then
asset_name="${asset_name}.exe"
fi
output_path="$output_dir_abs/$asset_name"
cgo_enabled=0
if [[ "$driver" == "duckdb" ]]; then
cgo_enabled=1
fi
echo "🔧 构建 $driver -> $asset_name (tag=$tag, CGO_ENABLED=$cgo_enabled)"
set +e
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
go build -tags "$tag" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
build_exit=$?
set -e
if [[ $build_exit -ne 0 ]]; then
echo "❌ 构建失败:$driver"
failed_drivers+=("$driver")
if [[ "$strict_mode" == "true" ]]; then
exit $build_exit
fi
continue
fi
cp "$output_path" "$bundle_platform_dir/$asset_name"
built_assets+=("$asset_name")
done
if [[ ${#built_assets[@]} -eq 0 ]]; then
echo "❌ 未成功构建任何驱动代理。"
exit 1
fi
rm -f "$bundle_zip_path"
if command -v zip >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
zip -qry "$bundle_zip_path" "$platform_dir"
)
elif command -v ditto >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
ditto -c -k --sequesterRsrc --keepParent "$platform_dir" "$bundle_zip_path"
)
else
echo "❌ 未找到 zip/ditto无法生成驱动总包 zip。"
exit 1
fi
echo ""
echo "✅ 构建完成"
echo " 单文件输出目录:$output_dir_abs"
echo " 驱动总包:$bundle_zip_path"
echo " 已构建:${built_assets[*]}"
if [[ ${#skipped_drivers[@]} -gt 0 ]]; then
echo " 已跳过:${skipped_drivers[*]}"
fi
if [[ ${#failed_drivers[@]} -gt 0 ]]; then
echo "⚠️ 构建失败驱动:${failed_drivers[*]}"
exit 2
fi

View File

@@ -1,42 +1,16 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# 配置
APP_NAME="GoNavi"
DIST_DIR="dist"
BUILD_BIN_DIR="build/bin"
DEFAULT_BINARY_NAME="GoNavi" # 对应 wails.json 中的 outputfilename
DEV_VERSION_FILE="version/dev-version.txt"
DEFAULT_DEV_VERSION="0.0.1-test"
resolve_build_version() {
if [ -n "${GONAVI_VERSION:-}" ]; then
printf '%s\n' "${GONAVI_VERSION}"
return
fi
if [ -f "$DEV_VERSION_FILE" ]; then
local dev_version
dev_version=$(head -n 1 "$DEV_VERSION_FILE" | tr -d '\r' | tr -d '[:space:]')
if [ -n "$dev_version" ]; then
printf '%s\n' "$dev_version"
return
fi
fi
local package_version
package_version=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[:space:]')
if [ -n "$package_version" ]; then
printf '%s\n' "$package_version"
return
fi
printf '%s\n' "$DEFAULT_DEV_VERSION"
}
VERSION="$(resolve_build_version)"
# 提取版本号
VERSION=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]')
if [ -z "$VERSION" ]; then
VERSION="0.0.0"
fi
echo " 检测到版本号: $VERSION"
LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION"
@@ -46,162 +20,121 @@ RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
get_file_size_bytes() {
local target="$1"
if [ ! -f "$target" ]; then
echo 0
return
fi
if stat -f%z "$target" >/dev/null 2>&1; then
stat -f%z "$target"
return
fi
if stat -c%s "$target" >/dev/null 2>&1; then
stat -c%s "$target"
return
fi
wc -c <"$target" | tr -d '[:space:]'
}
format_size_mb() {
local bytes="${1:-0}"
awk -v b="$bytes" 'BEGIN { printf "%.2fMB", b / 1024 / 1024 }'
}
try_compress_binary_with_upx() {
local exe_path="$1"
local label="$2"
if [ ! -f "$exe_path" ]; then
echo -e "${RED} ❌ 未找到 ${label} 文件:$exe_path${NC}"
exit 1
fi
if ! command -v upx >/dev/null 2>&1; then
echo -e "${RED} ❌ 未找到 upx${label} 必须进行压缩后才能继续打包。${NC}"
case "$(uname -s)" in
Darwin)
echo " 安装命令: brew install upx"
;;
Linux)
echo " 安装命令: sudo apt-get install -y upx-ucl (或对应发行版包管理器)"
;;
esac
exit 1
fi
local before_bytes after_bytes
before_bytes=$(get_file_size_bytes "$exe_path")
echo " 🗜️ 正在使用 UPX 压缩 ${label}..."
if upx --best --lzma --force "$exe_path" >/dev/null 2>&1; then
if ! upx -t "$exe_path" >/dev/null 2>&1; then
echo -e "${RED} ❌ UPX 校验失败:${label}${NC}"
exit 1
fi
after_bytes=$(get_file_size_bytes "$exe_path")
if [ "$after_bytes" -lt "$before_bytes" ]; then
local saved_bytes=$((before_bytes - after_bytes))
echo " ✅ UPX 压缩完成: $(format_size_mb "$before_bytes") -> $(format_size_mb "$after_bytes"),减少 $(format_size_mb "$saved_bytes")"
else
echo " UPX 压缩完成: $(format_size_mb "$before_bytes") -> $(format_size_mb "$after_bytes")"
fi
else
echo -e "${RED} ❌ UPX 压缩失败:${label}${NC}"
exit 1
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
}
package_macos_bundle_zip() {
local app_path="$1"
local archive_path="$2"
local archive_abs
if [ ! -d "$app_path" ]; then
echo -e "${RED} ❌ 未找到 macOS 应用包:$app_path${NC}"
exit 1
fi
archive_abs="$(cd "$(dirname "$archive_path")" && pwd)/$(basename "$archive_path")"
rm -f "$archive_path"
if command -v ditto >/dev/null 2>&1; then
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$archive_abs"
elif command -v zip >/dev/null 2>&1; then
(
cd "$(dirname "$app_path")" && \
zip -qry "$archive_abs" "$(basename "$app_path")"
)
else
echo -e "${RED} ❌ 未找到 ditto/zip无法打包 macOS 应用。${NC}"
exit 1
fi
if [ ! -f "$archive_abs" ]; then
echo -e "${RED} ❌ macOS 应用归档失败:$archive_abs${NC}"
exit 1
fi
}
package_macos_release() {
local platform="$1"
local archive_suffix="$2"
echo -e "${GREEN}🍎 正在构建 macOS (${platform})...${NC}"
wails build -platform "darwin/${platform}" -clean -ldflags "$LDFLAGS"
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ macOS ${platform} 构建失败。${NC}"
return
fi
local app_src="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
local app_dest_name="${APP_NAME}-${VERSION}-${archive_suffix}.app"
local zip_name="${APP_NAME}-${VERSION}-${archive_suffix}.zip"
mv "$app_src" "$DIST_DIR/$app_dest_name"
local app_bin_path
app_bin_path=$(find "$DIST_DIR/$app_dest_name/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -z "$app_bin_path" ] || [ ! -f "$app_bin_path" ]; then
echo -e "${RED} ❌ 未找到 macOS ${platform} 主程序文件。${NC}"
exit 1
fi
echo -e "${YELLOW} ⚠️ macOS ${platform} 改为无交互 ZIP 打包,不再生成 DMG。${NC}"
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (${platform})..."
clear_macos_bundle_xattrs "$DIST_DIR/$app_dest_name"
codesign --force --deep --sign - "$DIST_DIR/$app_dest_name"
echo " 📦 正在打包 macOS 应用归档 (${platform})..."
package_macos_bundle_zip "$DIST_DIR/$app_dest_name" "$DIST_DIR/$zip_name"
rm -rf "$DIST_DIR/$app_dest_name"
echo " ✅ 已生成 $zip_name"
}
echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}"
# 清理并创建输出目录
rm -rf "$DIST_DIR"
mkdir -p "$DIST_DIR"
rm -rf $DIST_DIR
mkdir -p $DIST_DIR
package_macos_release "arm64" "mac-arm64"
package_macos_release "amd64" "mac-amd64"
# --- macOS ARM64 构建 ---
echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}"
wails build -platform darwin/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app"
DMG_NAME="${APP_NAME}-${VERSION}-mac-arm64.dmg"
# 移动 .app 到 dist
mv "$APP_SRC" "$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 \
--volname "${APP_NAME} ${VERSION}" \
--volicon "build/appicon.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$DIST_DIR/$APP_DEST_NAME"
# 检查是否生成了 rw.* 的临时文件并重命名 (create-dmg 有时会有此行为)
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到临时文件名,正在重命名...${NC}"
mv "$RW_FILE" "$DIST_DIR/$DMG_NAME"
fi
fi
# 删除中间的 .app 文件,保持目录整洁
rm -rf "$DIST_DIR/$APP_DEST_NAME"
if [ -f "$DIST_DIR/$DMG_NAME" ]; then
echo " ✅ 已生成 $DMG_NAME"
else
echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出。${NC}"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
echo " 安装命令: brew install create-dmg"
fi
else
echo -e "${RED} ❌ macOS arm64 构建失败。${NC}"
fi
# --- macOS AMD64 构建 ---
echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}"
wails build -platform darwin/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app"
DMG_NAME="${APP_NAME}-${VERSION}-mac-amd64.dmg"
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (amd64)..."
rm -f "$DIST_DIR/$DMG_NAME"
create-dmg \
--volname "${APP_NAME} ${VERSION}" \
--volicon "build/appicon.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$DIST_DIR/$APP_DEST_NAME"
# 检查是否生成了 rw.* 的临时文件并重命名
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到临时文件名,正在重命名...${NC}"
mv "$RW_FILE" "$DIST_DIR/$DMG_NAME"
fi
fi
rm -rf "$DIST_DIR/$APP_DEST_NAME"
if [ -f "$DIST_DIR/$DMG_NAME" ]; then
echo " ✅ 已生成 $DMG_NAME"
else
echo -e "${RED} ❌ DMG 生成失败。${NC}"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具。${NC}"
fi
else
echo -e "${RED} ❌ macOS amd64 构建失败。${NC}"
fi
# --- Windows AMD64 构建 ---
echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$TARGET_EXE"
try_compress_binary_with_upx "$TARGET_EXE" "Windows amd64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
else
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
@@ -215,9 +148,7 @@ echo -e "${GREEN}🪟 正在构建 Windows (arm64)...${NC}"
if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$TARGET_EXE"
echo -e "${YELLOW} ⚠️ 当前 UPX 不支持 win64/arm64跳过 Windows arm64 压缩。${NC}"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
else
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
@@ -237,10 +168,8 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
# 本机 Linux amd64直接构建
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux amd64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
# 打包为 tar.gz
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
@@ -257,10 +186,8 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
export CGO_ENABLED=1
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux amd64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
rm "${APP_NAME}-${VERSION}-linux-amd64"
@@ -281,10 +208,8 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
# 本机 Linux arm64直接构建
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux arm64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
rm "${APP_NAME}-${VERSION}-linux-arm64"
@@ -300,10 +225,8 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
export CGO_ENABLED=1
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux arm64 可执行文件"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
rm "${APP_NAME}-${VERSION}-linux-arm64"

View File

@@ -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")
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,483 +0,0 @@
# JVM 缓存可视化编辑设计
## 1. 背景
当前用户在公司 Java 项目中经常把缓存或运行时状态直接保存在 JVM 内存中。出现数据脏值、缓存穿透、临时纠偏或排障时,通常只有两种方式:
- 为特定业务临时补管理接口
- 重启应用并依赖重新初始化
这两种方式都存在明显问题:
- 临时接口会污染业务代码,并带来后续维护和权限风险
- 重启应用成本高,且不适合用于精确修复单个缓存项
GoNavi 现有已具备三类可复用基础:
- 统一连接与工作台能力:`frontend/src/components/ConnectionModal.tsx``frontend/src/components/Sidebar.tsx``frontend/src/components/TabManager.tsx`
- 独立运行时能力样板Redis 通过 `internal/app/methods_redis.go` 和专用前端视图实现,不依赖 SQL `Database` 抽象
- AI 与日志能力底座:`frontend/src/components/AIChatPanel.tsx``frontend/src/components/QueryEditor.tsx``frontend/src/components/LogPanel.tsx`
因此GoNavi 有条件扩展出 JVM 运行时连接与受控编辑能力,但不能简单把该需求理解为“新数据库驱动”。
## 2. 目标
- 为 GoNavi 增加统一的 `JVM Connector` 子系统,用于连接和浏览 Java 服务的运行时缓存/管理对象
- 在同一套 UI 下支持多种接入模式,并根据目标 JVM 能力自动协商或手动切换
- 提供结构化的缓存浏览、值检查、受控修改、操作预览和审计记录
- 允许 AI 参与解释、分析和生成修改计划,但不默认开放 AI 自动执行
- 尽量避免强依赖 `-javaagent` 或运行时动态 attach适配企业内对生产进程注入普遍敏感的环境
## 3. 非目标
- 不承诺“任意 JVM 内任意对象均可直接读写”
- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测
- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象
- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入
- 不绕过目标服务现有认证、鉴权和网络边界
## 4. 需求与约束
### 4.1 需求清单
- 统一配置 JVM 连接
- 探测当前 JVM 支持的接入模式与可用能力
- 浏览缓存空间、管理对象和受控操作
- 查看值快照与元数据
- 执行受控修改,并提供 before/after 预览
- 将操作结果写入审计记录
- 支持 AI 对资源结构和修改方案进行分析
### 4.2 已确认约束
- 用户倾向通用型产品形态,但目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
- 企业环境下,稳定性与安全性优先级高于“黑科技式通用能力”
- 一期应优先基于标准协议和业务可控接入面,而不是侵入式 runtime 操作
## 5. 现状分析
### 5.1 GoNavi 架构启示
- `internal/db/database.go` 面向标准化数据源 CRUD适合 SQL 类资源
- `internal/app/methods_redis.go` 证明 GoNavi 已支持“独立运行时系统能力线”
- `frontend/src/components/RedisViewer.tsx``frontend/src/components/RedisCommandEditor.tsx` 提供了树形浏览、结构化值编辑和控制台交互样板
- `frontend/src/components/AIChatPanel.tsx``frontend/src/components/ai/AIMessageBubble.tsx` 已具备 AI 交互和危险执行确认能力
### 5.2 结论
JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector` 子系统,而不是复用 SQL `Database` 接口。
## 6. 方案比较
### 方案 A单一路径通用 Agent
- 描述:统一要求目标 JVM 通过 `-javaagent` 或运行时 attach 暴露运行时对象访问能力
- 优点:
- 理论能力上限最高
- 可覆盖更多自研缓存和深层对象
- 缺点:
- 与已知企业约束直接冲突
- 风险最高,部署与安全成本高
- 与首期产品化目标不匹配
### 方案 B多接入模式 + 能力协商
- 描述:统一做 `JVM Connector`,底层同时支持 `JMX``Management Endpoint``Agent`
- 优点:
- 产品形态统一
- 能根据目标 JVM 能力降级
- 可先做低风险路径,后续再扩展高级模式
- 缺点:
- 不同模式能力不一致UI 与权限模型更复杂
### 方案 C只做业务侧管理端点
- 描述:完全放弃通用接入,只提供官方 Starter/管理端点接入
- 优点:
- 结构最稳AI 最容易接入
- 权限、审计、预览、回滚最好做
- 缺点:
- 不满足“尽量通用”的产品定位
- 无法覆盖仅开放 JMX 的存量系统
## 7. 选型
采用方案 B。当前已落地
- `JMX Provider`
- `Management Endpoint Provider`
- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent
## 8. 目标架构
### 8.1 总体结构
新增统一的 `JVM Connector` 子系统,分为五层:
- `Connection Layer`
- 新增 `jvm` 连接类型
- 保存目标地址、认证、允许模式、首选模式、环境标签等配置
- `Capability Layer`
- 建立连接后探测当前支持的 provider 与能力矩阵
- `Provider Layer`
- `JMX Provider`
- `Management Endpoint Provider`
- `Agent Provider`(预留)
- `Resource Layer`
- 将不同来源统一映射为结构化资源
- `Guard Layer`
- 统一负责预览、确认、审计、回读验证、错误归一化
### 8.2 设计原则
- UI 统一,协议多态
- 读写分离,修改必须经过 Guard Layer
- provider 不得自行绕过权限与审计链路
- 能力不足时显式降级,不提供“看似可用、实际不可执行”的假入口
## 9. Provider 设计
### 9.1 JMX Provider
- 负责:
- 建立 JMX/RMI 连接
- 发现 MBean
- 读取属性
- 调用白名单操作
- 写入允许修改的白名单属性
- 适用场景:
- 目标 JVM 已开放 JMX
- 缓存或管理对象已暴露为 MBean
- 特点:
- 低侵入、标准化、可落地
- key/value 级资源能力通常有限
### 9.2 Management Endpoint Provider
- 负责:
- 调用业务服务暴露的 GoNavi 管理端点或 Starter
- 返回结构化缓存资源、元数据和受控动作
- 提供修改预览与回滚信息
- 适用场景:
- 业务方愿意接入轻量 Starter/管理端点
- 需要更强的 key/value 级浏览与修改能力
- 特点:
- 最适合产品化和 AI 协同
- 权限、脱敏、审计、回滚最容易做
### 9.3 Agent Provider
- 负责:
- 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口
- 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力
- 定位:
- 高级模式
- 不默认启用
- 需要目标 Java 服务以 `-javaagent` 方式显式启动
## 10. 统一资源模型
建议统一抽象以下资源:
- `runtime`
- 目标 JVM 实例
- `cacheNamespace`
- 缓存空间,如某个 CacheManager 下的 cacheName
- `cacheEntry`
- 具体缓存项 key/value
- `managedBean`
- 可读写的托管对象或 MBean
- `operation`
- 受控操作,如 `evict``put``refresh``clear`
- `auditRecord`
- 每次读写与 AI 建议的审计记录
统一资源模型要求:
- 每个资源都有稳定 ID、显示名、provider 来源、能力标签、敏感级别
- 值快照必须区分原始值、展示值和可编辑值
- 资源定位信息必须可写入审计
## 11. AI 协同设计
### 11.1 AI 的角色
AI 在 JVM 场景中只能作为“受控编排者”,不能作为直接执行者。
AI 可以:
- 解释缓存/Bean 的结构和当前状态
- 生成筛选条件和定位建议
- 生成结构化修改计划
- 生成风险说明和回滚建议
- 对执行前后结果做对比分析
AI 不应默认做:
- 直接执行 JVM 修改
- 自由生成任意脚本并直写内存
- 绕过人工确认直接调用 provider
### 11.2 AI 输出形态
AI 不直接输出脚本,而输出结构化变更计划,例如:
```json
{
"targetType": "cacheEntry",
"selector": {
"namespace": "userSessionCache",
"key": "user:1001"
},
"action": "updateValue",
"payload": {
"format": "json",
"value": {
"status": "ACTIVE"
}
},
"reason": "修复错误缓存态"
}
```
### 11.3 AI 执行链路
1. AI 读取结构化上下文
2. AI 产出结构化变更计划
3. Guard Layer 校验目标资源、能力和权限
4. UI 展示修改预览与风险提示
5. 用户确认
6. provider 执行
7. 系统回读验证并写审计
### 11.4 一期 AI 边界
- 支持 AI 分析资源
- 支持 AI 生成修改计划
- 不默认支持 AI 自动执行修改
## 12. 页面与交互设计
### 12.1 连接层
`ConnectionModal` 中新增 `JVM` 类型,建议配置:
- 连接名称
- 目标地址/端口
- 认证信息
- 允许模式列表
- 首选模式
- 环境标签DEV/UAT/PROD
- 默认权限级别(只读/读写)
### 12.2 侧边栏
展示结构:
- 连接
- 模式能力
- 资源类型
- `cacheNamespace` / `managedBean` / `operation`
每个连接或节点显示能力徽标,例如:
- `JMX`
- `Endpoint`
- `Agent`
- `只读`
- `可写`
### 12.3 主工作区 Tab
建议新增以下 Tab 类型:
- `概览`
- `资源浏览`
- `值检查器`
- `修改预览`
- `AI 助手`
- `审计记录`
### 12.4 标准操作流
1. 用户连接 JVM
2. 系统探测 provider 能力
3. 用户选择资源并读取快照
4. 用户手工修改或让 AI 生成计划
5. 系统生成 before/after 预览
6. 用户二次确认
7. provider 执行
8. 系统回读验证
9. 写入审计与操作日志
## 13. 权限与审计
### 13.1 权限模型
权限建议分四层:
- `连接级`
- 决定默认 `readonly` / `readwrite`
- `模式级`
- 决定某 provider 支持哪些动作
- `资源级`
- 某些资源永远只读
- `环境级`
- `PROD` 默认强制二次确认,禁用 AI 自动执行
### 13.2 审计要求
JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogPanel 样式。
建议记录:
- 连接 ID / 名称
- provider 类型
- 资源定位信息
- 动作类型
- 修改原因
- AI 是否参与
- 执行前摘要
- 执行后摘要
- 结果状态
- 耗时
- 错误信息
建议本地独立落盘为 `jvm_audit.jsonl` 或等价结构,不混入 `sqlLogs`
## 14. 错误处理与兼容性边界
### 14.1 错误分层
- `连接层失败`
- 认证失败、证书失败、JMX/RMI 不通、端点 401/403
- `能力层失败`
- 连接成功但不支持列 key、写值或批量操作
- `执行层失败`
- 资源不存在、值格式非法、provider 拒绝写入
- `验证层失败`
- 执行返回成功但回读校验不一致
所有错误都应显式标明是哪个 provider、哪一层失败避免泛化为“修改失败”。
### 14.2 首期兼容性承诺
优先承诺以下边界:
- Java 8 / 11 / 17 / 21
- Spring Boot 服务优先
- JMX 标准 MBean
- Management Endpoint 模式下优先支持:
- Caffeine
- Ehcache
- Guava Cache
- Spring Cache 抽象下可枚举缓存
- 接入 GoNavi Starter 的自研缓存
- 值类型首期优先:
- string
- number
- boolean
- JSON object / JSON array
- map / list 的结构化展示
### 14.3 首期不承诺
- 任意 Java 对象深度反射编辑
- 无类型信息的二进制对象直接改写
- 跨 classloader 任意对象定位
- 生产环境默认开放批量危险写入
## 15. MVP 分期
### Phase 1连接与只读探测
- JVM 连接类型
- JMX / Endpoint 能力探测
- 资源树浏览
- 值查看
- 概览页与能力徽标
- 不开放写入
### Phase 2受控修改与审计
- 白名单资源写入
- before/after 预览
- 二次确认
- 审计日志
- 回读验证
- 环境级保护策略
### Phase 3AI 协同
- AI 解释资源
- AI 生成修改计划
- AI 风险分析
- AI 回滚建议
- 仍默认不允许 AI 自动执行
### Phase 4高级模式
- Agent Provider
- 预埋 Java Agent 的 runtime 资源治理能力
- 仅在特殊环境启用
## 16. 验证策略
### 16.1 功能验证
- 能连接 JMX 目标
- 能连接 Endpoint 目标
- 能列出缓存空间
- 能查看 key/value
- 能完成受控修改并回读成功
### 16.2 兼容性验证
- Java 8 / 11 / 17 / 21
- 本地、容器、K8s 内网场景
- 开启认证 / 不开启认证
- 仅 JMX、仅 Endpoint、双模式并存
### 16.3 安全验证
- 只读连接无法写入
- `PROD` 环境必须二次确认
- AI 无法绕过人工确认直接执行
- 审计日志完整记录修改链路
### 16.4 稳定性验证
- 目标 JVM 不可达时 UI 不假死
- 资源树大数量时支持分页或懒加载
- 回读失败时标识“不确定状态”
- provider 超时、部分失败、降级路径清晰
## 17. 风险与缓解
### 17.1 风险
- 多 provider 模式会带来能力不一致,用户可能误解“所有 JVM 都能随便改”
- JMX 模式的 key/value 级能力可能明显不足
- 管理端点模式需要业务接入,推广成本高于纯客户端方案
- 若未来引入 Agent 模式,可能引入新的安全审核和兼容性成本
### 17.2 缓解
- 在 UI 中显式展示能力矩阵和当前 provider 来源
- 所有修改都强制经过预览、确认与审计
- 首期将“通用”定义为“统一入口 + 多模式协商”,而不是“单通道万能能力”
- Agent 仅作为高级扩展位,避免污染 MVP 边界
## 18. 最终结论
JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采用“统一入口、多 provider、能力协商、强 Guard Layer”的产品化方案。
推荐结论如下:
- 新增独立的 `JVM Connector` 子系统
- 首期支持 `JMX + Management Endpoint`
- `Agent` 作为高级可选模式交付
- AI 首期支持分析与生成修改计划,不默认开放自动执行
- 所有修改必须经过预览、确认、审计和回读验证
这一路径能够在兼顾企业安全约束的前提下,为用户提供可持续演进的 JVM 运行时缓存治理能力。

View File

@@ -1,73 +0,0 @@
# 需求进度追踪 - AI聊天发送快捷键
## 1. 需求摘要
- 需求名称AI 聊天发送快捷键
- 提出日期2026-04-28
- 负责人Claude Code
- 目标:将 AI 聊天发送快捷键纳入工具中心快捷键管理,支持录制自定义 Enter 相关组合键,降低输入法 Enter 上屏时误发送的风险。
- 非目标:不调整后端 AI 服务配置,不改发送按钮行为,不把 AI 发送快捷键放在 AI 设置弹窗的独立入口。
## 2. 范围与验收
- 范围工具中心快捷键管理、AI 聊天输入框、本地前端偏好持久化。
- 验收标准工具中心出现“AI 聊天发送”快捷键;默认 Enter 发送;可录制 Enter / Cmd+Enter / Ctrl+Enter / Alt+Enter 等 Enter 相关组合;普通字符键不可录制为 AI 发送Shift+Enter 始终换行;输入法 composing 状态不发送刷新后快捷键保持AI 设置弹窗不再出现独立“聊天输入”快捷键入口。
- 依赖与约束:沿用 Zustand `lite-db-storage` 中的 `shortcutOptions` 持久化;保持现有 AI 后端接口不变。
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认输入法 Enter 上屏导致误发送,需要支持录制自定义快捷键,并复用工具中心快捷键体系。
- [x] 阶段 2影响分析影响工具中心快捷键配置、AIChatPanel、AIChatInput、store 和相关测试。
- [x] 阶段 3方案设计采用共享 `shortcutOptions` actionAI 输入框局部消费,不走全局快捷键执行器。
- [x] 阶段 4实施计划计划已按用户反馈调整为工具中心统一方案。
- [x] 阶段 5实现与自检目标红灯测试已补充新方案核心实现已完成。
- [x] 阶段 6评审与交付已完成代码审查反馈修复、目标测试、全量测试、构建、diff 检查和浏览器手工验证。
- [ ] 阶段 7发布与观察发布后观察用户输入法场景反馈。
## 4. 变更清单
- 已完成:新增工具中心 AI 发送 action 目标测试;实现 Enter 默认快捷键、Enter 组合录制规则、AI 输入框按 `shortcutOptions` 判定发送;移除 AI 设置独立入口;修复刷新后录制值被启动配置刷新覆盖的问题;限制 AI 发送快捷键只能录制 0 或 1 个修饰键的 Enter 组合;消费 AI 发送快捷键后阻止事件继续冒泡;更新 store、工具函数和输入框提示测试。
- 进行中:无。
- 待处理:发布后观察输入法场景反馈。
## 5. 风险与阻塞
- 风险:默认 Enter 发送在少数未标记 composing 的输入法中仍可能误发。
- 阻塞:无。
- 缓解措施:用户可在工具中心录制 Cmd+Enter / Ctrl+Enter / Alt+Enter普通 Enter 不再触发发送AI 发送录制限制为 Enter 相关组合并保留 Shift+Enter 换行;输入法 composing 状态始终不发送。
## 6. 决策记录
- 决策 1AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。
- 决策 2`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。
- 决策 3AI 发送快捷键允许默认无修饰键 Enter但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。
- 决策 4输入法 composing 状态始终不发送。
- 决策 5AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。
- 决策 6AI 输入框命中发送快捷键后同时执行 `preventDefault``stopPropagation`,避免事件继续冒泡到全局快捷键处理器。
## 7. 验证记录
- 验证项:初版两档下拉方案红灯测试。
- 结果:已确认旧实现失败。
- 证据:`aiChatSendShortcut.test.ts` 缺模块失败;`store.test.ts` 新增字段缺失失败;`AIChatInput.notice.test.tsx` placeholder 仍为 Enter 失败。
- 验证项:工具中心统一方案红灯测试。
- 结果:已确认旧实现失败。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts` 显示缺少 `sendAIChatMessage` action、`canRecordShortcutForAction` 和自定义 binding 判定失败;`src/store.test.ts` 显示 `shortcutOptions.sendAIChatMessage` 缺失;`src/components/ai/AIChatInput.notice.test.tsx` 显示 placeholder 未渲染 `Meta+Enter 发送`
- 验证项:工具中心统一方案目标绿灯测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts`6 passed`src/components/ai/AIChatInput.notice.test.tsx`2 passed`src/store.test.ts`10 passed
- 验证项:代码审查反馈红灯测试。
- 结果:已确认旧实现失败。
- 证据:多修饰键 Enter 组合被误放行、缺少 `consumeAIChatSendShortcutOnKeyDown`、脏持久化 `sendAIChatMessage: A` 未回退到 Enter。
- 验证项:代码审查反馈修复后目标测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts src/components/ai/AIChatInput.notice.test.tsx src/store.test.ts`3 files passed22 tests passed
- 验证项:浏览器手工验证。
- 结果:已通过。
- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。
- 验证项:前端全量测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run`88 files passed421 tests passed
- 验证项diff 空白检查。
- 结果:已通过。
- 证据:`git diff --check` 无输出。
- 验证项:生产构建。
- 结果:已通过。
- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。
## 8. 下一步
- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。
- 负责人Claude Code

View File

@@ -1,246 +0,0 @@
# 需求进度追踪 - JVM缓存可视化编辑
## 1. 需求摘要
- 需求名称JVM缓存可视化编辑
- 提出日期2026-04-22
- 负责人Codex
- 目标:完成 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的通用能力交付,降低“改缓存只能写接口或重启应用”的运维与排障成本
- 非目标:不承诺覆盖所有 Java 框架/所有对象类型,不绕过目标应用现有安全控制,不在首期开放脚本式任意表达式执行
## 2. 范围与验收
- 范围:
- 交付 JVM 共享契约、连接配置、provider 注册、连接测试与能力探测
- 交付 Endpoint / JMX / Agent 三种接入模式及其资源浏览、读值、预览、执行链路
- 交付 JVM 资源页、预览弹窗、审计查看、AI 草稿生成与回填能力
- 交付 Guard、审计、来源标记、真实集成测试与构建验证
- 验收标准:
- 可以在 GoNavi 中新增 JVM 连接并完成连接测试
- 可以按资源树浏览 JVM 对象并查看结构化快照
- 可以对支持写入的资源执行预览和确认写入,且带 Guard 与审计
- 可以通过 AI 生成结构化修改草稿,但不会跳过人工确认直接执行
- 可以通过真实 JMX 与真实 HTTP contract 完成端到端验证,并通过前后端构建回归
- 依赖与约束:
- 需复用 GoNavi 当前 Wails + React + driver-agent 架构
- 新能力不得破坏现有数据库/Redis 工作流
- 高风险写操作必须具备明确鉴权、审计与回滚思路
- JMX 模式要求 GoNavi 运行机器本地可用 `java` 可执行文件
## 3. 里程碑与进度
- [x] 阶段 1需求澄清完成
- [x] 阶段 2影响分析完成
- [x] 阶段 3方案设计完成已形成正式设计文档
- [x] 阶段 4实施计划完成已形成正式实施计划
- [x] 阶段 5实现与自检完成Task 1 至 Task 7 已完成,代码与构建回归通过)
- [x] 阶段 6评审与交付完成已完成契约复核、上下文隔离修正、文档回填与交付检查
- [ ] 阶段 7发布与观察未开始
## 4. 变更清单
- 已完成:
- 确认 GoNavi 当前存在统一驱动接口与可选 driver-agent 机制
- 确认前端已有 Redis 结构化浏览、命令编辑器、Monaco 编辑器、DataGrid 编辑能力可复用
- 初步判断 JVM 运行时对象编辑不适合直接复用 SQL/Database 抽象,需新增非数据库协议层
- 用户已确认目标方向为“通用型 JVM 接入”
- 用户已确认升级到完整模式,开始高风险架构评估
- 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
- 已形成 JVM 缓存可视化编辑正式设计文档
- 已形成 JVM Connector MVP 正式实施计划文档
- 已完成 Task 1JVM 共享契约与配置归一化
- 已完成 Task 2Provider 注册、连接测试与能力探测 API
- 已完成 Task 3JVM 连接表单、图标与展示文案接入
- 已完成 Task 4只读资源浏览与 JVM Tab
- 已完成 Task 5写入预览、Guard 和审计记录
- 已完成 Task 6AI 结构化变更计划
- 已完成 Task 7全量回归、文档回填与交付检查
- 已完成 JVM AI 计划解析、资源定位解析、AI 计划到当前 JVM 变更草稿的显式映射,避免把 `payload.format/value` 包装层直接透传到现有 JVM 写入契约
- 已完成 AI 聊天面板 JVM 上下文注入、AI 气泡“应用到 JVM 预览”入口以及 JVM 资源页草稿回填闭环
- 已完成 JVM AI 计划来源上下文绑定:消息现在绑定生成时的 `tabId + connectionId + providerMode + resourcePath`,避免切换 JVM 页签后误投递到当前激活页
- 已完成 Endpoint provider 真实 HTTP contract 与补测,支持资源浏览、读值、预览和执行
- 已完成可手工启动的 Java Endpoint fixture 与真实集成补测,可直接验证 Endpoint 模式端到端行为
- 已完成 JMX provider 真实 helper 接入与补测,支持 `domain -> mbean -> attribute/operation` 浏览、attribute `set`、operation `invoke`
- 已完成 JMX helper 预编译 runtime jar 内嵌分发,运行时不再依赖仓库源码目录,也不再要求本地 `javac`
- 已完成 JVM 快照动作提示与 payload 模板回填,前端可直接根据 `supportedActions` 生成草稿
- 已完成 AI 参与来源写入 JVM 审计记录审计页可区分“手工”与“AI 辅助”
- 已完成 Agent provider、Agent 连接表单与概览展示,支持通过独立 Agent Base URL 接入 GoNavi Java Agent
- 已完成真实 Java Agent fixture 与集成验证,可通过 `-javaagent` 方式真实验证 Agent 模式资源浏览、预览与执行
- 已完成 JVM 收口优化Endpoint 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义
- 待处理:
- 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项
## 5. 风险与阻塞
- 风险:
- 直接修改 JVM 内对象属于高风险运行时操作,误改可能造成业务状态污染
- 不同缓存框架Caffeine/Ehcache/Guava/自研 Map缺少统一标准协议
- 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性
- 若目标 JVM 不允许预埋或动态注入 Agent则“通用型”能力边界会明显收缩
- 多接入模式会带来能力不一致问题UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
- 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力
- 当前 AI 计划若只提供 `namespace + key`,仍更适合 endpoint/cache 风格资源JMX 复杂 target 仍建议优先使用 `resourcePath`
- JMX helper 已改为内嵌 jar 分发,但操作者机器仍需本地存在可用 `java`
- Agent 模式要求目标 Java 服务显式以 `-javaagent` 方式启动 GoNavi Java Agent并额外暴露管理端口
- JMX operation preview 仅做参数/签名校验和预览快照,不预测真实副作用
- JMX 参数转换当前覆盖基础类型、`ObjectName` 和部分数组;复杂对象写入仍是后续扩展项
- 历史旧 AI 消息不包含 JVM 来源上下文,若需要应用到预览,需在目标 JVM 资源页重新生成计划
- 阻塞:
- 当前开发收口阶段无新增阻塞
- 缓解措施:
- 优先收敛到标准接入面JMX / Spring Actuator / Java Agent 三选一)
- 首期只支持白名单对象类型与受控写操作
- 要求变更审计、预览、确认与失败回滚路径
- 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入”
- JMX helper 改为内嵌 runtime jar默认写入用户缓存目录必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath
- 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行
## 6. 决策记录
- 决策 1先做可行性评估与方案设计不直接进入实现
- 决策 2默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程
- 决策 3已按完整模式推进后续方案将优先评估通用 Agent 路径是否成立
- 决策 4由于目标服务大概率不允许 agent/attach后续推荐方向转为“多接入模式 + 能力协商”
- 决策 5AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入
- 决策 6AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
- 决策 7当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
- 决策 8JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
- 决策 9JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java`
- 决策 10Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach
## 7. 验证记录
- 验证项:
- GoNavi 驱动代理机制核查
- GoNavi 现有 Redis/编辑器/UI 复用能力核查
- JVM Connector 正式设计文档自检
- JVM Connector 实施计划文档自检
- Task 1JVM 共享契约与配置归一化
- Task 2Provider 注册、连接测试与能力探测 API
- Task 6AI 计划解析、资源定位解析、契约映射与页签上下文隔离
- Task 7Java Endpoint fixture 真实集成验证
- Task 7JMX helper 内嵌分发与运行时缓存验证
- Task 7Agent provider 与真实 Java Agent 集成验证
- Task 7后端全量测试
- Task 7前端全量测试
- Task 7前端生产构建
- Task 7Wails 生产构建
- 结果:
- 已确认存在可复用的连接桥接与编辑器基础设施
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
- 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节
- 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测
- Task 1 已完成规格审查与代码质量审查,结论均通过
- 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API
- Task 2 已完成规格审查与代码质量审查,结论均通过
- 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入
- Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题
- 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点
- Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题
- 已完成 Task 5后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力
- Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
- 已完成 Task 6AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
- 已完成 Task 6AI 聊天消息与 JVM 来源页签绑定AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
- 已完成 Task 7Java Endpoint fixture可真实验证 `resources / value / preview / apply` 四个 endpoint contract
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过
- 已完成 Task 7JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过
- 已完成 Task 7Agent provider、Java agent fixture 与真实 `-javaagent` 集成测试
- `go test ./internal/jvm -run 'TestAgentProvider' -count=1` 通过
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过11 tests
- `go test ./... -count=1` 通过
- `cd frontend && npm test -- --run` 通过61 files259 tests
- `cd frontend && npm run build` 通过;构建中存在既有 chunk size / dynamic import 警告,但未阻塞产物生成
- `wails build -clean` 通过,成功生成 macOS 应用包
- 已完成 JVM 收口优化:模式能力探测现在按当前 mode 做业务化错误翻译,避免概览页继续回显 `non-JRMP server``baseURL is required` 这类原始报错
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 再次通过Endpoint 能力探测只读语义回归)
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` 再次通过(能力探测模式透传与中文错误翻译回归)
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` 通过JVM 资源页布局回归)
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` 通过(风险等级、审计结果等本地化展示回归)
- `cd frontend && npm run build` 再次通过
- `wails build -clean` 再次通过,成功生成最新可验收桌面包
- 证据(日志/截图/链接):
- `cmd/optional-driver-agent/main.go`
- `internal/db/database.go`
- `frontend/src/components/RedisViewer.tsx`
- `frontend/src/components/RedisCommandEditor.tsx`
- `frontend/src/components/QueryEditor.tsx`
- `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md`
- `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`
- `internal/connection/types.go`
- `internal/jvm/types.go`
- `internal/jvm/config.go`
- `internal/jvm/config_test.go`
- `frontend/src/types.ts`
- `frontend/src/utils/jvmConnectionConfig.ts`
- `frontend/src/utils/jvmConnectionConfig.test.ts`
- `go test ./internal/jvm -count=1`
- `go test ./...`
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm test -- --run`
- `cd frontend && npm run build`
- `internal/jvm/provider.go`
- `internal/jvm/jmx_provider.go`
- `internal/jvm/http_provider.go`
- `internal/jvm/http_provider_test.go`
- `internal/jvm/jmx_helper.go`
- `internal/jvm/jmx_helper_test.go`
- `internal/jvm/provider_contract_test.go`
- `internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar`
- `internal/jvm/jmxhelper_assets/README.md`
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java`
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java`
- `tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/wailsjs/go/app/App.d.ts`
- `frontend/wailsjs/go/app/App.js`
- `frontend/wailsjs/go/models.ts`
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1`
- `go test ./internal/jvm ./internal/app -count=1`
- `wails build -clean`
- `frontend/src/components/DatabaseIcons.tsx`
- `frontend/src/components/ConnectionModal.tsx`
- `frontend/src/utils/jvmRuntimePresentation.ts`
- `frontend/src/utils/jvmRuntimePresentation.test.ts`
- `frontend/src/utils/jvmConnectionConfig.ts`
- `frontend/src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm run build`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/src/components/Sidebar.tsx`
- `frontend/src/components/TabManager.tsx`
- `frontend/src/components/JVMOverview.tsx`
- `frontend/src/components/JVMResourceBrowser.tsx`
- `frontend/src/components/jvm/JVMModeBadge.tsx`
- `frontend/src/store.ts`
- `frontend/src/types.ts`
- `go test ./internal/app -run 'TestJVM(ListResources|GetValue)' -count=1`
- `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1`
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
- `cd frontend && npm run build`
- `internal/jvm/guard.go`
- `internal/jvm/guard_test.go`
- `internal/jvm/audit_store.go`
- `internal/jvm/audit_store_test.go`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/src/components/JVMAuditViewer.tsx`
- `frontend/src/components/jvm/JVMChangePreviewModal.tsx`
- `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1`
- `go test ./internal/jvm ./internal/app -count=1`
- `cd frontend && npm run build`
- `frontend/src/utils/jvmAiPlan.ts`
- `frontend/src/utils/jvmAiPlan.test.ts`
- `frontend/src/components/AIChatPanel.tsx`
- `frontend/src/components/ai/AIMessageBubble.tsx`
- `frontend/src/components/JVMResourceBrowser.tsx`
- `frontend/src/types.ts`
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts`
- `go test ./... -count=1`
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1`
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1`
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx`
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts`
- `cd frontend && npm test -- --run`
- `wails build -clean`
## 8. 下一步
- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入
- 负责人Codex

View File

@@ -1,24 +0,0 @@
# SQL 方言适配需求进度追踪
## 背景
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
- GitHub 相关问题Refs #402(金仓字段类型/DDL 方言、Refs #409Oracle 删除数据 DATE 字面量)。
## 范围
- 表设计 ALTER TABLE 预览:按 MySQL-family、PostgreSQL-family、Oracle/Dameng、SQL Server、SQLite、DuckDB、ClickHouse、TDengine 分支生成。
- 新建表 DDL 预览:避免 Oracle/Dameng/SQL Server/SQLite/DuckDB/ClickHouse/TDengine 输出 MySQL 表选项。
- SQL 自动补全:按当前连接方言解析关键字和函数,避免 Oracle/SQL Server 出现 MySQL-only 提示。
- 表设计字段类型:按数据源给出候选类型,不再大量回退到 MySQL 通用类型。
- Oracle/Dameng 数据复制/删除 SQLDATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
## 验证
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
- `npm run build`
## 风险与后续
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER并用中文注释阻止 MySQL 专属子句外溢。
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。

View File

@@ -1,71 +0,0 @@
# 需求进度追踪 - 发布脚本测试版号与 Mac 打包无交互
## 1. 需求摘要
- 需求名称:发布脚本测试版号与 Mac 打包无交互
- 提出日期2026-04-24
- 负责人Codex
- 目标:
- `build-release.sh` 不再触发 macOS DMG/Finder 排版交互。
- `build-release.sh` 与开发态应用内版本号统一使用测试版号来源。
- 非目标:
- 不调整 GitHub Release 工作流。
- 不修改正式发布 tag 版本策略。
## 2. 范围与验收
- 范围:
- 发布脚本 `build-release.sh`
- 版本解析逻辑 `internal/app/version.go`
- 共享测试版号文件
- 验收标准:
- `bash build-release.sh` 的 macOS 打包不再调用 `create-dmg` 或触发 Finder 排版。
- 本地开发态版本显示与发布脚本默认版本号一致。
- 保留环境变量覆盖版本号能力。
- 依赖与约束:
- 维持现有 Windows/Linux 构建逻辑不变。
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认去掉 DMG 排版,统一测试版号来源
- [x] 阶段 2影响分析锁定 `build-release.sh``internal/app/version.go`
- [x] 阶段 3方案设计共享 `version/dev-version.txt`macOS 改 ZIP 打包
- [x] 阶段 4实施计划先补版本回归测试再改实现
- [ ] 阶段 5实现与自检
- [ ] 阶段 6评审与交付
- [ ] 阶段 7发布与观察
## 4. 变更清单
- 已完成:
- 新增共享测试版号文件。
- 新增版本回归测试。
- 改造发布脚本 macOS 打包为无交互 ZIP。
- 进行中:
- 自检验证。
- 待处理:
- 无。
## 5. 风险与阻塞
- 风险:
- 正式发版若未覆盖 `GONAVI_VERSION`,默认会使用测试版号。
- 阻塞:
- 无。
- 缓解措施:
- 允许通过 `GONAVI_VERSION` 环境变量显式覆盖。
## 6. 决策记录
- 决策 1`version/dev-version.txt` 作为本地开发/测试共享版本号来源。
- 决策 2发布脚本的 macOS 产物改为 ZIP避免 `create-dmg` 的 Finder 交互。
## 7. 验证记录
- 验证项:
- 版本回归测试
- 发布脚本语法检查
- 发布脚本运行输出
- 结果:
- 进行中
- 证据(日志/截图/链接):
- 待补充
## 8. 下一步
- 下一步行动:
- 跑通回归测试和脚本验证,确认输出产物与版本号
- 负责人:
- Codex

View File

@@ -1,182 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>AI UI Brainstorming Prototypes</title>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Ant Design -->
<script src="https://unpkg.com/dayjs/dayjs.min.js"></script>
<script src="https://unpkg.com/antd/dist/antd.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/antd/dist/reset.css" />
<!-- Icons -->
<script src="https://unpkg.com/@ant-design/icons/dist/index.umd.js"></script>
<style>
body { padding: 40px; background: #f0f2f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }
.prototype-container { display: flex; gap: 40px; }
.prototype-column { flex: 1; max-width: 600px; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); overflow: hidden; }
.prototype-header { padding: 16px 24px; border-bottom: 1px solid #f0f0f0; background: #fafafa; font-weight: bold; }
.prototype-body { padding: 24px; }
/* Default App Theme Colors (Light Mode) */
:root {
--gn-border: rgba(16,24,40,0.08);
--gn-bg: rgba(255,255,255,0.84);
--gn-text: #162033;
--gn-muted: rgba(16,24,40,0.55);
--gn-primary: #1677ff;
--gn-primary-bg: rgba(24,144,255,0.1);
}
/* V1 Styles: Professional List */
.v1-list-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; margin-bottom: 8px; border-radius: 8px;
border: 1px solid transparent; cursor: pointer; transition: all 0.2s;
}
.v1-list-item:hover { background: #f5f5f5; }
.v1-list-item.selected {
background: var(--gn-primary-bg); border-color: var(--gn-primary);
}
/* V2 Styles: Refined Cards (ConnectionModal Style) */
.v2-card-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
}
.v2-card {
padding: 16px; border-radius: 12px; border: 1px solid var(--gn-border);
cursor: pointer; transition: all 0.2s; background: white;
box-shadow: inset 0 0 0 1px rgba(16,24,40,0.01);
}
.v2-card:hover { border-color: #d9d9d9; background: #fafafa; }
.v2-card.selected {
border-color: var(--gn-primary); box-shadow: 0 0 0 1px var(--gn-primary) inset;
}
.section-title { font-size: 13px; font-weight: 600; color: var(--gn-muted); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState } = React;
const { Input, Slider, Select, Button, Form, ConfigProvider } = antd;
const { ThunderboltOutlined, CloudOutlined, ExperimentOutlined, AppstoreOutlined, SettingOutlined, LinkOutlined, KeyOutlined } = icons;
const PROVIDERS = [
{ key: 'openai', label: 'OpenAI', icon: <ThunderboltOutlined />, desc: 'GPT-4o / o1' },
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'V3 / R1' },
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Sonnet 3.5' },
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '通用 API' },
];
const V1ListDesign = () => {
const [selected, setSelected] = useState('openai');
return (
<div className="prototype-column">
<div className="prototype-header">方案一IDE 专业列表风格 (更克制无彩色渐变)</div>
<div className="prototype-body">
<div className="section-title">提供商选择</div>
<div style={{ marginBottom: 24, padding: 8, background: '#fafafa', borderRadius: 10, border: '1px solid #f0f0f0' }}>
{PROVIDERS.map(p => (
<div key={p.key} className={`v1-list-item ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 32, height: 32, borderRadius: 6, display: 'grid', placeItems: 'center',
background: selected === p.key ? '#1677ff' : '#e6f4ff',
color: selected === p.key ? '#fff' : '#1677ff', fontSize: 16
}}>
{p.icon}
</div>
<div>
<div style={{ fontWeight: 500, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
<div style={{ fontSize: 12, color: 'var(--gn-muted)' }}>{p.desc}</div>
</div>
</div>
<div style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selected === p.key ? 'var(--gn-primary)' : '#d9d9d9'}`, padding: 2 }}>
{selected === p.key && <div style={{ width: '100%', height: '100%', background: 'var(--gn-primary)', borderRadius: '50%' }} />}
</div>
</div>
))}
</div>
<div className="section-title">连接配置 (紧凑表单)</div>
<Form layout="vertical" size="middle">
<Form.Item label="API Endpoint">
<Input placeholder="https://api.openai.com/v1" prefix={<LinkOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
<Form.Item label="API Key">
<Input.Password placeholder="sk-..." prefix={<KeyOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
<Form.Item label="Model Name">
<Input placeholder="gpt-4o" prefix={<AppstoreOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
</Form>
</div>
</div>
);
};
const V2CardDesign = () => {
const [selected, setSelected] = useState('openai');
return (
<div className="prototype-column">
<div className="prototype-header">方案二GoNavi 统一卡片风格 (类似 ConnectionModal)</div>
<div className="prototype-body">
<div className="section-title">选择服务提供商</div>
<div className="v2-card-grid" style={{ marginBottom: 24 }}>
{PROVIDERS.map(p => (
<div key={p.key} className={`v2-card ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ color: selected === p.key ? 'var(--gn-primary)' : 'var(--gn-muted)', fontSize: 20, marginTop: 2 }}>
{p.icon}
</div>
<div>
<div style={{ fontWeight: 600, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
<div style={{ fontSize: 12, color: 'var(--gn-muted)', marginTop: 4 }}>{p.desc}</div>
</div>
</div>
</div>
))}
</div>
<div style={{ padding: 20, borderRadius: 12, border: '1px solid var(--gn-border)', background: '#fafafa' }}>
<div className="section-title" style={{ marginTop: 0 }}>认证与设置</div>
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} size="middle">
<Form.Item label="Endpoint" style={{ marginBottom: 16 }}>
<Input placeholder="https://api..." />
</Form.Item>
<Form.Item label="API Key" style={{ marginBottom: 16 }}>
<Input.Password placeholder="sk-..." />
</Form.Item>
<Form.Item label="模型名称" style={{ marginBottom: 0 }}>
<Input placeholder="例如 gpt-4o" />
</Form.Item>
</Form>
</div>
</div>
</div>
);
};
const App = () => (
<ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}>
<div style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 24, margin: 0 }}>AI 设置 UI 重构探讨</h1>
<p style={{ color: 'var(--gn-muted)' }}>当前设计带有太多渐变和鲜艳色彩"AI 味"以下是遵循 GoNavi 本身设计规范克制专业的两个方案</p>
</div>
<div className="prototype-container">
<V1ListDesign />
<V2CardDesign />
</div>
</ConfigProvider>
);
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>

View File

@@ -5,23 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GoNavi</title>
<script>
if (typeof window !== 'undefined' && !window.go) {
window.go = {
app: {
App: new Proxy({}, { get: () => async () => ({ success: false }) })
}
};
}
if (typeof window !== 'undefined' && !window.runtime) {
window.runtime = new Proxy({}, {
get: (target, prop) => {
if (prop === 'Environment') return async () => ({ platform: 'darwin' });
return typeof prop === 'string' && prop.startsWith('WindowIs') ? () => false : () => {};
}
});
}
</script>
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
{
"name": "gonavi-client",
"private": true,
"version": "0.6.5",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run"
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.2.6",
@@ -16,17 +15,11 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
"mermaid": "^11.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
"uuid": "^9.0.1",
"zustand": "^4.4.7"
@@ -35,12 +28,9 @@
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-resizable": "^3.0.8",
"@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^3.2.4"
"vite": "^5.0.8"
}
}
}

View File

@@ -1 +1 @@
0295a42fd931778d85157816d79d29e5
d0f9366af59a6367ad3c7e2d4185ead4

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>ClickHouse</title><path d="M21.333 10H24v4h-2.667ZM16 1.335h2.667v21.33H16Zm-5.333 0h2.666v21.33h-2.666ZM0 22.665V1.335h2.667v21.33zm5.333-21.33H8v21.33H5.333Z"/></svg>

Before

Width:  |  Height:  |  Size: 246 B

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apache Doris</title><path d="M8.666.0001c-.5355-.004-1.068.1072-1.5241.3384-.207.1048-.5749.3802-.8177.6118-1.0278.9803-1.2876 2.5138-.6553 3.8679.205.439.5068.7694 2.8476 3.1166 2.4527 2.4594 2.6352 2.6255 2.8852 2.6258.2446.0003.3647-.099 1.4408-1.19.9367-.9496 1.2306-1.2992 1.4536-1.7286.5966-1.149.6487-2.0513.174-3.014-.2264-.459-.4816-.7514-1.9012-2.176-.9018-.9052-1.7907-1.7496-1.9751-1.8765C10.0488.2005 9.3548.0052 8.666 0ZM3.5518 5.5737c-.2176.0031-.6097.085-.6097.3285v12.0904l.1642.175c.1123.1194.2498.1748.4342.1748.2545 0 .4436-.1738 3.349-3.0786 2.6868-2.6862 3.079-2.909 3.0791-3.305.0002-.3961-.3924-.6194-3.0784-3.306-2.8612-2.8619-3.0968-3.079-3.3384-3.079Zm13.0967.861c-.0481.0184-.112.1636-.1418.3225-.0756.403-.3719 1.109-.6572 1.5663-.1407.2253-2.2392 2.3955-5.049 5.2212-2.7513 2.7667-4.9104 4.9985-5.0468 5.2165-.4552.7275-.5967 1.3905-.4684 2.1964.222 1.3947 1.3263 2.6812 2.5486 2.9693.4667.11 1.618.0927 2.0329-.0305.2084-.062.526-.2112.7055-.3318.5023-.3373 9.341-9.0562 9.6463-9.5154.449-.6753.8356-1.0716.8395-1.9762-.0056-.5935-.1305-1.1138-1.0715-2.306-.5094-.6523-3.2341-3.3723-3.338-3.3324Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>DuckDB</title><path d="M12 0C5.363 0 0 5.363 0 12s5.363 12 12 12 12-5.363 12-12S18.637 0 12 0zM9.502 7.03a4.974 4.974 0 0 1 4.97 4.97 4.974 4.974 0 0 1-4.97 4.97A4.974 4.974 0 0 1 4.532 12a4.974 4.974 0 0 1 4.97-4.97zm6.563 3.183h2.351c.98 0 1.787.782 1.787 1.762s-.807 1.789-1.787 1.789h-2.351v-3.551z"/></svg>

Before

Width:  |  Height:  |  Size: 389 B

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MariaDB</title><path d="M23.157 4.412c-.676.284-.79.31-1.673.372-.65.045-.757.057-1.212.209-.75.246-1.395.75-2.02 1.59-.296.398-1.249 1.913-1.249 1.988 0 .057-.65.998-.915 1.32-.574.713-1.08 1.079-2.14 1.59-.77.36-1.224.524-4.102 1.477-1.073.353-2.133.738-2.367.864-.852.449-1.515 1.036-2.203 1.938-1.003 1.32-.972 1.313-3.042.947a12.264 12.264 0 00-.675-.063c-.644-.05-1.023.044-1.332.334L0 17.193l.177.088c.094.05.353.234.561.398.215.17.461.347.55.391.088.044.17.088.183.101.012.013-.089.17-.228.353-.435.581-.593.871-.574 1.048.019.164.032.17.43.17.517-.006.826-.056 1.261-.208.65-.233 2.058-.94 2.784-1.4.776-.5 1.717-.998 1.956-1.042.082-.02.354-.07.594-.114.58-.107 1.464-.095 2.587.05.108.013.373.045.6.064.227.025.43.057.454.076.026.012.474.037.998.056.934.026 1.104.007 1.3-.189.126-.133.385-.631.498-.985.209-.643.417-.921.366-.492-.113.966-.322 1.692-.713 2.411-.259.499-.663 1.092-.934 1.395-.322.347-.315.36.088.315.619-.063 1.471-.397 2.096-.82.827-.562 1.647-1.691 2.19-3.03.107-.27.22-.22.183.083-.013.094-.038.315-.057.498l-.031.328.353-.202c.833-.48 1.414-1.262 2.127-2.884.227-.518.877-2.922 1.073-3.976a9.64 9.64 0 01.271-1.042c.127-.429.196-.555.48-.858.183-.19.625-.555.978-.808.72-.505.953-.75 1.187-1.205.208-.417.284-1.13.132-1.357-.132-.202-.284-.196-.763.006Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MongoDB</title><path d="M17.193 9.555c-1.264-5.58-4.252-7.414-4.573-8.115-.28-.394-.53-.954-.735-1.44-.036.495-.055.685-.523 1.184-.723.566-4.438 3.682-4.74 10.02-.282 5.912 4.27 9.435 4.888 9.884l.07.05A73.49 73.49 0 0111.91 24h.481c.114-1.032.284-2.056.51-3.07.417-.296.604-.463.85-.693a11.342 11.342 0 003.639-8.464c.01-.814-.103-1.662-.197-2.218zm-5.336 8.195s0-8.291.275-8.29c.213 0 .49 10.695.49 10.695-.381-.045-.765-1.76-.765-2.405z"/></svg>

Before

Width:  |  Height:  |  Size: 527 B

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MySQL</title><path d="M16.405 5.501c-.115 0-.193.014-.274.033v.013h.014c.054.104.146.18.214.273.054.107.1.214.154.32l.014-.015c.094-.066.14-.172.14-.333-.04-.047-.046-.094-.08-.14-.04-.067-.126-.1-.18-.153zM5.77 18.695h-.927a50.854 50.854 0 00-.27-4.41h-.008l-1.41 4.41H2.45l-1.4-4.41h-.01a72.892 72.892 0 00-.195 4.41H0c.055-1.966.192-3.81.41-5.53h1.15l1.335 4.064h.008l1.347-4.064h1.095c.242 2.015.384 3.86.428 5.53zm4.017-4.08c-.378 2.045-.876 3.533-1.492 4.46-.482.716-1.01 1.073-1.583 1.073-.153 0-.34-.046-.566-.138v-.494c.11.017.24.026.386.026.268 0 .483-.075.647-.222.197-.18.295-.382.295-.605 0-.155-.077-.47-.23-.944L6.23 14.615h.91l.727 2.36c.164.536.233.91.205 1.123.4-1.064.678-2.227.835-3.483zm12.325 4.08h-2.63v-5.53h.885v4.85h1.745zm-3.32.135l-1.016-.5c.09-.076.177-.158.255-.25.433-.506.648-1.258.648-2.253 0-1.83-.718-2.746-2.155-2.746-.704 0-1.254.232-1.65.697-.43.508-.646 1.256-.646 2.245 0 .972.19 1.686.574 2.14.35.41.877.615 1.583.615.264 0 .506-.033.725-.098l1.325.772.36-.622zM15.5 17.588c-.225-.36-.337-.94-.337-1.736 0-1.393.424-2.09 1.27-2.09.443 0 .77.167.977.5.224.362.336.936.336 1.723 0 1.404-.424 2.108-1.27 2.108-.445 0-.77-.167-.978-.5zm-1.658-.425c0 .47-.172.856-.516 1.156-.344.3-.803.45-1.384.45-.543 0-1.064-.172-1.573-.515l.237-.476c.438.22.833.328 1.19.328.332 0 .593-.073.783-.22a.754.754 0 00.3-.615c0-.33-.23-.61-.648-.845-.388-.213-1.163-.657-1.163-.657-.422-.307-.632-.636-.632-1.177 0-.45.157-.81.47-1.085.315-.278.72-.415 1.22-.415.512 0 .98.136 1.4.41l-.213.476a2.726 2.726 0 00-1.064-.23c-.283 0-.502.068-.654.206a.685.685 0 00-.248.524c0 .328.234.61.666.85.393.215 1.187.67 1.187.67.433.305.648.63.648 1.168zm9.382-5.852c-.535-.014-.95.04-1.297.188-.1.04-.26.04-.274.167.055.053.063.14.11.214.08.134.218.313.346.407.14.11.28.216.427.31.26.16.555.255.81.416.145.094.293.213.44.313.073.05.12.14.214.172v-.02c-.046-.06-.06-.147-.105-.214-.067-.067-.134-.127-.2-.193a3.223 3.223 0 00-.695-.675c-.214-.146-.682-.35-.77-.595l-.013-.014c.146-.013.32-.066.46-.106.227-.06.435-.047.67-.106.106-.027.213-.06.32-.094v-.06c-.12-.12-.21-.283-.334-.395a8.867 8.867 0 00-1.104-.823c-.21-.134-.476-.22-.697-.334-.08-.04-.214-.06-.26-.127-.12-.146-.19-.34-.275-.514a17.69 17.69 0 01-.547-1.163c-.12-.262-.193-.523-.34-.763-.69-1.137-1.437-1.826-2.586-2.5-.247-.14-.543-.2-.856-.274-.167-.008-.334-.02-.5-.027-.11-.047-.216-.174-.31-.235-.38-.24-1.364-.76-1.644-.072-.18.434.267.862.422 1.082.115.153.26.328.34.5.047.116.06.235.107.356.106.294.207.622.347.897.073.14.153.287.247.413.054.073.146.107.167.227-.094.136-.1.334-.154.5-.24.757-.146 1.693.194 2.25.107.166.362.534.703.393.3-.12.234-.5.32-.835.02-.08.007-.133.048-.187v.015c.094.188.188.367.274.555.206.328.566.668.867.895.16.12.287.328.487.402v-.02h-.015c-.043-.058-.1-.086-.154-.133a3.445 3.445 0 01-.35-.4 8.76 8.76 0 01-.747-1.218c-.11-.21-.202-.436-.29-.643-.04-.08-.04-.2-.107-.24-.1.146-.247.273-.32.453-.127.288-.14.642-.188 1.01-.027.007-.014 0-.027.014-.214-.052-.287-.274-.367-.46-.2-.475-.233-1.238-.06-1.785.047-.14.247-.582.167-.716-.042-.127-.174-.2-.247-.303a2.478 2.478 0 01-.24-.427c-.16-.374-.24-.788-.414-1.162-.08-.173-.22-.354-.334-.513-.127-.18-.267-.307-.368-.52-.033-.073-.08-.194-.027-.274.014-.054.042-.075.094-.09.088-.072.335.022.422.062.247.1.455.194.662.334.094.066.195.193.315.226h.14c.214.047.455.014.655.073.355.114.675.28.962.46a5.953 5.953 0 012.085 2.286c.08.154.115.295.188.455.14.33.313.663.455.982.14.315.275.636.476.897.1.14.502.213.682.286.133.06.34.115.46.188.23.14.454.3.67.454.11.076.443.243.463.378z"/></svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Redis</title><path d="M22.71 13.145c-1.66 2.092-3.452 4.483-7.038 4.483-3.203 0-4.397-2.825-4.48-5.12.701 1.484 2.073 2.685 4.214 2.63 4.117-.133 6.94-3.852 6.94-7.239 0-4.05-3.022-6.972-8.268-6.972-3.752 0-8.4 1.428-11.455 3.685C2.59 6.937 3.885 9.958 4.35 9.626c2.648-1.904 4.748-3.13 6.784-3.744C8.12 9.244.886 17.05 0 18.425c.1 1.261 1.66 4.648 2.424 4.648.232 0 .431-.133.664-.365a100.49 100.49 0 0 0 5.54-6.765c.222 3.104 1.748 6.898 6.014 6.898 3.819 0 7.604-2.756 9.33-8.965.2-.764-.73-1.361-1.261-.73zm-4.349-5.013c0 1.959-1.926 2.922-3.685 2.922-.941 0-1.664-.247-2.235-.568 1.051-1.592 2.092-3.225 3.21-4.973 1.972.334 2.71 1.43 2.71 2.619z"/></svg>

Before

Width:  |  Height:  |  Size: 738 B

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Sphinx</title><path d="M16.284 19.861c0-.654.177-1.834.393-2.623.499-1.822.774-4.079.497-4.079-.116 0-.959.762-1.873 1.694-3.472 3.54-7.197 5.543-10.312 5.543-1.778 0-2.987-.45-4.154-1.545C.128 18.186 0 17.858 0 16.703c0-1.188.117-1.468.909-2.175.718-.642 1.171-.813 2.157-.813.76.171 1.21.16 1.457.461.251.296.338 1.265.035 1.832-.162.303-.585.491-1.105.491-.49 0-.77-.116-.669-.278.315-.511-.135-.857-.713-.548-.699.374-.711 1.698-.021 2.322.969.878 3.65 1.208 5.262.648 1.743-.605 4.022-2.061 5.841-3.732l1.6-1.469-2.088-.013c-2.186-.012-3.608-.273-8.211-1.506-1.531-.41-3.003-.765-3.271-.789-.304-.026-.503-.274-.487-.656.027-.646.378-1.127.793-1.308.249-.109 1.977-.274 3.809-.761 7.136-1.898 7.569-1.629 12.323-.426 1.553.393 3.351.821 4.147.835 1.227.022 1.493.124 1.74.666.16.351.291.686.291.745 0 .058-.695.424-1.545.813-3.12 1.428-4.104 2.185-3.088 3.635.421.602.412.666-.14 1.052-.323.227-.59.687-.593 1.022-.009.908-.583 2.856-1.417 3.624l-.732.675v-1.189Zm1.594-8.328c1.242-.346 1.994-.738 3.539-1.562-1.272-.372-4.462-.895-4.462-.895-2.354-.472-2.108-.448-2.214.071a3.475 3.475 0 0 1-.45 1.105c-.541.848-2.521 1.026-3.656.483-.356-.171-.714-.821-.709-1.283.007-.65-.362-.801-.598-.714-.191.07-.813.079-2.179.448-4.514 1.217-5.132 1.078-2.189 1.495.353.05 2.223.572 3.136.815 2.239.597 2.658.641 5.556.581 2.015-.042 2.858-.163 4.226-.544ZM.732 6.258c.056-.577.088-.702 1.692-1.025.919-.185 3.185-.785 5.036-1.333 4.254-1.26 5.462-1.263 9.873-.026 1.904.535 4.037.973 4.74.975 1.097.002 1.668.487 1.668.487.505 1.16.412 1.24-1.558 1.24-1.374 0-2.558-.232-4.385-.857-1.389-.476-3.369-.923-4.451-1.004-1.974-.149-1.971-.15-8.072 1.529-1.072.295-2.553.624-3.29.732l-1.342.196.089-.914Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>SQLite</title><path d="M21.678.521c-1.032-.92-2.28-.55-3.513.544a8.71 8.71 0 0 0-.547.535c-2.109 2.237-4.066 6.38-4.674 9.544.237.48.422 1.093.544 1.561a13.044 13.044 0 0 1 .164.703s-.019-.071-.096-.296l-.05-.146a1.689 1.689 0 0 0-.033-.08c-.138-.32-.518-.995-.686-1.289-.143.423-.27.818-.376 1.176.484.884.778 2.4.778 2.4s-.025-.099-.147-.442c-.107-.303-.644-1.244-.772-1.464-.217.804-.304 1.346-.226 1.478.152.256.296.698.422 1.186.286 1.1.485 2.44.485 2.44l.017.224a22.41 22.41 0 0 0 .056 2.748c.095 1.146.273 2.13.5 2.657l.155-.084c-.334-1.038-.47-2.399-.41-3.967.09-2.398.642-5.29 1.661-8.304 1.723-4.55 4.113-8.201 6.3-9.945-1.993 1.8-4.692 7.63-5.5 9.788-.904 2.416-1.545 4.684-1.931 6.857.666-2.037 2.821-2.912 2.821-2.912s1.057-1.304 2.292-3.166c-.74.169-1.955.458-2.362.629-.6.251-.762.337-.762.337s1.945-1.184 3.613-1.72C21.695 7.9 24.195 2.767 21.678.521m-18.573.543A1.842 1.842 0 0 0 1.27 2.9v16.608a1.84 1.84 0 0 0 1.835 1.834h9.418a22.953 22.953 0 0 1-.052-2.707c-.006-.062-.011-.141-.016-.2a27.01 27.01 0 0 0-.473-2.378c-.121-.47-.275-.898-.369-1.057-.116-.197-.098-.31-.097-.432 0-.12.015-.245.037-.386a9.98 9.98 0 0 1 .234-1.045l.217-.028c-.017-.035-.014-.065-.031-.097l-.041-.381a32.8 32.8 0 0 1 .382-1.194l.2-.019c-.008-.016-.01-.038-.018-.053l-.043-.316c.63-3.28 2.587-7.443 4.8-9.791.066-.069.133-.128.198-.194Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,6 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>SQL Server</title>
<path fill="#A91D22" d="M4.2 7.25c1.05-1.56 4.53-2.69 8.24-2.69 3.34 0 6.13.91 7.25 2.15.57.64.63 1.29.16 1.87-1 1.27-3.81 2.09-7.18 2.09-3.85 0-7.1-1.03-8.29-2.52-.32-.4-.38-.61-.18-.9Z"/>
<path fill="#D63539" d="M5.07 11.11c1.27-1.2 4.24-2.04 7.42-2.04 3.59 0 6.58 1.04 7.34 2.54.27.54.16 1.07-.34 1.55-1.18 1.12-3.89 1.81-7.12 1.81-3.56 0-6.56-.91-7.6-2.25-.4-.52-.31-1.02.3-1.61Z"/>
<path fill="#F15F5C" d="M7.2 16.12c1.12-.75 3.11-1.18 5.38-1.18 2.43 0 4.59.52 5.71 1.39.84.65 1 1.42.42 2.05-.92 1-3.09 1.63-5.74 1.63-2.87 0-5.34-.75-6.22-1.88-.53-.68-.36-1.37.45-2.01Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 691 B

View File

@@ -7,7 +7,7 @@ html, body, #root {
}
body, #root {
border-radius: var(--gonavi-border-radius); /* Slightly rounded app window corners */
border-radius: 14px; /* Slightly rounded app window corners */
}
/* 侧边栏 Tree 样式优化 */
@@ -37,126 +37,6 @@ body, #root {
padding-right: 8px;
}
.sidebar-tree-scroll-shell {
overflow-x: auto;
overflow-y: hidden;
}
.sidebar-tree-scroll-content {
min-width: 100%;
}
.sidebar-tree-scroll-shell .ant-tree {
min-width: 100%;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-list-holder,
.sidebar-tree-scroll-shell .ant-tree .ant-tree-list-holder-inner {
min-width: 100%;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-treenode {
width: auto;
min-width: 100%;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-node-content-wrapper {
width: auto !important;
min-width: 0;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-title {
flex: 0 0 auto;
min-width: 0;
overflow: visible;
text-overflow: clip;
}
.redis-viewer-workbench .ant-tree {
background: transparent;
}
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner,
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner .ant-tree-treenode {
width: 100% !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper {
min-height: 36px;
border-radius: 14px;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
background: transparent !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
flex: 1 1 auto;
min-width: 0;
width: auto !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:hover,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:active,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus-visible,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
outline: none !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-treenode {
padding: 2px 0;
width: 100%;
border-radius: 14px;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
border: none;
align-items: center;
position: relative;
z-index: 0;
display: flex !important;
box-sizing: border-box;
}
.redis-viewer-workbench .ant-tree .ant-tree-switcher {
width: 0 !important;
min-width: 0 !important;
margin-inline-end: 0 !important;
padding: 0 !important;
overflow: hidden !important;
background: transparent !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-switcher:hover,
.redis-viewer-workbench .ant-tree .ant-tree-switcher:active,
.redis-viewer-workbench .ant-tree .ant-tree-switcher:focus {
background: transparent !important;
}
.redis-viewer-workbench .redis-tree-expander-button:hover,
.redis-viewer-workbench .redis-tree-expander-button:focus-visible {
background: transparent !important;
outline: none;
}
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper {
border-radius: 10px;
margin-inline-end: 6px;
}
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper:last-child {
margin-inline-end: 0;
}
.redis-viewer-workbench .ant-table {
background: transparent;
}
.redis-viewer-workbench .ant-table-wrapper .ant-table-thead > tr > th {
font-weight: 700;
}
/* Scrollbar styling for dark mode */
body[data-theme='dark'] ::-webkit-scrollbar {
width: 10px;
@@ -217,16 +97,6 @@ body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-s
color: rgba(255, 236, 179, 0.98) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
background: rgba(255, 255, 255, 0.05) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
background: linear-gradient(90deg, rgba(246, 196, 83, 0.22), rgba(246, 196, 83, 0.08)) !important;
border: 1px solid rgba(246, 196, 83, 0.24) !important;
}
body[data-theme='dark'] .ant-checkbox-checked .ant-checkbox-inner {
background-color: #f6c453 !important;
border-color: #f6c453 !important;
@@ -265,41 +135,6 @@ body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:h
background: rgba(246, 196, 83, 0.26) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
color: rgba(230, 234, 242, 0.9);
}
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
background: rgba(246, 196, 83, 0.16);
border-color: rgba(246, 196, 83, 0.3);
color: #f6c453;
}
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
background: rgba(15, 23, 42, 0.04) !important;
}
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
color: rgba(15, 23, 42, 0.92) !important;
background: linear-gradient(90deg, rgba(22, 119, 255, 0.12), rgba(22, 119, 255, 0.04)) !important;
border: 1px solid rgba(22, 119, 255, 0.18) !important;
}
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(15, 23, 42, 0.08);
color: rgba(51, 65, 85, 0.88);
}
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
background: rgba(22, 119, 255, 0.1);
border-color: rgba(22, 119, 255, 0.22);
color: #1677ff;
}
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
.connection-modal-wrap {
overflow: hidden !important;
@@ -375,47 +210,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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,497 +0,0 @@
.ai-chat-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
border-left: 1px solid rgba(128, 128, 128, 0.12);
position: relative;
}
/* Resize Handle */
.ai-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 10;
transition: background 0.15s ease;
}
.ai-resize-handle:hover,
.ai-resize-handle.active {
background: rgba(22, 119, 255, 0.5);
}
/* Header */
.ai-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
flex-shrink: 0;
}
.ai-chat-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.ai-chat-header-left .ai-logo {
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.ai-chat-header-left .ai-title {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.01em;
}
.ai-chat-header-right {
display: flex;
align-items: center;
gap: 4px;
}
/* Messages Area */
.ai-chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-chat-messages::-webkit-scrollbar {
width: 5px;
}
.ai-chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.ai-chat-messages::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 3px;
}
/* Welcome */
.ai-chat-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 40px 20px;
text-align: center;
flex: 1;
}
.ai-chat-welcome .welcome-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: grid;
place-items: center;
font-size: 28px;
}
.ai-chat-welcome .welcome-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 4px;
}
.ai-chat-welcome .quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 8px;
}
.ai-chat-welcome .quick-action-btn {
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid;
}
.ai-chat-welcome .quick-action-btn:hover {
background: rgba(99, 102, 241, 0.12) !important;
border-color: rgba(99, 102, 241, 0.3) !important;
color: #818cf8 !important;
}
/* IDE Style Messages */
.ai-ide-message {
padding: 12px 16px;
animation: ai-msg-in 0.2s ease-out;
}
@keyframes ai-msg-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-ide-message-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.ai-ide-message-content {
font-size: 13px;
line-height: 1.6;
word-break: break-word;
/* Remove pre-wrap here, as it conflicts with ReactMarkdown's block rendering */
}
/* Markdown Styles Override */
.ai-markdown-content {
white-space: normal;
}
.ai-markdown-content p {
margin: 0 0 10px;
}
.ai-markdown-content p:last-child {
margin-bottom: 0;
}
.ai-markdown-content h1,
.ai-markdown-content h2,
.ai-markdown-content h3,
.ai-markdown-content h4,
.ai-markdown-content h5,
.ai-markdown-content h6 {
margin: 16px 0 8px;
line-height: 1.4;
font-weight: 600;
}
.ai-markdown-content h1:first-child,
.ai-markdown-content h2:first-child,
.ai-markdown-content h3:first-child,
.ai-markdown-content h4:first-child,
.ai-markdown-content h5:first-child,
.ai-markdown-content h6:first-child {
margin-top: 0;
}
.ai-markdown-content pre {
margin: 10px 0;
border-radius: 4px;
padding: 10px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
overflow-x: auto;
border: 1px solid rgba(128, 128, 128, 0.15);
background: rgba(0, 0, 0, 0.2);
}
.ai-markdown-content code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: rgba(128, 128, 128, 0.15);
padding: 2px 4px;
border-radius: 3px;
font-size: 0.95em;
}
.ai-markdown-content ul, .ai-markdown-content ol {
margin: 0 0 10px;
padding-left: 20px;
}
.ai-markdown-content li {
margin-bottom: 4px;
}
/* Advanced Typing/Blinker indicator */
.ai-blinking-cursor {
display: inline-block;
width: 6px;
height: 14px;
background-color: currentColor;
border-radius: 1px;
vertical-align: middle;
margin-left: 4px;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes ai-dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* History Drawer Styles */
.ai-history-list::-webkit-scrollbar {
width: 4px;
}
.ai-history-list::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.2);
border-radius: 4px;
}
.ai-history-list:hover::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.4);
}
.ai-history-item:hover {
background: rgba(128, 128, 128, 0.08) !important;
}
.ai-history-item .ai-history-delete-btn {
opacity: 0;
transition: opacity 0.2s, background 0.2s;
}
.ai-history-item:hover .ai-history-delete-btn,
.ai-history-item.active .ai-history-delete-btn {
opacity: 1;
}
/* Input Area */
.ai-chat-input-area {
padding: 12px 16px 16px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
flex-shrink: 0;
}
/* Textarea scrollbar */
.ai-chat-input-wrapper textarea {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar {
width: 4px;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar-track {
background: transparent;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.ai-chat-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
border-radius: 6px;
border: 1px solid transparent;
border-bottom-color: rgba(128, 128, 128, 0.4);
padding: 6px 10px;
transition: all 0.2s ease;
background: transparent !important;
box-shadow: none !important;
}
.ai-chat-input-wrapper:focus-within {
border-color: var(--ant-primary-color, #1677ff) !important;
background: rgba(128, 128, 128, 0.05) !important;
}
.ai-chat-input-wrapper textarea {
width: 100%;
border: none;
outline: none;
background: transparent;
resize: none;
font-size: 13px;
line-height: 1.5;
min-height: 28px;
max-height: 200px;
padding: 0;
font-family: inherit;
overflow-y: auto;
}
.ai-chat-input-wrapper textarea::placeholder {
opacity: 0.4;
}
.ai-chat-send-btn {
width: 26px;
height: 26px;
border-radius: 4px;
display: grid;
place-items: center;
border: none;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.ai-chat-send-btn:hover {
transform: scale(1.06);
}
.ai-chat-send-btn:active {
transform: scale(0.96);
}
.ai-chat-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.ai-ide-message:hover .ai-message-actions {
opacity: 1 !important;
}
/* Markdown 额外样式增强: Table & Blockquote */
.ai-markdown-content table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
/* 让消息内容区域成为表格的滚动约束容器 */
.ai-ide-message-content {
max-width: 100%;
overflow-x: hidden;
}
/* 表格滚动容器 - 不限定直接子元素 */
.ai-markdown-content table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
max-width: 100%;
}
.ai-markdown-content table::-webkit-scrollbar {
height: 4px;
}
.ai-markdown-content table::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.ai-markdown-content th,
.ai-markdown-content td {
border: 1px solid rgba(125, 125, 125, 0.2);
padding: 6px 12px;
text-align: left;
white-space: nowrap;
}
.ai-markdown-content th {
background: rgba(125, 125, 125, 0.1);
font-weight: 600;
}
.ai-markdown-content blockquote {
margin: 12px 0;
padding: 8px 14px;
border-left: 4px solid rgba(125, 125, 125, 0.4);
background: rgba(125, 125, 125, 0.05);
color: inherit;
opacity: 0.85;
border-radius: 0 6px 6px 0;
font-style: italic;
}
/* 覆盖 code 块容器样式避免和 syntax highlighter 冲突 */
.ai-markdown-content > pre {
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
}
/* ===== 新版 AI 状态流转动画 ===== */
/* 1. 连接脉冲动画 (connecting) */
.ai-wave-pulse {
display: flex;
align-items: center;
gap: 4px;
}
.ai-wave-pulse span {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: currentColor;
animation: wave-pulse-anim 1.2s ease-in-out infinite;
}
.ai-wave-pulse span:nth-child(1) { animation-delay: 0s; }
.ai-wave-pulse span:nth-child(2) { animation-delay: 0.15s; }
.ai-wave-pulse span:nth-child(3) { animation-delay: 0.3s; }
@keyframes wave-pulse-anim {
0%, 100% { transform: translateY(0) scale(0.8); opacity: 0.4; }
50% { transform: translateY(-4px) scale(1.1); opacity: 1; }
}
/* 2. 平滑高度与透明度过渡 (针对 ThinkingBlock 和 面板折叠) */
.ai-expand-transition {
display: grid;
transition: grid-template-rows 0.3s ease-out, opacity 0.3s ease-out;
}
.ai-expand-transition.expanded {
grid-template-rows: 1fr;
opacity: 1;
}
.ai-expand-transition.collapsed {
grid-template-rows: 0fr;
opacity: 0;
}
.ai-expand-transition > div {
overflow: hidden;
}
/* 3. Agent风格旋转Loading环 */
.ai-spinning-ring {
width: 14px;
height: 14px;
border: 2px solid rgba(22, 119, 255, 0.2);
border-top-color: #1677ff;
border-radius: 50%;
animation: ai-spin-anim 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes ai-spin-anim {
to { transform: rotate(360deg); }
}
/* 面板/弹窗内部 toast 定位覆盖:从 fixed视口顶部改为 absolute容器内部顶部 */
.ai-chat-panel .ant-message,
.ai-settings-body .ant-message {
position: absolute !important;
top: 16px !important;
left: 50% !important;
transform: translateX(-50%) !important;
right: auto !important;
width: max-content;
z-index: 100;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,840 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Modal, Button, Input, Select, Form, Checkbox, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
import {
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_MODELS,
resolveProviderPresetKey,
resolvePresetBaseURL,
resolvePresetModelSelection,
resolvePresetTransport,
} from '../utils/aiProviderPresets';
import {
PROVIDER_PRESET_CARD_BASE_STYLE,
PROVIDER_PRESET_CARD_CONTENT_STYLE,
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
PROVIDER_PRESET_GRID_STYLE,
PROVIDER_PRESET_CARD_TITLE_STYLE,
} from '../utils/aiSettingsPresetLayout';
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface AISettingsModalProps {
open: boolean;
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
focusProviderId?: string;
}
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
interface ProviderPreset {
key: string;
label: string;
icon: React.ReactNode;
desc: string;
color: string;
backendType: AIProviderType;
fixedApiFormat?: string;
defaultBaseUrl: string;
defaultModel: string;
models: string[];
}
const PROVIDER_PRESETS: ProviderPreset[] = [
{ key: 'openai', label: 'OpenAI', icon: <ApiOutlined />, desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] },
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
{ key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: <CloudOutlined />, desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },
{ key: 'qwen-coding-plan', label: '通义千问Coding Plan', icon: <CloudOutlined />, desc: 'Claude Code CLI 代理链路 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS },
{ key: 'zhipu', label: '智谱 GLM', icon: <ExperimentOutlined />, desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] },
{ key: 'moonshot', label: 'Kimi', icon: <ExperimentOutlined />, desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] },
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] },
{ key: 'gemini', label: 'Gemini', icon: <CloudOutlined />, desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] },
{ key: 'volcengine-ark', label: '火山方舟', icon: <CloudOutlined />, desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
{ key: 'volcengine-coding', label: '火山 Coding Plan', icon: <CloudOutlined />, desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] },
{ key: 'minimax', label: 'MiniMax', icon: <ExperimentOutlined />, desc: 'M2.7 / M2.5 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] },
{ key: 'ollama', label: 'Ollama', icon: <AppstoreOutlined />, desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
];
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): ProviderPreset => {
const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom');
return findPreset(presetKey);
};
const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [
{ label: '只读模式', value: 'readonly', desc: 'AI 仅可执行 SELECT 等查询操作,最安全', color: '#22c55e', icon: '🔒' },
{ label: '读写模式', value: 'readwrite', desc: 'AI 可执行 INSERT/UPDATE/DELETE危险操作需二次确认', color: '#f59e0b', icon: '⚠️' },
{ label: '完全模式', value: 'full', desc: 'AI 可执行所有操作(含 DDL高危操作自动告警', color: '#ef4444', icon: '🔓' },
];
const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; icon: string }[] = [
{ label: '仅 Schema', value: 'schema_only', desc: '只传递表/列结构信息给 AI', icon: '📋' },
{ label: '含采样数据', value: 'with_samples', desc: '包含少量采样数据帮助 AI 理解数据特征', icon: '📊' },
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
];
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
const [clearProviderSecret, setClearProviderSecret] = useState(false);
const [form] = Form.useForm();
const modalBodyRef = useRef<HTMLDivElement>(null);
// Modal 内部 toast 通知
const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body });
// 主题色
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
const inputBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
// Hook 必须在组件顶层调用,不能在条件分支内
const watchedType = Form.useWatch('type', form);
const watchedPresetKey = Form.useWatch('presetKey', form);
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
const watchedApiKeyInput = Form.useWatch('apiKey', form);
const loadConfig = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (!Service) { console.warn('[AI] Service not found on window.go'); return; }
const [provRes, safeRes, ctxRes, promptsRes] = await Promise.all([
Service.AIGetProviders?.() || [],
Service.AIGetSafetyLevel?.() || 'readonly',
Service.AIGetContextLevel?.() || 'schema_only',
Service.AIGetBuiltinPrompts?.() || {},
]);
console.log('[AI] AIGetProviders result:', JSON.stringify(provRes), 'isArray:', Array.isArray(provRes));
if (Array.isArray(provRes)) {
setProviders(provRes);
const activeRes = await Service.AIGetActiveProvider?.();
console.log('[AI] AIGetActiveProvider result:', activeRes);
if (activeRes) setActiveProviderId(activeRes);
}
if (safeRes) setSafetyLevel(safeRes);
if (ctxRes) setContextLevel(ctxRes);
if (promptsRes) setBuiltinPrompts(promptsRes);
} catch (e) { console.warn('Failed to load AI config', e); }
}, []);
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
useEffect(() => {
if (!open || !focusProviderId) {
return;
}
if (!providers.some((provider) => provider.id === focusProviderId)) {
return;
}
setActiveSection('providers');
setActiveProviderId(focusProviderId);
}, [focusProviderId, open, providers]);
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
setEditingProvider(session.editingProvider as AIProviderConfig | null);
setIsEditing(session.isEditing);
setTestStatus(session.testStatus);
setClearProviderSecret(session.clearProviderSecret);
form.resetFields();
if (session.formValues) {
form.setFieldsValue(session.formValues);
}
}, [form]);
const resetProviderEditorSession = useCallback(() => {
applyProviderEditorSession(buildClosedProviderEditorSession());
}, [applyProviderEditorSession]);
const handleModalClose = useCallback(() => {
resetProviderEditorSession();
onClose();
}, [onClose, resetProviderEditorSession]);
useEffect(() => {
if (!open) {
resetProviderEditorSession();
}
}, [open, resetProviderEditorSession]);
const handleAddProvider = () => {
const preset = findPreset('openai');
applyProviderEditorSession(buildAddProviderEditorSession({
presetKey: 'openai',
presetBackendType: preset.backendType,
presetBaseUrl: preset.defaultBaseUrl,
presetModel: preset.defaultModel,
presetModels: preset.models,
apiFormat: 'openai',
}));
};
const handleEditProvider = (p: AIProviderConfig) => {
// 尝试根据 baseUrl 和 type 推断 preset
const matchedPreset = matchProviderPreset(p);
const resolvedTransport = resolvePresetTransport({
presetBackendType: matchedPreset.backendType,
presetFixedApiFormat: matchedPreset.fixedApiFormat,
valuesApiFormat: p.apiFormat,
});
applyProviderEditorSession(buildEditProviderEditorSession({
provider: { ...p, presetKey: matchedPreset.key } as any,
formValues: {
...p,
type: resolvedTransport.type,
models: p.models || [],
presetKey: matchedPreset.key,
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
},
}));
};
const handleDeleteProvider = async (id: string) => {
try {
const Service = (window as any).go?.aiservice?.Service;
const wasActive = id === activeProviderId;
await Service?.AIDeleteProvider?.(id);
await loadConfig();
// 合并提示:删除的是当前激活的供应商时,附带自动切换信息
if (wasActive) {
const newProviders: any[] = await Service?.AIGetProviders?.() || [];
if (newProviders.length > 0) {
const newActiveName = newProviders[0]?.name || '下一个供应商';
void messageApi.success(`已删除,自动切换到「${newActiveName}`);
} else {
void messageApi.success('已删除');
}
} else {
void messageApi.success('已删除');
}
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) { void messageApi.error(e?.message || '删除失败'); }
};
const handleSaveProvider = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const Service = (window as any).go?.aiservice?.Service;
// 构建 payload处理 model/models 逻辑
const preset = findPreset(values.presetKey);
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
presetKey: values.presetKey,
presetDefaultModel: preset.defaultModel,
presetModels: preset.models,
valuesModel: values.model,
customModels: values.models,
});
// 内置供应商自动使用 preset label 作为名称
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
const finalBaseUrl = resolvePresetBaseURL({
presetKey: values.presetKey,
presetDefaultBaseUrl: preset.defaultBaseUrl,
valuesBaseUrl: values.baseUrl,
});
const resolvedTransport = resolvePresetTransport({
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
});
const secretDraft = resolveProviderSecretDraft({
hasSecret: editingProvider?.hasSecret,
apiKeyInput: values.apiKey,
clearSecret: clearProviderSecret,
});
const payload = {
...editingProvider,
...values,
...resolvedTransport,
name: finalName,
apiKey: secretDraft.apiKey,
hasSecret: secretDraft.hasSecret,
model: finalModel,
models: resolvedModels,
baseUrl: finalBaseUrl,
apiFormat: resolvedTransport.apiFormat,
};
// 后端 AISaveProvider 统一处理新增和更新,返回 void失败抛异常
await Service?.AISaveProvider?.(payload);
void messageApi.success('已保存'); resetProviderEditorSession(); void loadConfig();
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) {
if (e?.errorFields) { /* antd form validation error, ignore */ }
else void messageApi.error(e?.message || '保存失败');
} finally { setLoading(false); }
};
const handleSetActive = async (id: string) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetActiveProvider?.(id);
setActiveProviderId(id); void messageApi.success('已切换');
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) { void messageApi.error(e?.message || '切换失败'); }
};
const handleSafetyChange = async (level: AISafetyLevel) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetSafetyLevel?.(level);
setSafetyLevel(level);
} catch (e) { /* ignore */ }
};
const handleContextChange = async (level: AIContextLevel) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetContextLevel?.(level);
setContextLevel(level);
} catch (e) { /* ignore */ }
};
const handleTestProvider = async () => {
try {
const values = await form.validateFields();
setLoading(true);
setTestStatus('idle');
const Service = (window as any).go?.aiservice?.Service;
const preset = findPreset(values.presetKey || 'openai');
const finalBaseUrl = resolvePresetBaseURL({
presetKey: values.presetKey || 'openai',
presetDefaultBaseUrl: preset.defaultBaseUrl,
valuesBaseUrl: values.baseUrl,
});
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
presetKey: values.presetKey || 'openai',
presetDefaultModel: preset.defaultModel,
presetModels: preset.models,
valuesModel: values.model,
customModels: values.models,
});
const resolvedTransport = resolvePresetTransport({
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
});
const secretDraft = resolveProviderSecretDraft({
hasSecret: editingProvider?.hasSecret,
apiKeyInput: values.apiKey,
clearSecret: clearProviderSecret,
});
if (secretDraft.mode === 'clear') {
throw new Error('测试连接前请填写新的 API Key或取消清除已保存密钥');
}
const res = await Service?.AITestProvider?.({
...editingProvider,
...values,
...resolvedTransport,
apiKey: secretDraft.apiKey,
hasSecret: secretDraft.hasSecret,
baseUrl: finalBaseUrl,
model: finalModel,
models: resolvedModels,
maxTokens: Number(values.maxTokens) || 4096,
temperature: Number(values.temperature) ?? 0.7,
apiFormat: resolvedTransport.apiFormat,
});
if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); }
else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); }
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); }
finally { setLoading(false); }
};
const handlePresetChange = (presetKey: string) => {
const preset = findPreset(presetKey);
const resolvedTransport = resolvePresetTransport({
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: form.getFieldValue('apiFormat'),
});
form.setFieldsValue({
presetKey,
type: resolvedTransport.type,
apiFormat: resolvedTransport.apiFormat || 'openai',
baseUrl: preset.defaultBaseUrl,
model: preset.defaultModel,
});
};
// ---- 字段装饰器样式 ----
const fieldGroupStyle: React.CSSProperties = {
padding: '14px 16px', borderRadius: 12, border: `1px solid ${cardBorder}`,
background: cardBg, marginBottom: 12,
};
const fieldLabelStyle: React.CSSProperties = {
fontSize: 13, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.08em',
color: sectionLabelColor, marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6,
};
// ===== Provider 列表 =====
const renderProviderList = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{providers.length === 0 && (
<div style={{
textAlign: 'center', padding: '36px 20px', color: overlayTheme.mutedText, fontSize: 14,
border: `1px dashed ${cardBorder}`, borderRadius: 14, background: cardBg,
}}>
<RobotOutlined style={{ fontSize: 32, marginBottom: 12, opacity: 0.3, display: 'block' }} />
<br />
<span style={{ fontSize: 13, opacity: 0.6 }}>使 AI </span>
</div>
)}
{providers.map(p => {
const matchedPreset = matchProviderPreset(p);
const isActive = p.id === activeProviderId;
return (
<div key={p.id} onClick={() => handleSetActive(p.id)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${isActive ? overlayTheme.selectedText : cardBorder}`,
background: isActive ? overlayTheme.selectedBg : cardBg,
display: 'flex', alignItems: 'center', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center',
background: isActive ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
color: isActive ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, flexShrink: 0, transition: 'all 0.2s ease',
}}>
{matchedPreset.icon || <ApiOutlined />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{p.name || p.type}
{isActive && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 13 }} />}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{matchedPreset.label}</span>
<span style={{ opacity: 0.4 }}>·</span>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.model || '未选择模型'}</span>
</div>
</div>
<Space size={2}>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />}
onClick={e => { e.stopPropagation(); handleEditProvider(p); }}
style={{ color: overlayTheme.mutedText }} />
</Tooltip>
<Popconfirm title="确认删除?" onConfirm={() => handleDeleteProvider(p.id)}
okButtonProps={{ danger: true }} okText="删除" cancelText="取消">
<Button type="text" size="small" icon={<DeleteOutlined />} danger
onClick={e => e.stopPropagation()} />
</Popconfirm>
</Space>
</div>
);
})}
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddProvider}
style={{ borderRadius: 12, height: 42, borderColor: darkMode ? 'rgba(255,255,255,0.12)' : undefined }}>
</Button>
</div>
);
// ===== Provider 编辑表单 =====
const renderProviderForm = () => {
const presetKeyFromForm = watchedPresetKey || (editingProvider as any)?.presetKey || 'openai';
return (
<div>
{/* 顶部返回 */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
<Button size="small" onClick={resetProviderEditorSession}
style={{ borderRadius: 8 }}> </Button>
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
</span>
</div>
<Form form={form} layout="vertical" size="small">
{/* Provider 类型选择 - 卡片式 */}
<div style={fieldGroupStyle}>
<div style={fieldLabelStyle}>
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
<Form.Item name="presetKey" noStyle>
<div style={PROVIDER_PRESET_GRID_STYLE}>
{PROVIDER_PRESETS.map(pt => (
<div key={pt.key} onClick={() => { form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }}
style={{
...PROVIDER_PRESET_CARD_BASE_STYLE,
border: `1.5px solid ${presetKeyFromForm === pt.key ? overlayTheme.selectedText : 'transparent'}`,
background: presetKeyFromForm === pt.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
boxShadow: presetKeyFromForm === pt.key ? 'none' : (darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)'),
}}>
<div style={{
color: presetKeyFromForm === pt.key ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease', flexShrink: 0,
}}>
{pt.icon}
</div>
<div style={PROVIDER_PRESET_CARD_CONTENT_STYLE}>
<div style={{ ...PROVIDER_PRESET_CARD_TITLE_STYLE, fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
<div style={{ ...PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.4 }}>{pt.desc}</div>
</div>
</div>
))}
</div>
</Form.Item>
<Form.Item name="type" hidden><Input /></Form.Item>
</div>
{/* 基本信息 - 仅自定义/Ollama 显示 */}
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
<div style={fieldLabelStyle}>
<RobotOutlined style={{ fontSize: 14 }} />
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}></span>} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}>
<Input placeholder="例如:我的自建 OpenAI / 专属大模型"
size="middle"
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
{presetKeyFromForm === 'custom' && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API </span>} name="apiFormat" style={{ marginBottom: 16 }}>
<div style={{
display: 'inline-flex', padding: 4, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, gap: 4
}}>
{[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map(fmt => (
<div
key={fmt.value}
onClick={() => form.setFieldsValue({ apiFormat: fmt.value })}
style={{
padding: '6px 16px', borderRadius: 6, fontSize: 13, fontWeight: watchedApiFormat === fmt.value ? 600 : 500, cursor: 'pointer',
background: watchedApiFormat === fmt.value ? (darkMode ? '#374151' : '#ffffff') : 'transparent',
color: watchedApiFormat === fmt.value ? overlayTheme.titleText : overlayTheme.mutedText,
boxShadow: watchedApiFormat === fmt.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
transition: 'all 0.2s ease',
}}
>
{fmt.label}
</div>
))}
</div>
</Form.Item>
)}
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}></span>} name="models" style={{ marginBottom: 0 }}>
<Select mode="tags" size="middle" placeholder="配置指定的模型ID留空则默认去服务端拉取" style={{ width: '100%' }} />
</Form.Item>
</div>
)}
<Form.Item name="model" hidden><Input /></Form.Item>
<Form.Item name="name" hidden><Input /></Form.Item>
{/* 认证信息 */}
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
<div style={fieldLabelStyle}>
<KeyOutlined style={{ fontSize: 14 }} /> &
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || clearProviderSecret || editingProvider?.hasSecret) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: editingProvider?.hasSecret ? 8 : 16 }}>
<Input.Password placeholder={editingProvider?.hasSecret ? '留空表示继续沿用已保存密钥' : 'sk-... / 你的 API Key'}
size="middle"
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
{editingProvider?.hasSecret && (
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: cardBg }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
API Key沿
</div>
<Checkbox
checked={clearProviderSecret}
disabled={String(watchedApiKeyInput || '').trim() !== ''}
onChange={(event) => setClearProviderSecret(event.target.checked)}
>
API Key
</Checkbox>
</div>
)}
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
<Input placeholder={findPreset(presetKeyFromForm).defaultBaseUrl || 'https://...'}
size="middle"
suffix={<LinkOutlined style={{ color: overlayTheme.mutedText }} />}
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
)}
</div>
{/* 操作按钮 */}
<div style={{
display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12, paddingTop: 16,
borderTop: `1px solid ${cardBorder}`, paddingBottom: 24,
}}>
<Button onClick={handleTestProvider} loading={loading} style={{ borderRadius: 10 }}
icon={testStatus === 'success' ? <CheckOutlined style={{ color: '#22c55e' }} /> : undefined}>
{testStatus === 'success' ? '连接正常' : testStatus === 'error' ? '重新测试' : '测试连接'}
</Button>
<Button type="primary" onClick={handleSaveProvider} loading={loading}
style={{ borderRadius: 10, fontWeight: 600 }}>
</Button>
</div>
</Form>
</div>
);
};
// ===== 安全控制 =====
const renderSafetySettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
AI SQL
</div>
{SAFETY_OPTIONS.map(opt => {
const active = safetyLevel === opt.value;
return (
<div key={opt.value} onClick={() => handleSafetyChange(opt.value)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`,
background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : overlayTheme.selectedBg) : cardBg,
display: 'flex', alignItems: 'flex-start', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
background: active ? (opt.color === '#ef4444' ? `${opt.color}25` : overlayTheme.iconBg) : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
color: active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor) : overlayTheme.mutedText,
transition: 'all 0.2s ease',
}}>
{opt.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{opt.label}
{active && <CheckOutlined style={{ color: opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor, fontSize: 14 }} />}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
</div>
</div>
);
})}
</div>
);
// ===== 上下文级别 =====
const renderContextSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
AI
</div>
{CONTEXT_OPTIONS.map(opt => {
const active = contextLevel === opt.value;
return (
<div key={opt.value} onClick={() => handleContextChange(opt.value)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : cardBg,
display: 'flex', alignItems: 'flex-start', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
background: active ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
color: active ? overlayTheme.iconColor : overlayTheme.mutedText,
transition: 'all 0.2s ease',
}}>
{opt.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{opt.label}
{active && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 14 }} />}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
</div>
</div>
);
})}
</div>
);
const renderBuiltinPrompts = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
GoNavi AI
</div>
{Object.entries(builtinPrompts).map(([title, promptText]) => (
<div key={title} style={{
padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: cardBg,
}}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
</div>
<div style={{
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
padding: '10px 12px', borderRadius: 8, fontSize: 13, color: overlayTheme.mutedText,
whiteSpace: 'pre-wrap', fontFamily: 'monospace', lineHeight: 1.5,
userSelect: 'text', border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)'
}}>
{promptText}
</div>
</div>
))}
</div>
);
const BUILTIN_TOOLS_INFO = [
{ name: 'get_connections', icon: '🔗', desc: '获取所有可用的数据库连接', detail: '返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。', params: '无参数' },
{ name: 'get_databases', icon: '🗄️', desc: '获取指定连接下的所有数据库', detail: '传入 connectionId返回该连接下的数据库/Schema 名称列表。', params: 'connectionId: 连接 ID' },
{ name: 'get_tables', icon: '📋', desc: '获取指定数据库下的所有表名', detail: '传入 connectionId 和 dbName返回表名列表。AI 用它来定位用户提到的目标表。', params: 'connectionId, dbName' },
{ name: 'get_columns', icon: '🔍', desc: '获取指定表的字段结构', detail: '传入 connectionId、dbName 和 tableName返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。', params: 'connectionId, dbName, tableName' },
{ name: 'get_table_ddl', icon: '📝', desc: '获取表的建表语句 (DDL)', detail: '传入 connectionId、dbName 和 tableName返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。', params: 'connectionId, dbName, tableName' },
{ name: 'execute_sql', icon: '▶️', desc: '执行 SQL 查询并返回结果', detail: '传入 connectionId、dbName 和 sql在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。', params: 'connectionId, dbName, sql' },
];
const renderBuiltinTools = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
AI
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, padding: '8px 12px', borderRadius: 8, background: cardBg, border: `1px solid ${cardBorder}` }}>
💡 get_connections get_databases get_tables get_columns SQL
</div>
{BUILTIN_TOOLS_INFO.map(tool => (
<div key={tool.name} style={{
padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg,
transition: 'all 0.2s ease',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 20 }}>{tool.icon}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, fontFamily: 'monospace' }}>
{tool.name}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 2 }}>{tool.desc}</div>
</div>
</div>
<div style={{
fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, padding: '8px 12px',
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.02)', borderRadius: 8,
}}>
{tool.detail}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, display: 'flex', alignItems: 'center', gap: 6 }}>
<ToolOutlined style={{ fontSize: 12 }} />
<span></span>
<code style={{ fontFamily: 'monospace', fontSize: 12, padding: '1px 6px', borderRadius: 4, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
{tool.params}
</code>
</div>
</div>
))}
</div>
);
const modalShellStyle = {
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
};
return (
<Modal
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,
}}>
<RobotOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>AI </div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
AI
</div>
</div>
</div>
}
open={open}
onCancel={handleModalClose}
footer={null}
width={820}
styles={{
content: modalShellStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, height: 620, overflow: 'hidden' },
}}
>
<div ref={modalBodyRef} className="ai-settings-body" style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch', position: 'relative' }}>
{messageContextHolder}
<div style={{ padding: '0 12px', height: 'fit-content' }}>
<div style={{ marginBottom: 12, fontWeight: 600, color: overlayTheme.titleText }}></div>
<div style={{ display: 'grid', gap: 10 }}>
{[
{ key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: <ApiOutlined /> },
{ key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: <SafetyCertificateOutlined /> },
{ key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: <RobotOutlined /> },
{ key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: <ToolOutlined /> },
{ key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: <ExperimentOutlined /> },
].map((item) => {
const active = activeSection === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setActiveSection(item.key as typeof activeSection)}
style={{
textAlign: 'left',
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${active
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
background: active
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 16 }}>{item.icon}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.title}</span>
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : 'rgba(128,128,128,0.7)' }}>
{item.description}
</div>
</button>
);
})}
</div>
</div>
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
{activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())}
{activeSection === 'safety' && renderSafetySettings()}
{activeSection === 'context' && renderContextSettings()}
{activeSection === 'tools' && renderBuiltinTools()}
{activeSection === 'prompts' && renderBuiltinPrompts()}
</div>
</div>
</Modal>
);
};
export default AISettingsModal;

File diff suppressed because it is too large Load Diff

View File

@@ -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>
);
}

View File

@@ -1,312 +0,0 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import DataGrid from './DataGrid';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
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(),
}));
const backendApp = vi.hoisted(() => ({
ImportData: vi.fn(),
ExportTable: vi.fn(),
ExportData: vi.fn(),
ExportQuery: vi.fn(),
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
loading: vi.fn(() => vi.fn()),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => any) => selector(storeState),
}));
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('@monaco-editor/react', () => ({
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
}));
vi.mock('./ImportPreviewModal', () => ({
default: () => null,
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
ReloadOutlined: Icon,
ImportOutlined: Icon,
ExportOutlined: Icon,
DownOutlined: Icon,
PlusOutlined: Icon,
DeleteOutlined: Icon,
SaveOutlined: Icon,
UndoOutlined: Icon,
FilterOutlined: Icon,
CloseOutlined: Icon,
ConsoleSqlOutlined: Icon,
FileTextOutlined: Icon,
CopyOutlined: Icon,
ClearOutlined: Icon,
EditOutlined: Icon,
VerticalAlignBottomOutlined: Icon,
LeftOutlined: Icon,
RightOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
};
});
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: any) => <>{children}</>,
PointerSensor: vi.fn(),
MouseSensor: vi.fn(),
TouchSensor: vi.fn(),
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
closestCenter: vi.fn(),
}));
vi.mock('@dnd-kit/sortable', () => ({
SortableContext: ({ children }: any) => <>{children}</>,
useSortable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
})),
horizontalListSortingStrategy: vi.fn(),
arrayMove: (items: any[], from: number, to: number) => {
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
},
}));
vi.mock('@dnd-kit/utilities', () => ({
CSS: {
Transform: {
toString: () => '',
},
},
}));
vi.mock('antd', () => {
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
<button type="button" disabled={disabled || loading} data-button-type={type} onClick={onClick} {...rest}>
{children}
</button>
);
const Input: any = ({ value, onChange, placeholder, ...rest }: any) => (
<input value={value} onChange={onChange} placeholder={placeholder} {...rest} />
);
Input.TextArea = ({ value, onChange, placeholder }: any) => (
<textarea value={value} onChange={onChange} placeholder={placeholder} />
);
const createForm = () => ({
resetFields: vi.fn(),
setFieldsValue: vi.fn(),
getFieldsValue: vi.fn(() => ({})),
getFieldValue: vi.fn(),
validateFields: vi.fn(() => Promise.resolve({})),
});
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [createForm()];
const Modal: any = ({ children, footer, open, title }: any) => (
open ? (
<section data-modal-title={title}>
<h2>{title}</h2>
{children}
<div>{footer}</div>
</section>
) : null
);
Modal.useModal = () => [{ info: vi.fn(() => ({ destroy: vi.fn() })) }, null];
const passthrough = ({ children }: any) => <>{children}</>;
return {
Table: () => <table />,
message: messageApi,
Input,
Button,
Dropdown: passthrough,
Form,
Pagination: () => null,
Select: () => null,
Modal,
Checkbox: ({ checked, onChange }: any) => <input type="checkbox" checked={checked} onChange={onChange} />,
Segmented: () => null,
Tooltip: passthrough,
Popover: passthrough,
DatePicker: () => null,
TimePicker: () => null,
AutoComplete: ({ children }: any) => <>{children}</>,
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const waitForEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe('DataGrid DDL interactions', () => {
beforeEach(() => {
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' });
vi.stubGlobal('document', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
activeElement: null,
elementFromPoint: vi.fn(() => null),
createElement: vi.fn(() => ({
style: {},
getContext: vi.fn(() => ({ measureText: vi.fn(() => ({ width: 0 })) })),
})),
body: { style: {} },
});
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
innerHeight: 768,
innerWidth: 1024,
getComputedStyle: vi.fn(() => ({ font: '12px sans-serif' })),
});
vi.stubGlobal('navigator', {
platform: 'MacIntel',
userAgent: '',
clipboard: { writeText: vi.fn(() => Promise.resolve()) },
});
vi.stubGlobal('HTMLElement', class {});
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
backendApp.ImportData.mockReset();
backendApp.ExportTable.mockReset();
backendApp.ExportData.mockReset();
backendApp.ExportQuery.mockReset();
backendApp.ApplyChanges.mockReset();
backendApp.DBGetColumns.mockReset();
backendApp.DBGetIndexes.mockReset();
backendApp.DBShowCreateTable.mockReset();
vi.unstubAllGlobals();
});
it('ignores stale DDL responses after the table context changes', async () => {
let resolveFirstRequest: (value: any) => void = () => {};
backendApp.DBShowCreateTable.mockReturnValueOnce(new Promise((resolve) => {
resolveFirstRequest = resolve;
}));
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '查看 DDL').props.onClick();
});
await act(async () => {
renderer!.update(
<DataGrid
data={[{ __gonavi_row_key__: 'row-2', id: 2 }]}
columnNames={['id']}
loading={false}
tableName="orders"
dbName="main"
connectionId="conn-1"
/>,
);
resolveFirstRequest({ success: true, data: 'CREATE TABLE users' });
});
await waitForEffects();
expect(textContent(renderer!.root)).not.toContain('CREATE TABLE users');
expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0);
});
});

View File

@@ -1,194 +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(),
DBShowCreateTable: 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"');
expect(markup).toContain('data-grid-page-find="true"');
expect(markup).toContain('data-grid-page-find-prev="true"');
expect(markup).toContain('data-grid-page-find-next="true"');
expect(markup).toContain('当前页查找...');
});
it('renders a DDL action for table data pages only', () => {
const tableMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
expect(tableMarkup).toContain('查看 DDL');
const schemaTableMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="public.users"
dbName=""
connectionId="conn-1"
/>,
);
expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"');
expect(schemaTableMarkup).toContain('查看 DDL');
expect(schemaTableMarkup).toContain('data-grid-page-find="true"');
const queryMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
exportScope="queryResult"
/>,
);
expect(queryMarkup).not.toContain('data-grid-ddl-action="true"');
});
it('renders row copy and paste actions in editable table toolbar', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
/>,
);
expect(markup).toContain('data-grid-copy-row-action="true"');
expect(markup).toContain('data-grid-paste-row-action="true"');
expect(markup).toContain('复制行');
expect(markup).toContain('粘贴行');
});
it('renders a quick WHERE condition editor when table filters are visible', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
showFilter
quickWhereCondition="name like 'a%'"
onApplyQuickWhereCondition={() => {}}
/>,
);
expect(markup).toContain('data-grid-quick-where="true"');
expect(markup).toContain('WHERE');
expect(markup).toContain('输入 WHERE 后面的条件');
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,9 @@ import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildOrderBySQL, buildWhereSQL, 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 { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
buildEffectiveFilterConditions,
normalizeQuickWhereCondition,
validateQuickWhereCondition,
} from '../utils/dataGridWhereFilter';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
type ViewerPaginationState = {
current: number;
@@ -22,7 +14,6 @@ type ViewerPaginationState = {
total: number;
totalKnown: boolean;
totalApprox: boolean;
approximateTotal?: number;
totalCountLoading: boolean;
totalCountCancelled: boolean;
};
@@ -79,6 +70,30 @@ const parseTotalFromCountRow = (row: any): number | null => {
return null;
};
const parseDuckDBApproxTotalRow = (row: any): number | null => {
if (!row || typeof row !== 'object') return null;
const entries = Object.entries(row as Record<string, unknown>);
if (entries.length === 0) return null;
const preferredKeys = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'count', 'total'];
for (const preferred of preferredKeys) {
for (const [key, raw] of entries) {
if (String(key || '').trim().toLowerCase() !== preferred) continue;
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
for (const [key, raw] of entries) {
const normalized = String(key || '').trim().toLowerCase();
if (normalized.includes('estimate') || normalized.includes('row') || normalized.includes('count') || normalized.includes('total')) {
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
return null;
};
const normalizeDuckDBIdentifier = (raw: string): string => {
const text = String(raw || '').trim();
if (text.length >= 2) {
@@ -140,10 +155,9 @@ const reverseOrderBySQL = (orderBySQL: string): string => {
type ViewerFilterSnapshot = {
showFilter: boolean;
conditions: FilterCondition[];
quickWhereCondition: string;
currentPage: number;
pageSize: number;
sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>;
sortInfo: { columnKey: string, order: string } | null;
scrollTop: number;
scrollLeft: number;
};
@@ -171,24 +185,22 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
if (!cached) {
return { showFilter: false, conditions: [], quickWhereCondition: '', currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: null, scrollTop: 0, scrollLeft: 0 };
}
return {
showFilter: cached.showFilter === true,
conditions: normalizeViewerFilterConditions(cached.conditions),
quickWhereCondition: normalizeQuickWhereCondition(cached.quickWhereCondition),
currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1,
pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100,
sortInfo: Array.isArray(cached.sortInfo)
? cached.sortInfo.filter(s => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend'))
.map(s => ({ columnKey: String(s.columnKey), order: s.order }))
: (cached.sortInfo && (cached.sortInfo as any).columnKey ? [{ columnKey: String((cached.sortInfo as any).columnKey), order: (cached.sortInfo as any).order }] : []),
sortInfo: cached.sortInfo && cached.sortInfo.columnKey && (cached.sortInfo.order === 'ascend' || cached.sortInfo.order === 'descend')
? { columnKey: String(cached.sortInfo.columnKey), order: cached.sortInfo.order }
: null,
scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0,
scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0,
};
};
const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
const [data, setData] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
@@ -201,8 +213,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const countKeyRef = useRef<string>('');
const duckdbApproxSeqRef = useRef(0);
const duckdbApproxKeyRef = useRef<string>('');
const oracleApproxSeqRef = useRef(0);
const oracleApproxKeyRef = useRef<string>('');
const manualCountSeqRef = useRef(0);
const manualCountKeyRef = useRef<string>('');
const pkSeqRef = useRef(0);
@@ -217,7 +227,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
left: initialViewerSnapshot.scrollLeft,
});
const initialLoadRef = useRef(false);
const skipNextAutoFetchRef = useRef(false);
const [pagination, setPagination] = useState<ViewerPaginationState>({
current: initialViewerSnapshot.currentPage,
@@ -229,53 +238,36 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
totalCountCancelled: false,
});
const [sortInfo, setSortInfo] = useState<Array<{ columnKey: string, order: string, enabled?: boolean }>>(initialViewerSnapshot.sortInfo);
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(initialViewerSnapshot.sortInfo);
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
const [quickWhereCondition, setQuickWhereCondition] = useState<string>(initialViewerSnapshot.quickWhereCondition);
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
const currentConnType = currentConnCaps.type;
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
const preferManualTotalCount = currentConnCaps.preferManualTotalCount;
const supportsApproximateTableCount = currentConnCaps.supportsApproximateTableCount;
const supportsApproximateTotalPages = currentConnCaps.supportsApproximateTotalPages;
const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
const normalizedTabId = String(tabId || '').trim();
if (!normalizedTabId) return;
viewerFilterSnapshotsByTab.set(normalizedTabId, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
quickWhereCondition: normalizeQuickWhereCondition(quickWhereCondition),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
scrollTop: scrollSnapshotRef.current.top,
scrollLeft: scrollSnapshotRef.current.left,
...overrides,
});
}, [showFilter, filterConditions, quickWhereCondition, pagination.current, pagination.pageSize, sortInfo]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
setShowFilter(snapshot.showFilter);
setFilterConditions(snapshot.conditions);
setQuickWhereCondition(snapshot.quickWhereCondition);
setSortInfo(snapshot.sortInfo);
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
}, [tab.id]);
useEffect(() => {
persistViewerSnapshot(tab.id);
}, [persistViewerSnapshot]);
useEffect(() => {
return () => {
persistViewerSnapshot(tab.id);
};
}, [tab.id, persistViewerSnapshot]);
viewerFilterSnapshotsByTab.set(tab.id, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
scrollTop: scrollSnapshotRef.current.top,
scrollLeft: scrollSnapshotRef.current.left,
});
}, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
@@ -283,7 +275,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
pkKeyRef.current = '';
countKeyRef.current = '';
duckdbApproxKeyRef.current = '';
oracleApproxKeyRef.current = '';
manualCountKeyRef.current = '';
duckdbSafeSelectCacheRef.current = {};
latestConfigRef.current = null;
@@ -293,7 +284,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
latestCountKeyRef.current = '';
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
skipNextAutoFetchRef.current = true;
setPagination(prev => ({
...prev,
current: snapshot.currentPage,
@@ -301,7 +291,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
total: 0,
totalKnown: false,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
}));
@@ -309,13 +298,18 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
scrollSnapshotRef.current = snapshot;
persistViewerSnapshot(tab.id, {
const cached = getViewerFilterSnapshot(tab.id);
viewerFilterSnapshotsByTab.set(tab.id, {
...cached,
scrollTop: snapshot.top,
scrollLeft: snapshot.left,
});
}, [tab.id, persistViewerSnapshot]);
}, [tab.id]);
const handleManualTotalCount = useCallback(async () => {
const handleDuckDBManualCount = useCallback(async () => {
if (latestDbTypeRef.current !== 'duckdb') {
return;
}
const config = latestConfigRef.current;
const dbName = latestDbNameRef.current;
const countSql = latestCountSqlRef.current;
@@ -330,13 +324,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const countSeq = ++manualCountSeqRef.current;
const countStart = Date.now();
setPagination(prev => ({ ...prev, totalCountLoading: true, totalCountCancelled: false }));
const countConfig = buildRpcConnectionConfig(config, { timeout: 120 });
const countConfig: any = { ...(config as any), timeout: 120 };
try {
const resCount = await DBQuery(countConfig as any, dbName, countSql);
const countDuration = Date.now() - countStart;
addSqlLog({
id: `log-${Date.now()}-manual-count`,
id: `log-${Date.now()}-duckdb-manual-count`,
timestamp: Date.now(),
sql: countSql,
status: resCount?.success ? 'success' : 'error',
@@ -370,7 +364,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
total,
totalKnown: true,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
}));
@@ -382,7 +375,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
}
}, [addSqlLog]);
const handleCancelManualTotalCount = useCallback(() => {
const handleDuckDBCancelManualCount = useCallback(() => {
manualCountSeqRef.current++;
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
}, []);
@@ -406,17 +399,9 @@ 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';
const normalizedQuickWhereCondition = normalizeQuickWhereCondition(quickWhereCondition);
const quickWhereValidation = validateQuickWhereCondition(normalizedQuickWhereCondition);
if (!quickWhereValidation.ok) {
message.error(quickWhereValidation.message);
if (fetchSeqRef.current === seq) setLoading(false);
return;
}
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, normalizedQuickWhereCondition);
const dbName = tab.dbName || '';
const tableName = tab.tableName || '';
@@ -424,7 +409,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
let mongoFilter: Record<string, unknown> | undefined;
if (isMongoDB) {
try {
mongoFilter = buildMongoFilter(effectiveFilterConditions);
mongoFilter = buildMongoFilter(filterConditions);
} catch (e: any) {
message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`);
if (fetchSeqRef.current === seq) setLoading(false);
@@ -434,7 +419,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const whereSQL = isMongoDB
? JSON.stringify(mongoFilter || {})
: buildWhereSQL(dbType, effectiveFilterConditions);
: buildWhereSQL(dbType, filterConditions);
const countSql = isMongoDB
? buildMongoCountCommand(tableName, mongoFilter || {})
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
@@ -442,15 +427,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const totalRows = Number(pagination.total);
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
const totalKnown = pagination.totalKnown && hasFiniteTotal;
const approximateTotalRows = Number(pagination.approximateTotal);
const hasApproximateTotalPages =
!totalKnown &&
supportsApproximateTotalPages &&
pagination.totalApprox &&
Number.isFinite(approximateTotalRows) &&
approximateTotalRows > 0;
const effectiveTotalRows = hasApproximateTotalPages ? approximateTotalRows : totalRows;
const totalPages = Number.isFinite(effectiveTotalRows) && effectiveTotalRows > 0 ? Math.max(1, Math.ceil(effectiveTotalRows / size)) : 0;
const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0;
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
const offset = (currentPage - 1) * size;
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
@@ -478,7 +455,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
if (pageRowCount > 0) {
const tailOffset = Math.max(0, totalRows - (offset + pageRowCount));
if (tailOffset < offset) {
sql = buildPaginatedSelectSQL(dbType, baseSql, reverseOrderSQL, pageRowCount, tailOffset);
sql = `${baseSql}${reverseOrderSQL} LIMIT ${pageRowCount} OFFSET ${tailOffset}`;
useClickHouseReversePagination = true;
clickHouseReverseLimit = pageRowCount;
clickHouseReverseHasMore = currentPage < totalPages;
@@ -487,7 +464,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
}
if (!useClickHouseReversePagination) {
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, size + 1, offset);
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
}
}
@@ -497,7 +474,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
const startTime = Date.now();
try {
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, querySql);
const result = await DBQuery(config as any, dbName, querySql);
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
@@ -524,7 +501,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
}
};
const hasSort = hasExplicitSort(sortInfo);
const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
let resData = await executeDataQuery(sql, '主查询');
@@ -533,7 +510,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
let safeSelect = duckdbSafeSelectCacheRef.current[cacheKey] || '';
if (!safeSelect) {
try {
const resCols = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName);
const resCols = await DBGetColumns(config as any, dbName, tableName);
if (resCols?.success && Array.isArray(resCols.data)) {
const columnDefs = resCols.data as ColumnDefinition[];
const selectParts = columnDefs.map((col) => {
@@ -557,7 +534,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
if (safeSelect) {
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, pkColumns), size + 1, offset);
fallbackSql += buildOrderBySQL(dbType, sortInfo, pkColumns);
fallbackSql += ` LIMIT ${size + 1} OFFSET ${offset}`;
executedSql = fallbackSql;
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
}
@@ -586,7 +564,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
if (pkKeyRef.current !== pkKey) {
pkKeyRef.current = pkKey;
const pkSeq = ++pkSeqRef.current;
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName)
DBGetColumns(config as any, dbName, tableName)
.then((resCols: any) => {
if (pkSeqRef.current !== pkSeq) return;
if (pkKeyRef.current !== pkKey) return;
@@ -644,7 +622,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
total: derivedTotal,
totalKnown: true,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
};
@@ -660,20 +637,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
}
}
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
const hasApproximateTotalForCurrentKey =
prev.totalApprox &&
(duckdbApproxKeyRef.current === countKey || oracleApproxKeyRef.current === countKey) &&
Number.isFinite(prev.approximateTotal) &&
Number(prev.approximateTotal) >= minExpectedTotal;
if (hasApproximateTotalForCurrentKey) {
if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
return {
...prev,
current: currentPage,
pageSize: size,
total: derivedTotal,
totalKnown: false,
totalApprox: true,
approximateTotal: prev.approximateTotal,
totalCountLoading: keepManualCounting,
totalCountCancelled: false,
};
@@ -685,13 +655,12 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
total: derivedTotal,
totalKnown: false,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: keepManualCounting,
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
};
});
const shouldRunAsyncCount = !derivedTotalKnown && !preferManualTotalCount;
const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB;
if (shouldRunAsyncCount) {
if (countKeyRef.current !== countKey) {
countKeyRef.current = countKey;
@@ -699,7 +668,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const countStart = Date.now();
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
// DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT。
const countConfig = buildRpcConnectionConfig(config, { timeout: 5 });
const countConfig: any = { ...(config as any), timeout: 5 };
DBQuery(countConfig, dbName, countSql)
.then((resCount: any) => {
@@ -716,7 +685,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
});
if (countSeqRef.current !== countSeq) return;
if (latestCountKeyRef.current !== countKey) return;
if (countKeyRef.current !== countKey) return;
if (!resCount.success) return;
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
@@ -729,7 +698,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
total,
totalKnown: true,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
}));
@@ -742,88 +710,48 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
}
}
if (!derivedTotalKnown) {
const approximateCountStrategy = supportsApproximateTableCount
? resolveApproximateTableCountStrategy({ dbType: dbTypeLower, whereSQL })
: 'none';
if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) {
duckdbApproxKeyRef.current = countKey;
const approxSeq = ++duckdbApproxSeqRef.current;
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
const escapedSchema = escapeSQLLiteral(schemaName);
const escapedTable = escapeSQLLiteral(pureTableName);
const approxConfig: any = { ...(config as any), timeout: 3 };
const approxSqlCandidates = [
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
];
if (approximateCountStrategy === 'duckdb-estimated-size' && duckdbApproxKeyRef.current !== countKey) {
duckdbApproxKeyRef.current = countKey;
const approxSeq = ++duckdbApproxSeqRef.current;
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
const escapedSchema = escapeSQLLiteral(schemaName);
const escapedTable = escapeSQLLiteral(pureTableName);
const approxConfig = buildRpcConnectionConfig(config, { timeout: 3 });
const approxSqlCandidates = [
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
];
(async () => {
for (const approxSql of approxSqlCandidates) {
try {
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
(async () => {
for (const approxSql of approxSqlCandidates) {
try {
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
const approxTotal = parseApproximateTableCountRow(approxRes.data[0]);
if (approxTotal === null) continue;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
setPagination(prev => {
if (latestCountKeyRef.current !== countKey) return prev;
if (prev.totalKnown) return prev;
return {
...prev,
totalKnown: false,
totalApprox: true,
approximateTotal: approxTotal,
totalCountCancelled: false,
};
});
return;
} catch {
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
}
}
})();
}
if (approximateCountStrategy === 'oracle-num-rows' && oracleApproxKeyRef.current !== countKey) {
oracleApproxKeyRef.current = countKey;
const approxSeq = ++oracleApproxSeqRef.current;
const approxConfig = buildRpcConnectionConfig(config, { timeout: 3 });
const approxSql = buildOracleApproximateTotalSql({ dbName, tableName });
DBQuery(approxConfig as any, dbName, approxSql)
.then((approxRes: any) => {
if (oracleApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) return;
const approxTotal = parseApproximateTableCountRow(approxRes.data[0], ['approx_total', 'num_rows', 'estimated_rows', 'row_count', 'count', 'total']);
if (approxTotal === null) return;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) return;
const approxTotal = parseDuckDBApproxTotalRow(approxRes.data[0]);
if (approxTotal === null) continue;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
setPagination(prev => {
if (latestCountKeyRef.current !== countKey) return prev;
if (countKeyRef.current !== countKey) return prev;
if (prev.totalKnown) return prev;
return {
...prev,
total: approxTotal,
totalKnown: false,
totalApprox: true,
approximateTotal: approxTotal,
totalCountCancelled: false,
};
});
})
.catch(() => {
if (oracleApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
});
}
return;
} catch {
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
}
}
})();
}
} else {
message.error(String(resData.message || '查询失败'));
@@ -842,7 +770,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]);
// 依赖 pkColumns在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
@@ -851,70 +779,44 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
fetchData(pagination.current, pagination.pageSize);
}, [fetchData, pagination.current, pagination.pageSize]);
const handleSort = useCallback((field: string, order: string) => {
// 支持多字段排序field 为 JSON 数组字符串时解析为多字段
try {
const parsed = JSON.parse(field);
if (Array.isArray(parsed)) {
setSortInfo(parsed.filter((s: any) => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend')));
return;
}
} catch { /* 单字段模式 */ }
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
const normalizedField = String(field || '').trim();
if (!normalizedField || !normalizedOrder) {
setSortInfo([]);
setSortInfo(null);
return;
}
setSortInfo([{ columnKey: normalizedField, order: normalizedOrder, enabled: true }]);
setSortInfo({ columnKey: normalizedField, order: normalizedOrder });
}, []);
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
const handleApplyQuickWhereCondition = useCallback((condition: string) => {
const normalized = normalizeQuickWhereCondition(condition);
const validation = validateQuickWhereCondition(normalized);
if (!validation.ok) {
message.error(validation.message);
return;
}
setQuickWhereCondition(normalized);
}, []);
const exportSqlWithFilter = useMemo(() => {
const tableName = String(tab.tableName || '').trim();
const dbType = resolveDataSourceType(currentConnConfig);
const dbType = String(currentConnConfig?.type || '').trim();
if (!tableName || !dbType) return '';
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
const whereSQL = buildWhereSQL(dbType, filterConditions);
if (!whereSQL) return '';
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
const normalizedType = dbType.toLowerCase();
const hasSortForBuffer = hasExplicitSort(sortInfo);
if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
useEffect(() => {
const action = resolveDataViewerAutoFetchAction({
skipNextAutoFetch: skipNextAutoFetchRef.current,
hasInitialLoad: initialLoadRef.current,
});
if (action === 'skip') {
skipNextAutoFetchRef.current = false;
return;
}
if (action === 'load-current-page') {
if (!initialLoadRef.current) {
initialLoadRef.current = true;
fetchData(pagination.current, pagination.pageSize);
return;
}
fetchData(1, pagination.pageSize);
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
return (
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
@@ -931,14 +833,12 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
onSort={handleSort}
onPageChange={handlePageChange}
pagination={pagination}
onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined}
onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined}
onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined}
onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined}
showFilter={showFilter}
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
appliedFilterConditions={filterConditions}
quickWhereCondition={quickWhereCondition}
onApplyQuickWhereCondition={handleApplyQuickWhereCondition}
readOnly={forceReadOnly}
sortInfoExternal={sortInfo}
exportSqlWithFilter={exportSqlWithFilter || undefined}

View File

@@ -1,223 +0,0 @@
import React from 'react';
// ─── 公共接口 ───────────────────────────────────────────────
export interface DbIconProps {
size?: number;
color?: string;
}
// ─── 默认色表 ───────────────────────────────────────────────
const DB_DEFAULT_COLORS: Record<string, string> = {
mysql: '#00758F',
mariadb: '#003545',
postgres: '#336791',
redis: '#DC382D',
mongodb: '#47A248',
jvm: '#1677FF',
kingbase: '#1890FF',
dameng: '#E6002D',
oracle: '#F80000',
sqlserver: '#CC2927',
clickhouse: '#FFBF00',
sqlite: '#003B57',
duckdb: '#FFC107',
vastbase: '#0066CC',
highgo: '#00A86B',
tdengine: '#2962FF',
diros: '#0050B3',
sphinx: '#2F5D62',
custom: '#888888',
};
export const getDbDefaultColor = (type: string): string =>
DB_DEFAULT_COLORS[type?.toLowerCase()] || DB_DEFAULT_COLORS.custom;
// ─── 有品牌 SVG 文件的数据库类型(文件在 /db-icons/ 下) ────
const BRAND_SVG_TYPES = new Set([
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite',
'diros', 'sphinx', 'duckdb', 'sqlserver',
]);
/** 品牌 SVG 图标:用 <img> 加载 /db-icons/*.svg */
const BrandSvgIcon: React.FC<{ type: string; size: number; color?: string }> = ({ type, size, color }) => {
const bgColor = color || getDbDefaultColor(type);
return (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: size, height: size, borderRadius: size * 0.22,
background: '#fff', border: `1.5px solid ${bgColor}`,
flexShrink: 0, overflow: 'hidden',
}}>
<img
src={`/db-icons/${type}.svg`}
alt={type}
width={size * 0.7}
height={size * 0.7}
style={{ display: 'block' }}
/>
</span>
);
};
// ─── 彩色标签图标fallback ──────────────────────────────
/** 通用彩色标签:填充背景 + 白色粗体缩写 */
const ColorBadge: React.FC<{ size: number; color: string; label: string }> = ({ size, color, label }) => {
const textSize = label.length <= 2 ? size * 0.48 : size * 0.38;
return (
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="22" rx="5" fill={color}/>
<text
x="12" y="12" dominantBaseline="central" textAnchor="middle"
fontSize={textSize} fontWeight="800" fontFamily="system-ui,-apple-system,sans-serif"
fill="#fff" letterSpacing={label.length > 2 ? -0.5 : 0}
>
{label}
</text>
</svg>
);
};
// ─── 各数据库图标 ───────────────────────────────────────────
// 有品牌 SVG 的数据库
const MySQLIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mysql" size={size} color={color} />
);
const MariaDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mariadb" size={size} color={color} />
);
const PostgresIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="postgres" size={size} color={color} />
);
const RedisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="redis" size={size} color={color} />
);
const MongoDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mongodb" size={size} color={color} />
);
const ClickHouseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="clickhouse" size={size} color={color} />
);
const SQLiteIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="sqlite" size={size} color={color} />
);
// 无品牌 SVG → 彩色文字标签
const OracleIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oracle} label="Or" />
);
const SQLServerIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="sqlserver" size={size} color={color} />
);
const DorisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="diros" size={size} color={color} />
);
const SphinxIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="sphinx" size={size} color={color} />
);
const DuckDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="duckdb" size={size} color={color} />
);
const KingBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.kingbase} label="KB" />
);
const DamengIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.dameng} label="DM" />
);
const VastBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.vastbase} label="VB" />
);
const HighGoIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.highgo} label="HG" />
);
const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.tdengine} label="TD" />
);
const JVMIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.jvm} label="JVM" />
);
/** Custom — 齿轮图标 */
const CustomIcon: React.FC<DbIconProps> = ({ size = 16, color }) => {
const c = color || DB_DEFAULT_COLORS.custom;
return (
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="22" rx="5" fill={c}/>
<circle cx="12" cy="12" r="3.5" stroke="#fff" strokeWidth="1.5" fill="none"/>
<path d="M12 4v2.5M12 17.5V20M4 12h2.5M17.5 12H20M6.34 6.34l1.77 1.77M15.89 15.89l1.77 1.77M6.34 17.66l1.77-1.77M15.89 8.11l1.77-1.77" stroke="#fff" strokeWidth="1.3" strokeLinecap="round"/>
</svg>
);
};
// ─── 图标注册表 ─────────────────────────────────────────────
const DorisIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.diros} label="Do" />
);
const SphinxIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sphinx} label="Sp" />
);
const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
mysql: MySQLIcon,
mariadb: MariaDBIcon,
diros: DorisIcon,
sphinx: SphinxIcon,
postgres: PostgresIcon,
redis: RedisIcon,
mongodb: MongoDBIcon,
jvm: JVMIcon,
kingbase: KingBaseIcon,
dameng: DamengIcon,
oracle: OracleIcon,
sqlserver: SQLServerIcon,
clickhouse: ClickHouseIcon,
sqlite: SQLiteIcon,
duckdb: DuckDBIcon,
vastbase: VastBaseIcon,
highgo: HighGoIcon,
tdengine: TDengineIcon,
custom: CustomIcon,
};
/** 可选图标类型列表(用于图标选择器 UI */
export const DB_ICON_TYPES: string[] = [
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'jvm',
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse',
'kingbase', 'dameng', 'vastbase', 'highgo', 'tdengine', 'custom',
];
/** 该类型是否有品牌 SVG 文件 */
export const hasBrandSvg = (type: string): boolean => BRAND_SVG_TYPES.has(type?.toLowerCase());
/** 获取数据库图标 React 节点 */
export const getDbIcon = (type: string, color?: string, size?: number): React.ReactNode => {
const key = (type || 'custom').toLowerCase();
const Component = DB_ICON_MAP[key] || CustomIcon;
return <Component size={size} color={color} />;
};
/** 获取数据库图标显示名称(中文) */
export const getDbIconLabel = (type: string): string => {
const labels: Record<string, string> = {
mysql: 'MySQL', mariadb: 'MariaDB', postgres: 'PostgreSQL',
redis: 'Redis', mongodb: 'MongoDB', jvm: 'JVM',
oracle: 'Oracle',
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
vastbase: 'VastBase', highgo: '瀚高', tdengine: 'TDengine',
custom: '自定义',
};
return labels[type?.toLowerCase()] || type;
};
/** 预设颜色列表 */
export const PRESET_ICON_COLORS: string[] = [
'#336791', '#00758F', '#DC382D', '#47A248', '#F80000',
'#CC2927', '#1890FF', '#E6002D', '#FFBF00', '#2962FF',
'#00A86B', '#0066CC', '#FF6B35', '#7C3AED',
];

View File

@@ -4,29 +4,11 @@ import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';
import { DBQuery } from '../../wailsjs/go/app/App';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
interface DefinitionViewerProps {
tab: TabData;
}
const normalizeMySQLViewDDL = (rawDefinition: unknown): string => {
const text = String(rawDefinition || '').trim();
if (!text) return '';
const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, '');
const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i;
if (createViewPrefixPattern.test(normalized)) {
return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`;
}
if (/^\s*(select|with)\b/i.test(normalized)) {
return normalized;
}
return `${normalized};`;
};
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -219,7 +201,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const sql = String(query || '').trim();
if (!sql) continue;
try {
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
const result = await DBQuery(config as any, dbName, sql);
if (!result.success || !Array.isArray(result.data)) {
lastMessage = result.message || lastMessage;
continue;
@@ -245,7 +227,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
];
for (const query of candidates) {
try {
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
const result = await DBQuery(config as any, dbName, query);
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
continue;
}
@@ -274,15 +256,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);

View File

@@ -3,12 +3,7 @@ import { Alert, Button, Collapse, Input, Modal, Progress, Select, Space, Switch,
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, InfoCircleFilled, ReloadOutlined } from '@ant-design/icons';
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 { normalizeOpacityForPlatform } from '../utils/appearance';
import {
CheckDriverNetworkStatus,
DownloadDriverPackage,
@@ -16,7 +11,6 @@ import {
GetDriverVersionPackageSize,
GetDriverStatusList,
InstallLocalDriverPackage,
OpenDriverDownloadDirectory,
RemoveDriverPackage,
SelectDriverPackageDirectory,
SelectDriverPackageFile,
@@ -172,8 +166,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const theme = useStore((state) => state.theme);
const appearance = useStore((state) => state.appearance);
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const opacity = normalizeOpacityForPlatform(appearance.opacity);
const modalContentRef = useRef<HTMLDivElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
const tableScrollTargetsRef = useRef<HTMLElement[]>([]);
@@ -763,16 +756,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
};
}, [appendOperationLog, open]);
const resolveLocalImportVersion = useCallback((row: DriverStatusRow) => {
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
return selectedOption?.version || row.pinnedVersion || '';
}, [selectedVersionMap, versionMap]);
const installDriver = useCallback(async (row: DriverStatusRow) => {
setActionState({ driverType: row.type, kind: 'install' });
setProgressMap((prev) => ({
@@ -836,11 +819,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
percent: 0,
},
}));
const selectedVersion = resolveLocalImportVersion(row);
const versionTip = selectedVersion ? `${selectedVersion}` : '';
appendOperationLog(row.type, `[START] 开始本地导入${versionTip}${sourceLabel}${pathText}`);
appendOperationLog(row.type, `[START] 开始本地导入(${sourceLabel}${pathText}`);
try {
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir, selectedVersion);
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir);
if (!result?.success) {
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
@@ -849,9 +830,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
return false;
}
appendOperationLog(row.type, `[DONE] 本地导入安装完成 ${versionTip}`.trim());
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
if (!options?.silentToast) {
message.success(`${row.name}${versionTip} 本地驱动包已安装启用`);
message.success(`${row.name} 本地驱动包已安装启用`);
}
if (!options?.skipRefresh) {
await refreshStatus(false);
@@ -860,12 +841,12 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
} finally {
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, refreshStatus, resolveLocalImportVersion]);
}, [appendOperationLog, downloadDir, refreshStatus]);
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
if (!fileRes?.success) {
if (String(fileRes?.message || '') !== '已取消') {
if (String(fileRes?.message || '') !== 'Cancelled') {
message.error(fileRes?.message || '选择本地驱动包文件失败');
}
return;
@@ -881,7 +862,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const installDriversFromDirectory = useCallback(async () => {
const directoryRes = await SelectDriverPackageDirectory(downloadDir);
if (!directoryRes?.success) {
if (String(directoryRes?.message || '') !== '已取消') {
if (String(directoryRes?.message || '') !== 'Cancelled') {
message.error(directoryRes?.message || '选择本地驱动包目录失败');
}
return;
@@ -954,18 +935,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
message.error(`目录导入失败${forceTip}:失败 ${failCount}${skipTip}`);
}, [appendOperationLog, downloadDir, forceOverwriteInstalled, installDriverFromLocalPath, refreshStatus, rows]);
const openDriverDirectory = useCallback(async () => {
try {
const res = await OpenDriverDownloadDirectory(downloadDir);
if (!res?.success) {
throw new Error(res?.message || '打开驱动目录失败');
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
message.error(`打开驱动目录失败: ${errMsg}`);
}
}, [downloadDir]);
const openDriverLog = useCallback((driverType: string) => {
const normalized = String(driverType || '').trim().toLowerCase();
if (!normalized) {
@@ -1097,35 +1066,29 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
const mongoHint = row.type === 'mongodb'
? '当前仅支持 MongoDB 1.17.x 和 2.x更老 1.x 暂不提供安装。'
: '';
return (
<div style={{ display: 'grid', gap: 4 }}>
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
{mongoHint ? <Text type="secondary" style={{ fontSize: 12 }}>{mongoHint}</Text> : null}
</div>
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
);
},
},
@@ -1176,7 +1139,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'}
@@ -1260,7 +1223,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
paddingRight: 18,
},
}}
destroyOnHidden
destroyOnClose
footer={(
<div className="driver-manager-footer">
<div
@@ -1378,14 +1341,10 @@ 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">/ `mariadb-driver-agent``mariadb-driver-agent.exe``GoNavi-DriverAgents.zip`使</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
<Button icon={<FolderOpenOutlined />} onClick={() => void openDriverDirectory()}>
</Button>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
@@ -1414,12 +1373,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
onChange={(checked) => setForceOverwriteInstalled(checked)}
disabled={batchDirectoryImporting}
/>
<Button
icon={<FolderOpenOutlined />}
onClick={() => void openDriverDirectory()}
>
</Button>
<Button
icon={<FolderOpenOutlined />}
loading={batchDirectoryImporting}

View File

@@ -1,465 +0,0 @@
import React, { useState, useRef, useCallback, useMemo } from 'react';
import { Modal, Input, Button, Table, Progress, Space, Tag, message, Tooltip, Select, Empty } from 'antd';
import { SearchOutlined, StopOutlined, EyeOutlined, DatabaseOutlined } from '@ant-design/icons';
import { DBQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/app/App';
import { quoteIdentPart, escapeLiteral } from '../utils/sql';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isMacLikePlatform } from '../utils/appearance';
interface FindInDatabaseModalProps {
open: boolean;
onClose: () => void;
connectionId: string;
dbName: string;
}
interface SearchResultItem {
tableName: string;
matchedColumns: string[];
matchCount: number;
rows: Record<string, any>[];
columns: string[];
}
/** 判断数据库列类型是否为文本类型(只搜索文本字段) */
const isTextColumnType = (colType: string): boolean => {
const t = (colType || '').toLowerCase().trim();
// 显式排除非文本类型
if (/^(int|bigint|smallint|tinyint|mediumint|float|double|decimal|numeric|real|money|smallmoney|bit|boolean|bool)/.test(t)) return false;
if (/^(date|time|datetime|timestamp|year|interval)/.test(t)) return false;
if (/^(blob|binary|varbinary|image|bytea|raw|long raw)/.test(t)) return false;
if (/^(geometry|geography|point|line|polygon|spatial)/.test(t)) return false;
if (/^(json|jsonb|xml|uuid|uniqueidentifier)/.test(t)) return false;
if (/^(serial|bigserial|smallserial|autoincrement|identity)/.test(t)) return false;
// 文本类型正匹配
if (/^(varchar|char|nvarchar|nchar|text|ntext|tinytext|mediumtext|longtext|string|clob|nclob|character)/.test(t)) return true;
if (t === 'sysname' || t === 'sql_variant') return true;
// 未知类型默认尝试搜索
return true;
};
/** 根据 dbType 构建限制返回行数的 SELECT SQL */
const buildLimitedSelectSQL = (dbType: string, baseSql: string, limit: number): string => {
const normalizedType = (dbType || '').toLowerCase();
switch (normalizedType) {
case 'sqlserver':
case 'mssql':
return baseSql.replace(/^SELECT\b/i, `SELECT TOP ${limit}`);
case 'oracle':
case 'dameng':
return `${baseSql} FETCH FIRST ${limit} ROWS ONLY`;
default:
return `${baseSql} LIMIT ${limit}`;
}
};
const MAX_MATCH_ROWS_PER_TABLE = 100;
const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose, connectionId, dbName }) => {
const [keyword, setKeyword] = useState('');
const [matchMode, setMatchMode] = useState<'contains' | 'exact'>('contains');
const [searching, setSearching] = useState(false);
const [results, setResults] = useState<SearchResultItem[]>([]);
const [progress, setProgress] = useState({ current: 0, total: 0, tableName: '' });
const [expandedTable, setExpandedTable] = useState<string | null>(null);
const cancelledRef = useRef(false);
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]);
const buildConfig = useCallback(() => {
if (!conn) return null;
return {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
}, [conn]);
const handleSearch = useCallback(async () => {
const searchKeyword = keyword.trim();
if (!searchKeyword) {
message.warning('请输入搜索关键字');
return;
}
const config = buildConfig();
if (!config) {
message.error('未找到连接配置');
return;
}
setSearching(true);
setResults([]);
setExpandedTable(null);
cancelledRef.current = false;
try {
// 1. 获取所有表
const tablesRes = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
if (!tablesRes.success) {
message.error('获取表列表失败: ' + tablesRes.message);
setSearching(false);
return;
}
const tableRows: any[] = Array.isArray(tablesRes.data) ? tablesRes.data : [];
const tableNames = tableRows.map((row: any) => Object.values(row)[0] as string).filter(Boolean);
if (tableNames.length === 0) {
message.info('当前数据库没有表');
setSearching(false);
return;
}
setProgress({ current: 0, total: tableNames.length, tableName: '' });
// 2. 获取所有列信息(返回 any[],含 tableName/name/type 字段)
const allColsRes = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName);
const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : [];
// 按表名分组
const columnsByTable: Record<string, Array<{ name: string; type: string }>> = {};
allColumns.forEach((col: any) => {
const tbl = col.tableName || '';
if (!columnsByTable[tbl]) columnsByTable[tbl] = [];
columnsByTable[tbl].push({ name: col.name, type: col.type || '' });
});
const searchResults: SearchResultItem[] = [];
const escapedKeyword = escapeLiteral(searchKeyword);
// 3. 逐表搜索
for (let i = 0; i < tableNames.length; i++) {
if (cancelledRef.current) break;
const tableName = tableNames[i];
setProgress({ current: i + 1, total: tableNames.length, tableName });
// 获取该表的文本列
const tableCols = columnsByTable[tableName] || [];
const textCols = tableCols.filter(c => isTextColumnType(c.type));
if (textCols.length === 0) continue;
// 构建 WHERE 子句
const castType = (dbType === 'sqlserver' || dbType === 'mssql') ? 'NVARCHAR(MAX)' : 'CHAR';
const whereConditions = textCols.map(c => {
const quotedCol = quoteIdentPart(dbType, c.name);
if (matchMode === 'exact') {
return `CAST(${quotedCol} AS ${castType}) = '${escapedKeyword}'`;
}
return `CAST(${quotedCol} AS ${castType}) LIKE '%${escapedKeyword}%'`;
});
const quotedTable = quoteIdentPart(dbType, tableName);
const baseSql = `SELECT * FROM ${quotedTable} WHERE ${whereConditions.join(' OR ')}`;
const sql = buildLimitedSelectSQL(dbType, baseSql, MAX_MATCH_ROWS_PER_TABLE);
try {
const res = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
if (res.success && Array.isArray(res.data) && res.data.length > 0) {
// 检查哪些列实际匹配了
const matchedCols = new Set<string>();
const lowerKeyword = searchKeyword.toLowerCase();
res.data.forEach((row: any) => {
textCols.forEach(c => {
const val = row[c.name];
if (val != null) {
const strVal = String(val).toLowerCase();
if (matchMode === 'exact' ? strVal === lowerKeyword : strVal.includes(lowerKeyword)) {
matchedCols.add(c.name);
}
}
});
});
if (matchedCols.size > 0) {
const columns = Object.keys(res.data[0]);
searchResults.push({
tableName,
matchedColumns: Array.from(matchedCols),
matchCount: res.data.length,
rows: res.data,
columns,
});
setResults([...searchResults]);
}
}
} catch {
// 单表查询失败不中断整体搜索
}
}
if (!cancelledRef.current) {
setResults([...searchResults]);
if (searchResults.length === 0) {
message.info('未找到匹配的数据');
}
}
} catch (e: any) {
message.error('搜索出错: ' + (e?.message || String(e)));
} finally {
setSearching(false);
}
}, [keyword, matchMode, dbName, dbType, buildConfig]);
const handleCancel = useCallback(() => {
cancelledRef.current = true;
}, []);
const handleClose = useCallback(() => {
cancelledRef.current = true;
setResults([]);
setExpandedTable(null);
setProgress({ current: 0, total: 0, tableName: '' });
onClose();
}, [onClose]);
// 汇总表的列定义
const summaryColumns = useMemo(() => [
{
title: '表名',
dataIndex: 'tableName',
key: 'tableName',
width: 220,
render: (text: string) => (
<span style={{ fontWeight: 500, color: wt.titleText }}>
<DatabaseOutlined style={{ marginRight: 6, color: wt.iconColor }} />
{text}
</span>
),
},
{
title: '匹配列',
dataIndex: 'matchedColumns',
key: 'matchedColumns',
render: (cols: string[]) => (
<Space size={4} wrap>
{cols.map(col => (
<Tag key={col} color="blue" style={{ margin: 0, fontSize: 12 }}>{col}</Tag>
))}
</Space>
),
},
{
title: '命中行数',
dataIndex: 'matchCount',
key: 'matchCount',
width: 100,
align: 'center' as const,
render: (count: number) => (
<Tag color={count >= MAX_MATCH_ROWS_PER_TABLE ? 'orange' : 'green'}>
{count >= MAX_MATCH_ROWS_PER_TABLE ? `${count}` : count}
</Tag>
),
},
{
title: '操作',
key: 'action',
width: 80,
align: 'center' as const,
render: (_: any, record: SearchResultItem) => (
<Tooltip title={expandedTable === record.tableName ? '收起详情' : '查看详情'}>
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={(e) => { e.stopPropagation(); setExpandedTable(prev => prev === record.tableName ? null : record.tableName); }}
style={{ color: wt.iconColor }}
/>
</Tooltip>
),
},
], [wt, expandedTable]);
// 展开的详情行 - 动态列
const expandedResult = useMemo(() => {
if (!expandedTable) return null;
return results.find(r => r.tableName === expandedTable);
}, [expandedTable, results]);
const detailColumns = useMemo(() => {
if (!expandedResult) return [];
const lowerKeyword = keyword.trim().toLowerCase();
return expandedResult.columns.map(col => ({
title: col,
dataIndex: col,
key: col,
width: 180,
ellipsis: true,
render: (value: any) => {
const strVal = value != null ? String(value) : '';
const isMatch = expandedResult.matchedColumns.includes(col) &&
strVal.toLowerCase().includes(lowerKeyword);
return (
<Tooltip title={strVal} placement="topLeft">
<span style={isMatch ? { background: 'rgba(255, 193, 7, 0.3)', padding: '1px 3px', borderRadius: 3 } : undefined}>
{strVal || <span style={{ color: wt.mutedText }}>NULL</span>}
</span>
</Tooltip>
);
},
}));
}, [expandedResult, keyword, wt]);
const percent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
return (
<Modal
title={
<span style={{ color: wt.titleText, fontWeight: 600 }}>
<SearchOutlined style={{ marginRight: 8, color: wt.iconColor }} />
{dbName}
</span>
}
open={open}
onCancel={handleClose}
footer={null}
width={960}
styles={{
content: {
background: wt.shellBg,
borderRadius: 16,
border: wt.shellBorder,
boxShadow: wt.shellShadow,
backdropFilter: wt.shellBackdropFilter,
WebkitBackdropFilter: wt.shellBackdropFilter,
},
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8 },
}}
destroyOnClose
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 搜索栏 */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Input
placeholder="输入要搜索的字符串..."
value={keyword}
onChange={e => setKeyword(e.target.value)}
onPressEnter={!searching ? handleSearch : undefined}
style={{ flex: 1 }}
disabled={searching}
autoFocus
/>
<Select
value={matchMode}
onChange={v => setMatchMode(v)}
disabled={searching}
style={{ width: 110 }}
options={[
{ label: '包含', value: 'contains' },
{ label: '精确匹配', value: 'exact' },
]}
/>
{searching ? (
<Button icon={<StopOutlined />} danger onClick={handleCancel}>
</Button>
) : (
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch} disabled={!keyword.trim()}>
</Button>
)}
</div>
{/* 进度条 */}
{searching && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Progress
percent={percent}
size="small"
status="active"
strokeColor={wt.iconColor}
/>
<span style={{ fontSize: 12, color: wt.mutedText }}>
{progress.tableName}... ({progress.current}/{progress.total})
</span>
</div>
)}
{/* 结果汇总表 */}
{results.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: wt.mutedText, fontWeight: 500 }}>
{results.length}
{searching && '(搜索进行中...'}
</div>
<Table
dataSource={results}
columns={summaryColumns}
rowKey="tableName"
size="small"
pagination={false}
style={{ borderRadius: 8, overflow: 'hidden' }}
scroll={{ y: expandedTable ? 200 : 400 }}
onRow={(record) => ({
style: {
cursor: 'pointer',
background: expandedTable === record.tableName ? wt.hoverBg : undefined,
},
onClick: () => setExpandedTable(prev => prev === record.tableName ? null : record.tableName),
})}
/>
</div>
)}
{/* 详情展开 */}
{expandedResult && (
<div style={{
border: wt.sectionBorder,
borderRadius: 8,
background: wt.sectionBg,
overflow: 'hidden',
}}>
<div style={{
padding: '8px 12px',
borderBottom: wt.sectionBorder,
fontSize: 13,
fontWeight: 500,
color: wt.titleText,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span>
<DatabaseOutlined style={{ marginRight: 6 }} />
{expandedResult.tableName}
</span>
<Tag color="blue">{expandedResult.rows.length} </Tag>
</div>
<Table
dataSource={expandedResult.rows.map((row, i) => ({ ...row, __rowIdx: i }))}
columns={detailColumns}
rowKey="__rowIdx"
size="small"
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false }}
scroll={{ x: Math.max(800, expandedResult.columns.length * 180) }}
style={{ fontSize: 12 }}
/>
</div>
)}
{/* 无结果且搜索完成 */}
{!searching && results.length === 0 && progress.total > 0 && (
<Empty description="未找到匹配的数据" style={{ margin: '24px 0' }} />
)}
</div>
</Modal>
);
};
export default FindInDatabaseModal;

View File

@@ -4,7 +4,6 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App';
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
import { useStore } from '../store';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
interface ImportPreviewModalProps {
visible: boolean;
@@ -108,7 +107,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
};
const res = await ImportDataWithProgress(buildRpcConnectionConfig(config) as any, dbName, tableName, filePath);
const res = await ImportDataWithProgress(config as any, dbName, tableName, filePath);
if (res.success && res.data) {
setImportResult(res.data);

View File

@@ -1,48 +0,0 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMAuditViewer from "./JVMAuditViewer";
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) =>
selector({
connections: [
{
id: "conn-jvm-1",
name: "orders-jvm",
config: {
host: "localhost",
port: 10990,
jvm: {
preferredMode: "endpoint",
readOnly: false,
},
},
},
],
theme: "light",
}),
}));
describe("JVMAuditViewer", () => {
it("renders a unified JVM workspace audit shell", () => {
const markup = renderToStaticMarkup(
<JVMAuditViewer
tab={{
id: "tab-jvm-audit",
type: "jvm-audit",
title: "[orders-jvm] JVM 审计",
connectionId: "conn-jvm-1",
providerMode: "endpoint",
} as any}
/>,
);
expect(markup).toContain('data-jvm-workspace-shell="true"');
expect(markup).toContain('data-jvm-workspace-hero="true"');
expect(markup).toContain("JVM 变更审计");
expect(markup).toContain("审计记录");
expect(markup).toContain("最近 50 条");
});
});

View File

@@ -1,271 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Card,
Empty,
Select,
Space,
Table,
Tag,
Typography,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import { ReloadOutlined } from "@ant-design/icons";
import { useStore } from "../store";
import type { JVMAuditRecord, TabData } from "../types";
import {
formatJVMAuditResultLabel,
formatJVMActionDisplayText,
resolveJVMAuditResultColor,
} from "../utils/jvmResourcePresentation";
import JVMModeBadge from "./jvm/JVMModeBadge";
import {
getJVMWorkspaceCardStyle,
JVMWorkspaceHero,
JVMWorkspaceShell,
} from "./jvm/JVMWorkspaceLayout";
const { Text } = Typography;
type JVMAuditViewerProps = {
tab: TabData;
};
const LIMIT_OPTIONS = [20, 50, 100, 200];
const normalizeAuditRecords = (value: any): JVMAuditRecord[] => {
if (Array.isArray(value)) {
return value as JVMAuditRecord[];
}
if (Array.isArray(value?.data)) {
return value.data as JVMAuditRecord[];
}
return [];
};
const filterAuditRecordsByMode = (
records: JVMAuditRecord[],
providerMode?: string,
): JVMAuditRecord[] => {
const normalizedMode = String(providerMode || "")
.trim()
.toLowerCase();
if (!normalizedMode) {
return records;
}
return records.filter(
(record) =>
String(record.providerMode || "")
.trim()
.toLowerCase() === normalizedMode,
);
};
const formatTimestamp = (timestamp: number): string => {
if (!timestamp) {
return "-";
}
const normalized = timestamp > 1e12 ? timestamp : timestamp * 1000;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return String(timestamp);
}
return date.toLocaleString("zh-CN", { hour12: false });
};
const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const theme = useStore((state) => state.theme);
const darkMode = theme === "dark";
const [limit, setLimit] = useState(50);
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<JVMAuditRecord[]>([]);
const [error, setError] = useState("");
const columns = useMemo<ColumnsType<JVMAuditRecord>>(
() => [
{
title: "时间",
dataIndex: "timestamp",
key: "timestamp",
width: 180,
render: (value: number) => formatTimestamp(value),
},
{
title: "模式",
dataIndex: "providerMode",
key: "providerMode",
width: 120,
render: (value: string) => (
<JVMModeBadge mode={value || tab.providerMode || "jmx"} />
),
},
{
title: "动作",
dataIndex: "action",
key: "action",
width: 160,
render: (value: string) => formatJVMActionDisplayText(value) || "-",
},
{
title: "资源",
dataIndex: "resourceId",
key: "resourceId",
ellipsis: true,
render: (value: string) => value || "-",
},
{
title: "原因",
dataIndex: "reason",
key: "reason",
ellipsis: true,
render: (value: string) => value || "-",
},
{
title: "来源",
dataIndex: "source",
key: "source",
width: 120,
render: (value?: string) => {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "ai-plan") {
return <Tag color="purple">AI </Tag>;
}
return <Tag></Tag>;
},
},
{
title: "结果",
dataIndex: "result",
key: "result",
width: 140,
render: (value: string) => (
<Tag color={resolveJVMAuditResultColor(value)}>
{formatJVMAuditResultLabel(value)}
</Tag>
),
},
],
[tab.providerMode],
);
const loadRecords = async () => {
if (!connection) {
setLoading(false);
setRecords([]);
setError("连接不存在或已被删除");
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMListAuditRecords !== "function") {
setLoading(false);
setRecords([]);
setError("JVMListAuditRecords 后端方法不可用");
return;
}
setLoading(true);
setError("");
try {
const result = await backendApp.JVMListAuditRecords(connection.id, limit);
if (result?.success === false) {
setRecords([]);
setError(String(result?.message || "读取 JVM 审计记录失败"));
return;
}
setRecords(
filterAuditRecordsByMode(
normalizeAuditRecords(result),
tab.providerMode,
),
);
} catch (err: any) {
setRecords([]);
setError(err?.message || "读取 JVM 审计记录失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRecords();
}, [connection, limit, tab.connectionId]);
if (!connection) {
return (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
const activeMode =
tab.providerMode || connection.config.jvm?.preferredMode || "jmx";
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
return (
<JVMWorkspaceShell darkMode={darkMode}>
<JVMWorkspaceHero
darkMode={darkMode}
eyebrow="JVM Audit"
title="JVM 变更审计"
description={
<>
<Text strong>{connection.name}</Text>
<Text type="secondary"> · {connection.id}</Text>
<Text type="secondary"> · {limit} </Text>
</>
}
badges={<JVMModeBadge mode={activeMode} />}
actions={
<>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => void loadRecords()}
>
</Button>
<Select
size="small"
value={limit}
onChange={setLimit}
options={LIMIT_OPTIONS.map((item) => ({
value: item,
label: `最近 ${item}`,
}))}
style={{ width: 132 }}
/>
</>
}
/>
<Card title="审计记录" variant="borderless" style={cardStyle}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
{error ? <Alert type="error" showIcon message={error} /> : null}
<Table<JVMAuditRecord>
rowKey={(record) =>
`${record.timestamp}-${record.resourceId}-${record.action}`
}
loading={loading}
columns={columns}
dataSource={records}
pagination={false}
locale={{
emptyText: error ? "当前无法加载审计记录" : "暂无审计记录",
}}
scroll={{ x: 960 }}
size="small"
/>
</Space>
</Card>
</JVMWorkspaceShell>
);
};
export default JVMAuditViewer;

View File

@@ -1,946 +0,0 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create } from "react-test-renderer";
import { message } from "antd";
import { beforeEach, describe, expect, it, vi } from "vitest";
import JVMDiagnosticConsole, {
createJVMDiagnosticLocalPendingChunk,
createJVMDiagnosticRunningRecord,
isJVMDiagnosticTerminalPhase,
} from "./JVMDiagnosticConsole";
const baseState = {
connections: [
{
id: "conn-1",
name: "orders-jvm",
config: {
host: "orders.internal",
jvm: {
diagnostic: {
enabled: true,
transport: "agent-bridge",
},
},
},
},
],
jvmDiagnosticDrafts: {},
jvmDiagnosticOutputs: {},
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
};
let mockState: any = baseState;
let registeredCompletionProvider: any = null;
let registeredDiagnosticChunkHandler: any = null;
const mockBackendApp = {
JVMListDiagnosticAuditRecords: vi.fn(),
JVMExecuteDiagnosticCommand: vi.fn(),
};
const mockMonaco = {
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(
startLineNumber: number,
startColumn: number,
endLineNumber: number,
endColumn: number,
) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
KeyMod: { CtrlCmd: 2048 },
KeyCode: { Enter: 3 },
editor: {
setTheme: vi.fn(),
},
languages: {
CompletionItemKind: {
Keyword: 1,
Snippet: 2,
Value: 3,
},
CompletionItemInsertTextRule: {
InsertAsSnippet: 4,
},
register: vi.fn(),
registerCompletionItemProvider: vi.fn((language: string, provider: any) => {
if (language === "jvm-diagnostic") {
registeredCompletionProvider = provider;
}
return { dispose: vi.fn() };
}),
},
};
const mockEditor = {
addCommand: vi.fn(),
};
vi.mock("@monaco-editor/react", () => ({
default: ({
beforeMount,
language,
onMount,
value,
}: {
beforeMount?: (monaco: any) => void;
language?: string;
onMount?: (editor: any, monaco: any) => void;
value?: string;
}) => {
beforeMount?.(mockMonaco);
onMount?.(mockEditor, mockMonaco);
return (
<div
data-before-mount={beforeMount ? "true" : "false"}
data-monaco-editor-mock="true"
data-language={language}
>
{value}
</div>
);
},
}));
vi.mock("../../wailsjs/runtime", () => ({
EventsOn: vi.fn((_eventName: string, handler: any) => {
registeredDiagnosticChunkHandler = handler;
return vi.fn();
}),
}));
vi.mock("@ant-design/icons", () => {
const Icon = () => <span />;
return {
ClearOutlined: Icon,
HistoryOutlined: Icon,
PauseCircleOutlined: Icon,
PlayCircleOutlined: Icon,
ReloadOutlined: Icon,
RocketOutlined: Icon,
ToolOutlined: Icon,
};
});
vi.mock("antd", () => {
const passthrough = ({ children, style }: any) => <div style={style}>{children}</div>;
const Text = ({ children, style }: any) => <span style={style}>{children}</span>;
const Paragraph = ({ children, style }: any) => <p style={style}>{children}</p>;
const Title = ({ children, style }: any) => <h3 style={style}>{children}</h3>;
const Empty = ({ description }: any) => <div>{description}</div>;
Empty.PRESENTED_IMAGE_SIMPLE = "simple";
const List = ({ dataSource = [], renderItem }: any) => (
<div>{dataSource.map((item: any, index: number) => renderItem(item, index))}</div>
);
List.Item = ({ children, style }: any) => <div style={style}>{children}</div>;
const Typography = { Text, Paragraph, Title };
return {
Alert: ({ message: alertMessage, description, style }: any) => (
<div style={style}>{alertMessage}{description}</div>
),
Button: ({ children, onClick, disabled, style }: any) => <button onClick={onClick} disabled={disabled} style={style}>{children}</button>,
Card: ({ children, title, style }: any) => <section style={style}>{title}{children}</section>,
Empty,
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
List,
Space: passthrough,
Tag: ({ children, style }: any) => <span style={style}>{children}</span>,
Typography,
message: {
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
};
});
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) => selector(mockState),
}));
describe("JVMDiagnosticConsole", () => {
beforeEach(() => {
registeredCompletionProvider = null;
registeredDiagnosticChunkHandler = null;
mockState = {
...baseState,
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
};
mockBackendApp.JVMListDiagnosticAuditRecords.mockResolvedValue({
success: true,
data: [],
});
mockBackendApp.JVMExecuteDiagnosticCommand.mockReset();
vi.mocked(message.success).mockClear();
vi.mocked(message.warning).mockClear();
vi.mocked(message.info).mockClear();
(globalThis as any).window = {
...(globalThis as any).window,
go: { app: { App: mockBackendApp } },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
mockMonaco.editor.setTheme.mockClear();
mockMonaco.languages.register.mockClear();
mockMonaco.languages.registerCompletionItemProvider.mockClear();
mockEditor.addCommand.mockClear();
});
it("builds local pending output and history while a command is waiting for backend events", () => {
const chunk = createJVMDiagnosticLocalPendingChunk({
sessionId: "session-1",
commandId: "cmd-1",
command: "thread -n 5",
});
const record = createJVMDiagnosticRunningRecord({
connectionId: "conn-1",
sessionId: "session-1",
commandId: "cmd-1",
transport: "arthas-tunnel",
command: "thread -n 5",
source: "manual",
reason: "排查线程",
});
expect(chunk).toMatchObject({
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
});
expect(chunk.content).toContain("thread -n 5");
expect(record).toMatchObject({
connectionId: "conn-1",
sessionId: "session-1",
commandId: "cmd-1",
transport: "arthas-tunnel",
command: "thread -n 5",
status: "running",
reason: "排查线程",
});
expect(isJVMDiagnosticTerminalPhase("completed")).toBe(true);
expect(isJVMDiagnosticTerminalPhase("failed")).toBe(true);
expect(isJVMDiagnosticTerminalPhase("running")).toBe(false);
});
it("keeps a stable workbench shell and hides command inputs before session creation", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("开始一次诊断");
expect(markup).toContain("命令输入将在会话建立后显示");
expect(markup).toContain("先建立会话,再显示命令编辑器和模板");
expect(markup).toContain("会话与能力");
expect(markup).toContain("审计历史");
expect(markup).not.toContain("命令模板");
expect(markup).not.toContain("实时输出");
expect(markup).not.toContain('data-monaco-editor-mock="true"');
});
it("shows command input, reason field, and presets after a session exists", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
reason: "排查 CPU 线程",
},
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("overflow:auto");
expect(markup).toContain("JVM 诊断工作台");
expect(markup).toContain("会话与能力");
expect(markup).toContain("实时输出");
expect(markup).toContain("审计历史");
expect(markup.indexOf("命令输入")).toBeGreaterThanOrEqual(0);
expect(markup).toContain("诊断命令");
expect(markup).toContain("诊断原因(可选)");
expect(markup).toContain("用于审计记录");
expect(markup.indexOf("命令输入")).toBeLessThan(markup.indexOf("实时输出"));
expect(markup).toContain("观察类命令");
expect(markup).toContain("thread");
expect(markup).toContain("执行命令");
expect(markup).toContain('data-monaco-editor-mock="true"');
expect(markup).toContain('data-language="jvm-diagnostic"');
});
it("redacts sensitive diagnostic output in the rendered console", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "watch com.foo.SecretService read '{returnObj}'",
},
},
jvmDiagnosticOutputs: {
"tab-1": [
{
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "password=secret-token\napiKey: api-key-secret",
},
],
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("password=********");
expect(markup).toContain("apiKey: ********");
expect(markup).not.toContain("secret-token");
expect(markup).not.toContain("api-key-secret");
});
it("uses the same styled editor shell and registers command completion before mount", () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thr",
reason: "排查 CPU 线程",
},
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain(
'data-jvm-diagnostic-command-editor-shell="true"',
);
expect(markup).toContain('data-before-mount="true"');
expect(markup).toContain("border-radius:14px");
expect(registeredCompletionProvider).toBeTruthy();
const result = registeredCompletionProvider.provideCompletionItems(
{
getValueInRange: () => "thr",
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 4 }),
},
{ lineNumber: 1, column: 4 },
);
expect(result.suggestions).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: "thread",
insertText: "thread ",
}),
]),
);
});
it("redacts failed diagnostic event content before storing and alerting", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts successful diagnostic warning messages", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
mockBackendApp.JVMExecuteDiagnosticCommand.mockResolvedValue({
success: true,
message: "api_key=query-secret",
});
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
expect(message.warning).toHaveBeenCalledWith("api_key=********");
expect(message.warning).not.toHaveBeenCalledWith(
expect.stringContaining("query-secret"),
);
});
it("redacts successful diagnostic warning messages with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let resolveCommand: (value: any) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((resolve) => {
resolveCommand = resolve;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
resolveCommand({
success: true,
message: "def456\n-----END PRIVATE KEY-----",
});
});
expect(JSON.stringify((message.warning as any).mock.calls)).not.toContain(
"def456",
);
});
it("keeps diagnostic redaction state after clearing visible output", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIV",
},
});
});
const clearButton = renderer.root
.findAllByType("button")
.find((button: any) => button.children.includes("清空输出"));
await act(async () => {
clearButton.props.onClick();
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "failed",
content: "ATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(mockState.clearJVMDiagnosticOutput).toHaveBeenCalledWith("tab-1");
expect(JSON.stringify(appendedChunks)).not.toContain("ATE KEY");
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
});
it("redacts frontend fallback errors with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
it("keeps diagnostic redaction state after local completion fallback", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let resolveCommand: (value: any) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((resolve) => {
resolveCommand = resolve;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
resolveCommand({ success: true });
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "completed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
it("redacts terminal-seen execute errors with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "completed",
content: "still waiting for execute call",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts execute errors after a real failed terminal event closes the active PEM stream", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts delayed failed terminal events after frontend fallback closes the active PEM stream", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +0,0 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMMonitoringDashboard from "./JVMMonitoringDashboard";
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) =>
selector({
theme: "light",
connections: [
{
id: "conn-1",
name: "orders-jvm",
config: {
host: "orders.internal",
port: 9010,
jvm: {
preferredMode: "jmx",
allowedModes: ["jmx"],
},
},
},
],
}),
}));
describe("JVMMonitoringDashboard", () => {
it("shows start action and empty-state guidance before monitoring starts", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-1",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
expect(markup).toContain("开始监控");
expect(markup).toContain("当前尚未开始持续监控");
expect(markup).toContain("堆内存");
expect(markup).toContain("暂无堆内存采样数据");
expect(markup).not.toContain("暂无 Heap 采样数据");
expect(markup).not.toContain("当前 provider 未提供 Heap 指标");
});
it("renders a dedicated vertical scroll shell for tall monitoring content", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-scroll",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
expect(markup).toContain('data-jvm-monitoring-dashboard-scroll-shell="true"');
expect(markup).toContain("height:100%");
expect(markup).toContain("overflow-y:auto");
});
it("stacks monitoring charts before detail panels so charts keep full content width", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-layout",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
expect(markup).toContain('data-jvm-monitoring-content-stack="true"');
expect(markup).toContain("gap:24px");
expect(markup).not.toContain("minmax(min(100%, 320px), 1fr)");
});
});

View File

@@ -1,392 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import { Alert, Button, Card, Empty, Space, Spin, Tag, Typography } from "antd";
import { DashboardOutlined, PauseCircleOutlined, PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons";
import { useStore } from "../store";
import type { JVMMonitoringSessionState, TabData } from "../types";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import {
buildMonitoringAvailabilityText,
normalizeMonitoringProviderMode,
type JVMMonitoringProviderMode,
} from "../utils/jvmMonitoringPresentation";
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
import JVMMonitoringCharts from "./jvm/JVMMonitoringCharts";
import JVMMonitoringDetailPanel from "./jvm/JVMMonitoringDetailPanel";
import JVMMonitoringStatusCards from "./jvm/JVMMonitoringStatusCards";
const { Paragraph, Text, Title } = Typography;
const POLL_INTERVAL_MS = 2000;
type JVMMonitoringDashboardProps = {
tab: TabData;
};
const isMonitoringSessionMissing = (message: string): boolean =>
/monitoring session not found/i.test(String(message || ""));
const createEmptySession = (
connectionId: string,
providerMode: JVMMonitoringProviderMode,
): JVMMonitoringSessionState => ({
connectionId,
providerMode,
running: false,
points: [],
recentGcEvents: [],
availableMetrics: [],
missingMetrics: [],
providerWarnings: [],
});
const normalizeMonitoringSession = (
payload: any,
connectionId: string,
providerMode: JVMMonitoringProviderMode,
): JVMMonitoringSessionState => ({
connectionId: String(payload?.connectionId || connectionId),
providerMode: normalizeMonitoringProviderMode(payload?.providerMode, providerMode),
running: payload?.running === true,
points: Array.isArray(payload?.points) ? payload.points : [],
recentGcEvents: Array.isArray(payload?.recentGcEvents) ? payload.recentGcEvents : [],
availableMetrics: Array.isArray(payload?.availableMetrics)
? payload.availableMetrics
: [],
missingMetrics: Array.isArray(payload?.missingMetrics) ? payload.missingMetrics : [],
providerWarnings: Array.isArray(payload?.providerWarnings)
? payload.providerWarnings
: [],
});
const resolveBackendApp = () =>
typeof window === "undefined" ? undefined : (window as any).go?.app?.App;
const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab }) => {
const theme = useStore((state) => state.theme);
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const darkMode = theme === "dark";
const providerMode = normalizeMonitoringProviderMode(
tab.providerMode,
normalizeMonitoringProviderMode(connection?.config.jvm?.preferredMode, "jmx"),
);
const [session, setSession] = useState<JVMMonitoringSessionState>(() =>
createEmptySession(tab.connectionId, providerMode),
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [actionLoading, setActionLoading] = useState(false);
const [pollSeed, setPollSeed] = useState(0);
const rpcConnectionConfig = useMemo(() => {
if (!connection) {
return null;
}
return buildRpcConnectionConfig(connection.config, {
database: "",
jvm: {
...(connection.config.jvm || {}),
preferredMode: providerMode,
allowedModes: [providerMode],
},
});
}, [connection, providerMode]);
const latestPoint = useMemo(() => {
const points = session.points || [];
return points.length > 0 ? points[points.length - 1] : undefined;
}, [session.points]);
useEffect(() => {
setSession(createEmptySession(tab.connectionId, providerMode));
}, [tab.connectionId, providerMode]);
useEffect(() => {
if (!connection || !rpcConnectionConfig) {
setLoading(false);
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const backendApp = resolveBackendApp();
const poll = async () => {
if (cancelled) {
return;
}
setLoading(true);
if (typeof backendApp?.JVMGetMonitoringHistory !== "function") {
setError("JVMGetMonitoringHistory 后端方法不可用");
setLoading(false);
return;
}
try {
const result = await backendApp.JVMGetMonitoringHistory(
rpcConnectionConfig,
providerMode,
);
if (cancelled) {
return;
}
if (result?.success === false) {
const message = String(result?.message || "读取监控历史失败");
if (isMonitoringSessionMissing(message)) {
setSession(createEmptySession(tab.connectionId, providerMode));
setError("");
setLoading(false);
return;
}
throw new Error(message);
}
const nextSession = normalizeMonitoringSession(
result?.data,
tab.connectionId,
providerMode,
);
setSession(nextSession);
setError("");
setLoading(false);
if (nextSession.running) {
timer = setTimeout(poll, POLL_INTERVAL_MS);
}
} catch (fetchError: any) {
if (!cancelled) {
setError(fetchError?.message || "读取监控历史失败");
setLoading(false);
}
}
};
void poll();
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [connection, providerMode, rpcConnectionConfig, tab.connectionId, pollSeed]);
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 80 }} />;
}
const backendApp = resolveBackendApp();
const availabilityText = buildMonitoringAvailabilityText(session);
const modeMeta = resolveJVMModeMeta(providerMode);
const emptyState = !session.running && (session.points || []).length === 0;
const handleStart = async () => {
if (!rpcConnectionConfig || typeof backendApp?.JVMStartMonitoring !== "function") {
setError("JVMStartMonitoring 后端方法不可用");
return;
}
setActionLoading(true);
setError("");
try {
const result = await backendApp.JVMStartMonitoring(rpcConnectionConfig);
if (result?.success === false) {
throw new Error(String(result?.message || "开始监控失败"));
}
setSession(
normalizeMonitoringSession(result?.data, tab.connectionId, providerMode),
);
setPollSeed((current) => current + 1);
} catch (startError: any) {
setError(startError?.message || "开始监控失败");
} finally {
setActionLoading(false);
}
};
const handleStop = async () => {
if (!rpcConnectionConfig || typeof backendApp?.JVMStopMonitoring !== "function") {
setError("JVMStopMonitoring 后端方法不可用");
return;
}
setActionLoading(true);
setError("");
try {
const result = await backendApp.JVMStopMonitoring(
rpcConnectionConfig,
providerMode,
);
if (result?.success === false) {
throw new Error(String(result?.message || "停止监控失败"));
}
setSession((current) => ({ ...current, running: false }));
setPollSeed((current) => current + 1);
} catch (stopError: any) {
setError(stopError?.message || "停止监控失败");
} finally {
setActionLoading(false);
}
};
return (
<div
className="jvm-monitoring-dashboard-scroll-shell"
data-jvm-monitoring-dashboard-scroll-shell="true"
style={{
height: "100%",
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
padding: 20,
display: "grid",
gap: 16,
alignContent: "start",
background: darkMode ? "#141414" : "#f5f7fb",
}}
>
<Card variant="borderless" style={{ borderRadius: 12 }}>
<Space
direction="vertical"
size={12}
style={{ width: "100%", alignItems: "stretch" }}
>
<Space size={12} wrap style={{ justifyContent: "space-between" }}>
<div>
<Title level={3} style={{ margin: 0 }}>
<DashboardOutlined style={{ color: "#1677ff", marginRight: 8 }} />
JVM
</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
<Text type="secondary">
{" "}
· {connection.config.host}:{connection.config.port}
</Text>
</Paragraph>
</div>
<Space wrap>
<Tag color={modeMeta.color} style={{ marginInlineEnd: 0 }}>
{modeMeta.label}
</Tag>
{session.running ? (
<Tag color="green"></Tag>
) : (
<Tag></Tag>
)}
<Button
icon={<ReloadOutlined />}
onClick={() => setPollSeed((current) => current + 1)}
>
</Button>
{session.running ? (
<Button
danger
type="primary"
icon={<PauseCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStop()}
>
</Button>
) : (
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStart()}
>
</Button>
)}
</Space>
</Space>
{(session.missingMetrics?.length || session.providerWarnings?.length) ? (
<Alert
type="warning"
showIcon
message="监控能力存在降级"
description={availabilityText}
/>
) : null}
{error ? <Alert type="error" showIcon message={error} /> : null}
</Space>
</Card>
{loading && emptyState ? (
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
<Spin />
</div>
) : null}
{emptyState ? (
<div
data-jvm-monitoring-content-stack="true"
style={{
display: "grid",
gap: 24,
alignItems: "start",
}}
>
<Card variant="borderless" style={{ borderRadius: 12 }}>
<Empty
description="当前尚未开始持续监控"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Paragraph type="secondary" style={{ maxWidth: 520, margin: "0 auto 16px" }}>
GoNavi
</Paragraph>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStart()}
>
</Button>
</Empty>
</Card>
<JVMMonitoringCharts
points={session.points || []}
session={session}
darkMode={darkMode}
/>
</div>
) : (
<div
data-jvm-monitoring-content-stack="true"
style={{
display: "grid",
gap: 24,
alignItems: "start",
}}
>
<JVMMonitoringStatusCards
latestPoint={latestPoint}
session={session}
darkMode={darkMode}
/>
<JVMMonitoringCharts
points={session.points || []}
session={session}
darkMode={darkMode}
/>
<JVMMonitoringDetailPanel
session={session}
latestPoint={latestPoint}
darkMode={darkMode}
/>
</div>
)}
</div>
);
};
export default JVMMonitoringDashboard;

View File

@@ -1,65 +0,0 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMOverview from "./JVMOverview";
vi.mock("../../wailsjs/go/app/App", () => ({
JVMProbeCapabilities: vi.fn(),
}));
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) =>
selector({
connections: [
{
id: "conn-jvm-1",
name: "orders-jvm",
config: {
host: "localhost",
port: 10990,
jvm: {
preferredMode: "jmx",
allowedModes: ["jmx", "endpoint", "agent"],
readOnly: true,
environment: "dev",
endpoint: {
enabled: true,
baseUrl: "http://localhost:8080/actuator",
},
agent: {
enabled: true,
baseUrl: "http://localhost:8563",
},
},
},
},
],
theme: "light",
}),
}));
describe("JVMOverview", () => {
it("renders a unified JVM workspace overview shell", () => {
const markup = renderToStaticMarkup(
<JVMOverview
tab={{
id: "tab-jvm-overview",
type: "jvm-overview",
title: "[orders-jvm] JVM 概览",
connectionId: "conn-jvm-1",
providerMode: "jmx",
} as any}
/>,
);
expect(markup).toContain('data-jvm-workspace-shell="true"');
expect(markup).toContain('data-jvm-workspace-hero="true"');
expect(markup).toContain("JVM 运行时概览");
expect(markup).toContain("连接摘要");
expect(markup).toContain("模式能力");
expect(markup).toContain("JMX 地址");
expect(markup).toContain("Endpoint");
expect(markup).toContain("Agent");
});
});

View File

@@ -1,239 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Card,
Descriptions,
Empty,
Skeleton,
Space,
Tag,
Typography,
} from "antd";
import { useStore } from "../store";
import { JVMProbeCapabilities } from "../../wailsjs/go/app/App";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
import type { JVMCapability, TabData } from "../types";
import JVMModeBadge from "./jvm/JVMModeBadge";
import {
getJVMWorkspaceCardStyle,
JVMWorkspaceHero,
JVMWorkspaceShell,
} from "./jvm/JVMWorkspaceLayout";
const { Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
type JVMOverviewProps = {
tab: TabData;
};
const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const theme = useStore((state) => state.theme);
const darkMode = theme === "dark";
const providerMode =
tab.providerMode || connection?.config.jvm?.preferredMode || "jmx";
const readOnly = connection?.config.jvm?.readOnly !== false;
const allowedModes = connection?.config.jvm?.allowedModes || [];
const [capabilities, setCapabilities] = useState<JVMCapability[]>([]);
const [capabilityLoading, setCapabilityLoading] = useState(true);
const [capabilityError, setCapabilityError] = useState("");
const endpointSummary = useMemo(() => {
if (!connection?.config.jvm?.endpoint) {
return "";
}
const endpoint = connection.config.jvm.endpoint;
if (!endpoint.enabled && !endpoint.baseUrl) {
return "";
}
return endpoint.baseUrl || "已启用";
}, [connection]);
const agentSummary = useMemo(() => {
if (!connection?.config.jvm?.agent) {
return "";
}
const agent = connection.config.jvm.agent;
if (!agent.enabled && !agent.baseUrl) {
return "";
}
return agent.baseUrl || "已启用";
}, [connection]);
const allowedModeSummary = useMemo(() => {
const items = allowedModes.length > 0 ? allowedModes : ["jmx"];
return items.map((item) => resolveJVMModeMeta(item).label).join("、");
}, [allowedModes]);
useEffect(() => {
if (!connection) {
setCapabilities([]);
setCapabilityError("连接不存在或已被删除");
setCapabilityLoading(false);
return;
}
let cancelled = false;
const loadCapabilities = async () => {
setCapabilityLoading(true);
setCapabilityError("");
try {
const result = await JVMProbeCapabilities(
buildRpcConnectionConfig(connection.config, { database: "" }) as any,
);
if (cancelled) {
return;
}
if (result?.success === false) {
setCapabilities([]);
setCapabilityError(
String(result?.message || "读取 JVM 模式能力失败"),
);
return;
}
setCapabilities(
Array.isArray(result?.data) ? (result.data as JVMCapability[]) : [],
);
} catch (error: any) {
if (!cancelled) {
setCapabilities([]);
setCapabilityError(error?.message || "读取 JVM 模式能力失败");
}
} finally {
if (!cancelled) {
setCapabilityLoading(false);
}
}
};
void loadCapabilities();
return () => {
cancelled = true;
};
}, [connection]);
if (!connection) {
return (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
const jmxHost = connection.config.jvm?.jmx?.host || connection.config.host;
const jmxPort = connection.config.jvm?.jmx?.port || connection.config.port;
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
return (
<JVMWorkspaceShell darkMode={darkMode}>
<JVMWorkspaceHero
darkMode={darkMode}
eyebrow="JVM Runtime"
title="JVM 运行时概览"
description={
<>
<Text strong>{connection.name}</Text>
<Text type="secondary">
{" "}
· {connection.config.host}:{connection.config.port}
</Text>
</>
}
badges={
<>
<JVMModeBadge mode={providerMode} />
<Tag color={readOnly ? "blue" : "red"}>
{readOnly ? "只读连接" : "可写连接"}
</Tag>
<Tag>{connection.config.jvm?.environment || "dev"}</Tag>
</>
}
/>
<Card title="连接摘要" variant="borderless" style={cardStyle}>
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
<Descriptions.Item label="当前模式">
{resolveJVMModeMeta(providerMode).label}
</Descriptions.Item>
<Descriptions.Item label="允许模式">
{allowedModeSummary}
</Descriptions.Item>
<Descriptions.Item label="JMX 地址">{`${jmxHost}:${jmxPort}`}</Descriptions.Item>
<Descriptions.Item label="Endpoint">
{endpointSummary || "未配置"}
</Descriptions.Item>
<Descriptions.Item label="Agent">
{agentSummary || "未配置"}
</Descriptions.Item>
<Descriptions.Item label="资源浏览">
{"通过侧边栏展开模式节点后懒加载"}
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="模式能力" variant="borderless" style={cardStyle}>
{capabilityLoading ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : capabilityError ? (
<Alert
type="error"
showIcon
message="读取 JVM 模式能力失败"
description={
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{capabilityError}
</span>
}
/>
) : capabilities.length === 0 ? (
<Empty description="暂无模式能力数据" />
) : (
<Space direction="vertical" size={12} style={{ width: "100%" }}>
{capabilities.map((capability) => (
<div
key={capability.mode}
style={{
border: "1px solid rgba(5, 5, 5, 0.08)",
borderRadius: 8,
padding: 12,
}}
>
<Space size={8} wrap>
<JVMModeBadge mode={capability.mode} />
<Tag color={capability.canBrowse ? "green" : "default"}>
{capability.canBrowse ? "可浏览" : "不可浏览"}
</Tag>
<Tag color={capability.canWrite ? "red" : "blue"}>
{capability.canWrite ? "可写" : "只读"}
</Tag>
<Tag color={capability.canPreview ? "gold" : "default"}>
{capability.canPreview ? "支持预览" : "不支持预览"}
</Tag>
</Space>
{capability.reason ? (
<Text
type="secondary"
style={{
display: "block",
marginTop: 8,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{capability.reason}
</Text>
) : null}
</div>
))}
</Space>
)}
</Card>
</JVMWorkspaceShell>
);
};
export default JVMOverview;

View File

@@ -1,563 +0,0 @@
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import JVMResourceBrowser from "./JVMResourceBrowser";
import type { JVMValueSnapshot } from "../types";
const storeState = vi.hoisted(() => ({
connections: [
{
id: "conn-jvm-writable",
name: "orders-jvm",
config: {
host: "127.0.0.1",
user: "jmx-user",
port: 9010,
type: "jvm",
jvm: {
preferredMode: "jmx",
readOnly: false,
jmx: {
password: "initial-jmx-secret",
},
},
},
},
],
addTab: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
theme: "light",
}));
const backendApp = vi.hoisted(() => ({
JVMGetValue: vi.fn(),
JVMPreviewChange: vi.fn(),
JVMApplyChange: vi.fn(),
}));
vi.mock("@monaco-editor/react", () => ({
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
}));
vi.mock("@ant-design/icons", () => ({
FileSearchOutlined: () => <span />,
ReloadOutlined: () => <span />,
RobotOutlined: () => <span />,
}));
vi.mock("antd", () => {
const Text = ({ children }: any) => <span>{children}</span>;
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
<button
type="button"
data-button-type={type}
disabled={disabled || loading}
onClick={onClick}
{...rest}
>
{children}
</button>
);
const Card = ({ children, title }: any) => (
<section>
<h2>{title}</h2>
{children}
</section>
);
const Descriptions: any = ({ children }: any) => <dl>{children}</dl>;
Descriptions.Item = ({ children, label }: any) => (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
const Input: any = ({ value, onChange, placeholder }: any) => (
<input value={value} onChange={onChange} placeholder={placeholder} />
);
Input.TextArea = ({ value, onChange }: any) => (
<textarea value={value} onChange={onChange} />
);
return {
Alert: ({ message }: any) => <div role="alert">{message}</div>,
Button,
Card,
Descriptions,
Empty: ({ description }: any) => <div>{description}</div>,
Input,
Skeleton: () => <div>loading</div>,
Space: ({ children }: any) => <div>{children}</div>,
Tag: ({ children }: any) => <span>{children}</span>,
Typography: { Text },
};
});
vi.mock("../store", () => {
const useStore = (selector: (state: typeof storeState) => any) => selector(storeState);
useStore.getState = () => storeState;
return { useStore };
});
vi.mock("./jvm/JVMModeBadge", () => ({
default: ({ mode }: { mode: string }) => <span>{mode}</span>,
}));
vi.mock("./jvm/JVMWorkspaceLayout", () => ({
getJVMWorkspaceCardStyle: () => ({}),
JVMWorkspaceHero: ({ actions, badges, description, title }: any) => (
<header>
<h1>{title}</h1>
{description}
{badges}
{actions}
</header>
),
JVMWorkspaceShell: ({ children }: any) => <main>{children}</main>,
}));
vi.mock("./jvm/JVMChangePreviewModal", () => ({
default: ({ open, onConfirm }: any) =>
open ? <button type="button" onClick={onConfirm}></button> : null,
}));
const writableTab = {
id: "tab-jvm-resource",
type: "jvm-resource",
title: "[orders-jvm] JVM 资源",
connectionId: "conn-jvm-writable",
providerMode: "jmx",
resourcePath: "jmx:/attribute/app/Mode",
resourceKind: "attribute",
} as any;
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === "string" ? item : textContent(item)))
.join("");
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === "button" && textContent(node).includes(text))[0];
const waitForEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe("JVMResourceBrowser interactions", () => {
beforeEach(() => {
storeState.connections = [
{
id: "conn-jvm-writable",
name: "orders-jvm",
config: {
host: "127.0.0.1",
user: "jmx-user",
port: 9010,
type: "jvm",
jvm: {
preferredMode: "jmx",
readOnly: false,
jmx: {
password: "initial-jmx-secret",
},
},
},
},
];
const snapshot: JVMValueSnapshot = {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
version: "v1",
value: "cold",
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "warm" },
},
],
};
backendApp.JVMGetValue.mockResolvedValue({ success: true, data: snapshot });
backendApp.JVMPreviewChange.mockResolvedValue({
allowed: true,
requiresConfirmation: true,
confirmationToken: "token-from-preview",
summary: "设置 Mode",
riskLevel: "high",
before: snapshot,
after: { ...snapshot, value: "warm", version: "v2" },
});
backendApp.JVMApplyChange.mockResolvedValue({
success: true,
data: {
status: "applied",
updatedValue: { ...snapshot, value: "warm", version: "v2" },
},
});
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
go: {
app: {
App: backendApp,
},
},
});
});
afterEach(() => {
backendApp.JVMGetValue.mockReset();
backendApp.JVMPreviewChange.mockReset();
backendApp.JVMApplyChange.mockReset();
vi.unstubAllGlobals();
});
it("applies the latest successful preview request even when the draft is edited afterward", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
});
const payloadEditor = () => renderer!.root.findByType("textarea");
await act(async () => {
payloadEditor().props.onChange({ target: { value: '{"value":"previewed"}' } });
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
await act(async () => {
payloadEditor().props.onChange({ target: { value: '{"value":"edited-after-preview"}' } });
});
await act(async () => {
findButton(renderer!, "确认执行").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMApplyChange).toHaveBeenCalledTimes(1);
expect(backendApp.JVMApplyChange.mock.calls[0][0]).toBe(
backendApp.JVMPreviewChange.mock.calls[0][0],
);
expect(backendApp.JVMApplyChange.mock.calls[0][1]).toMatchObject({
action: "set",
confirmationToken: "token-from-preview",
payload: { value: "previewed" },
});
});
it("does not let a stale snapshot resource id override the current resource preview", async () => {
backendApp.JVMGetValue.mockResolvedValueOnce({
success: true,
data: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
version: "v1",
value: "cold",
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "warm" },
},
],
} as JVMValueSnapshot,
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
});
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMPreviewChange.mock.calls[backendApp.JVMPreviewChange.mock.calls.length - 1]?.[1]).toMatchObject({
resourceId: "jmx:/attribute/app/OtherMode",
});
});
it("ignores stale preview responses after the resource context changes", async () => {
let resolvePreview: (value: any) => void = () => {};
backendApp.JVMPreviewChange.mockReturnValueOnce(
new Promise((resolve) => {
resolvePreview = resolve;
}),
);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
resolvePreview({
allowed: true,
requiresConfirmation: true,
confirmationToken: "stale-token",
summary: "旧预览",
riskLevel: "high",
before: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "cold",
},
after: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "warm",
},
});
});
await waitForEffects();
expect(findButton(renderer!, "确认执行")).toBeUndefined();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after the resource context changes", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
findButton(renderer!, "确认执行").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after the connection config changes", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
storeState.connections = [
{
...storeState.connections[0],
config: {
...storeState.connections[0].config,
jvm: {
...storeState.connections[0].config.jvm,
readOnly: true,
},
},
},
];
await act(async () => {
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
});
const confirmButton = findButton(renderer!, "确认执行");
if (confirmButton) {
await act(async () => {
confirmButton.props.onClick();
});
}
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after JVM credentials change", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
storeState.connections = [
{
...storeState.connections[0],
config: {
...storeState.connections[0].config,
jvm: {
...storeState.connections[0].config.jvm,
jmx: {
...storeState.connections[0].config.jvm.jmx,
password: "rotated-jmx-secret",
},
},
},
},
];
await act(async () => {
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
});
const confirmButton = findButton(renderer!, "确认执行");
if (confirmButton) {
await act(async () => {
confirmButton.props.onClick();
});
}
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("does not seed sensitive payload examples into the draft editor", async () => {
backendApp.JVMGetValue.mockResolvedValueOnce({
success: true,
data: {
resourceId: "jmx:/attribute/app/Password",
kind: "attribute",
format: "string",
version: "v1",
value: "secret-token",
sensitive: true,
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "secret-token" },
},
],
} as JVMValueSnapshot,
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/Password",
}}
/>,
);
});
await waitForEffects();
expect(renderer!.root.findByType("textarea").props.value).not.toContain("secret-token");
});
});

View File

@@ -1,118 +0,0 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import JVMResourceBrowser from './JVMResourceBrowser';
vi.mock('@monaco-editor/react', () => ({
default: ({ language, value }: { language?: string; value?: string }) => (
<div data-monaco-editor-mock="true" data-language={language}>
{value}
</div>
),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
connections: [
{
id: 'conn-jvm-1',
name: 'localhost',
config: {
host: 'localhost',
jvm: {
preferredMode: 'jmx',
readOnly: true,
},
},
},
{
id: 'conn-jvm-2',
name: 'writable-jvm',
config: {
host: 'localhost',
jvm: {
preferredMode: 'jmx',
readOnly: false,
},
},
},
],
addTab: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}),
}));
vi.mock('./jvm/JVMModeBadge', () => ({
default: ({ mode }: { mode: string }) => <span>{mode}</span>,
}));
vi.mock('./jvm/JVMChangePreviewModal', () => ({
default: () => null,
}));
describe('JVMResourceBrowser layout', () => {
it('renders a dedicated vertical scroll shell for tall snapshot content', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-1',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-1',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).toContain('data-jvm-resource-browser-scroll-shell="true"');
expect(markup).toContain('data-jvm-workspace-shell="true"');
expect(markup).toContain('data-jvm-workspace-hero="true"');
expect(markup).toContain('data-jvm-resource-workbench="true"');
expect(markup).toContain('height:100%');
expect(markup).toContain('overflow-y:auto');
expect(markup).toContain('grid-template-columns:minmax(0, 1fr) minmax(360px, 440px)');
});
it('shows the draft action field with a Chinese label', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-2',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-2',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).toContain('动作');
expect(markup).not.toContain('>Action<');
});
it('hides the change draft form entirely for read-only JVM connections', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-3',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-1',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).not.toContain('变更草稿');
expect(markup).not.toContain('预览变更');
expect(markup).not.toContain('Payload(JSON)');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import React, { useRef, useEffect } from 'react';
import { Table, Tag, Button, Tooltip, Empty } from 'antd';
import { ClearOutlined, CloseOutlined, BugOutlined, ClockCircleOutlined } from '@ant-design/icons';
import { Table, Tag, Button, Tooltip } from 'antd';
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { normalizeOpacityForPlatform } from '../utils/appearance';
interface LogPanelProps {
height: number;
@@ -16,8 +16,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const opacity = normalizeOpacityForPlatform(appearance.opacity);
// Background Helper
const getBg = (darkHex: string) => {
@@ -29,25 +28,10 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const bgMain = getBg('#1d1d1d');
const shellOpacity = darkMode ? Math.max(0.18, opacity * 0.82) : Math.max(0.28, opacity * 0.92);
const shellOpacityStrong = darkMode ? Math.max(0.22, opacity * 0.9) : Math.max(0.34, opacity * 0.96);
const panelDividerColor = darkMode
? `rgba(255,255,255,${Math.max(0.04, opacity * 0.10)})`
: `rgba(0,0,0,${Math.max(0.04, opacity * 0.08)})`;
const panelDividerColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const panelMutedTextColor = darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(0,0,0,0.58)';
const panelShellBg = darkMode
? `linear-gradient(180deg, rgba(15,20,30,${shellOpacity}) 0%, rgba(9,13,22,${shellOpacityStrong}) 100%)`
: `linear-gradient(180deg, rgba(255,255,255,${shellOpacityStrong}) 0%, rgba(246,248,252,${shellOpacity}) 100%)`;
const panelAccentColor = darkMode ? '#ffd666' : '#1677ff';
const panelShadow = darkMode
? `0 12px 28px rgba(0,0,0,${Math.max(0.05, opacity * 0.18)})`
: `0 12px 24px rgba(15,23,42,${Math.max(0.02, opacity * 0.08)})`;
const logScrollbarThumb = darkMode
? `rgba(255, 255, 255, ${Math.max(0.18, opacity * 0.34)})`
: `rgba(0, 0, 0, ${Math.max(0.12, opacity * 0.26)})`;
const logScrollbarThumbHover = darkMode
? `rgba(255, 255, 255, ${Math.max(0.28, opacity * 0.48)})`
: `rgba(0, 0, 0, ${Math.max(0.18, opacity * 0.36)})`;
const logScrollbarThumb = darkMode ? 'rgba(255, 255, 255, 0.34)' : 'rgba(0, 0, 0, 0.26)';
const logScrollbarThumbHover = darkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.36)';
const columns = [
{
@@ -61,7 +45,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
dataIndex: 'status',
width: 70,
render: (status: string) => (
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0, borderRadius: 999, paddingInline: 8, fontSize: 11, fontWeight: 700 }}>
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0 }}>
{status === 'success' ? 'OK' : 'ERR'}
</Tag>
)
@@ -76,7 +60,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
title: 'SQL / Message',
dataIndex: 'sql',
render: (text: string, record: any) => (
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.45' }}>
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.2' }}>
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>Affected: {record.affectedRows}</div>}
@@ -88,18 +72,12 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
return (
<div style={{
height,
margin: 0,
border: `1px solid ${panelDividerColor}`,
borderRadius: 14,
background: panelShellBg,
WebkitBackdropFilter: opacity < 0.999 ? 'blur(14px)' : 'none',
boxShadow: panelShadow,
backdropFilter: darkMode && opacity < 0.999 ? 'blur(18px)' : 'none',
borderTop: `1px solid ${panelDividerColor}`,
background: bgMain,
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
zIndex: 100
zIndex: 100 // Ensure above other content
}}>
{/* Resize Handle */}
<div
@@ -117,53 +95,38 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
{/* Toolbar */}
<div style={{
padding: '10px 14px',
padding: '4px 8px',
borderBottom: `1px solid ${panelDividerColor}`,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
minHeight: 48
height: 32
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? `rgba(255,214,102,${Math.max(0.10, Math.min(0.18, opacity * 0.18))})` : `rgba(24,144,255,${Math.max(0.08, Math.min(0.16, opacity * 0.16))})`, color: panelAccentColor, flexShrink: 0 }}>
<BugOutlined />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: darkMode ? '#f5f7ff' : '#162033' }}>SQL </div>
<div style={{ fontSize: 12, color: panelMutedTextColor }}>便</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 'bold', fontSize: '12px' }}>
<BugOutlined /> SQL
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div>
<Tooltip title="清空日志">
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} style={{ color: panelMutedTextColor }} />
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} />
</Tooltip>
<Tooltip title="关闭面板">
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: panelMutedTextColor }} />
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} />
</Tooltip>
</div>
</div>
{/* List */}
<div className="log-panel-scroll" style={{ flex: 1, overflow: 'auto', padding: '8px 10px 10px' }}>
{sqlLogs.length === 0 ? (
<div style={{ height: '100%', minHeight: 160, display: 'grid', placeItems: 'center' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<span style={{ color: panelMutedTextColor }}> SQL </span>}
/>
</div>
) : (
<Table
className="log-panel-table"
dataSource={sqlLogs}
columns={columns}
size="small"
pagination={false}
rowKey="id"
showHeader={false}
/>
)}
<div className="log-panel-scroll" style={{ flex: 1, overflow: 'auto' }}>
<Table
className="log-panel-table"
dataSource={sqlLogs}
columns={columns}
size="small"
pagination={false}
rowKey="id"
showHeader={false}
// scroll={{ y: height - 32 }} // Let flex handle it
/>
</div>
<style>{`
.log-panel-scroll {
@@ -193,16 +156,6 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
.log-panel-table .ant-table-tbody > tr > td {
background: transparent !important;
}
.log-panel-table .ant-table-tbody > tr > td {
padding: 8px 10px !important;
border-bottom: 1px solid ${panelDividerColor} !important;
}
.log-panel-table .ant-table-tbody > tr:last-child > td {
border-bottom: none !important;
}
.log-panel-table .ant-table-row:hover > td {
background: ${darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)'} !important;
}
`}</style>
</div>
);

View File

@@ -1,279 +0,0 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SavedQuery, TabData } from '../types';
import QueryEditor from './QueryEditor';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
addSqlLog: vi.fn(),
addTab: vi.fn(),
savedQueries: [] as SavedQuery[],
saveQuery: vi.fn(),
theme: 'light',
sqlFormatOptions: { keywordCase: 'upper' as const },
setSqlFormatOptions: vi.fn(),
queryOptions: { maxRows: 5000 },
setQueryOptions: vi.fn(),
shortcutOptions: {
runQuery: { enabled: false, combo: '' },
},
activeTabId: 'tab-1',
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBQueryWithCancel: vi.fn(),
DBQueryMulti: vi.fn(),
DBGetTables: vi.fn(),
DBGetAllColumns: vi.fn(),
DBGetDatabases: vi.fn(),
DBGetColumns: vi.fn(),
CancelQuery: vi.fn(),
GenerateQueryID: vi.fn(),
WriteSQLFile: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}));
const editorState = vi.hoisted(() => {
const state = {
value: '',
editor: null as any,
};
state.editor = {
getValue: vi.fn(() => state.value),
setValue: vi.fn((value: string) => {
state.value = value;
}),
getModel: vi.fn(() => ({
getValue: () => state.value,
setValue: (value: string) => {
state.value = value;
},
getValueInRange: () => '',
getLineContent: () => '',
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }),
})),
getSelection: vi.fn(() => null),
addAction: vi.fn(),
onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
hasTextFocus: vi.fn(() => true),
};
return state;
});
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => selector(storeState),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('../utils/autoFetchVisibility', () => ({
useAutoFetchVisibility: () => false,
}));
vi.mock('@monaco-editor/react', () => ({
default: ({ defaultValue, onMount }: any) => {
React.useEffect(() => {
editorState.value = String(defaultValue || '');
onMount?.(editorState.editor, {
editor: { setTheme: vi.fn() },
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
registerCompletionItemProvider: vi.fn(),
},
});
}, []);
return <textarea data-editor value={editorState.value} readOnly />;
},
}));
vi.mock('./DataGrid', () => ({
default: () => null,
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
SettingOutlined: Icon,
CloseOutlined: Icon,
StopOutlined: Icon,
RobotOutlined: Icon,
};
});
vi.mock('antd', () => {
const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
<button type="button" disabled={disabled || loading} onClick={onClick} {...rest}>
{children}
</button>
);
Button.Group = ({ children }: any) => <div>{children}</div>;
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
return {
Button,
message: messageApi,
Modal: ({ children, open }: any) => (open ? <section>{children}</section> : null),
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
Form,
Dropdown: ({ children }: any) => <>{children}</>,
Tooltip: ({ children }: any) => <>{children}</>,
Select: () => null,
Tabs: () => null,
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
id: 'tab-1',
title: 'query.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select 1;',
...overrides,
});
describe('QueryEditor external SQL save', () => {
beforeEach(() => {
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
storeState.addTab.mockReset();
storeState.saveQuery.mockReset();
storeState.savedQueries = [];
storeState.activeTabId = 'tab-1';
messageApi.success.mockReset();
messageApi.error.mockReset();
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
editorState.value = '';
editorState.editor.getValue.mockClear();
editorState.editor.setValue.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
let renderer: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 2;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 2;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
filePath,
query: 'select 2;',
savedQueryId: undefined,
}));
expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!');
});
it('does not create saved queries when external SQL file writes fail', async () => {
let renderer: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 4;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 4;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).not.toHaveBeenCalled();
expect(messageApi.error).toHaveBeenCalledWith('保存 SQL 文件失败: 磁盘只读');
});
it('keeps saved query quick-save behavior for non-file tabs', async () => {
storeState.savedQueries = [
{
id: 'saved-1',
name: '常用查询',
sql: 'select 1;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
},
];
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
editorState.value = 'select 3;';
await act(async () => {
findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).not.toHaveBeenCalled();
expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
id: 'saved-1',
name: '常用查询',
sql: 'select 3;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
}));
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import { Button, Space, message } from 'antd';
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import Editor, { OnMount } from '@monaco-editor/react';
interface RedisCommandEditorProps {
@@ -15,67 +14,6 @@ interface CommandResult {
result: any;
error?: string;
timestamp: number;
durationMs: number;
}
// 智能解析 Redis 脚本块,保护多行引号内的换行符
function parseRedisScriptBlocks(script: string): string[] {
const blocks: string[] = [];
let currentBlock = "";
let inQuote: string | null = null;
let isEscaping = false;
const lines = script.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!inQuote && (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('#'))) {
continue;
}
for (let j = 0; j < line.length; j++) {
const char = line[j];
if (isEscaping) {
isEscaping = false;
currentBlock += char;
continue;
}
if (char === '\\') {
isEscaping = true;
currentBlock += char;
continue;
}
if (char === '"' || char === "'") {
if (inQuote === char) {
inQuote = null;
} else if (!inQuote) {
inQuote = char;
}
}
currentBlock += char;
}
if (inQuote || (i < lines.length - 1 && currentBlock.trim() !== '')) {
if (!inQuote) {
blocks.push(currentBlock.trim());
currentBlock = "";
} else {
currentBlock += '\n';
}
}
}
if (currentBlock.trim() !== '') {
blocks.push(currentBlock.trim());
}
return blocks.filter(b => b.trim() !== '');
}
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
@@ -85,13 +23,6 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
const [command, setCommand] = useState('');
const [results, setResults] = useState<CommandResult[]>([]);
const [loading, setLoading] = useState(false);
// UI Layout state
const [editorHeight, setEditorHeight] = useState(250);
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const resultsEndRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<any>(null);
const getConfig = useCallback(() => {
@@ -106,173 +37,77 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
};
}, [connection, redisDB]);
const handleEditorMount: OnMount = (editor, monaco) => {
const handleEditorMount: OnMount = (editor) => {
editorRef.current = editor;
// Add keyboard shortcut for execute
editor.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
// Ctrl/Cmd + Enter
2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter
() => handleExecute()
);
if (!(window as any).__redisCompletionRegistered) {
(window as any).__redisCompletionRegistered = true;
const redisCommands = [
"APPEND", "AUTH", "BGREWRITEAOF", "BGSAVE", "BITCOUNT", "BITFIELD", "BITOP",
"BITPOS", "BLPOP", "BRPOP", "BRPOPLPUSH", "BZMPOP", "BZPOPMIN", "BZPOPMAX",
"CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DBSIZE", "DEBUG", "DECR", "DECRBY",
"DEL", "DISCARD", "DUMP", "ECHO", "EVAL", "EVALSHA", "EXEC", "EXISTS", "EXPIRE",
"EXPIREAT", "EXPIRETIME", "FLUSHALL", "FLUSHDB", "GEOADD", "GEODIST", "GEOHASH",
"GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH", "GEOSEARCHSTORE",
"GET", "GETBIT", "GETDEL", "GETEX", "GETRANGE", "GETSET", "HDEL", "HELLO",
"HEXISTS", "HGET", "HGETALL", "HINCRBY", "HINCRBYFLOAT", "HKEYS", "HLEN",
"HMGET", "HMSET", "HSCAN", "HSET", "HSETNX", "HSTRLEN", "HVALS", "INCR",
"INCRBY", "INCRBYFLOAT", "INFO", "KEYS", "LASTSAVE", "LCS", "LINDEX", "LINSERT",
"LLEN", "LMOVE", "LMPOP", "LPOP", "LPOS", "LPUSH", "LPUSHX", "LRANGE", "LREM",
"LSET", "LTRIM", "MEMORY", "MGET", "MIGRATE", "MODULE", "MONITOR", "MOVE", "MSET",
"MSETNX", "MULTI", "OBJECT", "PERSIST", "PEXPIRE", "PEXPIREAT", "PEXPIRETIME",
"PFADD", "PFCOUNT", "PFMERGE", "PING", "PSETEX", "PSUBSCRIBE", "PTTL", "PUBLISH",
"PUBSUB", "PUNSUBSCRIBE", "QUIT", "RANDOMKEY", "READONLY", "READWRITE", "RENAME",
"RENAMENX", "RESET", "RESTORE", "ROLE", "RPOP", "RPOPLPUSH", "RPUSH", "RPUSHX",
"SADD", "SAVE", "SCAN", "SCARD", "SCRIPT", "SDIFF", "SDIFFSTORE", "SELECT",
"SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SHUTDOWN", "SINTER", "SINTERCARD",
"SINTERSTORE", "SISMEMBER", "SLAVEOF", "SLOWLOG", "SMEMBERS", "SMISMEMBER",
"SMOVE", "SORT", "SORT_RO", "SPOP", "SRANDMEMBER", "SREM", "SSCAN", "STRLEN",
"SUBSCRIBE", "SUNION", "SUNIONSTORE", "SWAPDB", "SYNC", "TIME", "TOUCH", "TTL",
"TYPE", "UNLINK", "UNSUBSCRIBE", "UNWATCH", "WAIT", "WATCH", "XACK", "XADD",
"XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XINFO", "XLEN", "XPENDING", "XRANGE",
"XREAD", "XREADGROUP", "XREVRANGE", "XTRIM", "ZADD", "ZCARD", "ZCOUNT", "ZDIFF",
"ZDIFFSTORE", "ZINCRBY", "ZINTER", "ZINTERCARD", "ZINTERSTORE", "ZLEXCOUNT",
"ZMPOP", "ZMSCORE", "ZPOPMAX", "ZPOPMIN", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX",
"ZRANGEBYSCORE", "ZRANK", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK",
"ZREMRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK",
"ZSCAN", "ZSCORE", "ZUNION", "ZUNIONSTORE"
];
monaco.languages.registerCompletionItemProvider('redis', {
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
};
return {
suggestions: redisCommands.map(cmd => ({
label: cmd,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: cmd,
range: range,
detail: "Redis Command"
}))
};
}
});
}
};
const handleExecute = async () => {
const config = getConfig();
if (!config) return;
let cmdToExecute = '';
// 1. 获取用户是否有高亮选中的文本
const selection = editorRef.current?.getSelection();
if (selection && !selection.isEmpty()) {
cmdToExecute = editorRef.current?.getModel()?.getValueInRange(selection) || '';
} else {
// 没有选中则取全部文本
cmdToExecute = editorRef.current?.getValue() || '';
}
cmdToExecute = cmdToExecute.trim();
const cmdToExecute = command.trim();
if (!cmdToExecute) {
message.warning('请输入要执行的命令');
message.warning('请输入命令');
return;
}
// 2. 智能解析多行命令
const commands = parseRedisScriptBlocks(cmdToExecute);
if (commands.length === 0) return;
// Support multiple commands separated by newlines
const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#'));
setLoading(true);
const newResults: CommandResult[] = [];
for (const cmd of commands) {
const start = Date.now();
const trimmedCmd = cmd.trim();
if (!trimmedCmd) continue;
try {
const res = await (window as any).go.app.App.RedisExecuteCommand(buildRpcConnectionConfig(config), cmd);
const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd);
newResults.push({
command: cmd,
command: trimmedCmd,
result: res.success ? res.data : null,
error: res.success ? undefined : res.message,
timestamp: Date.now(),
durationMs: Date.now() - start
timestamp: Date.now()
});
} catch (e: any) {
newResults.push({
command: cmd,
command: trimmedCmd,
result: null,
error: e?.message || String(e),
timestamp: Date.now(),
durationMs: Date.now() - start
timestamp: Date.now()
});
}
}
setResults(prev => [...prev, ...newResults]);
setResults(prev => [...newResults, ...prev]);
setLoading(false);
};
// Auto scroll to bottom when new results arrive
useEffect(() => {
if (resultsEndRef.current) {
resultsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [results]);
const handleClear = () => {
setResults([]);
};
const formatResult = (result: any): React.ReactNode => {
const formatResult = (result: any): string => {
if (result === null || result === undefined) {
return <span style={{ color: '#569cd6' }}>(nil)</span>;
return '(nil)';
}
if (typeof result === 'string') {
// 尝试美化 JSON 字符串
try {
const parsed = JSON.parse(result);
if (typeof parsed === 'object' && parsed !== null) {
return (
<div style={{ marginTop: 4, padding: 8, background: 'rgba(0,0,0,0.2)', borderRadius: 4 }}>
{JSON.stringify(parsed, null, 2)}
</div>
);
}
} catch (e) {
// not a valid json, just return string
}
return <span style={{ color: '#ce9178' }}>"{result}"</span>;
return `"${result}"`;
}
if (typeof result === 'number') {
return <span style={{ color: '#b5cea8' }}>(integer) {result}</span>;
return `(integer) ${result}`;
}
if (Array.isArray(result)) {
if (result.length === 0) {
return '(empty array)';
}
return (
<div style={{ marginLeft: 8 }}>
{result.map((item, index) => (
<div key={index} style={{ display: 'flex' }}>
<span style={{ color: '#608b4e', marginRight: 8, userSelect: 'none' }}>{index + 1})</span>
<div>{formatResult(item)}</div>
</div>
))}
</div>
);
return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n');
}
if (typeof result === 'object') {
return JSON.stringify(result, null, 2);
@@ -280,56 +115,18 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
return String(result);
};
// Resizing logic
const handleDragStart = (e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
document.body.style.cursor = 'row-resize';
};
const handleDragMove = useCallback((e: MouseEvent) => {
if (!dragRef.current) return;
const delta = e.clientY - dragRef.current.startY;
let newHeight = dragRef.current.startHeight + delta;
// 限制高度
const minHeight = 100;
const maxHeight = containerRef.current ? containerRef.current.clientHeight - 100 : 800;
if (newHeight < minHeight) newHeight = minHeight;
if (newHeight > maxHeight) newHeight = maxHeight;
setEditorHeight(newHeight);
// 更新编辑器布局
if (editorRef.current) {
editorRef.current.layout();
}
}, []);
const handleDragEnd = useCallback(() => {
dragRef.current = null;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
document.body.style.cursor = 'default';
if (editorRef.current) {
editorRef.current.layout();
}
}, [handleDragMove]);
if (!connection) {
return <div style={{ padding: 20 }}></div>;
}
return (
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', background: '#fff' }}>
{/* Editor Top Pane */}
<div style={{ height: editorHeight, minHeight: 100, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fdfdfd' }}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Command Input */}
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<span style={{ fontWeight: 600 }}>Redis Console</span>
<span style={{ color: '#888', fontSize: 13, background: '#f0f0f0', padding: '2px 8px', borderRadius: 12 }}>db{redisDB}</span>
<span style={{ fontWeight: 500 }}>Redis </span>
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
</Space>
<Space>
<Button
@@ -338,89 +135,68 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
onClick={handleExecute}
loading={loading}
>
(Cmd+Enter)
(Ctrl+Enter)
</Button>
<Button icon={<ClearOutlined />} onClick={handleClear}></Button>
</Space>
</div>
<div style={{ flex: 1, position: 'relative' }}>
<Editor
defaultLanguage="redis"
language="redis"
value={command}
onChange={(value) => setCommand(value || '')}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
fontSize: 14,
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
padding: { top: 10, bottom: 10 }
}}
/>
</div>
<Editor
height="150px"
defaultLanguage="plaintext"
value={command}
onChange={(value) => setCommand(value || '')}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
fontSize: 14,
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2
}}
/>
</div>
{/* Resizer Handle */}
<div
className="horizontal-resizer"
onMouseDown={handleDragStart}
style={{
height: 8,
cursor: 'row-resize',
background: '#f0f0f0',
borderTop: '1px solid #e0e0e0',
borderBottom: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10
}}
>
<div style={{ width: 40, height: 4, background: '#ccc', borderRadius: 2 }} />
</div>
{/* Results Terminal Bottom Pane */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '4px 12px', background: '#252526', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #333' }}>
<span style={{ color: '#ccc', fontSize: 12 }}>Execution Output</span>
<Button type="text" size="small" icon={<ClearOutlined />} onClick={handleClear} style={{ color: '#aaa' }}></Button>
</div>
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: '"Consolas", "Courier New", monospace', fontSize: 13, padding: 12 }}>
{results.length === 0 ? (
<div style={{ color: '#666', textAlign: 'center', marginTop: 40 }}>
<div></div>
<div style={{ fontSize: 12, marginTop: 12 }}>
Tips: <code></code> <code style={{ color: '#999' }}>Ctrl + Enter</code>
{/* Results */}
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
{results.length === 0 ? (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
Redis Ctrl+Enter
<br />
<span style={{ fontSize: 12 }}></span>
</div>
) : (
results.map((item, index) => (
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
<div style={{ color: '#569cd6', marginBottom: 4 }}>
&gt; {item.command}
</div>
{item.error ? (
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
(error) {item.error}
</div>
) : (
<div style={{ color: '#ce9178', whiteSpace: 'pre-wrap' }}>
{formatResult(item.result)}
</div>
)}
</div>
) : (
results.map((item, index) => (
<div key={item.timestamp + index} style={{ marginBottom: 16 }}>
<div style={{ color: '#569cd6', marginBottom: 6, fontWeight: 'bold' }}>
<span style={{ color: '#4CAF50', marginRight: 8 }}></span>
{item.command}
<span style={{ color: '#666', fontSize: 11, marginLeft: 12, fontWeight: 'normal' }}>[{item.durationMs}ms]</span>
</div>
<div style={{ paddingLeft: 20 }}>
{item.error ? (
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
(error) {item.error}
</div>
) : (
<div style={{ whiteSpace: 'pre-wrap' }}>
{formatResult(item.result)}
</div>
)}
</div>
</div>
))
)}
<div ref={resultsEndRef} />
</div>
))
)}
</div>
{/* Common Commands Help */}
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
:
<span style={{ marginLeft: 8 }}>
<code>KEYS *</code> |
<code style={{ marginLeft: 8 }}>GET key</code> |
<code style={{ marginLeft: 8 }}>SET key value</code> |
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
<code style={{ marginLeft: 8 }}>INFO</code> |
<code style={{ marginLeft: 8 }}>DBSIZE</code>
</span>
</div>
</div>
);

View File

@@ -1,379 +0,0 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Card, Row, Col, Statistic, Select, Button, message, Tag, Typography, Tooltip, Spin } from 'antd';
import { AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid, Legend, LineChart, Line } from 'recharts';
import {
DesktopOutlined,
DashboardOutlined,
ApiOutlined,
HddOutlined,
ReloadOutlined,
PlayCircleOutlined,
PauseCircleOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { SavedConnection } from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { RedisGetServerInfo } from '../../wailsjs/go/app/App';
const { Title, Text } = Typography;
interface RedisMonitorProps {
connectionId: string;
redisDB: number;
}
// Data point for charts
interface MetricPoint {
time: string;
qps: number;
memory: number; // in MB
memory_rss: number; // in MB
clients: number;
cpuSys: number;
cpuUser: number;
hitRate: number;
keys: number;
}
const MAX_HISTORY_POINTS = 60; // Keep up to 60 data points
const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
const [isRunning, setIsRunning] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<MetricPoint[]>([]);
const [currentInfo, setCurrentInfo] = useState<Record<string, string>>({});
// Ref to track if component is mounted to prevent state updates after unmount
const mountedRef = useRef(true);
// Interval ref
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Previous ops counter to calculate QPS if instantaneous_ops_per_sec is not enough
const prevMetricsRef = useRef({ prevOps: 0, prevTime: 0 });
const connection = connections.find((c: SavedConnection) => c.id === connectionId);
const fetchMetrics = async () => {
if (!connection) return;
try {
const config = buildRpcConnectionConfig(connection.config, { redisDB });
const res = await RedisGetServerInfo(config);
if (!mountedRef.current) return;
if (!res.success) {
setError(res.message || 'Failed to fetch Redis info');
return;
}
setError(null);
const infoMap = res.data as Record<string, string>;
setCurrentInfo(infoMap);
const now = new Date();
const timeStr = now.toLocaleTimeString([], { hour12: false, second: '2-digit' });
// Parse values
const qps = parseInt(infoMap['instantaneous_ops_per_sec'] || '0', 10);
const memBytes = parseInt(infoMap['used_memory'] || '0', 10);
const memRssBytes = parseInt(infoMap['used_memory_rss'] || '0', 10);
const clients = parseInt(infoMap['connected_clients'] || '0', 10);
const cpuSys = parseFloat(infoMap['used_cpu_sys'] || '0');
const cpuUser = parseFloat(infoMap['used_cpu_user'] || '0');
const hits = parseInt(infoMap['keyspace_hits'] || '0', 10);
const misses = parseInt(infoMap['keyspace_misses'] || '0', 10);
const hitRate = (hits + misses) > 0 ? (hits / (hits + misses)) * 100 : 0;
let keys = 0;
Object.keys(infoMap).forEach(k => {
if (k.startsWith('db')) {
const m = infoMap[k].match(/keys=(\d+)/);
if (m) keys += parseInt(m[1], 10);
}
});
const point: MetricPoint = {
time: timeStr,
qps,
memory: parseFloat((memBytes / 1024 / 1024).toFixed(2)),
memory_rss: parseFloat((memRssBytes / 1024 / 1024).toFixed(2)),
clients,
cpuSys: parseFloat(cpuSys.toFixed(2)),
cpuUser: parseFloat(cpuUser.toFixed(2)),
hitRate: parseFloat(hitRate.toFixed(2)),
keys
};
setHistory(prev => {
const next = [...prev, point];
if (next.length > MAX_HISTORY_POINTS) {
return next.slice(next.length - MAX_HISTORY_POINTS);
}
return next;
});
if (loading) setLoading(false);
} catch (err: any) {
if (mountedRef.current) {
setError(err.message || 'Unknown error');
if (loading) setLoading(false);
}
}
};
useEffect(() => {
mountedRef.current = true;
fetchMetrics(); // initial fetch
return () => {
mountedRef.current = false;
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
useEffect(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
if (isRunning) {
intervalRef.current = setInterval(fetchMetrics, 2000); // 2 second interval
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isRunning, connectionId, redisDB, connection]);
if (!connection) {
return <div style={{ padding: 20 }}>Connection not found.</div>;
}
// Determine styles for charts based on theme
const chartTextColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)';
const chartGridColor = darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
const cardBgColor = darkMode ? '#1f1f1f' : '#ffffff';
const getFormatMemoryString = (bytes: string) => {
const val = parseInt(bytes || '0', 10);
if (val > 1024*1024*1024) return (val/1024/1024/1024).toFixed(2) + ' GB';
if (val > 1024*1024) return (val/1024/1024).toFixed(2) + ' MB';
if (val > 1024) return (val/1024).toFixed(2) + ' KB';
return val + ' B';
};
const getUptimeString = (seconds: string) => {
const d = parseInt(seconds || '0', 10);
if (d < 60) return `${d}s`;
if (d < 3600) return `${Math.floor(d/60)}m ${d%60}s`;
if (d < 86400) return `${Math.floor(d/3600)}h ${Math.floor((d%3600)/60)}m`;
return `${Math.floor(d/86400)}d ${Math.floor((d%86400)/3600)}h`;
};
return (
<div style={{ height: '100%', overflow: 'auto', padding: '16px 24px', backgroundColor: darkMode ? '#141414' : '#f0f2f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<div>
<Title level={3} style={{ margin: 0, fontWeight: 600 }}>
<DashboardOutlined style={{ marginRight: 8, color: '#1677ff' }} />
Redis
</Title>
<Text type="secondary">
{connection.name}
{currentInfo.redis_version && ` • Redis ${currentInfo.redis_version}`}
{currentInfo.os && `${currentInfo.os}`}
</Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
{error && <Tag color="error" style={{ height: 32, lineHeight: '30px', fontSize: 13 }}>{error}</Tag>}
{loading && !error && <Spin style={{ alignSelf: 'center', marginRight: 16 }} />}
<Button
type={isRunning ? "default" : "primary"}
icon={isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={() => setIsRunning(!isRunning)}
>
{isRunning ? '暂停刷新' : '恢复刷新'}
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchMetrics}>
</Button>
</div>
</div>
<Row gutter={[16, 16]}>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}><DesktopOutlined /> (Used)</span>}
value={getFormatMemoryString(currentInfo.used_memory || '0')}
valueStyle={{ color: '#eb2f96', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Peak: {getFormatMemoryString(currentInfo.used_memory_peak || '0')}</Text>}
/>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}><ApiOutlined /> (Clients)</span>}
value={currentInfo.connected_clients || '0'}
valueStyle={{ color: '#1677ff', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Blocked: {currentInfo.blocked_clients || '0'}</Text>}
/>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}><HddOutlined /> (OPS)</span>}
value={currentInfo.instantaneous_ops_per_sec || '0'}
valueStyle={{ color: '#52c41a', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>cmds/s</Text>}
/>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}> (Uptime)</span>}
value={getUptimeString(currentInfo.uptime_in_seconds || '0')}
valueStyle={{ color: '#fa8c16', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Days: {currentInfo.uptime_in_days || '0'}</Text>}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={12}>
<Card
bordered={false}
title="请求吞吐量 (QPS)"
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorQps" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#52c41a" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#52c41a" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
/>
<Area type="monotone" dataKey="qps" name="QPS" stroke="#52c41a" strokeWidth={2} fillOpacity={1} fill="url(#colorQps)" isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</Card>
</Col>
<Col span={12}>
<Card
bordered={false}
title="内存开销 (Memory)"
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} domain={['auto', 'auto']} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
formatter={(value: any) => [`${value} MB`]}
/>
<Legend verticalAlign="top" height={36}/>
<Line type="monotone" dataKey="memory" name="Used Memory" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="memory_rss" name="RSS Memory" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={12}>
<Card
bordered={false}
title="CPU 使用率 (CPU Usage)"
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
formatter={(value: any) => [`${value} s`]}
/>
<Legend verticalAlign="top" height={36}/>
<Line type="monotone" dataKey="cpuSys" name="System" stroke="#cf1322" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="cpuUser" name="User" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
<Col span={12}>
<Card
bordered={false}
title="连接信息 (Clients & Keys)"
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis yAxisId="left" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis yAxisId="right" orientation="right" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
/>
<Legend verticalAlign="top" height={36}/>
<Line yAxisId="left" type="stepAfter" dataKey="clients" name="Clients" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line yAxisId="right" type="stepAfter" dataKey="keys" name="Total Keys" stroke="#fa8c16" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
<div style={{ marginTop: 24 }}>
<Card bordered={false} title="详细服务器参数" style={{ background: cardBgColor, borderRadius: 8 }}>
<div style={{ columnCount: 3, columnGap: 40 }}>
{['redis_version', 'os', 'arch_bits', 'multiplexing_api', 'gcc_version', 'run_id', 'tcp_port', 'uptime_in_days', 'hz', 'lru_clock', 'role', 'maxmemory_human', 'maxmemory_policy', 'mem_fragmentation_ratio', 'keyspace_hits', 'keyspace_misses', 'total_connections_received'].map(key => (
currentInfo[key] ? (
<div key={key} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8, borderBottom: `1px dashed ${chartGridColor}` }}>
<Text type="secondary">{key}</Text>
<Text strong>{currentInfo[key]}</Text>
</div>
) : null
))}
</div>
</Card>
</div>
</div>
);
};
export default RedisMonitor;

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -12,44 +12,45 @@ import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import RedisMonitor from './RedisMonitor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import JVMOverview from './JVMOverview';
import JVMResourceBrowser from './JVMResourceBrowser';
import JVMAuditViewer from './JVMAuditViewer';
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
import type { TabData } from '../types';
import { buildTabDisplayTitle } from '../utils/tabDisplay';
import { resolveConnectionAccentColor } from '../utils/connectionVisual';
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') return tab.title;
if (!connectionName) return tab.title;
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
};
type SortableTabLabelProps = {
displayTitle: string;
menuItems: MenuProps['items'];
accentColor?: string;
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
displayTitle,
menuItems,
accentColor,
}) => {
const labelStyle = accentColor
? ({ '--connection-accent': accentColor } as React.CSSProperties)
: undefined;
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span
className={`tab-dnd-label${accentColor ? ' has-connection-accent' : ''}`}
className="tab-dnd-label"
onContextMenu={(e) => e.preventDefault()}
title={displayTitle}
style={labelStyle}
title="拖拽调整标签顺序"
>
{accentColor ? <span className="tab-connection-accent" aria-hidden="true" /> : null}
<span className="tab-title-text">{displayTitle}</span>
{displayTitle}
</span>
</Dropdown>
);
@@ -87,7 +88,6 @@ const TabManager: React.FC = () => {
const theme = useStore(state => state.theme);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const addTab = useStore(state => state.addTab);
const closeTab = useStore(state => state.closeTab);
const closeOtherTabs = useStore(state => state.closeOtherTabs);
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
@@ -133,59 +133,6 @@ const TabManager: React.FC = () => {
setDraggingTabId(null);
};
React.useEffect(() => {
const handleGlobalInsertSql = (e: any) => {
const { sql, runImmediately, connectionId: eventConnId, dbName: eventDbName } = e.detail;
if (!sql) return;
const activeTab = tabs.find(t => t.id === activeTabId);
// 🔧 runImmediately点击"执行")始终新建独立 tab避免追加到已有 tab 导致 SQL 重复
if (runImmediately) {
const newTabId = 'tab-' + Date.now();
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
addTab({
id: newTabId,
type: 'query',
title: '新建查询',
query: sql,
connectionId: resolvedConnId,
dbName: resolvedDbName
});
setActiveTab(newTabId);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
detail: { tabId: newTabId, sql, runImmediately: true, connectionId: resolvedConnId, dbName: resolvedDbName }
}));
}, 300);
return;
}
// 插入模式:追加到已有 tab 或新建 tab
if (activeTab && activeTab.type === 'query') {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
detail: { tabId: activeTab.id, sql, runImmediately: false, connectionId: eventConnId, dbName: eventDbName }
}));
} else {
const newTabId = 'tab-' + Date.now();
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
addTab({
id: newTabId,
type: 'query',
title: '新建查询',
query: sql,
connectionId: resolvedConnId,
dbName: resolvedDbName
});
setActiveTab(newTabId);
}
};
window.addEventListener('gonavi:insert-sql', handleGlobalInsertSql);
return () => window.removeEventListener('gonavi:insert-sql', handleGlobalInsertSql);
}, [tabs, activeTabId, addTab, setActiveTab, connections]);
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
@@ -195,39 +142,27 @@ 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 accentColor = connection ? resolveConnectionAccentColor(connection) : undefined;
const tabIsActive = tab.id === activeTabId;
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command';
const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive;
let content;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} isActive={tabIsActive} />;
if (!shouldRenderContent) {
content = null;
} else if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
} else if (tab.type === 'table') {
content = <DataViewer tab={tab} isActive={tabIsActive} />;
content = <DataViewer tab={tab} />;
} else if (tab.type === 'design') {
content = <TableDesigner tab={tab} />;
} else if (tab.type === 'redis-keys') {
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-command') {
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-monitor') {
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'trigger') {
content = <TriggerViewer tab={tab} />;
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
content = <DefinitionViewer tab={tab} />;
} else if (tab.type === 'table-overview') {
content = <TableOverview tab={tab} />;
} else if (tab.type === 'jvm-overview') {
content = <JVMOverview tab={tab} />;
} else if (tab.type === 'jvm-resource') {
content = <JVMResourceBrowser tab={tab} />;
} else if (tab.type === 'jvm-audit') {
content = <JVMAuditViewer tab={tab} />;
} else if (tab.type === 'jvm-diagnostic') {
content = <JVMDiagnosticConsole tab={tab} />;
} else if (tab.type === 'jvm-monitoring') {
content = <JVMMonitoringDashboard tab={tab} />;
}
const menuItems: MenuProps['items'] = [
@@ -263,7 +198,6 @@ const TabManager: React.FC = () => {
<SortableTabLabel
displayTitle={displayTitle}
menuItems={menuItems}
accentColor={accentColor}
/>
),
key: tab.id,
@@ -328,26 +262,8 @@ const TabManager: React.FC = () => {
-webkit-user-select: none;
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
}
.main-tabs .tab-dnd-label.has-connection-accent {
position: relative;
}
.main-tabs .tab-connection-accent {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--connection-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--connection-accent) 22%, transparent);
flex: 0 0 auto;
}
.main-tabs .tab-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.main-tabs .tab-dnd-node.is-dragging,
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
cursor: grabbing !important;
@@ -364,10 +280,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;
@@ -385,7 +297,6 @@ const TabManager: React.FC = () => {
<Tabs
className="main-tabs"
type="editable-card"
destroyInactiveTabPane={false}
onChange={(newActiveKey) => {
if (Date.now() < suppressClickUntilRef.current) return;
onChange(newActiveKey);

File diff suppressed because it is too large Load Diff

View File

@@ -1,187 +0,0 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import TableDesignerSqlPreview, { resolveSqlChangeHighlights } from './TableDesignerSqlPreview';
const mockMonaco = {
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(
startLineNumber: number,
startColumn: number,
endLineNumber: number,
endColumn: number,
) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
editor: {
defineTheme: vi.fn(),
},
};
const mockEditor = {
deltaDecorations: vi.fn(() => ['decoration-1']),
getModel: vi.fn(() => ({
getLineCount: () => 5,
getLineMaxColumn: (lineNumber: number) => (lineNumber === 1 ? 22 : 80),
})),
};
vi.mock('@monaco-editor/react', () => ({
default: ({
beforeMount,
defaultLanguage,
language,
onMount,
options,
theme,
value,
}: {
beforeMount?: (monaco: any) => void;
defaultLanguage?: string;
language?: string;
onMount?: (editor: any, monaco: any) => void;
options?: Record<string, any>;
theme?: string;
value?: string;
}) => {
beforeMount?.(mockMonaco);
onMount?.(mockEditor, mockMonaco);
return (
<div
data-default-language={defaultLanguage}
data-language={language}
data-monaco-editor-mock="true"
data-options={JSON.stringify(options)}
data-theme={theme}
>
{value}
</div>
);
},
}));
describe('TableDesignerSqlPreview', () => {
beforeEach(() => {
mockEditor.deltaDecorations.mockClear();
mockMonaco.editor.defineTheme.mockClear();
});
it('renders SQL changes in a read-only Monaco SQL editor with explicit syntax highlight theme', () => {
const markup = renderToStaticMarkup(
<TableDesignerSqlPreview
sql={'ALTER TABLE "users"\nRENAME COLUMN "name" TO "display_name";'}
darkMode={false}
/>,
);
expect(markup).toContain('data-table-designer-sql-preview="true"');
expect(markup).toContain('data-monaco-editor-mock="true"');
expect(markup).toContain('data-default-language="sql"');
expect(markup).toContain('data-language="sql"');
expect(markup).toContain('data-theme="gonavi-sql-preview-light"');
expect(markup).toContain('&quot;readOnly&quot;:true');
expect(markup).toContain('&quot;lineNumbers&quot;:&quot;on&quot;');
expect(markup).not.toContain('&quot;glyphMargin&quot;:true');
expect(markup).toContain('ALTER TABLE');
expect(markup).toContain('RENAME COLUMN');
expect(mockMonaco.editor.defineTheme).toHaveBeenCalledWith(
'gonavi-sql-preview-light',
expect.objectContaining({
base: 'vs',
inherit: true,
rules: expect.arrayContaining([
expect.objectContaining({ token: 'keyword', foreground: expect.any(String) }),
expect.objectContaining({ token: 'string', foreground: expect.any(String) }),
expect.objectContaining({ token: 'comment', foreground: expect.any(String) }),
]),
}),
);
});
it('detects only SQL change operation lines instead of highlighting the whole SQL block', () => {
const highlights = resolveSqlChangeHighlights([
'ALTER TABLE "users"',
'ADD COLUMN "age" int NULL;',
'ALTER TABLE "users"',
'RENAME COLUMN "name" TO "display_name";',
'-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注',
].join('\n'));
expect(highlights).toEqual([
expect.objectContaining({ kind: 'add', lineNumber: 2 }),
expect.objectContaining({ kind: 'rename', lineNumber: 4 }),
]);
});
it('adds Monaco decorations to changed SQL lines only', () => {
renderToStaticMarkup(
<TableDesignerSqlPreview
sql={[
'ALTER TABLE "users"',
'ADD COLUMN "age" int NULL;',
'ALTER TABLE "users"',
'DROP COLUMN "legacy_name";',
].join('\n')}
/>,
);
expect(mockEditor.deltaDecorations).toHaveBeenCalledWith(
[],
expect.arrayContaining([
expect.objectContaining({
range: expect.objectContaining({ startLineNumber: 2, endLineNumber: 2 }),
options: expect.objectContaining({
className: expect.stringContaining('gonavi-sql-preview-change-line-add'),
isWholeLine: true,
linesDecorationsClassName: expect.stringContaining('gonavi-sql-preview-change-marker-add'),
}),
}),
expect.objectContaining({
range: expect.objectContaining({ startLineNumber: 4, endLineNumber: 4 }),
options: expect.objectContaining({
className: expect.stringContaining('gonavi-sql-preview-change-line-drop'),
isWholeLine: true,
linesDecorationsClassName: expect.stringContaining('gonavi-sql-preview-change-marker-drop'),
}),
}),
]),
);
const firstDecorationCall = mockEditor.deltaDecorations.mock.calls[0] as unknown as [unknown, unknown[]];
expect(firstDecorationCall[1]).toHaveLength(2);
expect(firstDecorationCall[1]).toEqual(
expect.arrayContaining([
expect.objectContaining({
options: expect.not.objectContaining({
glyphMarginClassName: expect.any(String),
}),
}),
]),
);
});
it('uses the dark SQL preview theme when dark mode is enabled', () => {
const markup = renderToStaticMarkup(
<TableDesignerSqlPreview sql="CREATE TABLE users (id int);" darkMode />,
);
expect(markup).toContain('data-theme="gonavi-sql-preview-dark"');
expect(mockMonaco.editor.defineTheme).toHaveBeenCalledWith(
'gonavi-sql-preview-dark',
expect.objectContaining({
base: 'vs-dark',
inherit: true,
}),
);
});
});

View File

@@ -1,250 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react';
interface TableDesignerSqlPreviewProps {
sql: string;
darkMode?: boolean;
height?: string | number;
}
export type SqlChangeHighlightKind =
| 'add'
| 'comment'
| 'constraint'
| 'create'
| 'drop'
| 'modify'
| 'rename';
export interface SqlChangeHighlight {
line: string;
lineNumber: number;
kind: SqlChangeHighlightKind;
label: string;
}
const SQL_PREVIEW_LIGHT_THEME = 'gonavi-sql-preview-light';
const SQL_PREVIEW_DARK_THEME = 'gonavi-sql-preview-dark';
const CHANGE_LINE_RULES: Array<{
kind: SqlChangeHighlightKind;
label: string;
pattern: RegExp;
}> = [
{ kind: 'rename', label: '重命名变更', pattern: /\b(RENAME\s+COLUMN|CHANGE\s+COLUMN|RENAME\s+TO|SP_RENAME)\b/i },
{ kind: 'add', label: '新增变更', pattern: /\b(ADD\s+COLUMN|ADD\s+PRIMARY\s+KEY)\b/i },
{ kind: 'drop', label: '删除变更', pattern: /\b(DROP\s+COLUMN|DROP\s+PRIMARY\s+KEY)\b/i },
{ kind: 'modify', label: '字段属性变更', pattern: /\b(MODIFY\s+COLUMN|ALTER\s+COLUMN|SET\s+DATA\s+TYPE|SET\s+DEFAULT|DROP\s+DEFAULT|SET\s+NOT\s+NULL|DROP\s+NOT\s+NULL)\b/i },
{ kind: 'constraint', label: '约束变更', pattern: /\b(ADD\s+CONSTRAINT|DROP\s+CONSTRAINT)\b/i },
{ kind: 'comment', label: '备注变更', pattern: /\b(COMMENT\s+ON\s+COLUMN|COMMENT\s+ON\s+TABLE)\b/i },
];
const CREATE_TABLE_PATTERN = /^\s*CREATE\s+TABLE\b/i;
const getCreateTableLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('--')) return null;
return {
line,
lineNumber,
kind: 'create',
label: '新建表结构',
};
};
const getAlterLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('--')) return null;
const matchedRule = CHANGE_LINE_RULES.find((rule) => rule.pattern.test(trimmed));
if (!matchedRule) return null;
return {
line,
lineNumber,
kind: matchedRule.kind,
label: matchedRule.label,
};
};
export const resolveSqlChangeHighlights = (sql: string): SqlChangeHighlight[] => {
const lines = sql.split(/\r?\n/);
const isCreateTableSql = lines.some((line) => CREATE_TABLE_PATTERN.test(line));
return lines
.map((line, index) => (
isCreateTableSql
? getCreateTableLineHighlight(line, index + 1)
: getAlterLineHighlight(line, index + 1)
))
.filter((highlight): highlight is SqlChangeHighlight => Boolean(highlight));
};
const registerSqlPreviewThemes: BeforeMount = (monaco) => {
monaco.editor.defineTheme(SQL_PREVIEW_LIGHT_THEME, {
base: 'vs',
inherit: true,
rules: [
{ token: 'keyword', foreground: '006C9C', fontStyle: 'bold' },
{ token: 'operator', foreground: '8250DF' },
{ token: 'number', foreground: 'B45309' },
{ token: 'string', foreground: '15803D' },
{ token: 'comment', foreground: '64748B', fontStyle: 'italic' },
{ token: 'predefined', foreground: '0F766E' },
],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#0F172A0A',
'editorGutter.background': '#00000000',
'editorLineNumber.foreground': '#94A3B8',
},
});
monaco.editor.defineTheme(SQL_PREVIEW_DARK_THEME, {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: '7DD3FC', fontStyle: 'bold' },
{ token: 'operator', foreground: 'C4B5FD' },
{ token: 'number', foreground: 'FDBA74' },
{ token: 'string', foreground: '86EFAC' },
{ token: 'comment', foreground: '94A3B8', fontStyle: 'italic' },
{ token: 'predefined', foreground: '5EEAD4' },
],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#FFFFFF12',
'editorGutter.background': '#00000000',
'editorLineNumber.foreground': '#64748B',
},
});
};
const getLineDecorationClassName = (kind: SqlChangeHighlightKind): string =>
`gonavi-sql-preview-change-line gonavi-sql-preview-change-line-${kind}`;
const getLineDecorationMarkerClassName = (kind: SqlChangeHighlightKind): string =>
`gonavi-sql-preview-change-marker gonavi-sql-preview-change-marker-${kind}`;
const TableDesignerSqlPreview: React.FC<TableDesignerSqlPreviewProps> = ({
sql,
darkMode = false,
height = '360px',
}) => {
const decorationIdsRef = useRef<string[]>([]);
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const changeHighlights = useMemo(() => resolveSqlChangeHighlights(sql), [sql]);
const applyChangeDecorations = useCallback(() => {
const editor = editorRef.current;
const monaco = monacoRef.current;
const model = editor?.getModel?.();
if (!editor || !monaco || !model) return;
const lineCount = model.getLineCount();
const decorations = changeHighlights
.filter((highlight) => highlight.lineNumber <= lineCount)
.map((highlight) => {
const endColumn = Math.max(1, model.getLineMaxColumn(highlight.lineNumber));
return {
range: new monaco.Range(highlight.lineNumber, 1, highlight.lineNumber, endColumn),
options: {
className: getLineDecorationClassName(highlight.kind),
hoverMessage: { value: highlight.label },
isWholeLine: true,
linesDecorationsClassName: getLineDecorationMarkerClassName(highlight.kind),
},
};
});
decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, decorations);
}, [changeHighlights]);
const handleEditorMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
applyChangeDecorations();
};
useEffect(() => {
applyChangeDecorations();
}, [applyChangeDecorations, sql]);
return (
<div
data-table-designer-sql-preview="true"
style={{
maxHeight: 400,
overflow: 'hidden',
borderRadius: 8,
border: darkMode ? '1px solid #333' : '1px solid #eee',
}}
>
<style>
{`
.gonavi-sql-preview-change-line {
border-left: 3px solid transparent;
}
.gonavi-sql-preview-change-line-add,
.gonavi-sql-preview-change-line-create {
background: rgba(22, 163, 74, 0.14);
border-left-color: #16a34a;
}
.gonavi-sql-preview-change-line-drop {
background: rgba(220, 38, 38, 0.14);
border-left-color: #dc2626;
}
.gonavi-sql-preview-change-line-modify,
.gonavi-sql-preview-change-line-rename,
.gonavi-sql-preview-change-line-constraint,
.gonavi-sql-preview-change-line-comment {
background: rgba(217, 119, 6, 0.16);
border-left-color: #d97706;
}
.gonavi-sql-preview-change-marker {
width: 4px !important;
margin-left: 2px;
border-radius: 999px;
}
.gonavi-sql-preview-change-marker-add,
.gonavi-sql-preview-change-marker-create {
background: #16a34a;
}
.gonavi-sql-preview-change-marker-drop {
background: #dc2626;
}
.gonavi-sql-preview-change-marker-modify,
.gonavi-sql-preview-change-marker-rename,
.gonavi-sql-preview-change-marker-constraint,
.gonavi-sql-preview-change-marker-comment {
background: #d97706;
}
`}
</style>
<Editor
beforeMount={registerSqlPreviewThemes}
defaultLanguage="sql"
height={height}
language="sql"
onMount={handleEditorMount}
options={{
automaticLayout: true,
fontFamily: '"JetBrains Mono", "Cascadia Code", Consolas, monospace',
fontSize: 13,
lineNumbers: 'on',
lineDecorationsWidth: 14,
minimap: { enabled: false },
padding: { top: 8, bottom: 8 },
readOnly: true,
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
theme={darkMode ? SQL_PREVIEW_DARK_THEME : SQL_PREVIEW_LIGHT_THEME}
value={sql}
/>
</div>
);
};
export default TableDesignerSqlPreview;

View File

@@ -1,749 +0,0 @@
import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import {
TABLE_OVERVIEW_RENDER_BATCH_SIZE,
buildTableOverviewSearchIndex,
filterAndSortTableOverviewRows,
resolveTableOverviewVisibleRows,
type TableOverviewSortField,
type TableOverviewSortOrder,
} from '../utils/tableOverviewFilter';
interface TableOverviewProps {
tab: TabData;
}
interface TableStatRow {
name: string;
comment: string;
rows: number;
dataSize: number;
indexSize: number;
engine: string;
createTime: string;
updateTime: string;
}
type SortField = TableOverviewSortField;
type SortOrder = TableOverviewSortOrder;
type ViewMode = 'card' | 'list';
const formatSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const formatRows = (count: number): string => {
if (count === undefined || count === null || count < 0) return '—';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
};
const getMetadataDialect = (connType: string, driver?: string): string => {
const type = (connType || '').trim().toLowerCase();
if (type === 'custom') {
const d = (driver || '').trim().toLowerCase();
if (d === 'diros' || d === 'doris') return 'mysql';
return d;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
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`;
case 'postgres':
case 'kingbase':
case 'vastbase':
case 'highgo': {
const schema = schemaName || 'public';
return `
SELECT
n.nspname || '.' || c.relname AS table_name,
obj_description(c.oid, 'pg_class') AS table_comment,
c.reltuples::bigint AS table_rows,
pg_total_relation_size(c.oid) AS data_length,
pg_indexes_size(c.oid) AS index_length
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname = '${escapeLiteral(schema)}'
ORDER BY c.relname`;
}
case 'sqlserver': {
const safeDB = `[${dbName.replace(/]/g, ']]')}]`;
return `
SELECT
s.name + '.' + t.name AS table_name,
ep.value AS table_comment,
SUM(p.rows) AS table_rows,
SUM(a.total_pages) * 8 * 1024 AS data_length,
SUM(a.used_pages) * 8 * 1024 AS index_length
FROM ${safeDB}.sys.tables t
JOIN ${safeDB}.sys.schemas s ON t.schema_id = s.schema_id
LEFT JOIN ${safeDB}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description'
LEFT JOIN ${safeDB}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)
LEFT JOIN ${safeDB}.sys.allocation_units a ON p.partition_id = a.container_id
WHERE t.type = 'U'
GROUP BY s.name, t.name, ep.value
ORDER BY s.name, t.name`;
}
case 'clickhouse':
return `SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length FROM system.tables WHERE database = '${escapeLiteral(dbName)}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`;
case 'dm':
case 'oracle': {
const owner = (schemaName || dbName).toUpperCase();
return `SELECT table_name, comments AS table_comment, num_rows AS table_rows, 0 AS data_length, 0 AS index_length FROM all_tab_comments JOIN all_tables USING (table_name, owner) WHERE owner = '${escapeLiteral(owner)}' ORDER BY table_name`;
}
default:
return `SELECT table_name, '' AS table_comment, 0 AS table_rows, 0 AS data_length, 0 AS index_length FROM information_schema.tables WHERE table_schema = '${escapeLiteral(dbName)}' AND table_type = 'BASE TABLE' ORDER BY table_name`;
}
};
const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableStatRow[] => {
return rows.map((row) => {
const get = (keys: string[]): any => {
for (const k of keys) {
for (const rk of Object.keys(row)) {
if (rk.toLowerCase() === k.toLowerCase() && row[rk] !== null && row[rk] !== undefined) return row[rk];
}
}
return undefined;
};
const strVal = (keys: string[]) => String(get(keys) ?? '').trim();
const numVal = (keys: string[]) => {
const v = get(keys);
if (v === null || v === undefined || v === '') return 0;
const n = Number(v);
return isNaN(n) ? 0 : Math.max(0, Math.round(n));
};
return {
name: strVal(['Name', 'table_name', 'tablename', 'TABLE_NAME']),
comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']),
rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']),
dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']),
indexSize: numVal(['Index_length', 'index_length', 'INDEX_LENGTH']),
engine: strVal(['Engine', 'engine']),
createTime: strVal(['Create_time', 'create_time']),
updateTime: strVal(['Update_time', 'update_time']),
};
}).filter(t => t.name);
};
const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const darkMode = theme === 'dark';
const [tables, setTables] = useState<TableStatRow[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
const deferredSearchText = useDeferredValue(searchText);
const isSearchPending = searchText !== deferredSearchText;
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;
setLoading(true);
try {
const config = {
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || '',
database: connection.config.database || '',
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 res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(metadataDialect, res.data));
} else {
message.error('获取表信息失败: ' + (res.message || '未知错误'));
}
} catch (e: any) {
message.error('获取表信息失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
}
}, [connection, metadataDialect, tab.dbName]);
useEffect(() => {
if (!autoFetchVisible) {
return;
}
void loadData();
}, [autoFetchVisible, loadData]);
const tableSearchIndex = useMemo(() => buildTableOverviewSearchIndex(tables), [tables]);
const sortedFiltered = useMemo(() => (
filterAndSortTableOverviewRows(tableSearchIndex, deferredSearchText, sortField, sortOrder)
), [deferredSearchText, sortField, sortOrder, tableSearchIndex]);
useEffect(() => {
setVisibleTableLimit(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
}, [deferredSearchText, sortField, sortOrder, viewMode, tables]);
const visibleOverview = useMemo(() => (
resolveTableOverviewVisibleRows(sortedFiltered, visibleTableLimit)
), [sortedFiltered, visibleTableLimit]);
const visibleTables = visibleOverview.visibleRows;
const openTable = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `${connection.id}-${tab.dbName}-${tableName}`,
title: tableName,
type: 'table',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
});
}, [connection, tab.dbName, addTab, setActiveContext]);
const openDesign = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
title: `设计表 (${tableName})`,
type: 'design',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
initialTab: 'columns',
readOnly: false,
});
}, [connection, tab.dbName, addTab, setActiveContext]);
const buildConfig = useCallback(() => {
if (!connection) return null;
return {
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || '',
database: connection.config.database || '',
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
}, [connection]);
const handleCopyStructure = useCallback(async (tableName: string) => {
const config = buildConfig();
if (!config) return;
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
if (res.success) {
navigator.clipboard.writeText(res.data as string);
message.success('表结构已复制到剪贴板');
} else {
message.error(res.message);
}
}, [buildConfig, tab.dbName]);
const handleExport = useCallback(async (tableName: string, format: string) => {
const config = buildConfig();
if (!config) return;
const hide = message.loading(`正在导出 ${tableName}${format.toUpperCase()}...`, 0);
const res = await ExportTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, format);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
}, [buildConfig, tab.dbName]);
const handleDeleteTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
Modal.confirm({
title: '确认删除表',
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
const res = await DropTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
if (res.success) {
message.success('表删除成功');
loadData();
} else {
message.error('删除失败: ' + res.message);
}
},
});
}, [buildConfig, tab.dbName, loadData]);
const handleTableDataDangerAction = useCallback((tableName: string, action: TableDataDangerActionKind) => {
const config = buildConfig();
if (!config) return;
const { label, progressLabel } = getTableDataDangerActionMeta(action);
Modal.confirm({
title: `确认${label}`,
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
okText: '继续',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
const app = (window as any).go.app.App;
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
try {
const res = await app[methodName](buildRpcConnectionConfig(config) as any, tab.dbName || '', [tableName]);
hide();
if (res.success) {
message.success(`${progressLabel}成功`);
loadData();
} else {
message.error(`${progressLabel}失败: ${res.message}`);
return Promise.reject();
}
} catch (e: any) {
hide();
message.error(`${progressLabel}失败: ${e?.message || String(e)}`);
return Promise.reject();
}
},
});
}, [buildConfig, tab.dbName, loadData]);
const handleRenameTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
let newName = tableName;
Modal.confirm({
title: '重命名表',
content: (
<Input
{...noAutoCapInputProps}
defaultValue={tableName}
onChange={e => { newName = e.target.value; }}
placeholder="输入新表名"
autoFocus
style={{ marginTop: 8 }}
/>
),
onOk: async () => {
const trimmed = newName.trim();
if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); }
if (trimmed === tableName) { message.warning('新旧表名相同'); return; }
const res = await RenameTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, trimmed);
if (res.success) {
message.success('表重命名成功');
loadData();
} else {
message.error('重命名失败: ' + res.message);
}
},
});
}, [buildConfig, tab.dbName, loadData]);
// --- Theme ---
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
const textPrimary = darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)';
const textSecondary = darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)';
const textMuted = darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)';
const accentColor = '#1677ff';
const containerBg = darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.01)';
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder(field === 'name' ? 'asc' : 'desc');
}
};
const sortMenuItems = [
{ key: 'name', label: `按名称${sortField === 'name' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('name') },
{ key: 'rows', label: `按行数${sortField === 'rows' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('rows') },
{ key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') },
];
const totalRows = useMemo(() => tables.reduce((s, t) => s + t.rows, 0), [tables]);
const totalSize = useMemo(() => tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0), [tables]);
const maxCombinedSize = useMemo(() => sortedFiltered.reduce((max, table) => {
return Math.max(max, table.dataSize + table.indexSize);
}, 0), [sortedFiltered]);
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
<Spin size="large" tip="加载表信息..." />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
{/* Toolbar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
<DatabaseOutlined style={{ fontSize: 16, color: accentColor }} />
<span style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
<span style={{ fontSize: 12, color: textMuted }}>
{tables.length} · {formatRows(totalRows)} · {formatSize(totalSize)}
</span>
<div style={{ flex: 1 }} />
<Input
{...noAutoCapInputProps}
placeholder="搜索表名或注释..."
prefix={<SearchOutlined style={{ color: textMuted }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
style={{ width: 240 }}
size="small"
/>
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</Dropdown>
<div style={{ display: 'flex', gap: 2, padding: 2, borderRadius: 6, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
<Tooltip title="卡片视图">
<div
onClick={() => setViewMode('card')}
style={{
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
background: viewMode === 'card' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
boxShadow: viewMode === 'card' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: viewMode === 'card' ? accentColor : textMuted,
}}
>
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
</Tooltip>
<Tooltip title="列表视图">
<div
onClick={() => setViewMode('list')}
style={{
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
background: viewMode === 'list' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
boxShadow: viewMode === 'list' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: viewMode === 'list' ? accentColor : textMuted,
}}
>
<UnorderedListOutlined style={{ fontSize: 14 }} />
</div>
</Tooltip>
</div>
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</div>
{/* Content Area */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length > 0 && (isSearchPending || visibleOverview.hiddenCount > 0 || deferredSearchText.trim()) && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 10,
padding: '8px 10px',
borderRadius: 10,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.025)',
color: textMuted,
fontSize: 12,
}}
>
<span>
{isSearchPending
? '正在更新筛选结果...'
: `匹配 ${sortedFiltered.length} 张表,当前渲染 ${visibleTables.length}`}
</span>
{visibleOverview.hiddenCount > 0 && (
<span> {visibleOverview.hiddenCount} </span>
)}
</div>
)}
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : viewMode === 'card' ? (
/* ========== 卡片视图 ========== */
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}>
{visibleTables.map(t => (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: buildTableSelectQuery(metadataDialect, t.name),
});
}},
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
]},
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
]},
],
}}
>
<div
onDoubleClick={() => openTable(t.name)}
style={{
background: cardBg,
border: `1px solid ${cardBorder}`,
borderRadius: 10,
padding: '14px 16px',
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
{t.name}
</span>
</Tooltip>
</div>
{t.comment && (
<Tooltip title={t.comment} mouseEnterDelay={0.4}>
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.comment}
</div>
</Tooltip>
)}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
</div>
</div>
</Dropdown>
))}
</div>
) : (
/* ========== 行视图 ========== */
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{visibleTables.map(t => {
const combinedSize = t.dataSize + t.indexSize;
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%';
const fillColor = darkMode ? 'rgba(22,119,255,0.18)' : 'rgba(22,119,255,0.12)';
const rowSecondary = t.comment || (t.engine ? `${t.engine}` : '双击打开数据,右键查看更多操作');
return (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: buildTableSelectQuery(metadataDialect, t.name),
});
}},
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
]},
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
]},
],
}}
>
<div
onDoubleClick={() => openTable(t.name)}
style={{
position: 'relative',
overflow: 'hidden',
borderRadius: 10,
border: `1px solid ${cardBorder}`,
background: cardBg,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: fillWidth,
background: fillColor,
pointerEvents: 'none',
transition: 'width 0.2s ease',
}}
/>
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '14px 16px',
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0, flex: '1 1 320px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ color: textPrimary, fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.name}
</span>
</Tooltip>
{t.engine && (
<span
style={{
flexShrink: 0,
padding: '1px 6px',
borderRadius: 999,
fontSize: 11,
color: textMuted,
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
}}
>
{t.engine}
</span>
)}
</div>
<Tooltip title={rowSecondary} mouseEnterDelay={0.4}>
<div style={{ marginTop: 6, color: textSecondary, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{rowSecondary}
</div>
</Tooltip>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap', fontSize: 12 }}>
<div style={{ minWidth: 96, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</div>
</div>
<div style={{ minWidth: 110, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</div>
</div>
<div style={{ minWidth: 110, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</div>
</div>
<div style={{ minWidth: 96, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'}
</div>
</div>
</div>
</div>
</div>
</Dropdown>
);
})}
</div>
)}
{sortedFiltered.length > 0 && visibleOverview.hiddenCount > 0 && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0 4px' }}>
<Button
size="small"
onClick={() => setVisibleTableLimit(limit => limit + TABLE_OVERVIEW_RENDER_BATCH_SIZE)}
>
{visibleOverview.hiddenCount}
</Button>
</div>
)}
</div>
</div>
);
};
export default TableOverview;

View File

@@ -1,10 +1,9 @@
import React, { useState, useEffect } from 'react';
import Editor from '@monaco-editor/react';
import Editor, { loader } from '@monaco-editor/react';
import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';
import { DBQuery } from '../../wailsjs/go/app/App';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
interface TriggerViewerProps {
tab: TabData;
@@ -19,7 +18,31 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
// 初始化透明 Monaco Editor 主题
useEffect(() => {
loader.init().then(monaco => {
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#ffffff10',
'editorGutter.background': '#00000000',
}
});
monaco.editor.defineTheme('transparent-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#00000010',
'editorGutter.background': '#00000000',
}
});
});
}, []);
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
@@ -101,7 +124,7 @@ LIMIT 1`];
const sql = String(query || '').trim();
if (!sql) continue;
try {
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
const result = await DBQuery(config as any, dbName, sql);
if (!result.success || !Array.isArray(result.data)) {
lastMessage = result.message || lastMessage;
continue;
@@ -127,7 +150,7 @@ LIMIT 1`];
];
for (const query of candidates) {
try {
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
const result = await DBQuery(config as any, dbName, query);
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
continue;
}

View File

@@ -1,76 +0,0 @@
import React from 'react';
import { Button, Tooltip } from 'antd';
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIChatMessage } from '../../types';
interface AIChatHeaderProps {
darkMode: boolean;
mutedColor: string;
textColor: string;
overlayTheme: OverlayWorkbenchTheme;
onHistoryClick: () => void;
onClear: () => void;
onSettingsClick: () => void;
onClose: () => void;
messages?: AIChatMessage[];
sessionTitle?: string;
}
const exportToMarkdown = (messages: AIChatMessage[], title: string) => {
const lines: string[] = [`# ${title}`, '', `> 导出时间:${new Date().toLocaleString()}`, ''];
messages.forEach(msg => {
const role = msg.role === 'user' ? '👤 You' : '🤖 GoNavi AI';
lines.push(`## ${role}`);
lines.push('');
lines.push(msg.content);
lines.push('');
lines.push('---');
lines.push('');
});
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '-')}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
darkMode, mutedColor, textColor, overlayTheme,
onHistoryClick, onClear, onSettingsClick, onClose,
messages = [], sessionTitle = '新对话'
}) => {
return (
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
<div className="ai-chat-header-left" style={{ gap: 8 }}>
<Tooltip title="历史会话">
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
</Tooltip>
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
<RobotOutlined />
</div>
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
</div>
<div className="ai-chat-header-right">
{messages.length > 0 && (
<Tooltip title="导出为 Markdown">
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
</Tooltip>
)}
<Tooltip title="新对话 (清空当前)">
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="AI 设置">
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="关闭面板">
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
</Tooltip>
</div>
</div>
);
};

View File

@@ -1,93 +0,0 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { AIChatInput } from './AIChatInput';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
vi.mock('../../store', () => ({
useStore: (selector: (state: any) => any) => selector({
aiContexts: {},
addAIContext: vi.fn(),
removeAIContext: vi.fn(),
}),
}));
vi.mock('../../../wailsjs/go/app/App', () => ({
DBGetTables: vi.fn(),
DBShowCreateTable: vi.fn(),
DBGetDatabases: vi.fn(),
}));
describe('AIChatInput notice layout', () => {
it('renders the composer notice above the input editor', () => {
const markup = renderToStaticMarkup(
<AIChatInput
input=""
setInput={() => {}}
draftImages={[]}
setDraftImages={() => {}}
sending={false}
onSend={() => {}}
onStop={() => {}}
handleKeyDown={() => {}}
activeConnName=""
activeContext={null}
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
composerNotice={{
tone: 'error',
title: '模型列表加载失败',
description: '请检查供应商入口和 API Key。',
}}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
/>
);
const noticeIndex = markup.indexOf('data-ai-chat-composer-notice="true"');
const inputIndex = markup.indexOf('data-ai-chat-composer-input="true"');
expect(noticeIndex).toBeGreaterThanOrEqual(0);
expect(inputIndex).toBeGreaterThanOrEqual(0);
expect(noticeIndex).toBeLessThan(inputIndex);
});
it('renders the selected send shortcut in the composer placeholder', () => {
const markup = renderToStaticMarkup(
<AIChatInput
input=""
setInput={() => {}}
draftImages={[]}
setDraftImages={() => {}}
sending={false}
onSend={() => {}}
onStop={() => {}}
handleKeyDown={() => {}}
activeConnName=""
activeContext={null}
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
composerNotice={null}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
/>
);
expect(markup).toContain('Meta+Enter 发送');
});
});

View File

@@ -1,634 +0,0 @@
import React from 'react';
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
import { useStore } from '../../store';
import { DBGetTables, DBShowCreateTable, DBGetDatabases, DBGetColumns } from '../../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
import type { ShortcutBinding } from '../../utils/shortcuts';
interface AIChatInputProps {
input: string;
setInput: (val: string) => void;
draftImages: string[];
setDraftImages: React.Dispatch<React.SetStateAction<string[]>>;
sending: boolean;
onSend: () => void;
onStop: () => void;
handleKeyDown: (e: React.KeyboardEvent) => void;
activeConnName: string;
activeContext: any;
activeProvider: any;
dynamicModels: string[];
loadingModels: boolean;
sendShortcutBinding: ShortcutBinding;
composerNotice?: AIComposerNotice | null;
onModelChange: (val: string) => void;
onFetchModels: () => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
darkMode: boolean;
textColor: string;
mutedColor: string;
overlayTheme: OverlayWorkbenchTheme;
contextUsageChars?: number;
maxContextChars?: number;
}
export const AIChatInput: React.FC<AIChatInputProps> = ({
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
sendShortcutBinding, composerNotice,
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
contextUsageChars, maxContextChars
}) => {
const [contextOpen, setContextOpen] = React.useState(false);
const [contextLoading, setContextLoading] = React.useState(false);
const [contextTables, setContextTables] = React.useState<{name: string}[]>([]);
const [selectedTableKeys, setSelectedTableKeys] = React.useState<string[]>([]);
const [searchText, setSearchText] = React.useState('');
const [appendingContext, setAppendingContext] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
files.forEach(file => {
if (file.type.indexOf('image') !== -1) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(file);
}
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const [dbList, setDbList] = React.useState<string[]>([]);
const [selectedDbName, setSelectedDbName] = React.useState<string>('');
const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase()));
const [contextExpanded, setContextExpanded] = React.useState(false);
const composerNoticePalette = React.useMemo(() => {
if (composerNotice?.tone === 'error') {
return darkMode
? {
background: 'rgba(255,120,117,0.12)',
borderColor: 'rgba(255,120,117,0.24)',
iconColor: '#ff7875',
}
: {
background: 'rgba(255,77,79,0.08)',
borderColor: 'rgba(255,77,79,0.16)',
iconColor: '#ff4d4f',
};
}
return darkMode
? {
background: 'rgba(250,173,20,0.12)',
borderColor: 'rgba(250,173,20,0.22)',
iconColor: '#ffd666',
}
: {
background: 'rgba(250,173,20,0.08)',
borderColor: 'rgba(250,173,20,0.18)',
iconColor: '#d48806',
};
}, [composerNotice, darkMode]);
// Slash commands
const [showSlashMenu, setShowSlashMenu] = React.useState(false);
const [slashFilter, setSlashFilter] = React.useState('');
const slashCommands = React.useMemo(() => [
{ cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' },
{ cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL' },
{ cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```' },
{ cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n\n```' },
{ cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' },
{ cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' },
{ cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' },
{ cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' },
], []);
const filteredSlashCmds = slashCommands.filter(c => c.cmd.startsWith(slashFilter.toLowerCase()));
const aiContexts = useStore(state => state.aiContexts);
const addAIContext = useStore(state => state.addAIContext);
const removeAIContext = useStore(state => state.removeAIContext);
const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
const activeContextItems = aiContexts[connectionKey] || [];
const fetchTablesForDb = async (dbName: string, connConfig: any) => {
setContextLoading(true);
setSelectedDbName(dbName);
try {
const res = await DBGetTables(buildRpcConnectionConfig(connConfig), dbName);
if (res.success && Array.isArray(res.data)) {
setContextTables(res.data.map(r => ({ name: Object.values(r)[0] as string })));
} else {
message.error('获取表格失败: ' + res.message);
setContextTables([]);
}
} catch (e: any) {
message.error(e.message);
setContextTables([]);
} finally {
setContextLoading(false);
}
};
const handleOpenContext = async () => {
if (!activeContext?.connectionId) {
message.warning('请先在左侧选择一个数据库作为所聊上下文');
return;
}
const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId);
if (!conn) return;
setContextOpen(true);
setContextLoading(true);
setSearchText('');
// Store dbName::tableName composite keys
setSelectedTableKeys(activeContextItems.map(c => `${c.dbName}::${c.tableName}`));
try {
// Fetch databases
const dbRes = await DBGetDatabases(buildRpcConnectionConfig(conn.config) as any);
if (dbRes.success && Array.isArray(dbRes.data)) {
const databases = dbRes.data.map((r: any) => Object.values(r)[0] as string);
setDbList(databases);
}
// Fetch tables for the active contextual database
const initDbName = activeContext.dbName || '';
setSelectedDbName(initDbName);
const tablesRes = await DBGetTables(buildRpcConnectionConfig(conn.config) as any, initDbName);
if (tablesRes.success && Array.isArray(tablesRes.data)) {
setContextTables(tablesRes.data.map((r: any) => ({ name: Object.values(r)[0] as string })));
} else {
setContextTables([]);
}
} catch (e: any) {
message.error(e.message);
} finally {
setContextLoading(false);
}
};
const handleAppendContext = async () => {
const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId);
if (!conn) return;
setAppendingContext(true);
try {
let addedCount = 0;
let removedCount = 0;
for (const cx of activeContextItems) {
const key = `${cx.dbName}::${cx.tableName}`;
if (!selectedTableKeys.includes(key)) {
removeAIContext(connectionKey, cx.dbName, cx.tableName);
removedCount++;
}
}
for (const key of selectedTableKeys) {
const [dbName, tableName] = key.split('::');
if (!dbName || !tableName) continue;
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
continue;
}
const rpcConfig = buildRpcConnectionConfig(conn.config) as any;
const schemaResult = await resolveAITableSchemaToolResult({
tableName,
fetchDDL: () => DBShowCreateTable(rpcConfig, dbName, tableName),
fetchColumns: () => DBGetColumns(rpcConfig, dbName, tableName),
});
if (!schemaResult.success) {
message.error(`获取表 ${dbName}.${tableName} 结构失败: ${schemaResult.content}`);
}
if (schemaResult.success && schemaResult.content) {
addAIContext(connectionKey, {
dbName: dbName,
tableName: tableName,
ddl: schemaResult.content
});
addedCount++;
}
}
if (addedCount > 0 || removedCount > 0) {
if (addedCount > 0 && removedCount === 0) {
message.success(`已添加 ${addedCount} 张表的结构到上下文`);
} else if (removedCount > 0 && addedCount === 0) {
message.success(`已从上下文移除 ${removedCount} 张表的结构`);
} else {
message.success(`上下文已同步更新:新增 ${addedCount},移除 ${removedCount}`);
}
if (addedCount > 0) setContextExpanded(true);
} else {
message.info('选中的表未发生变化');
}
setContextOpen(false);
} catch (e: any) {
message.error(e.message);
} finally {
setAppendingContext(false);
}
};
return (
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
<div className="ai-chat-input-wrapper" style={{
borderColor: 'transparent',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 8,
padding: '8px 4px 8px'
}}>
<div className="ai-chat-input-preview-area" style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{activeContextItems.length > 0 && (
<Tag
onClick={() => setContextExpanded(!contextExpanded)}
style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }}
>
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
<DatabaseOutlined /> ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
</span>
</Tag>
)}
{contextExpanded && activeContextItems.map((ctx, idx) => (
<Tag
key={`ctx-${idx}`}
closable
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }}
>
<span style={{ fontSize: 13 }}>🗄 {ctx.tableName}</span>
</Tag>
))}
{draftImages.map((b64, i) => (
<div key={i} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${i}`} />
<div
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }}
>
</div>
</div>
))}
</div>
{composerNotice && (
<div
data-ai-chat-composer-notice="true"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: '8px 10px',
borderRadius: 12,
background: composerNoticePalette.background,
border: `1px solid ${composerNoticePalette.borderColor}`,
}}
>
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
{composerNotice.title}
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
{composerNotice.description}
</div>
</div>
</div>
)}
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
{showSlashMenu && filteredSlashCmds.length > 0 && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
background: darkMode ? '#2a2a2a' : '#fff',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 100,
maxHeight: 220, overflowY: 'auto', padding: 4
}}>
{filteredSlashCmds.map(cmd => (
<div
key={cmd.cmd}
style={{
padding: '8px 12px', borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 10,
transition: 'background 0.15s'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={() => {
setInput(cmd.prompt);
setShowSlashMenu(false);
setSlashFilter('');
textareaRef.current?.focus();
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor, minWidth: 80 }}>{cmd.cmd}</span>
<span style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{cmd.label}</span>
<span style={{ fontSize: 11, color: mutedColor, marginLeft: 'auto' }}>{cmd.desc}</span>
</div>
))}
</div>
)}
<Input.TextArea
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
if (blob) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(blob);
}
}
}
}}
ref={textareaRef as any}
value={input}
onChange={(e) => {
const val = e.target.value;
setInput(val);
// Slash command detection
if (val.startsWith('/')) {
setSlashFilter(val.split(/\s/)[0]);
setShowSlashMenu(true);
} else {
setShowSlashMenu(false);
setSlashFilter('');
}
}}
onKeyDown={handleKeyDown as any}
placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding)}Shift+Enter 换行,/ 快捷命令)`}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{activeConnName && (
<Tooltip title="当前数据查询上下文">
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 11, padding: '2px 8px', borderRadius: 12,
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
color: overlayTheme.mutedText, cursor: 'default'
}}>
<DatabaseOutlined style={{ fontSize: 10 }} />
<span style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
</span>
</div>
</Tooltip>
)}
{activeProvider && (
<Select
size="small"
variant="filled"
value={activeProvider.model || undefined}
onChange={onModelChange}
onDropdownVisibleChange={(open) => {
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
onFetchModels();
}
}}
loading={loadingModels}
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
style={{ width: 130, fontSize: 11, background: 'transparent' }}
dropdownStyle={{ minWidth: 200 }}
showSearch
placeholder="选择模型"
/>
)}
{contextUsageChars !== undefined && maxContextChars !== undefined && (
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k时将触发自动压缩。`}>
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 10, padding: '2px 6px', borderRadius: 12, border: '1px solid transparent',
background: contextUsageChars > maxContextChars * 0.8 ? (darkMode ? 'rgba(250, 173, 20, 0.1)' : 'rgba(250, 173, 20, 0.08)') : (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'),
borderColor: contextUsageChars > maxContextChars * 0.8 ? 'rgba(250, 173, 20, 0.3)' : 'transparent',
color: contextUsageChars > maxContextChars * 0.8 ? '#faad14' : overlayTheme.mutedText, cursor: 'default',
transition: 'all 0.3s'
}}>
<span>🧠 {(contextUsageChars / 1000).toFixed(1)}k / {(maxContextChars / 1000).toFixed(0)}k</span>
</div>
</Tooltip>
)}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
<input
type="file"
accept="image/*"
multiple
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleImageUpload}
/>
<Tooltip title="上传图片/截图">
<Button
type="text"
icon={<PictureOutlined style={{ fontSize: 16 }} />}
onClick={() => fileInputRef.current?.click()}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
onMouseEnter={e => e.currentTarget.style.color = textColor}
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
/>
</Tooltip>
<Tooltip title="关联附带数据库表上下文">
<Button
type="text"
icon={<TableOutlined style={{ fontSize: 16 }} />}
onClick={handleOpenContext}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
onMouseEnter={e => e.currentTarget.style.color = textColor}
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
/>
</Tooltip>
{sending ? (
<button
className="ai-chat-send-btn ai-chat-stop-btn"
onClick={onStop}
title="停止生成"
style={{
background: 'rgba(255,77,79,0.1)',
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
width: 26, height: 26, borderRadius: 6, padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
}}
>
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
</button>
) : (
<button
className="ai-chat-send-btn"
onClick={() => onSend()}
disabled={!input.trim() && draftImages.length === 0}
title="发送"
style={{
background: (input.trim() || draftImages.length > 0) ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
color: (input.trim() || draftImages.length > 0) ? overlayTheme.iconColor : mutedColor,
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: (input.trim() || draftImages.length > 0) ? 'pointer' : 'not-allowed', flexShrink: 0
}}
>
<SendOutlined />
</button>
)}
</div>
</div>
</div>
<Modal
title={<span style={{ color: textColor }}></span>}
open={contextOpen}
onCancel={() => setContextOpen(false)}
onOk={handleAppendContext}
confirmLoading={appendingContext}
okText="同步所选表至上下文"
cancelText="取消"
centered
styles={{
content: { background: darkMode ? '#1e1e1e' : '#ffffff', border: overlayTheme.shellBorder },
header: { background: darkMode ? '#1e1e1e' : '#ffffff', borderBottom: overlayTheme.shellBorder },
body: { padding: '20px 24px' }
}}
>
<Spin spinning={contextLoading}>
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
{dbList.length > 0 && (
<Select
value={selectedDbName}
onChange={val => {
const c = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
if (c) fetchTablesForDb(val, c.config);
}}
options={dbList.map(d => ({ label: d, value: d }))}
style={{ width: 160, flexShrink: 0 }}
placeholder="切换数据库"
showSearch
/>
)}
<Input
placeholder="在当前库搜索表名..."
prefix={<SearchOutlined style={{ color: overlayTheme.mutedText }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
style={{ background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', border: 'none', flexGrow: 1 }}
/>
</div>
{filteredTables.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`, paddingBottom: 12, marginBottom: 8 }}>
<Checkbox
indeterminate={
filteredTables.length > 0 &&
filteredTables.some(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`)) &&
!filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))
}
checked={filteredTables.length > 0 && filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))}
onChange={(e) => {
if (e.target.checked) {
const newSelected = new Set([...selectedTableKeys, ...filteredTables.map(t => `${selectedDbName}::${t.name}`)]);
setSelectedTableKeys(Array.from(newSelected));
} else {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
setSelectedTableKeys(selectedTableKeys.filter(key => !filteredKeys.includes(key)));
}
}}
style={{ color: textColor, fontWeight: 'bold' }}
>
({filteredTables.length})
</Checkbox>
<Button
type="link"
size="small"
style={{ padding: 0, height: 'auto', fontSize: 13 }}
onClick={() => {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
const remainingSelected = selectedTableKeys.filter(key => !filteredKeys.includes(key));
const toAdd = filteredKeys.filter(key => !selectedTableKeys.includes(key));
setSelectedTableKeys([...remainingSelected, ...toAdd]);
}}
>
</Button>
</div>
<div style={{ maxHeight: 300, overflowY: 'auto', margin: '0 -24px', padding: '0 24px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredTables.map(t => {
const key = `${selectedDbName}::${t.name}`;
const isSelected = selectedTableKeys.includes(key);
return (
<div
key={key}
style={{
padding: '6px 10px',
borderRadius: 6,
transition: 'background 0.2s',
cursor: 'pointer'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={(e) => {
// If click originated from the checkbox input itself, let its onChange handle it to avoid duplicate toggle
if ((e.target as HTMLElement).tagName.toLowerCase() === 'input') return;
if (isSelected) {
setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
} else {
setSelectedTableKeys([...selectedTableKeys, key]);
}
}}
>
<Checkbox
checked={isSelected}
onChange={(e) => {
if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]);
else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
}}
style={{ color: textColor, width: '100%' }}
>
<span style={{ fontSize: 13, userSelect: 'none' }}>{t.name}</span>
</Checkbox>
</div>
);
})}
</div>
</div>
</div>
) : (
<div style={{ padding: '40px 0', textAlign: 'center', color: overlayTheme.mutedText }}>
'{searchText}'
</div>
)}
</Spin>
</Modal>
</div>
);
};

View File

@@ -1,64 +0,0 @@
import React from 'react';
import { RobotOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
interface AIChatWelcomeProps {
overlayTheme: OverlayWorkbenchTheme;
quickActionBg: string;
quickActionBorder: string;
textColor: string;
mutedColor: string;
onQuickAction: (prompt: string, autoSend?: boolean) => void;
contextTableNames?: string[];
}
export const AIChatWelcome: React.FC<AIChatWelcomeProps> = ({
overlayTheme, quickActionBg, quickActionBorder, textColor, mutedColor, onQuickAction, contextTableNames = []
}) => {
const hasContext = contextTableNames.length > 0;
const tableList = contextTableNames.join('、');
const quickActions = hasContext
? [
{ label: '📝 生成 SQL', prompt: `请根据以下表结构生成一条常用查询语句:${tableList}` },
{ label: '🔍 解释表结构', prompt: `请详细解释以下表的设计意图和字段含义:${tableList}` },
{ label: '⚡ 优化建议', prompt: `请分析以下表的结构设计,给出索引优化和查询性能优化建议:${tableList}` },
{ label: '🏗️ Schema 分析', prompt: `请对以下表进行全面的 Schema 分析,包括数据类型选择、范式评估和改进建议:${tableList}` },
]
: [
{ label: '📝 生成 SQL', prompt: '请根据当前数据库表结构生成一条查询语句:' },
{ label: '🔍 解释 SQL', prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n\n```' },
{ label: '⚡ 优化建议', prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n\n```' },
{ label: '🏗️ Schema 分析', prompt: '请分析当前数据库的表结构并给出优化建议。' },
];
return (
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
GoNavi AI
</div>
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
{hasContext
? `已自动关联 ${contextTableNames.length} 张表结构,点击下方按钮快速开始分析。`
: '我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。'}
</div>
<div className="quick-actions">
{quickActions.map(action => (
<div
key={action.label}
className="quick-action-btn"
style={{
background: quickActionBg,
borderColor: quickActionBorder,
color: textColor,
}}
onClick={() => onQuickAction(action.prompt)}
>
{action.label}
</div>
))}
</div>
</div>
);
};

View File

@@ -1,127 +0,0 @@
import React, { useState } from 'react';
import { Drawer, Button, Tooltip, Input } from 'antd';
import { MenuFoldOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
import { useStore } from '../../store';
interface AIHistoryDrawerProps {
open: boolean;
onClose: () => void;
bgColor?: string;
darkMode: boolean;
textColor: string;
mutedColor: string;
borderColor: string;
onCreateNew: () => void;
sessionId: string;
}
export const AIHistoryDrawer: React.FC<AIHistoryDrawerProps> = ({
open, onClose, bgColor, darkMode, textColor, mutedColor, borderColor, onCreateNew, sessionId
}) => {
const aiChatSessions = useStore(state => state.aiChatSessions);
const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId);
const deleteAISession = useStore(state => state.deleteAISession);
// 阶段4: 历史记录搜索
const [searchText, setSearchText] = useState('');
const filteredSessions = aiChatSessions.filter(s =>
!searchText || (s.title && s.title.toLowerCase().includes(searchText.toLowerCase()))
);
return (
<Drawer
placement="left"
closable={false}
onClose={onClose}
open={open}
getContainer={false}
style={{ position: 'absolute', background: bgColor || (darkMode ? '#1e1e1e' : '#f8f9fa') }}
width={260}
bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }}
>
{/* 侧拉面板头部 */}
<div style={{ padding: '16px 16px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor }}></span>
<Tooltip title="收起">
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={onClose} style={{ color: mutedColor }} />
</Tooltip>
</div>
{/* 新建对话按钮 */}
<div style={{ padding: '0 12px 12px' }}>
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={() => { onCreateNew(); onClose(); }}
style={{ borderColor: borderColor, color: textColor, background: 'transparent' }}
>
</Button>
</div>
{/* 列表搜索 */}
<div style={{ padding: '0 12px 12px' }}>
<Input
placeholder="搜索历史记录..."
prefix={<SearchOutlined style={{ color: mutedColor }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
variant="filled"
size="small"
style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'transparent', color: textColor }}
/>
</div>
{/* 列表容器 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 10px 16px' }} className="ai-history-list">
{filteredSessions.length === 0 ? (
<div style={{ padding: '30px 0', textAlign: 'center', color: mutedColor, fontSize: 12 }}></div>
) : (
filteredSessions.map(session => (
<div
key={session.id}
className={`ai-history-item ${sessionId === session.id ? 'active' : ''}`}
onClick={() => { setAIActiveSessionId(session.id); onClose(); }}
style={{
padding: '10px 12px',
borderRadius: 6,
marginBottom: 4,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: sessionId === session.id ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : 'transparent',
transition: 'background 0.2s',
}}
>
<div style={{ overflow: 'hidden', flex: 1, paddingRight: 8 }}>
<div style={{ fontSize: 13, color: textColor, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: sessionId === session.id ? 600 : 'normal' }}>
{session.title || '新对话'}
</div>
<div style={{ fontSize: 11, color: mutedColor, marginTop: 4 }}>
{new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<Tooltip title="删除">
<Button
className="ai-history-delete-btn"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
deleteAISession(session.id);
}}
style={{ display: sessionId === session.id ? 'inline-flex' : undefined }}
/>
</Tooltip>
</div>
))
)}
</div>
</Drawer>
);
};

View File

@@ -1,833 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button, Tooltip, message } from 'antd';
import { UserOutlined, RobotOutlined, EditOutlined, ReloadOutlined, DeleteOutlined, CheckOutlined, CopyOutlined, PlayCircleOutlined, ApiOutlined, LoadingOutlined, CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import mermaid from 'mermaid';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import type { AIChatMessage, AIToolCall } from '../../types';
import { useStore } from '../../store';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { normalizeAiMarkdown } from '../../utils/aiMarkdown';
import { extractJVMChangePlan, resolveJVMAIPlanTargetTabId } from '../../utils/jvmAiPlan';
import {
parseJVMDiagnosticPlan,
resolveJVMDiagnosticPlanTargetTabId,
} from '../../utils/jvmDiagnosticPlan';
import { buildAIReadonlyPreviewSQL } from '../../utils/aiSqlLimit';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
const MemoizedMarkdown = React.memo(({
content,
darkMode,
overlayTheme,
activeConnectionConfig,
activeConnectionId,
activeDbName
}: {
content: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
activeConnectionConfig?: any;
activeConnectionId?: string;
activeDbName?: string;
}) => {
const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]);
// 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo
const components = React.useMemo(() => ({
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
if (!inline && match && match[1] === 'mermaid') {
return <MermaidRenderer chart={String(children).replace(/\n$/, '')} darkMode={darkMode} />;
}
return !inline && match ? (
<AIBlockHashRender match={match} darkMode={darkMode} overlayTheme={overlayTheme} children={children} activeConnectionConfig={activeConnectionConfig} activeConnectionId={activeConnectionId} activeDbName={activeDbName} />
) : (
<code className={className} {...props}>
{children}
</code>
);
}
}), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]);
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{normalizedContent}
</ReactMarkdown>
);
});
interface AIMessageBubbleProps {
msg: AIChatMessage;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
textColor: string;
onEdit: (msg: AIChatMessage) => void;
onRetry: (msg: AIChatMessage) => void;
onDelete: (id: string) => void;
activeConnectionId?: string;
activeConnectionConfig?: any;
activeDbName?: string;
allMessages?: AIChatMessage[];
}
const AIToolResultItem: React.FC<{ resultMsg: AIChatMessage, darkMode: boolean, overlayTheme: OverlayWorkbenchTheme }> = ({ resultMsg, darkMode, overlayTheme }) => {
const [toolExpanded, setToolExpanded] = useState(false);
const charCount = resultMsg.content ? resultMsg.content.length : 0;
return (
<div style={{
background: darkMode ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.02)',
borderRadius: 6,
padding: '6px 10px',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`,
marginTop: 8,
width: '100%'
}}>
<div
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', gap: 6, fontSize: 12, color: overlayTheme.mutedText }}
onClick={() => setToolExpanded(!toolExpanded)}
>
{toolExpanded ? <CaretDownOutlined /> : <CaretRightOutlined />}
<ApiOutlined style={{ color: '#1677ff' }} />
<span> (<span style={{ fontFamily: 'monospace', color: overlayTheme.iconColor }}>{resultMsg.tool_name || 'unknown'}</span>)</span>
<span style={{ fontSize: 11, marginLeft: 8, opacity: 0.6 }}>{charCount > 0 ? `${charCount} 个字符` : '无数据'}</span>
</div>
{toolExpanded && (
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 300, overflowY: 'auto', background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.03)', padding: 8, borderRadius: 6 }}>
{resultMsg.content}
</div>
)}
</div>
);
};
const MermaidRenderer = ({ chart, darkMode }: { chart: string, darkMode: boolean }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (containerRef.current) {
try {
mermaid.initialize({ startOnLoad: false, theme: darkMode ? 'dark' : 'default' });
const id = `mermaid-${Math.random().toString(36).substring(2)}`;
(async () => {
const result: any = await mermaid.render(id, chart);
if (containerRef.current) {
containerRef.current.innerHTML = result.svg || result;
}
})().catch((e: any) => {
if (containerRef.current) {
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 解析失败: ${e.message}</div>`;
}
});
} catch (e: any) {
if (containerRef.current) {
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 渲染异常: ${e.message}</div>`;
}
}
}
}, [chart, darkMode]);
return <div ref={containerRef} className="ai-mermaid-container" style={{ margin: '16px 0', display: 'flex', justifyContent: 'flex-start', overflowX: 'auto' }} />;
};
const CodeCopyBtn = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false);
return (
<span
className="ai-code-copy-btn"
onClick={() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
opacity: copied ? 1 : 0.6,
transition: 'opacity 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = copied ? '1' : '0.6'; }}
>
{copied ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
<span style={{ marginLeft: 4 }}>{copied ? '已复制' : '复制代码'}</span>
</span>
);
};
const CodeRunBtn = ({ text, connectionId, dbName }: { text: string; connectionId?: string; dbName?: string }) => {
// 解析 SQL 顶部的 @context 注释,格式:-- @context connectionId=xxx dbName=yyy
const contextMatch = text.match(/^--\s*@context\s+connectionId=(\S+)\s+dbName=(\S+)/m);
const resolvedConnId = contextMatch?.[1] || connectionId;
const resolvedDbName = contextMatch?.[2] || dbName;
// 发送给查询编辑器时去掉 @context 注释行
const cleanSql = text.replace(/^--\s*@context\s+.*\n?/gm, '').trim();
const sqlDetail = (runImmediately: boolean) => ({ sql: cleanSql, runImmediately, connectionId: resolvedConnId, dbName: resolvedDbName });
const handleExecute = async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AICheckSQL) {
const result = await Service.AICheckSQL(text);
if (!result.allowed) {
message.error(`🔒 安全策略拦截:当前安全级别不允许执行 ${result.operationType} 类型的 SQL。请在 AI 设置中调整安全级别。`);
return;
}
if (result.requiresConfirm) {
const { Modal } = await import('antd');
Modal.confirm({
title: '⚠️ 安全确认',
content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`,
okText: '确认执行',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
},
});
return;
}
}
// Safety check passed or not available, execute directly
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
} catch (e) {
// If safety check fails, still allow manual execution
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
}
};
return (
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<Tooltip title="将该段 SQL 注入查询工作区(可快捷修改或执行)">
<span
className="ai-code-run-btn"
onClick={() => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(false) }));
}}
style={{
cursor: 'pointer', display: 'flex', alignItems: 'center',
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#10b981'
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
>
<PlayCircleOutlined />
<span style={{ marginLeft: 4 }}></span>
</span>
</Tooltip>
<Tooltip title="立即执行(受 AI 安全策略管控)">
<span
className="ai-code-run-btn"
onClick={handleExecute}
style={{
cursor: 'pointer', display: 'flex', alignItems: 'center',
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#1677ff'
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
>
<PlayCircleOutlined />
<span style={{ marginLeft: 4 }}></span>
</span>
</Tooltip>
</div>
);
};
// 阶段2: 代码块体验升级 (折叠展开、行号显示、内联SQL预览)
const AIBlockHashRender = ({ match, darkMode, overlayTheme, children, activeConnectionConfig, activeConnectionId, activeDbName }: any) => {
const codeText = String(children).replace(/\n$/, '');
// 将 @context 注释行从显示文本中剔除,用户无需看到内部元数据
const displayText = codeText.replace(/^--\s*@context\s+.*\n?/gm, '').trim();
const [expanded, setExpanded] = useState(false);
const [previewData, setPreviewData] = useState<any[] | null>(null);
const [previewCols, setPreviewCols] = useState<string[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState('');
const [previewExpanded, setPreviewExpanded] = useState(false);
const MAX_HEIGHT = 300;
const isLongCode = displayText.split('\n').length > 15;
const isSql = match[1] === 'sql';
const isSelectQuery = isSql && /^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i.test(displayText.trim());
const handleInlineExecute = async () => {
if (!activeConnectionConfig || previewLoading) return;
setPreviewLoading(true);
setPreviewError('');
setPreviewData(null);
try {
const { DBQuery } = await import('../../../wailsjs/go/app/App');
const previewSql = buildAIReadonlyPreviewSQL(
activeConnectionConfig?.type || '',
displayText,
50,
activeConnectionConfig?.driver || '',
);
const res = await DBQuery(activeConnectionConfig, activeDbName || '', previewSql);
if (res.success && Array.isArray(res.data)) {
const rows = res.data as any[];
const cols = rows.length > 0 ? Object.keys(rows[0]) : [];
setPreviewCols(cols);
setPreviewData(rows.slice(0, 20));
setPreviewExpanded(true);
} else {
setPreviewError(res.message || '查询无结果');
}
} catch (err: any) {
setPreviewError(err?.message || '执行失败');
} finally {
setPreviewLoading(false);
}
};
return (
<div className="ai-code-block-container" style={{ margin: '12px 0', border: overlayTheme.sectionBorder, borderRadius: 6, overflow: 'hidden' }}>
<div className="ai-code-header" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '6px 12px', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
fontSize: 12, color: overlayTheme.mutedText
}}>
<span style={{ fontFamily: 'monospace' }}>{match[1]}</span>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
{isSql && <CodeRunBtn text={codeText} connectionId={activeConnectionId} dbName={activeDbName} />}
{isSelectQuery && activeConnectionConfig && (
<Tooltip title="在聊天内预览查询结果最多20行">
<span
onClick={handleInlineExecute}
style={{
cursor: previewLoading ? 'wait' : 'pointer', display: 'flex', alignItems: 'center',
opacity: previewLoading ? 1 : 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#faad14'
}}
onMouseEnter={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '0.6'; }}
>
{previewLoading ? '⏳' : '👁'}
<span style={{ marginLeft: 4 }}>{previewLoading ? '执行中...' : '预览'}</span>
</span>
</Tooltip>
)}
<CodeCopyBtn text={displayText} />
</div>
</div>
<div style={{ position: 'relative' }}>
<SyntaxHighlighter
style={darkMode ? vscDarkPlus as any : vs as any}
language={match[1]}
PreTag="div"
showLineNumbers={true}
customStyle={{
margin: 0,
borderRadius: 0,
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.02)',
maxHeight: expanded ? 'none' : (isLongCode ? MAX_HEIGHT : 'none'),
overflowY: expanded ? 'auto' : 'hidden',
fontSize: '14px',
lineHeight: 1.6
}}
codeTagProps={{
style: {
fontSize: '14px',
fontFamily: 'Menlo, Monaco, Consolas, "Courier New", monospace'
}
}}
>
{displayText}
</SyntaxHighlighter>
{!expanded && isLongCode && (
<div
style={{
position: 'absolute',
bottom: 0, left: 0, right: 0,
height: 60,
background: `linear-gradient(to bottom, transparent, ${darkMode ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)'})`,
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
paddingBottom: 8, cursor: 'pointer'
}}
onClick={() => setExpanded(true)}
>
<span style={{ fontSize: 12, color: overlayTheme.iconColor, background: darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', padding: '2px 8px', borderRadius: 12 }}>
</span>
</div>
)}
{expanded && isLongCode && (
<div
style={{
display: 'flex', justifyContent: 'center', padding: '6px 0',
background: darkMode ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.02)', cursor: 'pointer',
borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`
}}
onClick={() => setExpanded(false)}
>
<span style={{ fontSize: 12, color: overlayTheme.iconColor }}></span>
</div>
)}
</div>
{/* Inline SQL Preview Results */}
{previewError && (
<div style={{ padding: '8px 12px', fontSize: 12, color: '#ef4444', background: darkMode ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.05)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}` }}>
{previewError}
</div>
)}
{previewExpanded && previewData && previewData.length > 0 && (
<div style={{ borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 12px', background: darkMode ? 'rgba(250,173,20,0.08)' : 'rgba(250,173,20,0.05)' }}>
<span style={{ fontSize: 11, color: overlayTheme.mutedText }}>📊 {previewData.length} × {previewCols.length} </span>
<span style={{ fontSize: 11, color: overlayTheme.mutedText, cursor: 'pointer' }} onClick={() => setPreviewExpanded(false)}> </span>
</div>
<div style={{ overflowX: 'auto', maxHeight: 200, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11, fontFamily: 'monospace' }}>
<thead>
<tr>
{previewCols.map(col => (
<th key={col} style={{ padding: '4px 8px', textAlign: 'left', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', color: overlayTheme.titleText, fontWeight: 600, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}` }}>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{previewData.map((row, ri) => (
<tr key={ri}>
{previewCols.map(col => (
<td key={col} style={{ padding: '3px 8px', color: overlayTheme.mutedText, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'}`, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{row[col] === null ? <span style={{ color: '#999', fontStyle: 'italic' }}>NULL</span> : String(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!previewExpanded && previewData && previewData.length > 0 && (
<div
style={{ padding: '4px 12px', cursor: 'pointer', fontSize: 11, color: overlayTheme.mutedText, background: darkMode ? 'rgba(250,173,20,0.05)' : 'rgba(250,173,20,0.03)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}` }}
onClick={() => setPreviewExpanded(true)}
>
📊 {previewData.length}
</div>
)}
</div>
);
};
// 可折叠思考过程组件
const ThinkingBlock: React.FC<{ displayThinking: string; totalLen: number; isTyping: boolean; isGlobalLoading: boolean; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ displayThinking, totalLen, isTyping, isGlobalLoading, darkMode, overlayTheme, hasContent }) => {
// 如果整体在loading且尚未吐出content我们认为真正的思考还在进行如果吐出content了思考框就算告一段落
const isActivelyThinking = isGlobalLoading && !hasContent;
const [expanded, setExpanded] = useState(isActivelyThinking);
const contentRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => { if (isActivelyThinking) setExpanded(true); }, [isActivelyThinking]);
// 断开连接或思考结束时,若已有内容且不再产生新内容则默认收起
React.useEffect(() => {
if (!isGlobalLoading) setExpanded(false);
}, [isGlobalLoading]);
// 自动滚动到思考内容底部
React.useEffect(() => {
if (expanded && isTyping && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
}, [displayThinking, expanded, isTyping]);
return (
<div style={{
marginBottom: hasContent ? 8 : 0,
borderRadius: 6,
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
overflow: 'hidden',
}}>
<div
onClick={() => setExpanded(e => !e)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 10px', cursor: 'pointer',
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
fontSize: 12, color: overlayTheme.mutedText, userSelect: 'none',
}}
>
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10 }}></span>
<span>💭 </span>
{isActivelyThinking && <span style={{ fontSize: 10, color: '#8b5cf6', animation: 'pulse 1.5s ease-in-out infinite' }}>...</span>}
{!isActivelyThinking && <span style={{ fontSize: 10, opacity: 0.5 }}>({displayThinking.length} )</span>}
</div>
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
<div ref={contentRef} style={{
padding: expanded ? '8px 12px' : '0 12px',
borderLeft: '3px solid #8b5cf6',
margin: '0 8px 8px',
fontSize: 12, lineHeight: 1.7,
color: overlayTheme.mutedText,
fontStyle: 'italic',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
maxHeight: 400, overflowY: 'auto',
}}>
{displayThinking}
{isTyping && <span className="ai-blinking-cursor" style={{ background: '#8b5cf6', marginLeft: 4, width: 6, height: 12, display: 'inline-block', verticalAlign: 'middle', opacity: 0.8 }} />}
</div>
</div>
</div>
);
};
// 工具调用进度面板聚合展示组件
const AIToolCallingBlock: React.FC<{ tool_calls: AIToolCall[]; loading: boolean; allMessages: AIChatMessage[]; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ tool_calls, loading, allMessages, darkMode, overlayTheme, hasContent }) => {
const totalCalls = tool_calls.length;
const allDone = tool_calls.every(tc => allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id));
const [expanded, setExpanded] = useState(!allDone && loading);
// 断开连接或执行完毕时,若已完成则默认收起
React.useEffect(() => {
if (allDone || !loading) setExpanded(false);
}, [allDone, loading]);
// 显示友好的人类可读动作名
const getHumanActionName = (fname: string) => {
if (fname === 'get_connections') return '获取可用连接信息';
if (fname === 'get_databases') return '扫描数据库列表';
if (fname === 'get_tables') return '分析表结构信息';
return fname;
};
return (
<div style={{
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.025)',
borderRadius: 8, fontSize: 12, overflow: 'hidden',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
marginTop: hasContent ? 12 : 0,
display: 'flex', flexDirection: 'column',
}}>
<div
onClick={() => setExpanded(!expanded)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', cursor: 'pointer', userSelect: 'none',
background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.titleText, fontWeight: 500 }}>
{!allDone && loading ? (
<div className="ai-spinning-ring" />
) : (
<CheckOutlined style={{ color: '#10b981' }} />
)}
<span>{!allDone && loading ? '正在执行数据探针...' : `数据探针执行完毕 (${totalCalls} 项)`}</span>
</div>
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10, color: overlayTheme.mutedText }}></span>
</div>
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
<div style={{ padding: expanded ? '4px 12px 12px' : '0 12px' }}>
{tool_calls.map((tc, idx) => {
const resultMsg = allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id);
const isDone = !!resultMsg;
const actionName = getHumanActionName(tc.function.name);
return (
<div key={tc.id} style={{
display: 'flex', flexDirection: 'column', gap: 4,
marginTop: 6, paddingLeft: 8,
borderLeft: `2px solid ${isDone ? '#10b981' : (loading ? '#1677ff' : overlayTheme.shellBorder)}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isDone
? <CheckOutlined style={{ color: '#10b981', fontSize: 11 }} />
: (loading ? <div className="ai-spinning-ring" style={{ width: 10, height: 10, borderWidth: 1.5 }} /> : <ApiOutlined style={{ color: overlayTheme.mutedText, fontSize: 11 }} />)
}
<span style={{ color: isDone ? overlayTheme.mutedText : overlayTheme.titleText }}>{actionName}</span>
</div>
{resultMsg && <AIToolResultItem resultMsg={resultMsg} darkMode={darkMode} overlayTheme={overlayTheme} />}
</div>
);
})}
</div>
</div>
</div>
);
};
export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg, darkMode, overlayTheme, textColor, onEdit, onRetry, onDelete, activeConnectionId, activeConnectionConfig, activeDbName, allMessages }) => {
const [isCopied, setIsCopied] = useState(false);
const isUser = msg.role === 'user';
// 从 content 中提取 <think>...</think> 标签内容(部分模型如 MiniMax、DeepSeek 会以文本形式返回思考过程)
const { displayContent, parsedThinking } = React.useMemo(() => {
const content = msg.content || '';
// 优先使用后端已结构化的 thinking 字段(如 Claude API 原生 thinking
if (msg.thinking) {
return { displayContent: content, parsedThinking: msg.thinking };
}
// 尝试从 content 中提取 <think>...</think> 标签
const thinkRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g;
let thinkParts: string[] = [];
let cleanContent = content;
let match;
while ((match = thinkRegex.exec(content)) !== null) {
thinkParts.push(match[1].trim());
}
if (thinkParts.length > 0) {
// 移除所有 <think>...</think> 标签(含未闭合的)
cleanContent = content.replace(/<think>[\s\S]*?(?:<\/think>|$)/g, '').trim();
return { displayContent: cleanContent, parsedThinking: thinkParts.join('\n\n') };
}
return { displayContent: content, parsedThinking: '' };
}, [msg.content, msg.thinking]);
const jvmPlan = React.useMemo(() => {
if (isUser) {
return null;
}
return extractJVMChangePlan(displayContent);
}, [displayContent, isUser]);
const jvmDiagnosticPlan = React.useMemo(() => {
if (isUser) {
return null;
}
return parseJVMDiagnosticPlan(displayContent);
}, [displayContent, isUser]);
const isTypingThinking = !!(msg.loading && msg.phase === 'thinking');
if (msg.role === 'tool') return null;
// 如果是纯空壳的加载状态connecting或还在思考/工具阶段但还没吐出一个字的 content
const isWaitState = msg.phase === 'connecting' ||
(msg.loading && !msg.content && (msg.phase === 'thinking' || msg.phase === 'tool_calling'));
if (isWaitState) {
return (
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
<div style={{
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
borderRadius: 12, padding: '14px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.mutedText }}>
<div className="ai-wave-pulse">
<span /> <span /> <span />
</div>
<span style={{ fontSize: 13, opacity: 0.8 }}>{msg.content || '正在建立连接'}...</span>
</div>
{/* 即使在波纹过渡态,如果有 thinking / tool_calls 也要显示出来,只是把它们压在波纹下面 */}
<div style={{ marginTop: parsedThinking || (msg.tool_calls && msg.tool_calls.length > 0) ? 12 : 0 }}>
{!isUser && parsedThinking && (
<ThinkingBlock
displayThinking={parsedThinking}
totalLen={parsedThinking.length}
isTyping={isTypingThinking}
isGlobalLoading={!!msg.loading}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={false}
/>
)}
{!isUser && msg.tool_calls && msg.tool_calls.length > 0 && (
<AIToolCallingBlock
tool_calls={msg.tool_calls}
loading={!!msg.loading}
allMessages={allMessages || []}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={false}
/>
)}
</div>
</div>
</div>
);
}
return (
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
<div style={{
background: isUser ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
borderRadius: 12,
padding: '14px 16px',
}}>
<div className="ai-ide-message-header" style={{
color: isUser ? overlayTheme.mutedText : overlayTheme.titleText,
marginBottom: isUser ? 6 : 10,
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
}}>
<div>
{isUser
? <><UserOutlined /> <span>You</span></>
: <><RobotOutlined style={{ color: overlayTheme.iconColor }} /> <span>GoNavi AI</span></>}
</div>
{/* 气泡操作栏 */}
<div className="ai-message-actions" style={{ display: 'flex', gap: 8, opacity: 0, transition: 'opacity 0.2s', padding: '0 4px' }}>
<Tooltip title={isCopied ? "已复制" : "复制全文"}>
{isCopied ? (
<CheckOutlined className="ai-action-icon" style={{ color: '#10b981' }} />
) : (
<CopyOutlined className="ai-action-icon" onClick={() => {
navigator.clipboard.writeText(msg.content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
}} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
)}
</Tooltip>
{isUser ? (
<Tooltip title="编辑此条消息(移除其后所有记录并重新发送)">
<EditOutlined className="ai-action-icon" onClick={() => onEdit(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
) : (
<Tooltip title="重新生成(移除此条并触发上次用户输入重发)">
<ReloadOutlined className="ai-action-icon" onClick={() => onRetry(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
)}
<Tooltip title="删除单条消息">
<DeleteOutlined className="ai-action-icon" onClick={() => onDelete(msg.id)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
</div>
</div>
<div className="ai-ide-message-content ai-markdown-content" style={{ color: textColor }}>
{msg.images && msg.images.length > 0 && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
{msg.images.map((img, i) => (
<img key={i} src={img} alt={`Attached ${i}`} style={{ maxWidth: 200, maxHeight: 200, borderRadius: 8, objectFit: 'contain', border: overlayTheme.shellBorder }} />
))}
</div>
)}
{/* 可折叠思考过程 */}
{!isUser && parsedThinking && (
<ThinkingBlock
displayThinking={parsedThinking}
totalLen={parsedThinking.length}
isTyping={isTypingThinking}
isGlobalLoading={!!msg.loading}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={!!msg.content}
/>
)}
{isUser ? (
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 13 }}>{msg.content}</div>
) : (
<MemoizedMarkdown
content={displayContent}
darkMode={darkMode}
overlayTheme={overlayTheme}
activeConnectionConfig={activeConnectionConfig}
activeConnectionId={activeConnectionId}
activeDbName={activeDbName}
/>
)}
{!isUser && jvmPlan && (
<div style={{ marginTop: 12 }}>
<Button
size="small"
type="primary"
onClick={() => {
const targetContext = msg.jvmPlanContext;
if (!targetContext) {
message.warning('这条 JVM 计划缺少来源页签上下文,请在目标 JVM 资源页重新生成。');
return;
}
const store = useStore.getState();
const targetTabId = resolveJVMAIPlanTargetTabId(store.tabs, targetContext);
if (!targetTabId) {
message.warning('未找到与该 JVM 计划匹配的资源页签,请先打开原目标资源后再应用。');
return;
}
window.dispatchEvent(new CustomEvent('gonavi:jvm-apply-ai-plan', {
detail: {
plan: jvmPlan,
targetTabId,
connectionId: targetContext.connectionId,
providerMode: targetContext.providerMode,
resourcePath: targetContext.resourcePath,
},
}));
}}
>
JVM
</Button>
</div>
)}
{!isUser && jvmDiagnosticPlan && (
<div style={{ marginTop: 12 }}>
<Button
size="small"
type="primary"
onClick={() => {
const targetContext = msg.jvmDiagnosticPlanContext;
if (!targetContext) {
message.warning('这条诊断计划缺少来源页签上下文,请在目标诊断控制台重新生成。');
return;
}
const store = useStore.getState();
const targetTabId = resolveJVMDiagnosticPlanTargetTabId(
store.tabs,
store.connections,
targetContext,
);
if (!targetTabId) {
message.warning('未找到与该诊断计划匹配的诊断控制台页签,请先打开原目标控制台后再应用。');
return;
}
window.dispatchEvent(new CustomEvent('gonavi:jvm-apply-diagnostic-plan', {
detail: {
plan: jvmDiagnosticPlan,
targetTabId,
connectionId: targetContext.connectionId,
transport: targetContext.transport,
},
}));
}}
>
</Button>
</div>
)}
{/* 错误原文复制按钮 */}
{!isUser && msg.rawError && (
<div style={{ marginTop: 8 }}>
<button
onClick={() => {
navigator.clipboard.writeText(msg.rawError || '');
const btn = document.getElementById(`raw-err-btn-${msg.id}`);
if (btn) { btn.textContent = '✅ 已复制'; setTimeout(() => { btn.textContent = '📋 复制报错原文'; }, 1500); }
}}
id={`raw-err-btn-${msg.id}`}
style={{
fontSize: 12, padding: '3px 10px', borderRadius: 6, cursor: 'pointer',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'}`,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
color: overlayTheme.mutedText, transition: 'all 0.15s ease',
}}
>
📋
</button>
</div>
)}
{/* 工具调用进度展示 */}
{!isUser && msg.tool_calls && msg.tool_calls.length > 0 && (
<AIToolCallingBlock
tool_calls={msg.tool_calls}
loading={!!msg.loading}
allMessages={allMessages || []}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={!!msg.content}
/>
)}
{msg.loading && msg.phase !== 'tool_calling' && msg.content && (
<span className="ai-blinking-cursor" style={{ background: overlayTheme.iconColor }} />
)}
</div>
</div>
</div>
);
});

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
calculateAutoFitColumnWidth,
normalizeAutoFitCellText,
} from './dataGridAutoWidth';
const measure = (text: string) => text.length * 8;
describe('dataGridAutoWidth helpers', () => {
it('prefers the widest header or sampled value and adds padding', () => {
const width = calculateAutoFitColumnWidth({
headerTexts: ['user_name'],
valueTexts: ['alice', 'very_long_username_value'],
measureHeaderText: measure,
measureCellText: measure,
padding: 32,
minWidth: 80,
maxWidth: 720,
defaultWidth: 140,
});
expect(width).toBe('very_long_username_value'.length * 8 + 32);
});
it('measures multiline content by the longest visible line and clamps to max width', () => {
const width = calculateAutoFitColumnWidth({
headerTexts: ['notes'],
valueTexts: ['short\nmuch much longer line here'],
measureHeaderText: measure,
measureCellText: measure,
padding: 24,
minWidth: 80,
maxWidth: 160,
defaultWidth: 140,
});
expect(width).toBe(160);
});
it('normalizes null and oversized object values into stable preview text', () => {
expect(normalizeAutoFitCellText(null)).toBe('NULL');
expect(normalizeAutoFitCellText({ a: 1, b: 2 })).toBe('{"a":1,"b":2}');
expect(normalizeAutoFitCellText(Array.from({ length: 81 }, (_, index) => index))).toBe('[Array(81)]');
});
});

View File

@@ -1,108 +0,0 @@
const AUTO_FIT_DEFAULT_MIN_WIDTH = 80;
const AUTO_FIT_DEFAULT_MAX_WIDTH = 720;
const AUTO_FIT_DEFAULT_PADDING = 40;
const AUTO_FIT_DEFAULT_SAMPLE_LIMIT = 200;
const AUTO_FIT_MAX_PREVIEW_CHARS = 120;
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return Object.prototype.toString.call(value) === '[object Object]';
};
const clampWidth = (value: number, minWidth: number, maxWidth: number) => {
const safeMin = Math.max(1, Math.floor(minWidth));
const safeMax = Math.max(safeMin, Math.floor(maxWidth));
return Math.min(safeMax, Math.max(safeMin, Math.ceil(value)));
};
const normalizePreviewLine = (value: string): string => {
const normalized = String(value ?? '').replace(/\r\n/g, '\n');
if (normalized.length <= AUTO_FIT_MAX_PREVIEW_CHARS) {
return normalized;
}
return `${normalized.slice(0, AUTO_FIT_MAX_PREVIEW_CHARS)}`;
};
const splitPreviewLines = (value: string): string[] => {
return normalizePreviewLine(value)
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.length > 0);
};
export const normalizeAutoFitCellText = (value: unknown): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (typeof value === 'string') {
return normalizePreviewLine(value);
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
if (Array.isArray(value)) {
if (value.length > 80) {
return `[Array(${value.length})]`;
}
try {
return normalizePreviewLine(JSON.stringify(value));
} catch {
return '[Array]';
}
}
if (isPlainObject(value)) {
const topLevelSize = Object.keys(value).length;
if (topLevelSize > 80) {
return `{Object(${topLevelSize})}`;
}
try {
return normalizePreviewLine(JSON.stringify(value));
} catch {
return '[Object]';
}
}
return normalizePreviewLine(String(value));
};
export const calculateAutoFitColumnWidth = ({
headerTexts,
valueTexts,
measureHeaderText,
measureCellText,
minWidth = AUTO_FIT_DEFAULT_MIN_WIDTH,
maxWidth = AUTO_FIT_DEFAULT_MAX_WIDTH,
padding = AUTO_FIT_DEFAULT_PADDING,
sampleLimit = AUTO_FIT_DEFAULT_SAMPLE_LIMIT,
defaultWidth,
}: {
headerTexts: Array<string | null | undefined>;
valueTexts: unknown[];
measureHeaderText: (text: string) => number;
measureCellText: (text: string) => number;
minWidth?: number;
maxWidth?: number;
padding?: number;
sampleLimit?: number;
defaultWidth: number;
}): number => {
const safePadding = Math.max(0, Math.ceil(padding));
let widestTextWidth = Math.max(0, Number(defaultWidth) - safePadding);
headerTexts.forEach((text) => {
splitPreviewLines(normalizeAutoFitCellText(text ?? '')).forEach((line) => {
widestTextWidth = Math.max(widestTextWidth, measureHeaderText(line));
});
});
valueTexts.slice(0, Math.max(1, sampleLimit)).forEach((value) => {
splitPreviewLines(normalizeAutoFitCellText(value)).forEach((line) => {
widestTextWidth = Math.max(widestTextWidth, measureCellText(line));
});
});
return clampWidth(widestTextWidth + safePadding, minWidth, maxWidth);
};

View File

@@ -1,189 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildCopyDeleteSQL,
buildCopyInsertSQL,
buildCopyUpdateSQL,
resolveUniqueKeyGroupsFromIndexes,
} from './dataGridCopyInsert';
describe('buildCopyInsertSQL', () => {
it('normalizes PostgreSQL timestamp values for copy-as-insert and uses PostgreSQL identifier quoting', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.OrderLog',
orderedCols: ['CreatedAt', 'note'],
record: {
CreatedAt: '2026-01-21T18:32:26+08:00',
note: "O'Brien",
},
columnTypesByLowerName: {
createdat: 'timestamp without time zone',
note: 'text',
},
});
expect(sql).toBe(
`INSERT INTO public."OrderLog" ("CreatedAt", note) VALUES ('2026-01-21 18:32:26', 'O''Brien');`,
);
});
it('keeps timezone offsets for timezone-aware PostgreSQL columns while still removing the T separator', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.audit_log',
orderedCols: ['created_at'],
record: {
created_at: '2026-01-21T18:32:26+08:00',
},
columnTypesByLowerName: {
created_at: 'timestamp with time zone',
},
});
expect(sql).toBe(
`INSERT INTO public.audit_log (created_at) VALUES ('2026-01-21 18:32:26+08:00');`,
);
});
it('keeps RFC3339-looking text unchanged for non-temporal columns', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.audit_log',
orderedCols: ['payload'],
record: {
payload: '2026-01-21T18:32:26+08:00',
},
columnTypesByLowerName: {
payload: 'text',
},
});
expect(sql).toBe(
`INSERT INTO public.audit_log (payload) VALUES ('2026-01-21T18:32:26+08:00');`,
);
});
it('groups composite unique indexes by name and sequence order', () => {
expect(resolveUniqueKeyGroupsFromIndexes([
{ name: 'PRIMARY', columnName: 'id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
{ name: 'uk_order_code', columnName: 'code', nonUnique: 0, seqInIndex: 2, indexType: 'BTREE' },
{ name: 'uk_order_code', columnName: 'tenant_id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
{ name: 'idx_note', columnName: 'note', nonUnique: 1, seqInIndex: 1, indexType: 'BTREE' },
])).toEqual([
['id'],
['tenant_id', 'code'],
]);
});
it('builds UPDATE SQL with a primary-key WHERE clause and keeps literal formatting aligned with INSERT', () => {
const result = buildCopyUpdateSQL({
dbType: 'mysql',
tableName: 'orders',
orderedCols: ['id', 'note', 'deleted_at'],
record: {
id: 7,
note: "O'Brien",
deleted_at: null,
},
pkColumns: ['id'],
columnTypesByLowerName: {
deleted_at: 'datetime',
},
allTableColumns: ['id', 'note', 'deleted_at'],
});
expect(result).toEqual({
ok: true,
whereStrategy: 'primary-key',
sql: `UPDATE \`orders\` SET \`id\` = '7', \`note\` = 'O''Brien', \`deleted_at\` = NULL WHERE (\`id\` = '7');`,
});
});
it('builds DELETE SQL with a composite unique-key WHERE clause when no primary key is available', () => {
const result = buildCopyDeleteSQL({
dbType: 'postgres',
tableName: 'public.audit_log',
orderedCols: ['tenant_id', 'code', 'payload'],
record: {
tenant_id: 'acme',
code: 'evt-7',
payload: '{"ok":true}',
},
uniqueKeyGroups: [['tenant_id', 'code']],
allTableColumns: ['tenant_id', 'code', 'payload'],
});
expect(result).toEqual({
ok: true,
whereStrategy: 'unique-key',
sql: `DELETE FROM public.audit_log WHERE (tenant_id = 'acme' AND code = 'evt-7');`,
});
});
it('falls back to all-column matching and uses IS NULL for null values', () => {
const result = buildCopyDeleteSQL({
dbType: 'sqlserver',
tableName: 'dbo.OrderLog',
orderedCols: ['id', 'deleted_at', 'flag'],
allTableColumns: ['id', 'deleted_at', 'flag'],
record: {
id: 5,
deleted_at: null,
flag: true,
},
});
expect(result).toEqual({
ok: true,
whereStrategy: 'all-columns',
sql: `DELETE FROM [dbo].[OrderLog] WHERE ([id] = '5' AND [deleted_at] IS NULL AND [flag] = 'true');`,
});
});
it('uses Oracle date constructors when all-column DELETE matching includes DATE values', () => {
const result = buildCopyDeleteSQL({
dbType: 'oracle',
tableName: 'LZJ.RIJIE_TABLE',
orderedCols: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'],
allTableColumns: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'],
record: {
NAME: '张三',
CREATED_AT: '2026-04-26T08:30:00+08:00',
STATUS: 'DONE',
MEMO: null,
},
columnTypesByLowerName: {
name: 'NVARCHAR2',
created_at: 'DATE',
status: 'VARCHAR2',
memo: 'VARCHAR2',
},
});
expect(result).toEqual({
ok: true,
whereStrategy: 'all-columns',
sql: `DELETE FROM "LZJ"."RIJIE_TABLE" WHERE ("NAME" = '张三' AND "CREATED_AT" = TO_DATE('2026-04-26 08:30:00', 'YYYY-MM-DD HH24:MI:SS') AND "STATUS" = 'DONE' AND "MEMO" IS NULL);`,
});
});
it('refuses to build UPDATE/DELETE SQL when the result set lacks keys and does not cover all table columns', () => {
const result = buildCopyDeleteSQL({
dbType: 'mysql',
tableName: 'orders',
orderedCols: ['note'],
allTableColumns: ['id', 'note', 'created_at'],
record: {
note: 'partial row',
},
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('expected buildCopyDeleteSQL to fail');
}
expect(result.error).toContain('主键');
expect(result.error).toContain('全部字段');
});
});

View File

@@ -1,444 +0,0 @@
import type { IndexDefinition } from '../types';
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { isOracleLikeDialect } from '../utils/sqlDialect';
type BuildCopyInsertSQLParams = {
dbType: string;
tableName?: string;
orderedCols: string[];
record: Record<string, any>;
columnTypesByLowerName?: Record<string, string>;
};
type BuildCopyMutationSQLParams = BuildCopyInsertSQLParams & {
pkColumns?: string[];
uniqueKeyGroups?: string[][];
allTableColumns?: string[];
};
type CopySqlWhereStrategy = 'primary-key' | 'unique-key' | 'all-columns';
export type CopyMutationSQLResult =
| { ok: true; sql: string; whereStrategy: CopySqlWhereStrategy }
| { ok: false; error: string };
type CopyMutationWhereClauseResult =
| { ok: true; clause: string; whereStrategy: CopySqlWhereStrategy }
| { ok: false; error: string };
const looksLikeDateTimeText = (val: string): boolean => {
if (!val) return false;
const len = val.length;
if (len < 19 || len > 64) return false;
const charCode0 = val.charCodeAt(0);
if (charCode0 < 48 || charCode0 > 57) return false;
return (
val[4] === '-' &&
val[7] === '-' &&
(val[10] === ' ' || val[10] === 'T') &&
val[13] === ':' &&
val[16] === ':'
);
};
const normalizeDateTimeString = (val: string): string => {
if (!looksLikeDateTimeText(val)) {
return val;
}
if (/^0{4}-0{2}-0{2}/.test(val)) {
return val;
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
return match ? `${match[1]} ${match[2]}` : val;
};
const normalizeTimezoneAwareDateTimeString = (val: string): string => {
if (!looksLikeDateTimeText(val)) {
return val;
}
if (/^0{4}-0{2}-0{2}/.test(val)) {
return val;
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
if (!match) {
return val;
}
const suffix = match[3] || '';
return `${match[1]} ${match[2]}${suffix}`;
};
const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp') || raw.includes('timestamptz')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'timetz' || base === 'year';
};
const isTimezoneAwareColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
return (
raw.includes('with time zone') ||
raw.includes('with timezone') ||
raw.includes('datetimeoffset') ||
raw.includes('timestamptz') ||
raw.includes('timetz')
);
};
export const normalizeTemporalLiteralText = (
value: string,
columnType?: string,
normalizeWhenTypeMissing = false,
): string => {
const rawType = String(columnType || '').trim();
if (!rawType) {
return normalizeWhenTypeMissing ? normalizeDateTimeString(value) : value;
}
if (!isTemporalColumnType(rawType)) {
return value;
}
return isTimezoneAwareColumnType(rawType)
? normalizeTimezoneAwareDateTimeString(value)
: normalizeDateTimeString(value);
};
export const formatLocalDateTimeLiteral = (value: Date): string => {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
const hour = String(value.getHours()).padStart(2, '0');
const minute = String(value.getMinutes()).padStart(2, '0');
const second = String(value.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
};
const getColumnType = (columnTypesByLowerName: Record<string, string>, columnName: string): string | undefined => (
columnTypesByLowerName[String(columnName || '').toLowerCase()]
);
const getRecordValue = (
record: Record<string, any>,
columnName: string,
): { exists: boolean; value: any } => {
if (Object.prototype.hasOwnProperty.call(record || {}, columnName)) {
return { exists: true, value: record?.[columnName] };
}
const loweredColumnName = String(columnName || '').toLowerCase();
const matchedKey = Object.keys(record || {}).find((key) => key.toLowerCase() === loweredColumnName);
if (!matchedKey) {
return { exists: false, value: undefined };
}
return { exists: true, value: record?.[matchedKey] };
};
const normalizeColumnList = (columns: string[] | undefined): string[] => {
const seen = new Set<string>();
const result: string[] = [];
(columns || []).forEach((column) => {
const normalized = String(column || '').trim();
if (!normalized) return;
const lowered = normalized.toLowerCase();
if (seen.has(lowered)) return;
seen.add(lowered);
result.push(normalized);
});
return result;
};
const toNormalizedLiteralText = (value: any, columnType?: string): string => {
if (typeof value === 'string') {
return normalizeTemporalLiteralText(value, columnType, true);
}
if (value instanceof Date) {
return formatLocalDateTimeLiteral(value);
}
return String(value);
};
const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => {
if (!isTemporalColumnType(columnType)) {
return null;
}
const normalized = toNormalizedLiteralText(value, columnType);
const escaped = escapeLiteral(normalized);
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return `TO_DATE('${escaped}', 'YYYY-MM-DD')`;
}
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) {
const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`;
}
const rawType = String(columnType || '').toLowerCase();
if (rawType.includes('timestamp')) {
return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
}
return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
};
const formatCopySqlLiteral = (value: any, columnType?: string, dbType = ''): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (isOracleLikeDialect(dbType)) {
const oracleTemporalLiteral = formatOracleTemporalLiteral(value, columnType);
if (oracleTemporalLiteral) {
return oracleTemporalLiteral;
}
}
return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`;
};
const doesResultCoverAllTableColumns = (orderedCols: string[], allTableColumns: string[]): boolean => {
const normalizedOrderedCols = normalizeColumnList(orderedCols);
const normalizedAllTableColumns = normalizeColumnList(allTableColumns);
if (normalizedOrderedCols.length === 0 || normalizedOrderedCols.length !== normalizedAllTableColumns.length) {
return false;
}
const orderedSet = new Set(normalizedOrderedCols.map((column) => column.toLowerCase()));
return normalizedAllTableColumns.every((column) => orderedSet.has(column.toLowerCase()));
};
const buildWhereClauseForColumns = ({
dbType,
columns,
record,
columnTypesByLowerName,
requireNonNullValues,
}: {
dbType: string;
columns: string[];
record: Record<string, any>;
columnTypesByLowerName: Record<string, string>;
requireNonNullValues: boolean;
}): string | null => {
const predicates: string[] = [];
for (const columnName of columns) {
const { exists, value } = getRecordValue(record, columnName);
if (!exists) {
return null;
}
const quotedColumn = quoteIdentPart(dbType, columnName);
if (value === null || value === undefined) {
if (requireNonNullValues) {
return null;
}
predicates.push(`${quotedColumn} IS NULL`);
continue;
}
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`);
}
if (predicates.length === 0) {
return null;
}
return `(${predicates.join(' AND ')})`;
};
const resolveMutationWhereClause = ({
dbType,
orderedCols,
record,
pkColumns = [],
uniqueKeyGroups = [],
allTableColumns = [],
columnTypesByLowerName = {},
}: BuildCopyMutationSQLParams): CopyMutationWhereClauseResult => {
const normalizedPkColumns = normalizeColumnList(pkColumns);
const pkWhereClause = buildWhereClauseForColumns({
dbType,
columns: normalizedPkColumns,
record,
columnTypesByLowerName,
requireNonNullValues: true,
});
if (pkWhereClause) {
return { ok: true, clause: pkWhereClause, whereStrategy: 'primary-key' };
}
const normalizedUniqueKeyGroups = (uniqueKeyGroups || [])
.map((group) => normalizeColumnList(group))
.filter((group) => group.length > 0);
for (const group of normalizedUniqueKeyGroups) {
const uniqueWhereClause = buildWhereClauseForColumns({
dbType,
columns: group,
record,
columnTypesByLowerName,
requireNonNullValues: true,
});
if (uniqueWhereClause) {
return { ok: true, clause: uniqueWhereClause, whereStrategy: 'unique-key' };
}
}
if (doesResultCoverAllTableColumns(orderedCols, allTableColumns)) {
const fullRowWhereClause = buildWhereClauseForColumns({
dbType,
columns: orderedCols,
record,
columnTypesByLowerName,
requireNonNullValues: false,
});
if (fullRowWhereClause) {
return { ok: true, clause: fullRowWhereClause, whereStrategy: 'all-columns' };
}
}
return {
ok: false,
error: '当前结果集缺少可安全定位行数据的主键/唯一键,且未覆盖表的全部字段,无法生成 WHERE 条件。',
};
};
export const buildCopyInsertSQL = ({
dbType,
tableName,
orderedCols,
record,
columnTypesByLowerName = {},
}: BuildCopyInsertSQLParams): string => {
const targetTable = quoteQualifiedIdent(dbType, tableName || 'table');
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
const values = orderedCols.map((col) => {
const { value } = getRecordValue(record, col);
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col), dbType);
});
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
};
const buildCopyMutationSQL = (
mode: 'update' | 'delete',
{
dbType,
tableName,
orderedCols,
record,
pkColumns = [],
uniqueKeyGroups = [],
allTableColumns = [],
columnTypesByLowerName = {},
}: BuildCopyMutationSQLParams,
): CopyMutationSQLResult => {
const normalizedTableName = String(tableName || '').trim();
const normalizedOrderedCols = normalizeColumnList(orderedCols);
if (!normalizedTableName) {
return {
ok: false,
error: `当前结果集未关联明确表名,无法生成 ${mode.toUpperCase()} SQL。`,
};
}
if (normalizedOrderedCols.length === 0) {
return {
ok: false,
error: '当前结果集没有可复制的字段,无法生成 SQL。',
};
}
const whereClause = resolveMutationWhereClause({
dbType,
orderedCols: normalizedOrderedCols,
record,
pkColumns,
uniqueKeyGroups,
allTableColumns,
columnTypesByLowerName,
});
if (whereClause.ok === false) {
return { ok: false, error: whereClause.error };
}
const targetTable = quoteQualifiedIdent(dbType, normalizedTableName);
if (mode === 'delete') {
return {
ok: true,
sql: `DELETE FROM ${targetTable} WHERE ${whereClause.clause};`,
whereStrategy: whereClause.whereStrategy,
};
}
const assignments = normalizedOrderedCols.map((columnName) => {
const { value } = getRecordValue(record, columnName);
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`;
});
return {
ok: true,
sql: `UPDATE ${targetTable} SET ${assignments.join(', ')} WHERE ${whereClause.clause};`,
whereStrategy: whereClause.whereStrategy,
};
};
export const buildCopyUpdateSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
buildCopyMutationSQL('update', params)
);
export const buildCopyDeleteSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
buildCopyMutationSQL('delete', params)
);
export const resolveUniqueKeyGroupsFromIndexes = (indexes: IndexDefinition[] | undefined): string[][] => {
type IndexBucket = {
order: number;
columns: Array<{ columnName: string; seqInIndex: number; order: number }>;
};
const buckets = new Map<string, IndexBucket>();
(indexes || []).forEach((index, order) => {
if (index?.nonUnique !== 0) {
return;
}
const name = String(index?.name || '').trim();
const columnName = String(index?.columnName || '').trim();
if (!name || !columnName) {
return;
}
if (!buckets.has(name)) {
buckets.set(name, { order, columns: [] });
}
const bucket = buckets.get(name);
if (!bucket) {
return;
}
bucket.columns.push({
columnName,
seqInIndex: Number.isFinite(Number(index?.seqInIndex)) ? Number(index.seqInIndex) : 0,
order,
});
});
return Array.from(buckets.values())
.sort((left, right) => left.order - right.order)
.map((bucket) => {
const seen = new Set<string>();
return bucket.columns
.slice()
.sort((left, right) => {
const leftSeq = left.seqInIndex > 0 ? left.seqInIndex : Number.MAX_SAFE_INTEGER;
const rightSeq = right.seqInIndex > 0 ? right.seqInIndex : Number.MAX_SAFE_INTEGER;
if (leftSeq !== rightSeq) {
return leftSeq - rightSeq;
}
return left.order - right.order;
})
.map((item) => item.columnName)
.filter((columnName) => {
const lowered = columnName.toLowerCase();
if (seen.has(lowered)) {
return false;
}
seen.add(lowered);
return true;
});
})
.filter((group) => group.length > 0);
};

View File

@@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest';
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
describe('dataGridLayout helpers', () => {
it('returns zero bottom padding without horizontal overflow', () => {
expect(calculateTableBodyBottomPadding({
hasHorizontalOverflow: false,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
})).toBe(0);
});
it('adds safe area when horizontal overflow exists', () => {
expect(calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
})).toBe(28);
expect(calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 14,
floatingScrollbarGap: 4,
})).toBe(30);
});
it('keeps scroll width aligned with viewport or content width', () => {
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 1200, isMacLike: false })).toBe(1200);
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 0, isMacLike: false })).toBe(646);
expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202);
});
});

View File

@@ -1,48 +0,0 @@
export interface TableBodyBottomPaddingOptions {
hasHorizontalOverflow: boolean;
floatingScrollbarHeight: number;
floatingScrollbarGap: number;
}
export interface VirtualTableScrollXOptions {
totalWidth: number;
tableViewportWidth: number;
isMacLike: boolean;
}
const MIN_SCROLLBAR_CLEARANCE = 8;
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
export const calculateTableBodyBottomPadding = ({
hasHorizontalOverflow,
floatingScrollbarHeight,
floatingScrollbarGap,
}: TableBodyBottomPaddingOptions): number => {
if (!hasHorizontalOverflow) {
return 0;
}
const safeScrollbarHeight = Math.max(0, Math.ceil(floatingScrollbarHeight));
const safeScrollbarGap = Math.max(0, Math.ceil(floatingScrollbarGap));
return safeScrollbarHeight + FLOATING_SCROLLBAR_VISUAL_EXTRA + safeScrollbarGap + MIN_SCROLLBAR_CLEARANCE;
};
export const calculateVirtualTableScrollX = ({
totalWidth,
tableViewportWidth,
isMacLike,
}: VirtualTableScrollXOptions): number => {
const safeTotalWidth = Math.max(0, Math.ceil(totalWidth));
const safeViewportWidth = Math.max(0, Math.floor(tableViewportWidth));
if (safeViewportWidth > 0 && safeTotalWidth < safeViewportWidth) {
return safeViewportWidth;
}
if (isMacLike && safeViewportWidth > 0 && safeTotalWidth > safeViewportWidth) {
return safeTotalWidth + 2;
}
return safeTotalWidth;
};

View File

@@ -1,41 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard';
const rowKeyField = '__gonavi_row_key__';
describe('dataGridRowClipboard', () => {
it('copies selected rows in selection order without the internal row key', () => {
const copiedRows = buildCopiedRowsForPaste({
rows: [
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
{ [rowKeyField]: 'row-2', id: 2, name: 'beta', hidden_note: 'B' },
],
selectedRowKeys: ['row-2', 'row-1'],
columnNames: ['id', 'name', 'hidden_note'],
rowKeyField,
});
expect(copiedRows).toEqual([
{ id: 2, name: 'beta', hidden_note: 'B' },
{ id: 1, name: 'alpha', hidden_note: 'A' },
]);
});
it('builds pasted rows as new rows with fresh internal keys', () => {
const pastedRows = buildPastedRowsFromCopiedRows({
rows: [
{ id: 2, name: 'beta' },
{ id: 1, name: 'alpha' },
],
columnNames: ['id', 'name'],
rowKeyField,
createRowKey: (index) => `paste-${index}`,
});
expect(pastedRows).toEqual([
{ [rowKeyField]: 'paste-0', id: 2, name: 'beta' },
{ [rowKeyField]: 'paste-1', id: 1, name: 'alpha' },
]);
});
});

View File

@@ -1,66 +0,0 @@
export interface BuildCopiedRowsForPasteInput {
rows: Array<Record<string, any>>;
selectedRowKeys: any[];
columnNames: string[];
rowKeyField: string;
rowKeyToString?: (key: any) => string;
}
export interface BuildPastedRowsFromCopiedRowsInput {
rows: Array<Record<string, any>>;
columnNames: string[];
rowKeyField: string;
createRowKey: (index: number) => string;
}
const defaultRowKeyToString = (key: any): string => String(key);
const getCopyableColumnNames = (columnNames: string[], rowKeyField: string): string[] =>
columnNames.filter((columnName) => columnName !== rowKeyField);
const pickCopyableRowValues = (
row: Record<string, any>,
columnNames: string[],
rowKeyField: string,
): Record<string, any> => {
const next: Record<string, any> = {};
getCopyableColumnNames(columnNames, rowKeyField).forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return next;
};
export const buildCopiedRowsForPaste = ({
rows,
selectedRowKeys,
columnNames,
rowKeyField,
rowKeyToString = defaultRowKeyToString,
}: BuildCopiedRowsForPasteInput): Array<Record<string, any>> => {
if (!Array.isArray(rows) || !Array.isArray(selectedRowKeys) || selectedRowKeys.length === 0) {
return [];
}
const rowsByKey = new Map<string, Record<string, any>>();
rows.forEach((row) => {
const rowKey = row?.[rowKeyField];
if (rowKey === undefined || rowKey === null) return;
rowsByKey.set(rowKeyToString(rowKey), row);
});
return selectedRowKeys
.map((selectedKey) => rowsByKey.get(rowKeyToString(selectedKey)))
.filter((row): row is Record<string, any> => Boolean(row))
.map((row) => pickCopyableRowValues(row, columnNames, rowKeyField));
};
export const buildPastedRowsFromCopiedRows = ({
rows,
columnNames,
rowKeyField,
createRowKey,
}: BuildPastedRowsFromCopiedRowsInput): Array<Record<string, any>> =>
rows.map((row, index) => ({
[rowKeyField]: createRowKey(index),
...pickCopyableRowValues(row, columnNames, rowKeyField),
}));

View File

@@ -1,43 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
describe('dataGridSelectionCopy helpers', () => {
it('builds clipboard text in visible row and column order', () => {
const text = buildSelectedCellClipboardText({
selectedCells: [
{ rowKey: 'row-2', colName: 'name' },
{ rowKey: 'row-1', colName: 'id' },
{ rowKey: 'row-1', colName: 'name' },
{ rowKey: 'row-2', colName: 'id' },
],
rows: [
{ __rowKey: 'row-1', id: 1, name: 'Alice' },
{ __rowKey: 'row-2', id: 2, name: 'Bob' },
],
columnOrder: ['id', 'name', 'email'],
rowKeyField: '__rowKey',
});
expect(text).toBe('1\tAlice\n2\tBob');
});
it('normalizes null, objects and multiline text for clipboard safety', () => {
const text = buildSelectedCellClipboardText({
selectedCells: [
{ rowKey: 'row-1', colName: 'notes' },
{ rowKey: 'row-1', colName: 'meta' },
{ rowKey: 'row-2', colName: 'notes' },
{ rowKey: 'row-2', colName: 'meta' },
],
rows: [
{ __rowKey: 'row-1', notes: null, meta: { a: 1 } },
{ __rowKey: 'row-2', notes: 'line1\nline2\tvalue', meta: [1, 2] },
],
columnOrder: ['notes', 'meta'],
rowKeyField: '__rowKey',
});
expect(text).toBe('NULL\t{"a":1}\nline1 line2 value\t[1,2]');
});
});

View File

@@ -1,65 +0,0 @@
export interface SelectedGridCell {
rowKey: string;
colName: string;
}
const normalizeClipboardCellValue = (value: unknown): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (typeof value === 'string') {
return value.replace(/\r\n/g, '\n').replace(/[\t\n\r]+/g, ' ').trim();
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
try {
return JSON.stringify(value).replace(/[\t\n\r]+/g, ' ').trim();
} catch {
return String(value).replace(/[\t\n\r]+/g, ' ').trim();
}
};
export const buildSelectedCellClipboardText = ({
selectedCells,
rows,
columnOrder,
rowKeyField,
}: {
selectedCells: SelectedGridCell[];
rows: Array<Record<string, any>>;
columnOrder: string[];
rowKeyField: string;
}): string => {
if (!selectedCells.length || !rows.length || !columnOrder.length || !rowKeyField) {
return '';
}
const selectedRowKeys = new Set(selectedCells.map((cell) => cell.rowKey));
const selectedColumnKeys = new Set(selectedCells.map((cell) => cell.colName));
const orderedRows = rows.filter((row) => selectedRowKeys.has(String(row?.[rowKeyField] ?? '')));
const orderedColumns = columnOrder.filter((columnName) => selectedColumnKeys.has(columnName));
if (!orderedRows.length || !orderedColumns.length) {
return '';
}
const selectedCellKeySet = new Set(selectedCells.map((cell) => `${cell.rowKey}::${cell.colName}`));
return orderedRows
.map((row) => {
const rowKey = String(row?.[rowKeyField] ?? '');
return orderedColumns
.map((columnName) => {
if (!selectedCellKeySet.has(`${rowKey}::${columnName}`)) {
return '';
}
return normalizeClipboardCellValue(row?.[columnName]);
})
.join('\t');
})
.join('\n');
};

View File

@@ -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');
});
});

View File

@@ -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;
};

Some files were not shown because too many files have changed in this diff Show More