mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-29 19:32:19 +08:00
Compare commits
27 Commits
release/0.
...
feature/ki
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a830c7a82 | ||
|
|
adab24085e | ||
|
|
cc201f3df6 | ||
|
|
57d6ace2f8 | ||
|
|
0f5dc2184d | ||
|
|
0b95908ae9 | ||
|
|
4c322818bc | ||
|
|
3fefa13023 | ||
|
|
75a5a322e0 | ||
|
|
1be003b0a2 | ||
|
|
0a0609c459 | ||
|
|
0682baa14d | ||
|
|
fb65b553e9 | ||
|
|
b53227cb15 | ||
|
|
6c41e15e99 | ||
|
|
ba51fa658c | ||
|
|
18ed4ca50c | ||
|
|
37704e2b3b | ||
|
|
6b9104fae8 | ||
|
|
647768221e | ||
|
|
251e1b22d7 | ||
|
|
c99d4b1fa6 | ||
|
|
ff98ec79a4 | ||
|
|
072b4e6e78 | ||
|
|
2449184ad3 | ||
|
|
6ae49d4b84 | ||
|
|
5c23722ad8 |
342
.github/workflows/test-build-all-platforms.yml
vendored
Normal file
342
.github/workflows/test-build-all-platforms.yml
vendored
Normal 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
|
||||||
91
.github/workflows/test-macos-build.yml
vendored
91
.github/workflows/test-macos-build.yml
vendored
@@ -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
|
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
fetchData(1, pagination.pageSize);
|
if (!initialLoadRef.current) {
|
||||||
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
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 (
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user