Compare commits

..

27 Commits

Author SHA1 Message Date
tianqijiuyun-latiao
4a830c7a82 chore(ci): 删除旧的 macOS 单平台测试工作流 2026-03-07 13:32:55 +08:00
tianqijiuyun-latiao
adab24085e fix(db-query-value): 清理 query_value 合并冲突并保持零日期处理 2026-03-07 13:07:00 +08:00
tianqijiuyun-latiao
cc201f3df6 Merge branch 'refs/heads/main' into feature/kingbase_opt
# Conflicts:
#	.github/workflows/test-build-all-platforms.yml
#	internal/db/query_value.go
#	internal/db/query_value_test.go
2026-03-07 12:58:02 +08:00
tianqijiuyun-latiao
57d6ace2f8 fix(data-grid-scroll): 修复数据区触摸板横向滚动失效 refs #175 2026-03-06 22:39:36 +08:00
tianqijiuyun-latiao
0f5dc2184d fix(window-scale): 修复任务栏切换后字体异常放大 refs #193 2026-03-06 20:27:45 +08:00
tianqijiuyun-latiao
0b95908ae9 fix(datetime-display): 修复零日期显示被错误转换 refs #189 2026-03-06 19:35:33 +08:00
tianqijiuyun-latiao
4c322818bc fix(data-viewer): 保持切换标签后的表格滚动位置 2026-03-06 19:22:07 +08:00
tianqijiuyun-latiao
3fefa13023 fix(ci): 修复全平台测试包 artifact 命名冲突 2026-03-06 18:24:28 +08:00
辣条
75a5a322e0 - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)
* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流
2026-03-06 17:32:14 +08:00
tianqijiuyun-latiao
1be003b0a2 chore(ci): 新增全平台测试包手动构建工作流 2026-03-06 16:26:29 +08:00
tianqijiuyun-latiao
0a0609c459 fix(query-execution): 支持带前置注释的读查询结果识别 2026-03-06 16:05:34 +08:00
tianqijiuyun-latiao
0682baa14d fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178 2026-03-06 15:39:51 +08:00
Syngnat
fb65b553e9 Release/0.5.3 (#191) 2026-03-06 14:30:07 +08:00
辣条
b53227cb15 - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)
* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157
2026-03-06 13:55:13 +08:00
tianqijiuyun-latiao
6c41e15e99 fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157 2026-03-06 12:37:10 +08:00
tianqijiuyun-latiao
ba51fa658c Merge branch 'refs/heads/main' into feature/kingbase_opt 2026-03-06 09:57:37 +08:00
tianqijiuyun-latiao
18ed4ca50c fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154 2026-03-06 08:01:40 +08:00
tianqijiuyun-latiao
37704e2b3b fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155 2026-03-06 07:54:51 +08:00
tianqijiuyun-latiao
6b9104fae8 fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181 2026-03-06 07:49:41 +08:00
tianqijiuyun-latiao
647768221e feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174 2026-03-06 00:53:40 +08:00
tianqijiuyun-latiao
251e1b22d7 fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185 2026-03-06 00:42:12 +08:00
tianqijiuyun-latiao
c99d4b1fa6 chore(ci): 允许测试工作流在当前分支自动触发 2026-03-06 00:02:47 +08:00
tianqijiuyun-latiao
ff98ec79a4 chore(ci): 新增手动触发的 macOS 测试构建工作流 2026-03-05 23:58:19 +08:00
tianqijiuyun-latiao
072b4e6e78 fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败
refs #177
2026-03-05 23:36:46 +08:00
tianqijiuyun-latiao
2449184ad3 fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误
refs #176
2026-03-05 21:10:36 +08:00
tianqijiuyun-latiao
6ae49d4b84 fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销
refs #178
2026-03-05 20:42:33 +08:00
tianqijiuyun-latiao
5c23722ad8 feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源
refs #168
2026-03-05 19:23:00 +08:00
10 changed files with 815 additions and 133 deletions

View File

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

View File

@@ -1,91 +0,0 @@
name: Test Build macOS (Manual)
on:
workflow_dispatch:
inputs:
build_label:
description: "测试包标识(仅用于文件名)"
required: false
default: "test"
push:
branches:
- feature/kingbase_opt
paths:
- ".github/workflows/test-macos-build.yml"
permissions:
contents: read
jobs:
build-macos:
name: Build macOS ${{ matrix.arch }}
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- platform: darwin/amd64
arch: amd64
- platform: darwin/arm64
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.3"
check-latest: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
- name: Build App
run: |
set -euo pipefail
OUTPUT_NAME="gonavi-test-${{ matrix.arch }}"
BUILD_LABEL="${{ inputs.build_label }}"
if [ -z "$BUILD_LABEL" ]; then
BUILD_LABEL="test"
fi
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
wails build \
-platform "${{ matrix.platform }}" \
-clean \
-o "$OUTPUT_NAME" \
-ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
- name: Package Zip
run: |
set -euo pipefail
APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app"
if [ ! -d "$APP_PATH" ]; then
APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true)
fi
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
echo "未找到 .app 产物"
ls -la build/bin || true
exit 1
fi
LABEL="${{ inputs.build_label }}"
if [ -z "$LABEL" ]; then
LABEL="test"
fi
ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip"
mkdir -p artifacts
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME"
shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256"
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }}
path: artifacts/*
if-no-files-found: error

View File

@@ -283,6 +283,7 @@ function App() {
let inFlight = false; let inFlight = false;
let lastRatio = Number(window.devicePixelRatio) || 1; let lastRatio = Number(window.devicePixelRatio) || 1;
let lastFixAt = 0; let lastFixAt = 0;
let activationTimer: number | null = null;
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms)); const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
@@ -334,17 +335,55 @@ function App() {
void fixWindowScaleIfNeeded(); void fixWindowScaleIfNeeded();
}; };
const scheduleActivationFix = () => {
if (cancelled) return;
if (activationTimer !== null) {
window.clearTimeout(activationTimer);
}
activationTimer = window.setTimeout(() => {
activationTimer = null;
if (cancelled) return;
void fixWindowScaleIfNeeded();
}, 80);
};
const handleWindowFocus = () => {
if (cancelled) return;
checkDevicePixelRatio();
scheduleActivationFix();
};
const handleVisibilityChange = () => {
if (cancelled) return;
if (document.visibilityState !== 'visible') {
return;
}
checkDevicePixelRatio();
scheduleActivationFix();
};
const handlePageShow = () => {
if (cancelled) return;
checkDevicePixelRatio();
scheduleActivationFix();
};
const pollTimer = window.setInterval(checkDevicePixelRatio, 900); const pollTimer = window.setInterval(checkDevicePixelRatio, 900);
window.addEventListener('resize', checkDevicePixelRatio); window.addEventListener('resize', checkDevicePixelRatio);
window.addEventListener('focus', checkDevicePixelRatio); window.addEventListener('focus', handleWindowFocus);
document.addEventListener('visibilitychange', checkDevicePixelRatio); window.addEventListener('pageshow', handlePageShow);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
cancelled = true; cancelled = true;
if (activationTimer !== null) {
window.clearTimeout(activationTimer);
}
window.clearInterval(pollTimer); window.clearInterval(pollTimer);
window.removeEventListener('resize', checkDevicePixelRatio); window.removeEventListener('resize', checkDevicePixelRatio);
window.removeEventListener('focus', checkDevicePixelRatio); window.removeEventListener('focus', handleWindowFocus);
document.removeEventListener('visibilitychange', checkDevicePixelRatio); window.removeEventListener('pageshow', handlePageShow);
document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}, []); }, []);

View File

@@ -610,6 +610,8 @@ interface DataGridProps {
exportSqlWithFilter?: string; exportSqlWithFilter?: string;
onApplyFilter?: (conditions: GridFilterCondition[]) => void; onApplyFilter?: (conditions: GridFilterCondition[]) => void;
appliedFilterConditions?: FilterCondition[]; appliedFilterConditions?: FilterCondition[];
scrollSnapshot?: { top: number; left: number };
onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void;
} }
type GridFilterCondition = FilterCondition & { type GridFilterCondition = FilterCondition & {
@@ -629,7 +631,8 @@ type ColumnMeta = {
const DataGrid: React.FC<DataGridProps> = ({ const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false, data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions,
scrollSnapshot, onScrollSnapshotChange
}) => { }) => {
const connections = useStore(state => state.connections); const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog); const addSqlLog = useStore(state => state.addSqlLog);
@@ -750,6 +753,8 @@ const DataGrid: React.FC<DataGridProps> = ({
const lastTableScrollLeftRef = useRef(0); const lastTableScrollLeftRef = useRef(0);
const lastExternalScrollLeftRef = useRef(0); const lastExternalScrollLeftRef = useRef(0);
const pendingScrollToBottomRef = useRef(false); const pendingScrollToBottomRef = useRef(false);
const lastReportedScrollRef = useRef<{ top: number; left: number }>({ top: 0, left: 0 });
const didRestoreScrollRef = useRef(false);
// 批量编辑模式状态 // 批量编辑模式状态
const [cellEditMode, setCellEditMode] = useState(false); const [cellEditMode, setCellEditMode] = useState(false);
@@ -2074,9 +2079,14 @@ const DataGrid: React.FC<DataGridProps> = ({
const estimatedVisibleCellCount = mergedDisplayData.length * Math.max(columnNames.length, 1); const estimatedVisibleCellCount = mergedDisplayData.length * Math.max(columnNames.length, 1);
const enableLargeResultOptimizedEditing = const enableLargeResultOptimizedEditing =
viewMode === 'table' && (mergedDisplayData.length >= 60 || estimatedVisibleCellCount >= 4000); viewMode === 'table' && (
mergedDisplayData.length >= 60 ||
estimatedVisibleCellCount >= 1600 ||
columnNames.length >= 36 ||
(isMacLike && columnNames.length >= 24)
);
const enableVirtual = enableLargeResultOptimizedEditing; const enableVirtual = enableLargeResultOptimizedEditing;
const enableInlineEditableCell = canModifyData; const enableInlineEditableCell = canModifyData && !enableLargeResultOptimizedEditing;
const columns = useMemo(() => { const columns = useMemo(() => {
return columnNames.map(key => ({ return columnNames.map(key => ({
@@ -2762,6 +2772,13 @@ const DataGrid: React.FC<DataGridProps> = ({
return active ? [active] : []; return active ? [active] : [];
}, []); }, []);
const pickVerticalScrollTarget = useCallback((tableContainer: HTMLElement): HTMLElement | null => {
const virtualHolder = tableContainer.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null;
const rcVirtualHolder = tableContainer.querySelector('.rc-virtual-list-holder') as HTMLElement | null;
const body = tableContainer.querySelector('.ant-table-body') as HTMLElement | null;
return virtualHolder || rcVirtualHolder || body;
}, []);
const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => { const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => {
const externalScroll = externalHScrollRef.current; const externalScroll = externalHScrollRef.current;
if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') { if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') {
@@ -2844,12 +2861,162 @@ const DataGrid: React.FC<DataGridProps> = ({
}; };
}, [horizontalScrollVisible]); }, [horizontalScrollVisible]);
// 非虚拟模式:支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。
// 某些平台在表格内容未铺满一页时,不会把水平手势正确路由到表格 body导致只能在表头/底部滚动条区域滚动。
useEffect(() => {
if (viewMode !== 'table' || enableVirtual) return;
const container = tableContainerRef.current;
if (!(container instanceof HTMLElement)) return;
const resolveHorizontalDelta = (event: WheelEvent) => {
if (Math.abs(event.deltaX) > 0.5) {
return event.deltaX;
}
if (event.shiftKey && Math.abs(event.deltaY) > 0.5) {
return event.deltaY;
}
return 0;
};
const isTableDataAreaTarget = (target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target : null;
if (!element) return false;
if (element.closest('.data-grid-external-hscroll')) return false;
return !!element.closest('.ant-table-body, .ant-table-content, .ant-table-cell, .ant-table-row, .ant-table-tbody');
};
const handleContainerHorizontalWheel = (event: WheelEvent) => {
const horizontalDelta = resolveHorizontalDelta(event);
if (!Number.isFinite(horizontalDelta) || Math.abs(horizontalDelta) < 0.5) return;
if (!isTableDataAreaTarget(event.target)) return;
const targets = pickHorizontalScrollTargets(container);
const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0];
if (!(activeTarget instanceof HTMLElement)) return;
const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth);
if (maxScrollLeft <= 0) return;
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta));
if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) return;
event.preventDefault();
event.stopPropagation();
horizontalSyncSourceRef.current = 'table';
activeTarget.scrollLeft = nextScrollLeft;
lastTableScrollLeftRef.current = nextScrollLeft;
const externalScroll = externalHScrollRef.current;
if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) {
externalScroll.scrollLeft = nextScrollLeft;
lastExternalScrollLeftRef.current = nextScrollLeft;
}
horizontalSyncSourceRef.current = '';
};
container.addEventListener('wheel', handleContainerHorizontalWheel, { passive: false, capture: true });
return () => {
container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions);
};
}, [viewMode, enableVirtual, pickHorizontalScrollTargets]);
useEffect(() => { useEffect(() => {
if (viewMode !== 'table') return; if (viewMode !== 'table') return;
const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current)); const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current));
return () => cancelAnimationFrame(rafId); return () => cancelAnimationFrame(rafId);
}, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]); }, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]);
useEffect(() => {
if (viewMode !== 'table' || !onScrollSnapshotChange) return;
const tableContainer = tableContainerRef.current;
if (!(tableContainer instanceof HTMLElement)) return;
let rafId: number | null = null;
let boundVerticalTarget: HTMLElement | null = null;
let boundHorizontalTargets: HTMLElement[] = [];
const externalScroll = externalHScrollRef.current;
const hasStoredScroll = !!scrollSnapshot && (Math.abs(scrollSnapshot.top) > 0.5 || Math.abs(scrollSnapshot.left) > 0.5);
const emitSnapshot = () => {
if (!didRestoreScrollRef.current && hasStoredScroll) {
return;
}
const verticalTarget = boundVerticalTarget || pickVerticalScrollTarget(tableContainer);
const horizontalTargets = boundHorizontalTargets.length > 0 ? boundHorizontalTargets : pickHorizontalScrollTargets(tableContainer);
const top = verticalTarget ? verticalTarget.scrollTop : 0;
const left = horizontalTargets[0]?.scrollLeft ?? externalScroll?.scrollLeft ?? 0;
if (Math.abs(lastReportedScrollRef.current.top - top) < 1 && Math.abs(lastReportedScrollRef.current.left - left) < 1) {
return;
}
lastReportedScrollRef.current = { top, left };
onScrollSnapshotChange({ top, left });
};
const bindTargets = () => {
if (boundVerticalTarget) {
boundVerticalTarget.removeEventListener('scroll', emitSnapshot);
}
boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot));
externalScroll?.removeEventListener('scroll', emitSnapshot);
boundVerticalTarget = pickVerticalScrollTarget(tableContainer);
boundHorizontalTargets = pickHorizontalScrollTargets(tableContainer);
boundVerticalTarget?.addEventListener('scroll', emitSnapshot, { passive: true });
boundHorizontalTargets.forEach(target => target.addEventListener('scroll', emitSnapshot, { passive: true }));
externalScroll?.addEventListener('scroll', emitSnapshot, { passive: true });
emitSnapshot();
};
rafId = requestAnimationFrame(bindTargets);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
if (boundVerticalTarget) {
boundVerticalTarget.removeEventListener('scroll', emitSnapshot);
}
boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot));
externalScroll?.removeEventListener('scroll', emitSnapshot);
};
}, [viewMode, mergedDisplayData.length, onScrollSnapshotChange, pickHorizontalScrollTargets, pickVerticalScrollTarget, scrollSnapshot]);
useEffect(() => {
if (viewMode !== 'table') return;
if (!scrollSnapshot) return;
if (didRestoreScrollRef.current) return;
const tableContainer = tableContainerRef.current;
if (!(tableContainer instanceof HTMLElement)) return;
if (mergedDisplayData.length === 0) return;
let rafId = requestAnimationFrame(() => {
const verticalTarget = pickVerticalScrollTarget(tableContainer);
const horizontalTargets = pickHorizontalScrollTargets(tableContainer);
const nextTop = Math.max(0, scrollSnapshot.top);
const nextLeft = Math.max(0, scrollSnapshot.left);
if (verticalTarget && Math.abs(verticalTarget.scrollTop - scrollSnapshot.top) > 1) {
verticalTarget.scrollTop = nextTop;
}
if (Math.abs(nextLeft) > 0.5) {
horizontalTargets.forEach(target => {
if (Math.abs(target.scrollLeft - nextLeft) > 1) {
target.scrollLeft = nextLeft;
}
});
const externalScroll = externalHScrollRef.current;
if (externalScroll && Math.abs(externalScroll.scrollLeft - nextLeft) > 1) {
externalScroll.scrollLeft = nextLeft;
}
lastTableScrollLeftRef.current = nextLeft;
lastExternalScrollLeftRef.current = nextLeft;
}
lastReportedScrollRef.current = { top: nextTop, left: nextLeft };
didRestoreScrollRef.current = true;
onScrollSnapshotChange?.({ top: nextTop, left: nextLeft });
});
return () => cancelAnimationFrame(rafId);
}, [viewMode, mergedDisplayData.length, scrollSnapshot, pickHorizontalScrollTargets, pickVerticalScrollTarget, onScrollSnapshotChange]);
// 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动 // 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动
useEffect(() => { useEffect(() => {
if (viewMode !== 'table' || !enableVirtual) return; if (viewMode !== 'table' || !enableVirtual) return;

View File

@@ -155,6 +155,16 @@ const reverseOrderBySQL = (orderBySQL: string): string => {
type ViewerFilterSnapshot = { type ViewerFilterSnapshot = {
showFilter: boolean; showFilter: boolean;
conditions: FilterCondition[]; conditions: FilterCondition[];
currentPage: number;
pageSize: number;
sortInfo: { columnKey: string, order: string } | null;
scrollTop: number;
scrollLeft: number;
};
type ViewerScrollSnapshot = {
top: number;
left: number;
}; };
const viewerFilterSnapshotsByTab = new Map<string, ViewerFilterSnapshot>(); const viewerFilterSnapshotsByTab = new Map<string, ViewerFilterSnapshot>();
@@ -175,15 +185,23 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => { const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim()); const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
if (!cached) { if (!cached) {
return { showFilter: false, conditions: [] }; return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: null, scrollTop: 0, scrollLeft: 0 };
} }
return { return {
showFilter: cached.showFilter === true, showFilter: cached.showFilter === true,
conditions: normalizeViewerFilterConditions(cached.conditions), 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: cached.sortInfo && cached.sortInfo.columnKey && (cached.sortInfo.order === 'ascend' || cached.sortInfo.order === 'descend')
? { columnKey: String(cached.sortInfo.columnKey), order: cached.sortInfo.order }
: null,
scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0,
scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0,
}; };
}; };
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]); const [columnNames, setColumnNames] = useState<string[]>([]);
const [pkColumns, setPkColumns] = useState<string[]>([]); const [pkColumns, setPkColumns] = useState<string[]>([]);
@@ -204,10 +222,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const latestDbNameRef = useRef<string>(''); const latestDbNameRef = useRef<string>('');
const latestCountSqlRef = useRef<string>(''); const latestCountSqlRef = useRef<string>('');
const latestCountKeyRef = useRef<string>(''); const latestCountKeyRef = useRef<string>('');
const scrollSnapshotRef = useRef<ViewerScrollSnapshot>({
top: initialViewerSnapshot.scrollTop,
left: initialViewerSnapshot.scrollLeft,
});
const initialLoadRef = useRef(false);
const [pagination, setPagination] = useState<ViewerPaginationState>({ const [pagination, setPagination] = useState<ViewerPaginationState>({
current: 1, current: initialViewerSnapshot.currentPage,
pageSize: 100, pageSize: initialViewerSnapshot.pageSize,
total: 0, total: 0,
totalKnown: false, totalKnown: false,
totalApprox: false, totalApprox: false,
@@ -215,10 +238,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
totalCountCancelled: false, totalCountCancelled: false,
}); });
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null); const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(initialViewerSnapshot.sortInfo);
const [showFilter, setShowFilter] = useState<boolean>(() => getViewerFilterSnapshot(tab.id).showFilter); const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(() => getViewerFilterSnapshot(tab.id).conditions); const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({}); const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config; const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
const currentConnCaps = getDataSourceCapabilities(currentConnConfig); const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
@@ -229,16 +252,25 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const snapshot = getViewerFilterSnapshot(tab.id); const snapshot = getViewerFilterSnapshot(tab.id);
setShowFilter(snapshot.showFilter); setShowFilter(snapshot.showFilter);
setFilterConditions(snapshot.conditions); setFilterConditions(snapshot.conditions);
setSortInfo(snapshot.sortInfo);
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
}, [tab.id]); }, [tab.id]);
useEffect(() => { useEffect(() => {
viewerFilterSnapshotsByTab.set(tab.id, { viewerFilterSnapshotsByTab.set(tab.id, {
showFilter, showFilter,
conditions: normalizeViewerFilterConditions(filterConditions), conditions: normalizeViewerFilterConditions(filterConditions),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
scrollTop: scrollSnapshotRef.current.top,
scrollLeft: scrollSnapshotRef.current.left,
}); });
}, [tab.id, showFilter, filterConditions]); }, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
useEffect(() => { useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
setPkColumns([]); setPkColumns([]);
pkKeyRef.current = ''; pkKeyRef.current = '';
countKeyRef.current = ''; countKeyRef.current = '';
@@ -250,16 +282,29 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
latestDbNameRef.current = ''; latestDbNameRef.current = '';
latestCountSqlRef.current = ''; latestCountSqlRef.current = '';
latestCountKeyRef.current = ''; latestCountKeyRef.current = '';
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
setPagination(prev => ({ setPagination(prev => ({
...prev, ...prev,
current: 1, current: snapshot.currentPage,
pageSize: snapshot.pageSize,
total: 0, total: 0,
totalKnown: false, totalKnown: false,
totalApprox: false, totalApprox: false,
totalCountLoading: false, totalCountLoading: false,
totalCountCancelled: false, totalCountCancelled: false,
})); }));
}, [tab.connectionId, tab.dbName, tab.tableName]); }, [tab.id, tab.connectionId, tab.dbName, tab.tableName]);
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
scrollSnapshotRef.current = snapshot;
const cached = getViewerFilterSnapshot(tab.id);
viewerFilterSnapshotsByTab.set(tab.id, {
...cached,
scrollTop: snapshot.top,
scrollLeft: snapshot.left,
});
}, [tab.id]);
const handleDuckDBManualCount = useCallback(async () => { const handleDuckDBManualCount = useCallback(async () => {
if (latestDbTypeRef.current !== 'duckdb') { if (latestDbTypeRef.current !== 'duckdb') {
@@ -765,8 +810,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]); }, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
useEffect(() => { useEffect(() => {
if (!initialLoadRef.current) {
initialLoadRef.current = true;
fetchData(pagination.current, pagination.pageSize);
return;
}
fetchData(1, pagination.pageSize); fetchData(1, pagination.pageSize);
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter }, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
return ( return (
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}> <div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
@@ -792,6 +842,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
readOnly={forceReadOnly} readOnly={forceReadOnly}
sortInfoExternal={sortInfo} sortInfoExternal={sortInfo}
exportSqlWithFilter={exportSqlWithFilter || undefined} exportSqlWithFilter={exportSqlWithFilter || undefined}
scrollSnapshot={scrollSnapshotRef.current}
onScrollSnapshotChange={handleTableScrollSnapshotChange}
/> />
</div> </div>
); );

View File

@@ -416,12 +416,7 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin
a.queryMu.Unlock() a.queryMu.Unlock()
}() }()
lowerQuery := strings.TrimSpace(strings.ToLower(query)) isReadQuery := isReadOnlySQLQuery(runConfig.Type, query)
isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain")
// MongoDB JSON 命令中的 find/count/aggregate 也属于读查询
if !isReadQuery && strings.ToLower(strings.TrimSpace(runConfig.Type)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") {
isReadQuery = true
}
runReadQuery := func(inst db.Database) ([]map[string]interface{}, []string, error) { runReadQuery := func(inst db.Database) ([]map[string]interface{}, []string, error) {
if q, ok := inst.(interface { if q, ok := inst.(interface {
@@ -500,11 +495,7 @@ func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string,
ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second) ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second)
defer cancel() defer cancel()
lowerQuery := strings.TrimSpace(strings.ToLower(query)) isReadQuery := isReadOnlySQLQuery(runConfig.Type, query)
isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain")
if !isReadQuery && strings.ToLower(strings.TrimSpace(runConfig.Type)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") {
isReadQuery = true
}
if isReadQuery { if isReadQuery {
var data []map[string]interface{} var data []map[string]interface{}

View File

@@ -5,6 +5,66 @@ import (
"unicode" "unicode"
) )
func leadingSQLKeyword(query string) string {
text := strings.TrimSpace(query)
for len(text) > 0 {
trimmed := strings.TrimLeft(text, " \t\r\n")
if trimmed == "" {
return ""
}
text = trimmed
switch {
case strings.HasPrefix(text, "--"):
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
text = text[idx+1:]
continue
}
return ""
case strings.HasPrefix(text, "#"):
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
text = text[idx+1:]
continue
}
return ""
case strings.HasPrefix(text, "/*"):
if idx := strings.Index(text, "*/"); idx >= 0 {
text = text[idx+2:]
continue
}
return ""
}
break
}
if text == "" {
return ""
}
for i, r := range text {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
continue
}
if i == 0 {
return ""
}
return strings.ToLower(text[:i])
}
return strings.ToLower(text)
}
func isReadOnlySQLQuery(dbType string, query string) bool {
if strings.ToLower(strings.TrimSpace(dbType)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") {
return true
}
switch leadingSQLKeyword(query) {
case "select", "with", "show", "describe", "desc", "explain", "pragma", "values":
return true
default:
return false
}
}
func sanitizeSQLForPgLike(dbType string, query string) string { func sanitizeSQLForPgLike(dbType string, query string) string {
switch strings.ToLower(strings.TrimSpace(dbType)) { switch strings.ToLower(strings.TrimSpace(dbType)) {
case "postgres", "kingbase", "highgo", "vastbase": case "postgres", "kingbase", "highgo", "vastbase":

View File

@@ -305,10 +305,30 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
return strings.ReplaceAll(s, "'", "''") return strings.ReplaceAll(s, "'", "''")
} }
query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default query := fmt.Sprintf(`
FROM information_schema.columns SELECT
WHERE table_schema = '%s' AND table_name = '%s' a.attname AS column_name,
ORDER BY ordinal_position`, esc(schema), esc(table)) pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
col_description(a.attrelid, a.attnum) AS comment,
CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
LEFT JOIN (
SELECT i.indrelid, a3.attname
FROM pg_index i
JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey)
WHERE i.indisprimary
) pk ON pk.indrelid = c.oid AND pk.attname = a.attname
WHERE c.relkind IN ('r', 'p')
AND n.nspname = '%s'
AND c.relname = '%s'
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum`, esc(schema), esc(table))
data, _, err := k.Query(query) data, _, err := k.Query(query)
if err != nil { if err != nil {
@@ -321,11 +341,21 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
Name: fmt.Sprintf("%v", row["column_name"]), Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]), Type: fmt.Sprintf("%v", row["data_type"]),
Nullable: fmt.Sprintf("%v", row["is_nullable"]), Nullable: fmt.Sprintf("%v", row["is_nullable"]),
Key: fmt.Sprintf("%v", row["column_key"]),
Extra: "",
Comment: "",
} }
if row["column_default"] != nil { if row["column_default"] != nil {
def := fmt.Sprintf("%v", row["column_default"]) def := fmt.Sprintf("%v", row["column_default"])
col.Default = &def col.Default = &def
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") {
col.Extra = "auto_increment"
}
}
if v, ok := row["comment"]; ok && v != nil {
col.Comment = fmt.Sprintf("%v", v)
} }
columns = append(columns, col) columns = append(columns, col)
@@ -347,10 +377,30 @@ func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection
} }
// 使用 current_schema() 获取当前schema // 使用 current_schema() 获取当前schema
query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default query := fmt.Sprintf(`
FROM information_schema.columns SELECT
WHERE table_schema = current_schema() AND table_name = '%s' a.attname AS column_name,
ORDER BY ordinal_position`, esc(table)) pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
col_description(a.attrelid, a.attnum) AS comment,
CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
LEFT JOIN (
SELECT i.indrelid, a3.attname
FROM pg_index i
JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey)
WHERE i.indisprimary
) pk ON pk.indrelid = c.oid AND pk.attname = a.attname
WHERE c.relkind IN ('r', 'p')
AND n.nspname = current_schema()
AND c.relname = '%s'
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum`, esc(table))
data, _, err := k.Query(query) data, _, err := k.Query(query)
if err != nil { if err != nil {
@@ -363,11 +413,21 @@ func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection
Name: fmt.Sprintf("%v", row["column_name"]), Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]), Type: fmt.Sprintf("%v", row["data_type"]),
Nullable: fmt.Sprintf("%v", row["is_nullable"]), Nullable: fmt.Sprintf("%v", row["is_nullable"]),
Key: fmt.Sprintf("%v", row["column_key"]),
Extra: "",
Comment: "",
} }
if row["column_default"] != nil { if row["column_default"] != nil {
def := fmt.Sprintf("%v", row["column_default"]) def := fmt.Sprintf("%v", row["column_default"])
col.Default = &def col.Default = &def
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") {
col.Extra = "auto_increment"
}
}
if v, ok := row["comment"]; ok && v != nil {
col.Comment = fmt.Sprintf("%v", v)
} }
columns = append(columns, col) columns = append(columns, col)
@@ -650,7 +710,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
} }
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND ")) query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil { if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err) return fmt.Errorf("delete error: %v; sql=%s", err, query)
} }
} }
@@ -683,7 +743,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND ")) query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil { if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err) return fmt.Errorf("update error: %v; sql=%s", err, query)
} }
} }
@@ -707,7 +767,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", ")) query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil { if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err) return fmt.Errorf("insert error: %v; sql=%s", err, query)
} }
} }

View File

@@ -1,4 +1,4 @@
package db package db
import ( import (
"encoding/hex" "encoding/hex"
@@ -31,12 +31,44 @@ func normalizeQueryValue(v interface{}) interface{} {
} }
func normalizeQueryValueWithDBType(v interface{}, databaseTypeName string) interface{} { func normalizeQueryValueWithDBType(v interface{}, databaseTypeName string) interface{} {
if tm, ok := v.(time.Time); ok {
return normalizeTemporalValueForDisplay(tm, databaseTypeName)
}
if b, ok := v.([]byte); ok { if b, ok := v.([]byte); ok {
return bytesToDisplayValue(b, databaseTypeName) return bytesToDisplayValue(b, databaseTypeName)
} }
return normalizeCompositeQueryValue(v) return normalizeCompositeQueryValue(v)
} }
func normalizeTemporalValueForDisplay(value time.Time, databaseTypeName string) interface{} {
if value.IsZero() {
if zeroValue, ok := zeroTemporalDisplayValue(databaseTypeName); ok {
return zeroValue
}
}
return value.Format(time.RFC3339Nano)
}
func zeroTemporalDisplayValue(databaseTypeName string) (string, bool) {
typeName := strings.ToUpper(strings.TrimSpace(databaseTypeName))
if typeName == "" {
return "0000-00-00 00:00:00", true
}
switch {
case strings.Contains(typeName, "TIMESTAMP") || strings.Contains(typeName, "DATETIME"):
return "0000-00-00 00:00:00", true
case typeName == "DATE" || typeName == "NEWDATE":
return "0000-00-00", true
case strings.Contains(typeName, "TIME"):
return "00:00:00", true
case strings.Contains(typeName, "YEAR"):
return "0000", true
default:
return "", false
}
}
func normalizeCompositeQueryValue(v interface{}) interface{} { func normalizeCompositeQueryValue(v interface{}) interface{} {
if v == nil { if v == nil {
return nil return nil
@@ -91,7 +123,7 @@ func normalizeCompositeQueryValue(v interface{}) interface{} {
// 部分驱动(如 Kingbase会返回复杂结构体值直接透传会导致前端渲染和比较开销激增。 // 部分驱动(如 Kingbase会返回复杂结构体值直接透传会导致前端渲染和比较开销激增。
// 统一降级为可读字符串,避免对象深层序列化触发 UI 卡顿。 // 统一降级为可读字符串,避免对象深层序列化触发 UI 卡顿。
if tm, ok := v.(time.Time); ok { if tm, ok := v.(time.Time); ok {
return tm.Format(time.RFC3339Nano) return normalizeTemporalValueForDisplay(tm, "")
} }
if stringer, ok := v.(fmt.Stringer); ok { if stringer, ok := v.(fmt.Stringer); ok {
return stringer.String() return stringer.String()

View File

@@ -195,3 +195,33 @@ func TestNormalizeQueryValueWithDBType_TimeStructToRFC3339(t *testing.T) {
t.Fatalf("time.Time 规整值异常,实际=%s", text) t.Fatalf("time.Time 规整值异常,实际=%s", text)
} }
} }
func TestNormalizeQueryValueWithDBType_ZeroTemporalValues(t *testing.T) {
zero := time.Time{}
cases := []struct {
name string
dbType string
wantText string
}{
{name: "date", dbType: "DATE", wantText: "0000-00-00"},
{name: "newdate", dbType: "NEWDATE", wantText: "0000-00-00"},
{name: "datetime", dbType: "DATETIME", wantText: "0000-00-00 00:00:00"},
{name: "timestamp", dbType: "TIMESTAMP", wantText: "0000-00-00 00:00:00"},
{name: "time", dbType: "TIME", wantText: "00:00:00"},
{name: "year", dbType: "YEAR", wantText: "0000"},
{name: "unknown", dbType: "", wantText: "0000-00-00 00:00:00"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := normalizeQueryValueWithDBType(zero, tc.dbType)
text, ok := got.(string)
if !ok {
t.Fatalf("期望 string实际=%v(%T)", got, got)
}
if text != tc.wantText {
t.Fatalf("dbType=%s 期望=%s实际=%s", tc.dbType, tc.wantText, text)
}
})
}
}