Compare commits
189 Commits
fix/window
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d013f27d | ||
|
|
09aa526570 | ||
|
|
5844cd7c01 | ||
|
|
4f74c44147 | ||
|
|
a5fdfefa2d | ||
|
|
37ac13b94e | ||
|
|
d4d685b076 | ||
|
|
9f6d524e3d | ||
|
|
a89289f1cc | ||
|
|
b958ff6481 | ||
|
|
98e9e5686d | ||
|
|
93446e060e | ||
|
|
ecc8ff1197 | ||
|
|
82369b4070 | ||
|
|
1bda751ada | ||
|
|
7bc358d612 | ||
|
|
36a57f9601 | ||
|
|
e85c561f1e | ||
|
|
2677364d0e | ||
|
|
da28207168 | ||
|
|
87cfbee6d3 | ||
|
|
0100b771b0 | ||
|
|
1758d6f918 | ||
|
|
b86cfcacaa | ||
|
|
7d543e06c6 | ||
|
|
17e4e3ad1c | ||
|
|
84579b83c9 | ||
|
|
7ddef7096b | ||
|
|
557178f182 | ||
|
|
a1b546ddd9 | ||
|
|
da5e879409 | ||
|
|
8935ad2905 | ||
|
|
cd5a0e85e8 | ||
|
|
ccb9f09452 | ||
|
|
5afd80c559 | ||
|
|
1b36f60821 | ||
|
|
eaa76d8f04 | ||
|
|
0f717706b0 | ||
|
|
8950081a6c | ||
|
|
3bf8758418 | ||
|
|
561d3810da | ||
|
|
18cb66b893 | ||
|
|
ab61e703b1 | ||
|
|
7933b4c315 | ||
|
|
c99f857d0a | ||
|
|
2c3f4a1032 | ||
|
|
72de16995a | ||
|
|
0adc8411fa | ||
|
|
8efa7e2de6 | ||
|
|
ecee206304 | ||
|
|
299dceb01c | ||
|
|
5cad761bdd | ||
|
|
b8728170ec | ||
|
|
4ce4cdaad8 | ||
|
|
cc7ef12029 | ||
|
|
5b6403f266 | ||
|
|
caceb2868d | ||
|
|
e7b9ff4a10 | ||
|
|
76f65cb96c | ||
|
|
8bdc6e8086 | ||
|
|
1eb2f6dffe | ||
|
|
5c5e1fc68f | ||
|
|
fb70f1420c | ||
|
|
d75596921c | ||
|
|
d251594fd9 | ||
|
|
7598bf372b | ||
|
|
64021ffd2a | ||
|
|
fbd785400f | ||
|
|
b573fd95cc | ||
|
|
a097d96380 | ||
|
|
6ee0fea110 | ||
|
|
e6b822c967 | ||
|
|
0ab10d2e80 | ||
|
|
064cdc34be | ||
|
|
c62f4b7d3c | ||
|
|
304a4926d2 | ||
|
|
cabf84a041 | ||
|
|
9b02720169 | ||
|
|
eb36dcc5a2 | ||
|
|
1a3f137438 | ||
|
|
5f94cd3911 | ||
|
|
bb257c35bc | ||
|
|
1dabac1a65 | ||
|
|
e013288967 | ||
|
|
d467322ebe | ||
|
|
e26a456eae | ||
|
|
501ad9e9a3 | ||
|
|
482a7fce2e | ||
|
|
e6af5f966b | ||
|
|
eef973b7fc | ||
|
|
d8b6b4ef8d | ||
|
|
4d58cc6e26 | ||
|
|
b0bdddad9b | ||
|
|
a73ca36a32 | ||
|
|
92e9381fcc | ||
|
|
c4c7e379d1 | ||
|
|
695713c779 | ||
|
|
ca49b37dc7 | ||
|
|
c8c0c5f20a | ||
|
|
d61d7ec39b | ||
|
|
e964c8ecf8 | ||
|
|
7644462180 | ||
|
|
3bd02e2e09 | ||
|
|
0daf702d25 | ||
|
|
058c74e49a | ||
|
|
b85c7529ec | ||
|
|
e521d2125f | ||
|
|
450fdfa59e | ||
|
|
c87b15b22a | ||
|
|
797ba27d20 | ||
|
|
ed1f40e04a | ||
|
|
2b190e564f | ||
|
|
1c050aefd0 | ||
|
|
75a5a322e0 | ||
|
|
61d6197fe3 | ||
|
|
6157161293 | ||
|
|
0f843a7dcf | ||
|
|
fb65b553e9 | ||
|
|
1a5bf79dd3 | ||
|
|
dea096d4c2 | ||
|
|
04f8b266d3 | ||
|
|
b53227cb15 | ||
|
|
0246d7fae5 | ||
|
|
4aa177ed37 | ||
|
|
4f5a7bd94b | ||
|
|
00c6f9871f | ||
|
|
6a4b397ecc | ||
|
|
3973038aea | ||
|
|
71b41459e7 | ||
|
|
69942bb77e | ||
|
|
f372b20a68 | ||
|
|
e6da986927 | ||
|
|
4570516678 | ||
|
|
8c91d8929b | ||
|
|
786835c9bc | ||
|
|
f2fc7cbd05 | ||
|
|
462ca57907 | ||
|
|
4bfdb2cb6c | ||
|
|
6918b56ed9 | ||
|
|
1afb8850ad | ||
|
|
3284eeba17 | ||
|
|
494484eb92 | ||
|
|
6156884455 | ||
|
|
a54b8906a3 | ||
|
|
f477feab2f | ||
|
|
e76e174bfe | ||
|
|
b904c0b107 | ||
|
|
c02e7c12e8 | ||
|
|
a87c801e66 | ||
|
|
7f00139847 | ||
|
|
78c5351399 | ||
|
|
e2acfa51eb | ||
|
|
9a684cd82c | ||
|
|
e3b142053f | ||
|
|
3ca898a950 | ||
|
|
84688e995a | ||
|
|
4d0940636d | ||
|
|
26b79adc5f | ||
|
|
90aa3561be | ||
|
|
ec59023736 | ||
|
|
4a96cb93d2 | ||
|
|
4c322db9d0 | ||
|
|
ed18c8285f | ||
|
|
5f8cedabd8 | ||
|
|
20923989b9 | ||
|
|
210106cde7 | ||
|
|
87aac277ec | ||
|
|
4de3f408c5 | ||
|
|
439625a49c | ||
|
|
884d72f3d3 | ||
|
|
98c1600e13 | ||
|
|
eb594b7741 | ||
|
|
587ed3444b | ||
|
|
e366a61910 | ||
|
|
5986b71c4d | ||
|
|
cb18bc3067 | ||
|
|
d676ac9084 | ||
|
|
7fcbcb2471 | ||
|
|
c680e50e74 | ||
|
|
4cb5071b0b | ||
|
|
7ae5341c1c | ||
|
|
01940e74b7 | ||
|
|
30210bc40e | ||
|
|
e90a3e2db6 | ||
|
|
5df95730d8 | ||
|
|
67a9c454d0 | ||
|
|
c17493952b | ||
|
|
dd258bd46c | ||
|
|
505c89066b |
26
.github/release.yaml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
changelog:
|
||||
categories:
|
||||
- title: 新功能
|
||||
labels:
|
||||
- feature
|
||||
- enhancement
|
||||
- feat
|
||||
- title: 问题修复
|
||||
labels:
|
||||
- bug
|
||||
- fix
|
||||
- title: 文档与流程
|
||||
labels:
|
||||
- docs
|
||||
- documentation
|
||||
- ci
|
||||
- workflow
|
||||
- chore
|
||||
- title: 重构与优化
|
||||
labels:
|
||||
- refactor
|
||||
- perf
|
||||
- optimization
|
||||
- title: 其他更新
|
||||
labels:
|
||||
- '*'
|
||||
607
.github/workflows/dev-build.yml
vendored
Normal file
@@ -0,0 +1,607 @@
|
||||
name: Dev Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
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 签名..."
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
# 删除旧的 dev pre-release(保持只有最新一个)
|
||||
- name: Delete Previous Dev Release
|
||||
uses: dev-drprasad/delete-tag-and-release@v1.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
tag_name: dev-latest
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- 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 }})
|
||||
**构建时间**: ${{ github.event.head_commit.timestamp }}
|
||||
|
||||
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
|
||||
> 每次 push 到 `dev` 分支会自动覆盖此 release。
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
3
.github/workflows/release-winget.yml
vendored
@@ -10,6 +10,9 @@ on:
|
||||
description: 'Tag of release you want to publish'
|
||||
type: string
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-latest
|
||||
|
||||
287
.github/workflows/release.yml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
# Phase 1: Build in parallel and output artifacts
|
||||
build:
|
||||
@@ -88,6 +91,26 @@ 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')
|
||||
@@ -102,6 +125,9 @@ 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
|
||||
|
||||
@@ -131,15 +157,91 @@ jobs:
|
||||
- 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
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_ARGS=()
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
TAG_ARGS+=(-tags "${{ matrix.wails_tags }}")
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
else
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
fi
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} "${TAG_ARGS[@]}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
|
||||
- name: Build Optional Driver Agents
|
||||
if: ${{ matrix.build_optional_agents }}
|
||||
@@ -149,12 +251,20 @@ jobs:
|
||||
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||
GOOS="${TARGET_PLATFORM%%/*}"
|
||||
GOARCH="${TARGET_PLATFORM##*/}"
|
||||
DRIVERS=(mariadb diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine)
|
||||
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
|
||||
TAG="gonavi_${DRIVER}_driver"
|
||||
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"
|
||||
@@ -162,20 +272,12 @@ jobs:
|
||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
|
||||
if [ "$DRIVER" = "duckdb" ]; then
|
||||
set +e
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
DUCKDB_RC=$?
|
||||
set -e
|
||||
if [ "${DUCKDB_RC}" -ne 0 ]; then
|
||||
echo "⚠️ DuckDB 代理构建失败(平台 ${GOOS}/${GOARCH}),跳过该资产,不阻断发布"
|
||||
rm -f "${OUTPUT_PATH}"
|
||||
continue
|
||||
fi
|
||||
else
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
@@ -201,6 +303,13 @@ 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 时,
|
||||
@@ -225,7 +334,7 @@ jobs:
|
||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||
|
||||
# Windows Packaging
|
||||
- name: Package Windows Portable Zip
|
||||
- name: Package Windows EXE
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -236,7 +345,6 @@ 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"
|
||||
@@ -248,11 +356,39 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "📦 生成 Windows 可执行文件 $finalExeName..."
|
||||
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
|
||||
$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 压缩包 $finalZipName..."
|
||||
Compress-Archive -LiteralPath $finalExe -DestinationPath "..\\..\\$finalZipName" -Force
|
||||
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
|
||||
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
|
||||
|
||||
# Linux Packaging (tar.gz and AppImage)
|
||||
- name: Package Linux
|
||||
@@ -271,6 +407,17 @@ 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..."
|
||||
@@ -343,7 +490,6 @@ jobs:
|
||||
path: |
|
||||
GoNavi-*.dmg
|
||||
GoNavi-*.exe
|
||||
GoNavi-*.zip
|
||||
GoNavi-*.tar.gz
|
||||
GoNavi-*.AppImage
|
||||
drivers/**
|
||||
@@ -365,6 +511,38 @@ jobs:
|
||||
- name: List Assets
|
||||
run: ls -R release-assets
|
||||
|
||||
- name: Verify Optional Driver Assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-assets
|
||||
|
||||
REQUIRED_FILES=(
|
||||
"drivers/Windows/duckdb-driver-agent-windows-amd64.exe"
|
||||
"drivers/MacOS/duckdb-driver-agent-darwin-amd64"
|
||||
"drivers/MacOS/duckdb-driver-agent-darwin-arm64"
|
||||
"drivers/Linux/duckdb-driver-agent-linux-amd64"
|
||||
"drivers/Windows/clickhouse-driver-agent-windows-amd64.exe"
|
||||
"drivers/MacOS/clickhouse-driver-agent-darwin-amd64"
|
||||
"drivers/MacOS/clickhouse-driver-agent-darwin-arm64"
|
||||
"drivers/Linux/clickhouse-driver-agent-linux-amd64"
|
||||
)
|
||||
|
||||
missing=0
|
||||
for file in "${REQUIRED_FILES[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "❌ 缺少驱动资产:$file"
|
||||
missing=1
|
||||
else
|
||||
echo "✅ 已找到驱动资产:$file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$missing" -ne 0 ]; then
|
||||
echo "❌ 可选驱动资产不完整,终止发布"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package Driver Agents Bundle
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -435,6 +613,74 @@ 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/')
|
||||
@@ -442,5 +688,6 @@ jobs:
|
||||
files: release-assets/*
|
||||
draft: true
|
||||
make_latest: true
|
||||
body_path: ${{ steps.changelog.outputs.changelog_file }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
7
.gitignore
vendored
@@ -17,5 +17,10 @@ dist/
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
.ace-tool/
|
||||
.superpowers/
|
||||
.claude/
|
||||
tmpclaude-*
|
||||
.gemini/
|
||||
**/tmpclaude-*
|
||||
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
|
||||
155
CONTRIBUTING.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Contributing Guide
|
||||
|
||||
Thank you for contributing to this project.
|
||||
|
||||
This repository follows a release-first workflow: `main` is the default public branch, while releases are prepared through `release/*` branches.
|
||||
|
||||
---
|
||||
|
||||
## Branch Model
|
||||
|
||||
- `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
|
||||
- `feature/*`: new features or enhancements
|
||||
|
||||
Maintainer release flow:
|
||||
|
||||
```text
|
||||
feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How External Contributors Should Open Pull Requests
|
||||
|
||||
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `main`**.
|
||||
|
||||
Reasons:
|
||||
|
||||
- `main` is the default branch, so the PR entry point is clearer
|
||||
- merged contributions are immediately visible on the default branch
|
||||
- maintainers can handle downstream sync and release preparation in one place
|
||||
|
||||
Recommended flow:
|
||||
|
||||
1. Fork this repository
|
||||
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 `main` branch of this repository
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Requirements
|
||||
|
||||
Please keep each pull request focused, reviewable, and easy to validate.
|
||||
|
||||
Recommended expectations:
|
||||
|
||||
- one pull request should address one logical change
|
||||
- use a clear title that explains the purpose
|
||||
- include the following in the description:
|
||||
- background and problem statement
|
||||
- key changes
|
||||
- impact scope
|
||||
- validation method
|
||||
- include screenshots or recordings for UI changes when helpful
|
||||
- explicitly mention risk and rollback notes for compatibility, data, or build-chain changes
|
||||
|
||||
---
|
||||
|
||||
## Merge Strategy for Maintainers
|
||||
|
||||
Pull requests merged into `main` should generally use **Squash and merge**.
|
||||
|
||||
Reasons:
|
||||
|
||||
- keeps `main` history clean and linear
|
||||
- maps each PR to a single commit on `main`
|
||||
- reduces release, audit, and rollback complexity
|
||||
|
||||
---
|
||||
|
||||
## Maintainer Sync Rules
|
||||
|
||||
Because external pull requests are merged directly into `main`, maintainers must sync `main` back to development and release branches to avoid branch drift.
|
||||
|
||||
### 1. Sync `main` -> `dev` (required)
|
||||
|
||||
The automatic GitHub Actions sync workflow has been removed.
|
||||
Maintainers should sync `main` back to `dev` manually when needed:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. Create `release/*` from `dev`
|
||||
|
||||
Before a release, create a release branch from `dev`, for example:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git checkout -b release/v0.6.0
|
||||
git push -u origin release/v0.6.0
|
||||
```
|
||||
|
||||
### 3. Release from `release/*` back to `main`
|
||||
|
||||
When release preparation is complete, merge the release branch back into `main` and create a tag:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
git merge release/v0.6.0
|
||||
git push
|
||||
git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
```
|
||||
|
||||
### 4. Sync `main` back to `dev` after release
|
||||
|
||||
After the release, the same automation still applies. If needed, you can run the workflow manually (`workflow_dispatch`) or execute the fallback commands:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Message Recommendation
|
||||
|
||||
Keep commit messages clear and easy to audit.
|
||||
|
||||
Recommended format:
|
||||
|
||||
```text
|
||||
emoji type(scope): concise description
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
🔧 fix(ci): fix DuckDB driver toolchain on Windows AMD64
|
||||
✨ feat(redis): add Stream data browsing support
|
||||
♻️ refactor(datagrid): optimize large-table horizontal scrolling and rendering
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Notes
|
||||
|
||||
- Please include validation results for documentation, build-chain, or driver compatibility changes
|
||||
- For larger changes, opening an issue or draft PR first is recommended
|
||||
- Maintainers may ask contributors to narrow the scope if the change conflicts with the current project direction
|
||||
|
||||
Thank you for contributing.
|
||||
155
CONTRIBUTING.zh-CN.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢你对本项目的贡献。
|
||||
|
||||
本项目采用“发布优先(`main` 为默认分支)+ `release/*` 分支发版”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
|
||||
|
||||
---
|
||||
|
||||
## 分支模型
|
||||
|
||||
- `main`:稳定发布分支,也是仓库默认分支
|
||||
- `dev`:日常开发集成分支,主要供维护者使用
|
||||
- `release/*`:发布准备分支,主要供维护者使用
|
||||
- 外部贡献者建议使用以下分支命名:
|
||||
- `fix/*`:问题修复
|
||||
- `feature/*`:功能新增或增强
|
||||
|
||||
维护者发布流转如下:
|
||||
|
||||
```text
|
||||
feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 外部贡献者如何提 Pull Request
|
||||
|
||||
无论是 `fix/*` 还是 `feature/*`,**外部贡献者统一直接向 `main` 发起 Pull Request**。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- `main` 是默认分支,PR 入口更直观
|
||||
- 合并后贡献会直接体现在默认分支
|
||||
- 便于维护者统一做后续同步与发版整理
|
||||
|
||||
建议流程:
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 从你自己的仓库创建分支(建议命名为 `fix/*` 或 `feature/*`)
|
||||
3. 完成代码修改,并进行必要自检
|
||||
4. 推送到你的远程分支
|
||||
5. 向本仓库的 `main` 分支发起 Pull Request
|
||||
|
||||
---
|
||||
|
||||
## Pull Request 要求
|
||||
|
||||
请尽量保证 PR 单一、清晰、可审核。
|
||||
|
||||
建议遵循以下要求:
|
||||
|
||||
- 一个 PR 只解决一类问题,避免混入无关改动
|
||||
- 标题清晰说明改动目的
|
||||
- 描述中说明:
|
||||
- 背景与问题
|
||||
- 变更点
|
||||
- 影响范围
|
||||
- 验证方式
|
||||
- 如涉及 UI 调整,建议附截图或录屏
|
||||
- 如涉及兼容性、数据变更或构建链路调整,请明确说明风险和回滚方式
|
||||
|
||||
---
|
||||
|
||||
## PR 合并策略(维护者)
|
||||
|
||||
`main` 分支上的 PR 建议使用 **Squash and merge**。
|
||||
|
||||
原因:
|
||||
|
||||
- 保持 `main` 历史干净、线性
|
||||
- 每个 PR 在 `main` 上对应一个清晰提交
|
||||
- 降低发布排查与回滚成本
|
||||
|
||||
---
|
||||
|
||||
## 维护者同步规则
|
||||
|
||||
由于外部 PR 会直接合入 `main`,维护者必须及时将 `main` 的变更同步到开发与发布分支,避免分支漂移。
|
||||
|
||||
### 1. main → dev 同步(必做)
|
||||
|
||||
仓库已移除 GitHub Actions 自动回灌 workflow。
|
||||
当前统一采用手动方式将 `main` 同步回 `dev`:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. 发版前从 dev 切 release/*
|
||||
|
||||
发布前由维护者基于 `dev` 创建发布分支,例如:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git checkout -b release/v0.6.0
|
||||
git push -u origin release/v0.6.0
|
||||
```
|
||||
|
||||
### 3. release/* → main 发版
|
||||
|
||||
发布准备完成后,将 `release/*` 合并回 `main`,并打标签发布:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
git merge release/v0.6.0
|
||||
git push
|
||||
git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
```
|
||||
|
||||
### 4. main 回流到 dev(发版后必做)
|
||||
|
||||
发布完成后,仍沿用同一套自动化流程;如有需要,也可以手动触发 `workflow_dispatch`,或执行以下兜底命令,确保开发线与发布线一致:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 提交建议
|
||||
|
||||
建议保持提交信息简洁、明确,便于维护者审查与后续追踪。
|
||||
|
||||
推荐格式:
|
||||
|
||||
```text
|
||||
emoji type(scope): 中文描述
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链
|
||||
✨ feat(redis): 新增 Stream 类型数据浏览支持
|
||||
♻️ refactor(datagrid): 优化大表横向滚动与渲染结构
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 其他说明
|
||||
|
||||
- 文档、构建链路、驱动兼容性相关改动,请尽量附带验证结果
|
||||
- 若改动较大,建议先提 Issue 或 Draft PR,先对齐方案再实施
|
||||
- 如提交内容与项目当前架构方向冲突,维护者可能要求收敛范围后再合并
|
||||
|
||||
感谢你的贡献。
|
||||
229
README.md
@@ -1,4 +1,4 @@
|
||||
# GoNavi - 现代化的轻量级数据库管理工具
|
||||
# GoNavi - A Modern Lightweight Database Client
|
||||
|
||||
[](https://go.dev/)
|
||||
[](https://wails.io)
|
||||
@@ -6,11 +6,51 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/Syngnat/GoNavi/actions)
|
||||
|
||||
**GoNavi** 是一款基于 **Wails (Go)** 和 **React** 构建的现代化、高性能、跨平台数据库管理客户端。它旨在提供如原生应用般流畅的用户体验,同时保持极低的资源占用。
|
||||
**Language**: English | [简体中文](README.zh-CN.md)
|
||||
|
||||
相比于 Electron 应用,GoNavi 的体积更小(~10MB),启动速度更快,内存占用更低。
|
||||
GoNavi is a modern, high-performance, cross-platform database client built with **Wails (Go)** and **React**.
|
||||
It delivers native-like responsiveness with low resource usage.
|
||||
|
||||
<h2 align="center">📸 项目截图</h2>
|
||||
Compared with many Electron-based clients, GoNavi is typically smaller in binary size (around 10MB class), starts faster, and uses less memory.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
GoNavi is designed for developers and DBAs who need a unified desktop experience across multiple databases.
|
||||
|
||||
- **Native-performance architecture**: Wails (Go + WebView) with lightweight runtime overhead.
|
||||
- **Large dataset usability**: virtualized rendering and optimized DataGrid workflows for high-volume tables.
|
||||
- **Unified connectivity**: URI build/parse, SSH tunnel, proxy support, and on-demand driver activation.
|
||||
- **Production-oriented workflow**: SQL editor, object management, batch export/backup, sync tools, execution logs, and update checks.
|
||||
|
||||
## Supported Data Sources
|
||||
|
||||
> `Built-in`: available out of the box.
|
||||
> `Optional driver agent`: install/enable via Driver Manager first.
|
||||
|
||||
| Category | Data Source | Driver Mode | Typical Capabilities |
|
||||
|---|---|---|---|
|
||||
| Relational | MySQL | Built-in | Schema browsing, SQL query, data editing, export/backup |
|
||||
| Relational | PostgreSQL | Built-in | Schema browsing, SQL query, data editing, object management |
|
||||
| Relational | Oracle | Built-in | Query execution, object browsing, data editing |
|
||||
| Cache | Redis | Built-in | Key browsing, command execution, encoding/view switch |
|
||||
| Relational | MariaDB | Optional driver agent | Querying, object management, data editing |
|
||||
| Relational | Doris | Optional driver agent | Querying, object browsing, SQL execution |
|
||||
| Search | Sphinx | Optional driver agent | SphinxQL querying and object browsing |
|
||||
| Relational | SQL Server | Optional driver agent | Schema browsing, SQL query, object management |
|
||||
| File-based | SQLite | Optional driver agent | Local DB browsing, editing, export |
|
||||
| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow |
|
||||
| Domestic DB | Dameng | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | Kingbase | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | HighGo | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | Vastbase | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management |
|
||||
| Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying |
|
||||
| Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution |
|
||||
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
|
||||
|
||||
<h2 align="center">📸 Screenshots</h2>
|
||||
|
||||
<div align="center">
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
|
||||
@@ -24,137 +64,124 @@
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心特性
|
||||
## Key Features
|
||||
|
||||
### 🚀 极致性能
|
||||
- **零卡顿交互**:采用独创的 "幽灵拖拽" (Ghost Resizing) 技术,在包含数万行数据的表格中调整列宽,依然保持 60fps+ 的丝滑体验。
|
||||
- **虚拟滚动**:轻松处理海量数据展示,拒绝卡顿。
|
||||
### Performance
|
||||
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
|
||||
- **Virtualized rendering**: keeps large result sets responsive.
|
||||
|
||||
### 🔌 多数据库支持
|
||||
- **MySQL**:完整支持,涵盖数据编辑、结构管理与导入导出。
|
||||
- **PostgreSQL**:数据查看与编辑支持,事务提交能力持续完善。
|
||||
- **SQLite**:本地文件数据库支持。
|
||||
- **Oracle**:基础数据访问与编辑支持。
|
||||
- **Dameng(达梦)**:基础数据访问与编辑支持。
|
||||
- **Kingbase(人大金仓)**:基础数据访问与编辑支持。
|
||||
- **TDengine**:时序数据库连接、库表浏览与 SQL 查询支持。
|
||||
- **Redis**:Key/Value 浏览、命令执行、视图与编码切换。
|
||||
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
|
||||
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
|
||||
### Data Management (DataGrid)
|
||||
- In-place cell editing.
|
||||
- Batch insert/update/delete with transaction-oriented submit/rollback.
|
||||
- Large-field popup editor.
|
||||
- Context actions (set NULL, copy/export, etc.).
|
||||
- Smart read/write mode switching based on query context.
|
||||
- Export formats: CSV, Excel (XLSX), JSON, Markdown.
|
||||
|
||||
### 📊 强大的数据管理 (DataGrid)
|
||||
- **所见即所得编辑**:直接在表格中双击单元格修改数据。
|
||||
- **批量事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
|
||||
- **大字段编辑**:双击大字段自动打开弹窗编辑器,避免卡顿。
|
||||
- **右键上下文菜单**:快速设置 NULL、复制/导出等操作。
|
||||
- **智能上下文**:自动识别单表查询,解锁编辑功能;复杂查询自动切换为只读模式。
|
||||
- **批量导出/备份**:支持表与数据库的批量导出/备份。
|
||||
- **数据导出**:支持 CSV、Excel (XLSX)、JSON、Markdown 等格式。
|
||||
### SQL Editor
|
||||
- Monaco Editor core.
|
||||
- Context-aware completion for databases/tables/columns.
|
||||
- Multi-tab query workflow.
|
||||
|
||||
### 🧰 批量导出/备份
|
||||
- **数据库批量导出**:支持结构导出与结构+数据备份。
|
||||
- **表批量导出**:支持多表一键导出/备份。
|
||||
- **智能上下文检测**:自动判断目标范围,避免误操作。
|
||||
### Batch Export / Backup
|
||||
- Database-level and table-level batch export/backup.
|
||||
- Scope-aware operation flow to reduce mistakes.
|
||||
|
||||
### 🧩 Redis 视图与编码
|
||||
- **视图模式切换**:自动/原始文本/UTF-8/十六进制多模式显示。
|
||||
- **智能解码**:针对二进制值进行 UTF-8 质量判定与中文字符识别。
|
||||
- **命令执行**:内置命令面板快速操作。
|
||||
### Connectivity
|
||||
- URI generation/parsing.
|
||||
- SSH tunnel support.
|
||||
- Proxy support.
|
||||
- Config import/export (JSON).
|
||||
- Optional driver management and activation.
|
||||
|
||||
### 🔄 数据同步与导入导出
|
||||
- **连接配置导入/导出**:支持配置 JSON 导入导出,便于团队共享。
|
||||
- **数据同步**:内置数据同步面板,支持跨库同步任务配置。
|
||||
### Redis Tools
|
||||
- Multi-view value rendering (auto/raw text/UTF-8/hex).
|
||||
- Built-in command execution panel.
|
||||
|
||||
### 🆙 在线更新
|
||||
- **自动更新**:启动/定时/手动检查更新,自动下载并提示重启完成更新。
|
||||
### Observability and Update
|
||||
- SQL execution logs with timing information.
|
||||
- Startup/scheduled/manual update checks.
|
||||
|
||||
### 🧾 可观测性
|
||||
- **SQL 执行日志**:实时查看 SQL 与执行耗时,便于排障与优化。
|
||||
|
||||
### 📝 智能 SQL 编辑器
|
||||
- **Monaco Editor 内核**:集成 VS Code 同款编辑器,体验极佳。
|
||||
- **智能补全**:自动感知当前连接上下文,提供数据库、表名、字段名的实时补全。
|
||||
- **多标签页**:支持多窗口并行操作,像浏览器一样管理你的查询会话。
|
||||
|
||||
### 🎨 现代化 UI
|
||||
- **Ant Design 5**:企业级 UI 设计语言。
|
||||
- **暗黑模式**:内置深色/浅色主题切换,适应不同光照环境。
|
||||
- **响应式布局**:灵活的侧边栏与布局调整。
|
||||
### UI/UX
|
||||
- Ant Design 5 based interface.
|
||||
- Light/Dark themes.
|
||||
- Flexible sidebar and layout behavior.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
## Tech Stack
|
||||
|
||||
* **后端 (Backend)**: Go 1.24 + Wails v2
|
||||
* **前端 (Frontend)**: React 18 + TypeScript + Vite
|
||||
* **UI 框架**: Ant Design 5
|
||||
* **状态管理**: Zustand
|
||||
* **编辑器**: Monaco Editor
|
||||
- **Backend**: Go 1.24 + Wails v2
|
||||
- **Frontend**: React 18 + TypeScript + Vite
|
||||
- **UI**: Ant Design 5
|
||||
- **State Management**: Zustand
|
||||
- **Editor**: Monaco Editor
|
||||
|
||||
---
|
||||
|
||||
## 📦 安装与运行
|
||||
## Installation and Run
|
||||
|
||||
### 前置要求
|
||||
* [Go](https://go.dev/dl/) 1.21+
|
||||
* [Node.js](https://nodejs.org/) 18+
|
||||
* [Wails CLI](https://wails.io/docs/gettingstarted/installation): `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||
### Prerequisites
|
||||
- [Go](https://go.dev/dl/) 1.21+
|
||||
- [Node.js](https://nodejs.org/) 18+
|
||||
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
|
||||
`go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||
|
||||
### 开发模式
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
# Clone
|
||||
git clone https://github.com/Syngnat/GoNavi.git
|
||||
cd GoNavi
|
||||
|
||||
# 启动开发服务器 (支持热重载)
|
||||
# Start development with hot reload
|
||||
wails dev
|
||||
```
|
||||
|
||||
### 编译构建
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# 构建当前平台的可执行文件
|
||||
# Build for current platform
|
||||
wails build
|
||||
|
||||
# 清理并构建 (推荐发布前使用)
|
||||
# Clean build (recommended before release)
|
||||
wails build -clean
|
||||
```
|
||||
|
||||
构建产物将位于 `build/bin` 目录下。
|
||||
Artifacts are generated in `build/bin`.
|
||||
|
||||
### 跨平台编译 (GitHub Actions)
|
||||
### Cross-Platform Release (GitHub Actions)
|
||||
|
||||
本项目内置了 GitHub Actions 流水线,Push `v*` 格式的 Tag 即可自动触发构建并发布 Release。
|
||||
支持构建:
|
||||
* macOS (AMD64 / ARM64)
|
||||
* Windows (AMD64)
|
||||
* Linux (AMD64,提供 WebKitGTK 4.0 与 4.1 变体产物)
|
||||
The repository includes a release workflow.
|
||||
Push a `v*` tag to trigger automated build and release.
|
||||
Release notes are generated automatically from merged pull requests and categorized by `.github/release.yaml`.
|
||||
|
||||
Target artifacts include:
|
||||
- macOS (AMD64 / ARM64)
|
||||
- Windows (AMD64)
|
||||
- Linux (AMD64, WebKitGTK 4.0 and 4.1 variants)
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题 (Troubleshooting)
|
||||
## Troubleshooting
|
||||
|
||||
### macOS 提示 "应用已损坏,无法打开"
|
||||
### macOS: "App is damaged and can’t be opened"
|
||||
|
||||
由于本项目尚未购买 Apple 开发者证书进行签名(Notarization),macOS 的 Gatekeeper 安全机制可能会拦截应用的运行。请按照以下步骤解决:
|
||||
Without Apple notarization, Gatekeeper may block startup.
|
||||
|
||||
1. 将下载的 `GoNavi.app` 拖入 **应用程序** 文件夹。
|
||||
2. 打开 **终端 (Terminal)**。
|
||||
3. 复制并执行以下命令(输入密码时不会显示):
|
||||
```bash
|
||||
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
||||
```
|
||||
4. 或者:在 Finder 中右键点击应用图标,按住 `Control` 键选择 **打开**,然后在弹出的窗口中再次点击 **打开**。
|
||||
1. Move `GoNavi.app` to **Applications**.
|
||||
2. Open **Terminal**.
|
||||
3. Run:
|
||||
|
||||
### Linux 启动报错缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
|
||||
```bash
|
||||
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
||||
```
|
||||
|
||||
GoNavi 的 Linux 二进制依赖系统 WebKitGTK 运行库。不同发行版默认版本不同:
|
||||
Or right-click the app in Finder and choose **Open** with Control key flow.
|
||||
|
||||
- Debian 13 / Ubuntu 24.04 及更新版本:通常为 WebKitGTK 4.1
|
||||
- Ubuntu 22.04 / Debian 12 等:通常为 WebKitGTK 4.0
|
||||
### Linux: missing `libwebkit2gtk` / `libjavascriptcoregtk`
|
||||
|
||||
如果启动时报错(如 `libwebkit2gtk-4.0.so.37: cannot open shared object file`),请按系统安装对应依赖后重试:
|
||||
GoNavi depends on WebKitGTK runtime libraries.
|
||||
|
||||
```bash
|
||||
# Debian 13 / Ubuntu 24.04+
|
||||
@@ -166,20 +193,20 @@ sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
|
||||
```
|
||||
|
||||
如果你使用的是 Release 中带 `-WebKit41` 后缀的 Linux 产物,请优先在 Debian 13 / Ubuntu 24.04+ 上使用;普通 Linux 产物更适合 WebKitGTK 4.0 运行环境。
|
||||
If you use Linux artifacts with the `-WebKit41` suffix, prefer Debian 13 / Ubuntu 24.04+.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
## Contributing
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
Issues and pull requests are welcome.
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交你的改动 (`git commit -m 'feat: Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启一个 Pull Request
|
||||
For the full workflow, branch model, and maintainer sync rules, see:
|
||||
|
||||
## 📄 开源协议
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
本项目采用 [Apache-2.0 协议](LICENSE) 开源。
|
||||
External contributors should open pull requests directly against `main`.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under [Apache-2.0](LICENSE).
|
||||
|
||||
195
README.zh-CN.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# GoNavi - 现代化轻量级数据库客户端
|
||||
|
||||
[](https://go.dev/)
|
||||
[](https://wails.io)
|
||||
[](https://reactjs.org/)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Syngnat/GoNavi/actions)
|
||||
|
||||
**语言**: [English](README.md) | 简体中文
|
||||
|
||||
GoNavi 是基于 **Wails (Go)** 与 **React** 构建的跨平台数据库管理工具,强调原生性能、低资源占用与多数据源统一工作流。
|
||||
|
||||
相比常见 Electron 客户端,GoNavi 在体积、启动速度和内存占用上更轻量。
|
||||
|
||||
---
|
||||
|
||||
## 项目简介
|
||||
|
||||
GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做到“快、稳、统一”。
|
||||
|
||||
- **原生性能架构**:Wails(Go + WebView),降低运行时开销。
|
||||
- **大数据可用性**:虚拟滚动 + DataGrid 交互优化,提升大结果集可操作性。
|
||||
- **统一连接能力**:支持 URI 生成/解析、SSH 隧道、代理、驱动按需安装。
|
||||
- **工程化能力完整**:覆盖 SQL 编辑、对象管理、批量导出/备份、数据同步、执行日志、在线更新。
|
||||
|
||||
## 支持的数据源
|
||||
|
||||
> `内置`:主程序开箱即用。
|
||||
> `可选驱动代理`:需在驱动管理中安装启用后可用。
|
||||
|
||||
| 类别 | 数据源 | 驱动模式 | 典型能力 |
|
||||
|---|---|---|---|
|
||||
| 关系型 | MySQL | 内置 | 库表浏览、SQL 查询、数据编辑、导出/备份 |
|
||||
| 关系型 | PostgreSQL | 内置 | 库表浏览、SQL 查询、数据编辑、对象管理 |
|
||||
| 关系型 | Oracle | 内置 | 连接查询、对象浏览、数据编辑 |
|
||||
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
|
||||
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
|
||||
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
|
||||
| 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 |
|
||||
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
|
||||
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
|
||||
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
|
||||
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | Vastbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
|
||||
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
|
||||
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
|
||||
| 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |
|
||||
|
||||
<h2 align="center">📸 项目截图</h2>
|
||||
|
||||
<div align="center">
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
|
||||
<br />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 性能与交互
|
||||
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
|
||||
- 虚拟滚动渲染,降低大结果集卡顿风险。
|
||||
|
||||
### 数据管理(DataGrid)
|
||||
- 单元格所见即所得编辑。
|
||||
- 批量新增/修改/删除,支持事务提交与回滚。
|
||||
- 大字段弹窗编辑。
|
||||
- 右键上下文操作(NULL、复制、导出等)。
|
||||
- 根据查询上下文智能切换读写模式。
|
||||
- 支持 CSV / XLSX / JSON / Markdown 导出。
|
||||
|
||||
### SQL 编辑器
|
||||
- 基于 Monaco Editor。
|
||||
- 上下文补全(数据库/表/字段)。
|
||||
- 多标签查询工作流。
|
||||
|
||||
### 连接与驱动
|
||||
- URI 生成与解析。
|
||||
- SSH 隧道、代理支持。
|
||||
- 连接配置 JSON 导入/导出。
|
||||
- 可选驱动安装与启用管理。
|
||||
|
||||
### Redis 工具
|
||||
- 自动/原始文本/UTF-8/十六进制等视图模式。
|
||||
- 内置命令执行面板。
|
||||
|
||||
### 可观测性与更新
|
||||
- SQL 执行日志(含耗时)。
|
||||
- 启动/定时/手动更新检查。
|
||||
|
||||
### UI 体验
|
||||
- Ant Design 5 体系。
|
||||
- 深色/浅色主题切换。
|
||||
- 灵活布局与侧边栏行为。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Go 1.24 + Wails v2
|
||||
- **前端**: React 18 + TypeScript + Vite
|
||||
- **UI 框架**: Ant Design 5
|
||||
- **状态管理**: Zustand
|
||||
- **编辑器**: Monaco Editor
|
||||
|
||||
---
|
||||
|
||||
## 安装与运行
|
||||
|
||||
### 前置要求
|
||||
- [Go](https://go.dev/dl/) 1.21+
|
||||
- [Node.js](https://nodejs.org/) 18+
|
||||
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
|
||||
`go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/Syngnat/GoNavi.git
|
||||
cd GoNavi
|
||||
|
||||
# 启动开发(热重载)
|
||||
wails dev
|
||||
```
|
||||
|
||||
### 编译构建
|
||||
|
||||
```bash
|
||||
# 构建当前平台
|
||||
wails build
|
||||
|
||||
# 清理后构建(发布前推荐)
|
||||
wails build -clean
|
||||
```
|
||||
|
||||
构建产物位于 `build/bin`。
|
||||
|
||||
### 跨平台发布(GitHub Actions)
|
||||
|
||||
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
|
||||
Release 更新说明会基于已合并 Pull Request 自动生成,并按 `.github/release.yaml` 分类。
|
||||
|
||||
支持目标:
|
||||
- macOS (AMD64 / ARM64)
|
||||
- Windows (AMD64)
|
||||
- Linux (AMD64,含 WebKitGTK 4.0 / 4.1 变体)
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### macOS 提示“应用已损坏,无法打开”
|
||||
|
||||
在未进行 Apple Notarization 时,Gatekeeper 可能拦截应用。
|
||||
|
||||
```bash
|
||||
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
||||
```
|
||||
|
||||
### Linux 缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
|
||||
|
||||
```bash
|
||||
# Debian 13 / Ubuntu 24.04+
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0
|
||||
|
||||
# Ubuntu 22.04 / Debian 12
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
|
||||
欢迎提交 Issue 与 Pull Request。
|
||||
|
||||
完整流程、分支模型与维护者同步规则请查看:
|
||||
|
||||
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
|
||||
|
||||
外部贡献者统一直接向 `main` 发起 Pull Request。
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [Apache-2.0 协议](LICENSE)。
|
||||
228
build-driver-agents.sh
Executable file
@@ -0,0 +1,228 @@
|
||||
#!/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
|
||||
351
build-release.sh
@@ -20,6 +20,75 @@ 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
|
||||
}
|
||||
|
||||
MAC_VOLICON_PATH="build/darwin/icon.icns"
|
||||
if [ ! -f "$MAC_VOLICON_PATH" ]; then
|
||||
MAC_VOLICON_PATH=""
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}"
|
||||
|
||||
# 清理并创建输出目录
|
||||
@@ -36,47 +105,101 @@ if [ $? -eq 0 ]; then
|
||||
|
||||
# 移动 .app 到 dist
|
||||
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
|
||||
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
|
||||
echo -e "${YELLOW} ⚠️ macOS arm64 不再执行 UPX 压缩,保留原始主程序。${NC}"
|
||||
else
|
||||
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建 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
|
||||
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
|
||||
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
# 创建 DMG
|
||||
if command -v create-dmg &> /dev/null; then
|
||||
echo " 📦 正在打包 DMG (arm64)..."
|
||||
# 移除已存在的 DMG (以防万一)
|
||||
rm -f "$DIST_DIR/$DMG_NAME"
|
||||
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
|
||||
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
|
||||
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
|
||||
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
|
||||
else
|
||||
if command -v ditto &> /dev/null; then
|
||||
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
else
|
||||
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口(CI/本地静默打包更友好)。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
|
||||
if [ -n "$MAC_VOLICON_PATH" ]; then
|
||||
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
|
||||
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}"
|
||||
create-dmg "${CREATE_DMG_ARGS[@]}" \
|
||||
--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" \
|
||||
"$STAGE_DIR"
|
||||
|
||||
CREATE_DMG_EXIT_CODE=$?
|
||||
rm -rf "$STAGE_DIR"
|
||||
|
||||
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
|
||||
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
|
||||
else
|
||||
# create-dmg 可能会在失败时遗留 rw.*.dmg 中间产物;不要直接当作最终产物使用
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
|
||||
if [ -n "$RW_FILE" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
|
||||
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
rm -f "$RW_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 防御性:即使生成了目标文件,也要确保不是 UDRW(UDRW 在 Finder 下可能表现为“已损坏/无法打开”)
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
|
||||
if [ "$DMG_FORMAT" = "UDRW" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 UDRW(可写原始映像),正在转换为 UDZO...${NC}"
|
||||
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
|
||||
rm -f "$TMP_UDZO"
|
||||
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
|
||||
else
|
||||
# 删除中间的 .app 文件,保持目录整洁
|
||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
||||
echo " ✅ 已生成 $DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
|
||||
echo " 安装命令: brew install create-dmg"
|
||||
fi
|
||||
else
|
||||
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出。${NC}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
|
||||
echo " 安装命令: brew install create-dmg"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED} ❌ macOS arm64 构建失败。${NC}"
|
||||
fi
|
||||
|
||||
@@ -89,44 +212,96 @@ if [ $? -eq 0 ]; then
|
||||
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
|
||||
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
|
||||
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
|
||||
echo -e "${YELLOW} ⚠️ macOS amd64 不再执行 UPX 压缩,保留原始主程序。${NC}"
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具。${NC}"
|
||||
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${RED} ❌ macOS amd64 构建失败。${NC}"
|
||||
|
||||
# Ad-hoc 代码签名
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
|
||||
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
if command -v create-dmg &> /dev/null; then
|
||||
echo " 📦 正在打包 DMG (amd64)..."
|
||||
rm -f "$DIST_DIR/$DMG_NAME"
|
||||
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
|
||||
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-amd64.XXXXXX")
|
||||
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
|
||||
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
|
||||
else
|
||||
if command -v ditto &> /dev/null; then
|
||||
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
else
|
||||
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口(CI/本地静默打包更友好)。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
|
||||
if [ -n "$MAC_VOLICON_PATH" ]; then
|
||||
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
|
||||
fi
|
||||
|
||||
create-dmg "${CREATE_DMG_ARGS[@]}" \
|
||||
--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" \
|
||||
"$STAGE_DIR"
|
||||
|
||||
CREATE_DMG_EXIT_CODE=$?
|
||||
rm -rf "$STAGE_DIR"
|
||||
|
||||
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
|
||||
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
|
||||
else
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
|
||||
if [ -n "$RW_FILE" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
|
||||
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
rm -f "$RW_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
|
||||
if [ "$DMG_FORMAT" = "UDRW" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 UDRW(可写原始映像),正在转换为 UDZO...${NC}"
|
||||
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
|
||||
rm -f "$TMP_UDZO"
|
||||
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
|
||||
else
|
||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
||||
echo " ✅ 已生成 $DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
echo -e "${RED} ❌ DMG 生成失败。${NC}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具。${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED} ❌ macOS amd64 构建失败。${NC}"
|
||||
fi
|
||||
|
||||
# --- Windows AMD64 构建 ---
|
||||
@@ -134,7 +309,9 @@ 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
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||
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 可执行文件"
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||
else
|
||||
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
|
||||
@@ -148,7 +325,9 @@ 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
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
|
||||
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}"
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
|
||||
else
|
||||
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
|
||||
@@ -168,8 +347,10 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
|
||||
# 本机 Linux amd64,直接构建
|
||||
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||
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 可执行文件"
|
||||
# 打包为 tar.gz
|
||||
cd "$DIST_DIR"
|
||||
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
|
||||
@@ -186,8 +367,10 @@ 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
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||
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 可执行文件"
|
||||
cd "$DIST_DIR"
|
||||
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
|
||||
rm "${APP_NAME}-${VERSION}-linux-amd64"
|
||||
@@ -208,8 +391,10 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
|
||||
# 本机 Linux arm64,直接构建
|
||||
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||
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 可执行文件"
|
||||
cd "$DIST_DIR"
|
||||
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
|
||||
rm "${APP_NAME}-${VERSION}-linux-arm64"
|
||||
@@ -225,8 +410,10 @@ 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
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||
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 可执行文件"
|
||||
cd "$DIST_DIR"
|
||||
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
|
||||
rm "${APP_NAME}-${VERSION}-linux-arm64"
|
||||
|
||||
@@ -2,10 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
@@ -16,6 +19,7 @@ type agentRequest struct {
|
||||
Method string `json:"method"`
|
||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
TimeoutMs int64 `json:"timeoutMs,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName,omitempty"`
|
||||
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
||||
@@ -47,6 +51,8 @@ const (
|
||||
agentMethodApplyChanges = "applyChanges"
|
||||
)
|
||||
|
||||
const legacyClickHouseDefaultTimeout = 2 * time.Hour
|
||||
|
||||
var (
|
||||
agentDriverType string
|
||||
agentDatabaseFactory func() db.Database
|
||||
@@ -137,14 +143,14 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
case agentMethodQuery:
|
||||
data, fields, err := (*inst).Query(req.Query)
|
||||
data, fields, err := queryWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Fields = fields
|
||||
case agentMethodExec:
|
||||
affected, err := (*inst).Exec(req.Query)
|
||||
affected, err := execWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
@@ -218,7 +224,11 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
}
|
||||
|
||||
func writeResponse(writer *bufio.Writer, resp agentResponse) error {
|
||||
payload, err := json.Marshal(resp)
|
||||
// 对响应数据做统一 JSON 安全归一化:
|
||||
// 将 map[any]any(如 duckdb.Map)递归转换为 map[string]any,避免序列化失败导致代理进程退出。
|
||||
safeResp := resp
|
||||
safeResp.Data = normalizeAgentResponseData(resp.Data)
|
||||
payload, err := json.Marshal(safeResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -234,3 +244,87 @@ func fail(resp agentResponse, errText string) agentResponse {
|
||||
resp.Error = strings.TrimSpace(errText)
|
||||
return resp
|
||||
}
|
||||
|
||||
func normalizeAgentResponseData(v interface{}) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Pointer, reflect.Interface:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
return normalizeAgentResponseData(rv.Elem().Interface())
|
||||
case reflect.Map:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, rv.Len())
|
||||
iter := rv.MapRange()
|
||||
for iter.Next() {
|
||||
out[fmt.Sprint(iter.Key().Interface())] = normalizeAgentResponseData(iter.Value().Interface())
|
||||
}
|
||||
return out
|
||||
case reflect.Slice:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
// 保持 []byte 原样,避免改变现有二进制列的 JSON 编码行为(base64)。
|
||||
if rv.Type().Elem().Kind() == reflect.Uint8 {
|
||||
return v
|
||||
}
|
||||
size := rv.Len()
|
||||
items := make([]interface{}, size)
|
||||
for i := 0; i < size; i++ {
|
||||
items[i] = normalizeAgentResponseData(rv.Index(i).Interface())
|
||||
}
|
||||
return items
|
||||
case reflect.Array:
|
||||
size := rv.Len()
|
||||
items := make([]interface{}, size)
|
||||
for i := 0; i < size; i++ {
|
||||
items[i] = normalizeAgentResponseData(rv.Index(i).Interface())
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs <= 0 {
|
||||
return inst.Query(query)
|
||||
}
|
||||
if q, ok := inst.(interface {
|
||||
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
|
||||
}); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
return q.QueryContext(ctx, query)
|
||||
}
|
||||
return inst.Query(query)
|
||||
}
|
||||
|
||||
func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (int64, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs <= 0 {
|
||||
return inst.Exec(query)
|
||||
}
|
||||
if e, ok := inst.(interface {
|
||||
ExecContext(context.Context, string) (int64, error)
|
||||
}); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
return e.ExecContext(ctx, query)
|
||||
}
|
||||
return inst.Exec(query)
|
||||
}
|
||||
|
||||
172
cmd/optional-driver-agent/main_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
type duckMapLike map[any]any
|
||||
|
||||
func TestWriteResponse_NormalizesMapAnyAny(t *testing.T) {
|
||||
resp := agentResponse{
|
||||
ID: 1,
|
||||
Success: true,
|
||||
Data: []map[string]interface{}{
|
||||
{
|
||||
"id": int64(7),
|
||||
"meta": duckMapLike{"k": "v", 2: "two"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
writer := bufio.NewWriter(&out)
|
||||
if err := writeResponse(writer, resp); err != nil {
|
||||
t.Fatalf("writeResponse 返回错误: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &decoded); err != nil {
|
||||
t.Fatalf("解码响应失败: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded.Data) != 1 {
|
||||
t.Fatalf("期望 1 行数据,实际 %d", len(decoded.Data))
|
||||
}
|
||||
meta, ok := decoded.Data[0]["meta"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("meta 字段类型异常: %T", decoded.Data[0]["meta"])
|
||||
}
|
||||
if meta["k"] != "v" {
|
||||
t.Fatalf("字符串 key 转换异常: %v", meta["k"])
|
||||
}
|
||||
if meta["2"] != "two" {
|
||||
t.Fatalf("数字 key 未字符串化: %v", meta["2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) {
|
||||
raw := []byte{0x61, 0x62, 0x63}
|
||||
normalized := normalizeAgentResponseData(raw)
|
||||
out, ok := normalized.([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("期望 []byte,实际 %T", normalized)
|
||||
}
|
||||
if !bytes.Equal(out, raw) {
|
||||
t.Fatalf("[]byte 内容被意外改写: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAgentTimeoutDB struct {
|
||||
queryCalled bool
|
||||
queryContextCalled bool
|
||||
execCalled bool
|
||||
execContextCalled bool
|
||||
deadlineSet bool
|
||||
}
|
||||
|
||||
func (f *fakeAgentTimeoutDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||||
func (f *fakeAgentTimeoutDB) Close() error { return nil }
|
||||
func (f *fakeAgentTimeoutDB) Ping() error { return nil }
|
||||
func (f *fakeAgentTimeoutDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
f.queryCalled = true
|
||||
return nil, nil, errors.New("query should not be called")
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
f.queryContextCalled = true
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
f.deadlineSet = true
|
||||
}
|
||||
return []map[string]interface{}{{"ok": 1}}, []string{"ok"}, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) Exec(query string) (int64, error) {
|
||||
f.execCalled = true
|
||||
return 0, errors.New("exec should not be called")
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
f.execContextCalled = true
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
f.deadlineSet = true
|
||||
}
|
||||
return 3, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetDatabases() ([]string, error) { return nil, nil }
|
||||
func (f *fakeAgentTimeoutDB) GetTables(dbName string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) {
|
||||
fake := &fakeAgentTimeoutDB{}
|
||||
data, fields, err := queryWithOptionalTimeout(fake, "SELECT 1", int64((2 * time.Second).Milliseconds()))
|
||||
if err != nil {
|
||||
t.Fatalf("queryWithOptionalTimeout 返回错误: %v", err)
|
||||
}
|
||||
if !fake.queryContextCalled || fake.queryCalled {
|
||||
t.Fatalf("query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled)
|
||||
}
|
||||
if !fake.deadlineSet {
|
||||
t.Fatal("queryWithOptionalTimeout 未设置 deadline")
|
||||
}
|
||||
if len(data) != 1 || len(fields) != 1 || fields[0] != "ok" {
|
||||
t.Fatalf("queryWithOptionalTimeout 返回数据异常: data=%v fields=%v", data, fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecWithOptionalTimeout_UsesExecContext(t *testing.T) {
|
||||
fake := &fakeAgentTimeoutDB{}
|
||||
affected, err := execWithOptionalTimeout(fake, "DELETE FROM t", int64((2 * time.Second).Milliseconds()))
|
||||
if err != nil {
|
||||
t.Fatalf("execWithOptionalTimeout 返回错误: %v", err)
|
||||
}
|
||||
if !fake.execContextCalled || fake.execCalled {
|
||||
t.Fatalf("exec 调用路径异常,ExecContext=%v Exec=%v", fake.execContextCalled, fake.execCalled)
|
||||
}
|
||||
if !fake.deadlineSet {
|
||||
t.Fatal("execWithOptionalTimeout 未设置 deadline")
|
||||
}
|
||||
if affected != 3 {
|
||||
t.Fatalf("受影响行数异常,want=3 got=%d", affected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testing.T) {
|
||||
old := agentDriverType
|
||||
agentDriverType = "clickhouse"
|
||||
defer func() { agentDriverType = old }()
|
||||
|
||||
fake := &fakeAgentTimeoutDB{}
|
||||
_, _, err := queryWithOptionalTimeout(fake, "SELECT 1", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("queryWithOptionalTimeout 返回错误: %v", err)
|
||||
}
|
||||
if !fake.queryContextCalled || fake.queryCalled {
|
||||
t.Fatalf("clickhouse legacy query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled)
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_clickhouse.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_clickhouse_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "clickhouse"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.ClickHouseDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_mongodb_v1.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_mongodb_driver_v1
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "mongodb"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.MongoDBV1{}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,11 @@
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mariadb"
|
||||
},
|
||||
"diros": {
|
||||
"doris": {
|
||||
"engine": "go",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/diros"
|
||||
"downloadUrl": "builtin://activate/doris"
|
||||
},
|
||||
"sphinx": {
|
||||
"engine": "go",
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"duckdb": {
|
||||
"engine": "go",
|
||||
"version": "2.5.5",
|
||||
"version": "2.5.6",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/duckdb"
|
||||
},
|
||||
@@ -73,6 +73,12 @@
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/tdengine"
|
||||
},
|
||||
"clickhouse": {
|
||||
"engine": "go",
|
||||
"version": "2.43.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/clickhouse"
|
||||
},
|
||||
"postgres": {
|
||||
"engine": "go",
|
||||
"version": "1.11.1",
|
||||
|
||||
182
frontend/ai_ui_mockups_wip.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!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>
|
||||
@@ -5,6 +5,23 @@
|
||||
<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>
|
||||
|
||||
3345
frontend/package-lock.json
generated
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
@@ -15,11 +16,16 @@
|
||||
"@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",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql-formatter": "^15.7.0",
|
||||
"uuid": "^9.0.1",
|
||||
"zustand": "^4.4.7"
|
||||
@@ -31,6 +37,7 @@
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
5b8157374dae5f9340e31b2d0bd2c00e
|
||||
dcb87159cf0f1f6f750d1c4870911d3f
|
||||
1
frontend/public/db-icons/clickhouse.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 246 B |
1
frontend/public/db-icons/diros.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
frontend/public/db-icons/duckdb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 389 B |
1
frontend/public/db-icons/mariadb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/db-icons/mongodb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 527 B |
1
frontend/public/db-icons/mysql.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
1
frontend/public/db-icons/postgres.svg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
1
frontend/public/db-icons/redis.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 738 B |
1
frontend/public/db-icons/sphinx.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/public/db-icons/sqlite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -37,6 +37,91 @@ body, #root {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -57,6 +142,29 @@ body[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for light mode (transparent-friendly) */
|
||||
body[data-theme='light'] ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.30);
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
|
||||
body {
|
||||
transition: color 0.3s;
|
||||
@@ -67,6 +175,96 @@ body[data-theme='dark'] {
|
||||
在透明窗口环境下会显著加剧 GPU 负载 */
|
||||
}
|
||||
|
||||
/* 暗色 + 透明:提升选中/焦点可读性,避免默认蓝色在半透明背景下发灰 */
|
||||
body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
|
||||
body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
|
||||
background: rgba(246, 196, 83, 0.24) !important;
|
||||
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;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-checkbox-indeterminate .ant-checkbox-inner::after {
|
||||
background-color: #f6c453 !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-checkbox:hover .ant-checkbox-inner,
|
||||
body[data-theme='dark'] .ant-checkbox-wrapper:hover .ant-checkbox-inner {
|
||||
border-color: #f6c453 !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-radio-checked .ant-radio-inner {
|
||||
border-color: #f6c453 !important;
|
||||
background-color: #f6c453 !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-radio-wrapper:hover .ant-radio-inner,
|
||||
body[data-theme='dark'] .ant-radio:hover .ant-radio-inner {
|
||||
border-color: #f6c453 !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-switch.ant-switch-checked {
|
||||
background: #d8a93b !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected > td,
|
||||
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell {
|
||||
background: rgba(246, 196, 83, 0.18) !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected:hover > td,
|
||||
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell {
|
||||
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;
|
||||
@@ -92,3 +290,53 @@ body[data-theme='dark'] {
|
||||
background-color: #ff4d4f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */
|
||||
.driver-manager-table .ant-table-sticky-scroll {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content,
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body {
|
||||
overflow-x: auto !important;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal,
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.driver-manager-table-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.driver-manager-footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.driver-manager-footer-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.driver-manager-hscroll {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.driver-manager-hscroll-inner {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
2006
frontend/src/App.tsx
497
frontend/src/components/AIChatPanel.css
Normal file
@@ -0,0 +1,497 @@
|
||||
.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;
|
||||
}
|
||||
1413
frontend/src/components/AIChatPanel.tsx
Normal file
788
frontend/src/components/AISettingsModal.tsx
Normal file
@@ -0,0 +1,788 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
|
||||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
|
||||
import {
|
||||
getProviderFingerprint,
|
||||
getProviderHostname,
|
||||
matchQwenPresetKey,
|
||||
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_MODELS,
|
||||
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 type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
|
||||
interface AISettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
}
|
||||
|
||||
// 预设配置:每个预设映射到后端 type(openai/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'>): ProviderPreset => {
|
||||
const qwenPresetKey = matchQwenPresetKey(provider);
|
||||
if (qwenPresetKey) {
|
||||
return findPreset(qwenPresetKey);
|
||||
}
|
||||
const fingerprint = getProviderFingerprint(provider.baseUrl);
|
||||
const exactPreset = PROVIDER_PRESETS.find(pr =>
|
||||
pr.backendType === provider.type
|
||||
&& fingerprint !== ''
|
||||
&& fingerprint === getProviderFingerprint(pr.defaultBaseUrl)
|
||||
);
|
||||
if (exactPreset) {
|
||||
return exactPreset;
|
||||
}
|
||||
|
||||
const host = getProviderHostname(provider.baseUrl);
|
||||
if (host.endsWith('moonshot.cn')) {
|
||||
return findPreset('moonshot');
|
||||
}
|
||||
return PROVIDER_PRESETS.find(pr => pr.backendType === provider.type && host !== '' && host === getProviderHostname(pr.defaultBaseUrl))
|
||||
|| PROVIDER_PRESETS.find(pr => pr.backendType === provider.type)
|
||||
|| findPreset('custom');
|
||||
};
|
||||
|
||||
const 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 }) => {
|
||||
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 [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 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]);
|
||||
|
||||
const handleAddProvider = () => {
|
||||
const preset = findPreset('openai');
|
||||
const newProvider: AIProviderConfig = {
|
||||
id: '', type: preset.backendType, name: '', apiKey: '',
|
||||
baseUrl: preset.defaultBaseUrl, model: preset.defaultModel,
|
||||
models: [], maxTokens: 4096, temperature: 0.7,
|
||||
};
|
||||
setEditingProvider({ ...newProvider, presetKey: 'openai' } as any);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ ...newProvider, presetKey: 'openai', apiFormat: 'openai' });
|
||||
};
|
||||
|
||||
const handleEditProvider = (p: AIProviderConfig) => {
|
||||
// 尝试根据 baseUrl 和 type 推断 preset
|
||||
const matchedPreset = matchProviderPreset(p);
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: matchedPreset.backendType,
|
||||
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||||
valuesApiFormat: p.apiFormat,
|
||||
});
|
||||
setEditingProvider(p);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
...p,
|
||||
type: resolvedTransport.type,
|
||||
models: p.models || [],
|
||||
presetKey: matchedPreset.key,
|
||||
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
|
||||
});
|
||||
};
|
||||
|
||||
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 payload = {
|
||||
...editingProvider,
|
||||
...values,
|
||||
...resolvedTransport,
|
||||
name: finalName,
|
||||
model: finalModel,
|
||||
models: resolvedModels,
|
||||
baseUrl: finalBaseUrl,
|
||||
apiFormat: resolvedTransport.apiFormat,
|
||||
};
|
||||
// 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常
|
||||
await Service?.AISaveProvider?.(payload);
|
||||
void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); 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 res = await Service?.AITestProvider?.({
|
||||
...editingProvider,
|
||||
...values,
|
||||
...resolvedTransport,
|
||||
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={() => { setIsEditing(false); setEditingProvider(null); }}
|
||||
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={[{ required: true, message: '请输入 API Key' }]} style={{ marginBottom: 16 }}>
|
||||
<Input.Password placeholder="sk-... / 你的 API Key"
|
||||
size="middle"
|
||||
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
|
||||
</Form.Item>
|
||||
|
||||
{(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={onClose}
|
||||
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;
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Form, Select, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs } from 'antd';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Modal, Form, Select, Input, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs, theme as antdTheme } from 'antd';
|
||||
import { DatabaseOutlined, RocketOutlined, SwapOutlined, TableOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
|
||||
import { SavedConnection } from '../types';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
@@ -21,6 +23,12 @@ type TableDiffSummary = {
|
||||
deletes?: number;
|
||||
same?: number;
|
||||
message?: string;
|
||||
targetTableExists?: boolean;
|
||||
plannedAction?: string;
|
||||
warnings?: string[];
|
||||
unsupportedObjects?: string[];
|
||||
indexesToCreate?: number;
|
||||
indexesSkipped?: number;
|
||||
};
|
||||
type TableOps = {
|
||||
insert: boolean;
|
||||
@@ -31,10 +39,135 @@ type TableOps = {
|
||||
selectedDeletePks?: string[];
|
||||
};
|
||||
|
||||
type WorkflowType = 'sync' | 'migration';
|
||||
|
||||
const quoteSqlIdent = (dbType: string, ident: string): string => {
|
||||
const raw = String(ident || '').trim();
|
||||
if (!raw) return raw;
|
||||
const t = String(dbType || '').toLowerCase();
|
||||
if (t === 'mysql' || t === 'mariadb' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
|
||||
return `\`${raw.replace(/`/g, '``')}\``;
|
||||
}
|
||||
if (t === 'sqlserver') {
|
||||
return `[${raw.replace(/]/g, ']]')}]`;
|
||||
}
|
||||
return `"${raw.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const quoteSqlTable = (dbType: string, tableName: string): string => {
|
||||
const raw = String(tableName || '').trim();
|
||||
if (!raw) return raw;
|
||||
if (!raw.includes('.')) return quoteSqlIdent(dbType, raw);
|
||||
return raw
|
||||
.split('.')
|
||||
.map((part) => quoteSqlIdent(dbType, part))
|
||||
.join('.');
|
||||
};
|
||||
|
||||
const toSqlLiteral = (value: any, dbType: string): string => {
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (typeof value === 'boolean') {
|
||||
const t = String(dbType || '').toLowerCase();
|
||||
if (t === 'sqlserver') return value ? '1' : '0';
|
||||
return value ? 'TRUE' : 'FALSE';
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return `'${value.toISOString().replace(/'/g, "''")}'`;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
|
||||
} catch {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
}
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
};
|
||||
|
||||
const resolveRedisDbIndex = (raw?: string): number => {
|
||||
const value = Number(String(raw || '').trim());
|
||||
return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
|
||||
};
|
||||
|
||||
const buildSqlPreview = (
|
||||
previewData: any,
|
||||
tableName: string,
|
||||
dbType: string,
|
||||
ops?: TableOps,
|
||||
): { sqlText: string; statementCount: number } => {
|
||||
if (!previewData || !tableName) return { sqlText: '', statementCount: 0 };
|
||||
const tableExpr = quoteSqlTable(dbType, tableName);
|
||||
const pkCol = String(previewData.pkColumn || 'id');
|
||||
const statements: string[] = [];
|
||||
|
||||
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
|
||||
const updateRows = Array.isArray(previewData.updates) ? previewData.updates : [];
|
||||
const deleteRows = Array.isArray(previewData.deletes) ? previewData.deletes : [];
|
||||
|
||||
const selectedInsert = new Set((ops?.selectedInsertPks || []).map((v) => String(v)));
|
||||
const selectedUpdate = new Set((ops?.selectedUpdatePks || []).map((v) => String(v)));
|
||||
const selectedDelete = new Set((ops?.selectedDeletePks || []).map((v) => String(v)));
|
||||
|
||||
if (ops?.insert !== false) {
|
||||
insertRows.forEach((rowWrap: any) => {
|
||||
const pk = String(rowWrap?.pk ?? '');
|
||||
if (selectedInsert.size > 0 && !selectedInsert.has(pk)) return;
|
||||
const row = rowWrap?.row || {};
|
||||
const columns = Object.keys(row);
|
||||
if (columns.length === 0) return;
|
||||
const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', ');
|
||||
const valExpr = columns.map((c) => toSqlLiteral(row[c], dbType)).join(', ');
|
||||
statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`);
|
||||
});
|
||||
}
|
||||
|
||||
if (ops?.update !== false) {
|
||||
updateRows.forEach((rowWrap: any) => {
|
||||
const pk = String(rowWrap?.pk ?? '');
|
||||
if (selectedUpdate.size > 0 && !selectedUpdate.has(pk)) return;
|
||||
const source = rowWrap?.source || {};
|
||||
const changedColumns = Array.isArray(rowWrap?.changedColumns)
|
||||
? rowWrap.changedColumns
|
||||
: Object.keys(source).filter((k) => k !== pkCol);
|
||||
const setCols = changedColumns.filter((c: string) => String(c) !== pkCol);
|
||||
if (setCols.length === 0) return;
|
||||
const setExpr = setCols
|
||||
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toSqlLiteral(source[c], dbType)}`)
|
||||
.join(', ');
|
||||
statements.push(
|
||||
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (ops?.delete) {
|
||||
deleteRows.forEach((rowWrap: any) => {
|
||||
const pk = String(rowWrap?.pk ?? '');
|
||||
if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return;
|
||||
statements.push(
|
||||
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sqlText: statements.join('\n'),
|
||||
statementCount: statements.length,
|
||||
};
|
||||
};
|
||||
|
||||
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const connections = useStore((state) => state.connections);
|
||||
const themeMode = useStore((state) => state.theme);
|
||||
const appearance = useStore((state) => state.appearance);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { token } = antdTheme.useToken();
|
||||
const darkMode = themeMode === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
|
||||
// Step 1: Config
|
||||
const [sourceConnId, setSourceConnId] = useState<string>('');
|
||||
@@ -50,9 +183,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const [selectedTables, setSelectedTables] = useState<string[]>([]);
|
||||
|
||||
// Options
|
||||
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
|
||||
const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>('data');
|
||||
const [syncMode, setSyncMode] = useState<string>('insert_update');
|
||||
const [autoAddColumns, setAutoAddColumns] = useState<boolean>(true);
|
||||
const [targetTableStrategy, setTargetTableStrategy] = useState<'existing_only' | 'auto_create_if_missing' | 'smart'>('existing_only');
|
||||
const [createIndexes, setCreateIndexes] = useState<boolean>(false);
|
||||
const [mongoCollectionName, setMongoCollectionName] = useState<string>('');
|
||||
const [showSameTables, setShowSameTables] = useState<boolean>(false);
|
||||
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
||||
const [diffTables, setDiffTables] = useState<TableDiffSummary[]>([]);
|
||||
@@ -128,9 +265,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setSourceDb('');
|
||||
setTargetDb('');
|
||||
setSelectedTables([]);
|
||||
setWorkflowType('sync');
|
||||
setSyncContent('data');
|
||||
setSyncMode('insert_update');
|
||||
setAutoAddColumns(true);
|
||||
setTargetTableStrategy('existing_only');
|
||||
setCreateIndexes(false);
|
||||
setShowSameTables(false);
|
||||
setAnalyzing(false);
|
||||
setDiffTables([]);
|
||||
@@ -148,36 +288,66 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workflowType === 'migration') {
|
||||
if (syncMode === 'insert_update') {
|
||||
setSyncMode('insert_only');
|
||||
}
|
||||
if (syncContent === 'schema') {
|
||||
setSyncContent('both');
|
||||
}
|
||||
if (targetTableStrategy === 'existing_only') {
|
||||
setTargetTableStrategy('smart');
|
||||
}
|
||||
if (!createIndexes) {
|
||||
setCreateIndexes(true);
|
||||
}
|
||||
} else {
|
||||
if (targetTableStrategy !== 'existing_only') {
|
||||
setTargetTableStrategy('existing_only');
|
||||
}
|
||||
if (createIndexes) {
|
||||
setCreateIndexes(false);
|
||||
}
|
||||
}
|
||||
}, [workflowType]);
|
||||
|
||||
const handleSourceConnChange = async (connId: string) => {
|
||||
setSourceConnId(connId);
|
||||
setSourceDb('');
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
setSourceDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch source databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||
setSourceDbs(dbRows
|
||||
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch source databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetConnChange = async (connId: string) => {
|
||||
setTargetConnId(connId);
|
||||
setTargetDb('');
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
setTargetDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch target databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||
setTargetDbs(dbRows
|
||||
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch target databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextToTables = async () => {
|
||||
@@ -189,14 +359,17 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
try {
|
||||
const conn = connections.find(c => c.id === sourceConnId);
|
||||
if (conn) {
|
||||
const config = normalizeConnConfig(conn, sourceDb);
|
||||
const res = await DBGetTables(config as any, sourceDb);
|
||||
if (res.success) {
|
||||
// DBGetTables returns [{Table: "name"}, ...]
|
||||
const tables = (res.data as any[]).map((row: any) => row.Table || row.table || row.TABLE_NAME || Object.values(row)[0]);
|
||||
setAllTables(tables as string[]);
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
const config = normalizeConnConfig(conn, sourceDb);
|
||||
const res = await DBGetTables(config as any, sourceDb);
|
||||
if (res.success) {
|
||||
// DBGetTables returns [{Table: "name"}, ...]
|
||||
const tableRows = Array.isArray(res.data) ? res.data : [];
|
||||
const tables = tableRows
|
||||
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
|
||||
setAllTables(tables as string[]);
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
}
|
||||
@@ -236,6 +409,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
content: syncContent,
|
||||
mode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
jobId,
|
||||
};
|
||||
|
||||
@@ -286,6 +462,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
content: "data",
|
||||
mode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -362,6 +541,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
content: syncContent,
|
||||
mode: syncMode,
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
tableOptions,
|
||||
jobId,
|
||||
};
|
||||
@@ -402,10 +584,139 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
);
|
||||
};
|
||||
|
||||
const previewSql = useMemo(() => {
|
||||
if (!previewData || !previewTable) return { sqlText: '', statementCount: 0 };
|
||||
const targetType = String(connections.find(c => c.id === targetConnId)?.config?.type || '');
|
||||
const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false };
|
||||
return buildSqlPreview(previewData, previewTable, targetType, ops);
|
||||
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
|
||||
|
||||
const analysisWarnings = useMemo(() => {
|
||||
const items: string[] = [];
|
||||
diffTables.forEach((table) => {
|
||||
(table.warnings || []).forEach((warning) => items.push(`${table.table}: ${warning}`));
|
||||
(table.unsupportedObjects || []).forEach((warning) => items.push(`${table.table}: ${warning}`));
|
||||
});
|
||||
return Array.from(new Set(items));
|
||||
}, [diffTables]);
|
||||
|
||||
const isMigrationWorkflow = workflowType === 'migration';
|
||||
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
|
||||
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
|
||||
const sourceType = String(sourceConn?.config?.type || '').toLowerCase();
|
||||
const targetType = String(targetConn?.config?.type || '').toLowerCase();
|
||||
const isRedisMongoKeyspaceMigration = isMigrationWorkflow && (
|
||||
(sourceType === 'redis' && targetType === 'mongodb') ||
|
||||
(sourceType === 'mongodb' && targetType === 'redis')
|
||||
);
|
||||
const defaultMongoCollectionName = useMemo(() => {
|
||||
if (sourceType === 'redis' && targetType === 'mongodb') {
|
||||
return `redis_db_${resolveRedisDbIndex(sourceDb || sourceConn?.config?.database)}_keys`;
|
||||
}
|
||||
if (sourceType === 'mongodb' && targetType === 'redis') {
|
||||
return selectedTables[0] || `redis_db_${resolveRedisDbIndex(targetDb || targetConn?.config?.database)}_keys`;
|
||||
}
|
||||
return '';
|
||||
}, [sourceType, targetType, sourceDb, targetDb, sourceConn, targetConn, selectedTables]);
|
||||
|
||||
const modalPanelStyle = useMemo(() => ({
|
||||
background: darkMode
|
||||
? 'linear-gradient(180deg, rgba(16,22,34,0.96) 0%, rgba(10,14,24,0.98) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.36)' : '0 18px 44px rgba(15,23,42,0.14)',
|
||||
backdropFilter: darkMode ? 'blur(18px)' : 'none',
|
||||
}), [darkMode]);
|
||||
|
||||
const shellCardStyle = useMemo<React.CSSProperties>(() => ({
|
||||
borderRadius: 18,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : `rgba(255,255,255,${Math.max(effectiveOpacity, 0.88)})`,
|
||||
boxShadow: darkMode ? '0 12px 32px rgba(0,0,0,0.22)' : '0 10px 24px rgba(15,23,42,0.08)',
|
||||
overflow: 'hidden',
|
||||
}), [darkMode, effectiveOpacity]);
|
||||
|
||||
const heroPanelStyle = useMemo<React.CSSProperties>(() => ({
|
||||
padding: 18,
|
||||
borderRadius: 18,
|
||||
border: darkMode ? '1px solid rgba(255,214,102,0.12)' : '1px solid rgba(24,144,255,0.12)',
|
||||
background: darkMode
|
||||
? 'linear-gradient(135deg, rgba(255,214,102,0.10) 0%, rgba(255,255,255,0.03) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(24,144,255,0.10) 0%, rgba(255,255,255,0.95) 100%)',
|
||||
marginBottom: 18,
|
||||
}), [darkMode]);
|
||||
|
||||
const badgeStyle = useMemo<React.CSSProperties>(() => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.10)' : '1px solid rgba(15,23,42,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.86)',
|
||||
color: darkMode ? 'rgba(255,255,255,0.88)' : '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}), [darkMode]);
|
||||
|
||||
const quietPanelStyle = useMemo<React.CSSProperties>(() => ({
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.025)' : 'rgba(248,250,252,0.92)',
|
||||
}), [darkMode]);
|
||||
|
||||
const modalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
}), []);
|
||||
|
||||
const modalScrollableContentStyle = useMemo<React.CSSProperties>(() => ({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 4,
|
||||
overscrollBehavior: 'contain',
|
||||
}), []);
|
||||
|
||||
const modalFooterBarStyle = useMemo<React.CSSProperties>(() => ({
|
||||
marginTop: 18,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
paddingTop: 12,
|
||||
borderTop: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(15,23,42,0.06)',
|
||||
flex: '0 0 auto',
|
||||
}), [darkMode]);
|
||||
|
||||
const renderModalTitle = (title: string, description: string) => (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 14,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)',
|
||||
color: darkMode ? '#ffd666' : token.colorPrimary,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{title}</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12, lineHeight: 1.6, color: darkMode ? 'rgba(255,255,255,0.56)' : 'rgba(15,23,42,0.58)' }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="数据同步"
|
||||
title={renderModalTitle(isMigrationWorkflow ? '跨库迁移工作台' : '数据同步工作台', isMigrationWorkflow ? '按源库 → 目标库完成建表、导入与风险预检。' : '按已有目标表完成差异对比、同步执行与结果确认。')}
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
if (syncing) {
|
||||
@@ -414,23 +725,61 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
width={800}
|
||||
width={920}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
closable={!syncing}
|
||||
maskClosable={!syncing}
|
||||
styles={{
|
||||
content: modalPanelStyle,
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 },
|
||||
body: {
|
||||
paddingTop: 8,
|
||||
height: 760,
|
||||
maxHeight: 'calc(100vh - 120px)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 },
|
||||
}}
|
||||
>
|
||||
<div style={modalWorkspaceStyle}>
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={heroPanelStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{isMigrationWorkflow ? '跨数据源迁移' : '数据同步'}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, lineHeight: 1.7, color: darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(15,23,42,0.62)' }}>
|
||||
{isMigrationWorkflow
|
||||
? '适合把源表迁移到另一套数据库,可按策略自动建表、导入数据并补建可兼容索引。'
|
||||
: '适合目标表已存在的场景,先做差异分析,再按勾选执行插入、更新或删除。'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<span style={badgeStyle}>{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />} {isMigrationWorkflow ? '迁移模式' : '同步模式'}</span>
|
||||
<span style={badgeStyle}><DatabaseOutlined /> {sourceConnId ? '已选源连接' : '待选源连接'}</span>
|
||||
<span style={badgeStyle}><TableOutlined /> {selectedTables.length || 0} 张表</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Steps current={currentStep} style={{ marginBottom: 24 }}>
|
||||
<Step title="配置源与目标" />
|
||||
<Step title="选择表" />
|
||||
<Step title="执行结果" />
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
<div style={modalScrollableContentStyle}>
|
||||
{/* STEP 1: CONFIG */}
|
||||
{currentStep === 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 24, justifyContent: 'center' }}>
|
||||
<Card title="源数据库" style={{ width: 350 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 44px minmax(0, 1fr)', gap: 18, alignItems: 'stretch' }}>
|
||||
<Card
|
||||
title="源数据库"
|
||||
style={shellCardStyle}
|
||||
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="连接">
|
||||
<Select value={sourceConnId} onChange={handleSourceConnChange}>
|
||||
@@ -444,8 +793,16 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>至</div>
|
||||
<Card title="目标数据库" style={{ width: 350 }}>
|
||||
<div style={{ display: 'grid', placeItems: 'center' }}>
|
||||
<div style={{ ...badgeStyle, width: 44, height: 44, borderRadius: 14, justifyContent: 'center', padding: 0 }}>
|
||||
<SwapOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<Card
|
||||
title="目标数据库"
|
||||
style={shellCardStyle}
|
||||
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="连接">
|
||||
<Select value={targetConnId} onChange={handleTargetConnChange}>
|
||||
@@ -461,27 +818,94 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="同步选项" style={{ marginTop: 16 }}>
|
||||
<Card
|
||||
title={isMigrationWorkflow ? '迁移选项' : '同步选项'}
|
||||
style={{ ...shellCardStyle, marginTop: 18 }}
|
||||
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
|
||||
>
|
||||
<div style={{ ...quietPanelStyle, marginBottom: 14 }}>
|
||||
<Text style={{ color: darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(15,23,42,0.68)', lineHeight: 1.7 }}>
|
||||
先明确当前要做的是“已有目标表同步”还是“跨库迁移”,页面会按功能类型自动给出更安全的默认策略。
|
||||
</Text>
|
||||
</div>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="同步内容">
|
||||
<Form.Item label="功能类型">
|
||||
<Select value={workflowType} onChange={setWorkflowType}>
|
||||
<Option value="sync">数据同步(基于已有目标表做差异同步)</Option>
|
||||
<Option value="migration">跨库迁移(可自动建表后导入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type={isMigrationWorkflow ? 'info' : 'success'}
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={isMigrationWorkflow
|
||||
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
|
||||
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
|
||||
/>
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
|
||||
<Select value={syncContent} onChange={setSyncContent}>
|
||||
<Option value="data">仅同步数据</Option>
|
||||
<Option value="schema">仅同步结构</Option>
|
||||
<Option value="both">同步结构 + 数据</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="同步模式">
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
|
||||
<Select value={syncMode} onChange={setSyncMode} disabled={syncContent === 'schema'}>
|
||||
<Option value="insert_update">增量同步(对比差异,按插入/更新/删除勾选执行)</Option>
|
||||
<Option value="insert_only">仅插入(不对比目标;无主键表将跳过)</Option>
|
||||
<Option value="full_overwrite">全量覆盖(清空目标表后插入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow}>
|
||||
<Option value="existing_only">仅使用已有目标表</Option>
|
||||
<Option value="auto_create_if_missing">目标表不存在时自动建表后导入</Option>
|
||||
<Option value="smart">智能模式(存在则直接导入,不存在则自动建表)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{isRedisMongoKeyspaceMigration && (
|
||||
<Form.Item
|
||||
label="Mongo 集合名(可选)"
|
||||
extra={sourceType === 'redis'
|
||||
? '为空时沿用默认集合名;填写后本次 Redis 键空间会统一写入该 Mongo 集合。'
|
||||
: 'MongoDB → Redis 场景下通常直接选择源集合;这里留空即可,未显式选集合时才会回退使用该名称。'}
|
||||
>
|
||||
<Input
|
||||
value={mongoCollectionName}
|
||||
onChange={(e) => setMongoCollectionName(e.target.value)}
|
||||
placeholder={defaultMongoCollectionName || '请输入 Mongo 集合名'}
|
||||
allowClear
|
||||
maxLength={128}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)}>
|
||||
自动补齐目标表缺失字段(仅 MySQL 目标)
|
||||
自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}>
|
||||
自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
{isMigrationWorkflow && targetTableStrategy !== 'existing_only' && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="自动建表模式首期仅支持 MySQL → Kingbase;将迁移字段、主键、普通/唯一/联合索引,并显式跳过全文、空间、前缀、函数类索引。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{!isMigrationWorkflow && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="数据同步模式默认基于已有目标表执行;如需跨数据源建表导入,请切换到“跨库迁移”。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{syncContent !== 'schema' && syncMode === 'full_overwrite' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
@@ -496,26 +920,42 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
|
||||
{/* STEP 2: TABLES */}
|
||||
{currentStep === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={quietPanelStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
|
||||
显示相同表
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Transfer
|
||||
</div>
|
||||
<Transfer
|
||||
dataSource={allTables.map(t => ({ key: t, title: t }))}
|
||||
titles={['源表', '已选表']}
|
||||
targetKeys={selectedTables}
|
||||
onChange={(keys) => setSelectedTables(keys as string[])}
|
||||
render={item => item.title}
|
||||
listStyle={{ width: 350, height: 280, marginTop: 0 }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表', notFoundContent: '暂无数据' }}
|
||||
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{diffTables.length > 0 && (
|
||||
<div>
|
||||
<Divider orientation="left">对比结果</Divider>
|
||||
<div style={quietPanelStyle}>
|
||||
<Divider orientation="left" style={{ marginTop: 0 }}>对比结果</Divider>
|
||||
{analysisWarnings.length > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="预检发现风险或降级项,请在执行前确认"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{analysisWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
|
||||
{analysisWarnings.length > 8 && <li>还有 {analysisWarnings.length - 8} 项未展开</li>}
|
||||
</ul>
|
||||
}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
@@ -527,13 +967,29 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const same = Number(t.same || 0);
|
||||
const msg = String(t.message || '').trim();
|
||||
const can = !!t.canSync;
|
||||
const warns = Array.isArray(t.warnings) ? t.warnings.length : 0;
|
||||
const unsupported = Array.isArray(t.unsupportedObjects) ? t.unsupportedObjects.length : 0;
|
||||
if (showSameTables) return true;
|
||||
if (!can) return true;
|
||||
if (msg) return true;
|
||||
if (msg || warns > 0 || unsupported > 0) return true;
|
||||
return ins > 0 || upd > 0 || del > 0 || same === 0;
|
||||
})}
|
||||
columns={[
|
||||
{ title: '表名', dataIndex: 'table', key: 'table', ellipsis: true },
|
||||
{
|
||||
title: '目标表',
|
||||
key: 'targetTableExists',
|
||||
width: 90,
|
||||
render: (_: any, r: any) => r.targetTableExists ? '已存在' : '不存在'
|
||||
},
|
||||
{
|
||||
title: '计划',
|
||||
dataIndex: 'plannedAction',
|
||||
key: 'plannedAction',
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
render: (v: any) => String(v || '')
|
||||
},
|
||||
{
|
||||
title: '插入',
|
||||
key: 'inserts',
|
||||
@@ -542,11 +998,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
|
||||
const disabled = !r.canSync || analyzing || Number(r.inserts || 0) === 0;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={!!ops.insert}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateTableOption(r.table, 'insert', e.target.checked)}
|
||||
>
|
||||
<Checkbox checked={!!ops.insert} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'insert', e.target.checked)}>
|
||||
{Number(r.inserts || 0)}
|
||||
</Checkbox>
|
||||
);
|
||||
@@ -560,11 +1012,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
|
||||
const disabled = !r.canSync || analyzing || Number(r.updates || 0) === 0;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={!!ops.update}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateTableOption(r.table, 'update', e.target.checked)}
|
||||
>
|
||||
<Checkbox checked={!!ops.update} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'update', e.target.checked)}>
|
||||
{Number(r.updates || 0)}
|
||||
</Checkbox>
|
||||
);
|
||||
@@ -578,18 +1026,28 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
|
||||
const disabled = !r.canSync || analyzing || Number(r.deletes || 0) === 0;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={!!ops.delete}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateTableOption(r.table, 'delete', e.target.checked)}
|
||||
>
|
||||
<Checkbox checked={!!ops.delete} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'delete', e.target.checked)}>
|
||||
{Number(r.deletes || 0)}
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ title: '相同', dataIndex: 'same', key: 'same', width: 70, render: (v: any) => Number(v || 0) },
|
||||
{ title: '消息', dataIndex: 'message', key: 'message', ellipsis: true, render: (v: any) => (v ? String(v) : '') },
|
||||
{
|
||||
title: '风险',
|
||||
key: 'warnings',
|
||||
width: 220,
|
||||
render: (_: any, r: any) => {
|
||||
const warns = [...(Array.isArray(r.warnings) ? r.warnings : []), ...(Array.isArray(r.unsupportedObjects) ? r.unsupportedObjects : [])];
|
||||
if (warns.length === 0) return '-';
|
||||
return (
|
||||
<div style={{ color: '#d48806', fontSize: 12, lineHeight: 1.5 }}>
|
||||
{warns.slice(0, 2).map((item: string) => <div key={item}>{item}</div>)}
|
||||
{warns.length > 2 && <div>还有 {warns.length - 2} 项</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '预览',
|
||||
key: 'preview',
|
||||
@@ -613,7 +1071,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
|
||||
{/* STEP 3: RESULT */}
|
||||
{currentStep === 2 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={quietPanelStyle}>
|
||||
<Alert
|
||||
message={syncing ? "正在同步" : (syncResult?.success ? "同步完成" : "同步失败")}
|
||||
description={
|
||||
@@ -625,7 +1084,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Progress
|
||||
percent={syncProgress.percent}
|
||||
status={syncing ? "active" : (syncResult?.success ? "success" : "exception")}
|
||||
@@ -633,7 +1092,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider orientation="left">日志</Divider>
|
||||
</div>
|
||||
<div style={quietPanelStyle}>
|
||||
<Divider orientation="left" style={{ marginTop: 0 }}>执行日志</Divider>
|
||||
<div
|
||||
ref={logBoxRef}
|
||||
onScroll={() => {
|
||||
@@ -642,14 +1103,25 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
autoScrollRef.current = nearBottom;
|
||||
}}
|
||||
style={{ background: '#f5f5f5', padding: 12, height: 300, overflowY: 'auto', fontFamily: 'monospace' }}
|
||||
style={{
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(248,250,252,0.92)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)',
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
height: 300,
|
||||
overflowY: 'auto',
|
||||
fontFamily: 'SFMono-Regular, ui-monospace, Menlo, Consolas, monospace'
|
||||
}}
|
||||
>
|
||||
{syncLogs.map((item, i: number) => <div key={i}>{renderSyncLogItem(item)}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||||
</div>
|
||||
|
||||
<div style={modalFooterBarStyle}>
|
||||
{currentStep === 0 && (
|
||||
<Button type="primary" onClick={nextToTables} loading={loading}>下一步</Button>
|
||||
)}
|
||||
@@ -676,14 +1148,16 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Drawer
|
||||
title={`差异预览:${previewTable}`}
|
||||
styles={{ body: { background: darkMode ? 'rgba(9,13,20,0.98)' : '#f8fafc' } }}
|
||||
open={previewOpen}
|
||||
onClose={() => { setPreviewOpen(false); setPreviewTable(''); setPreviewData(null); }}
|
||||
width={900}
|
||||
>
|
||||
{previewLoading && <Alert type="info" showIcon message="正在加载差异预览..." />}
|
||||
{previewLoading && <Alert type="info" showIcon message="正在加载差异预览…" />}
|
||||
{!previewLoading && previewData && (
|
||||
<div>
|
||||
<Alert
|
||||
@@ -794,6 +1268,51 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'sql',
|
||||
label: `SQL(${previewSql.statementCount})`,
|
||||
children: (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
/>
|
||||
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">共 {previewSql.statementCount} 条语句(预览数据最多 200 条/类型)</Text>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!previewSql.sqlText}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(previewSql.sqlText || '');
|
||||
message.success('SQL 已复制');
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制');
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制 SQL
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 10,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
background: '#fafafa',
|
||||
maxHeight: 420,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,208 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { message } from 'antd';
|
||||
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, buildWhereSQL, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
|
||||
type ViewerPaginationState = {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalKnown: boolean;
|
||||
totalApprox: boolean;
|
||||
totalCountLoading: boolean;
|
||||
totalCountCancelled: boolean;
|
||||
};
|
||||
|
||||
const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const isIntegerText = (text: string): boolean => /^[+-]?\d+$/.test(text);
|
||||
|
||||
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value >= 0n && value <= JS_MAX_SAFE_INTEGER_BIGINT ? Number(value) : null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim();
|
||||
if (!text) return null;
|
||||
if (isIntegerText(text)) {
|
||||
try {
|
||||
const parsedBigInt = BigInt(text);
|
||||
if (parsedBigInt < 0n || parsedBigInt > JS_MAX_SAFE_INTEGER_BIGINT) {
|
||||
return null;
|
||||
}
|
||||
return Number(parsedBigInt);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const parsed = Number(text);
|
||||
return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseTotalFromCountRow = (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;
|
||||
|
||||
for (const [key, raw] of entries) {
|
||||
const normalized = String(key || '').trim().toLowerCase();
|
||||
if (normalized === 'total' || normalized === 'count' || normalized.includes('count')) {
|
||||
const parsed = toNonNegativeFiniteNumber(raw);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, raw] of entries) {
|
||||
const parsed = toNonNegativeFiniteNumber(raw);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
|
||||
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) {
|
||||
const first = text[0];
|
||||
const last = text[text.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
|
||||
return text.slice(1, -1).trim();
|
||||
}
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const resolveDuckDBSchemaAndTable = (dbName: string, tableName: string) => {
|
||||
const rawTable = String(tableName || '').trim();
|
||||
if (!rawTable) return { schemaName: 'main', pureTableName: '' };
|
||||
|
||||
const parts = rawTable.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const pureTableName = normalizeDuckDBIdentifier(parts[parts.length - 1]);
|
||||
const schemaName = normalizeDuckDBIdentifier(parts[parts.length - 2]);
|
||||
if (schemaName && pureTableName) {
|
||||
return { schemaName, pureTableName };
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackSchema = normalizeDuckDBIdentifier(String(dbName || '').trim()) || 'main';
|
||||
return { schemaName: fallbackSchema, pureTableName: normalizeDuckDBIdentifier(rawTable) };
|
||||
};
|
||||
|
||||
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
|
||||
|
||||
const isDuckDBUnsupportedTypeError = (msg: string): boolean => /unsupported\s*type:\s*duckdb\./i.test(String(msg || ''));
|
||||
|
||||
const isDuckDBComplexColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list');
|
||||
};
|
||||
|
||||
const reverseOrderBySQL = (orderBySQL: string): string => {
|
||||
const raw = String(orderBySQL || '').trim();
|
||||
if (!raw) return '';
|
||||
const body = raw.replace(/^order\s+by\s+/i, '').trim();
|
||||
if (!body) return '';
|
||||
|
||||
const parts = body
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
if (/\s+asc$/i.test(part)) return part.replace(/\s+asc$/i, ' DESC');
|
||||
if (/\s+desc$/i.test(part)) return part.replace(/\s+desc$/i, ' ASC');
|
||||
return `${part} DESC`;
|
||||
});
|
||||
if (parts.length === 0) return '';
|
||||
return ` ORDER BY ${parts.join(', ')}`;
|
||||
};
|
||||
|
||||
type ViewerFilterSnapshot = {
|
||||
showFilter: boolean;
|
||||
conditions: FilterCondition[];
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>;
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
};
|
||||
|
||||
type ViewerScrollSnapshot = {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
const viewerFilterSnapshotsByTab = new Map<string, ViewerFilterSnapshot>();
|
||||
|
||||
const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefined): FilterCondition[] => {
|
||||
if (!Array.isArray(conditions)) return [];
|
||||
return conditions.map((cond) => ({
|
||||
id: Number.isFinite(Number(cond?.id)) ? Number(cond?.id) : undefined,
|
||||
enabled: cond?.enabled !== false,
|
||||
logic: String(cond?.logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND',
|
||||
column: String(cond?.column || ''),
|
||||
op: String(cond?.op || '='),
|
||||
value: String(cond?.value ?? ''),
|
||||
value2: String(cond?.value2 ?? ''),
|
||||
}));
|
||||
};
|
||||
|
||||
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
|
||||
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
|
||||
if (!cached) {
|
||||
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
|
||||
}
|
||||
return {
|
||||
showFilter: cached.showFilter === true,
|
||||
conditions: normalizeViewerFilterConditions(cached.conditions),
|
||||
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 }] : []),
|
||||
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 }> = ({ tab }) => {
|
||||
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||
@@ -16,29 +212,184 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const fetchSeqRef = useRef(0);
|
||||
const countSeqRef = useRef(0);
|
||||
const countKeyRef = useRef<string>('');
|
||||
const duckdbApproxSeqRef = useRef(0);
|
||||
const duckdbApproxKeyRef = useRef<string>('');
|
||||
const manualCountSeqRef = useRef(0);
|
||||
const manualCountKeyRef = useRef<string>('');
|
||||
const pkSeqRef = useRef(0);
|
||||
const pkKeyRef = useRef<string>('');
|
||||
const latestConfigRef = useRef<any>(null);
|
||||
const latestDbTypeRef = useRef<string>('');
|
||||
const latestDbNameRef = useRef<string>('');
|
||||
const latestCountSqlRef = useRef<string>('');
|
||||
const latestCountKeyRef = useRef<string>('');
|
||||
const scrollSnapshotRef = useRef<ViewerScrollSnapshot>({
|
||||
top: initialViewerSnapshot.scrollTop,
|
||||
left: initialViewerSnapshot.scrollLeft,
|
||||
});
|
||||
const initialLoadRef = useRef(false);
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 100,
|
||||
const [pagination, setPagination] = useState<ViewerPaginationState>({
|
||||
current: initialViewerSnapshot.currentPage,
|
||||
pageSize: initialViewerSnapshot.pageSize,
|
||||
total: 0,
|
||||
totalKnown: false
|
||||
totalKnown: false,
|
||||
totalApprox: false,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
});
|
||||
|
||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
||||
const [sortInfo, setSortInfo] = useState<Array<{ columnKey: string, order: string, enabled?: boolean }>>(initialViewerSnapshot.sortInfo);
|
||||
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
|
||||
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
|
||||
const forceReadOnly = currentConnType === 'tdengine';
|
||||
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
|
||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
|
||||
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 persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
|
||||
const normalizedTabId = String(tabId || '').trim();
|
||||
if (!normalizedTabId) return;
|
||||
viewerFilterSnapshotsByTab.set(normalizedTabId, {
|
||||
showFilter,
|
||||
conditions: normalizeViewerFilterConditions(filterConditions),
|
||||
currentPage: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortInfo,
|
||||
scrollTop: scrollSnapshotRef.current.top,
|
||||
scrollLeft: scrollSnapshotRef.current.left,
|
||||
...overrides,
|
||||
});
|
||||
}, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||
setShowFilter(snapshot.showFilter);
|
||||
setFilterConditions(snapshot.conditions);
|
||||
setSortInfo(snapshot.sortInfo);
|
||||
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||
initialLoadRef.current = false;
|
||||
}, [tab.id]);
|
||||
|
||||
useEffect(() => {
|
||||
persistViewerSnapshot(tab.id);
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
persistViewerSnapshot(tab.id);
|
||||
};
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||
setPkColumns([]);
|
||||
pkKeyRef.current = '';
|
||||
countKeyRef.current = '';
|
||||
setPagination(prev => ({ ...prev, current: 1, total: 0, totalKnown: false }));
|
||||
}, [tab.connectionId, tab.dbName, tab.tableName]);
|
||||
duckdbApproxKeyRef.current = '';
|
||||
manualCountKeyRef.current = '';
|
||||
duckdbSafeSelectCacheRef.current = {};
|
||||
latestConfigRef.current = null;
|
||||
latestDbTypeRef.current = '';
|
||||
latestDbNameRef.current = '';
|
||||
latestCountSqlRef.current = '';
|
||||
latestCountKeyRef.current = '';
|
||||
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||
initialLoadRef.current = false;
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: snapshot.currentPage,
|
||||
pageSize: snapshot.pageSize,
|
||||
total: 0,
|
||||
totalKnown: false,
|
||||
totalApprox: false,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName]);
|
||||
|
||||
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
|
||||
scrollSnapshotRef.current = snapshot;
|
||||
persistViewerSnapshot(tab.id, {
|
||||
scrollTop: snapshot.top,
|
||||
scrollLeft: snapshot.left,
|
||||
});
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
|
||||
const handleDuckDBManualCount = useCallback(async () => {
|
||||
if (latestDbTypeRef.current !== 'duckdb') {
|
||||
return;
|
||||
}
|
||||
const config = latestConfigRef.current;
|
||||
const dbName = latestDbNameRef.current;
|
||||
const countSql = latestCountSqlRef.current;
|
||||
const countKey = latestCountKeyRef.current;
|
||||
|
||||
if (!config || !countSql || !countKey) {
|
||||
message.warning('当前结果集尚未就绪,请先执行一次加载');
|
||||
return;
|
||||
}
|
||||
|
||||
manualCountKeyRef.current = countKey;
|
||||
const countSeq = ++manualCountSeqRef.current;
|
||||
const countStart = Date.now();
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: true, totalCountCancelled: false }));
|
||||
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()}-duckdb-manual-count`,
|
||||
timestamp: Date.now(),
|
||||
sql: countSql,
|
||||
status: resCount?.success ? 'success' : 'error',
|
||||
duration: countDuration,
|
||||
message: resCount?.success ? '' : String(resCount?.message || '统计失败'),
|
||||
dbName
|
||||
});
|
||||
|
||||
if (manualCountSeqRef.current !== countSeq) return;
|
||||
if (manualCountKeyRef.current !== countKey) return;
|
||||
|
||||
if (!resCount?.success) {
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||
message.error(String(resCount?.message || '统计总数失败'));
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(resCount.data) || resCount.data.length === 0) {
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const total = parseTotalFromCountRow(resCount.data[0]);
|
||||
if (total === null) {
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||
message.error('统计结果解析失败');
|
||||
return;
|
||||
}
|
||||
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
if (manualCountSeqRef.current !== countSeq) return;
|
||||
if (manualCountKeyRef.current !== countKey) return;
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||
message.error(`统计总数失败: ${String(e?.message || e)}`);
|
||||
}
|
||||
}, [addSqlLog]);
|
||||
|
||||
const handleDuckDBCancelManualCount = useCallback(() => {
|
||||
manualCountSeqRef.current++;
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
|
||||
const seq = ++fetchSeqRef.current;
|
||||
@@ -65,40 +416,141 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const dbName = tab.dbName || '';
|
||||
const tableName = tab.tableName || '';
|
||||
const isMongoDB = dbTypeLower === 'mongodb';
|
||||
let mongoFilter: Record<string, unknown> | undefined;
|
||||
if (isMongoDB) {
|
||||
try {
|
||||
mongoFilter = buildMongoFilter(filterConditions);
|
||||
} catch (e: any) {
|
||||
message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`);
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const whereSQL = buildWhereSQL(dbType, filterConditions);
|
||||
|
||||
const countSql = `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
|
||||
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const offset = (page - 1) * size;
|
||||
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
|
||||
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
|
||||
const whereSQL = isMongoDB
|
||||
? JSON.stringify(mongoFilter || {})
|
||||
: buildWhereSQL(dbType, filterConditions);
|
||||
const countSql = isMongoDB
|
||||
? buildMongoCountCommand(tableName, mongoFilter || {})
|
||||
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
const orderBySQL = isMongoDB ? '' : buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const totalRows = Number(pagination.total);
|
||||
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
|
||||
const totalKnown = pagination.totalKnown && hasFiniteTotal;
|
||||
const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0;
|
||||
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
|
||||
const offset = (currentPage - 1) * size;
|
||||
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
|
||||
const reverseOrderSQL = isClickHouse ? reverseOrderBySQL(orderBySQL) : '';
|
||||
let useClickHouseReversePagination = false;
|
||||
let clickHouseReverseLimit = 0;
|
||||
let clickHouseReverseHasMore = false;
|
||||
let sql = '';
|
||||
if (isMongoDB) {
|
||||
const mongoSort = buildMongoSort(sortInfo, pkColumns);
|
||||
sql = buildMongoFindCommand({
|
||||
collection: tableName,
|
||||
filter: mongoFilter || {},
|
||||
sort: mongoSort,
|
||||
limit: size + 1,
|
||||
skip: offset,
|
||||
});
|
||||
} else {
|
||||
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
sql = `${baseSql}${orderBySQL}`;
|
||||
// ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景,
|
||||
// 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET,并在前端翻转结果。
|
||||
if (isClickHouse && totalKnown && offset > 0 && reverseOrderSQL) {
|
||||
const pageRowCount = Math.max(0, Math.min(size, totalRows - offset));
|
||||
if (pageRowCount > 0) {
|
||||
const tailOffset = Math.max(0, totalRows - (offset + pageRowCount));
|
||||
if (tailOffset < offset) {
|
||||
sql = buildPaginatedSelectSQL(dbType, baseSql, reverseOrderSQL, pageRowCount, tailOffset);
|
||||
useClickHouseReversePagination = true;
|
||||
clickHouseReverseLimit = pageRowCount;
|
||||
clickHouseReverseHasMore = currentPage < totalPages;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!useClickHouseReversePagination) {
|
||||
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
|
||||
sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, size + 1, offset);
|
||||
}
|
||||
}
|
||||
|
||||
const requestStartTime = Date.now();
|
||||
let executedSql = sql;
|
||||
try {
|
||||
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
|
||||
const startTime = Date.now();
|
||||
const result = await DBQuery(config as any, dbName, querySql);
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-data`,
|
||||
timestamp: Date.now(),
|
||||
sql: querySql,
|
||||
status: result.success ? 'success' : 'error',
|
||||
duration: Date.now() - startTime,
|
||||
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
|
||||
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
|
||||
dbName
|
||||
});
|
||||
return result;
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, querySql);
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-data`,
|
||||
timestamp: Date.now(),
|
||||
sql: querySql,
|
||||
status: result.success ? 'success' : 'error',
|
||||
duration: Date.now() - startTime,
|
||||
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
|
||||
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
|
||||
dbName
|
||||
});
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
const errMessage = String(e?.message || e || 'query failed');
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-data`,
|
||||
timestamp: Date.now(),
|
||||
sql: querySql,
|
||||
status: 'error',
|
||||
duration: Date.now() - startTime,
|
||||
message: `${attemptLabel}: ${errMessage}`,
|
||||
dbName
|
||||
});
|
||||
return { success: false, message: errMessage, data: [], fields: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
|
||||
const hasSort = hasExplicitSort(sortInfo);
|
||||
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
|
||||
let resData = await executeDataQuery(sql, '主查询');
|
||||
|
||||
if (!resData.success && dbTypeLower === 'duckdb' && isDuckDBUnsupportedTypeError(String(resData.message || ''))) {
|
||||
const cacheKey = `${tab.connectionId}|${dbName}|${tableName}`;
|
||||
let safeSelect = duckdbSafeSelectCacheRef.current[cacheKey] || '';
|
||||
if (!safeSelect) {
|
||||
try {
|
||||
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) => {
|
||||
const colName = String(col?.name || '').trim();
|
||||
if (!colName) return '';
|
||||
const quotedCol = quoteIdentPart(dbType, colName);
|
||||
if (isDuckDBComplexColumnType(col?.type)) {
|
||||
return `CAST(${quotedCol} AS VARCHAR) AS ${quotedCol}`;
|
||||
}
|
||||
return quotedCol;
|
||||
}).filter(Boolean);
|
||||
if (selectParts.length > 0) {
|
||||
safeSelect = selectParts.join(', ');
|
||||
duckdbSafeSelectCacheRef.current[cacheKey] = safeSelect;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore and keep original error path
|
||||
}
|
||||
}
|
||||
|
||||
if (safeSelect) {
|
||||
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, pkColumns), size + 1, offset);
|
||||
executedSql = fallbackSql;
|
||||
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
|
||||
}
|
||||
}
|
||||
|
||||
if (!resData.success && isMySQLFamily && hasSort && isSortMemoryErr(resData.message)) {
|
||||
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
|
||||
if (retrySql32MB !== sql) {
|
||||
@@ -141,7 +593,12 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
let resultData = resData.data as any[];
|
||||
if (!Array.isArray(resultData)) resultData = [];
|
||||
|
||||
const hasMore = resultData.length > size;
|
||||
if (useClickHouseReversePagination) {
|
||||
// 反向查询后恢复为原排序方向,保证用户看到的仍是“最后一页正序数据”。
|
||||
resultData = resultData.slice(0, clickHouseReverseLimit).reverse();
|
||||
}
|
||||
|
||||
const hasMore = useClickHouseReversePagination ? clickHouseReverseHasMore : resultData.length > size;
|
||||
if (hasMore) resultData = resultData.slice(0, size);
|
||||
|
||||
let fieldNames = resData.fields || [];
|
||||
@@ -156,26 +613,71 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
setData(resultData);
|
||||
const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`;
|
||||
const derivedTotalKnown = !hasMore;
|
||||
const derivedTotal = derivedTotalKnown ? offset + resultData.length : page * size + 1;
|
||||
const derivedTotal = derivedTotalKnown ? offset + resultData.length : currentPage * size + 1;
|
||||
const isDuckDB = dbTypeLower === 'duckdb';
|
||||
const minExpectedTotal = hasMore ? offset + resultData.length + 1 : offset + resultData.length;
|
||||
if (derivedTotalKnown) countKeyRef.current = countKey;
|
||||
latestConfigRef.current = config;
|
||||
latestDbTypeRef.current = dbTypeLower;
|
||||
latestDbNameRef.current = dbName;
|
||||
latestCountSqlRef.current = countSql;
|
||||
latestCountKeyRef.current = countKey;
|
||||
|
||||
setPagination(prev => {
|
||||
if (derivedTotalKnown) {
|
||||
return { ...prev, current: page, pageSize: size, total: derivedTotal, totalKnown: true };
|
||||
return {
|
||||
...prev,
|
||||
current: currentPage,
|
||||
pageSize: size,
|
||||
total: derivedTotal,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
}
|
||||
if (prev.totalKnown && countKeyRef.current === countKey) {
|
||||
return { ...prev, current: page, pageSize: size };
|
||||
if (!isDuckDB) {
|
||||
return { ...prev, current: currentPage, pageSize: size };
|
||||
}
|
||||
// 当当前页存在“下一页”信号时,已知总数至少应大于当前页末尾。
|
||||
// 若旧总数不满足该条件(例如历史统计值为 0),降级为未知总数并回退到 derivedTotal。
|
||||
if (Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
|
||||
return { ...prev, current: currentPage, pageSize: size };
|
||||
}
|
||||
}
|
||||
return { ...prev, current: page, pageSize: size, total: derivedTotal, totalKnown: false };
|
||||
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
|
||||
if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
|
||||
return {
|
||||
...prev,
|
||||
current: currentPage,
|
||||
pageSize: size,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
totalCountLoading: keepManualCounting,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
current: currentPage,
|
||||
pageSize: size,
|
||||
total: derivedTotal,
|
||||
totalKnown: false,
|
||||
totalApprox: false,
|
||||
totalCountLoading: keepManualCounting,
|
||||
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
|
||||
};
|
||||
});
|
||||
|
||||
if (!derivedTotalKnown) {
|
||||
const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB;
|
||||
if (shouldRunAsyncCount) {
|
||||
if (countKeyRef.current !== countKey) {
|
||||
countKeyRef.current = countKey;
|
||||
const countSeq = ++countSeqRef.current;
|
||||
const countStart = Date.now();
|
||||
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
|
||||
// 这里为统计请求设置更短的超时,避免“后台统计”长期占用资源。
|
||||
// DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT。
|
||||
const countConfig: any = { ...(config as any), timeout: 5 };
|
||||
|
||||
DBQuery(countConfig, dbName, countSql)
|
||||
@@ -198,10 +700,17 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
if (!resCount.success) return;
|
||||
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
|
||||
|
||||
const total = Number(resCount.data[0]?.['total']);
|
||||
if (!Number.isFinite(total) || total < 0) return;
|
||||
const total = parseTotalFromCountRow(resCount.data[0]);
|
||||
if (total === null) return;
|
||||
|
||||
setPagination(prev => ({ ...prev, total, totalKnown: true }));
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (countSeqRef.current !== countSeq) return;
|
||||
@@ -210,6 +719,50 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) {
|
||||
duckdbApproxKeyRef.current = countKey;
|
||||
const approxSeq = ++duckdbApproxSeqRef.current;
|
||||
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
|
||||
const escapedSchema = escapeSQLLiteral(schemaName);
|
||||
const escapedTable = escapeSQLLiteral(pureTableName);
|
||||
const approxConfig: any = { ...(config as any), timeout: 3 };
|
||||
const approxSqlCandidates = [
|
||||
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
|
||||
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
|
||||
];
|
||||
|
||||
(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;
|
||||
|
||||
const approxTotal = parseDuckDBApproxTotalRow(approxRes.data[0]);
|
||||
if (approxTotal === null) continue;
|
||||
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
|
||||
|
||||
setPagination(prev => {
|
||||
if (countKeyRef.current !== countKey) return prev;
|
||||
if (prev.totalKnown) return prev;
|
||||
return {
|
||||
...prev,
|
||||
total: approxTotal,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||
if (countKeyRef.current !== countKey) return;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
} else {
|
||||
message.error(String(resData.message || '查询失败'));
|
||||
}
|
||||
@@ -227,7 +780,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
}
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
}, [connections, tab, sortInfo, filterConditions, pkColumns]);
|
||||
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]);
|
||||
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
|
||||
// 主键信息只会在首次加载后更新一次,避免循环查询。
|
||||
|
||||
@@ -236,21 +789,52 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
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(null);
|
||||
setSortInfo([]);
|
||||
return;
|
||||
}
|
||||
setSortInfo({ columnKey: normalizedField, order: normalizedOrder });
|
||||
setSortInfo([{ columnKey: normalizedField, order: normalizedOrder, enabled: true }]);
|
||||
}, []);
|
||||
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 exportSqlWithFilter = useMemo(() => {
|
||||
const tableName = String(tab.tableName || '').trim();
|
||||
const dbType = String(currentConnConfig?.type || '').trim();
|
||||
if (!tableName || !dbType) return '';
|
||||
|
||||
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')) {
|
||||
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
|
||||
}
|
||||
return sql;
|
||||
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1, pagination.pageSize);
|
||||
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
||||
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]); // 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' }}>
|
||||
@@ -259,6 +843,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
columnNames={columnNames}
|
||||
loading={loading}
|
||||
tableName={tab.tableName}
|
||||
exportScope="table"
|
||||
dbName={tab.dbName}
|
||||
connectionId={tab.connectionId}
|
||||
pkColumns={pkColumns}
|
||||
@@ -266,11 +851,17 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
onSort={handleSort}
|
||||
onPageChange={handlePageChange}
|
||||
pagination={pagination}
|
||||
onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined}
|
||||
onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined}
|
||||
showFilter={showFilter}
|
||||
onToggleFilter={handleToggleFilter}
|
||||
onApplyFilter={handleApplyFilter}
|
||||
appliedFilterConditions={filterConditions}
|
||||
readOnly={forceReadOnly}
|
||||
sortInfoExternal={sortInfo}
|
||||
exportSqlWithFilter={exportSqlWithFilter || undefined}
|
||||
scrollSnapshot={scrollSnapshotRef.current}
|
||||
onScrollSnapshotChange={handleTableScrollSnapshotChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
217
frontend/src/components/DatabaseIcons.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
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',
|
||||
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',
|
||||
]);
|
||||
|
||||
/** 品牌 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 }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sqlserver} label="SS" />
|
||||
);
|
||||
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" />
|
||||
);
|
||||
|
||||
/** 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,
|
||||
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',
|
||||
'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', 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',
|
||||
];
|
||||
462
frontend/src/components/FindInDatabaseModal.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
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';
|
||||
|
||||
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 conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]);
|
||||
const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]);
|
||||
|
||||
const wt = useMemo(() => {
|
||||
const isDark = theme === 'dark';
|
||||
return buildOverlayWorkbenchTheme(isDark);
|
||||
}, [theme]);
|
||||
|
||||
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(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(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(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;
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Table, Tag, Button, Tooltip } from 'antd';
|
||||
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
|
||||
import { Table, Tag, Button, Tooltip, Empty } from 'antd';
|
||||
import { ClearOutlined, CloseOutlined, BugOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
|
||||
interface LogPanelProps {
|
||||
height: number;
|
||||
@@ -16,7 +16,8 @@ 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 opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
|
||||
// Background Helper
|
||||
const getBg = (darkHex: string) => {
|
||||
@@ -27,24 +28,40 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
};
|
||||
const bgMain = getBg('#1f1f1f');
|
||||
const bgToolbar = getBg('#2a2a2a');
|
||||
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 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 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 columns = [
|
||||
{
|
||||
title: 'Time',
|
||||
dataIndex: 'timestamp',
|
||||
width: 80,
|
||||
render: (ts: number) => <span style={{ color: '#888', fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
|
||||
render: (ts: number) => <span style={{ color: panelMutedTextColor, fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0 }}>
|
||||
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0, borderRadius: 999, paddingInline: 8, fontSize: 11, fontWeight: 700 }}>
|
||||
{status === 'success' ? 'OK' : 'ERR'}
|
||||
</Tag>
|
||||
)
|
||||
@@ -59,10 +76,10 @@ 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.2' }}>
|
||||
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.45' }}>
|
||||
<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: '#888', marginTop: 1 }}>Affected: {record.affectedRows}</div>}
|
||||
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>Affected: {record.affectedRows}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -71,12 +88,18 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
return (
|
||||
<div style={{
|
||||
height,
|
||||
borderTop: 'none',
|
||||
background: bgMain,
|
||||
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',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
zIndex: 100 // Ensure above other content
|
||||
overflow: 'hidden',
|
||||
zIndex: 100
|
||||
}}>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
@@ -94,38 +117,53 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
borderBottom: 'none',
|
||||
padding: '10px 14px',
|
||||
borderBottom: `1px solid ${panelDividerColor}`,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: 32
|
||||
gap: 12,
|
||||
minHeight: 48
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 'bold', fontSize: '12px' }}>
|
||||
<BugOutlined /> SQL 执行日志
|
||||
<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>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tooltip title="清空日志">
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} />
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} style={{ color: panelMutedTextColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭面板">
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} />
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: panelMutedTextColor }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<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 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>
|
||||
<style>{`
|
||||
.log-panel-scroll {
|
||||
@@ -155,6 +193,16 @@ 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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { Tabs, Dropdown } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { MenuProps, TabsProps } from 'antd';
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
|
||||
import { useStore } from '../store';
|
||||
import DataViewer from './DataViewer';
|
||||
import QueryEditor from './QueryEditor';
|
||||
@@ -9,6 +14,7 @@ import RedisViewer from './RedisViewer';
|
||||
import RedisCommandEditor from './RedisCommandEditor';
|
||||
import TriggerViewer from './TriggerViewer';
|
||||
import DefinitionViewer from './DefinitionViewer';
|
||||
import TableOverview from './TableOverview';
|
||||
import type { TabData } from '../types';
|
||||
|
||||
const detectConnectionEnvLabel = (connectionName: string): string | null => {
|
||||
@@ -23,22 +29,81 @@ const detectConnectionEnvLabel = (connectionName: string): string | null => {
|
||||
};
|
||||
|
||||
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
|
||||
if (tab.type !== 'table' && tab.type !== 'design') return tab.title;
|
||||
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title;
|
||||
if (!connectionName) return tab.title;
|
||||
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
|
||||
return `[${prefix}] ${tab.title}`;
|
||||
};
|
||||
|
||||
type SortableTabLabelProps = {
|
||||
displayTitle: string;
|
||||
menuItems: MenuProps['items'];
|
||||
};
|
||||
|
||||
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
|
||||
displayTitle,
|
||||
menuItems,
|
||||
}) => {
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<span
|
||||
className="tab-dnd-label"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
title="拖拽调整标签顺序"
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
type DraggableTabNodeProps = {
|
||||
node: React.ReactElement;
|
||||
};
|
||||
|
||||
const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
|
||||
const tabId = String(node.key || '').trim();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
|
||||
const style: React.CSSProperties = {
|
||||
...(node.props.style || {}),
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
opacity: isDragging ? 0.88 : 1,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
zIndex: isDragging ? 2 : node.props.style?.zIndex,
|
||||
};
|
||||
|
||||
return React.cloneElement(node, {
|
||||
ref: setNodeRef,
|
||||
style,
|
||||
...attributes,
|
||||
...listeners,
|
||||
className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`,
|
||||
});
|
||||
};
|
||||
|
||||
const TabManager: React.FC = () => {
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const connections = useStore(state => state.connections);
|
||||
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);
|
||||
const closeTabsToRight = useStore(state => state.closeTabsToRight);
|
||||
const closeAllTabs = useStore(state => state.closeAllTabs);
|
||||
const moveTab = useStore(state => state.moveTab);
|
||||
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
|
||||
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||
const suppressClickUntilRef = useRef<number>(0);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
const onChange = (newActiveKey: string) => {
|
||||
setActiveTab(newActiveKey);
|
||||
@@ -50,6 +115,87 @@ const TabManager: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const sourceId = String(event.active.id || '').trim();
|
||||
setDraggingTabId(sourceId || null);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const sourceId = String(event.active.id || '').trim();
|
||||
const targetId = String(event.over?.id || '').trim();
|
||||
setDraggingTabId(null);
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
return;
|
||||
}
|
||||
suppressClickUntilRef.current = Date.now() + 120;
|
||||
moveTab(sourceId, targetId);
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
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) => (
|
||||
<DefaultTabBar {...tabBarProps}>
|
||||
{(node) => <DraggableTabNode key={node.key} node={node} />}
|
||||
</DefaultTabBar>
|
||||
);
|
||||
|
||||
const items = useMemo(() => tabs.map((tab, index) => {
|
||||
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
|
||||
const displayTitle = buildTabDisplayTitle(tab, connectionName);
|
||||
@@ -68,6 +214,8 @@ const TabManager: React.FC = () => {
|
||||
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} />;
|
||||
}
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
@@ -100,9 +248,10 @@ const TabManager: React.FC = () => {
|
||||
|
||||
return {
|
||||
label: (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<span onContextMenu={(e) => e.preventDefault()}>{displayTitle}</span>
|
||||
</Dropdown>
|
||||
<SortableTabLabel
|
||||
displayTitle={displayTitle}
|
||||
menuItems={menuItems}
|
||||
/>
|
||||
),
|
||||
key: tab.id,
|
||||
children: content,
|
||||
@@ -156,18 +305,64 @@ const TabManager: React.FC = () => {
|
||||
display: none !important;
|
||||
}
|
||||
.main-tabs .ant-tabs-nav::before {
|
||||
border-bottom: none !important;
|
||||
border-bottom: 1px solid ${tabsNavBorderColor} !important;
|
||||
}
|
||||
.main-tabs .ant-tabs-tab {
|
||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease;
|
||||
}
|
||||
.main-tabs .tab-dnd-label {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
.main-tabs .tab-dnd-node.is-dragging,
|
||||
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible {
|
||||
outline: none !important;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.72);
|
||||
background: rgba(255, 214, 102, 0.16);
|
||||
}
|
||||
body[data-theme='light'] .main-tabs .ant-tabs-tab-btn:focus-visible {
|
||||
outline: none !important;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
|
||||
background: rgba(9, 109, 217, 0.08);
|
||||
}
|
||||
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;
|
||||
}
|
||||
`}</style>
|
||||
<Tabs
|
||||
className="main-tabs"
|
||||
type="editable-card"
|
||||
onChange={onChange}
|
||||
activeKey={activeTabId || undefined}
|
||||
onEdit={onEdit}
|
||||
items={items}
|
||||
hideAdd
|
||||
/>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
<Tabs
|
||||
className="main-tabs"
|
||||
type="editable-card"
|
||||
destroyInactiveTabPane={false}
|
||||
onChange={(newActiveKey) => {
|
||||
if (Date.now() < suppressClickUntilRef.current) return;
|
||||
onChange(newActiveKey);
|
||||
}}
|
||||
activeKey={activeTabId || undefined}
|
||||
onEdit={onEdit}
|
||||
items={items}
|
||||
hideAdd
|
||||
renderTabBar={renderTabBar}
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
460
frontend/src/components/TableOverview.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
|
||||
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
|
||||
import type { TabData } from '../types';
|
||||
|
||||
interface TableOverviewProps {
|
||||
tab: TabData;
|
||||
}
|
||||
|
||||
interface TableStatRow {
|
||||
name: string;
|
||||
comment: string;
|
||||
rows: number;
|
||||
dataSize: number;
|
||||
indexSize: number;
|
||||
engine: string;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
type SortField = 'name' | 'rows' | 'dataSize';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
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 `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``;
|
||||
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 connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
|
||||
|
||||
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 dialect = getMetadataDialect(connection.config.type, (connection.config as any)?.driver);
|
||||
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const res = await DBQuery(config as any, tab.dbName || '', sql);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setTables(parseTableStats(dialect, res.data));
|
||||
} else {
|
||||
message.error('获取表信息失败: ' + (res.message || '未知错误'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('获取表信息失败: ' + (e?.message || String(e)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connection, tab.dbName]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const sortedFiltered = useMemo(() => {
|
||||
let list = [...tables];
|
||||
if (searchText.trim()) {
|
||||
const kw = searchText.trim().toLowerCase();
|
||||
list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw));
|
||||
}
|
||||
list.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
else if (sortField === 'rows') cmp = a.rows - b.rows;
|
||||
else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize;
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return list;
|
||||
}, [tables, searchText, sortField, sortOrder]);
|
||||
|
||||
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(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(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(config as any, tab.dbName || '', tableName);
|
||||
if (res.success) {
|
||||
message.success('表删除成功');
|
||||
loadData();
|
||||
} else {
|
||||
message.error('删除失败: ' + res.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [buildConfig, tab.dbName, loadData]);
|
||||
|
||||
const handleRenameTable = useCallback((tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
let newName = tableName;
|
||||
Modal.confirm({
|
||||
title: '重命名表',
|
||||
content: (
|
||||
<Input
|
||||
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(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 = tables.reduce((s, t) => s + t.rows, 0);
|
||||
const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0);
|
||||
|
||||
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
|
||||
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>
|
||||
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
|
||||
{sortedFiltered.length === 0 ? (
|
||||
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
}}>
|
||||
{sortedFiltered.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: `SELECT * FROM ${t.name};`,
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
|
||||
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
|
||||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
|
||||
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
|
||||
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
|
||||
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
|
||||
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
|
||||
]},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableOverview;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Editor, { loader } from '@monaco-editor/react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { Spin, Alert } from 'antd';
|
||||
import { TabData } from '../types';
|
||||
import { useStore } from '../store';
|
||||
@@ -18,31 +18,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
|
||||
const theme = useStore(state => state.theme);
|
||||
const darkMode = theme === 'dark';
|
||||
|
||||
// 初始化透明 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',
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
|
||||
|
||||
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
||||
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
|
||||
|
||||
76
frontend/src/components/ai/AIChatHeader.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
61
frontend/src/components/ai/AIChatInput.notice.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
aiContexts: {},
|
||||
addAIContext: vi.fn(),
|
||||
removeAIContext: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../wailsjs/go/app/App', () => ({
|
||||
DBGetTables: vi.fn(),
|
||||
DBShowCreateTable: vi.fn(),
|
||||
DBGetDatabases: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('AIChatInput notice layout', () => {
|
||||
it('renders the composer notice above the input editor', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIChatInput
|
||||
input=""
|
||||
setInput={() => {}}
|
||||
draftImages={[]}
|
||||
setDraftImages={() => {}}
|
||||
sending={false}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: '', models: [] }}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
composerNotice={{
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
description: '请检查供应商入口和 API Key。',
|
||||
}}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
darkMode={false}
|
||||
textColor="#162033"
|
||||
mutedColor="rgba(16,24,40,0.55)"
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
const noticeIndex = markup.indexOf('data-ai-chat-composer-notice="true"');
|
||||
const inputIndex = markup.indexOf('data-ai-chat-composer-input="true"');
|
||||
|
||||
expect(noticeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(inputIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(noticeIndex).toBeLessThan(inputIndex);
|
||||
});
|
||||
});
|
||||
632
frontend/src/components/ai/AIChatInput.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
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 } from '../../../wailsjs/go/app/App';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
|
||||
|
||||
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;
|
||||
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,
|
||||
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(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(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(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 res = await DBShowCreateTable(conn.config as any, dbName, tableName);
|
||||
let createSql = '';
|
||||
if (res.success && res.data) {
|
||||
if (typeof res.data === 'string') {
|
||||
createSql = res.data;
|
||||
} else if (Array.isArray(res.data) && res.data.length > 0) {
|
||||
const row = res.data[0];
|
||||
createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string;
|
||||
}
|
||||
} else {
|
||||
message.error(`获取表 ${dbName}.${tableName} 结构失败: ` + (res.message || '未知错误'));
|
||||
}
|
||||
|
||||
if (createSql) {
|
||||
addAIContext(connectionKey, {
|
||||
dbName: dbName,
|
||||
tableName: tableName,
|
||||
ddl: createSql
|
||||
});
|
||||
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="输入消息... (Enter 发送,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>
|
||||
);
|
||||
};
|
||||
64
frontend/src/components/ai/AIChatWelcome.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
127
frontend/src/components/ai/AIHistoryDrawer.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
735
frontend/src/components/ai/AIMessageBubble.tsx
Normal file
@@ -0,0 +1,735 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { 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 { AIChatMessage, AIToolCall } from '../../types';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
// 🔧 性能优化:将 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;
|
||||
}) => {
|
||||
// 缓存 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}>
|
||||
{content}
|
||||
</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 res = await DBQuery(activeConnectionConfig, activeDbName || '', displayText + ' LIMIT 50');
|
||||
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 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 && 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>
|
||||
);
|
||||
});
|
||||
32
frontend/src/components/dataGridLayout.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
48
frontend/src/components/dataGridLayout.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
};
|
||||
79
frontend/src/components/redisViewerTree.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RedisKeyInfo } from '../types';
|
||||
import {
|
||||
applyRenamedRedisKeyState,
|
||||
applyTreeNodeCheck,
|
||||
buildCheckedTreeNodeState,
|
||||
buildRedisKeyTree,
|
||||
isGroupFullyChecked,
|
||||
} from './redisViewerTree';
|
||||
|
||||
const sampleKeys: RedisKeyInfo[] = [
|
||||
{ key: 'app:user:1', type: 'string', ttl: -1 },
|
||||
{ key: 'app:user:2', type: 'string', ttl: -1 },
|
||||
{ key: 'app:order:1', type: 'hash', ttl: 120 },
|
||||
{ key: 'misc', type: 'set', ttl: -1 },
|
||||
];
|
||||
|
||||
describe('redisViewerTree helpers', () => {
|
||||
it('builds grouped redis key tree and group selection state', () => {
|
||||
const tree = buildRedisKeyTree(sampleKeys, true);
|
||||
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
|
||||
const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
|
||||
|
||||
expect(appGroup).toBeTruthy();
|
||||
expect(userGroup).toBeTruthy();
|
||||
expect(appGroup?.descendantRawKeys).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
|
||||
|
||||
const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
|
||||
expect(selectedAfterGroupCheck).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
|
||||
|
||||
const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
|
||||
expect(checkedState.checked).toEqual(['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app']);
|
||||
expect(checkedState.halfChecked).toEqual([]);
|
||||
expect(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck)).toBe(true);
|
||||
|
||||
const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
|
||||
expect(selectedAfterGroupUncheck).toEqual([]);
|
||||
expect(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck)).toBe(false);
|
||||
});
|
||||
|
||||
it('marks parent groups as half checked for partial selection', () => {
|
||||
const tree = buildRedisKeyTree(sampleKeys, true);
|
||||
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
|
||||
const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
|
||||
|
||||
expect(partialState.halfChecked).toEqual(['group:app:user', 'group:app']);
|
||||
expect(isGroupFullyChecked(appGroup!, ['app:user:1'])).toBe(false);
|
||||
});
|
||||
|
||||
it('updates selected keys consistently after rename', () => {
|
||||
const renamedState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'app:user:2',
|
||||
selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
|
||||
},
|
||||
'app:user:2',
|
||||
'app:user:200'
|
||||
);
|
||||
|
||||
expect(renamedState.keys.map((item) => item.key)).toEqual(['app:user:1', 'app:user:200', 'app:order:1', 'misc']);
|
||||
expect(renamedState.selectedKey).toBe('app:user:200');
|
||||
expect(renamedState.selectedKeys).toEqual(['app:user:1', 'app:user:200', 'misc']);
|
||||
|
||||
const unrelatedRenameState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'misc',
|
||||
selectedKeys: ['app:user:1'],
|
||||
},
|
||||
'app:order:1',
|
||||
'app:order:9'
|
||||
);
|
||||
|
||||
expect(unrelatedRenameState.selectedKey).toBe('misc');
|
||||
expect(unrelatedRenameState.selectedKeys).toEqual(['app:user:1']);
|
||||
});
|
||||
});
|
||||
260
frontend/src/components/redisViewerTree.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { RedisKeyInfo } from '../types';
|
||||
|
||||
const KEY_GROUP_DELIMITER = ':';
|
||||
const EMPTY_SEGMENT_LABEL = '(empty)';
|
||||
|
||||
type RedisKeyTreeLeaf = {
|
||||
keyInfo: RedisKeyInfo;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type RedisKeyTreeGroup = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, RedisKeyTreeGroup>;
|
||||
leaves: RedisKeyTreeLeaf[];
|
||||
leafCount: number;
|
||||
};
|
||||
|
||||
export type RedisTreeDataNode = DataNode & {
|
||||
nodeType: 'group' | 'leaf';
|
||||
groupName?: string;
|
||||
groupLeafCount?: number;
|
||||
leafLabel?: string;
|
||||
rawKey?: string;
|
||||
keyType?: string;
|
||||
ttl?: number;
|
||||
descendantRawKeys?: string[];
|
||||
};
|
||||
|
||||
export type RedisKeyTreeResult = {
|
||||
treeData: RedisTreeDataNode[];
|
||||
groupKeys: string[];
|
||||
};
|
||||
|
||||
export type RedisTreeCheckedState = {
|
||||
checked: string[];
|
||||
halfChecked: string[];
|
||||
};
|
||||
|
||||
export type RenamedRedisKeyStateInput = {
|
||||
keys: RedisKeyInfo[];
|
||||
selectedKey: string | null;
|
||||
selectedKeys: string[];
|
||||
};
|
||||
|
||||
export type RenamedRedisKeyStateResult = {
|
||||
keys: RedisKeyInfo[];
|
||||
selectedKey: string | null;
|
||||
selectedKeys: string[];
|
||||
};
|
||||
|
||||
const normalizeKeySegment = (segment: string): string => {
|
||||
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
|
||||
};
|
||||
|
||||
const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => {
|
||||
return { name, path, children: new Map(), leaves: [], leafCount: 0 };
|
||||
};
|
||||
|
||||
const calculateGroupLeafCount = (group: RedisKeyTreeGroup): number => {
|
||||
let count = group.leaves.length;
|
||||
group.children.forEach((child) => {
|
||||
count += calculateGroupLeafCount(child);
|
||||
});
|
||||
group.leafCount = count;
|
||||
return count;
|
||||
};
|
||||
|
||||
export const buildLeafNodeKey = (rawKey: string): string => `key:${rawKey}`;
|
||||
|
||||
export const parseRawKeyFromNodeKey = (nodeKey: React.Key): string | null => {
|
||||
const keyText = String(nodeKey);
|
||||
if (!keyText.startsWith('key:')) {
|
||||
return null;
|
||||
}
|
||||
return keyText.slice(4);
|
||||
};
|
||||
|
||||
export const buildRedisKeyTree = (
|
||||
keys: RedisKeyInfo[],
|
||||
sortLeafNodes: boolean
|
||||
): RedisKeyTreeResult => {
|
||||
const root = createTreeGroup('__root__', '__root__');
|
||||
|
||||
keys.forEach((keyInfo) => {
|
||||
const segments = keyInfo.key.split(KEY_GROUP_DELIMITER);
|
||||
if (segments.length <= 1) {
|
||||
root.leaves.push({ keyInfo, label: keyInfo.key });
|
||||
return;
|
||||
}
|
||||
|
||||
const groupSegments = segments.slice(0, -1);
|
||||
const leafLabel = normalizeKeySegment(segments[segments.length - 1]);
|
||||
let current = root;
|
||||
const pathParts: string[] = [];
|
||||
|
||||
groupSegments.forEach((segment) => {
|
||||
const normalized = normalizeKeySegment(segment);
|
||||
pathParts.push(normalized);
|
||||
const groupPath = pathParts.join(KEY_GROUP_DELIMITER);
|
||||
let child = current.children.get(normalized);
|
||||
if (!child) {
|
||||
child = createTreeGroup(normalized, groupPath);
|
||||
current.children.set(normalized, child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
|
||||
current.leaves.push({ keyInfo, label: leafLabel });
|
||||
});
|
||||
|
||||
calculateGroupLeafCount(root);
|
||||
const groupKeys: string[] = [];
|
||||
|
||||
const toTreeNodes = (group: RedisKeyTreeGroup): RedisTreeDataNode[] => {
|
||||
const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const childLeaves = sortLeafNodes
|
||||
? [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key))
|
||||
: group.leaves;
|
||||
|
||||
const groupNodes: RedisTreeDataNode[] = childGroups.map((child) => {
|
||||
const children = toTreeNodes(child);
|
||||
const descendantRawKeys = children.flatMap((node) => {
|
||||
if (node.nodeType === 'leaf') {
|
||||
return node.rawKey ? [node.rawKey] : [];
|
||||
}
|
||||
return node.descendantRawKeys || [];
|
||||
});
|
||||
const groupNodeKey = `group:${child.path}`;
|
||||
groupKeys.push(groupNodeKey);
|
||||
return {
|
||||
key: groupNodeKey,
|
||||
title: child.name,
|
||||
nodeType: 'group',
|
||||
groupName: child.name,
|
||||
groupLeafCount: child.leafCount,
|
||||
selectable: false,
|
||||
descendantRawKeys,
|
||||
children,
|
||||
};
|
||||
});
|
||||
|
||||
const leafNodes: RedisTreeDataNode[] = childLeaves.map((leaf) => {
|
||||
return {
|
||||
key: buildLeafNodeKey(leaf.keyInfo.key),
|
||||
isLeaf: true,
|
||||
title: leaf.label,
|
||||
nodeType: 'leaf',
|
||||
leafLabel: leaf.label,
|
||||
rawKey: leaf.keyInfo.key,
|
||||
keyType: leaf.keyInfo.type,
|
||||
ttl: leaf.keyInfo.ttl,
|
||||
};
|
||||
});
|
||||
|
||||
return [...groupNodes, ...leafNodes];
|
||||
};
|
||||
|
||||
return {
|
||||
treeData: toTreeNodes(root),
|
||||
groupKeys,
|
||||
};
|
||||
};
|
||||
|
||||
export const applyTreeNodeCheck = (
|
||||
selectedKeys: string[],
|
||||
node: RedisTreeDataNode,
|
||||
checked: boolean
|
||||
): string[] => {
|
||||
if (node.nodeType === 'leaf') {
|
||||
if (!node.rawKey) {
|
||||
return selectedKeys;
|
||||
}
|
||||
if (checked) {
|
||||
return Array.from(new Set([...selectedKeys, node.rawKey]));
|
||||
}
|
||||
return selectedKeys.filter((item) => item !== node.rawKey);
|
||||
}
|
||||
|
||||
const descendantRawKeys = node.descendantRawKeys || [];
|
||||
if (descendantRawKeys.length === 0) {
|
||||
return selectedKeys;
|
||||
}
|
||||
if (checked) {
|
||||
return Array.from(new Set([...selectedKeys, ...descendantRawKeys]));
|
||||
}
|
||||
const removeSet = new Set(descendantRawKeys);
|
||||
return selectedKeys.filter((item) => !removeSet.has(item));
|
||||
};
|
||||
|
||||
const walkGroupStates = (
|
||||
nodes: RedisTreeDataNode[],
|
||||
selectedKeySet: Set<string>,
|
||||
checked: string[],
|
||||
halfChecked: string[]
|
||||
) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.nodeType === 'leaf') {
|
||||
if (node.rawKey && selectedKeySet.has(node.rawKey)) {
|
||||
checked.push(String(node.key));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
walkGroupStates((node.children || []) as RedisTreeDataNode[], selectedKeySet, checked, halfChecked);
|
||||
const descendantRawKeys = node.descendantRawKeys || [];
|
||||
if (descendantRawKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = descendantRawKeys.filter((rawKey) => selectedKeySet.has(rawKey)).length;
|
||||
if (selectedCount === descendantRawKeys.length) {
|
||||
checked.push(String(node.key));
|
||||
return;
|
||||
}
|
||||
if (selectedCount > 0) {
|
||||
halfChecked.push(String(node.key));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const buildCheckedTreeNodeState = (
|
||||
selectedKeys: string[],
|
||||
keyTree: RedisKeyTreeResult
|
||||
): RedisTreeCheckedState => {
|
||||
const selectedKeySet = new Set(selectedKeys);
|
||||
const checked: string[] = [];
|
||||
const halfChecked: string[] = [];
|
||||
|
||||
walkGroupStates(keyTree.treeData, selectedKeySet, checked, halfChecked);
|
||||
return { checked, halfChecked };
|
||||
};
|
||||
|
||||
export const isGroupFullyChecked = (
|
||||
node: RedisTreeDataNode,
|
||||
selectedKeys: string[]
|
||||
): boolean => {
|
||||
if (node.nodeType !== 'group') {
|
||||
return false;
|
||||
}
|
||||
const descendantRawKeys = node.descendantRawKeys || [];
|
||||
if (descendantRawKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const selectedKeySet = new Set(selectedKeys);
|
||||
return descendantRawKeys.every((rawKey) => selectedKeySet.has(rawKey));
|
||||
};
|
||||
|
||||
export const applyRenamedRedisKeyState = (
|
||||
state: RenamedRedisKeyStateInput,
|
||||
oldKey: string,
|
||||
newKey: string
|
||||
): RenamedRedisKeyStateResult => {
|
||||
return {
|
||||
keys: state.keys.map((item) => (item.key === oldKey ? { ...item, key: newKey } : item)),
|
||||
selectedKey: state.selectedKey === oldKey ? newKey : state.selectedKey,
|
||||
selectedKeys: state.selectedKeys.map((item) => (item === oldKey ? newKey : item)),
|
||||
};
|
||||
};
|
||||
28
frontend/src/components/redisViewerWorkbenchTheme.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
|
||||
|
||||
describe('buildRedisWorkbenchTheme', () => {
|
||||
it('builds dark redis workbench theme', () => {
|
||||
const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14 });
|
||||
expect(darkTheme.isDark).toBe(true);
|
||||
expect(darkTheme.panelBg).toMatch(/^rgba\(/);
|
||||
expect(darkTheme.toolbarPrimaryBg).toMatch(/^linear-gradient\(/);
|
||||
expect(darkTheme.actionDangerBg).not.toBe(darkTheme.actionSecondaryBg);
|
||||
expect(darkTheme.treeSelectedBg).not.toBe(darkTheme.treeHoverBg);
|
||||
expect(darkTheme.appBg).toMatch(/rgba\(15, 15, 17,/);
|
||||
expect(darkTheme.panelBg).toMatch(/rgba\(24, 24, 28,/);
|
||||
expect(darkTheme.panelBgStrong).toMatch(/rgba\(31, 31, 36,/);
|
||||
expect(darkTheme.backdropFilter).toBe('blur(14px)');
|
||||
});
|
||||
|
||||
it('builds light redis workbench theme', () => {
|
||||
const lightTheme = buildRedisWorkbenchTheme({ darkMode: false, opacity: 1, blur: 0 });
|
||||
expect(lightTheme.isDark).toBe(false);
|
||||
expect(lightTheme.panelBg).toMatch(/^rgba\(/);
|
||||
expect(lightTheme.contentEmptyBg).toMatch(/^linear-gradient\(/);
|
||||
expect(lightTheme.textPrimary).not.toBe(lightTheme.textSecondary);
|
||||
expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg);
|
||||
expect(lightTheme.backdropFilter).toBe('none');
|
||||
});
|
||||
});
|
||||
129
frontend/src/components/redisViewerWorkbenchTheme.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
type RedisWorkbenchThemeInput = {
|
||||
darkMode: boolean;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
};
|
||||
|
||||
type RedisWorkbenchTheme = {
|
||||
isDark: boolean;
|
||||
appBg: string;
|
||||
panelBg: string;
|
||||
panelBgStrong: string;
|
||||
panelBgSubtle: string;
|
||||
panelBorder: string;
|
||||
panelInset: string;
|
||||
toolbarPrimaryBg: string;
|
||||
contentEmptyBg: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
textMuted: string;
|
||||
accent: string;
|
||||
accentSoft: string;
|
||||
accentBorder: string;
|
||||
actionSecondaryBg: string;
|
||||
actionSecondaryBorder: string;
|
||||
actionDangerBg: string;
|
||||
actionDangerBorder: string;
|
||||
actionDangerText: string;
|
||||
statusTagBg: string;
|
||||
statusTagBorder: string;
|
||||
statusTagMutedBg: string;
|
||||
statusTagMutedBorder: string;
|
||||
treeHoverBg: string;
|
||||
treeSelectedBg: string;
|
||||
treeSelectedBorder: string;
|
||||
divider: string;
|
||||
shadow: string;
|
||||
backdropFilter: string;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
export const buildRedisWorkbenchTheme = ({
|
||||
darkMode,
|
||||
opacity,
|
||||
blur,
|
||||
}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => {
|
||||
const normalizedOpacity = clamp(opacity, 0.1, 1);
|
||||
const normalizedBlur = Math.max(0, Math.round(blur));
|
||||
const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0;
|
||||
|
||||
if (darkMode) {
|
||||
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
|
||||
const appBottomAlpha = isTranslucent ? Math.max(0.12, Math.min(0.28, normalizedOpacity * 0.22)) : 0.96;
|
||||
const panelAlpha = isTranslucent ? Math.max(0.06, Math.min(0.16, normalizedOpacity * 0.1)) : 0.34;
|
||||
const strongAlpha = isTranslucent ? Math.max(0.1, Math.min(0.22, normalizedOpacity * 0.16)) : 0.42;
|
||||
const subtleAlpha = isTranslucent ? Math.max(0.03, Math.min(0.08, normalizedOpacity * 0.05)) : 0.08;
|
||||
return {
|
||||
isDark: true,
|
||||
appBg: `linear-gradient(180deg, rgba(15, 15, 17, ${appTopAlpha}) 0%, rgba(11, 11, 13, ${appBottomAlpha}) 100%)`,
|
||||
panelBg: `rgba(24, 24, 28, ${panelAlpha})`,
|
||||
panelBgStrong: `rgba(31, 31, 36, ${strongAlpha})`,
|
||||
panelBgSubtle: `rgba(255, 255, 255, ${subtleAlpha})`,
|
||||
panelBorder: `1px solid rgba(255, 255, 255, ${isTranslucent ? Math.max(0.12, Math.min(0.24, normalizedOpacity * 0.2)) : 0.08})`,
|
||||
panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? Math.max(0.05, Math.min(0.12, normalizedOpacity * 0.1)) : 0.04})`,
|
||||
toolbarPrimaryBg: `linear-gradient(135deg, rgba(246,196,83,0.22) 0%, rgba(246,196,83,0.12) 100%)`,
|
||||
contentEmptyBg: `linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.015) 100%)`,
|
||||
textPrimary: 'rgba(245, 247, 251, 0.96)',
|
||||
textSecondary: 'rgba(218, 224, 235, 0.82)',
|
||||
textMuted: 'rgba(168, 177, 194, 0.72)',
|
||||
accent: '#f6c453',
|
||||
accentSoft: 'rgba(246, 196, 83, 0.18)',
|
||||
accentBorder: 'rgba(246, 196, 83, 0.3)',
|
||||
actionSecondaryBg: 'rgba(255, 255, 255, 0.04)',
|
||||
actionSecondaryBorder: 'rgba(255, 255, 255, 0.09)',
|
||||
actionDangerBg: 'rgba(255, 95, 95, 0.12)',
|
||||
actionDangerBorder: 'rgba(255, 95, 95, 0.28)',
|
||||
actionDangerText: '#ff8f8f',
|
||||
statusTagBg: 'rgba(25, 106, 255, 0.16)',
|
||||
statusTagBorder: 'rgba(25, 106, 255, 0.28)',
|
||||
statusTagMutedBg: 'rgba(255, 255, 255, 0.04)',
|
||||
statusTagMutedBorder: 'rgba(255, 255, 255, 0.08)',
|
||||
treeHoverBg: 'rgba(255, 255, 255, 0.045)',
|
||||
treeSelectedBg: 'linear-gradient(90deg, rgba(246,196,83,0.2) 0%, rgba(246,196,83,0.08) 100%)',
|
||||
treeSelectedBorder: 'rgba(246, 196, 83, 0.24)',
|
||||
divider: 'rgba(255, 255, 255, 0.07)',
|
||||
shadow: '0 20px 48px rgba(0, 0, 0, 0.26)',
|
||||
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
const appTopAlpha = isTranslucent ? Math.max(0.16, Math.min(0.36, normalizedOpacity * 0.24)) : 0.98;
|
||||
const appBottomAlpha = isTranslucent ? Math.max(0.22, Math.min(0.44, normalizedOpacity * 0.32)) : 0.96;
|
||||
const panelAlpha = isTranslucent ? Math.max(0.18, Math.min(0.4, normalizedOpacity * 0.26)) : 0.94;
|
||||
const strongAlpha = isTranslucent ? Math.max(0.26, Math.min(0.52, normalizedOpacity * 0.34)) : 0.98;
|
||||
return {
|
||||
isDark: false,
|
||||
appBg: `linear-gradient(180deg, rgba(248, 250, 252, ${appTopAlpha}) 0%, rgba(242, 245, 248, ${appBottomAlpha}) 100%)`,
|
||||
panelBg: `rgba(255, 255, 255, ${panelAlpha})`,
|
||||
panelBgStrong: `rgba(255, 255, 255, ${strongAlpha})`,
|
||||
panelBgSubtle: 'rgba(15, 23, 42, 0.03)',
|
||||
panelBorder: `1px solid rgba(15, 23, 42, ${isTranslucent ? Math.max(0.1, Math.min(0.18, normalizedOpacity * 0.12)) : 0.08})`,
|
||||
panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? 0.38 : 0.72})`,
|
||||
toolbarPrimaryBg: 'linear-gradient(135deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.06) 100%)',
|
||||
contentEmptyBg: 'linear-gradient(180deg, rgba(15,23,42,0.02) 0%, rgba(15,23,42,0.01) 100%)',
|
||||
textPrimary: 'rgba(15, 23, 42, 0.92)',
|
||||
textSecondary: 'rgba(51, 65, 85, 0.82)',
|
||||
textMuted: 'rgba(100, 116, 139, 0.76)',
|
||||
accent: '#1677ff',
|
||||
accentSoft: 'rgba(22, 119, 255, 0.12)',
|
||||
accentBorder: 'rgba(22, 119, 255, 0.22)',
|
||||
actionSecondaryBg: 'rgba(255, 255, 255, 0.72)',
|
||||
actionSecondaryBorder: 'rgba(15, 23, 42, 0.08)',
|
||||
actionDangerBg: 'rgba(255, 77, 79, 0.08)',
|
||||
actionDangerBorder: 'rgba(255, 77, 79, 0.24)',
|
||||
actionDangerText: '#cf1322',
|
||||
statusTagBg: 'rgba(22, 119, 255, 0.1)',
|
||||
statusTagBorder: 'rgba(22, 119, 255, 0.16)',
|
||||
statusTagMutedBg: 'rgba(15, 23, 42, 0.04)',
|
||||
statusTagMutedBorder: 'rgba(15, 23, 42, 0.08)',
|
||||
treeHoverBg: 'rgba(15, 23, 42, 0.035)',
|
||||
treeSelectedBg: 'linear-gradient(90deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.05) 100%)',
|
||||
treeSelectedBorder: 'rgba(22, 119, 255, 0.18)',
|
||||
divider: 'rgba(15, 23, 42, 0.08)',
|
||||
shadow: '0 22px 52px rgba(15, 23, 42, 0.08)',
|
||||
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
};
|
||||
};
|
||||
|
||||
export type { RedisWorkbenchTheme, RedisWorkbenchThemeInput };
|
||||
95
frontend/src/components/tableDesignerIndexUtils.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasIndexFormChanged,
|
||||
normalizeIndexFormFromRow,
|
||||
shouldRestoreOriginalIndex,
|
||||
toggleIndexSelection,
|
||||
type IndexDisplaySnapshot,
|
||||
type IndexFormSnapshot,
|
||||
} from './tableDesignerIndexUtils';
|
||||
|
||||
describe('tableDesignerIndexUtils', () => {
|
||||
it('normalizes index rows for edit form reuse', () => {
|
||||
const row: IndexDisplaySnapshot = {
|
||||
key: 'idx_user_name',
|
||||
name: 'idx_user_name',
|
||||
indexType: 'btree',
|
||||
nonUnique: 0,
|
||||
columnNames: ['name'],
|
||||
};
|
||||
|
||||
expect(normalizeIndexFormFromRow(row, ['NORMAL', 'UNIQUE', 'PRIMARY', 'FULLTEXT', 'SPATIAL'])).toEqual({
|
||||
name: 'idx_user_name',
|
||||
columnNames: ['name'],
|
||||
kind: 'UNIQUE',
|
||||
indexType: 'BTREE',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects no-op index edits as unchanged', () => {
|
||||
const previousForm: IndexFormSnapshot = {
|
||||
name: 'idx_user_name',
|
||||
columnNames: ['name'],
|
||||
kind: 'UNIQUE',
|
||||
indexType: 'BTREE',
|
||||
};
|
||||
const nextForm: IndexFormSnapshot = {
|
||||
name: 'idx_user_name',
|
||||
columnNames: ['name'],
|
||||
kind: 'UNIQUE',
|
||||
indexType: 'BTREE',
|
||||
};
|
||||
|
||||
expect(hasIndexFormChanged(previousForm, nextForm)).toBe(false);
|
||||
});
|
||||
|
||||
it('marks edits as changed when index columns differ', () => {
|
||||
const previousForm: IndexFormSnapshot = {
|
||||
name: 'idx_user_name',
|
||||
columnNames: ['name'],
|
||||
kind: 'NORMAL',
|
||||
indexType: 'DEFAULT',
|
||||
};
|
||||
const nextForm: IndexFormSnapshot = {
|
||||
name: 'idx_user_name',
|
||||
columnNames: ['name', 'email'],
|
||||
kind: 'NORMAL',
|
||||
indexType: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(hasIndexFormChanged(previousForm, nextForm)).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles selected index keys without duplicates', () => {
|
||||
expect(toggleIndexSelection([], 'idx_user_name', true)).toEqual(['idx_user_name']);
|
||||
expect(toggleIndexSelection(['idx_user_name'], 'idx_user_name', true)).toEqual(['idx_user_name']);
|
||||
expect(toggleIndexSelection(['idx_user_name'], 'idx_user_name')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps single-selection toggles stable across repeated clicks', () => {
|
||||
let selected = toggleIndexSelection([], 'idx_user_name');
|
||||
expect(selected).toEqual(['idx_user_name']);
|
||||
|
||||
selected = toggleIndexSelection(selected, 'idx_user_name');
|
||||
expect(selected).toEqual([]);
|
||||
|
||||
selected = toggleIndexSelection(selected, 'idx_user_name');
|
||||
expect(selected).toEqual(['idx_user_name']);
|
||||
|
||||
selected = toggleIndexSelection(selected, 'idx_user_email');
|
||||
expect(selected).toEqual(['idx_user_name', 'idx_user_email']);
|
||||
|
||||
selected = toggleIndexSelection(selected, 'idx_user_email');
|
||||
expect(selected).toEqual(['idx_user_name']);
|
||||
|
||||
selected = toggleIndexSelection(selected, 'idx_user_name');
|
||||
expect(selected).toEqual([]);
|
||||
});
|
||||
|
||||
it('only restores original index when create step fails after drop step', () => {
|
||||
expect(shouldRestoreOriginalIndex({ failedStatementIndex: 1 })).toBe(true);
|
||||
expect(shouldRestoreOriginalIndex({ failedStatementIndex: 0 })).toBe(false);
|
||||
expect(shouldRestoreOriginalIndex({})).toBe(false);
|
||||
});
|
||||
});
|
||||
78
frontend/src/components/tableDesignerIndexUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type IndexKind = 'NORMAL' | 'UNIQUE' | 'PRIMARY' | 'FULLTEXT' | 'SPATIAL';
|
||||
|
||||
export interface IndexDisplaySnapshot {
|
||||
key: string;
|
||||
name: string;
|
||||
indexType: string;
|
||||
nonUnique: number;
|
||||
columnNames: string[];
|
||||
}
|
||||
|
||||
export interface IndexFormSnapshot {
|
||||
name: string;
|
||||
columnNames: string[];
|
||||
kind: IndexKind;
|
||||
indexType: string;
|
||||
}
|
||||
|
||||
export interface SchemaExecutionSnapshot {
|
||||
failedStatementIndex?: number;
|
||||
}
|
||||
|
||||
export const normalizeIndexFormFromRow = (
|
||||
row: IndexDisplaySnapshot,
|
||||
supportedKinds: IndexKind[],
|
||||
): IndexFormSnapshot => {
|
||||
const selectedName = String(row.name || '').trim();
|
||||
const selectedNameUpper = selectedName.toUpperCase();
|
||||
const selectedTypeUpper = String(row.indexType || '').trim().toUpperCase();
|
||||
let kind: IndexKind = 'NORMAL';
|
||||
if (selectedNameUpper === 'PRIMARY') {
|
||||
kind = 'PRIMARY';
|
||||
} else if (selectedTypeUpper === 'FULLTEXT') {
|
||||
kind = 'FULLTEXT';
|
||||
} else if (selectedTypeUpper === 'SPATIAL') {
|
||||
kind = 'SPATIAL';
|
||||
} else if (row.nonUnique === 0) {
|
||||
kind = 'UNIQUE';
|
||||
}
|
||||
if (!supportedKinds.includes(kind)) {
|
||||
kind = row.nonUnique === 0 ? 'UNIQUE' : 'NORMAL';
|
||||
}
|
||||
return {
|
||||
name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName,
|
||||
columnNames: [...row.columnNames],
|
||||
kind,
|
||||
indexType: kind === 'NORMAL' || kind === 'UNIQUE'
|
||||
? (selectedTypeUpper || 'DEFAULT')
|
||||
: 'DEFAULT',
|
||||
};
|
||||
};
|
||||
|
||||
export const hasIndexFormChanged = (
|
||||
previousForm: IndexFormSnapshot,
|
||||
nextForm: IndexFormSnapshot,
|
||||
): boolean => {
|
||||
if (previousForm.name !== nextForm.name) return true;
|
||||
if (previousForm.kind !== nextForm.kind) return true;
|
||||
if (previousForm.indexType !== nextForm.indexType) return true;
|
||||
if (previousForm.columnNames.length !== nextForm.columnNames.length) return true;
|
||||
return previousForm.columnNames.some((col, idx) => col !== nextForm.columnNames[idx]);
|
||||
};
|
||||
|
||||
export const toggleIndexSelection = (
|
||||
selectedKeys: string[],
|
||||
key: string,
|
||||
checked?: boolean,
|
||||
): string[] => {
|
||||
const exists = selectedKeys.includes(key);
|
||||
const nextChecked = checked ?? !exists;
|
||||
if (nextChecked) {
|
||||
return exists ? selectedKeys : [...selectedKeys, key];
|
||||
}
|
||||
return selectedKeys.filter((item) => item !== key);
|
||||
};
|
||||
|
||||
export const shouldRestoreOriginalIndex = (result: SchemaExecutionSnapshot): boolean => (
|
||||
(result.failedStatementIndex ?? -1) > 0
|
||||
);
|
||||
@@ -5,18 +5,50 @@ import App from './App'
|
||||
|
||||
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
|
||||
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
|
||||
// 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。
|
||||
import 'monaco-editor/esm/nls.messages.zh-cn'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
loader.config({ monaco })
|
||||
|
||||
if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
(window as any).go = {
|
||||
app: {
|
||||
App: {
|
||||
CheckUpdate: async () => ({ success: false }),
|
||||
DownloadUpdate: async () => ({ success: false }),
|
||||
GetSavedConnections: async () => [],
|
||||
SaveConnection: async () => null,
|
||||
DeleteConnection: async () => null,
|
||||
OpenConnection: async () => null,
|
||||
CloseConnection: async () => null,
|
||||
GetDatabases: async () => [],
|
||||
GetTables: async () => [],
|
||||
GetTableData: async () => ({ columns: [], rows: [], total: 0 }),
|
||||
GetTableColumns: async () => [],
|
||||
ExecuteQuery: async () => ({ columns: [], rows: [], time: 0 }),
|
||||
GetSavedQueries: async () => [],
|
||||
SaveQuery: async () => null,
|
||||
DeleteQuery: async () => null,
|
||||
GetAppInfo: async () => ({}),
|
||||
CheckForUpdates: async () => ({ success: false }),
|
||||
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
|
||||
InstallUpdateAndRestart: async () => ({ success: false }),
|
||||
ImportConfigFile: async () => ({ success: false }),
|
||||
ExportData: async () => ({ success: false }),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
|
||||
monaco.editor.defineTheme('transparent-dark', {
|
||||
base: 'vs-dark', inherit: true, rules: [],
|
||||
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000' }
|
||||
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#1e1e1e', 'editorStickyScrollHover.background': '#2a2a2a' }
|
||||
})
|
||||
monaco.editor.defineTheme('transparent-light', {
|
||||
base: 'vs', inherit: true, rules: [],
|
||||
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000' }
|
||||
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#ffffff', 'editorStickyScrollHover.background': '#f5f5f5' }
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem } from './types';
|
||||
import {
|
||||
ShortcutAction,
|
||||
ShortcutBinding,
|
||||
ShortcutOptions,
|
||||
DEFAULT_SHORTCUT_OPTIONS,
|
||||
cloneShortcutOptions,
|
||||
sanitizeShortcutOptions,
|
||||
} from './utils/shortcuts';
|
||||
|
||||
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
|
||||
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false };
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
const MIN_FONT_SIZE = 12;
|
||||
const MAX_FONT_SIZE = 20;
|
||||
const DEFAULT_STARTUP_FULLSCREEN = false;
|
||||
const LEGACY_DEFAULT_OPACITY = 0.95;
|
||||
const OPACITY_EPSILON = 1e-6;
|
||||
@@ -11,12 +25,23 @@ const MAX_HOST_ENTRY_LENGTH = 512;
|
||||
const MAX_HOST_ENTRIES = 64;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const PERSIST_VERSION = 7;
|
||||
const DEFAULT_CONNECTION_TYPE = 'mysql';
|
||||
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
enabled: false,
|
||||
type: 'socks5',
|
||||
host: '',
|
||||
port: 1080,
|
||||
user: '',
|
||||
password: '',
|
||||
};
|
||||
const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'doris',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'clickhouse',
|
||||
'postgres',
|
||||
'redis',
|
||||
'tdengine',
|
||||
@@ -31,18 +56,38 @@ const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
'duckdb',
|
||||
'custom',
|
||||
]);
|
||||
const SSL_SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'dameng',
|
||||
'clickhouse',
|
||||
'postgres',
|
||||
'sqlserver',
|
||||
'oracle',
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'mongodb',
|
||||
'redis',
|
||||
'tdengine',
|
||||
]);
|
||||
|
||||
const getDefaultPortByType = (type: string): number => {
|
||||
switch (type) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
return 3306;
|
||||
case 'doris':
|
||||
case 'diros':
|
||||
return 9030;
|
||||
case 'duckdb':
|
||||
return 0;
|
||||
case 'sphinx':
|
||||
return 9306;
|
||||
case 'clickhouse':
|
||||
return 9000;
|
||||
case 'postgres':
|
||||
case 'vastbase':
|
||||
return 5432;
|
||||
@@ -93,6 +138,13 @@ const normalizeIntegerInRange = (value: unknown, fallbackValue: number, min: num
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeFloatInRange = (value: unknown, fallbackValue: number, min: number, max: number): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallbackValue;
|
||||
if (parsed < min || parsed > max) return fallbackValue;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const isValidHostEntry = (entry: string): boolean => {
|
||||
if (!entry) return false;
|
||||
if (entry.length > MAX_HOST_ENTRY_LENGTH) return false;
|
||||
@@ -138,6 +190,9 @@ const sanitizeAddressList = (value: unknown): string[] => {
|
||||
|
||||
const normalizeConnectionType = (value: unknown): string => {
|
||||
const type = toTrimmedString(value).toLowerCase();
|
||||
if (type === 'doris') {
|
||||
return 'diros';
|
||||
}
|
||||
return SUPPORTED_CONNECTION_TYPES.has(type) ? type : DEFAULT_CONNECTION_TYPE;
|
||||
};
|
||||
|
||||
@@ -147,6 +202,16 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
const defaultPort = getDefaultPortByType(type);
|
||||
const savePassword = typeof raw.savePassword === 'boolean' ? raw.savePassword : true;
|
||||
const mongoSrv = !!raw.mongoSrv;
|
||||
const sslCapable = SSL_SUPPORTED_CONNECTION_TYPES.has(type);
|
||||
const sslModeRaw = toTrimmedString(raw.sslMode, 'preferred').toLowerCase();
|
||||
const sslMode: 'preferred' | 'required' | 'skip-verify' | 'disable' =
|
||||
sslModeRaw === 'required'
|
||||
? 'required'
|
||||
: sslModeRaw === 'skip-verify'
|
||||
? 'skip-verify'
|
||||
: sslModeRaw === 'disable'
|
||||
? 'disable'
|
||||
: 'preferred';
|
||||
|
||||
const sshRaw = (raw.ssh && typeof raw.ssh === 'object') ? raw.ssh as Record<string, unknown> : {};
|
||||
const ssh = {
|
||||
@@ -166,6 +231,18 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
user: toTrimmedString(proxyRaw.user),
|
||||
password: toTrimmedString(proxyRaw.password),
|
||||
};
|
||||
const httpTunnelRaw = (raw.httpTunnel && typeof raw.httpTunnel === 'object')
|
||||
? raw.httpTunnel as Record<string, unknown>
|
||||
: ((raw.HTTPTunnel && typeof raw.HTTPTunnel === 'object') ? raw.HTTPTunnel as Record<string, unknown> : {});
|
||||
const httpTunnel = {
|
||||
host: toTrimmedString(httpTunnelRaw.host ?? raw.httpTunnelHost),
|
||||
port: normalizePort(httpTunnelRaw.port ?? raw.httpTunnelPort, 8080),
|
||||
user: toTrimmedString(httpTunnelRaw.user ?? raw.httpTunnelUser),
|
||||
password: toTrimmedString(httpTunnelRaw.password ?? raw.httpTunnelPassword),
|
||||
};
|
||||
const supportsNetworkTunnel = type !== 'sqlite' && type !== 'duckdb';
|
||||
const useHttpTunnel = supportsNetworkTunnel && (raw.useHttpTunnel === true || raw.UseHTTPTunnel === true);
|
||||
const useProxy = supportsNetworkTunnel && !!raw.useProxy && !useHttpTunnel;
|
||||
|
||||
const safeConfig: ConnectionConfig & Record<string, unknown> = {
|
||||
...raw,
|
||||
@@ -176,13 +253,19 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
password: savePassword ? toTrimmedString(raw.password) : '',
|
||||
savePassword,
|
||||
database: toTrimmedString(raw.database),
|
||||
useSSL: sslCapable ? !!raw.useSSL : false,
|
||||
sslMode: sslCapable ? sslMode : 'disable',
|
||||
sslCertPath: sslCapable ? toTrimmedString(raw.sslCertPath) : '',
|
||||
sslKeyPath: sslCapable ? toTrimmedString(raw.sslKeyPath) : '',
|
||||
useSSH: !!raw.useSSH,
|
||||
ssh,
|
||||
useProxy: !!raw.useProxy,
|
||||
useProxy,
|
||||
proxy,
|
||||
useHttpTunnel,
|
||||
httpTunnel,
|
||||
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
|
||||
hosts: sanitizeAddressList(raw.hosts),
|
||||
topology: raw.topology === 'replica' ? 'replica' : 'single',
|
||||
topology: raw.topology === 'replica' ? 'replica' : (raw.topology === 'cluster' ? 'cluster' : 'single'),
|
||||
mysqlReplicaUser: toTrimmedString(raw.mysqlReplicaUser),
|
||||
mysqlReplicaPassword: savePassword ? toTrimmedString(raw.mysqlReplicaPassword) : '',
|
||||
replicaSet: toTrimmedString(raw.replicaSet),
|
||||
@@ -229,7 +312,8 @@ const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection
|
||||
const raw = value as Record<string, unknown>;
|
||||
const config = sanitizeConnectionConfig(resolveConnectionConfigPayload(raw));
|
||||
const id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`;
|
||||
const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`;
|
||||
const displayType = config.type === 'diros' ? 'doris' : config.type;
|
||||
const fallbackName = config.host ? `${displayType}-${config.host}` : `连接-${index + 1}`;
|
||||
const name = toTrimmedString(raw.name, fallbackName) || fallbackName;
|
||||
const includeDatabases = sanitizeStringArray(raw.includeDatabases, 256);
|
||||
const includeRedisDatabases = sanitizeNumberArray(raw.includeRedisDatabases, 0, 15);
|
||||
@@ -262,6 +346,27 @@ const sanitizeConnections = (value: unknown): SavedConnection[] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeConnectionTags = (value: unknown): ConnectionTag[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const result: ConnectionTag[] = [];
|
||||
const idSet = new Set<string>();
|
||||
|
||||
value.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') return;
|
||||
const raw = entry as Record<string, unknown>;
|
||||
const id = toTrimmedString(raw.id, `tag-${index + 1}`) || `tag-${index + 1}`;
|
||||
if (idSet.has(id)) return;
|
||||
idSet.add(id);
|
||||
|
||||
const name = toTrimmedString(raw.name, `标签-${index + 1}`) || `标签-${index + 1}`;
|
||||
const connectionIds = sanitizeStringArray(raw.connectionIds, 256);
|
||||
|
||||
result.push({ id, name, connectionIds });
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const isLegacyDefaultAppearance = (appearance: Partial<{ opacity: number; blur: number }> | undefined): boolean => {
|
||||
if (!appearance) {
|
||||
return true;
|
||||
@@ -288,25 +393,60 @@ export interface QueryOptions {
|
||||
showColumnType: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalProxyConfig extends ProxyConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
connections: SavedConnection[];
|
||||
connectionTags: ConnectionTag[];
|
||||
tabs: TabData[];
|
||||
activeTabId: string | null;
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { opacity: number; blur: number };
|
||||
appearance: { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean };
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
startupFullscreen: boolean;
|
||||
globalProxy: GlobalProxyConfig;
|
||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||
queryOptions: QueryOptions;
|
||||
shortcutOptions: ShortcutOptions;
|
||||
sqlLogs: SqlLog[];
|
||||
tableAccessCount: Record<string, number>;
|
||||
tableSortPreference: Record<string, 'name' | 'frequency'>;
|
||||
tableColumnOrders: Record<string, string[]>;
|
||||
enableColumnOrderMemory: boolean;
|
||||
tableHiddenColumns: Record<string, string[]>;
|
||||
enableHiddenColumnMemory: boolean;
|
||||
windowBounds: { width: number; height: number; x: number; y: number } | null;
|
||||
windowState: 'normal' | 'fullscreen' | 'maximized';
|
||||
sidebarWidth: number;
|
||||
|
||||
// AI 运行时与持久化状态
|
||||
aiPanelVisible: boolean;
|
||||
aiChatHistory: Record<string, AIChatMessage[]>; // sessionId -> messages
|
||||
replaceAIChatHistory: (sessionId: string, messages: AIChatMessage[]) => void;
|
||||
aiChatSessions: { id: string; title: string; updatedAt: number }[]; // 历史会话列表
|
||||
aiActiveSessionId: string | null;
|
||||
updateAISessionTitle: (sessionId: string, title: string) => void;
|
||||
|
||||
aiContexts: Record<string, AIContextItem[]>;
|
||||
addAIContext: (connectionKey: string, context: AIContextItem) => void;
|
||||
removeAIContext: (connectionKey: string, dbName: string, tableName: string) => void;
|
||||
clearAIContexts: (connectionKey: string) => void;
|
||||
|
||||
addConnection: (conn: SavedConnection) => void;
|
||||
updateConnection: (conn: SavedConnection) => void;
|
||||
removeConnection: (id: string) => void;
|
||||
|
||||
addConnectionTag: (tag: ConnectionTag) => void;
|
||||
updateConnectionTag: (tag: ConnectionTag) => void;
|
||||
removeConnectionTag: (id: string) => void;
|
||||
moveConnectionToTag: (connectionId: string, targetTagId: string | null) => void;
|
||||
reorderTags: (tagIds: string[]) => void;
|
||||
|
||||
addTab: (tab: TabData) => void;
|
||||
closeTab: (id: string) => void;
|
||||
closeOtherTabs: (id: string) => void;
|
||||
@@ -314,6 +454,7 @@ interface AppState {
|
||||
closeTabsToRight: (id: string) => void;
|
||||
closeTabsByConnection: (connectionId: string) => void;
|
||||
closeTabsByDatabase: (connectionId: string, dbName: string) => void;
|
||||
moveTab: (sourceId: string, targetId: string) => void;
|
||||
closeAllTabs: () => void;
|
||||
setActiveTab: (id: string) => void;
|
||||
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
|
||||
@@ -322,16 +463,43 @@ interface AppState {
|
||||
deleteQuery: (id: string) => void;
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }>) => void;
|
||||
setUiScale: (scale: number) => void;
|
||||
setFontSize: (size: number) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
setGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
||||
updateShortcut: (action: ShortcutAction, binding: Partial<ShortcutBinding>) => void;
|
||||
resetShortcutOptions: () => void;
|
||||
|
||||
addSqlLog: (log: SqlLog) => void;
|
||||
clearSqlLogs: () => void;
|
||||
|
||||
recordTableAccess: (connectionId: string, dbName: string, tableName: string) => void;
|
||||
setTableSortPreference: (connectionId: string, dbName: string, sortBy: 'name' | 'frequency') => void;
|
||||
setTableColumnOrder: (connectionId: string, dbName: string, tableName: string, order: string[]) => void;
|
||||
setEnableColumnOrderMemory: (enabled: boolean) => void;
|
||||
clearTableColumnOrder: (connectionId: string, dbName: string, tableName: string) => void;
|
||||
|
||||
setTableHiddenColumns: (connectionId: string, dbName: string, tableName: string, hiddenColumns: string[]) => void;
|
||||
setEnableHiddenColumnMemory: (enabled: boolean) => void;
|
||||
clearTableHiddenColumns: (connectionId: string, dbName: string, tableName: string) => void;
|
||||
setWindowBounds: (bounds: { width: number; height: number; x: number; y: number }) => void;
|
||||
setWindowState: (state: 'normal' | 'fullscreen' | 'maximized') => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
|
||||
// AI actions
|
||||
toggleAIPanel: () => void;
|
||||
setAIPanelVisible: (visible: boolean) => void;
|
||||
addAIChatMessage: (sessionId: string, message: AIChatMessage) => void;
|
||||
updateAIChatMessage: (sessionId: string, messageId: string, updates: Partial<AIChatMessage>) => void;
|
||||
deleteAIChatMessage: (sessionId: string, messageId: string) => void;
|
||||
truncateAIChatMessages: (sessionId: string, upToMessageId: string) => void;
|
||||
clearAIChatHistory: (sessionId: string) => void;
|
||||
deleteAISession: (sessionId: string) => void;
|
||||
createNewAISession: () => void;
|
||||
setAIActiveSessionId: (sessionId: string | null) => void;
|
||||
}
|
||||
|
||||
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
|
||||
@@ -395,16 +563,42 @@ const sanitizeTableSortPreference = (value: unknown): Record<string, 'name' | 'f
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeTableColumnOrders = (value: unknown): Record<string, string[]> => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const result: Record<string, string[]> = {};
|
||||
Object.entries(raw).forEach(([key, orderArray]) => {
|
||||
if (Array.isArray(orderArray)) {
|
||||
result[key] = orderArray.map(col => String(col));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeTableHiddenColumns = (value: unknown): Record<string, string[]> => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const result: Record<string, string[]> = {};
|
||||
Object.entries(raw).forEach(([key, hiddenArray]) => {
|
||||
if (Array.isArray(hiddenArray)) {
|
||||
result[key] = hiddenArray.map(col => String(col));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeAppearance = (
|
||||
appearance: Partial<{ opacity: number; blur: number }> | undefined,
|
||||
appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }> | undefined,
|
||||
version: number
|
||||
): { opacity: number; blur: number } => {
|
||||
): { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean } => {
|
||||
if (!appearance || typeof appearance !== 'object') {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
}
|
||||
const nextAppearance = {
|
||||
enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled,
|
||||
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
|
||||
blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur,
|
||||
useNativeMacWindowControls: typeof appearance.useNativeMacWindowControls === 'boolean'
|
||||
? appearance.useNativeMacWindowControls
|
||||
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
|
||||
};
|
||||
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
@@ -416,6 +610,52 @@ const sanitizeStartupFullscreen = (value: unknown): boolean => {
|
||||
return value === true;
|
||||
};
|
||||
|
||||
const sanitizeUiScale = (value: unknown): number => {
|
||||
return normalizeFloatInRange(value, DEFAULT_UI_SCALE, MIN_UI_SCALE, MAX_UI_SCALE);
|
||||
};
|
||||
|
||||
const sanitizeFontSize = (value: unknown): number => {
|
||||
return normalizeIntegerInRange(value, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
};
|
||||
|
||||
const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase();
|
||||
const type: 'socks5' | 'http' = typeRaw === 'http' ? 'http' : 'socks5';
|
||||
const fallbackPort = type === 'http' ? 8080 : 1080;
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
type,
|
||||
host: toTrimmedString(raw.host),
|
||||
port: normalizePort(raw.port, fallbackPort),
|
||||
user: toTrimmedString(raw.user),
|
||||
password: toTrimmedString(raw.password),
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeWindowState = (value: unknown): 'normal' | 'fullscreen' | 'maximized' => {
|
||||
if (value === 'fullscreen' || value === 'maximized') return value;
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
const sanitizeSidebarWidth = (value: unknown): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 330;
|
||||
return Math.max(200, Math.min(600, Math.trunc(parsed)));
|
||||
};
|
||||
|
||||
const sanitizeWindowBounds = (value: unknown): { width: number; height: number; x: number; y: number } | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const width = Number(raw.width);
|
||||
const height = Number(raw.height);
|
||||
const x = Number(raw.x);
|
||||
const y = Number(raw.y);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
if (width < 400 || height < 300) return null;
|
||||
return { width: Math.trunc(width), height: Math.trunc(height), x: Math.trunc(x), y: Math.trunc(y) };
|
||||
};
|
||||
|
||||
const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknown> => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return {};
|
||||
@@ -427,28 +667,154 @@ const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknow
|
||||
return raw;
|
||||
};
|
||||
|
||||
// --- AI 会话文件持久化辅助函数 ---
|
||||
|
||||
/** 每个 session 独立防抖定时器(2秒) */
|
||||
const _persistTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
function _debouncedPersistSession(sessionId: string) {
|
||||
if (_persistTimers[sessionId]) clearTimeout(_persistTimers[sessionId]);
|
||||
_persistTimers[sessionId] = setTimeout(() => {
|
||||
delete _persistTimers[sessionId];
|
||||
const state = useStore.getState();
|
||||
const messages = state.aiChatHistory[sessionId];
|
||||
const sessionMeta = state.aiChatSessions.find(s => s.id === sessionId);
|
||||
if (!messages && !sessionMeta) return; // session 已被删除,跳过
|
||||
const title = sessionMeta?.title || '新的对话';
|
||||
const updatedAt = sessionMeta?.updatedAt || Date.now();
|
||||
const messagesJSON = JSON.stringify(messages || []);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
Service?.AISaveSession?.(sessionId, title, updatedAt, messagesJSON).catch((e: any) => {
|
||||
console.error('[AI Session Persist] 持久化失败:', sessionId, e);
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/** 从后端加载会话列表(仅元数据,不含消息体) */
|
||||
export async function loadAISessionsFromBackend(): Promise<{ id: string; title: string; updatedAt: number }[]> {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service?.AIGetSessions) return [];
|
||||
try {
|
||||
const sessions = await Service.AIGetSessions();
|
||||
if (Array.isArray(sessions)) {
|
||||
useStore.setState({ aiChatSessions: sessions });
|
||||
return sessions;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AI Session] 加载会话列表失败:', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 从后端加载指定会话的消息数据到内存 */
|
||||
export async function loadAISessionFromBackend(sessionId: string): Promise<boolean> {
|
||||
const state = useStore.getState();
|
||||
// 如果内存中已有消息,跳过重复加载
|
||||
if (state.aiChatHistory[sessionId]?.length > 0) return true;
|
||||
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service?.AILoadSession) return false;
|
||||
try {
|
||||
const result = await Service.AILoadSession(sessionId);
|
||||
if (result?.success) {
|
||||
let messages = result.messages;
|
||||
// messages 可能是 JSON string 或已解析的数组
|
||||
if (typeof messages === 'string') {
|
||||
try { messages = JSON.parse(messages); } catch { messages = []; }
|
||||
}
|
||||
if (Array.isArray(messages)) {
|
||||
useStore.setState((prev) => ({
|
||||
aiChatHistory: { ...prev.aiChatHistory, [sessionId]: messages },
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AI Session] 加载会话消息失败:', sessionId, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
connections: [],
|
||||
connectionTags: [],
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
activeContext: null,
|
||||
savedQueries: [],
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
uiScale: DEFAULT_UI_SCALE,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
|
||||
globalProxy: { ...DEFAULT_GLOBAL_PROXY },
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
|
||||
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
|
||||
sqlLogs: [],
|
||||
tableAccessCount: {},
|
||||
tableSortPreference: {},
|
||||
tableColumnOrders: {},
|
||||
enableColumnOrderMemory: true,
|
||||
tableHiddenColumns: {},
|
||||
enableHiddenColumnMemory: true,
|
||||
windowBounds: null,
|
||||
windowState: 'normal' as const,
|
||||
sidebarWidth: 330,
|
||||
|
||||
// AI 运行状态
|
||||
aiPanelVisible: false,
|
||||
aiChatHistory: {},
|
||||
aiChatSessions: [],
|
||||
aiActiveSessionId: null,
|
||||
aiContexts: {},
|
||||
|
||||
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
|
||||
updateConnection: (conn) => set((state) => ({
|
||||
connections: state.connections.map(c => c.id === conn.id ? conn : c)
|
||||
})),
|
||||
removeConnection: (id) => set((state) => ({ connections: state.connections.filter(c => c.id !== id) })),
|
||||
removeConnection: (id) => set((state) => ({
|
||||
connections: state.connections.filter(c => c.id !== id),
|
||||
connectionTags: state.connectionTags.map(tag => ({
|
||||
...tag,
|
||||
connectionIds: tag.connectionIds.filter(cid => cid !== id)
|
||||
}))
|
||||
})),
|
||||
|
||||
addConnectionTag: (tag) => set((state) => ({ connectionTags: [...state.connectionTags, tag] })),
|
||||
updateConnectionTag: (tag) => set((state) => ({
|
||||
connectionTags: state.connectionTags.map(t => t.id === tag.id ? tag : t)
|
||||
})),
|
||||
removeConnectionTag: (id) => set((state) => ({
|
||||
connectionTags: state.connectionTags.filter(t => t.id !== id)
|
||||
})),
|
||||
moveConnectionToTag: (connectionId, targetTagId) => set((state) => {
|
||||
const newTags = state.connectionTags.map(tag => {
|
||||
//先从所有tag中移除该connection
|
||||
const filteredIds = tag.connectionIds.filter(id => id !== connectionId);
|
||||
if (tag.id === targetTagId) {
|
||||
return { ...tag, connectionIds: [...filteredIds, connectionId] };
|
||||
}
|
||||
return { ...tag, connectionIds: filteredIds };
|
||||
});
|
||||
return { connectionTags: newTags };
|
||||
}),
|
||||
reorderTags: (tagIds) => set((state) => {
|
||||
const tagMap = new Map(state.connectionTags.map(t => [t.id, t]));
|
||||
const newTags: ConnectionTag[] = [];
|
||||
tagIds.forEach(id => {
|
||||
const tag = tagMap.get(id);
|
||||
if (tag) {
|
||||
newTags.push(tag);
|
||||
tagMap.delete(id);
|
||||
}
|
||||
});
|
||||
// 追加未指定的tag(如果有的话)
|
||||
newTags.push(...Array.from(tagMap.values()));
|
||||
return { connectionTags: newTags };
|
||||
}),
|
||||
|
||||
addTab: (tab) => set((state) => {
|
||||
const index = state.tabs.findIndex(t => t.id === tab.id);
|
||||
@@ -458,6 +824,33 @@ export const useStore = create<AppState>()(
|
||||
newTabs[index] = { ...newTabs[index], ...tab };
|
||||
return { tabs: newTabs, activeTabId: tab.id };
|
||||
}
|
||||
// 语义去重:对 table/design 类型按 connectionId+dbName+tableName 匹配已有 Tab
|
||||
if ((tab.type === 'table' || tab.type === 'design') && tab.tableName && tab.connectionId && tab.dbName) {
|
||||
const semanticIndex = state.tabs.findIndex(t =>
|
||||
t.type === tab.type &&
|
||||
t.connectionId === tab.connectionId &&
|
||||
t.dbName === tab.dbName &&
|
||||
t.tableName === tab.tableName
|
||||
);
|
||||
if (semanticIndex !== -1) {
|
||||
const existingTab = state.tabs[semanticIndex];
|
||||
const newTabs = [...state.tabs];
|
||||
newTabs[semanticIndex] = { ...existingTab, ...tab, id: existingTab.id };
|
||||
return { tabs: newTabs, activeTabId: existingTab.id };
|
||||
}
|
||||
}
|
||||
// 语义去重:对 query 类型按 savedQueryId 匹配已有 Tab(避免保存后重复打开)
|
||||
if (tab.type === 'query' && tab.savedQueryId) {
|
||||
const savedQueryIndex = state.tabs.findIndex(t =>
|
||||
t.type === 'query' && (t.savedQueryId === tab.savedQueryId || t.id === tab.savedQueryId)
|
||||
);
|
||||
if (savedQueryIndex !== -1) {
|
||||
const existingTab = state.tabs[savedQueryIndex];
|
||||
const newTabs = [...state.tabs];
|
||||
newTabs[savedQueryIndex] = { ...existingTab, ...tab, id: existingTab.id };
|
||||
return { tabs: newTabs, activeTabId: existingTab.id };
|
||||
}
|
||||
}
|
||||
return { tabs: [...state.tabs, tab], activeTabId: tab.id };
|
||||
}),
|
||||
|
||||
@@ -531,6 +924,23 @@ export const useStore = create<AppState>()(
|
||||
};
|
||||
}),
|
||||
|
||||
moveTab: (sourceId, targetId) => set((state) => {
|
||||
const fromId = String(sourceId || '').trim();
|
||||
const toId = String(targetId || '').trim();
|
||||
if (!fromId || !toId || fromId === toId) {
|
||||
return state;
|
||||
}
|
||||
const fromIndex = state.tabs.findIndex((tab) => tab.id === fromId);
|
||||
const toIndex = state.tabs.findIndex((tab) => tab.id === toId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
return state;
|
||||
}
|
||||
const nextTabs = [...state.tabs];
|
||||
const [movingTab] = nextTabs.splice(fromIndex, 1);
|
||||
nextTabs.splice(toIndex, 0, movingTab);
|
||||
return { tabs: nextTabs };
|
||||
}),
|
||||
|
||||
closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })),
|
||||
|
||||
setActiveTab: (id) => set({ activeTabId: id }),
|
||||
@@ -549,9 +959,22 @@ export const useStore = create<AppState>()(
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
||||
setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }),
|
||||
setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }),
|
||||
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
|
||||
setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })),
|
||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
|
||||
updateShortcut: (action, binding) => set((state) => ({
|
||||
shortcutOptions: {
|
||||
...state.shortcutOptions,
|
||||
[action]: {
|
||||
...state.shortcutOptions[action],
|
||||
...binding,
|
||||
},
|
||||
},
|
||||
})),
|
||||
resetShortcutOptions: () => set({ shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS) }),
|
||||
|
||||
addSqlLog: (log) => set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
|
||||
clearSqlLogs: () => set({ sqlLogs: [] }),
|
||||
@@ -576,22 +999,242 @@ export const useStore = create<AppState>()(
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
setTableColumnOrder: (connectionId, dbName, tableName, order) => set((state) => {
|
||||
const key = `${connectionId}-${dbName}-${tableName}`;
|
||||
return {
|
||||
tableColumnOrders: {
|
||||
...state.tableColumnOrders,
|
||||
[key]: order
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
clearTableColumnOrder: (connectionId, dbName, tableName) => set((state) => {
|
||||
const key = `${connectionId}-${dbName}-${tableName}`;
|
||||
const newOrders = { ...state.tableColumnOrders };
|
||||
delete newOrders[key];
|
||||
return { tableColumnOrders: newOrders };
|
||||
}),
|
||||
|
||||
setEnableColumnOrderMemory: (enabled) => set({ enableColumnOrderMemory: !!enabled }),
|
||||
|
||||
setTableHiddenColumns: (connectionId, dbName, tableName, hiddenColumns) => set((state) => {
|
||||
const key = `${connectionId}-${dbName}-${tableName}`;
|
||||
return {
|
||||
tableHiddenColumns: {
|
||||
...state.tableHiddenColumns,
|
||||
[key]: hiddenColumns
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
clearTableHiddenColumns: (connectionId, dbName, tableName) => set((state) => {
|
||||
const key = `${connectionId}-${dbName}-${tableName}`;
|
||||
const newHidden = { ...state.tableHiddenColumns };
|
||||
delete newHidden[key];
|
||||
return { tableHiddenColumns: newHidden };
|
||||
}),
|
||||
|
||||
setEnableHiddenColumnMemory: (enabled) => set({ enableHiddenColumnMemory: !!enabled }),
|
||||
|
||||
setWindowBounds: (bounds) => set({
|
||||
windowBounds: {
|
||||
width: Math.max(400, Math.trunc(bounds.width)),
|
||||
height: Math.max(300, Math.trunc(bounds.height)),
|
||||
x: Math.trunc(bounds.x),
|
||||
y: Math.trunc(bounds.y),
|
||||
}
|
||||
}),
|
||||
|
||||
setWindowState: (state) => set({ windowState: state }),
|
||||
|
||||
setSidebarWidth: (width) => set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }),
|
||||
|
||||
// AI actions
|
||||
toggleAIPanel: () => set((state) => ({ aiPanelVisible: !state.aiPanelVisible })),
|
||||
setAIPanelVisible: (visible) => set({ aiPanelVisible: visible }),
|
||||
addAIChatMessage: (sessionId, message) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
const messages = history[sessionId] || [];
|
||||
history[sessionId] = [...messages, message];
|
||||
|
||||
let newSessions = [...state.aiChatSessions];
|
||||
const existingSession = newSessions.find(s => s.id === sessionId);
|
||||
|
||||
if (!existingSession) {
|
||||
let title = message.role === 'user' ? message.content : '新的对话';
|
||||
if (title.length > 20) {
|
||||
title = title.substring(0, 20) + '...';
|
||||
}
|
||||
newSessions.unshift({ id: sessionId, title, updatedAt: Date.now() });
|
||||
} else {
|
||||
newSessions = newSessions.filter(s => s.id !== sessionId);
|
||||
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
return { aiChatHistory: history, aiChatSessions: newSessions };
|
||||
});
|
||||
// 异步持久化到文件(fire-and-forget,防抖由外层控制)
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
updateAIChatMessage: (sessionId, messageId, updates) => {
|
||||
set((state) => {
|
||||
const messages = state.aiChatHistory[sessionId];
|
||||
if (!messages) return state;
|
||||
const idx = messages.findIndex(m => m.id === messageId);
|
||||
if (idx < 0) return state;
|
||||
const newMessages = [...messages];
|
||||
newMessages[idx] = { ...newMessages[idx], ...updates };
|
||||
const history = { ...state.aiChatHistory, [sessionId]: newMessages };
|
||||
const isContentOnlyUpdate = Object.keys(updates).length === 1 && 'content' in updates;
|
||||
if (!isContentOnlyUpdate) {
|
||||
let newSessions = [...state.aiChatSessions];
|
||||
const existingSession = newSessions.find(s => s.id === sessionId);
|
||||
if (existingSession) {
|
||||
newSessions = newSessions.filter(s => s.id !== sessionId);
|
||||
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
|
||||
}
|
||||
return { aiChatHistory: history, aiChatSessions: newSessions };
|
||||
}
|
||||
return { aiChatHistory: history };
|
||||
});
|
||||
// 流式打字高频调用,防抖 2 秒后才写磁盘
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
deleteAIChatMessage: (sessionId, messageId) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
if (history[sessionId]) {
|
||||
history[sessionId] = history[sessionId].filter(m => m.id !== messageId);
|
||||
}
|
||||
return { aiChatHistory: history };
|
||||
});
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
truncateAIChatMessages: (sessionId, upToMessageId) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
const messages = history[sessionId];
|
||||
if (messages) {
|
||||
const idx = messages.findIndex(m => m.id === upToMessageId);
|
||||
if (idx >= 0) {
|
||||
history[sessionId] = messages.slice(0, idx + 1);
|
||||
}
|
||||
}
|
||||
return { aiChatHistory: history };
|
||||
});
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
clearAIChatHistory: (sessionId) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
delete history[sessionId];
|
||||
return { aiChatHistory: history };
|
||||
});
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
replaceAIChatHistory: (sessionId, messages) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
history[sessionId] = messages;
|
||||
return { aiChatHistory: history };
|
||||
});
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
deleteAISession: (sessionId) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
delete history[sessionId];
|
||||
const newSessions = state.aiChatSessions.filter(s => s.id !== sessionId);
|
||||
const newActive = state.aiActiveSessionId === sessionId ? null : state.aiActiveSessionId;
|
||||
return { aiChatHistory: history, aiChatSessions: newSessions, aiActiveSessionId: newActive };
|
||||
});
|
||||
// 删除文件
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
Service?.AIDeleteSession?.(sessionId).catch(() => {});
|
||||
},
|
||||
createNewAISession: () => set(() => {
|
||||
const newId = `session-${Date.now()}`;
|
||||
return { aiActiveSessionId: newId };
|
||||
}),
|
||||
setAIActiveSessionId: (sessionId) => set({ aiActiveSessionId: sessionId }),
|
||||
updateAISessionTitle: (sessionId, title) => {
|
||||
set((state) => {
|
||||
const newSessions = [...state.aiChatSessions];
|
||||
const session = newSessions.find(s => s.id === sessionId);
|
||||
if (session) {
|
||||
session.title = title;
|
||||
}
|
||||
return { aiChatSessions: newSessions };
|
||||
});
|
||||
_debouncedPersistSession(sessionId);
|
||||
},
|
||||
addAIContext: (connectionKey, context) => set((state) => {
|
||||
const contexts = state.aiContexts[connectionKey] || [];
|
||||
if (contexts.find(c => c.dbName === context.dbName && c.tableName === context.tableName)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
aiContexts: {
|
||||
...state.aiContexts,
|
||||
[connectionKey]: [...contexts, context]
|
||||
}
|
||||
};
|
||||
}),
|
||||
removeAIContext: (connectionKey, dbName, tableName) => set((state) => {
|
||||
const contexts = state.aiContexts[connectionKey] || [];
|
||||
return {
|
||||
aiContexts: {
|
||||
...state.aiContexts,
|
||||
[connectionKey]: contexts.filter(c => !(c.dbName === dbName && c.tableName === tableName))
|
||||
}
|
||||
};
|
||||
}),
|
||||
clearAIContexts: (connectionKey) => set((state) => {
|
||||
const { [connectionKey]: _, ...rest } = state.aiContexts;
|
||||
return { aiContexts: rest };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'lite-db-storage', // name of the item in the storage (must be unique)
|
||||
version: 3,
|
||||
version: PERSIST_VERSION,
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||
const nextState: Partial<AppState> = { ...state };
|
||||
nextState.connections = sanitizeConnections(state.connections);
|
||||
if (version < 5) {
|
||||
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
|
||||
} else {
|
||||
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
|
||||
}
|
||||
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
|
||||
nextState.theme = sanitizeTheme(state.theme);
|
||||
nextState.appearance = sanitizeAppearance(state.appearance, version);
|
||||
nextState.uiScale = sanitizeUiScale(state.uiScale);
|
||||
nextState.fontSize = sanitizeFontSize(state.fontSize);
|
||||
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
|
||||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
|
||||
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
|
||||
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
|
||||
nextState.shortcutOptions = sanitizeShortcutOptions(state.shortcutOptions);
|
||||
nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount);
|
||||
nextState.tableSortPreference = sanitizeTableSortPreference(state.tableSortPreference);
|
||||
// 新增的列排序记忆状态不需要做版本特殊兼容,直接做基本的类型保护
|
||||
const safeOrders = sanitizeTableColumnOrders(state.tableColumnOrders);
|
||||
nextState.tableColumnOrders = safeOrders;
|
||||
nextState.enableColumnOrderMemory = state.enableColumnOrderMemory !== false;
|
||||
const safeHidden = sanitizeTableHiddenColumns(state.tableHiddenColumns);
|
||||
nextState.tableHiddenColumns = safeHidden;
|
||||
nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false;
|
||||
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
|
||||
nextState.windowState = sanitizeWindowState(state.windowState);
|
||||
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
|
||||
|
||||
// 保留原有的 AI 持久化记录,或者为空(版本兼容)
|
||||
nextState.aiChatHistory = (state.aiChatHistory && typeof state.aiChatHistory === 'object') ? state.aiChatHistory : {};
|
||||
nextState.aiChatSessions = Array.isArray(state.aiChatSessions) ? state.aiChatSessions : [];
|
||||
return nextState as AppState;
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
@@ -600,26 +1243,57 @@ export const useStore = create<AppState>()(
|
||||
...currentState,
|
||||
...state,
|
||||
connections: sanitizeConnections(state.connections),
|
||||
connectionTags: sanitizeConnectionTags(state.connectionTags),
|
||||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||||
theme: sanitizeTheme(state.theme),
|
||||
appearance: sanitizeAppearance(state.appearance, 3),
|
||||
appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION),
|
||||
uiScale: sanitizeUiScale(state.uiScale),
|
||||
fontSize: sanitizeFontSize(state.fontSize),
|
||||
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
|
||||
globalProxy: sanitizeGlobalProxy(state.globalProxy),
|
||||
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
|
||||
tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders),
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
|
||||
tableHiddenColumns: sanitizeTableHiddenColumns(state.tableHiddenColumns),
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory !== false,
|
||||
windowBounds: sanitizeWindowBounds(state.windowBounds),
|
||||
windowState: sanitizeWindowState(state.windowState),
|
||||
sidebarWidth: sanitizeSidebarWidth(state.sidebarWidth),
|
||||
|
||||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||||
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
||||
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
|
||||
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
|
||||
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
|
||||
|
||||
// AI 会话数据不再从 localStorage 恢复,改为从后端文件加载
|
||||
aiChatHistory: {},
|
||||
aiChatSessions: [],
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
connections: state.connections,
|
||||
connectionTags: state.connectionTags,
|
||||
savedQueries: state.savedQueries,
|
||||
theme: state.theme,
|
||||
appearance: state.appearance,
|
||||
uiScale: state.uiScale,
|
||||
fontSize: state.fontSize,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
globalProxy: state.globalProxy,
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
queryOptions: state.queryOptions,
|
||||
shortcutOptions: state.shortcutOptions,
|
||||
tableAccessCount: state.tableAccessCount,
|
||||
tableSortPreference: state.tableSortPreference
|
||||
tableSortPreference: state.tableSortPreference,
|
||||
tableColumnOrders: state.tableColumnOrders,
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory,
|
||||
tableHiddenColumns: state.tableHiddenColumns,
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
|
||||
windowBounds: state.windowBounds,
|
||||
windowState: state.windowState,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
|
||||
// AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage
|
||||
}), // Don't persist logs
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,6 +14,13 @@ export interface ProxyConfig {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface HTTPTunnelConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
type: string;
|
||||
host: string;
|
||||
@@ -22,17 +29,23 @@ export interface ConnectionConfig {
|
||||
password?: string;
|
||||
savePassword?: boolean;
|
||||
database?: string;
|
||||
useSSL?: boolean;
|
||||
sslMode?: 'preferred' | 'required' | 'skip-verify' | 'disable';
|
||||
sslCertPath?: string;
|
||||
sslKeyPath?: string;
|
||||
useSSH?: boolean;
|
||||
ssh?: SSHConfig;
|
||||
useProxy?: boolean;
|
||||
proxy?: ProxyConfig;
|
||||
useHttpTunnel?: boolean;
|
||||
httpTunnel?: HTTPTunnelConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
timeout?: number;
|
||||
redisDB?: number; // Redis database index (0-15)
|
||||
uri?: string; // Connection URI for copy/paste
|
||||
hosts?: string[]; // Multi-host addresses: host:port
|
||||
topology?: 'single' | 'replica';
|
||||
topology?: 'single' | 'replica' | 'cluster';
|
||||
mysqlReplicaUser?: string;
|
||||
mysqlReplicaPassword?: string;
|
||||
replicaSet?: string;
|
||||
@@ -59,6 +72,14 @@ export interface SavedConnection {
|
||||
config: ConnectionConfig;
|
||||
includeDatabases?: string[];
|
||||
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
|
||||
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
|
||||
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
|
||||
}
|
||||
|
||||
export interface ConnectionTag {
|
||||
id: string;
|
||||
name: string;
|
||||
connectionIds: string[];
|
||||
}
|
||||
|
||||
export interface ColumnDefinition {
|
||||
@@ -97,7 +118,7 @@ export interface TriggerDefinition {
|
||||
export interface TabData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def';
|
||||
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
|
||||
connectionId: string;
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
@@ -109,6 +130,7 @@ export interface TabData {
|
||||
viewName?: string; // View name for view definition tabs
|
||||
routineName?: string; // Routine name for function/procedure definition tabs
|
||||
routineType?: string; // 'FUNCTION' or 'PROCEDURE'
|
||||
savedQueryId?: string; // Saved query identity for quick-save behavior
|
||||
}
|
||||
|
||||
export interface DatabaseNode {
|
||||
@@ -137,7 +159,7 @@ export interface RedisKeyInfo {
|
||||
|
||||
export interface RedisScanResult {
|
||||
keys: RedisKeyInfo[];
|
||||
cursor: number;
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
export interface RedisValue {
|
||||
@@ -161,3 +183,63 @@ export interface StreamEntry {
|
||||
id: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
// --- AI Types ---
|
||||
|
||||
export type AIProviderType = 'openai' | 'anthropic' | 'gemini' | 'custom';
|
||||
export type AISafetyLevel = 'readonly' | 'readwrite' | 'full';
|
||||
export type AIContextLevel = 'schema_only' | 'with_samples' | 'with_results';
|
||||
|
||||
export interface AIContextItem {
|
||||
dbName: string;
|
||||
tableName: string;
|
||||
ddl: string;
|
||||
}
|
||||
|
||||
export interface AIProviderConfig {
|
||||
id: string;
|
||||
type: AIProviderType;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
models?: string[];
|
||||
apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli
|
||||
headers?: Record<string, string>;
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
}
|
||||
|
||||
export interface AIToolCall {
|
||||
id: string;
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ChatPhase = 'idle' | 'connecting' | 'thinking' | 'generating' | 'tool_calling';
|
||||
|
||||
export interface AIChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
phase?: ChatPhase;
|
||||
content: string;
|
||||
thinking?: string;
|
||||
timestamp: number;
|
||||
loading?: boolean;
|
||||
images?: string[]; // base64 encoded images with data URI prefix
|
||||
tool_calls?: AIToolCall[];
|
||||
tool_call_id?: string;
|
||||
tool_name?: string; // used for UI display
|
||||
rawError?: string; // 存储未清洗的原始错误信息,用于用户复制排查
|
||||
success?: boolean; // 标记探针执行是否成功
|
||||
}
|
||||
|
||||
export interface AISafetyResult {
|
||||
allowed: boolean;
|
||||
operationType: 'query' | 'dml' | 'ddl' | 'other';
|
||||
requiresConfirm: boolean;
|
||||
warningMessage?: string;
|
||||
}
|
||||
|
||||
33
frontend/src/utils/aiComposerNotice.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildModelFetchFailedNotice,
|
||||
buildMissingModelNotice,
|
||||
buildMissingProviderNotice,
|
||||
} from './aiComposerNotice';
|
||||
|
||||
describe('ai composer notice helpers', () => {
|
||||
it('builds a compact notice for missing provider', () => {
|
||||
expect(buildMissingProviderNotice()).toEqual({
|
||||
tone: 'warning',
|
||||
title: '还没有可用供应商',
|
||||
description: '先在 AI 设置里添加并启用一个模型供应商。',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a compact notice for missing model selection', () => {
|
||||
expect(buildMissingModelNotice()).toEqual({
|
||||
tone: 'warning',
|
||||
title: '先选择一个模型',
|
||||
description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a readable inline notice for model fetch failures', () => {
|
||||
expect(buildModelFetchFailedNotice('当前接口未返回可用模型')).toEqual({
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
description: '当前接口未返回可用模型',
|
||||
});
|
||||
});
|
||||
});
|
||||
27
frontend/src/utils/aiComposerNotice.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type AIComposerNoticeTone = 'warning' | 'error';
|
||||
|
||||
export interface AIComposerNotice {
|
||||
tone: AIComposerNoticeTone;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const defaultModelFetchFailedDescription = '请检查供应商入口、API Key 或账号权限,然后重新打开模型下拉。';
|
||||
|
||||
export const buildMissingProviderNotice = (): AIComposerNotice => ({
|
||||
tone: 'warning',
|
||||
title: '还没有可用供应商',
|
||||
description: '先在 AI 设置里添加并启用一个模型供应商。',
|
||||
});
|
||||
|
||||
export const buildMissingModelNotice = (): AIComposerNotice => ({
|
||||
tone: 'warning',
|
||||
title: '先选择一个模型',
|
||||
description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
|
||||
});
|
||||
|
||||
export const buildModelFetchFailedNotice = (error?: string): AIComposerNotice => ({
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
description: String(error || '').trim() || defaultModelFetchFailedDescription,
|
||||
});
|
||||
111
frontend/src/utils/aiProviderPresets.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
matchQwenPresetKey,
|
||||
QWEN_BAILIAN_MODELS_BASE_URL,
|
||||
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_MODELS,
|
||||
resolvePresetBaseURL,
|
||||
resolvePresetModelSelection,
|
||||
resolvePresetTransport,
|
||||
} from './aiProviderPresets';
|
||||
|
||||
describe('ai provider preset helpers', () => {
|
||||
it('maps legacy Bailian compatible-mode URL back to the Bailian preset', () => {
|
||||
expect(matchQwenPresetKey({
|
||||
type: 'openai',
|
||||
baseUrl: QWEN_BAILIAN_MODELS_BASE_URL,
|
||||
})).toBe('qwen-bailian');
|
||||
});
|
||||
|
||||
it('maps Coding Plan anthropic URL to the dedicated Coding Plan preset', () => {
|
||||
expect(matchQwenPresetKey({
|
||||
type: 'anthropic',
|
||||
baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
})).toBe('qwen-coding-plan');
|
||||
});
|
||||
|
||||
it('maps Coding Plan Claude CLI config back to the dedicated Coding Plan preset', () => {
|
||||
expect(matchQwenPresetKey({
|
||||
type: 'custom',
|
||||
apiFormat: 'claude-cli',
|
||||
baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
})).toBe('qwen-coding-plan');
|
||||
});
|
||||
|
||||
it('does not keep a baked-in model list for the Coding Plan preset', () => {
|
||||
expect(QWEN_CODING_PLAN_MODELS).toEqual([
|
||||
'qwen3.5-plus',
|
||||
'kimi-k2.5',
|
||||
'glm-5',
|
||||
'MiniMax-M2.5',
|
||||
'qwen3-max-2026-01-23',
|
||||
'qwen3-coder-next',
|
||||
'qwen3-coder-plus',
|
||||
'glm-4.7',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps built-in preset model empty when the preset intentionally requires an explicit selection', () => {
|
||||
expect(resolvePresetModelSelection({
|
||||
presetKey: 'qwen-coding-plan',
|
||||
presetDefaultModel: '',
|
||||
presetModels: QWEN_CODING_PLAN_MODELS,
|
||||
valuesModel: '',
|
||||
customModels: [],
|
||||
})).toEqual({
|
||||
model: '',
|
||||
models: QWEN_CODING_PLAN_MODELS,
|
||||
});
|
||||
});
|
||||
|
||||
it('still falls back to the first configured model for custom-like presets', () => {
|
||||
expect(resolvePresetModelSelection({
|
||||
presetKey: 'custom',
|
||||
presetDefaultModel: '',
|
||||
presetModels: [],
|
||||
valuesModel: '',
|
||||
customModels: ['foo-model', 'bar-model'],
|
||||
})).toEqual({
|
||||
model: 'foo-model',
|
||||
models: ['foo-model', 'bar-model'],
|
||||
});
|
||||
});
|
||||
|
||||
it('forces built-in presets back to their standard base URL when saving or testing', () => {
|
||||
expect(resolvePresetBaseURL({
|
||||
presetKey: 'qwen-bailian',
|
||||
presetDefaultBaseUrl: 'https://dashscope.aliyuncs.com/apps/anthropic',
|
||||
valuesBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
})).toBe('https://dashscope.aliyuncs.com/apps/anthropic');
|
||||
});
|
||||
|
||||
it('keeps the user-entered base URL for custom-like presets', () => {
|
||||
expect(resolvePresetBaseURL({
|
||||
presetKey: 'custom',
|
||||
presetDefaultBaseUrl: '',
|
||||
valuesBaseUrl: 'https://example-proxy.internal/v1',
|
||||
})).toBe('https://example-proxy.internal/v1');
|
||||
});
|
||||
|
||||
it('forces qwen coding plan to save as custom plus claude-cli', () => {
|
||||
expect(resolvePresetTransport({
|
||||
presetBackendType: 'custom',
|
||||
presetFixedApiFormat: 'claude-cli',
|
||||
valuesApiFormat: 'anthropic',
|
||||
})).toEqual({
|
||||
type: 'custom',
|
||||
apiFormat: 'claude-cli',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps custom preset transport editable', () => {
|
||||
expect(resolvePresetTransport({
|
||||
presetBackendType: 'custom',
|
||||
valuesApiFormat: 'gemini',
|
||||
})).toEqual({
|
||||
type: 'custom',
|
||||
apiFormat: 'gemini',
|
||||
});
|
||||
});
|
||||
});
|
||||
143
frontend/src/utils/aiProviderPresets.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { AIProviderConfig, AIProviderType } from '../types';
|
||||
|
||||
export const LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
export const LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL = 'https://coding.dashscope.aliyuncs.com/v1';
|
||||
export const QWEN_BAILIAN_ANTHROPIC_BASE_URL = 'https://dashscope.aliyuncs.com/apps/anthropic';
|
||||
export const QWEN_CODING_PLAN_ANTHROPIC_BASE_URL = 'https://coding.dashscope.aliyuncs.com/apps/anthropic';
|
||||
export const QWEN_BAILIAN_MODELS_BASE_URL = LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL;
|
||||
|
||||
export const QWEN_CODING_PLAN_MODELS = [
|
||||
'qwen3.5-plus',
|
||||
'kimi-k2.5',
|
||||
'glm-5',
|
||||
'MiniMax-M2.5',
|
||||
'qwen3-max-2026-01-23',
|
||||
'qwen3-coder-next',
|
||||
'qwen3-coder-plus',
|
||||
'glm-4.7',
|
||||
];
|
||||
|
||||
const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama']);
|
||||
|
||||
export interface ResolvePresetModelSelectionInput {
|
||||
presetKey: string;
|
||||
presetDefaultModel: string;
|
||||
presetModels: string[];
|
||||
valuesModel?: string;
|
||||
customModels?: string[];
|
||||
}
|
||||
|
||||
export interface ResolvePresetModelSelectionResult {
|
||||
model: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export interface ResolvePresetBaseURLInput {
|
||||
presetKey: string;
|
||||
presetDefaultBaseUrl: string;
|
||||
valuesBaseUrl?: string;
|
||||
}
|
||||
|
||||
export interface ResolvePresetTransportInput {
|
||||
presetBackendType: AIProviderType;
|
||||
presetFixedApiFormat?: string;
|
||||
valuesApiFormat?: string;
|
||||
}
|
||||
|
||||
export interface ResolvePresetTransportResult {
|
||||
type: AIProviderType;
|
||||
apiFormat?: string;
|
||||
}
|
||||
|
||||
export const getProviderHostname = (raw?: string): string => {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return new URL(raw).hostname.toLowerCase();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getProviderFingerprint = (raw?: string): string => {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, '').toLowerCase();
|
||||
return `${url.hostname.toLowerCase()}${normalizedPath}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const matchQwenPresetKey = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): string | null => {
|
||||
const fingerprint = getProviderFingerprint(provider.baseUrl);
|
||||
const bailianFingerprints = new Set([
|
||||
getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL),
|
||||
getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL),
|
||||
]);
|
||||
if (fingerprint !== '' && bailianFingerprints.has(fingerprint)) {
|
||||
return 'qwen-bailian';
|
||||
}
|
||||
|
||||
const codingPlanFingerprints = new Set([
|
||||
getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL),
|
||||
getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL),
|
||||
]);
|
||||
if (fingerprint !== '' && codingPlanFingerprints.has(fingerprint)) {
|
||||
return 'qwen-coding-plan';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolvePresetModelSelection = ({
|
||||
presetKey,
|
||||
presetDefaultModel,
|
||||
presetModels,
|
||||
valuesModel,
|
||||
customModels,
|
||||
}: ResolvePresetModelSelectionInput): ResolvePresetModelSelectionResult => {
|
||||
const isCustomLike = CUSTOM_LIKE_PRESET_KEYS.has(presetKey);
|
||||
const resolvedModels = isCustomLike ? (customModels || []) : presetModels;
|
||||
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
|
||||
return {
|
||||
models: resolvedModels,
|
||||
model: isCustomLike ? (valuesModel || fallbackModel) : (valuesModel || presetDefaultModel),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolvePresetBaseURL = ({
|
||||
presetKey,
|
||||
presetDefaultBaseUrl,
|
||||
valuesBaseUrl,
|
||||
}: ResolvePresetBaseURLInput): string => {
|
||||
if (CUSTOM_LIKE_PRESET_KEYS.has(presetKey)) {
|
||||
return valuesBaseUrl || presetDefaultBaseUrl;
|
||||
}
|
||||
return presetDefaultBaseUrl;
|
||||
};
|
||||
|
||||
export const resolvePresetTransport = ({
|
||||
presetBackendType,
|
||||
presetFixedApiFormat,
|
||||
valuesApiFormat,
|
||||
}: ResolvePresetTransportInput): ResolvePresetTransportResult => {
|
||||
if (presetFixedApiFormat) {
|
||||
return {
|
||||
type: presetBackendType,
|
||||
apiFormat: presetFixedApiFormat,
|
||||
};
|
||||
}
|
||||
|
||||
if (presetBackendType === 'custom') {
|
||||
return {
|
||||
type: presetBackendType,
|
||||
apiFormat: valuesApiFormat || 'openai',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: presetBackendType,
|
||||
apiFormat: undefined,
|
||||
};
|
||||
};
|
||||
56
frontend/src/utils/aiSettingsPresetLayout.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||
PROVIDER_PRESET_CARD_CONTENT_STYLE,
|
||||
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
|
||||
PROVIDER_PRESET_GRID_STYLE,
|
||||
PROVIDER_PRESET_CARD_TITLE_STYLE,
|
||||
} from './aiSettingsPresetLayout';
|
||||
|
||||
describe('ai settings preset layout', () => {
|
||||
it('uses a fixed grid auto row height so provider bubbles stay visually consistent across rows', () => {
|
||||
expect(PROVIDER_PRESET_GRID_STYLE).toMatchObject({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: 6,
|
||||
gridAutoRows: '96px',
|
||||
alignItems: 'stretch',
|
||||
});
|
||||
});
|
||||
|
||||
it('stretches each provider card to fill the row height', () => {
|
||||
expect(PROVIDER_PRESET_CARD_BASE_STYLE).toMatchObject({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
height: '100%',
|
||||
minHeight: '96px',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the text column compact instead of pinning the description to the bottom', () => {
|
||||
expect(PROVIDER_PRESET_CARD_CONTENT_STYLE).toMatchObject({
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
expect(PROVIDER_PRESET_CARD_DESCRIPTION_STYLE).toMatchObject({
|
||||
marginTop: 4,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
expect(PROVIDER_PRESET_CARD_TITLE_STYLE).toMatchObject({
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
});
|
||||
});
|
||||
47
frontend/src/utils/aiSettingsPresetLayout.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const PROVIDER_PRESET_CARD_HEIGHT = 96;
|
||||
|
||||
export const PROVIDER_PRESET_GRID_STYLE: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: 6,
|
||||
gridAutoRows: `${PROVIDER_PRESET_CARD_HEIGHT}px`,
|
||||
alignItems: 'stretch',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_BASE_STYLE: CSSProperties = {
|
||||
padding: '12px 14px',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
height: '100%',
|
||||
minHeight: `${PROVIDER_PRESET_CARD_HEIGHT}px`,
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_CONTENT_STYLE: CSSProperties = {
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_DESCRIPTION_STYLE: CSSProperties = {
|
||||
marginTop: 4,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export const PROVIDER_PRESET_CARD_TITLE_STYLE: CSSProperties = {
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
23
frontend/src/utils/appearance.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
|
||||
|
||||
describe('appearance helpers', () => {
|
||||
it('falls back to opaque non-blurred appearance when disabled', () => {
|
||||
expect(resolveAppearanceValues({ enabled: false, opacity: 0.3, blur: 12 })).toEqual({ opacity: 1, blur: 0 });
|
||||
});
|
||||
|
||||
it('preserves configured values when appearance is enabled', () => {
|
||||
expect(resolveAppearanceValues({ enabled: true, opacity: 0.72, blur: 9 })).toEqual({ opacity: 0.72, blur: 9 });
|
||||
});
|
||||
|
||||
it('caps opacity at full opacity upper bound', () => {
|
||||
expect(normalizeOpacityForPlatform(2)).toBe(1);
|
||||
});
|
||||
|
||||
it('never returns negative blur and formats blur filter correctly', () => {
|
||||
expect(normalizeBlurForPlatform(-4)).toBe(0);
|
||||
expect(blurToFilter(0)).toBeUndefined();
|
||||
expect(blurToFilter(8)).toBe('blur(8px)');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,22 @@ const WINDOWS_BLUR_FACTOR = 1.00;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
export interface AppearanceSettingsLike {
|
||||
enabled?: boolean;
|
||||
opacity?: number;
|
||||
blur?: number;
|
||||
}
|
||||
|
||||
export const resolveAppearanceValues = (appearance: AppearanceSettingsLike | undefined): { opacity: number; blur: number } => {
|
||||
if (!appearance || appearance.enabled !== false) {
|
||||
return {
|
||||
opacity: appearance?.opacity ?? DEFAULT_OPACITY,
|
||||
blur: appearance?.blur ?? 0,
|
||||
};
|
||||
}
|
||||
return { opacity: DEFAULT_OPACITY, blur: 0 };
|
||||
};
|
||||
|
||||
export const isMacLikePlatform = (): boolean => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false;
|
||||
|
||||
86
frontend/src/utils/dataSourceCapabilities.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ConnectionConfig } from '../types';
|
||||
|
||||
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver'> | null | undefined;
|
||||
|
||||
const normalizeDataSourceToken = (raw: string): string => {
|
||||
const normalized = String(raw || '').trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'doris':
|
||||
return 'diros';
|
||||
case 'postgresql':
|
||||
return 'postgres';
|
||||
case 'dm':
|
||||
return 'dameng';
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveDataSourceType = (config: ConnectionLike): string => {
|
||||
if (!config) return '';
|
||||
const type = normalizeDataSourceToken(String(config.type || ''));
|
||||
if (type === 'custom') {
|
||||
const driver = normalizeDataSourceToken(String(config.driver || ''));
|
||||
return driver || 'custom';
|
||||
}
|
||||
return type;
|
||||
};
|
||||
|
||||
const SQL_QUERY_EXPORT_TYPES = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'postgres',
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'sqlserver',
|
||||
'sqlite',
|
||||
'duckdb',
|
||||
'oracle',
|
||||
'dameng',
|
||||
'tdengine',
|
||||
'clickhouse',
|
||||
]);
|
||||
|
||||
const COPY_INSERT_TYPES = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'postgres',
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'sqlserver',
|
||||
'sqlite',
|
||||
'duckdb',
|
||||
'oracle',
|
||||
'dameng',
|
||||
'tdengine',
|
||||
'clickhouse',
|
||||
]);
|
||||
|
||||
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
|
||||
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
|
||||
|
||||
export type DataSourceCapabilities = {
|
||||
type: string;
|
||||
supportsQueryEditor: boolean;
|
||||
supportsSqlQueryExport: boolean;
|
||||
supportsCopyInsert: boolean;
|
||||
forceReadOnlyQueryResult: boolean;
|
||||
};
|
||||
|
||||
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
|
||||
const type = resolveDataSourceType(config);
|
||||
return {
|
||||
type,
|
||||
supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type),
|
||||
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
|
||||
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
|
||||
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
|
||||
};
|
||||
};
|
||||
|
||||
47
frontend/src/utils/macWindow.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getMacNativeTitlebarPaddingLeft,
|
||||
getMacNativeTitlebarPaddingRight,
|
||||
shouldHandleMacNativeFullscreenShortcut,
|
||||
shouldSuppressMacNativeEscapeExit,
|
||||
} from './macWindow';
|
||||
|
||||
describe('macWindow helpers', () => {
|
||||
it('uses compact padding when native controls are disabled', () => {
|
||||
expect(getMacNativeTitlebarPaddingLeft(1, false)).toBe(16);
|
||||
expect(getMacNativeTitlebarPaddingRight(1, false)).toBe(0);
|
||||
});
|
||||
|
||||
it('reserves traffic-light safe area when native controls are enabled', () => {
|
||||
expect(getMacNativeTitlebarPaddingLeft(1, true)).toBe(96);
|
||||
expect(getMacNativeTitlebarPaddingRight(1, true)).toBe(16);
|
||||
});
|
||||
|
||||
it('keeps minimum safe area under small ui scales', () => {
|
||||
expect(getMacNativeTitlebarPaddingLeft(0.5, true)).toBe(88);
|
||||
expect(getMacNativeTitlebarPaddingRight(0.5, true)).toBe(12);
|
||||
});
|
||||
|
||||
it('matches Control+Command+F only for mac native mode', () => {
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(true);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'F' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects conflicting modifiers and non-target keys', () => {
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: true, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: false, altKey: false, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(false, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, false, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'g' })).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses Escape only in mac native fullscreen mode', () => {
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: false })).toBe(true);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, false, { key: 'Escape', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, false, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(false, true, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Enter', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
42
frontend/src/utils/macWindow.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export const getMacNativeTitlebarPaddingLeft = (uiScale: number, enabled: boolean): number => {
|
||||
if (!enabled) {
|
||||
return Math.max(12, Math.round(16 * uiScale));
|
||||
}
|
||||
return Math.max(88, Math.round(96 * uiScale));
|
||||
};
|
||||
|
||||
export const getMacNativeTitlebarPaddingRight = (uiScale: number, enabled: boolean): number => {
|
||||
if (!enabled) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(12, Math.round(16 * uiScale));
|
||||
};
|
||||
|
||||
export const shouldHandleMacNativeFullscreenShortcut = (
|
||||
isMacRuntime: boolean,
|
||||
useNativeMacWindowControls: boolean,
|
||||
event: Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'key'>,
|
||||
): boolean => {
|
||||
if (!isMacRuntime || !useNativeMacWindowControls) {
|
||||
return false;
|
||||
}
|
||||
if (!event.ctrlKey || !event.metaKey || event.altKey) {
|
||||
return false;
|
||||
}
|
||||
return String(event.key || '').toLowerCase() === 'f';
|
||||
};
|
||||
|
||||
export const shouldSuppressMacNativeEscapeExit = (
|
||||
isMacRuntime: boolean,
|
||||
useNativeMacWindowControls: boolean,
|
||||
isFullscreen: boolean,
|
||||
event: Pick<KeyboardEvent, 'key' | 'defaultPrevented'>,
|
||||
): boolean => {
|
||||
if (!isMacRuntime || !useNativeMacWindowControls || !isFullscreen) {
|
||||
return false;
|
||||
}
|
||||
if (event.defaultPrevented) {
|
||||
return false;
|
||||
}
|
||||
return String(event.key || '') === 'Escape';
|
||||
};
|
||||
1026
frontend/src/utils/mongodb.ts
Normal file
21
frontend/src/utils/overlayWorkbenchTheme.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
|
||||
|
||||
describe('buildOverlayWorkbenchTheme', () => {
|
||||
it('builds dark theme tokens', () => {
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true);
|
||||
expect(darkTheme.isDark).toBe(true);
|
||||
expect(darkTheme.shellBg).toMatch(/rgba\(15, 15, 17,/);
|
||||
expect(darkTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/);
|
||||
expect(darkTheme.iconColor).toBe('#ffd666');
|
||||
});
|
||||
|
||||
it('builds light theme tokens', () => {
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
expect(lightTheme.isDark).toBe(false);
|
||||
expect(lightTheme.shellBg).toMatch(/rgba\(255,255,255,0\.98\)/);
|
||||
expect(lightTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/);
|
||||
expect(lightTheme.iconColor).toBe('#1677ff');
|
||||
});
|
||||
});
|
||||
59
frontend/src/utils/overlayWorkbenchTheme.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
type OverlayWorkbenchTheme = {
|
||||
isDark: boolean;
|
||||
shellBg: string;
|
||||
shellBorder: string;
|
||||
shellShadow: string;
|
||||
shellBackdropFilter: string;
|
||||
sectionBg: string;
|
||||
sectionBorder: string;
|
||||
mutedText: string;
|
||||
titleText: string;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
hoverBg: string;
|
||||
selectedBg: string;
|
||||
selectedText: string;
|
||||
divider: string;
|
||||
};
|
||||
|
||||
export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchTheme => {
|
||||
if (darkMode) {
|
||||
return {
|
||||
isDark: true,
|
||||
shellBg: 'linear-gradient(180deg, rgba(15, 15, 17, 0.96) 0%, rgba(11, 11, 13, 0.98) 100%)',
|
||||
shellBorder: '1px solid rgba(255,255,255,0.08)',
|
||||
shellShadow: '0 24px 56px rgba(0,0,0,0.34)',
|
||||
shellBackdropFilter: 'blur(18px)',
|
||||
sectionBg: 'rgba(255,255,255,0.03)',
|
||||
sectionBorder: '1px solid rgba(255,255,255,0.08)',
|
||||
mutedText: 'rgba(255,255,255,0.5)',
|
||||
titleText: '#f5f7ff',
|
||||
iconBg: 'rgba(255,214,102,0.12)',
|
||||
iconColor: '#ffd666',
|
||||
hoverBg: 'rgba(255,214,102,0.10)',
|
||||
selectedBg: 'rgba(255,214,102,0.14)',
|
||||
selectedText: '#ffd666',
|
||||
divider: 'rgba(255,255,255,0.08)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isDark: false,
|
||||
shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
shellBorder: '1px solid rgba(16,24,40,0.08)',
|
||||
shellShadow: '0 18px 42px rgba(15,23,42,0.12)',
|
||||
shellBackdropFilter: 'none',
|
||||
sectionBg: 'rgba(255,255,255,0.84)',
|
||||
sectionBorder: '1px solid rgba(16,24,40,0.08)',
|
||||
mutedText: 'rgba(16,24,40,0.55)',
|
||||
titleText: '#162033',
|
||||
iconBg: 'rgba(24,144,255,0.1)',
|
||||
iconColor: '#1677ff',
|
||||
hoverBg: 'rgba(24,144,255,0.08)',
|
||||
selectedBg: 'rgba(24,144,255,0.12)',
|
||||
selectedText: '#1677ff',
|
||||
divider: 'rgba(16,24,40,0.08)',
|
||||
};
|
||||
};
|
||||
|
||||
export type { OverlayWorkbenchTheme };
|
||||
267
frontend/src/utils/shortcuts.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
|
||||
export type ShortcutAction =
|
||||
| 'runQuery'
|
||||
| 'focusSidebarSearch'
|
||||
| 'newQueryTab'
|
||||
| 'toggleLogPanel'
|
||||
| 'toggleTheme'
|
||||
| 'openShortcutManager'
|
||||
| 'toggleMacFullscreen';
|
||||
|
||||
export interface ShortcutBinding {
|
||||
combo: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type ShortcutOptions = Record<ShortcutAction, ShortcutBinding>;
|
||||
|
||||
export interface ShortcutActionMeta {
|
||||
label: string;
|
||||
description: string;
|
||||
allowInEditable?: boolean;
|
||||
platformOnly?: 'mac';
|
||||
}
|
||||
|
||||
const MODIFIER_ORDER = ['Ctrl', 'Meta', 'Alt', 'Shift'] as const;
|
||||
const MODIFIER_SET = new Set(MODIFIER_ORDER);
|
||||
|
||||
const KEY_ALIASES: Record<string, string> = {
|
||||
control: 'Ctrl',
|
||||
ctrl: 'Ctrl',
|
||||
command: 'Meta',
|
||||
cmd: 'Meta',
|
||||
meta: 'Meta',
|
||||
option: 'Alt',
|
||||
alt: 'Alt',
|
||||
shift: 'Shift',
|
||||
escape: 'Esc',
|
||||
esc: 'Esc',
|
||||
return: 'Enter',
|
||||
enter: 'Enter',
|
||||
tab: 'Tab',
|
||||
space: 'Space',
|
||||
' ': 'Space',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Delete',
|
||||
del: 'Delete',
|
||||
arrowup: 'Up',
|
||||
up: 'Up',
|
||||
arrowdown: 'Down',
|
||||
down: 'Down',
|
||||
arrowleft: 'Left',
|
||||
left: 'Left',
|
||||
arrowright: 'Right',
|
||||
right: 'Right',
|
||||
pagedown: 'PageDown',
|
||||
pageup: 'PageUp',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
insert: 'Insert',
|
||||
',': ',',
|
||||
'.': '.',
|
||||
'/': '/',
|
||||
';': ';',
|
||||
"'": "'",
|
||||
'[': '[',
|
||||
']': ']',
|
||||
'\\': '\\',
|
||||
'-': '-',
|
||||
'=': '=',
|
||||
'`': '`',
|
||||
};
|
||||
|
||||
export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
|
||||
'runQuery',
|
||||
'focusSidebarSearch',
|
||||
'newQueryTab',
|
||||
'toggleLogPanel',
|
||||
'toggleTheme',
|
||||
'openShortcutManager',
|
||||
'toggleMacFullscreen',
|
||||
];
|
||||
|
||||
export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> = {
|
||||
runQuery: {
|
||||
label: '执行 SQL',
|
||||
description: '在当前查询页执行 SQL',
|
||||
},
|
||||
focusSidebarSearch: {
|
||||
label: '聚焦侧边栏搜索',
|
||||
description: '定位到左侧连接树搜索框',
|
||||
allowInEditable: true,
|
||||
},
|
||||
newQueryTab: {
|
||||
label: '新建查询页',
|
||||
description: '创建一个新的 SQL 查询标签页',
|
||||
},
|
||||
toggleLogPanel: {
|
||||
label: '切换日志面板',
|
||||
description: '打开或关闭 SQL 执行日志面板',
|
||||
},
|
||||
toggleTheme: {
|
||||
label: '切换主题',
|
||||
description: '在亮色和暗色主题之间切换',
|
||||
},
|
||||
openShortcutManager: {
|
||||
label: '打开快捷键管理',
|
||||
description: '打开快捷键设置面板',
|
||||
allowInEditable: true,
|
||||
},
|
||||
toggleMacFullscreen: {
|
||||
label: '切换原生全屏',
|
||||
description: 'macOS 原生窗口控制模式下的全屏切换(⌃⌘F)',
|
||||
platformOnly: 'mac',
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
|
||||
runQuery: { combo: 'Ctrl+Shift+R', enabled: true },
|
||||
focusSidebarSearch: { combo: 'Ctrl+F', enabled: true },
|
||||
newQueryTab: { combo: 'Ctrl+Shift+N', enabled: true },
|
||||
toggleLogPanel: { combo: 'Ctrl+Shift+L', enabled: true },
|
||||
toggleTheme: { combo: 'Ctrl+Shift+D', enabled: true },
|
||||
openShortcutManager: { combo: 'Ctrl+,', enabled: true },
|
||||
toggleMacFullscreen: { combo: 'Ctrl+Meta+F', enabled: true },
|
||||
};
|
||||
|
||||
const normalizeKeyToken = (value: string): string => {
|
||||
const token = String(value || '').trim();
|
||||
if (!token) return '';
|
||||
const alias = KEY_ALIASES[token.toLowerCase()];
|
||||
if (alias) return alias;
|
||||
if (/^f([1-9]|1[0-2])$/i.test(token)) {
|
||||
return token.toUpperCase();
|
||||
}
|
||||
if (token.length === 1) {
|
||||
return token === '+' ? '+' : token.toUpperCase();
|
||||
}
|
||||
return token.length > 1 ? token[0].toUpperCase() + token.slice(1).toLowerCase() : token;
|
||||
};
|
||||
|
||||
export const normalizeShortcutCombo = (combo: string): string => {
|
||||
const raw = String(combo || '').trim();
|
||||
if (!raw) return '';
|
||||
|
||||
const pieces = raw
|
||||
.split('+')
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const modifiers: string[] = [];
|
||||
let key = '';
|
||||
|
||||
pieces.forEach((part) => {
|
||||
const normalized = normalizeKeyToken(part);
|
||||
if (!normalized) return;
|
||||
if (MODIFIER_SET.has(normalized as typeof MODIFIER_ORDER[number])) {
|
||||
if (!modifiers.includes(normalized)) {
|
||||
modifiers.push(normalized);
|
||||
}
|
||||
return;
|
||||
}
|
||||
key = normalized;
|
||||
});
|
||||
|
||||
modifiers.sort((a, b) => MODIFIER_ORDER.indexOf(a as typeof MODIFIER_ORDER[number]) - MODIFIER_ORDER.indexOf(b as typeof MODIFIER_ORDER[number]));
|
||||
if (!key) {
|
||||
return modifiers.join('+');
|
||||
}
|
||||
return [...modifiers, key].join('+');
|
||||
};
|
||||
|
||||
const normalizeKeyboardKey = (key: string): string => {
|
||||
const token = String(key || '').trim();
|
||||
if (!token) return '';
|
||||
const alias = KEY_ALIASES[token.toLowerCase()];
|
||||
if (alias) return alias;
|
||||
if (token.length === 1) {
|
||||
if (token === ' ') return 'Space';
|
||||
return token.toUpperCase();
|
||||
}
|
||||
if (/^f([1-9]|1[0-2])$/i.test(token)) {
|
||||
return token.toUpperCase();
|
||||
}
|
||||
return token.length > 1 ? token[0].toUpperCase() + token.slice(1) : token;
|
||||
};
|
||||
|
||||
export const eventToShortcut = (event: KeyboardEvent | ReactKeyboardEvent): string => {
|
||||
const key = normalizeKeyboardKey(event.key);
|
||||
if (!key || MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const modifiers: string[] = [];
|
||||
if (event.ctrlKey) modifiers.push('Ctrl');
|
||||
if (event.metaKey) modifiers.push('Meta');
|
||||
if (event.altKey) modifiers.push('Alt');
|
||||
if (event.shiftKey) modifiers.push('Shift');
|
||||
|
||||
return normalizeShortcutCombo([...modifiers, key].join('+'));
|
||||
};
|
||||
|
||||
export const isShortcutMatch = (event: KeyboardEvent | ReactKeyboardEvent, combo: string): boolean => {
|
||||
const expected = normalizeShortcutCombo(combo);
|
||||
if (!expected) return false;
|
||||
const actual = eventToShortcut(event);
|
||||
return actual === expected;
|
||||
};
|
||||
|
||||
export const hasModifierKey = (combo: string): boolean => {
|
||||
const normalized = normalizeShortcutCombo(combo);
|
||||
if (!normalized) return false;
|
||||
return normalized.split('+').some(part => MODIFIER_SET.has(part as typeof MODIFIER_ORDER[number]));
|
||||
};
|
||||
|
||||
export const cloneShortcutOptions = (value: ShortcutOptions): ShortcutOptions => {
|
||||
return SHORTCUT_ACTION_ORDER.reduce((acc, action) => {
|
||||
acc[action] = {
|
||||
combo: normalizeShortcutCombo(value[action]?.combo || DEFAULT_SHORTCUT_OPTIONS[action].combo),
|
||||
enabled: value[action]?.enabled !== false,
|
||||
};
|
||||
return acc;
|
||||
}, {} as ShortcutOptions);
|
||||
};
|
||||
|
||||
export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const defaults = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS);
|
||||
|
||||
SHORTCUT_ACTION_ORDER.forEach((action) => {
|
||||
const actionRaw = raw[action];
|
||||
if (!actionRaw || typeof actionRaw !== 'object') {
|
||||
return;
|
||||
}
|
||||
const binding = actionRaw as Record<string, unknown>;
|
||||
const combo = normalizeShortcutCombo(String(binding.combo || defaults[action].combo));
|
||||
defaults[action] = {
|
||||
combo: combo || defaults[action].combo,
|
||||
enabled: binding.enabled === false ? false : true,
|
||||
};
|
||||
});
|
||||
|
||||
return defaults;
|
||||
};
|
||||
|
||||
export const isEditableElement = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (target.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
||||
return true;
|
||||
}
|
||||
if (target.closest('.monaco-editor, .monaco-inputbox, .ant-select, .ant-picker, .ant-input')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getShortcutDisplay = (combo: string): string => {
|
||||
const normalized = normalizeShortcutCombo(combo);
|
||||
return normalized || '-';
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type FilterCondition = {
|
||||
id?: number;
|
||||
enabled?: boolean;
|
||||
logic?: 'AND' | 'OR';
|
||||
column?: string;
|
||||
op?: string;
|
||||
value?: string;
|
||||
@@ -36,7 +37,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
|
||||
if (!raw) return raw;
|
||||
const dbTypeLower = (dbType || '').toLowerCase();
|
||||
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine') {
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
|
||||
return `\`${raw.replace(/`/g, '``')}\``;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,11 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
|
||||
return raw;
|
||||
}
|
||||
|
||||
// SQL Server 使用 [bracket] 标识符
|
||||
if (dbTypeLower === 'sqlserver' || dbTypeLower === 'mssql') {
|
||||
return `[${raw.replace(/]/g, ']]')}]`;
|
||||
}
|
||||
|
||||
// 其他数据库默认加双引号
|
||||
return `"${raw.replace(/"/g, '""')}"`;
|
||||
};
|
||||
@@ -63,10 +69,13 @@ export const quoteQualifiedIdent = (dbType: string, ident: string) => {
|
||||
|
||||
export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''");
|
||||
|
||||
type SortInfo = {
|
||||
type SortInfoItem = {
|
||||
columnKey?: string;
|
||||
order?: string;
|
||||
} | null | undefined;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type SortInfo = SortInfoItem | SortInfoItem[] | null | undefined;
|
||||
|
||||
// 为排序查询按库类型注入 sort_buffer 提升参数(仅影响当前语句)。
|
||||
// MySQL: 使用 Optimizer Hint `SET_VAR`。
|
||||
@@ -95,17 +104,50 @@ export const withSortBufferTuningSQL = (
|
||||
return rawSql;
|
||||
};
|
||||
|
||||
/** 将 SortInfo(单字段或多字段)标准化为 SortInfoItem 数组 */
|
||||
const normalizeSortInfoItems = (sortInfo: SortInfo): SortInfoItem[] => {
|
||||
if (!sortInfo) return [];
|
||||
if (Array.isArray(sortInfo)) return sortInfo;
|
||||
return [sortInfo];
|
||||
};
|
||||
|
||||
/** 判断 SortInfo 中是否存在至少一个有效排序 */
|
||||
export const hasExplicitSort = (sortInfo: SortInfo): boolean => {
|
||||
const items = normalizeSortInfoItems(sortInfo);
|
||||
return items.some(item => {
|
||||
if (item?.enabled === false) return false;
|
||||
const col = String(item?.columnKey || '').trim();
|
||||
const order = String(item?.order || '');
|
||||
return !!col && (order === 'ascend' || order === 'descend');
|
||||
});
|
||||
};
|
||||
|
||||
export const buildOrderBySQL = (
|
||||
dbType: string,
|
||||
sortInfo: SortInfo,
|
||||
fallbackColumns: string[] = [],
|
||||
) => {
|
||||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||||
const sortColumn = normalizeIdentPart(String(sortInfo?.columnKey || ''));
|
||||
const sortOrder = String(sortInfo?.order || '');
|
||||
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
|
||||
if (sortColumn && direction) {
|
||||
return ` ORDER BY ${quoteIdentPart(dbType, sortColumn)} ${direction}`;
|
||||
const items = normalizeSortInfoItems(sortInfo);
|
||||
const seen = new Set<string>();
|
||||
const sortParts: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item?.enabled === false) continue;
|
||||
const sortColumn = normalizeIdentPart(String(item?.columnKey || ''));
|
||||
const sortOrder = String(item?.order || '');
|
||||
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
|
||||
if (sortColumn && direction) {
|
||||
const key = sortColumn.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
sortParts.push(`${quoteIdentPart(dbType, sortColumn)} ${direction}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sortParts.length > 0) {
|
||||
return ` ORDER BY ${sortParts.join(', ')}`;
|
||||
}
|
||||
|
||||
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY(即使按主键)可能触发 filesort,
|
||||
@@ -115,7 +157,6 @@ export const buildOrderBySQL = (
|
||||
return '';
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stableColumns = (fallbackColumns || [])
|
||||
.map((col) => normalizeIdentPart(String(col || '')))
|
||||
.filter((col) => {
|
||||
@@ -133,6 +174,42 @@ export const buildOrderBySQL = (
|
||||
return '';
|
||||
};
|
||||
|
||||
export const buildPaginatedSelectSQL = (
|
||||
dbType: string,
|
||||
baseSql: string,
|
||||
orderBySQL: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
) => {
|
||||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||||
const safeLimit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
const safeOffset = Math.max(0, Math.floor(Number(offset) || 0));
|
||||
const base = String(baseSql || '').trim();
|
||||
const orderBy = String(orderBySQL || '');
|
||||
|
||||
if (!base || safeLimit <= 0) {
|
||||
return `${base}${orderBy}`;
|
||||
}
|
||||
|
||||
switch (normalizedType) {
|
||||
case 'oracle': {
|
||||
const orderedSql = `${base}${orderBy}`;
|
||||
const upperBound = safeOffset + safeLimit;
|
||||
if (safeOffset <= 0) {
|
||||
return `SELECT * FROM (${orderedSql}) WHERE ROWNUM <= ${upperBound}`;
|
||||
}
|
||||
return `SELECT * FROM (SELECT "__gonavi_page__".*, ROWNUM "__gonavi_rn__" FROM (${orderedSql}) "__gonavi_page__" WHERE ROWNUM <= ${upperBound}) WHERE "__gonavi_rn__" > ${safeOffset}`;
|
||||
}
|
||||
case 'sqlserver':
|
||||
case 'mssql': {
|
||||
const effectiveOrderBy = orderBy.trim() ? orderBy : ' ORDER BY (SELECT NULL)';
|
||||
return `${base}${effectiveOrderBy} OFFSET ${safeOffset} ROWS FETCH NEXT ${safeLimit} ROWS ONLY`;
|
||||
}
|
||||
default:
|
||||
return `${base}${orderBy} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseListValues = (val: string) => {
|
||||
const raw = (val || '').trim();
|
||||
if (!raw) return [];
|
||||
@@ -142,8 +219,12 @@ export const parseListValues = (val: string) => {
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const normalizeConditionLogic = (logic: unknown): 'AND' | 'OR' => {
|
||||
return String(logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND';
|
||||
};
|
||||
|
||||
export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) => {
|
||||
const whereParts: string[] = [];
|
||||
const whereParts: Array<{ expr: string; logic: 'AND' | 'OR' }> = [];
|
||||
|
||||
(conditions || []).forEach((cond) => {
|
||||
if (cond?.enabled === false) return;
|
||||
@@ -152,10 +233,17 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
|
||||
const column = (cond?.column || '').trim();
|
||||
const value = (cond?.value ?? '').toString();
|
||||
const value2 = (cond?.value2 ?? '').toString();
|
||||
const logic = normalizeConditionLogic(cond?.logic);
|
||||
|
||||
const appendWherePart = (expr: string) => {
|
||||
const normalizedExpr = String(expr || '').trim();
|
||||
if (!normalizedExpr) return;
|
||||
whereParts.push({ expr: normalizedExpr, logic });
|
||||
};
|
||||
|
||||
if (op === 'CUSTOM') {
|
||||
const expr = value.trim();
|
||||
if (expr) whereParts.push(`(${expr})`);
|
||||
if (expr) appendWherePart(`(${expr})`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -165,80 +253,80 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
|
||||
|
||||
switch (op) {
|
||||
case 'IS_NULL':
|
||||
whereParts.push(`${col} IS NULL`);
|
||||
appendWherePart(`${col} IS NULL`);
|
||||
return;
|
||||
case 'IS_NOT_NULL':
|
||||
whereParts.push(`${col} IS NOT NULL`);
|
||||
appendWherePart(`${col} IS NOT NULL`);
|
||||
return;
|
||||
case 'IS_EMPTY':
|
||||
// 兼容:空值通常理解为 NULL 或空字符串
|
||||
whereParts.push(`(${col} IS NULL OR ${col} = '')`);
|
||||
appendWherePart(`(${col} IS NULL OR ${col} = '')`);
|
||||
return;
|
||||
case 'IS_NOT_EMPTY':
|
||||
whereParts.push(`(${col} IS NOT NULL AND ${col} <> '')`);
|
||||
appendWherePart(`(${col} IS NOT NULL AND ${col} <> '')`);
|
||||
return;
|
||||
case 'BETWEEN': {
|
||||
const v1 = value.trim();
|
||||
const v2 = value2.trim();
|
||||
if (!v1 || !v2) return;
|
||||
whereParts.push(`${col} BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
|
||||
appendWherePart(`${col} BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
|
||||
return;
|
||||
}
|
||||
case 'NOT_BETWEEN': {
|
||||
const v1 = value.trim();
|
||||
const v2 = value2.trim();
|
||||
if (!v1 || !v2) return;
|
||||
whereParts.push(`${col} NOT BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
|
||||
appendWherePart(`${col} NOT BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
|
||||
return;
|
||||
}
|
||||
case 'IN': {
|
||||
const items = parseListValues(value);
|
||||
if (items.length === 0) return;
|
||||
const list = items.map(v => `'${escapeLiteral(v)}'`).join(', ');
|
||||
whereParts.push(`${col} IN (${list})`);
|
||||
appendWherePart(`${col} IN (${list})`);
|
||||
return;
|
||||
}
|
||||
case 'NOT_IN': {
|
||||
const items = parseListValues(value);
|
||||
if (items.length === 0) return;
|
||||
const list = items.map(v => `'${escapeLiteral(v)}'`).join(', ');
|
||||
whereParts.push(`${col} NOT IN (${list})`);
|
||||
appendWherePart(`${col} NOT IN (${list})`);
|
||||
return;
|
||||
}
|
||||
case 'CONTAINS': {
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
whereParts.push(`${col} LIKE '%${escapeLiteral(v)}%'`);
|
||||
appendWherePart(`${col} LIKE '%${escapeLiteral(v)}%'`);
|
||||
return;
|
||||
}
|
||||
case 'NOT_CONTAINS': {
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
whereParts.push(`${col} NOT LIKE '%${escapeLiteral(v)}%'`);
|
||||
appendWherePart(`${col} NOT LIKE '%${escapeLiteral(v)}%'`);
|
||||
return;
|
||||
}
|
||||
case 'STARTS_WITH': {
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
whereParts.push(`${col} LIKE '${escapeLiteral(v)}%'`);
|
||||
appendWherePart(`${col} LIKE '${escapeLiteral(v)}%'`);
|
||||
return;
|
||||
}
|
||||
case 'NOT_STARTS_WITH': {
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
whereParts.push(`${col} NOT LIKE '${escapeLiteral(v)}%'`);
|
||||
appendWherePart(`${col} NOT LIKE '${escapeLiteral(v)}%'`);
|
||||
return;
|
||||
}
|
||||
case 'ENDS_WITH': {
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
whereParts.push(`${col} LIKE '%${escapeLiteral(v)}'`);
|
||||
appendWherePart(`${col} LIKE '%${escapeLiteral(v)}'`);
|
||||
return;
|
||||
}
|
||||
case 'NOT_ENDS_WITH': {
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
whereParts.push(`${col} NOT LIKE '%${escapeLiteral(v)}'`);
|
||||
appendWherePart(`${col} NOT LIKE '%${escapeLiteral(v)}'`);
|
||||
return;
|
||||
}
|
||||
case '=':
|
||||
@@ -249,7 +337,7 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
|
||||
case '>=': {
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
whereParts.push(`${col} ${op} '${escapeLiteral(v)}'`);
|
||||
appendWherePart(`${col} ${op} '${escapeLiteral(v)}'`);
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
@@ -257,16 +345,23 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
|
||||
if (op.toUpperCase() === 'LIKE') {
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
whereParts.push(`${col} LIKE '%${escapeLiteral(v)}%'`);
|
||||
appendWherePart(`${col} LIKE '%${escapeLiteral(v)}%'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
whereParts.push(`${col} ${op} '${escapeLiteral(v)}'`);
|
||||
appendWherePart(`${col} ${op} '${escapeLiteral(v)}'`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
if (whereParts.length === 0) return '';
|
||||
|
||||
let whereExpr = `(${whereParts[0].expr})`;
|
||||
for (let i = 1; i < whereParts.length; i++) {
|
||||
const part = whereParts[i];
|
||||
whereExpr = `(${whereExpr} ${part.logic} (${part.expr}))`;
|
||||
}
|
||||
return `WHERE ${whereExpr}`;
|
||||
};
|
||||
|
||||
26
frontend/src/utils/startupReadiness.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getConnectionWorkbenchState } from './startupReadiness';
|
||||
|
||||
describe('startup readiness helpers', () => {
|
||||
it('blocks sidebar interactions before local store hydration completes', () => {
|
||||
expect(getConnectionWorkbenchState(false, false)).toEqual({
|
||||
ready: false,
|
||||
message: '正在加载本地配置...',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps sidebar blocked until initial global proxy sync finishes', () => {
|
||||
expect(getConnectionWorkbenchState(true, false)).toEqual({
|
||||
ready: false,
|
||||
message: '正在同步全局代理配置...',
|
||||
});
|
||||
});
|
||||
|
||||
it('unblocks sidebar after startup configuration is fully applied', () => {
|
||||
expect(getConnectionWorkbenchState(true, true)).toEqual({
|
||||
ready: true,
|
||||
message: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
26
frontend/src/utils/startupReadiness.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface ConnectionWorkbenchState {
|
||||
ready: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function getConnectionWorkbenchState(
|
||||
isStoreHydrated: boolean,
|
||||
hasAppliedInitialGlobalProxy: boolean
|
||||
): ConnectionWorkbenchState {
|
||||
if (!isStoreHydrated) {
|
||||
return {
|
||||
ready: false,
|
||||
message: '正在加载本地配置...',
|
||||
};
|
||||
}
|
||||
if (!hasAppliedInitialGlobalProxy) {
|
||||
return {
|
||||
ready: false,
|
||||
message: '正在同步全局代理配置...',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
2
frontend/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist', // Standard Wails output directory
|
||||
emptyOutDir: true,
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
|
||||
43
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
Executable file
@@ -0,0 +1,43 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {ai} from '../models';
|
||||
|
||||
export function AIChatCancel(arg1:string):Promise<void>;
|
||||
|
||||
export function AIChatSend(arg1:Array<ai.Message>,arg2:Array<ai.Tool>):Promise<Record<string, any>>;
|
||||
|
||||
export function AIChatStream(arg1:string,arg2:Array<ai.Message>,arg3:Array<ai.Tool>):Promise<void>;
|
||||
|
||||
export function AICheckSQL(arg1:string):Promise<ai.SafetyResult>;
|
||||
|
||||
export function AIDeleteProvider(arg1:string):Promise<void>;
|
||||
|
||||
export function AIDeleteSession(arg1:string):Promise<void>;
|
||||
|
||||
export function AIGetActiveProvider():Promise<string>;
|
||||
|
||||
export function AIGetBuiltinPrompts():Promise<Record<string, string>>;
|
||||
|
||||
export function AIGetContextLevel():Promise<string>;
|
||||
|
||||
export function AIGetProviders():Promise<Array<ai.ProviderConfig>>;
|
||||
|
||||
export function AIGetSafetyLevel():Promise<string>;
|
||||
|
||||
export function AIGetSessions():Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function AIListModels():Promise<Record<string, any>>;
|
||||
|
||||
export function AILoadSession(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
export function AISaveProvider(arg1:ai.ProviderConfig):Promise<void>;
|
||||
|
||||
export function AISaveSession(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
|
||||
|
||||
export function AISetActiveProvider(arg1:string):Promise<void>;
|
||||
|
||||
export function AISetContextLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function AISetSafetyLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function AITestProvider(arg1:ai.ProviderConfig):Promise<Record<string, any>>;
|
||||
83
frontend/wailsjs/go/aiservice/Service.js
Executable file
@@ -0,0 +1,83 @@
|
||||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function AIChatCancel(arg1) {
|
||||
return window['go']['aiservice']['Service']['AIChatCancel'](arg1);
|
||||
}
|
||||
|
||||
export function AIChatSend(arg1, arg2) {
|
||||
return window['go']['aiservice']['Service']['AIChatSend'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AIChatStream(arg1, arg2, arg3) {
|
||||
return window['go']['aiservice']['Service']['AIChatStream'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function AICheckSQL(arg1) {
|
||||
return window['go']['aiservice']['Service']['AICheckSQL'](arg1);
|
||||
}
|
||||
|
||||
export function AIDeleteProvider(arg1) {
|
||||
return window['go']['aiservice']['Service']['AIDeleteProvider'](arg1);
|
||||
}
|
||||
|
||||
export function AIDeleteSession(arg1) {
|
||||
return window['go']['aiservice']['Service']['AIDeleteSession'](arg1);
|
||||
}
|
||||
|
||||
export function AIGetActiveProvider() {
|
||||
return window['go']['aiservice']['Service']['AIGetActiveProvider']();
|
||||
}
|
||||
|
||||
export function AIGetBuiltinPrompts() {
|
||||
return window['go']['aiservice']['Service']['AIGetBuiltinPrompts']();
|
||||
}
|
||||
|
||||
export function AIGetContextLevel() {
|
||||
return window['go']['aiservice']['Service']['AIGetContextLevel']();
|
||||
}
|
||||
|
||||
export function AIGetProviders() {
|
||||
return window['go']['aiservice']['Service']['AIGetProviders']();
|
||||
}
|
||||
|
||||
export function AIGetSafetyLevel() {
|
||||
return window['go']['aiservice']['Service']['AIGetSafetyLevel']();
|
||||
}
|
||||
|
||||
export function AIGetSessions() {
|
||||
return window['go']['aiservice']['Service']['AIGetSessions']();
|
||||
}
|
||||
|
||||
export function AIListModels() {
|
||||
return window['go']['aiservice']['Service']['AIListModels']();
|
||||
}
|
||||
|
||||
export function AILoadSession(arg1) {
|
||||
return window['go']['aiservice']['Service']['AILoadSession'](arg1);
|
||||
}
|
||||
|
||||
export function AISaveProvider(arg1) {
|
||||
return window['go']['aiservice']['Service']['AISaveProvider'](arg1);
|
||||
}
|
||||
|
||||
export function AISaveSession(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['aiservice']['Service']['AISaveSession'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function AISetActiveProvider(arg1) {
|
||||
return window['go']['aiservice']['Service']['AISetActiveProvider'](arg1);
|
||||
}
|
||||
|
||||
export function AISetContextLevel(arg1) {
|
||||
return window['go']['aiservice']['Service']['AISetContextLevel'](arg1);
|
||||
}
|
||||
|
||||
export function AISetSafetyLevel(arg1) {
|
||||
return window['go']['aiservice']['Service']['AISetSafetyLevel'](arg1);
|
||||
}
|
||||
|
||||
export function AITestProvider(arg1) {
|
||||
return window['go']['aiservice']['Service']['AITestProvider'](arg1);
|
||||
}
|
||||
32
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -6,12 +6,18 @@ import {redis} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
|
||||
export function CancelQuery(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function CancelSQLFileExecution(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
@@ -32,6 +38,12 @@ export function DBGetTriggers(arg1:connection.ConnectionConfig,arg2:string,arg3:
|
||||
|
||||
export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBQueryIsolated(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBQueryMulti(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSync(arg1:sync.SyncConfig):Promise<sync.SyncResult>;
|
||||
@@ -52,6 +64,8 @@ export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:stri
|
||||
|
||||
export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
|
||||
@@ -64,6 +78,8 @@ export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string
|
||||
|
||||
export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>,arg4:boolean):Promise<connection.QueryResult>;
|
||||
|
||||
export function GenerateQueryID():Promise<string>;
|
||||
|
||||
export function GetAppInfo():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
@@ -72,6 +88,8 @@ export function GetDriverVersionList(arg1:string,arg2:string):Promise<connection
|
||||
|
||||
export function GetDriverVersionPackageSize(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function GetGlobalProxyConfig():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportConfigFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
@@ -94,6 +112,8 @@ export function MySQLQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:str
|
||||
|
||||
export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenDownloadedUpdateDirectory():Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -114,13 +134,15 @@ export function RedisGetServerInfo(arg1:connection.ConnectionConfig):Promise<con
|
||||
|
||||
export function RedisGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisKeyExists(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisRenameKey(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:number):Promise<connection.QueryResult>;
|
||||
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:any,arg4:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisSelectDB(arg1:connection.ConnectionConfig,arg2:number):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -158,12 +180,20 @@ export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise
|
||||
|
||||
export function ResolveDriverRepositoryURL(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDatabaseFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverPackageDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -6,6 +6,14 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function CancelQuery(arg1) {
|
||||
return window['go']['app']['App']['CancelQuery'](arg1);
|
||||
}
|
||||
|
||||
export function CancelSQLFileExecution(arg1) {
|
||||
return window['go']['app']['App']['CancelSQLFileExecution'](arg1);
|
||||
}
|
||||
|
||||
export function CheckDriverNetworkStatus() {
|
||||
return window['go']['app']['App']['CheckDriverNetworkStatus']();
|
||||
}
|
||||
@@ -18,6 +26,10 @@ export function ConfigureDriverRuntimeDirectory(arg1) {
|
||||
return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function ConfigureGlobalProxy(arg1, arg2) {
|
||||
return window['go']['app']['App']['ConfigureGlobalProxy'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CreateDatabase(arg1, arg2) {
|
||||
return window['go']['app']['App']['CreateDatabase'](arg1, arg2);
|
||||
}
|
||||
@@ -58,6 +70,18 @@ export function DBQuery(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DBQuery'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DBQueryIsolated(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DBQueryIsolated'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DBQueryMulti(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DBQueryMulti'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function DBQueryWithCancel(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DBQueryWithCancel'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function DBShowCreateTable(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DBShowCreateTable'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -98,6 +122,10 @@ export function DropView(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DropView'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ExecuteSQLFile(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExecuteSQLFile'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ExportData(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -122,6 +150,10 @@ export function ExportTablesSQL(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function GenerateQueryID() {
|
||||
return window['go']['app']['App']['GenerateQueryID']();
|
||||
}
|
||||
|
||||
export function GetAppInfo() {
|
||||
return window['go']['app']['App']['GetAppInfo']();
|
||||
}
|
||||
@@ -138,6 +170,10 @@ export function GetDriverVersionPackageSize(arg1, arg2) {
|
||||
return window['go']['app']['App']['GetDriverVersionPackageSize'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetGlobalProxyConfig() {
|
||||
return window['go']['app']['App']['GetGlobalProxyConfig']();
|
||||
}
|
||||
|
||||
export function ImportConfigFile() {
|
||||
return window['go']['app']['App']['ImportConfigFile']();
|
||||
}
|
||||
@@ -182,6 +218,10 @@ export function MySQLShowCreateTable(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['MySQLShowCreateTable'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function OpenDownloadedUpdateDirectory() {
|
||||
return window['go']['app']['App']['OpenDownloadedUpdateDirectory']();
|
||||
}
|
||||
|
||||
export function OpenSQLFile() {
|
||||
return window['go']['app']['App']['OpenSQLFile']();
|
||||
}
|
||||
@@ -222,6 +262,10 @@ export function RedisGetValue(arg1, arg2) {
|
||||
return window['go']['app']['App']['RedisGetValue'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function RedisKeyExists(arg1, arg2) {
|
||||
return window['go']['app']['App']['RedisKeyExists'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function RedisListPush(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['RedisListPush'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -310,10 +354,18 @@ export function ResolveDriverRepositoryURL(arg1) {
|
||||
return window['go']['app']['App']['ResolveDriverRepositoryURL'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDatabaseFile(arg1, arg2) {
|
||||
return window['go']['app']['App']['SelectDatabaseFile'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1) {
|
||||
return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDriverPackageDirectory(arg1) {
|
||||
return window['go']['app']['App']['SelectDriverPackageDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDriverPackageFile(arg1) {
|
||||
return window['go']['app']['App']['SelectDriverPackageFile'](arg1);
|
||||
}
|
||||
@@ -322,6 +374,10 @@ export function SelectSSHKeyFile(arg1) {
|
||||
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
|
||||
}
|
||||
|
||||
export function SetMacNativeWindowControls(arg1) {
|
||||
return window['go']['app']['App']['SetMacNativeWindowControls'](arg1);
|
||||
}
|
||||
|
||||
export function SetWindowTranslucency(arg1, arg2) {
|
||||
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
|
||||
}
|
||||
@@ -329,3 +385,7 @@ export function SetWindowTranslucency(arg1, arg2) {
|
||||
export function TestConnection(arg1) {
|
||||
return window['go']['app']['App']['TestConnection'](arg1);
|
||||
}
|
||||
|
||||
export function TruncateTables(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['TruncateTables'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,180 @@
|
||||
export namespace ai {
|
||||
|
||||
export class ToolCall {
|
||||
id: string;
|
||||
type: string;
|
||||
// Go type: struct { Name string "json:\"name\""; Arguments string "json:\"arguments\"" }
|
||||
function: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ToolCall(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.type = source["type"];
|
||||
this.function = this.convertValues(source["function"], Object);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class Message {
|
||||
role: string;
|
||||
content: string;
|
||||
images?: string[];
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Message(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.role = source["role"];
|
||||
this.content = source["content"];
|
||||
this.images = source["images"];
|
||||
this.tool_call_id = source["tool_call_id"];
|
||||
this.tool_calls = this.convertValues(source["tool_calls"], ToolCall);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class ProviderConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
models?: string[];
|
||||
apiFormat?: string;
|
||||
headers?: Record<string, string>;
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.type = source["type"];
|
||||
this.name = source["name"];
|
||||
this.apiKey = source["apiKey"];
|
||||
this.baseUrl = source["baseUrl"];
|
||||
this.model = source["model"];
|
||||
this.models = source["models"];
|
||||
this.apiFormat = source["apiFormat"];
|
||||
this.headers = source["headers"];
|
||||
this.maxTokens = source["maxTokens"];
|
||||
this.temperature = source["temperature"];
|
||||
}
|
||||
}
|
||||
export class SafetyResult {
|
||||
allowed: boolean;
|
||||
operationType: string;
|
||||
requiresConfirm: boolean;
|
||||
warningMessage?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SafetyResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.allowed = source["allowed"];
|
||||
this.operationType = source["operationType"];
|
||||
this.requiresConfirm = source["requiresConfirm"];
|
||||
this.warningMessage = source["warningMessage"];
|
||||
}
|
||||
}
|
||||
export class ToolFunction {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ToolFunction(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.name = source["name"];
|
||||
this.description = source["description"];
|
||||
this.parameters = source["parameters"];
|
||||
}
|
||||
}
|
||||
export class Tool {
|
||||
type: string;
|
||||
function: ToolFunction;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Tool(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.type = source["type"];
|
||||
this.function = this.convertValues(source["function"], ToolFunction);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export namespace connection {
|
||||
|
||||
export class UpdateRow {
|
||||
@@ -48,6 +225,24 @@ export namespace connection {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class HTTPTunnelConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new HTTPTunnelConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.user = source["user"];
|
||||
this.password = source["password"];
|
||||
}
|
||||
}
|
||||
export class ProxyConfig {
|
||||
type: string;
|
||||
host: string;
|
||||
@@ -96,10 +291,16 @@ export namespace connection {
|
||||
password: string;
|
||||
savePassword?: boolean;
|
||||
database: string;
|
||||
useSSL?: boolean;
|
||||
sslMode?: string;
|
||||
sslCertPath?: string;
|
||||
sslKeyPath?: string;
|
||||
useSSH: boolean;
|
||||
ssh: SSHConfig;
|
||||
useProxy?: boolean;
|
||||
proxy?: ProxyConfig;
|
||||
useHttpTunnel?: boolean;
|
||||
httpTunnel?: HTTPTunnelConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
timeout?: number;
|
||||
@@ -130,10 +331,16 @@ export namespace connection {
|
||||
this.password = source["password"];
|
||||
this.savePassword = source["savePassword"];
|
||||
this.database = source["database"];
|
||||
this.useSSL = source["useSSL"];
|
||||
this.sslMode = source["sslMode"];
|
||||
this.sslCertPath = source["sslCertPath"];
|
||||
this.sslKeyPath = source["sslKeyPath"];
|
||||
this.useSSH = source["useSSH"];
|
||||
this.ssh = this.convertValues(source["ssh"], SSHConfig);
|
||||
this.useProxy = source["useProxy"];
|
||||
this.proxy = this.convertValues(source["proxy"], ProxyConfig);
|
||||
this.useHttpTunnel = source["useHttpTunnel"];
|
||||
this.httpTunnel = this.convertValues(source["httpTunnel"], HTTPTunnelConfig);
|
||||
this.driver = source["driver"];
|
||||
this.dsn = source["dsn"];
|
||||
this.timeout = source["timeout"];
|
||||
@@ -171,11 +378,13 @@ export namespace connection {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class QueryResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: any;
|
||||
fields?: string[];
|
||||
queryId?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QueryResult(source);
|
||||
@@ -187,6 +396,7 @@ export namespace connection {
|
||||
this.message = source["message"];
|
||||
this.data = source["data"];
|
||||
this.fields = source["fields"];
|
||||
this.queryId = source["queryId"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +454,9 @@ export namespace sync {
|
||||
mode: string;
|
||||
jobId?: string;
|
||||
autoAddColumns?: boolean;
|
||||
targetTableStrategy?: string;
|
||||
createIndexes?: boolean;
|
||||
mongoCollectionName?: string;
|
||||
tableOptions?: Record<string, TableOptions>;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -259,6 +472,9 @@ export namespace sync {
|
||||
this.mode = source["mode"];
|
||||
this.jobId = source["jobId"];
|
||||
this.autoAddColumns = source["autoAddColumns"];
|
||||
this.targetTableStrategy = source["targetTableStrategy"];
|
||||
this.createIndexes = source["createIndexes"];
|
||||
this.mongoCollectionName = source["mongoCollectionName"];
|
||||
this.tableOptions = this.convertValues(source["tableOptions"], TableOptions, true);
|
||||
}
|
||||
|
||||
|
||||
16
go.mod
@@ -5,8 +5,10 @@ go 1.24.3
|
||||
require (
|
||||
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
|
||||
gitee.com/chunanyong/dm v1.8.22
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0
|
||||
github.com/duckdb/duckdb-go/v2 v2.5.5
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/highgo/pq-sm3 v0.0.0
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/microsoft/go-mssqldb v1.9.6
|
||||
@@ -15,6 +17,7 @@ require (
|
||||
github.com/taosdata/driver-go/v3 v3.7.8
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
go.mongodb.org/mongo-driver v1.17.9
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/mod v0.32.0
|
||||
@@ -25,6 +28,8 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.5.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -36,6 +41,8 @@ require (
|
||||
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3 // indirect
|
||||
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
@@ -44,9 +51,8 @@ require (
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
@@ -61,7 +67,9 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -70,6 +78,7 @@ require (
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
@@ -84,6 +93,9 @@ require (
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
|
||||
79
go.sum
@@ -16,6 +16,10 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuo
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI=
|
||||
@@ -52,6 +56,10 @@ github.com/duckdb/duckdb-go/v2 v2.5.5 h1:TlK8ipnzoKW2aNrjGqRkFWLCDpJDxR/VwH8ezEc
|
||||
github.com/duckdb/duckdb-go/v2 v2.5.5/go.mod h1:6uIbC3gz36NCEygECzboygOo/Z9TeVwox/puG+ohWV0=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
@@ -62,19 +70,23 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
@@ -83,20 +95,29 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
@@ -134,8 +155,16 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
@@ -159,6 +188,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg=
|
||||
@@ -169,6 +200,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
@@ -176,6 +208,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/taosdata/driver-go/v3 v3.7.8 h1:N2H6HLLZH2ve2ipcoFgG9BJS+yW0XksqNYwEdSmHaJk=
|
||||
github.com/taosdata/driver-go/v3 v3.7.8/go.mod h1:gSxBEPOueMg0rTmMO1Ug6aeD7AwGdDGvUtLrsDTTpYc=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
@@ -192,8 +225,10 @@ github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSB
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
@@ -202,38 +237,66 @@ github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstf
|
||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
|
||||
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -260,15 +323,25 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
214
internal/ai/context/builder.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package aicontext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PromptTemplate AI 能力类型
|
||||
type PromptTemplate string
|
||||
|
||||
const (
|
||||
PromptSQLGenerate PromptTemplate = "sql_generate"
|
||||
PromptSQLExplain PromptTemplate = "sql_explain"
|
||||
PromptSQLOptimize PromptTemplate = "sql_optimize"
|
||||
PromptDataAnalyze PromptTemplate = "data_analyze"
|
||||
PromptSchemaInsight PromptTemplate = "schema_insight"
|
||||
PromptGeneralChat PromptTemplate = "general_chat"
|
||||
)
|
||||
|
||||
// GetBuiltinPrompts 获取所有内置系统提示词集合,用于前端展示
|
||||
func GetBuiltinPrompts() map[string]string {
|
||||
return map[string]string{
|
||||
"通用聊天助手": buildGeneralChatPrompt(),
|
||||
"SQL 生成器": buildSQLGeneratePrompt(),
|
||||
"SQL 解析器": buildSQLExplainPrompt(),
|
||||
"SQL 优化器": buildSQLOptimizePrompt(),
|
||||
"数据洞察分析": buildDataAnalyzePrompt(),
|
||||
"表结构审查": buildSchemaInsightPrompt(),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildSystemPrompt 根据模板类型和上下文构建 System Prompt
|
||||
func BuildSystemPrompt(template PromptTemplate, dbCtx *DatabaseContext) string {
|
||||
var prompt string
|
||||
|
||||
switch template {
|
||||
case PromptSQLGenerate:
|
||||
prompt = buildSQLGeneratePrompt()
|
||||
case PromptSQLExplain:
|
||||
prompt = buildSQLExplainPrompt()
|
||||
case PromptSQLOptimize:
|
||||
prompt = buildSQLOptimizePrompt()
|
||||
case PromptDataAnalyze:
|
||||
prompt = buildDataAnalyzePrompt()
|
||||
case PromptSchemaInsight:
|
||||
prompt = buildSchemaInsightPrompt()
|
||||
case PromptGeneralChat:
|
||||
prompt = buildGeneralChatPrompt()
|
||||
default:
|
||||
prompt = buildGeneralChatPrompt()
|
||||
}
|
||||
|
||||
if dbCtx != nil {
|
||||
prompt += "\n\n" + FormatDatabaseContext(dbCtx)
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
// FormatDatabaseContext 将数据库上下文格式化为 LLM 友好的文本
|
||||
func FormatDatabaseContext(ctx *DatabaseContext) string {
|
||||
if ctx == nil || len(ctx.Tables) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("## 当前数据库上下文\n\n数据库类型: %s\n数据库名: %s\n\n",
|
||||
ctx.DatabaseType, ctx.DatabaseName))
|
||||
|
||||
b.WriteString("### 表结构\n\n")
|
||||
for _, table := range ctx.Tables {
|
||||
b.WriteString(fmt.Sprintf("#### 表: %s", table.Name))
|
||||
if table.Comment != "" {
|
||||
b.WriteString(fmt.Sprintf(" (%s)", table.Comment))
|
||||
}
|
||||
if table.RowCount > 0 {
|
||||
b.WriteString(fmt.Sprintf(" [约 %d 行]", table.RowCount))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString("| 列名 | 类型 | 可空 | 主键 | 备注 |\n")
|
||||
b.WriteString("|------|------|------|------|------|\n")
|
||||
for _, col := range table.Columns {
|
||||
nullable := "否"
|
||||
if col.Nullable {
|
||||
nullable = "是"
|
||||
}
|
||||
pk := ""
|
||||
if col.PrimaryKey {
|
||||
pk = "✓"
|
||||
}
|
||||
comment := col.Comment
|
||||
if comment == "" {
|
||||
comment = "-"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s |\n",
|
||||
col.Name, col.Type, nullable, pk, comment))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(table.Indexes) > 0 {
|
||||
b.WriteString("**索引:**\n")
|
||||
for _, idx := range table.Indexes {
|
||||
unique := ""
|
||||
if idx.Unique {
|
||||
unique = " (唯一)"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- %s: [%s]%s\n",
|
||||
idx.Name, strings.Join(idx.Columns, ", "), unique))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(table.SampleRows) > 0 {
|
||||
b.WriteString(fmt.Sprintf("**采样数据 (%d 行):**\n\n", len(table.SampleRows)))
|
||||
if len(table.SampleRows) > 0 {
|
||||
// 使用第一行的 key 作为标题
|
||||
first := table.SampleRows[0]
|
||||
var keys []string
|
||||
for k := range first {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
b.WriteString("| " + strings.Join(keys, " | ") + " |\n")
|
||||
b.WriteString("|" + strings.Repeat("------|", len(keys)) + "\n")
|
||||
for _, row := range table.SampleRows {
|
||||
var vals []string
|
||||
for _, k := range keys {
|
||||
vals = append(vals, fmt.Sprintf("%v", row[k]))
|
||||
}
|
||||
b.WriteString("| " + strings.Join(vals, " | ") + " |\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildSQLGeneratePrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位顶级的数据库开发专家和 SQL 查询构建师。根据用户的自然语言需求,生成精准、优雅、高性能的 SQL 查询或 Redis 命令。
|
||||
|
||||
严苛输出规则:
|
||||
1. 首要目标是输出纯粹的代码:始终将代码放在正确语言标识(如 sql 或 bash)的 markdown 代码块中。
|
||||
2. 保持精简:不要添加过多的前置闲聊,直奔主题。
|
||||
3. 保护生产安全:优先使用参数化查询或安全防范写法避免 SQL 注入。对于未指定条件的 DELETE/UPDATE 语句,必须提出强烈的红线警告!!
|
||||
4. 性能至上:对大型查询默认添加合理的 LIMIT 限制(如 LIMIT 100),在 JOIN 和聚合时优先选择最高效的范式写法。
|
||||
5. 适度注释:对于存在复杂逻辑嵌套的代码,请在代码块内使用单行注释简要说明思路。`
|
||||
}
|
||||
|
||||
func buildSQLExplainPrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位深耕数据库领域多年的资深开发工程师。请用专业、条理分明且深入浅出的开发者语言向用户全盘解析 SQL 语句的底层意图与执行逻辑。
|
||||
|
||||
解析规范:
|
||||
1. 宏观逻辑解构:用简短的一句话概括这条 SQL 在业务上想要解决什么问题。
|
||||
2. 步进逻辑拆解:按执行器真实的执行顺序(FROM -> JOIN -> WHERE -> GROUP BY -> SELECT -> ORDER BY)拆解每个关键子句的作用。
|
||||
3. 性能排雷点:敏锐指出可能存在的性能陷阱(如隐式类型转换、没有走索引的函数调用、潜在的笛卡尔积/全表扫描等)。
|
||||
4. 严谨的排版:使用列表呈现关键点,重点词汇加粗,确保长文不累赘。`
|
||||
}
|
||||
|
||||
func buildSQLOptimizePrompt() string {
|
||||
return `你是 GoNavi AI 助手,一名曾主导过千万级高并发系统的全栈性能工程专家与高级 DBA。请对用户提供的原始 SQL 进行冷酷、精确的诊断并开出性能重构处方。
|
||||
|
||||
诊断与处方要求:
|
||||
1. 性能瓶颈透视:精准点出当前语句死穴(不合理的驱动表、无法利用覆盖索引、多此一举的子查询等)。
|
||||
2. 重构版本的 SQL:如果存在性能提升空间,直接向用户展示彻底优化过的高性能写法,并确保逻辑等价性。
|
||||
3. 剖析原因:不仅要告诉用户“怎么改”,更要说清楚执行器“为什么这样会更快”。
|
||||
4. 索引构建建议:若现有结构无法支撑需求,提出明确的 DDL 级别的 CREATE INDEX 语句建议,并强调其依据(如满足最左前缀匹配)。
|
||||
5. 优先级评估:在回答的最后标注本次优化建议的紧迫性(高:阻断级/锁表风险;中:吞吐量瓶颈;低:长效微调)。`
|
||||
}
|
||||
|
||||
func buildDataAnalyzePrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位具备极致敏锐商业嗅觉的高级数据分析专家。你将审视用户通过查询得到的数据样本,从中提炼出蕴含的真金白银般的信息。
|
||||
|
||||
洞察目标:
|
||||
1. 硬统计:总观数据行数、核心数值指标(极值、平均值、聚合中位数等)的冰冷现实。
|
||||
2. 趋势与异动:如果数据带有时间戳,敏锐捕捉其上升或下降趋势;如果有异类离群值,将其高亮标注。
|
||||
3. 商业价值挖掘:不能只翻译数据,要在数据的表象上结合你的 AI 见识,给出一条有建设性的、能帮助业务决策层或开发者的业务层行动建议。
|
||||
4. 展现格式:你的分析应该是“标题 + 浓缩要点”的极简研报形式,杜绝毫无波澜的流水账。`
|
||||
}
|
||||
|
||||
func buildSchemaInsightPrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位统筹数据库宏观生命周期的首席数据库架构师。在这个环节里,你需要对用户提供的数据库表结构执行最严厉的范式与前瞻性审查。
|
||||
|
||||
审查视界:
|
||||
1. 规范化博弈:是否存在明显的反三范式设计?这种冗余是否有助于性能(适当的反范式),还是纯粹的设计失误?
|
||||
2. 索引健壮性审查:评估主键选择(如自增、UUID 的利弊),是否存在冗余索引阻碍写入?以及是否遗漏了高频的联合索引。
|
||||
3. 物理容量前瞻:审视数据类型分配(如使用过大的 VARCHAR、没必要的 BIGINT 等可能带来的空间挥霍)。
|
||||
4. 代码级指引:如果存在结构性缺陷,不要只发牢骚,直接给出包含具体优化的 ALTER TABLE 结构修改建议脚本。`
|
||||
}
|
||||
|
||||
func buildGeneralChatPrompt() string {
|
||||
return `你是 GoNavi AI 助手,一款深度集成在数据库/缓存客户端(GoNavi)内部的专属智能专家系统。
|
||||
你的目标是成为开发者、DBA 和数据科学家最得力的超级外脑,提供专业、精准、具有前瞻性的数据端解决方案。
|
||||
|
||||
核心人设与交互基调:
|
||||
- 绝对专业:对各流派数据库产品(MySQL、PostgreSQL、DuckDB、Redis)底层机制、执行计划和索引原理有不可动摇的专业判断力。
|
||||
- 直击痛点:谢绝套话与无效寒暄,若用户的意图明确,首屏直接给出可以直接粘贴运行的优雅代码。
|
||||
- 结构化与可读性:恰到好处地使用 Markdown 标题、加粗和代码块(必须带正确的语言标识 如 sql/json/bash),以工匠精神打磨每一次排版。
|
||||
- 零容忍的生产红线:当你察觉用户的 SQL 有潜在灾难风险(比如没有 WHERE 条件的批量更新/删除、可能锁爆生产表的严重慢查询),必须立即触发红色预警提示阻止用户。
|
||||
|
||||
你的综合能力版图:
|
||||
1. 📝 自然语言驱动:翻译人类意图为精准的查询语句。
|
||||
2. 🔍 底层原理解析:剥丝抽茧分析查询背后的执行逻辑与性能隐患。
|
||||
3. ⚡ 专家级调优:指出并化解性能瓶颈,给出覆盖全维度的索引调优思路。
|
||||
4. 📊 数据洞察炼金:不仅聚合数据,更能从结果集中挖掘商业维度的深度规律。
|
||||
5. 🏗️ 架构先知视界:全局审阅表结构设计局限,提出抗数据膨胀级别的架构演进方案。
|
||||
|
||||
互动守则:
|
||||
- 永远使用专业、具有合作感且充满信心的中文与用户探讨问题。
|
||||
- 当被要求提供任何数据库代码时,需结合相关数据库引擎的最佳实践。如果不清楚当前方言版本,请以标准实现为主基调并好心指出版别差异(如 MySQL 8 窗口函数 等)。
|
||||
- 绝不轻易拒绝:如果用户要求写 SQL 但并未显式挂载任何表的详细 DDL,请尽最大努力根据对话上下文中带入的【纯表名列表】去推测他要查询哪个表。如果实在无法推断,请温柔且专业地向用户解释目前已知的表有哪些,并询问到底想查哪张表。`
|
||||
}
|
||||
|
||||
42
internal/ai/context/collector.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package aicontext
|
||||
|
||||
// DatabaseContext 数据库上下文信息,传递给 AI 辅助上下文理解
|
||||
type DatabaseContext struct {
|
||||
DatabaseType string `json:"databaseType"` // mysql, postgres 等
|
||||
DatabaseName string `json:"databaseName"`
|
||||
Tables []TableContext `json:"tables"`
|
||||
}
|
||||
|
||||
// TableContext 表的上下文信息
|
||||
type TableContext struct {
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Columns []ColumnInfo `json:"columns"`
|
||||
Indexes []IndexInfo `json:"indexes,omitempty"`
|
||||
SampleRows []map[string]interface{} `json:"sampleRows,omitempty"`
|
||||
RowCount int64 `json:"rowCount,omitempty"`
|
||||
}
|
||||
|
||||
// ColumnInfo 列信息
|
||||
type ColumnInfo struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Nullable bool `json:"nullable"`
|
||||
PrimaryKey bool `json:"primaryKey"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// IndexInfo 索引信息
|
||||
type IndexInfo struct {
|
||||
Name string `json:"name"`
|
||||
Columns []string `json:"columns"`
|
||||
Unique bool `json:"unique"`
|
||||
}
|
||||
|
||||
// QueryResultContext 查询结果上下文
|
||||
type QueryResultContext struct {
|
||||
SQL string `json:"sql"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
RowCount int `json:"rowCount"`
|
||||
}
|
||||
514
internal/ai/provider/anthropic.go
Normal file
@@ -0,0 +1,514 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAnthropicBaseURL = "https://api.anthropic.com"
|
||||
anthropicAPIVersion = "2023-06-01"
|
||||
)
|
||||
|
||||
func normalizeAnthropicMessagesURL(baseURL string) string {
|
||||
url := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if url == "" {
|
||||
url = defaultAnthropicBaseURL
|
||||
}
|
||||
if strings.HasSuffix(url, "/messages") {
|
||||
return url
|
||||
}
|
||||
if strings.HasSuffix(url, "/v1") {
|
||||
return url + "/messages"
|
||||
}
|
||||
return url + "/v1/messages"
|
||||
}
|
||||
|
||||
func IsDashScopeAnthropicCompatibleBaseURL(baseURL string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
return host == "dashscope.aliyuncs.com" || host == "coding.dashscope.aliyuncs.com"
|
||||
}
|
||||
|
||||
func ApplyAnthropicAuthHeaders(headers http.Header, baseURL string, apiKey string) {
|
||||
headers.Set("x-api-key", apiKey)
|
||||
if IsDashScopeAnthropicCompatibleBaseURL(baseURL) {
|
||||
headers.Set("Authorization", "Bearer "+apiKey)
|
||||
headers.Del("anthropic-version")
|
||||
return
|
||||
}
|
||||
headers.Set("anthropic-version", anthropicAPIVersion)
|
||||
}
|
||||
|
||||
// AnthropicProvider 实现 Anthropic Claude API 的 Provider
|
||||
type AnthropicProvider struct {
|
||||
config ai.ProviderConfig
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAnthropicProvider 创建 Anthropic Provider 实例
|
||||
func NewAnthropicProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultAnthropicBaseURL
|
||||
}
|
||||
model := strings.TrimSpace(config.Model)
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型")
|
||||
}
|
||||
maxTokens := config.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = defaultOpenAIMaxTokens
|
||||
}
|
||||
temperature := config.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = defaultOpenAITemperature
|
||||
}
|
||||
|
||||
normalized := config
|
||||
normalized.BaseURL = baseURL
|
||||
normalized.Model = model
|
||||
normalized.MaxTokens = maxTokens
|
||||
normalized.Temperature = temperature
|
||||
|
||||
return &AnthropicProvider{
|
||||
config: normalized,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: openAIHTTPTimeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) Name() string {
|
||||
if strings.TrimSpace(p.config.Name) != "" {
|
||||
return p.config.Name
|
||||
}
|
||||
return "Anthropic"
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) Validate() error {
|
||||
if strings.TrimSpace(p.config.APIKey) == "" {
|
||||
return fmt.Errorf("API Key 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 请求体类型 ---
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
System string `json:"system,omitempty"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// anthropicTool Anthropic 格式的工具定义
|
||||
type anthropicTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema any `json:"input_schema"`
|
||||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
// convertToolsToAnthropic 将 OpenAI 格式的 tools 转换为 Anthropic 格式
|
||||
func convertToolsToAnthropic(tools []ai.Tool) []anthropicTool {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]anthropicTool, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
result = append(result, anthropicTool{
|
||||
Name: t.Function.Name,
|
||||
Description: t.Function.Description,
|
||||
InputSchema: t.Function.Parameters,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage {
|
||||
messages := make([]anthropicMessage, 0, len(reqMessages))
|
||||
for _, m := range reqMessages {
|
||||
// tool result 消息:转换为 Anthropic 的 tool_result content block
|
||||
if m.Role == "tool" {
|
||||
messages = append(messages, anthropicMessage{
|
||||
Role: "user",
|
||||
Content: []map[string]interface{}{
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": m.ToolCallID,
|
||||
"content": m.Content,
|
||||
},
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// assistant 带 tool_calls:转换为 Anthropic 的 tool_use content block
|
||||
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||
var contentParts []map[string]interface{}
|
||||
if m.Content != "" {
|
||||
contentParts = append(contentParts, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": m.Content,
|
||||
})
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
var input interface{}
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]interface{}{}
|
||||
}
|
||||
contentParts = append(contentParts, map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
messages = append(messages, anthropicMessage{Role: "assistant", Content: contentParts})
|
||||
continue
|
||||
}
|
||||
|
||||
// 图片消息
|
||||
if len(m.Images) > 0 {
|
||||
var contentParts []map[string]interface{}
|
||||
for _, img := range m.Images {
|
||||
mimeType, rawBase64, err := ParseDataURI(img)
|
||||
if err == nil {
|
||||
contentParts = append(contentParts, map[string]interface{}{
|
||||
"type": "image",
|
||||
"source": map[string]interface{}{
|
||||
"type": "base64",
|
||||
"media_type": mimeType,
|
||||
"data": rawBase64,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
text := m.Content
|
||||
if text == "" {
|
||||
text = "请描述和分析这张图片。"
|
||||
}
|
||||
contentParts = append(contentParts, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": text,
|
||||
})
|
||||
messages = append(messages, anthropicMessage{Role: m.Role, Content: contentParts})
|
||||
} else {
|
||||
messages = append(messages, anthropicMessage{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// --- 响应体类型 ---
|
||||
|
||||
type anthropicContentBlock struct {
|
||||
Type string `json:"type"` // "text" | "tool_use"
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"` // tool_use
|
||||
Name string `json:"name,omitempty"` // tool_use
|
||||
Input json.RawMessage `json:"input,omitempty"` // tool_use
|
||||
}
|
||||
|
||||
type anthropicResponse struct {
|
||||
Content []anthropicContentBlock `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// 流式事件类型
|
||||
type anthropicStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Index int `json:"index,omitempty"`
|
||||
ContentBlock *anthropicContentBlock `json:"content_block,omitempty"`
|
||||
Delta *struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
PartialJSON string `json:"partial_json,omitempty"`
|
||||
} `json:"delta,omitempty"`
|
||||
}
|
||||
|
||||
// --- Chat 非流式 ---
|
||||
|
||||
func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemMsg, messages := extractSystemMessage(req.Messages)
|
||||
anthropicMsgs := buildAnthropicMessages(messages)
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
body := anthropicRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: anthropicMsgs,
|
||||
System: systemMsg,
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: temperature,
|
||||
Tools: convertToolsToAnthropic(req.Tools),
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
var result anthropicResponse
|
||||
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析 Anthropic 响应失败: %w", err)
|
||||
}
|
||||
if result.Error != nil && result.Error.Message != "" {
|
||||
return nil, fmt.Errorf("Anthropic API 错误: %s", result.Error.Message)
|
||||
}
|
||||
if len(result.Content) == 0 {
|
||||
return nil, fmt.Errorf("Anthropic 返回空响应")
|
||||
}
|
||||
|
||||
// 解析响应中的 text 和 tool_use content blocks
|
||||
var textContent string
|
||||
var toolCalls []ai.ToolCall
|
||||
for _, block := range result.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
textContent += block.Text
|
||||
case "tool_use":
|
||||
argsStr := "{}"
|
||||
if len(block.Input) > 0 {
|
||||
argsStr = string(block.Input)
|
||||
}
|
||||
toolCalls = append(toolCalls, ai.ToolCall{
|
||||
ID: block.ID,
|
||||
Type: "function",
|
||||
Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{
|
||||
Name: block.Name,
|
||||
Arguments: argsStr,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{
|
||||
Content: textContent,
|
||||
ToolCalls: toolCalls,
|
||||
TokensUsed: ai.TokenUsage{
|
||||
PromptTokens: result.Usage.InputTokens,
|
||||
CompletionTokens: result.Usage.OutputTokens,
|
||||
TotalTokens: result.Usage.InputTokens + result.Usage.OutputTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- ChatStream 流式 ---
|
||||
|
||||
func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
systemMsg, messages := extractSystemMessage(req.Messages)
|
||||
anthropicMsgs := buildAnthropicMessages(messages)
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
body := anthropicRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: anthropicMsgs,
|
||||
System: systemMsg,
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: temperature,
|
||||
Stream: true,
|
||||
Tools: convertToolsToAnthropic(req.Tools),
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
// 跟踪当前活跃的 tool_use blocks
|
||||
type activeToolUse struct {
|
||||
id string
|
||||
name string
|
||||
argsJSON strings.Builder
|
||||
}
|
||||
activeBlocks := make(map[int]*activeToolUse) // index -> block
|
||||
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var event anthropicStreamEvent
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "content_block_start":
|
||||
if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" {
|
||||
activeBlocks[event.Index] = &activeToolUse{
|
||||
id: event.ContentBlock.ID,
|
||||
name: event.ContentBlock.Name,
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
if event.Delta == nil {
|
||||
continue
|
||||
}
|
||||
switch event.Delta.Type {
|
||||
case "text_delta":
|
||||
if event.Delta.Text != "" {
|
||||
callback(ai.StreamChunk{Content: event.Delta.Text})
|
||||
}
|
||||
case "input_json_delta":
|
||||
if block, ok := activeBlocks[event.Index]; ok {
|
||||
block.argsJSON.WriteString(event.Delta.PartialJSON)
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_stop":
|
||||
if block, ok := activeBlocks[event.Index]; ok {
|
||||
argsStr := block.argsJSON.String()
|
||||
if argsStr == "" {
|
||||
argsStr = "{}"
|
||||
}
|
||||
// 产出完整的 tool call
|
||||
callback(ai.StreamChunk{
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{
|
||||
ID: block.id,
|
||||
Type: "function",
|
||||
Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{
|
||||
Name: block.name,
|
||||
Arguments: argsStr,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
delete(activeBlocks, event.Index)
|
||||
}
|
||||
|
||||
case "message_stop":
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
// --- HTTP 请求 ---
|
||||
|
||||
func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
url := normalizeAnthropicMessagesURL(p.baseURL)
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
ApplyAnthropicAuthHeaders(httpReq.Header, p.baseURL, p.config.APIKey)
|
||||
|
||||
if strings.Contains(string(jsonBody), `"stream":true`) || strings.Contains(string(jsonBody), `"stream": true`) {
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
httpReq.Header.Set("Cache-Control", "no-cache")
|
||||
httpReq.Header.Set("Connection", "keep-alive")
|
||||
}
|
||||
|
||||
// 仅官方 API 发 beta 特性头(代理不发,避免触发 Claude Code 验证)
|
||||
isOfficialAPI := p.baseURL == defaultAnthropicBaseURL || strings.Contains(p.baseURL, "anthropic.com")
|
||||
if isOfficialAPI {
|
||||
httpReq.Header.Set("anthropic-beta", "interleaved-thinking-2025-05-14,output-128k-2025-02-19,prompt-caching-2024-07-31")
|
||||
}
|
||||
|
||||
// 自定义 headers(用于兼容各类代理服务)
|
||||
for k, v := range p.config.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求到 %s 失败: %w", url, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("Anthropic API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// extractSystemMessage 从消息列表中提取 system 消息(Anthropic 要求 system 作为独立字段)
|
||||
func extractSystemMessage(messages []ai.Message) (string, []ai.Message) {
|
||||
var systemParts []string
|
||||
var remaining []ai.Message
|
||||
for _, m := range messages {
|
||||
if m.Role == "system" {
|
||||
systemParts = append(systemParts, m.Content)
|
||||
} else {
|
||||
remaining = append(remaining, m)
|
||||
}
|
||||
}
|
||||
return strings.Join(systemParts, "\n\n"), remaining
|
||||
}
|
||||
57
internal/ai/provider/anthropic_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeAnthropicMessagesURL_AppendsMessagesSuffix(t *testing.T) {
|
||||
url := normalizeAnthropicMessagesURL("https://api.anthropic.com")
|
||||
if url != "https://api.anthropic.com/v1/messages" {
|
||||
t.Fatalf("expected normalized anthropic messages url, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAnthropicMessagesURL_UsesMoonshotAnthropicMessagesEndpoint(t *testing.T) {
|
||||
url := normalizeAnthropicMessagesURL("https://api.moonshot.cn/anthropic")
|
||||
if url != "https://api.moonshot.cn/anthropic/v1/messages" {
|
||||
t.Fatalf("expected moonshot anthropic messages url, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAnthropicMessagesURL_PreservesExplicitMessagesPath(t *testing.T) {
|
||||
url := normalizeAnthropicMessagesURL("https://api.moonshot.cn/anthropic/v1/messages")
|
||||
if url != "https://api.moonshot.cn/anthropic/v1/messages" {
|
||||
t.Fatalf("expected explicit messages path to be preserved, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAnthropicAuthHeaders_UsesOfficialAnthropicHeadersForAnthropicAPI(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
ApplyAnthropicAuthHeaders(headers, "https://api.anthropic.com", "sk-test")
|
||||
|
||||
if got := headers.Get("x-api-key"); got != "sk-test" {
|
||||
t.Fatalf("expected x-api-key header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("anthropic-version"); got != anthropicAPIVersion {
|
||||
t.Fatalf("expected anthropic-version header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("Authorization"); got != "" {
|
||||
t.Fatalf("expected no authorization header for official anthropic, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAnthropicAuthHeaders_UsesBearerForDashScopeCompatibleAnthropic(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
ApplyAnthropicAuthHeaders(headers, "https://coding.dashscope.aliyuncs.com/apps/anthropic", "sk-sp-test")
|
||||
|
||||
if got := headers.Get("Authorization"); got != "Bearer sk-sp-test" {
|
||||
t.Fatalf("expected bearer authorization header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("x-api-key"); got != "sk-sp-test" {
|
||||
t.Fatalf("expected x-api-key header, got %q", got)
|
||||
}
|
||||
if got := headers.Get("anthropic-version"); got != "" {
|
||||
t.Fatalf("expected no anthropic-version header for DashScope, got %q", got)
|
||||
}
|
||||
}
|
||||