Compare commits
63 Commits
release/0.
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da28207168 | ||
|
|
87cfbee6d3 | ||
|
|
0100b771b0 | ||
|
|
1758d6f918 | ||
|
|
b86cfcacaa | ||
|
|
7d543e06c6 | ||
|
|
17e4e3ad1c | ||
|
|
84579b83c9 | ||
|
|
7ddef7096b | ||
|
|
557178f182 | ||
|
|
a1b546ddd9 | ||
|
|
da5e879409 | ||
|
|
8935ad2905 | ||
|
|
cd5a0e85e8 | ||
|
|
ccb9f09452 | ||
|
|
5afd80c559 | ||
|
|
1b36f60821 | ||
|
|
eaa76d8f04 | ||
|
|
0f717706b0 | ||
|
|
8950081a6c | ||
|
|
3bf8758418 | ||
|
|
561d3810da | ||
|
|
18cb66b893 | ||
|
|
ab61e703b1 | ||
|
|
7933b4c315 | ||
|
|
c99f857d0a | ||
|
|
2c3f4a1032 | ||
|
|
72de16995a | ||
|
|
0adc8411fa | ||
|
|
8efa7e2de6 | ||
|
|
ecee206304 | ||
|
|
299dceb01c | ||
|
|
5cad761bdd | ||
|
|
b8728170ec | ||
|
|
4ce4cdaad8 | ||
|
|
cc7ef12029 | ||
|
|
5b6403f266 | ||
|
|
caceb2868d | ||
|
|
e7b9ff4a10 | ||
|
|
76f65cb96c | ||
|
|
8bdc6e8086 | ||
|
|
1eb2f6dffe | ||
|
|
5c5e1fc68f | ||
|
|
fb70f1420c | ||
|
|
d75596921c | ||
|
|
d251594fd9 | ||
|
|
7598bf372b | ||
|
|
64021ffd2a | ||
|
|
fbd785400f | ||
|
|
b573fd95cc | ||
|
|
a097d96380 | ||
|
|
6ee0fea110 | ||
|
|
e6b822c967 | ||
|
|
0ab10d2e80 | ||
|
|
064cdc34be | ||
|
|
c62f4b7d3c | ||
|
|
304a4926d2 | ||
|
|
cabf84a041 | ||
|
|
9b02720169 | ||
|
|
eb36dcc5a2 | ||
|
|
1a3f137438 | ||
|
|
5f94cd3911 | ||
|
|
bb257c35bc |
86
.github/workflows/release.yml
vendored
@@ -95,13 +95,21 @@ jobs:
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
choco install upx --no-progress -y
|
||||
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
|
||||
if ($null -eq $upxCmd) {
|
||||
$UPX_VERSION = "4.2.4"
|
||||
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
|
||||
$zipPath = "$env:RUNNER_TEMP\upx.zip"
|
||||
$extractPath = "$env:RUNNER_TEMP\upx"
|
||||
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||||
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
|
||||
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
|
||||
if (!(Test-Path $upxCmd)) {
|
||||
Write-Error "❌ 未检测到 upx,无法保证 Windows 产物经过压缩"
|
||||
exit 1
|
||||
}
|
||||
& upx --version
|
||||
& $upxCmd --version
|
||||
|
||||
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
|
||||
- name: Install Linux Dependencies
|
||||
@@ -605,6 +613,74 @@ jobs:
|
||||
sha256sum "${FILES[@]}" > SHA256SUMS
|
||||
fi
|
||||
|
||||
- name: Checkout code for changelog
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: repo-for-changelog
|
||||
|
||||
- name: Generate Changelog
|
||||
id: changelog
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd repo-for-changelog
|
||||
TAG="${{ github.ref_name }}"
|
||||
# 获取上一个 tag
|
||||
PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "⚠️ 未找到上一个 tag,使用全部 commit"
|
||||
RANGE="$TAG"
|
||||
else
|
||||
RANGE="${PREV_TAG}..${TAG}"
|
||||
fi
|
||||
|
||||
echo "📋 生成更新日志:$RANGE"
|
||||
|
||||
# 提取 commit 消息(排除 merge commit)
|
||||
COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true)
|
||||
if [ -z "$COMMITS" ]; then
|
||||
BODY="暂无提交记录。"
|
||||
else
|
||||
CAT_FEAT=""
|
||||
CAT_FIX=""
|
||||
CAT_PERF=""
|
||||
CAT_REFACTOR=""
|
||||
CAT_I18N=""
|
||||
CAT_OTHER=""
|
||||
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
case "$line" in
|
||||
✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;;
|
||||
🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;;
|
||||
⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;;
|
||||
♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;;
|
||||
🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;;
|
||||
🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
|
||||
*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
|
||||
esac
|
||||
done <<< "$COMMITS"
|
||||
|
||||
BODY=""
|
||||
[ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n"
|
||||
[ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n"
|
||||
[ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n"
|
||||
[ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n"
|
||||
[ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n"
|
||||
[ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n"
|
||||
|
||||
# 附加 compare 链接
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
|
||||
BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 写入到文件避免多行环境变量问题
|
||||
printf '%b' "$BODY" > /tmp/changelog.md
|
||||
echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
@@ -612,6 +688,6 @@ jobs:
|
||||
files: release-assets/*
|
||||
draft: true
|
||||
make_latest: true
|
||||
generate_release_notes: true
|
||||
body_path: ${{ steps.changelog.outputs.changelog_file }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
18
.github/workflows/test-build-all-platforms.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Test Build All Platforms (Manual)
|
||||
name: Test Build All Platforms (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -100,13 +100,21 @@ jobs:
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
choco install upx --no-progress -y
|
||||
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
|
||||
if ($null -eq $upxCmd) {
|
||||
$UPX_VERSION = "4.2.4"
|
||||
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
|
||||
$zipPath = "$env:RUNNER_TEMP\upx.zip"
|
||||
$extractPath = "$env:RUNNER_TEMP\upx"
|
||||
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||||
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
|
||||
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
|
||||
if (!(Test-Path $upxCmd)) {
|
||||
Write-Error "❌ 未检测到 upx,无法保证 Windows 测试产物经过压缩"
|
||||
exit 1
|
||||
}
|
||||
& upx --version
|
||||
& $upxCmd --version
|
||||
|
||||
- name: Install Linux Dependencies
|
||||
if: contains(matrix.platform, 'linux')
|
||||
|
||||
4
.gitignore
vendored
@@ -17,8 +17,10 @@ dist/
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
.ace-tool/
|
||||
.superpowers/
|
||||
.claude/
|
||||
tmpclaude-*
|
||||
.gemini/
|
||||
**/tmpclaude-*
|
||||
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
|
||||
@@ -1 +1 @@
|
||||
d0f9366af59a6367ad3c7e2d4185ead4
|
||||
5b8157374dae5f9340e31b2d0bd2c00e
|
||||
1
frontend/public/db-icons/clickhouse.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>ClickHouse</title><path d="M21.333 10H24v4h-2.667ZM16 1.335h2.667v21.33H16Zm-5.333 0h2.666v21.33h-2.666ZM0 22.665V1.335h2.667v21.33zm5.333-21.33H8v21.33H5.333Z"/></svg>
|
||||
|
After Width: | Height: | Size: 246 B |
1
frontend/public/db-icons/diros.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apache Doris</title><path d="M8.666.0001c-.5355-.004-1.068.1072-1.5241.3384-.207.1048-.5749.3802-.8177.6118-1.0278.9803-1.2876 2.5138-.6553 3.8679.205.439.5068.7694 2.8476 3.1166 2.4527 2.4594 2.6352 2.6255 2.8852 2.6258.2446.0003.3647-.099 1.4408-1.19.9367-.9496 1.2306-1.2992 1.4536-1.7286.5966-1.149.6487-2.0513.174-3.014-.2264-.459-.4816-.7514-1.9012-2.176-.9018-.9052-1.7907-1.7496-1.9751-1.8765C10.0488.2005 9.3548.0052 8.666 0ZM3.5518 5.5737c-.2176.0031-.6097.085-.6097.3285v12.0904l.1642.175c.1123.1194.2498.1748.4342.1748.2545 0 .4436-.1738 3.349-3.0786 2.6868-2.6862 3.079-2.909 3.0791-3.305.0002-.3961-.3924-.6194-3.0784-3.306-2.8612-2.8619-3.0968-3.079-3.3384-3.079Zm13.0967.861c-.0481.0184-.112.1636-.1418.3225-.0756.403-.3719 1.109-.6572 1.5663-.1407.2253-2.2392 2.3955-5.049 5.2212-2.7513 2.7667-4.9104 4.9985-5.0468 5.2165-.4552.7275-.5967 1.3905-.4684 2.1964.222 1.3947 1.3263 2.6812 2.5486 2.9693.4667.11 1.618.0927 2.0329-.0305.2084-.062.526-.2112.7055-.3318.5023-.3373 9.341-9.0562 9.6463-9.5154.449-.6753.8356-1.0716.8395-1.9762-.0056-.5935-.1305-1.1138-1.0715-2.306-.5094-.6523-3.2341-3.3723-3.338-3.3324Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
frontend/public/db-icons/duckdb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>DuckDB</title><path d="M12 0C5.363 0 0 5.363 0 12s5.363 12 12 12 12-5.363 12-12S18.637 0 12 0zM9.502 7.03a4.974 4.974 0 0 1 4.97 4.97 4.974 4.974 0 0 1-4.97 4.97A4.974 4.974 0 0 1 4.532 12a4.974 4.974 0 0 1 4.97-4.97zm6.563 3.183h2.351c.98 0 1.787.782 1.787 1.762s-.807 1.789-1.787 1.789h-2.351v-3.551z"/></svg>
|
||||
|
After Width: | Height: | Size: 389 B |
1
frontend/public/db-icons/mariadb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MariaDB</title><path d="M23.157 4.412c-.676.284-.79.31-1.673.372-.65.045-.757.057-1.212.209-.75.246-1.395.75-2.02 1.59-.296.398-1.249 1.913-1.249 1.988 0 .057-.65.998-.915 1.32-.574.713-1.08 1.079-2.14 1.59-.77.36-1.224.524-4.102 1.477-1.073.353-2.133.738-2.367.864-.852.449-1.515 1.036-2.203 1.938-1.003 1.32-.972 1.313-3.042.947a12.264 12.264 0 00-.675-.063c-.644-.05-1.023.044-1.332.334L0 17.193l.177.088c.094.05.353.234.561.398.215.17.461.347.55.391.088.044.17.088.183.101.012.013-.089.17-.228.353-.435.581-.593.871-.574 1.048.019.164.032.17.43.17.517-.006.826-.056 1.261-.208.65-.233 2.058-.94 2.784-1.4.776-.5 1.717-.998 1.956-1.042.082-.02.354-.07.594-.114.58-.107 1.464-.095 2.587.05.108.013.373.045.6.064.227.025.43.057.454.076.026.012.474.037.998.056.934.026 1.104.007 1.3-.189.126-.133.385-.631.498-.985.209-.643.417-.921.366-.492-.113.966-.322 1.692-.713 2.411-.259.499-.663 1.092-.934 1.395-.322.347-.315.36.088.315.619-.063 1.471-.397 2.096-.82.827-.562 1.647-1.691 2.19-3.03.107-.27.22-.22.183.083-.013.094-.038.315-.057.498l-.031.328.353-.202c.833-.48 1.414-1.262 2.127-2.884.227-.518.877-2.922 1.073-3.976a9.64 9.64 0 01.271-1.042c.127-.429.196-.555.48-.858.183-.19.625-.555.978-.808.72-.505.953-.75 1.187-1.205.208-.417.284-1.13.132-1.357-.132-.202-.284-.196-.763.006Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/db-icons/mongodb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MongoDB</title><path d="M17.193 9.555c-1.264-5.58-4.252-7.414-4.573-8.115-.28-.394-.53-.954-.735-1.44-.036.495-.055.685-.523 1.184-.723.566-4.438 3.682-4.74 10.02-.282 5.912 4.27 9.435 4.888 9.884l.07.05A73.49 73.49 0 0111.91 24h.481c.114-1.032.284-2.056.51-3.07.417-.296.604-.463.85-.693a11.342 11.342 0 003.639-8.464c.01-.814-.103-1.662-.197-2.218zm-5.336 8.195s0-8.291.275-8.29c.213 0 .49 10.695.49 10.695-.381-.045-.765-1.76-.765-2.405z"/></svg>
|
||||
|
After Width: | Height: | Size: 527 B |
1
frontend/public/db-icons/mysql.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MySQL</title><path d="M16.405 5.501c-.115 0-.193.014-.274.033v.013h.014c.054.104.146.18.214.273.054.107.1.214.154.32l.014-.015c.094-.066.14-.172.14-.333-.04-.047-.046-.094-.08-.14-.04-.067-.126-.1-.18-.153zM5.77 18.695h-.927a50.854 50.854 0 00-.27-4.41h-.008l-1.41 4.41H2.45l-1.4-4.41h-.01a72.892 72.892 0 00-.195 4.41H0c.055-1.966.192-3.81.41-5.53h1.15l1.335 4.064h.008l1.347-4.064h1.095c.242 2.015.384 3.86.428 5.53zm4.017-4.08c-.378 2.045-.876 3.533-1.492 4.46-.482.716-1.01 1.073-1.583 1.073-.153 0-.34-.046-.566-.138v-.494c.11.017.24.026.386.026.268 0 .483-.075.647-.222.197-.18.295-.382.295-.605 0-.155-.077-.47-.23-.944L6.23 14.615h.91l.727 2.36c.164.536.233.91.205 1.123.4-1.064.678-2.227.835-3.483zm12.325 4.08h-2.63v-5.53h.885v4.85h1.745zm-3.32.135l-1.016-.5c.09-.076.177-.158.255-.25.433-.506.648-1.258.648-2.253 0-1.83-.718-2.746-2.155-2.746-.704 0-1.254.232-1.65.697-.43.508-.646 1.256-.646 2.245 0 .972.19 1.686.574 2.14.35.41.877.615 1.583.615.264 0 .506-.033.725-.098l1.325.772.36-.622zM15.5 17.588c-.225-.36-.337-.94-.337-1.736 0-1.393.424-2.09 1.27-2.09.443 0 .77.167.977.5.224.362.336.936.336 1.723 0 1.404-.424 2.108-1.27 2.108-.445 0-.77-.167-.978-.5zm-1.658-.425c0 .47-.172.856-.516 1.156-.344.3-.803.45-1.384.45-.543 0-1.064-.172-1.573-.515l.237-.476c.438.22.833.328 1.19.328.332 0 .593-.073.783-.22a.754.754 0 00.3-.615c0-.33-.23-.61-.648-.845-.388-.213-1.163-.657-1.163-.657-.422-.307-.632-.636-.632-1.177 0-.45.157-.81.47-1.085.315-.278.72-.415 1.22-.415.512 0 .98.136 1.4.41l-.213.476a2.726 2.726 0 00-1.064-.23c-.283 0-.502.068-.654.206a.685.685 0 00-.248.524c0 .328.234.61.666.85.393.215 1.187.67 1.187.67.433.305.648.63.648 1.168zm9.382-5.852c-.535-.014-.95.04-1.297.188-.1.04-.26.04-.274.167.055.053.063.14.11.214.08.134.218.313.346.407.14.11.28.216.427.31.26.16.555.255.81.416.145.094.293.213.44.313.073.05.12.14.214.172v-.02c-.046-.06-.06-.147-.105-.214-.067-.067-.134-.127-.2-.193a3.223 3.223 0 00-.695-.675c-.214-.146-.682-.35-.77-.595l-.013-.014c.146-.013.32-.066.46-.106.227-.06.435-.047.67-.106.106-.027.213-.06.32-.094v-.06c-.12-.12-.21-.283-.334-.395a8.867 8.867 0 00-1.104-.823c-.21-.134-.476-.22-.697-.334-.08-.04-.214-.06-.26-.127-.12-.146-.19-.34-.275-.514a17.69 17.69 0 01-.547-1.163c-.12-.262-.193-.523-.34-.763-.69-1.137-1.437-1.826-2.586-2.5-.247-.14-.543-.2-.856-.274-.167-.008-.334-.02-.5-.027-.11-.047-.216-.174-.31-.235-.38-.24-1.364-.76-1.644-.072-.18.434.267.862.422 1.082.115.153.26.328.34.5.047.116.06.235.107.356.106.294.207.622.347.897.073.14.153.287.247.413.054.073.146.107.167.227-.094.136-.1.334-.154.5-.24.757-.146 1.693.194 2.25.107.166.362.534.703.393.3-.12.234-.5.32-.835.02-.08.007-.133.048-.187v.015c.094.188.188.367.274.555.206.328.566.668.867.895.16.12.287.328.487.402v-.02h-.015c-.043-.058-.1-.086-.154-.133a3.445 3.445 0 01-.35-.4 8.76 8.76 0 01-.747-1.218c-.11-.21-.202-.436-.29-.643-.04-.08-.04-.2-.107-.24-.1.146-.247.273-.32.453-.127.288-.14.642-.188 1.01-.027.007-.014 0-.027.014-.214-.052-.287-.274-.367-.46-.2-.475-.233-1.238-.06-1.785.047-.14.247-.582.167-.716-.042-.127-.174-.2-.247-.303a2.478 2.478 0 01-.24-.427c-.16-.374-.24-.788-.414-1.162-.08-.173-.22-.354-.334-.513-.127-.18-.267-.307-.368-.52-.033-.073-.08-.194-.027-.274.014-.054.042-.075.094-.09.088-.072.335.022.422.062.247.1.455.194.662.334.094.066.195.193.315.226h.14c.214.047.455.014.655.073.355.114.675.28.962.46a5.953 5.953 0 012.085 2.286c.08.154.115.295.188.455.14.33.313.663.455.982.14.315.275.636.476.897.1.14.502.213.682.286.133.06.34.115.46.188.23.14.454.3.67.454.11.076.443.243.463.378z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
1
frontend/public/db-icons/postgres.svg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
1
frontend/public/db-icons/redis.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Redis</title><path d="M22.71 13.145c-1.66 2.092-3.452 4.483-7.038 4.483-3.203 0-4.397-2.825-4.48-5.12.701 1.484 2.073 2.685 4.214 2.63 4.117-.133 6.94-3.852 6.94-7.239 0-4.05-3.022-6.972-8.268-6.972-3.752 0-8.4 1.428-11.455 3.685C2.59 6.937 3.885 9.958 4.35 9.626c2.648-1.904 4.748-3.13 6.784-3.744C8.12 9.244.886 17.05 0 18.425c.1 1.261 1.66 4.648 2.424 4.648.232 0 .431-.133.664-.365a100.49 100.49 0 0 0 5.54-6.765c.222 3.104 1.748 6.898 6.014 6.898 3.819 0 7.604-2.756 9.33-8.965.2-.764-.73-1.361-1.261-.73zm-4.349-5.013c0 1.959-1.926 2.922-3.685 2.922-.941 0-1.664-.247-2.235-.568 1.051-1.592 2.092-3.225 3.21-4.973 1.972.334 2.71 1.43 2.71 2.619z"/></svg>
|
||||
|
After Width: | Height: | Size: 738 B |
1
frontend/public/db-icons/sphinx.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Sphinx</title><path d="M16.284 19.861c0-.654.177-1.834.393-2.623.499-1.822.774-4.079.497-4.079-.116 0-.959.762-1.873 1.694-3.472 3.54-7.197 5.543-10.312 5.543-1.778 0-2.987-.45-4.154-1.545C.128 18.186 0 17.858 0 16.703c0-1.188.117-1.468.909-2.175.718-.642 1.171-.813 2.157-.813.76.171 1.21.16 1.457.461.251.296.338 1.265.035 1.832-.162.303-.585.491-1.105.491-.49 0-.77-.116-.669-.278.315-.511-.135-.857-.713-.548-.699.374-.711 1.698-.021 2.322.969.878 3.65 1.208 5.262.648 1.743-.605 4.022-2.061 5.841-3.732l1.6-1.469-2.088-.013c-2.186-.012-3.608-.273-8.211-1.506-1.531-.41-3.003-.765-3.271-.789-.304-.026-.503-.274-.487-.656.027-.646.378-1.127.793-1.308.249-.109 1.977-.274 3.809-.761 7.136-1.898 7.569-1.629 12.323-.426 1.553.393 3.351.821 4.147.835 1.227.022 1.493.124 1.74.666.16.351.291.686.291.745 0 .058-.695.424-1.545.813-3.12 1.428-4.104 2.185-3.088 3.635.421.602.412.666-.14 1.052-.323.227-.59.687-.593 1.022-.009.908-.583 2.856-1.417 3.624l-.732.675v-1.189Zm1.594-8.328c1.242-.346 1.994-.738 3.539-1.562-1.272-.372-4.462-.895-4.462-.895-2.354-.472-2.108-.448-2.214.071a3.475 3.475 0 0 1-.45 1.105c-.541.848-2.521 1.026-3.656.483-.356-.171-.714-.821-.709-1.283.007-.65-.362-.801-.598-.714-.191.07-.813.079-2.179.448-4.514 1.217-5.132 1.078-2.189 1.495.353.05 2.223.572 3.136.815 2.239.597 2.658.641 5.556.581 2.015-.042 2.858-.163 4.226-.544ZM.732 6.258c.056-.577.088-.702 1.692-1.025.919-.185 3.185-.785 5.036-1.333 4.254-1.26 5.462-1.263 9.873-.026 1.904.535 4.037.973 4.74.975 1.097.002 1.668.487 1.668.487.505 1.16.412 1.24-1.558 1.24-1.374 0-2.558-.232-4.385-.857-1.389-.476-3.369-.923-4.451-1.004-1.974-.149-1.971-.15-8.072 1.529-1.072.295-2.553.624-3.29.732l-1.342.196.089-.914Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/public/db-icons/sqlite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>SQLite</title><path d="M21.678.521c-1.032-.92-2.28-.55-3.513.544a8.71 8.71 0 0 0-.547.535c-2.109 2.237-4.066 6.38-4.674 9.544.237.48.422 1.093.544 1.561a13.044 13.044 0 0 1 .164.703s-.019-.071-.096-.296l-.05-.146a1.689 1.689 0 0 0-.033-.08c-.138-.32-.518-.995-.686-1.289-.143.423-.27.818-.376 1.176.484.884.778 2.4.778 2.4s-.025-.099-.147-.442c-.107-.303-.644-1.244-.772-1.464-.217.804-.304 1.346-.226 1.478.152.256.296.698.422 1.186.286 1.1.485 2.44.485 2.44l.017.224a22.41 22.41 0 0 0 .056 2.748c.095 1.146.273 2.13.5 2.657l.155-.084c-.334-1.038-.47-2.399-.41-3.967.09-2.398.642-5.29 1.661-8.304 1.723-4.55 4.113-8.201 6.3-9.945-1.993 1.8-4.692 7.63-5.5 9.788-.904 2.416-1.545 4.684-1.931 6.857.666-2.037 2.821-2.912 2.821-2.912s1.057-1.304 2.292-3.166c-.74.169-1.955.458-2.362.629-.6.251-.762.337-.762.337s1.945-1.184 3.613-1.72C21.695 7.9 24.195 2.767 21.678.521m-18.573.543A1.842 1.842 0 0 0 1.27 2.9v16.608a1.84 1.84 0 0 0 1.835 1.834h9.418a22.953 22.953 0 0 1-.052-2.707c-.006-.062-.011-.141-.016-.2a27.01 27.01 0 0 0-.473-2.378c-.121-.47-.275-.898-.369-1.057-.116-.197-.098-.31-.097-.432 0-.12.015-.245.037-.386a9.98 9.98 0 0 1 .234-1.045l.217-.028c-.017-.035-.014-.065-.031-.097l-.041-.381a32.8 32.8 0 0 1 .382-1.194l.2-.019c-.008-.016-.01-.038-.018-.053l-.043-.316c.63-3.28 2.587-7.443 4.8-9.791.066-.069.133-.128.198-.194Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -37,6 +37,91 @@ body, #root {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-tree {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner,
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner .ant-tree-treenode {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper {
|
||||
min-height: 36px;
|
||||
border-radius: 14px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:hover,
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:active,
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus,
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus-visible,
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-treenode {
|
||||
padding: 2px 0;
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
border: none;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
display: flex !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-switcher {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
margin-inline-end: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-switcher:hover,
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-switcher:active,
|
||||
.redis-viewer-workbench .ant-tree .ant-tree-switcher:focus {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .redis-tree-expander-button:hover,
|
||||
.redis-viewer-workbench .redis-tree-expander-button:focus-visible {
|
||||
background: transparent !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper {
|
||||
border-radius: 10px;
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper:last-child {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-table-wrapper .ant-table-thead > tr > th {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark mode */
|
||||
body[data-theme='dark'] ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
@@ -97,6 +182,16 @@ body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-s
|
||||
color: rgba(255, 236, 179, 0.98) !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
|
||||
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
|
||||
background: linear-gradient(90deg, rgba(246, 196, 83, 0.22), rgba(246, 196, 83, 0.08)) !important;
|
||||
border: 1px solid rgba(246, 196, 83, 0.24) !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: #f6c453 !important;
|
||||
border-color: #f6c453 !important;
|
||||
@@ -135,6 +230,41 @@ body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:h
|
||||
background: rgba(246, 196, 83, 0.26) !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(230, 234, 242, 0.9);
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
|
||||
background: rgba(246, 196, 83, 0.16);
|
||||
border-color: rgba(246, 196, 83, 0.3);
|
||||
color: #f6c453;
|
||||
}
|
||||
|
||||
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
|
||||
background: rgba(15, 23, 42, 0.04) !important;
|
||||
}
|
||||
|
||||
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
|
||||
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
|
||||
color: rgba(15, 23, 42, 0.92) !important;
|
||||
background: linear-gradient(90deg, rgba(22, 119, 255, 0.12), rgba(22, 119, 255, 0.04)) !important;
|
||||
border: 1px solid rgba(22, 119, 255, 0.18) !important;
|
||||
}
|
||||
|
||||
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-color: rgba(15, 23, 42, 0.08);
|
||||
color: rgba(51, 65, 85, 0.88);
|
||||
}
|
||||
|
||||
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
|
||||
background: rgba(22, 119, 255, 0.1);
|
||||
border-color: rgba(22, 119, 255, 0.22);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
|
||||
.connection-modal-wrap {
|
||||
overflow: hidden !important;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
@@ -12,6 +12,7 @@ import LogPanel from './components/LogPanel';
|
||||
import { useStore } from './store';
|
||||
import { SavedConnection } from './types';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
|
||||
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SHORTCUT_ACTION_META,
|
||||
SHORTCUT_ACTION_ORDER,
|
||||
@@ -88,6 +89,8 @@ function App() {
|
||||
const [runtimePlatform, setRuntimePlatform] = useState('');
|
||||
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
|
||||
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
|
||||
const sidebarWidth = useStore(state => state.sidebarWidth);
|
||||
const setSidebarWidth = useStore(state => state.setSidebarWidth);
|
||||
const globalProxyInvalidHintShownRef = React.useRef(false);
|
||||
|
||||
// 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView,
|
||||
@@ -283,14 +286,43 @@ function App() {
|
||||
}, applyRetryDelayMs);
|
||||
};
|
||||
|
||||
const restoreWindowState = async () => {
|
||||
if (cancelled) return;
|
||||
const state = useStore.getState();
|
||||
// startupFullscreen 设置优先
|
||||
if (state.startupFullscreen) {
|
||||
applyStartupWindowPreference(1);
|
||||
return;
|
||||
}
|
||||
// 根据上次保存的窗口状态恢复
|
||||
const savedState = state.windowState;
|
||||
if (savedState === 'fullscreen') {
|
||||
applyStartupWindowPreference(1);
|
||||
return;
|
||||
}
|
||||
if (savedState === 'maximized') {
|
||||
try { await WindowMaximise(); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
// 普通窗口:恢复尺寸和位置
|
||||
const bounds = state.windowBounds;
|
||||
if (!bounds || bounds.width < 400 || bounds.height < 300) return;
|
||||
try {
|
||||
WindowSetSize(bounds.width, bounds.height);
|
||||
WindowSetPosition(bounds.x, bounds.y);
|
||||
} catch (e) {
|
||||
console.warn('Failed to restore window bounds', e);
|
||||
}
|
||||
};
|
||||
|
||||
if (useStore.persist.hasHydrated()) {
|
||||
applyStartupWindowPreference(1);
|
||||
void restoreWindowState();
|
||||
}
|
||||
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
applyStartupWindowPreference(1);
|
||||
void restoreWindowState();
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -302,6 +334,52 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 定时保存窗口状态、尺寸与位置
|
||||
useEffect(() => {
|
||||
const SAVE_INTERVAL_MS = 2000;
|
||||
let lastSaved = '';
|
||||
|
||||
const saveWindowState = async () => {
|
||||
try {
|
||||
const [isFs, isMax] = await Promise.all([
|
||||
WindowIsFullscreen().catch(() => false),
|
||||
WindowIsMaximised().catch(() => false),
|
||||
]);
|
||||
|
||||
// 保存窗口状态
|
||||
const store = useStore.getState();
|
||||
const newState = isFs ? 'fullscreen' : (isMax ? 'maximized' : 'normal');
|
||||
if (store.windowState !== newState) {
|
||||
store.setWindowState(newState);
|
||||
}
|
||||
|
||||
// 只在普通窗口模式下保存尺寸和位置
|
||||
if (isFs || isMax) return;
|
||||
|
||||
const [size, pos] = await Promise.all([
|
||||
WindowGetSize().catch(() => null),
|
||||
WindowGetPosition().catch(() => null),
|
||||
]);
|
||||
if (!size || !pos) return;
|
||||
const w = Math.trunc(Number(size.w || 0));
|
||||
const h = Math.trunc(Number(size.h || 0));
|
||||
const x = Math.trunc(Number(pos.x || 0));
|
||||
const y = Math.trunc(Number(pos.y || 0));
|
||||
if (w < 400 || h < 300) return;
|
||||
|
||||
const key = `${w},${h},${x},${y}`;
|
||||
if (key === lastSaved) return;
|
||||
lastSaved = key;
|
||||
store.setWindowBounds({ width: w, height: h, x, y });
|
||||
} catch (e) {
|
||||
// 静默忽略
|
||||
}
|
||||
};
|
||||
|
||||
const timer = window.setInterval(saveWindowState, SAVE_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWindowsPlatform()) {
|
||||
return;
|
||||
@@ -441,7 +519,6 @@ function App() {
|
||||
const floatingLogButtonShadow = darkMode
|
||||
? '0 8px 22px rgba(0,0,0,0.38)'
|
||||
: '0 8px 20px rgba(0,0,0,0.16)';
|
||||
|
||||
const isOpaqueUtilityMode = resolvedAppearance.opacity >= 0.999 && resolvedAppearance.blur <= 0;
|
||||
const utilityButtonBgAlpha = darkMode
|
||||
? Math.max(0.28, Math.min(0.76, effectiveOpacity * 0.72))
|
||||
@@ -461,10 +538,13 @@ function App() {
|
||||
: (darkMode
|
||||
? `0 8px 18px rgba(0,0,0,${Math.max(0.10, Math.min(0.22, effectiveOpacity * 0.24))})`
|
||||
: `0 8px 18px rgba(15,23,42,${Math.max(0.04, Math.min(0.12, effectiveOpacity * 0.12))})`);
|
||||
const isSidebarNarrow = sidebarWidth < 360;
|
||||
const isSidebarCompact = sidebarWidth < 320;
|
||||
const isSidebarUltraCompact = sidebarWidth < 260;
|
||||
const utilityButtonStyle = useMemo(() => ({
|
||||
height: Math.max(30, Math.round(32 * effectiveUiScale)),
|
||||
width: '100%',
|
||||
paddingInline: Math.max(10, Math.round(12 * effectiveUiScale)),
|
||||
paddingInline: isSidebarCompact ? Math.max(8, Math.round(9 * effectiveUiScale)) : Math.max(10, Math.round(12 * effectiveUiScale)),
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${utilityButtonBorderColor}`,
|
||||
background: utilityButtonBgColor,
|
||||
@@ -475,17 +555,14 @@ function App() {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
|
||||
const utilityDropdownShellStyle = useMemo(() => ({
|
||||
borderRadius: 14,
|
||||
padding: 6,
|
||||
background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.32)' : '0 16px 36px rgba(15,23,42,0.12)',
|
||||
backdropFilter: darkMode ? 'blur(16px)' : 'none',
|
||||
gap: isSidebarCompact ? 4 : 6,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}), [darkMode]);
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: isSidebarCompact ? 13 : 14,
|
||||
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, isSidebarCompact, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
|
||||
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
|
||||
|
||||
const sidebarQuickActionBaseStyle = useMemo(() => ({
|
||||
height: Math.max(34, Math.round(36 * effectiveUiScale)),
|
||||
@@ -500,6 +577,8 @@ function App() {
|
||||
backdropFilter: blurFilter,
|
||||
WebkitBackdropFilter: blurFilter,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}), [blurFilter, darkMode, effectiveUiScale]);
|
||||
const sidebarQueryActionStyle = useMemo(() => ({
|
||||
@@ -517,65 +596,58 @@ function App() {
|
||||
color: '#2a1f00',
|
||||
}), [sidebarQuickActionBaseStyle]);
|
||||
|
||||
const utilityMenuTheme = useMemo(() => ({
|
||||
components: {
|
||||
Menu: {
|
||||
popupBg: 'transparent',
|
||||
darkPopupBg: 'transparent',
|
||||
itemBg: 'transparent',
|
||||
darkItemBg: 'transparent',
|
||||
subMenuItemBg: 'transparent',
|
||||
itemColor: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
|
||||
itemHoverColor: darkMode ? '#fff7d6' : '#0f172a',
|
||||
itemHoverBg: darkMode ? 'rgba(255,214,102,0.10)' : 'rgba(24,144,255,0.08)',
|
||||
itemSelectedColor: darkMode ? '#ffd666' : '#1677ff',
|
||||
itemSelectedBg: darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.12)',
|
||||
itemBorderRadius: 10,
|
||||
itemMarginBlock: 4,
|
||||
itemMarginInline: 0,
|
||||
itemPaddingInline: 12,
|
||||
itemHeight: 40,
|
||||
groupTitleColor: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(16,24,40,0.48)',
|
||||
},
|
||||
},
|
||||
}), [darkMode]);
|
||||
const renderUtilityDropdown = (menu: React.ReactNode) => (
|
||||
<ConfigProvider theme={utilityMenuTheme}>
|
||||
<div style={{ ...utilityDropdownShellStyle, minWidth: 220 }}>
|
||||
{menu}
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
const utilityModalShellStyle = useMemo(() => ({
|
||||
background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.32)' : '0 18px 42px rgba(15,23,42,0.12)',
|
||||
backdropFilter: darkMode ? 'blur(18px)' : 'none',
|
||||
}), [darkMode]);
|
||||
background: overlayTheme.shellBg,
|
||||
border: overlayTheme.shellBorder,
|
||||
boxShadow: overlayTheme.shellShadow,
|
||||
backdropFilter: overlayTheme.shellBackdropFilter,
|
||||
}), [overlayTheme]);
|
||||
const utilityPanelStyle = useMemo(() => ({
|
||||
padding: 16,
|
||||
borderRadius: 14,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
|
||||
}), [darkMode]);
|
||||
border: overlayTheme.sectionBorder,
|
||||
background: overlayTheme.sectionBg,
|
||||
}), [overlayTheme]);
|
||||
const utilityMutedTextStyle = useMemo(() => ({
|
||||
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
|
||||
color: overlayTheme.mutedText,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
}), [darkMode]);
|
||||
}), [overlayTheme]);
|
||||
const renderUtilityModalTitle = (icon: React.ReactNode, title: string, description: string) => (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
|
||||
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
|
||||
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const utilityActionCardStyle = useMemo(() => ({
|
||||
width: '100%',
|
||||
minHeight: 68,
|
||||
borderRadius: 14,
|
||||
border: overlayTheme.sectionBorder,
|
||||
background: overlayTheme.sectionBg,
|
||||
color: overlayTheme.titleText,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 14,
|
||||
paddingInline: 16,
|
||||
boxShadow: 'none',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
}), [overlayTheme]);
|
||||
const utilityActionHintStyle = useMemo(() => ({
|
||||
fontSize: 12,
|
||||
color: overlayTheme.mutedText,
|
||||
fontWeight: 400,
|
||||
marginTop: 2,
|
||||
}), [overlayTheme]);
|
||||
|
||||
const sidebarHorizontalPadding = 10;
|
||||
const sidebarHorizontalPadding = isSidebarCompact ? 8 : 10;
|
||||
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const activeContext = useStore(state => state.activeContext);
|
||||
@@ -585,6 +657,7 @@ function App() {
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const updateCheckInFlightRef = React.useRef(false);
|
||||
const updateDownloadInFlightRef = React.useRef(false);
|
||||
const updateUserDismissedRef = React.useRef(false);
|
||||
const updateDownloadedVersionRef = React.useRef<string | null>(null);
|
||||
const updateInstallTriggeredVersionRef = React.useRef<string | null>(null);
|
||||
const updateDownloadMetaRef = React.useRef<UpdateDownloadResultData | null>(null);
|
||||
@@ -673,6 +746,7 @@ function App() {
|
||||
return;
|
||||
}
|
||||
updateDownloadInFlightRef.current = true;
|
||||
updateUserDismissedRef.current = false;
|
||||
updateDownloadMetaRef.current = null;
|
||||
setUpdateDownloadProgress({
|
||||
open: true,
|
||||
@@ -717,7 +791,18 @@ function App() {
|
||||
} else {
|
||||
void message.success({ content: '更新下载完成', duration: 2 });
|
||||
}
|
||||
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,请点击“下载进度”后安装)`);
|
||||
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,请点击"下载进度"后安装)`);
|
||||
// macOS:如果用户没有主动隐藏进度弹窗,则下载完成后自动打开下载目录
|
||||
if (isMacRuntime && !updateUserDismissedRef.current) {
|
||||
try {
|
||||
const openRes = await (window as any).go.app.App.OpenDownloadedUpdateDirectory();
|
||||
if (openRes?.success) {
|
||||
void message.success(openRes?.message || '已打开安装目录,请手动完成替换');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('自动打开下载目录失败', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setUpdateDownloadProgress(prev => ({
|
||||
...prev,
|
||||
@@ -748,18 +833,34 @@ function App() {
|
||||
&& updateDownloadProgress.version === lastUpdateInfo?.latestVersion
|
||||
&& (updateDownloadProgress.status === 'start'
|
||||
|| updateDownloadProgress.status === 'downloading'
|
||||
|| updateDownloadProgress.status === 'done'
|
||||
|| updateDownloadProgress.status === 'error');
|
||||
const canShowProgressEntry = (isLatestUpdateDownloaded || isBackgroundProgressForLatestUpdate)
|
||||
&& updateInstallTriggeredVersionRef.current !== (lastUpdateInfo?.latestVersion || null);
|
||||
|
||||
const handleInstallFromProgress = React.useCallback(async () => {
|
||||
if (updateDownloadProgress.status !== 'done') {
|
||||
// 允许从下载进度弹窗(status=done)或关于弹窗(isLatestUpdateDownloaded=true)触发
|
||||
const canInstall = updateDownloadProgress.status === 'done'
|
||||
|| (Boolean(lastUpdateInfo?.hasUpdate) && (Boolean(lastUpdateInfo?.downloaded) || updateDownloadedVersionRef.current === lastUpdateInfo?.latestVersion));
|
||||
if (!canInstall) {
|
||||
return;
|
||||
}
|
||||
if (isMacRuntime) {
|
||||
const res = await (window as any).go.app.App.OpenDownloadedUpdateDirectory();
|
||||
if (!res?.success) {
|
||||
void message.error('打开安装目录失败: ' + (res?.message || '未知错误'));
|
||||
// 文件可能已被用户删除,清除已下载状态以允许重新下载
|
||||
updateDownloadedVersionRef.current = null;
|
||||
updateDownloadMetaRef.current = null;
|
||||
setUpdateDownloadProgress(prev => ({
|
||||
...prev,
|
||||
status: 'idle',
|
||||
percent: 0,
|
||||
downloaded: 0,
|
||||
open: false,
|
||||
}));
|
||||
setLastUpdateInfo(prev => prev ? { ...prev, downloaded: false, downloadPath: undefined } : prev);
|
||||
setAboutUpdateStatus(prev => prev.replace('已下载', '未下载'));
|
||||
return;
|
||||
}
|
||||
updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null;
|
||||
@@ -774,7 +875,7 @@ function App() {
|
||||
}
|
||||
updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null;
|
||||
hideUpdateDownloadProgress();
|
||||
}, [hideUpdateDownloadProgress, isMacRuntime, lastUpdateInfo?.latestVersion, updateDownloadProgress.status, updateDownloadProgress.version]);
|
||||
}, [hideUpdateDownloadProgress, isMacRuntime, lastUpdateInfo?.latestVersion, lastUpdateInfo?.hasUpdate, lastUpdateInfo?.downloaded, updateDownloadProgress.status, updateDownloadProgress.version]);
|
||||
|
||||
const checkForUpdates = React.useCallback(async (silent: boolean) => {
|
||||
if (updateCheckInFlightRef.current) return;
|
||||
@@ -795,6 +896,11 @@ function App() {
|
||||
if (!info) return;
|
||||
const aboutOpen = isAboutOpenRef.current;
|
||||
if (info.hasUpdate) {
|
||||
// 以后端校验为准:如果后端确认文件不存在(downloaded=false),清除本地 ref
|
||||
if (!info.downloaded && updateDownloadedVersionRef.current === info.latestVersion) {
|
||||
updateDownloadedVersionRef.current = null;
|
||||
updateDownloadMetaRef.current = null;
|
||||
}
|
||||
const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion;
|
||||
const hasDownloaded = Boolean(info.downloaded) || localDownloaded;
|
||||
if (hasDownloaded) {
|
||||
@@ -914,28 +1020,34 @@ function App() {
|
||||
setAboutLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleNewQuery = () => {
|
||||
let connId = activeContext?.connectionId || '';
|
||||
let db = activeContext?.dbName || '';
|
||||
const handleNewQuery = useCallback(() => {
|
||||
let connId = '';
|
||||
let db = '';
|
||||
|
||||
// Priority: Active Tab Context > Sidebar Selection
|
||||
// Priority: Active Tab Context (if connection still valid) > Sidebar Selection (activeContext)
|
||||
if (activeTabId) {
|
||||
const currentTab = tabs.find(t => t.id === activeTabId);
|
||||
if (currentTab && currentTab.connectionId) {
|
||||
if (currentTab && currentTab.connectionId && connections.some(c => c.id === currentTab.connectionId)) {
|
||||
connId = currentTab.connectionId;
|
||||
db = currentTab.dbName || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Sidebar selection context (only if connection still valid)
|
||||
if (!connId && activeContext?.connectionId && connections.some(c => c.id === activeContext.connectionId)) {
|
||||
connId = activeContext.connectionId;
|
||||
db = activeContext.dbName || '';
|
||||
}
|
||||
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
id: `query-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: connId,
|
||||
dbName: db,
|
||||
query: ''
|
||||
});
|
||||
};
|
||||
}, [activeTabId, tabs, connections, activeContext, addTab]);
|
||||
|
||||
const handleImportConnections = async () => {
|
||||
const res = await (window as any).go.app.App.ImportConfigFile();
|
||||
@@ -957,7 +1069,7 @@ function App() {
|
||||
} catch (e) {
|
||||
void message.error("解析 JSON 失败");
|
||||
}
|
||||
} else if (res.message !== "Cancelled") {
|
||||
} else if (res.message !== "已取消") {
|
||||
void message.error("导入失败: " + res.message);
|
||||
}
|
||||
};
|
||||
@@ -970,45 +1082,12 @@ function App() {
|
||||
const res = await (window as any).go.app.App.ExportData(connections, ['id','name','config','includeDatabases','includeRedisDatabases'], "connections", "json");
|
||||
if (res.success) {
|
||||
void message.success("导出成功");
|
||||
} else if (res.message !== "Cancelled") {
|
||||
} else if (res.message !== "已取消") {
|
||||
void message.error("导出失败: " + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const toolsMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'import',
|
||||
label: '导入连接配置',
|
||||
icon: <UploadOutlined />,
|
||||
onClick: handleImportConnections
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
label: '导出连接配置',
|
||||
icon: <DownloadOutlined />,
|
||||
onClick: handleExportConnections
|
||||
},
|
||||
{
|
||||
key: 'sync',
|
||||
label: '数据同步',
|
||||
icon: <UploadOutlined rotate={90} />,
|
||||
onClick: () => setIsSyncModalOpen(true)
|
||||
},
|
||||
{
|
||||
key: 'drivers',
|
||||
label: '驱动管理',
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => setIsDriverModalOpen(true)
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'shortcut-settings',
|
||||
label: '快捷键管理',
|
||||
icon: <LinkOutlined />,
|
||||
onClick: () => setIsShortcutModalOpen(true)
|
||||
}
|
||||
];
|
||||
|
||||
const [isToolsModalOpen, setIsToolsModalOpen] = useState(false);
|
||||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
|
||||
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
|
||||
@@ -1105,7 +1184,6 @@ function App() {
|
||||
};
|
||||
|
||||
// Sidebar Resizing
|
||||
const [sidebarWidth, setSidebarWidth] = useState(330);
|
||||
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
|
||||
const rafRef = React.useRef<number | null>(null);
|
||||
const ghostRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -1492,17 +1570,15 @@ function App() {
|
||||
>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
|
||||
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft" dropdownRender={renderUtilityDropdown}>
|
||||
<Button type="text" icon={<ToolOutlined />} title="工具" style={utilityButtonStyle}>工具</Button>
|
||||
</Dropdown>
|
||||
<Button type="text" icon={<GlobalOutlined />} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}>代理</Button>
|
||||
<Button type="text" icon={<SkinOutlined />} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}>主题</Button>
|
||||
<Button type="text" icon={<InfoCircleOutlined />} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}>关于</Button>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isSidebarNarrow ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
|
||||
<Button type="text" icon={<ToolOutlined />} title="工具" style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)}>{isSidebarUltraCompact ? null : '工具'}</Button>
|
||||
<Button type="text" icon={<GlobalOutlined />} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}>{isSidebarUltraCompact ? null : '代理'}</Button>
|
||||
<Button type="text" icon={<SkinOutlined />} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}>{isSidebarUltraCompact ? null : '主题'}</Button>
|
||||
<Button type="text" icon={<InfoCircleOutlined />} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}>{isSidebarUltraCompact ? null : '关于'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 8, width: '100%' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isSidebarCompact ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1fr)', gap: 8, width: '100%' }}>
|
||||
<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
|
||||
新建查询
|
||||
</Button>
|
||||
@@ -1589,6 +1665,79 @@ function App() {
|
||||
initialValues={editingConnection}
|
||||
onOpenDriverManager={handleOpenDriverManagerFromConnection}
|
||||
/>
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<ToolOutlined />, '工具中心', '集中处理连接配置、同步、驱动和快捷键相关操作。')}
|
||||
open={isToolsModalOpen}
|
||||
onCancel={() => setIsToolsModalOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12, padding: '12px 0' }}>
|
||||
{[
|
||||
{
|
||||
key: 'import',
|
||||
icon: <UploadOutlined />,
|
||||
title: '导入连接配置',
|
||||
description: '从本地文件恢复连接列表。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
void handleImportConnections();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
icon: <DownloadOutlined />,
|
||||
title: '导出连接配置',
|
||||
description: '导出当前连接与可见配置字段。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
void handleExportConnections();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sync',
|
||||
icon: <UploadOutlined rotate={90} />,
|
||||
title: '数据同步',
|
||||
description: '进入跨源同步工作流。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setIsSyncModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'drivers',
|
||||
icon: <SettingOutlined />,
|
||||
title: '驱动管理',
|
||||
description: '安装、更新或移除数据库驱动。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setIsDriverModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'shortcut-settings',
|
||||
icon: <LinkOutlined />,
|
||||
title: '快捷键管理',
|
||||
description: '查看并调整全局快捷键绑定。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setIsShortcutModalOpen(true);
|
||||
},
|
||||
},
|
||||
].map((item) => (
|
||||
<Button key={item.key} type="text" style={utilityActionCardStyle} onClick={item.onClick}>
|
||||
<span style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', minWidth: 0 }}>
|
||||
<span>{item.title}</span>
|
||||
<span style={utilityActionHintStyle}>{item.description}</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<DataSyncModal
|
||||
open={isSyncModalOpen}
|
||||
onClose={() => setIsSyncModalOpen(false)}
|
||||
@@ -1602,19 +1751,24 @@ function App() {
|
||||
title={renderUtilityModalTitle(<InfoCircleOutlined />, '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')}
|
||||
open={isAboutOpen}
|
||||
onCancel={() => setIsAboutOpen(false)}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 10, justifyContent: 'flex-end' } }}
|
||||
footer={[
|
||||
canShowProgressEntry ? (
|
||||
isBackgroundProgressForLatestUpdate && !isLatestUpdateDownloaded ? (
|
||||
<Button key="progress" icon={<DownloadOutlined />} onClick={showUpdateDownloadProgress}>下载进度</Button>
|
||||
) : null,
|
||||
lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded ? (
|
||||
<Button key="download" icon={<DownloadOutlined />} onClick={() => downloadUpdate(lastUpdateInfo, false)}>下载更新</Button>
|
||||
) : null,
|
||||
lastUpdateInfo?.hasUpdate ? (
|
||||
lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? (
|
||||
<Button key="mute" onClick={() => { updateMutedVersionRef.current = lastUpdateInfo.latestVersion; setIsAboutOpen(false); }}>本次不再提示</Button>
|
||||
) : null,
|
||||
<Button key="check" icon={<CloudDownloadOutlined />} onClick={() => checkForUpdates(false)}>检查更新</Button>,
|
||||
<Button key="close" type="primary" onClick={() => setIsAboutOpen(false)}>关闭</Button>
|
||||
<Button key="close" onClick={() => setIsAboutOpen(false)}>关闭</Button>,
|
||||
lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? (
|
||||
<Button key="download" type="primary" icon={<DownloadOutlined />} onClick={() => downloadUpdate(lastUpdateInfo, false)}>下载更新</Button>
|
||||
) : null,
|
||||
isLatestUpdateDownloaded ? (
|
||||
<Button key="install-direct" type="primary" icon={<DownloadOutlined />} onClick={handleInstallFromProgress}>
|
||||
{isMacRuntime ? '打开安装目录' : '安装更新'}
|
||||
</Button>
|
||||
) : null,
|
||||
].filter(Boolean)}
|
||||
>
|
||||
{aboutLoading ? (
|
||||
@@ -2047,7 +2201,10 @@ function App() {
|
||||
footer={updateDownloadProgress.status === 'start' || updateDownloadProgress.status === 'downloading' ? [
|
||||
<Button
|
||||
key="background"
|
||||
onClick={hideUpdateDownloadProgress}
|
||||
onClick={() => {
|
||||
updateUserDismissedRef.current = true;
|
||||
hideUpdateDownloadProgress();
|
||||
}}
|
||||
>
|
||||
隐藏到后台
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined } from '@ant-design/icons';
|
||||
import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
|
||||
@@ -104,7 +106,9 @@ const ConnectionModal: React.FC<{
|
||||
const [dbType, setDbType] = useState('mysql');
|
||||
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
|
||||
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
|
||||
const [activeConfigSection, setActiveConfigSection] = useState<'basic' | 'network'>('basic');
|
||||
const [activeConfigSection, setActiveConfigSection] = useState<'basic' | 'network' | 'appearance'>('basic');
|
||||
const [customIconType, setCustomIconType] = useState<string | undefined>(undefined);
|
||||
const [customIconColor, setCustomIconColor] = useState<string | undefined>(undefined);
|
||||
const [activeNetworkConfig, setActiveNetworkConfig] = useState<'ssl' | 'ssh' | 'proxy' | 'httpTunnel'>('ssl');
|
||||
const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||
const [testErrorLogOpen, setTestErrorLogOpen] = useState(false);
|
||||
@@ -157,6 +161,7 @@ const ConnectionModal: React.FC<{
|
||||
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
|
||||
const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff';
|
||||
const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff';
|
||||
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
|
||||
|
||||
const tunnelSectionStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
@@ -168,35 +173,33 @@ const ConnectionModal: React.FC<{
|
||||
|
||||
|
||||
const modalShellStyle = useMemo(() => ({
|
||||
background: darkMode
|
||||
? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)',
|
||||
backdropFilter: darkMode ? 'blur(18px)' : 'none',
|
||||
}), [darkMode]);
|
||||
background: overlayTheme.shellBg,
|
||||
border: overlayTheme.shellBorder,
|
||||
boxShadow: overlayTheme.shellShadow,
|
||||
backdropFilter: overlayTheme.shellBackdropFilter,
|
||||
}), [overlayTheme]);
|
||||
|
||||
const modalInnerSectionStyle = useMemo(() => ({
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
|
||||
}), [darkMode]);
|
||||
border: overlayTheme.sectionBorder,
|
||||
background: overlayTheme.sectionBg,
|
||||
}), [overlayTheme]);
|
||||
|
||||
const modalMutedTextStyle = useMemo(() => ({
|
||||
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
|
||||
color: overlayTheme.mutedText,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
}), [darkMode]);
|
||||
}), [overlayTheme]);
|
||||
|
||||
const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
|
||||
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
|
||||
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1013,7 +1016,7 @@ const ConnectionModal: React.FC<{
|
||||
if (selectedPath) {
|
||||
form.setFieldValue('sshKeyPath', selectedPath);
|
||||
}
|
||||
} else if (res?.message !== 'Cancelled') {
|
||||
} else if (res?.message !== '已取消') {
|
||||
message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -1037,7 +1040,7 @@ const ConnectionModal: React.FC<{
|
||||
if (selectedPath) {
|
||||
form.setFieldValue('host', normalizeFileDbPath(selectedPath));
|
||||
}
|
||||
} else if (res?.message !== 'Cancelled') {
|
||||
} else if (res?.message !== '已取消') {
|
||||
message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -1061,6 +1064,8 @@ const ConnectionModal: React.FC<{
|
||||
setRedisDbList([]);
|
||||
setMongoMembers([]);
|
||||
setUriFeedback(null);
|
||||
setCustomIconType(undefined);
|
||||
setCustomIconColor(undefined);
|
||||
setTypeSelectWarning(null);
|
||||
setDriverStatusLoaded(false);
|
||||
void refreshDriverStatus();
|
||||
@@ -1146,6 +1151,8 @@ const ConnectionModal: React.FC<{
|
||||
mongoReplicaPassword: config.mongoReplicaPassword || ''
|
||||
});
|
||||
setUseSSL(!!config.useSSL);
|
||||
setCustomIconType(initialValues.iconType);
|
||||
setCustomIconColor(initialValues.iconColor);
|
||||
setUseSSH(config.useSSH || false);
|
||||
setUseProxy(hasProxy);
|
||||
setUseHttpTunnel(hasHttpTunnel);
|
||||
@@ -1212,7 +1219,9 @@ const ConnectionModal: React.FC<{
|
||||
name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)),
|
||||
config: config,
|
||||
includeDatabases: values.includeDatabases,
|
||||
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined
|
||||
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined,
|
||||
iconType: customIconType,
|
||||
iconColor: customIconColor,
|
||||
};
|
||||
|
||||
if (initialValues) {
|
||||
@@ -1735,32 +1744,32 @@ const ConnectionModal: React.FC<{
|
||||
|
||||
const dbTypeGroups = [
|
||||
{ label: '关系型数据库', items: [
|
||||
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
|
||||
{ key: 'mariadb', name: 'MariaDB', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#003545' }} /> },
|
||||
{ key: 'diros', name: 'Doris', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#0050b3' }} /> },
|
||||
{ key: 'sphinx', name: 'Sphinx', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#2F5D62' }} /> },
|
||||
{ key: 'clickhouse', name: 'ClickHouse', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#FFCC01' }} /> },
|
||||
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
|
||||
{ key: 'sqlserver', name: 'SQL Server', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#CC2927' }} /> },
|
||||
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
|
||||
{ key: 'duckdb', name: 'DuckDB', icon: <FileTextOutlined style={{ fontSize: 24, color: '#f59e0b' }} /> },
|
||||
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
|
||||
{ key: 'mysql', name: 'MySQL', icon: getDbIcon('mysql', undefined, 36) },
|
||||
{ key: 'mariadb', name: 'MariaDB', icon: getDbIcon('mariadb', undefined, 36) },
|
||||
{ key: 'diros', name: 'Doris', icon: getDbIcon('diros', undefined, 36) },
|
||||
{ key: 'sphinx', name: 'Sphinx', icon: getDbIcon('sphinx', undefined, 36) },
|
||||
{ key: 'clickhouse', name: 'ClickHouse', icon: getDbIcon('clickhouse', undefined, 36) },
|
||||
{ key: 'postgres', name: 'PostgreSQL', icon: getDbIcon('postgres', undefined, 36) },
|
||||
{ key: 'sqlserver', name: 'SQL Server', icon: getDbIcon('sqlserver', undefined, 36) },
|
||||
{ key: 'sqlite', name: 'SQLite', icon: getDbIcon('sqlite', undefined, 36) },
|
||||
{ key: 'duckdb', name: 'DuckDB', icon: getDbIcon('duckdb', undefined, 36) },
|
||||
{ key: 'oracle', name: 'Oracle', icon: getDbIcon('oracle', undefined, 36) },
|
||||
]},
|
||||
{ label: '国产数据库', items: [
|
||||
{ key: 'dameng', name: 'Dameng (达梦)', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
|
||||
{ key: 'kingbase', name: 'Kingbase (人大金仓)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#faad14' }} /> },
|
||||
{ key: 'highgo', name: 'HighGo (瀚高)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#00a854' }} /> },
|
||||
{ key: 'vastbase', name: 'Vastbase (海量)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#1a6dff' }} /> },
|
||||
{ key: 'dameng', name: 'Dameng (达梦)', icon: getDbIcon('dameng', undefined, 36) },
|
||||
{ key: 'kingbase', name: 'Kingbase (人大金仓)', icon: getDbIcon('kingbase', undefined, 36) },
|
||||
{ key: 'highgo', name: 'HighGo (瀚高)', icon: getDbIcon('highgo', undefined, 36) },
|
||||
{ key: 'vastbase', name: 'Vastbase (海量)', icon: getDbIcon('vastbase', undefined, 36) },
|
||||
]},
|
||||
{ label: 'NoSQL', items: [
|
||||
{ key: 'mongodb', name: 'MongoDB', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#47A248' }} /> },
|
||||
{ key: 'redis', name: 'Redis', icon: <CloudOutlined style={{ fontSize: 24, color: '#DC382D' }} /> },
|
||||
{ key: 'mongodb', name: 'MongoDB', icon: getDbIcon('mongodb', undefined, 36) },
|
||||
{ key: 'redis', name: 'Redis', icon: getDbIcon('redis', undefined, 36) },
|
||||
]},
|
||||
{ label: '时序数据库', items: [
|
||||
{ key: 'tdengine', name: 'TDengine', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#2F54EB' }} /> },
|
||||
{ key: 'tdengine', name: 'TDengine', icon: getDbIcon('tdengine', undefined, 36) },
|
||||
]},
|
||||
{ label: '其他', items: [
|
||||
{ key: 'custom', name: 'Custom (自定义)', icon: <AppstoreAddOutlined style={{ fontSize: 24, color: '#595959' }} /> },
|
||||
{ key: 'custom', name: 'Custom (自定义)', icon: getDbIcon('custom', undefined, 36) },
|
||||
]},
|
||||
];
|
||||
|
||||
@@ -2512,16 +2521,101 @@ const ConnectionModal: React.FC<{
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
const sectionItems: Array<{ key: 'basic' | 'network'; title: string; description: string; icon: React.ReactNode }> = [
|
||||
const sectionItems: Array<{ key: 'basic' | 'network' | 'appearance'; title: string; description: string; icon: React.ReactNode }> = [
|
||||
{ key: 'basic', title: '基础信息', description: '名称、地址、认证、URI 与数据库范围', icon: <DatabaseOutlined /> },
|
||||
...(!isCustom && !isFileDb ? [{ key: 'network' as const, title: '网络与安全', description: 'SSL、SSH、代理与高级连接', icon: <CloudOutlined /> }] : []),
|
||||
{ key: 'appearance', title: '外观', description: '自定义图标与颜色', icon: <BgColorsOutlined /> },
|
||||
];
|
||||
const resolvedSection = sectionItems.some((item) => item.key === activeConfigSection)
|
||||
? activeConfigSection
|
||||
: sectionItems[0]?.key || 'basic';
|
||||
|
||||
const effectiveIconType = customIconType || dbType;
|
||||
const effectiveIconColor = customIconColor || getDbDefaultColor(effectiveIconType);
|
||||
|
||||
const appearanceSection = (
|
||||
<div style={{ display: 'grid', gap: 18 }}>
|
||||
<div style={{ ...modalInnerSectionStyle, padding: 16 }}>
|
||||
<div style={{ marginBottom: 12, fontSize: 13, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>图标</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{DB_ICON_TYPES.map((iconKey) => {
|
||||
const isActive = effectiveIconType === iconKey;
|
||||
return (
|
||||
<button
|
||||
key={iconKey}
|
||||
type="button"
|
||||
title={getDbIconLabel(iconKey)}
|
||||
onClick={() => setCustomIconType(iconKey === dbType ? undefined : iconKey)}
|
||||
style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
display: 'grid', placeItems: 'center',
|
||||
border: `2px solid ${isActive ? effectiveIconColor : (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)')}`,
|
||||
background: isActive
|
||||
? (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(24,144,255,0.06)')
|
||||
: 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 120ms ease',
|
||||
}}
|
||||
>
|
||||
{getDbIcon(iconKey, isActive ? effectiveIconColor : undefined, 22)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.35)' }}>
|
||||
当前:{getDbIconLabel(effectiveIconType)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...modalInnerSectionStyle, padding: 16 }}>
|
||||
<div style={{ marginBottom: 12, fontSize: 13, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>颜色</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{PRESET_ICON_COLORS.map((presetColor) => {
|
||||
const isActive = effectiveIconColor === presetColor;
|
||||
return (
|
||||
<button
|
||||
key={presetColor}
|
||||
type="button"
|
||||
onClick={() => setCustomIconColor(presetColor === getDbDefaultColor(effectiveIconType) ? undefined : presetColor)}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: 8,
|
||||
background: presetColor,
|
||||
border: isActive ? `2.5px solid ${darkMode ? '#fff' : '#162033'}` : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 120ms ease',
|
||||
boxShadow: isActive ? `0 0 0 2px ${presetColor}40` : 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
type="color"
|
||||
value={effectiveIconColor}
|
||||
onChange={(e) => setCustomIconColor(e.target.value === getDbDefaultColor(effectiveIconType) ? undefined : e.target.value)}
|
||||
title="自定义颜色"
|
||||
style={{ width: 28, height: 28, border: 'none', padding: 0, cursor: 'pointer', borderRadius: 6, background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...modalInnerSectionStyle, padding: 16, display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>预览</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{getDbIcon(effectiveIconType, effectiveIconColor, 24)}
|
||||
<span style={{ fontSize: 14, color: darkMode ? '#e0e0e0' : '#333' }}>{form.getFieldValue('name') || '连接名称'}</span>
|
||||
</div>
|
||||
{(customIconType || customIconColor) && (
|
||||
<Button size="small" type="link" onClick={() => { setCustomIconType(undefined); setCustomIconColor(undefined); }}>
|
||||
重置为默认
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const currentSectionContent = resolvedSection === 'basic'
|
||||
? baseInfoSection
|
||||
: networkSecuritySection;
|
||||
: resolvedSection === 'appearance'
|
||||
? appearanceSection
|
||||
: networkSecuritySection;
|
||||
|
||||
if (sectionItems.length <= 1) {
|
||||
return currentSectionContent;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TabData, ColumnDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
|
||||
@@ -157,7 +157,7 @@ type ViewerFilterSnapshot = {
|
||||
conditions: FilterCondition[];
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
sortInfo: { columnKey: string, order: string } | null;
|
||||
sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>;
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
};
|
||||
@@ -185,16 +185,17 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin
|
||||
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
|
||||
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
|
||||
if (!cached) {
|
||||
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: null, scrollTop: 0, scrollLeft: 0 };
|
||||
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
|
||||
}
|
||||
return {
|
||||
showFilter: cached.showFilter === true,
|
||||
conditions: normalizeViewerFilterConditions(cached.conditions),
|
||||
currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1,
|
||||
pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100,
|
||||
sortInfo: cached.sortInfo && cached.sortInfo.columnKey && (cached.sortInfo.order === 'ascend' || cached.sortInfo.order === 'descend')
|
||||
? { columnKey: String(cached.sortInfo.columnKey), order: cached.sortInfo.order }
|
||||
: null,
|
||||
sortInfo: Array.isArray(cached.sortInfo)
|
||||
? cached.sortInfo.filter(s => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend'))
|
||||
.map(s => ({ columnKey: String(s.columnKey), order: s.order }))
|
||||
: (cached.sortInfo && (cached.sortInfo as any).columnKey ? [{ columnKey: String((cached.sortInfo as any).columnKey), order: (cached.sortInfo as any).order }] : []),
|
||||
scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0,
|
||||
scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0,
|
||||
};
|
||||
@@ -238,7 +239,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
totalCountCancelled: false,
|
||||
});
|
||||
|
||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(initialViewerSnapshot.sortInfo);
|
||||
const [sortInfo, setSortInfo] = useState<Array<{ columnKey: string, order: string, enabled?: boolean }>>(initialViewerSnapshot.sortInfo);
|
||||
|
||||
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
|
||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
|
||||
@@ -247,6 +248,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
|
||||
const currentConnType = currentConnCaps.type;
|
||||
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
|
||||
const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
|
||||
const normalizedTabId = String(tabId || '').trim();
|
||||
if (!normalizedTabId) return;
|
||||
viewerFilterSnapshotsByTab.set(normalizedTabId, {
|
||||
showFilter,
|
||||
conditions: normalizeViewerFilterConditions(filterConditions),
|
||||
currentPage: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortInfo,
|
||||
scrollTop: scrollSnapshotRef.current.top,
|
||||
scrollLeft: scrollSnapshotRef.current.left,
|
||||
...overrides,
|
||||
});
|
||||
}, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||
@@ -258,16 +273,14 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}, [tab.id]);
|
||||
|
||||
useEffect(() => {
|
||||
viewerFilterSnapshotsByTab.set(tab.id, {
|
||||
showFilter,
|
||||
conditions: normalizeViewerFilterConditions(filterConditions),
|
||||
currentPage: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortInfo,
|
||||
scrollTop: scrollSnapshotRef.current.top,
|
||||
scrollLeft: scrollSnapshotRef.current.left,
|
||||
});
|
||||
}, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
|
||||
persistViewerSnapshot(tab.id);
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
persistViewerSnapshot(tab.id);
|
||||
};
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||
@@ -298,13 +311,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
|
||||
scrollSnapshotRef.current = snapshot;
|
||||
const cached = getViewerFilterSnapshot(tab.id);
|
||||
viewerFilterSnapshotsByTab.set(tab.id, {
|
||||
...cached,
|
||||
persistViewerSnapshot(tab.id, {
|
||||
scrollTop: snapshot.top,
|
||||
scrollLeft: snapshot.left,
|
||||
});
|
||||
}, [tab.id]);
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
|
||||
const handleDuckDBManualCount = useCallback(async () => {
|
||||
if (latestDbTypeRef.current !== 'duckdb') {
|
||||
@@ -501,7 +512,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
|
||||
const hasSort = hasExplicitSort(sortInfo);
|
||||
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
|
||||
let resData = await executeDataQuery(sql, '主查询');
|
||||
|
||||
@@ -778,13 +789,21 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
fetchData(pagination.current, pagination.pageSize);
|
||||
}, [fetchData, pagination.current, pagination.pageSize]);
|
||||
const handleSort = useCallback((field: string, order: string) => {
|
||||
// 支持多字段排序:field 为 JSON 数组字符串时解析为多字段
|
||||
try {
|
||||
const parsed = JSON.parse(field);
|
||||
if (Array.isArray(parsed)) {
|
||||
setSortInfo(parsed.filter((s: any) => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend')));
|
||||
return;
|
||||
}
|
||||
} catch { /* 单字段模式 */ }
|
||||
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
|
||||
const normalizedField = String(field || '').trim();
|
||||
if (!normalizedField || !normalizedOrder) {
|
||||
setSortInfo(null);
|
||||
setSortInfo([]);
|
||||
return;
|
||||
}
|
||||
setSortInfo({ columnKey: normalizedField, order: normalizedOrder });
|
||||
setSortInfo([{ columnKey: normalizedField, order: normalizedOrder, enabled: true }]);
|
||||
}, []);
|
||||
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
|
||||
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
|
||||
@@ -801,8 +820,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const normalizedType = dbType.toLowerCase();
|
||||
const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
|
||||
if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
|
||||
const hasSortForBuffer = hasExplicitSort(sortInfo);
|
||||
if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
|
||||
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
|
||||
}
|
||||
return sql;
|
||||
|
||||
217
frontend/src/components/DatabaseIcons.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React from 'react';
|
||||
|
||||
// ─── 公共接口 ───────────────────────────────────────────────
|
||||
|
||||
export interface DbIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// ─── 默认色表 ───────────────────────────────────────────────
|
||||
|
||||
const DB_DEFAULT_COLORS: Record<string, string> = {
|
||||
mysql: '#00758F',
|
||||
mariadb: '#003545',
|
||||
postgres: '#336791',
|
||||
redis: '#DC382D',
|
||||
mongodb: '#47A248',
|
||||
kingbase: '#1890FF',
|
||||
dameng: '#E6002D',
|
||||
oracle: '#F80000',
|
||||
sqlserver: '#CC2927',
|
||||
clickhouse: '#FFBF00',
|
||||
sqlite: '#003B57',
|
||||
duckdb: '#FFC107',
|
||||
vastbase: '#0066CC',
|
||||
highgo: '#00A86B',
|
||||
tdengine: '#2962FF',
|
||||
diros: '#0050B3',
|
||||
sphinx: '#2F5D62',
|
||||
custom: '#888888',
|
||||
};
|
||||
|
||||
export const getDbDefaultColor = (type: string): string =>
|
||||
DB_DEFAULT_COLORS[type?.toLowerCase()] || DB_DEFAULT_COLORS.custom;
|
||||
|
||||
// ─── 有品牌 SVG 文件的数据库类型(文件在 /db-icons/ 下) ────
|
||||
|
||||
const BRAND_SVG_TYPES = new Set([
|
||||
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite',
|
||||
'diros', 'sphinx', 'duckdb',
|
||||
]);
|
||||
|
||||
/** 品牌 SVG 图标:用 <img> 加载 /db-icons/*.svg */
|
||||
const BrandSvgIcon: React.FC<{ type: string; size: number; color?: string }> = ({ type, size, color }) => {
|
||||
const bgColor = color || getDbDefaultColor(type);
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: size, height: size, borderRadius: size * 0.22,
|
||||
background: '#fff', border: `1.5px solid ${bgColor}`,
|
||||
flexShrink: 0, overflow: 'hidden',
|
||||
}}>
|
||||
<img
|
||||
src={`/db-icons/${type}.svg`}
|
||||
alt={type}
|
||||
width={size * 0.7}
|
||||
height={size * 0.7}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 彩色标签图标(fallback) ──────────────────────────────
|
||||
|
||||
/** 通用彩色标签:填充背景 + 白色粗体缩写 */
|
||||
const ColorBadge: React.FC<{ size: number; color: string; label: string }> = ({ size, color, label }) => {
|
||||
const textSize = label.length <= 2 ? size * 0.48 : size * 0.38;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="22" height="22" rx="5" fill={color}/>
|
||||
<text
|
||||
x="12" y="12" dominantBaseline="central" textAnchor="middle"
|
||||
fontSize={textSize} fontWeight="800" fontFamily="system-ui,-apple-system,sans-serif"
|
||||
fill="#fff" letterSpacing={label.length > 2 ? -0.5 : 0}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 各数据库图标 ───────────────────────────────────────────
|
||||
|
||||
// 有品牌 SVG 的数据库
|
||||
const MySQLIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="mysql" size={size} color={color} />
|
||||
);
|
||||
const MariaDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="mariadb" size={size} color={color} />
|
||||
);
|
||||
const PostgresIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="postgres" size={size} color={color} />
|
||||
);
|
||||
const RedisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="redis" size={size} color={color} />
|
||||
);
|
||||
const MongoDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="mongodb" size={size} color={color} />
|
||||
);
|
||||
const ClickHouseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="clickhouse" size={size} color={color} />
|
||||
);
|
||||
const SQLiteIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="sqlite" size={size} color={color} />
|
||||
);
|
||||
|
||||
// 无品牌 SVG → 彩色文字标签
|
||||
const OracleIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oracle} label="Or" />
|
||||
);
|
||||
const SQLServerIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sqlserver} label="SS" />
|
||||
);
|
||||
const DorisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="diros" size={size} color={color} />
|
||||
);
|
||||
const SphinxIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="sphinx" size={size} color={color} />
|
||||
);
|
||||
const DuckDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="duckdb" size={size} color={color} />
|
||||
);
|
||||
const KingBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.kingbase} label="KB" />
|
||||
);
|
||||
const DamengIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.dameng} label="DM" />
|
||||
);
|
||||
const VastBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.vastbase} label="VB" />
|
||||
);
|
||||
const HighGoIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.highgo} label="HG" />
|
||||
);
|
||||
const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.tdengine} label="TD" />
|
||||
);
|
||||
|
||||
/** Custom — 齿轮图标 */
|
||||
const CustomIcon: React.FC<DbIconProps> = ({ size = 16, color }) => {
|
||||
const c = color || DB_DEFAULT_COLORS.custom;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="22" height="22" rx="5" fill={c}/>
|
||||
<circle cx="12" cy="12" r="3.5" stroke="#fff" strokeWidth="1.5" fill="none"/>
|
||||
<path d="M12 4v2.5M12 17.5V20M4 12h2.5M17.5 12H20M6.34 6.34l1.77 1.77M15.89 15.89l1.77 1.77M6.34 17.66l1.77-1.77M15.89 8.11l1.77-1.77" stroke="#fff" strokeWidth="1.3" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 图标注册表 ─────────────────────────────────────────────
|
||||
|
||||
const DorisIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.diros} label="Do" />
|
||||
);
|
||||
const SphinxIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sphinx} label="Sp" />
|
||||
);
|
||||
|
||||
const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
||||
mysql: MySQLIcon,
|
||||
mariadb: MariaDBIcon,
|
||||
diros: DorisIcon,
|
||||
sphinx: SphinxIcon,
|
||||
postgres: PostgresIcon,
|
||||
redis: RedisIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
kingbase: KingBaseIcon,
|
||||
dameng: DamengIcon,
|
||||
oracle: OracleIcon,
|
||||
sqlserver: SQLServerIcon,
|
||||
clickhouse: ClickHouseIcon,
|
||||
sqlite: SQLiteIcon,
|
||||
duckdb: DuckDBIcon,
|
||||
vastbase: VastBaseIcon,
|
||||
highgo: HighGoIcon,
|
||||
tdengine: TDengineIcon,
|
||||
custom: CustomIcon,
|
||||
};
|
||||
|
||||
/** 可选图标类型列表(用于图标选择器 UI) */
|
||||
export const DB_ICON_TYPES: string[] = [
|
||||
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb',
|
||||
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse',
|
||||
'kingbase', 'dameng', 'vastbase', 'highgo', 'tdengine', 'custom',
|
||||
];
|
||||
|
||||
/** 该类型是否有品牌 SVG 文件 */
|
||||
export const hasBrandSvg = (type: string): boolean => BRAND_SVG_TYPES.has(type?.toLowerCase());
|
||||
|
||||
/** 获取数据库图标 React 节点 */
|
||||
export const getDbIcon = (type: string, color?: string, size?: number): React.ReactNode => {
|
||||
const key = (type || 'custom').toLowerCase();
|
||||
const Component = DB_ICON_MAP[key] || CustomIcon;
|
||||
return <Component size={size} color={color} />;
|
||||
};
|
||||
|
||||
/** 获取数据库图标显示名称(中文) */
|
||||
export const getDbIconLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
mysql: 'MySQL', mariadb: 'MariaDB', postgres: 'PostgreSQL',
|
||||
redis: 'Redis', mongodb: 'MongoDB', oracle: 'Oracle',
|
||||
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
|
||||
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
|
||||
vastbase: 'VastBase', highgo: '瀚高', tdengine: 'TDengine',
|
||||
custom: '自定义',
|
||||
};
|
||||
return labels[type?.toLowerCase()] || type;
|
||||
};
|
||||
|
||||
/** 预设颜色列表 */
|
||||
export const PRESET_ICON_COLORS: string[] = [
|
||||
'#336791', '#00758F', '#DC382D', '#47A248', '#F80000',
|
||||
'#CC2927', '#1890FF', '#E6002D', '#FFBF00', '#2962FF',
|
||||
'#00A86B', '#0066CC', '#FF6B35', '#7C3AED',
|
||||
];
|
||||
@@ -847,7 +847,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
|
||||
const fileRes = await SelectDriverPackageFile(downloadDir);
|
||||
if (!fileRes?.success) {
|
||||
if (String(fileRes?.message || '') !== 'Cancelled') {
|
||||
if (String(fileRes?.message || '') !== '已取消') {
|
||||
message.error(fileRes?.message || '选择本地驱动包文件失败');
|
||||
}
|
||||
return;
|
||||
@@ -863,7 +863,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
const installDriversFromDirectory = useCallback(async () => {
|
||||
const directoryRes = await SelectDriverPackageDirectory(downloadDir);
|
||||
if (!directoryRes?.success) {
|
||||
if (String(directoryRes?.message || '') !== 'Cancelled') {
|
||||
if (String(directoryRes?.message || '') !== '已取消') {
|
||||
message.error(directoryRes?.message || '选择本地驱动包目录失败');
|
||||
}
|
||||
return;
|
||||
|
||||
462
frontend/src/components/FindInDatabaseModal.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { Modal, Input, Button, Table, Progress, Space, Tag, message, Tooltip, Select, Empty } from 'antd';
|
||||
import { SearchOutlined, StopOutlined, EyeOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||
import { DBQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/app/App';
|
||||
import { quoteIdentPart, escapeLiteral } from '../utils/sql';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
|
||||
interface FindInDatabaseModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
}
|
||||
|
||||
interface SearchResultItem {
|
||||
tableName: string;
|
||||
matchedColumns: string[];
|
||||
matchCount: number;
|
||||
rows: Record<string, any>[];
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
/** 判断数据库列类型是否为文本类型(只搜索文本字段) */
|
||||
const isTextColumnType = (colType: string): boolean => {
|
||||
const t = (colType || '').toLowerCase().trim();
|
||||
// 显式排除非文本类型
|
||||
if (/^(int|bigint|smallint|tinyint|mediumint|float|double|decimal|numeric|real|money|smallmoney|bit|boolean|bool)/.test(t)) return false;
|
||||
if (/^(date|time|datetime|timestamp|year|interval)/.test(t)) return false;
|
||||
if (/^(blob|binary|varbinary|image|bytea|raw|long raw)/.test(t)) return false;
|
||||
if (/^(geometry|geography|point|line|polygon|spatial)/.test(t)) return false;
|
||||
if (/^(json|jsonb|xml|uuid|uniqueidentifier)/.test(t)) return false;
|
||||
if (/^(serial|bigserial|smallserial|autoincrement|identity)/.test(t)) return false;
|
||||
// 文本类型正匹配
|
||||
if (/^(varchar|char|nvarchar|nchar|text|ntext|tinytext|mediumtext|longtext|string|clob|nclob|character)/.test(t)) return true;
|
||||
if (t === 'sysname' || t === 'sql_variant') return true;
|
||||
// 未知类型默认尝试搜索
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 根据 dbType 构建限制返回行数的 SELECT SQL */
|
||||
const buildLimitedSelectSQL = (dbType: string, baseSql: string, limit: number): string => {
|
||||
const normalizedType = (dbType || '').toLowerCase();
|
||||
switch (normalizedType) {
|
||||
case 'sqlserver':
|
||||
case 'mssql':
|
||||
return baseSql.replace(/^SELECT\b/i, `SELECT TOP ${limit}`);
|
||||
case 'oracle':
|
||||
case 'dameng':
|
||||
return `${baseSql} FETCH FIRST ${limit} ROWS ONLY`;
|
||||
default:
|
||||
return `${baseSql} LIMIT ${limit}`;
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_MATCH_ROWS_PER_TABLE = 100;
|
||||
|
||||
const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose, connectionId, dbName }) => {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [matchMode, setMatchMode] = useState<'contains' | 'exact'>('contains');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, tableName: '' });
|
||||
const [expandedTable, setExpandedTable] = useState<string | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
|
||||
const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]);
|
||||
const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]);
|
||||
|
||||
const wt = useMemo(() => {
|
||||
const isDark = theme === 'dark';
|
||||
return buildOverlayWorkbenchTheme(isDark);
|
||||
}, [theme]);
|
||||
|
||||
const buildConfig = useCallback(() => {
|
||||
if (!conn) return null;
|
||||
return {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: conn.config.database || "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
}, [conn]);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
const searchKeyword = keyword.trim();
|
||||
if (!searchKeyword) {
|
||||
message.warning('请输入搜索关键字');
|
||||
return;
|
||||
}
|
||||
const config = buildConfig();
|
||||
if (!config) {
|
||||
message.error('未找到连接配置');
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
setResults([]);
|
||||
setExpandedTable(null);
|
||||
cancelledRef.current = false;
|
||||
|
||||
try {
|
||||
// 1. 获取所有表
|
||||
const tablesRes = await DBGetTables(config as any, dbName);
|
||||
if (!tablesRes.success) {
|
||||
message.error('获取表列表失败: ' + tablesRes.message);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
const tableRows: any[] = Array.isArray(tablesRes.data) ? tablesRes.data : [];
|
||||
const tableNames = tableRows.map((row: any) => Object.values(row)[0] as string).filter(Boolean);
|
||||
|
||||
if (tableNames.length === 0) {
|
||||
message.info('当前数据库没有表');
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress({ current: 0, total: tableNames.length, tableName: '' });
|
||||
|
||||
// 2. 获取所有列信息(返回 any[],含 tableName/name/type 字段)
|
||||
const allColsRes = await DBGetAllColumns(config as any, dbName);
|
||||
const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : [];
|
||||
|
||||
// 按表名分组
|
||||
const columnsByTable: Record<string, Array<{ name: string; type: string }>> = {};
|
||||
allColumns.forEach((col: any) => {
|
||||
const tbl = col.tableName || '';
|
||||
if (!columnsByTable[tbl]) columnsByTable[tbl] = [];
|
||||
columnsByTable[tbl].push({ name: col.name, type: col.type || '' });
|
||||
});
|
||||
|
||||
const searchResults: SearchResultItem[] = [];
|
||||
const escapedKeyword = escapeLiteral(searchKeyword);
|
||||
|
||||
// 3. 逐表搜索
|
||||
for (let i = 0; i < tableNames.length; i++) {
|
||||
if (cancelledRef.current) break;
|
||||
|
||||
const tableName = tableNames[i];
|
||||
setProgress({ current: i + 1, total: tableNames.length, tableName });
|
||||
|
||||
// 获取该表的文本列
|
||||
const tableCols = columnsByTable[tableName] || [];
|
||||
const textCols = tableCols.filter(c => isTextColumnType(c.type));
|
||||
|
||||
if (textCols.length === 0) continue;
|
||||
|
||||
// 构建 WHERE 子句
|
||||
const castType = (dbType === 'sqlserver' || dbType === 'mssql') ? 'NVARCHAR(MAX)' : 'CHAR';
|
||||
const whereConditions = textCols.map(c => {
|
||||
const quotedCol = quoteIdentPart(dbType, c.name);
|
||||
if (matchMode === 'exact') {
|
||||
return `CAST(${quotedCol} AS ${castType}) = '${escapedKeyword}'`;
|
||||
}
|
||||
return `CAST(${quotedCol} AS ${castType}) LIKE '%${escapedKeyword}%'`;
|
||||
});
|
||||
|
||||
const quotedTable = quoteIdentPart(dbType, tableName);
|
||||
const baseSql = `SELECT * FROM ${quotedTable} WHERE ${whereConditions.join(' OR ')}`;
|
||||
const sql = buildLimitedSelectSQL(dbType, baseSql, MAX_MATCH_ROWS_PER_TABLE);
|
||||
|
||||
try {
|
||||
const res = await DBQuery(config as any, dbName, sql);
|
||||
if (res.success && Array.isArray(res.data) && res.data.length > 0) {
|
||||
// 检查哪些列实际匹配了
|
||||
const matchedCols = new Set<string>();
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
res.data.forEach((row: any) => {
|
||||
textCols.forEach(c => {
|
||||
const val = row[c.name];
|
||||
if (val != null) {
|
||||
const strVal = String(val).toLowerCase();
|
||||
if (matchMode === 'exact' ? strVal === lowerKeyword : strVal.includes(lowerKeyword)) {
|
||||
matchedCols.add(c.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (matchedCols.size > 0) {
|
||||
const columns = Object.keys(res.data[0]);
|
||||
searchResults.push({
|
||||
tableName,
|
||||
matchedColumns: Array.from(matchedCols),
|
||||
matchCount: res.data.length,
|
||||
rows: res.data,
|
||||
columns,
|
||||
});
|
||||
setResults([...searchResults]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 单表查询失败不中断整体搜索
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelledRef.current) {
|
||||
setResults([...searchResults]);
|
||||
if (searchResults.length === 0) {
|
||||
message.info('未找到匹配的数据');
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('搜索出错: ' + (e?.message || String(e)));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, [keyword, matchMode, dbName, dbType, buildConfig]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
setResults([]);
|
||||
setExpandedTable(null);
|
||||
setProgress({ current: 0, total: 0, tableName: '' });
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// 汇总表的列定义
|
||||
const summaryColumns = useMemo(() => [
|
||||
{
|
||||
title: '表名',
|
||||
dataIndex: 'tableName',
|
||||
key: 'tableName',
|
||||
width: 220,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontWeight: 500, color: wt.titleText }}>
|
||||
<DatabaseOutlined style={{ marginRight: 6, color: wt.iconColor }} />
|
||||
{text}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '匹配列',
|
||||
dataIndex: 'matchedColumns',
|
||||
key: 'matchedColumns',
|
||||
render: (cols: string[]) => (
|
||||
<Space size={4} wrap>
|
||||
{cols.map(col => (
|
||||
<Tag key={col} color="blue" style={{ margin: 0, fontSize: 12 }}>{col}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '命中行数',
|
||||
dataIndex: 'matchCount',
|
||||
key: 'matchCount',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (count: number) => (
|
||||
<Tag color={count >= MAX_MATCH_ROWS_PER_TABLE ? 'orange' : 'green'}>
|
||||
{count >= MAX_MATCH_ROWS_PER_TABLE ? `≥${count}` : count}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: any, record: SearchResultItem) => (
|
||||
<Tooltip title={expandedTable === record.tableName ? '收起详情' : '查看详情'}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); setExpandedTable(prev => prev === record.tableName ? null : record.tableName); }}
|
||||
style={{ color: wt.iconColor }}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
], [wt, expandedTable]);
|
||||
|
||||
// 展开的详情行 - 动态列
|
||||
const expandedResult = useMemo(() => {
|
||||
if (!expandedTable) return null;
|
||||
return results.find(r => r.tableName === expandedTable);
|
||||
}, [expandedTable, results]);
|
||||
|
||||
const detailColumns = useMemo(() => {
|
||||
if (!expandedResult) return [];
|
||||
const lowerKeyword = keyword.trim().toLowerCase();
|
||||
return expandedResult.columns.map(col => ({
|
||||
title: col,
|
||||
dataIndex: col,
|
||||
key: col,
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (value: any) => {
|
||||
const strVal = value != null ? String(value) : '';
|
||||
const isMatch = expandedResult.matchedColumns.includes(col) &&
|
||||
strVal.toLowerCase().includes(lowerKeyword);
|
||||
return (
|
||||
<Tooltip title={strVal} placement="topLeft">
|
||||
<span style={isMatch ? { background: 'rgba(255, 193, 7, 0.3)', padding: '1px 3px', borderRadius: 3 } : undefined}>
|
||||
{strVal || <span style={{ color: wt.mutedText }}>NULL</span>}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
}));
|
||||
}, [expandedResult, keyword, wt]);
|
||||
|
||||
const percent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<span style={{ color: wt.titleText, fontWeight: 600 }}>
|
||||
<SearchOutlined style={{ marginRight: 8, color: wt.iconColor }} />
|
||||
在数据库中搜索 — {dbName}
|
||||
</span>
|
||||
}
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={960}
|
||||
styles={{
|
||||
content: {
|
||||
background: wt.shellBg,
|
||||
borderRadius: 16,
|
||||
border: wt.shellBorder,
|
||||
boxShadow: wt.shellShadow,
|
||||
backdropFilter: wt.shellBackdropFilter,
|
||||
WebkitBackdropFilter: wt.shellBackdropFilter,
|
||||
},
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8 },
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="输入要搜索的字符串..."
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onPressEnter={!searching ? handleSearch : undefined}
|
||||
style={{ flex: 1 }}
|
||||
disabled={searching}
|
||||
autoFocus
|
||||
/>
|
||||
<Select
|
||||
value={matchMode}
|
||||
onChange={v => setMatchMode(v)}
|
||||
disabled={searching}
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ label: '包含', value: 'contains' },
|
||||
{ label: '精确匹配', value: 'exact' },
|
||||
]}
|
||||
/>
|
||||
{searching ? (
|
||||
<Button icon={<StopOutlined />} danger onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch} disabled={!keyword.trim()}>
|
||||
搜索
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{searching && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<Progress
|
||||
percent={percent}
|
||||
size="small"
|
||||
status="active"
|
||||
strokeColor={wt.iconColor}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: wt.mutedText }}>
|
||||
正在搜索 {progress.tableName}... ({progress.current}/{progress.total})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果汇总表 */}
|
||||
{results.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 13, color: wt.mutedText, fontWeight: 500 }}>
|
||||
找到 {results.length} 个表包含匹配数据
|
||||
{searching && '(搜索进行中...)'}
|
||||
</div>
|
||||
<Table
|
||||
dataSource={results}
|
||||
columns={summaryColumns}
|
||||
rowKey="tableName"
|
||||
size="small"
|
||||
pagination={false}
|
||||
style={{ borderRadius: 8, overflow: 'hidden' }}
|
||||
scroll={{ y: expandedTable ? 200 : 400 }}
|
||||
onRow={(record) => ({
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
background: expandedTable === record.tableName ? wt.hoverBg : undefined,
|
||||
},
|
||||
onClick: () => setExpandedTable(prev => prev === record.tableName ? null : record.tableName),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情展开 */}
|
||||
{expandedResult && (
|
||||
<div style={{
|
||||
border: wt.sectionBorder,
|
||||
borderRadius: 8,
|
||||
background: wt.sectionBg,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: wt.sectionBorder,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: wt.titleText,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span>
|
||||
<DatabaseOutlined style={{ marginRight: 6 }} />
|
||||
{expandedResult.tableName} — 匹配行详情
|
||||
</span>
|
||||
<Tag color="blue">{expandedResult.rows.length} 行</Tag>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={expandedResult.rows.map((row, i) => ({ ...row, __rowIdx: i }))}
|
||||
columns={detailColumns}
|
||||
rowKey="__rowIdx"
|
||||
size="small"
|
||||
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false }}
|
||||
scroll={{ x: Math.max(800, expandedResult.columns.length * 180) }}
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无结果且搜索完成 */}
|
||||
{!searching && results.length === 0 && progress.total > 0 && (
|
||||
<Empty description="未找到匹配的数据" style={{ margin: '24px 0' }} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FindInDatabaseModal;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip } from 'antd';
|
||||
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress } from 'antd';
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
TableOutlined,
|
||||
@@ -32,10 +32,14 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
||||
CheckOutlined,
|
||||
FilterOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { SavedConnection } from '../types';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
|
||||
import { getDbIcon } from './DatabaseIcons';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import FindInDatabaseModal from './FindInDatabaseModal';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -88,6 +92,7 @@ const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
|
||||
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const savedQueries = useStore(state => state.savedQueries);
|
||||
const deleteQuery = useStore(state => state.deleteQuery);
|
||||
const addConnection = useStore(state => state.addConnection);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
@@ -106,6 +111,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const tableSortPreference = useStore(state => state.tableSortPreference);
|
||||
const recordTableAccess = useStore(state => state.recordTableAccess);
|
||||
const setTableSortPreference = useStore(state => state.setTableSortPreference);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const darkMode = theme === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
@@ -121,41 +127,40 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
};
|
||||
const bgMain = getBg('#141414');
|
||||
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
|
||||
const modalPanelStyle = useMemo(() => ({
|
||||
background: darkMode
|
||||
? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)',
|
||||
backdropFilter: darkMode ? 'blur(18px)' : 'none',
|
||||
}), [darkMode]);
|
||||
background: overlayTheme.shellBg,
|
||||
border: overlayTheme.shellBorder,
|
||||
boxShadow: overlayTheme.shellShadow,
|
||||
backdropFilter: overlayTheme.shellBackdropFilter,
|
||||
}), [overlayTheme]);
|
||||
const modalSectionStyle = useMemo(() => ({
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
|
||||
}), [darkMode]);
|
||||
border: overlayTheme.sectionBorder,
|
||||
background: overlayTheme.sectionBg,
|
||||
}), [overlayTheme]);
|
||||
const modalScrollSectionStyle = useMemo(() => ({
|
||||
maxHeight: 400,
|
||||
overflow: 'auto' as const,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
border: overlayTheme.sectionBorder,
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.8)',
|
||||
}), [darkMode]);
|
||||
background: overlayTheme.sectionBg,
|
||||
}), [overlayTheme]);
|
||||
const modalHintTextStyle = useMemo(() => ({
|
||||
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
|
||||
color: overlayTheme.mutedText,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
}), [darkMode]);
|
||||
}), [overlayTheme]);
|
||||
const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
|
||||
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
|
||||
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -279,6 +284,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [batchConnContext, setBatchConnContext] = useState<any>(null);
|
||||
const [selectedDbConnection, setSelectedDbConnection] = useState<string>('');
|
||||
|
||||
// Find in Database Modal
|
||||
const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' });
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh queries for expanded databases
|
||||
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
|
||||
@@ -322,7 +330,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return {
|
||||
title: conn.name,
|
||||
key: conn.id,
|
||||
icon: conn.config.type === 'redis' ? <CloudOutlined style={{ color: '#DC382D' }} /> : <HddOutlined />,
|
||||
icon: getDbIcon(conn.iconType || conn.config.type, conn.iconColor, 22),
|
||||
type: 'connection',
|
||||
dataRef: conn,
|
||||
isLeaf: false,
|
||||
@@ -1447,6 +1455,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: any, node: any) => {
|
||||
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
|
||||
const { id, dbName, schemaName } = node.dataRef;
|
||||
addTab({
|
||||
id: `table-overview-${id}-${dbName}${schemaName ? `-${schemaName}` : ''}`,
|
||||
title: `表概览 - ${dbName}${schemaName ? ` (${schemaName})` : ''}`,
|
||||
type: 'table-overview' as any,
|
||||
connectionId: id,
|
||||
dbName,
|
||||
schemaName,
|
||||
} as any);
|
||||
return;
|
||||
}
|
||||
if (node.type === 'table') {
|
||||
const { tableName, dbName, id } = node.dataRef;
|
||||
// 记录表访问
|
||||
@@ -1479,7 +1499,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
type: 'query',
|
||||
connectionId: q.connectionId,
|
||||
dbName: q.dbName,
|
||||
query: q.sql
|
||||
query: q.sql,
|
||||
savedQueryId: q.id,
|
||||
});
|
||||
return;
|
||||
} else if (node.type === 'redis-db') {
|
||||
@@ -1560,7 +1581,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error('导出失败: ' + res.message);
|
||||
}
|
||||
};
|
||||
@@ -1583,7 +1604,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error('导出失败: ' + res.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -1610,7 +1631,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error('导出失败: ' + res.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -1799,7 +1820,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
} else {
|
||||
message.success('导出成功');
|
||||
}
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error('导出失败: ' + res.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -1808,6 +1829,94 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchClear = async () => {
|
||||
const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||||
if (selectedObjects.length === 0) {
|
||||
message.warning('请至少选择一个对象');
|
||||
return;
|
||||
}
|
||||
|
||||
const { conn, dbName } = batchDbContext;
|
||||
const objectNames = selectedObjects.map(t => t.objectName);
|
||||
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '确认清空选中表',
|
||||
content: `清空选中表会永久删除表中所有数据,操作不可逆,是否继续?\r\n\r\n连接: ${conn.name}\n数据库: ${dbName}`,
|
||||
okText: '继续',
|
||||
cancelText: '取消',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setIsBatchModalOpen(false);
|
||||
const hide = message.loading(`正在清空选中表 (${objectNames.length})...`, 0);
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const app = (window as any).go.app.App;
|
||||
const res = await app.TruncateTables(normalizeConnConfig(conn.config), dbName, objectNames);
|
||||
hide();
|
||||
const duration = Date.now() - startTime;
|
||||
if (res.success) {
|
||||
message.success('清空成功');
|
||||
// 构造 SQL 日志
|
||||
let logSql = `/* Truncate Tables (${objectNames.length} tables) */\n`;
|
||||
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
|
||||
logSql += res.data.executedSQLs.join(';\n') + ';';
|
||||
} else {
|
||||
logSql += objectNames.map(name => name).join('; ');
|
||||
}
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
sql: logSql,
|
||||
status: 'success',
|
||||
duration,
|
||||
message: res.message,
|
||||
dbName,
|
||||
affectedRows: res.data?.count || 0
|
||||
});
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error('清空失败: ' + res.message);
|
||||
// 记录失败的日志
|
||||
let logSql = `/* Truncate Tables (${objectNames.length} tables) - FAILED */\n`;
|
||||
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
|
||||
logSql += res.data.executedSQLs.join(';\n') + ';';
|
||||
} else {
|
||||
logSql += objectNames.map(name => name).join('; ');
|
||||
}
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
sql: logSql,
|
||||
status: 'error',
|
||||
duration,
|
||||
message: res.message,
|
||||
dbName
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
const duration = Date.now() - startTime;
|
||||
hide();
|
||||
const errMsg = e?.message || String(e);
|
||||
message.error('清空失败: ' + errMsg);
|
||||
// 记录异常的日志
|
||||
let logSql = `/* Truncate Tables (${objectNames.length} tables) - ERROR */\n`;
|
||||
logSql += objectNames.map(name => name).join('; ');
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
sql: logSql,
|
||||
status: 'error',
|
||||
duration,
|
||||
message: errMsg,
|
||||
dbName
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
if (batchSelectionScope === 'all') {
|
||||
setCheckedTableKeys(checked ? allBatchObjectKeys : []);
|
||||
@@ -1939,7 +2048,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success(`${db.dbName} 导出成功`);
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error(`${db.dbName} 导出失败: ` + res.message);
|
||||
break;
|
||||
} else {
|
||||
@@ -1968,23 +2077,127 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
|
||||
const handleRunSQLFile = async (node: any) => {
|
||||
const res = await (window as any).go.app.App.OpenSQLFile();
|
||||
const res = await OpenSQLFile();
|
||||
if (res.success) {
|
||||
const sqlContent = res.data;
|
||||
const data = res.data;
|
||||
// 大文件:后端返回文件路径,走流式执行
|
||||
if (data && typeof data === 'object' && data.isLargeFile) {
|
||||
const connId = node.type === 'connection' ? node.key : node.dataRef?.id;
|
||||
const dbName = node.dataRef?.dbName || '';
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (!conn) {
|
||||
message.error('未找到对应的连接配置');
|
||||
return;
|
||||
}
|
||||
startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB);
|
||||
return;
|
||||
}
|
||||
// 小文件:加载到编辑器
|
||||
const sqlContent = data;
|
||||
const { dbName, id } = node.dataRef;
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `Import SQL`,
|
||||
title: `运行外部SQL文件`,
|
||||
type: 'query',
|
||||
connectionId: node.type === 'connection' ? node.key : node.dataRef.id,
|
||||
dbName: dbName,
|
||||
query: sqlContent
|
||||
});
|
||||
} else if (res.message !== "Cancelled") {
|
||||
message.error("读取文件失败: " + res.message);
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error('读取文件失败: ' + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSQLFileFromToolbar = async () => {
|
||||
const ctx = useStore.getState().activeContext;
|
||||
if (!ctx?.connectionId) {
|
||||
message.warning('请先选择一个连接或数据库');
|
||||
return;
|
||||
}
|
||||
const res = await OpenSQLFile();
|
||||
if (res.success) {
|
||||
const data = res.data;
|
||||
// 大文件:后端流式执行
|
||||
if (data && typeof data === 'object' && data.isLargeFile) {
|
||||
const conn = connections.find(c => c.id === ctx.connectionId);
|
||||
if (!conn) {
|
||||
message.error('未找到对应的连接配置');
|
||||
return;
|
||||
}
|
||||
startSQLFileExecution(conn.config, ctx.dbName || '', data.filePath, data.fileSizeMB);
|
||||
return;
|
||||
}
|
||||
// 小文件
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `运行外部SQL文件`,
|
||||
type: 'query',
|
||||
connectionId: ctx.connectionId,
|
||||
dbName: ctx.dbName || undefined,
|
||||
query: data
|
||||
});
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error('读取文件失败: ' + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
// SQL 文件流式执行状态
|
||||
const [sqlFileExecState, setSqlFileExecState] = useState<{
|
||||
open: boolean;
|
||||
jobId: string;
|
||||
fileSizeMB: string;
|
||||
status: 'running' | 'done' | 'cancelled' | 'error';
|
||||
executed: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
currentSQL: string;
|
||||
resultMessage: string;
|
||||
}>({
|
||||
open: false, jobId: '', fileSizeMB: '', status: 'running',
|
||||
executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: ''
|
||||
});
|
||||
|
||||
const startSQLFileExecution = (config: any, dbName: string, filePath: string, fileSizeMB: string) => {
|
||||
const jobId = `sqlfile-${Date.now()}`;
|
||||
setSqlFileExecState({
|
||||
open: true, jobId, fileSizeMB, status: 'running',
|
||||
executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: ''
|
||||
});
|
||||
|
||||
// 监听进度事件
|
||||
const offProgress = EventsOn('sqlfile:progress', (event: any) => {
|
||||
if (!event || event.jobId !== jobId) return;
|
||||
setSqlFileExecState(prev => ({
|
||||
...prev,
|
||||
status: event.status || prev.status,
|
||||
executed: typeof event.executed === 'number' ? event.executed : prev.executed,
|
||||
failed: typeof event.failed === 'number' ? event.failed : prev.failed,
|
||||
total: typeof event.total === 'number' ? event.total : prev.total,
|
||||
percent: typeof event.percent === 'number' ? Math.min(100, event.percent) : prev.percent,
|
||||
currentSQL: typeof event.currentSQL === 'string' ? event.currentSQL : prev.currentSQL,
|
||||
}));
|
||||
});
|
||||
|
||||
// 异步执行
|
||||
ExecuteSQLFile(config, dbName, filePath, jobId).then(res => {
|
||||
offProgress();
|
||||
setSqlFileExecState(prev => ({
|
||||
...prev,
|
||||
status: res.success ? 'done' : (prev.status === 'cancelled' ? 'cancelled' : 'error'),
|
||||
percent: 100,
|
||||
resultMessage: res.message || '',
|
||||
}));
|
||||
}).catch(err => {
|
||||
offProgress();
|
||||
setSqlFileExecState(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
resultMessage: String(err?.message || err),
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateDatabase = async () => {
|
||||
try {
|
||||
const values = await createDbForm.validateFields();
|
||||
@@ -1993,7 +2206,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: "", // No db selected
|
||||
database: (conn.config.type === 'oracle' || conn.config.type === 'dameng') ? (conn.config.database || "") : "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
@@ -2535,12 +2748,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const searchScopePopoverContent = useMemo(() => {
|
||||
const smartSelected = searchScopes.includes('smart');
|
||||
const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart');
|
||||
const borderColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)';
|
||||
const mutedTextColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)';
|
||||
const titleColor = darkMode ? 'rgba(255,255,255,0.92)' : '#162033';
|
||||
const panelBg = darkMode
|
||||
? 'linear-gradient(180deg, rgba(17,24,39,0.96) 0%, rgba(10,15,26,0.98) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)';
|
||||
const borderColor = overlayTheme.sectionBorder.replace('1px solid ', '');
|
||||
const mutedTextColor = overlayTheme.mutedText;
|
||||
const titleColor = overlayTheme.titleText;
|
||||
const panelBg = overlayTheme.shellBg;
|
||||
const smartBg = smartSelected
|
||||
? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)')
|
||||
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)');
|
||||
@@ -2591,7 +2802,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div style={{ height: 1, background: borderColor, opacity: 0.9 }} />
|
||||
<div style={{ height: 1, background: overlayTheme.divider, opacity: 0.9 }} />
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.3, color: mutedTextColor, textTransform: 'uppercase' }}>手动范围</div>
|
||||
@@ -2628,7 +2839,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [darkMode, searchScopes]);
|
||||
}, [darkMode, overlayTheme, searchScopes]);
|
||||
|
||||
const parseHostOnlyToken = (value: unknown): string[] => {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -2983,6 +3194,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'open-sql-file',
|
||||
label: '运行外部SQL文件',
|
||||
icon: <FileAddOutlined />,
|
||||
onClick: () => handleRunSQLFile(node)
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'edit',
|
||||
@@ -3169,7 +3386,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
},
|
||||
{
|
||||
key: 'run-sql',
|
||||
label: '运行 SQL 文件...',
|
||||
label: '运行外部SQL文件',
|
||||
icon: <FileAddOutlined />,
|
||||
onClick: () => handleRunSQLFile(node)
|
||||
}
|
||||
@@ -3261,13 +3478,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
label: '新建查询',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
const tableName = String(node.dataRef?.tableName || '').trim();
|
||||
const queryTemplate = tableName ? `SELECT * FROM ${tableName};` : 'SELECT * FROM ';
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `新建查询`,
|
||||
type: 'query',
|
||||
connectionId: node.dataRef.id,
|
||||
dbName: node.dataRef.dbName,
|
||||
query: ''
|
||||
query: queryTemplate
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -3324,6 +3543,56 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// 已存查询节点的右键菜单
|
||||
if (node.type === 'saved-query') {
|
||||
const q = node.dataRef;
|
||||
return [
|
||||
{
|
||||
key: 'open-query',
|
||||
label: '打开查询',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: q.id,
|
||||
title: q.name,
|
||||
type: 'query',
|
||||
connectionId: q.connectionId,
|
||||
dbName: q.dbName,
|
||||
query: q.sql,
|
||||
savedQueryId: q.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete-query',
|
||||
label: '删除查询',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除已保存的查询 "${q.name}" 吗?此操作不可恢复。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
deleteQuery(q.id);
|
||||
// 从树中移除节点
|
||||
setTreeData(origin => {
|
||||
const removeNode = (list: TreeNode[]): TreeNode[] =>
|
||||
list
|
||||
.filter(n => n.key !== node.key)
|
||||
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
|
||||
return removeNode(origin);
|
||||
});
|
||||
message.success('查询已删除');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -3335,7 +3604,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
|
||||
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
|
||||
<Badge status={status} style={{ marginRight: 8 }} />
|
||||
<Badge status={status} style={{ marginLeft: 4, marginRight: 8 }} />
|
||||
) : null;
|
||||
|
||||
const displayTitle = String(node.title ?? '');
|
||||
@@ -3536,6 +3805,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
批量操作库
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileAddOutlined />}
|
||||
onClick={handleOpenSQLFileFromToolbar}
|
||||
style={{ flex: '1 1 auto' }}
|
||||
>
|
||||
运行外部SQL文件
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
@@ -3716,6 +3993,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
取消
|
||||
</Button>
|
||||
<Space size={8} wrap style={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
key="clear"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleBatchClear()}
|
||||
disabled={checkedTableKeys.length === 0}
|
||||
>
|
||||
清空表
|
||||
</Button>
|
||||
<Button
|
||||
key="export-schema"
|
||||
icon={<ExportOutlined />}
|
||||
@@ -3989,6 +4275,66 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* SQL 文件流式执行进度 Modal */}
|
||||
<Modal
|
||||
title="运行外部SQL文件"
|
||||
open={sqlFileExecState.open}
|
||||
centered
|
||||
closable={sqlFileExecState.status !== 'running'}
|
||||
maskClosable={false}
|
||||
footer={sqlFileExecState.status === 'running' ? [
|
||||
<Button key="cancel" danger onClick={() => {
|
||||
CancelSQLFileExecution(sqlFileExecState.jobId);
|
||||
setSqlFileExecState(prev => ({ ...prev, status: 'cancelled' }));
|
||||
}}>
|
||||
取消执行
|
||||
</Button>
|
||||
] : [
|
||||
<Button key="close" type="primary" onClick={() => setSqlFileExecState(prev => ({ ...prev, open: false }))}>
|
||||
关闭
|
||||
</Button>
|
||||
]}
|
||||
onCancel={() => {
|
||||
if (sqlFileExecState.status !== 'running') {
|
||||
setSqlFileExecState(prev => ({ ...prev, open: false }));
|
||||
}
|
||||
}}
|
||||
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none' }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Progress
|
||||
percent={Math.round(sqlFileExecState.percent)}
|
||||
status={sqlFileExecState.status === 'error' ? 'exception' : sqlFileExecState.status === 'done' ? 'success' : 'active'}
|
||||
strokeColor={sqlFileExecState.status === 'cancelled' ? '#faad14' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, lineHeight: '22px', marginBottom: 8 }}>
|
||||
<div>文件大小:<strong>{sqlFileExecState.fileSizeMB} MB</strong></div>
|
||||
<div>状态:<strong>{
|
||||
sqlFileExecState.status === 'running' ? '执行中...' :
|
||||
sqlFileExecState.status === 'done' ? '✅ 完成' :
|
||||
sqlFileExecState.status === 'cancelled' ? '⚠️ 已取消' : '❌ 出错'
|
||||
}</strong></div>
|
||||
<div>已执行:<strong style={{ color: '#52c41a' }}>{sqlFileExecState.executed}</strong> 条 | 失败:<strong style={{ color: sqlFileExecState.failed > 0 ? '#ff4d4f' : undefined }}>{sqlFileExecState.failed}</strong> 条</div>
|
||||
</div>
|
||||
{sqlFileExecState.currentSQL && sqlFileExecState.status === 'running' && (
|
||||
<div style={{ fontSize: 12, color: 'rgba(128,128,128,0.8)', background: 'rgba(128,128,128,0.06)', borderRadius: 6, padding: '6px 10px', marginTop: 8, fontFamily: 'monospace', wordBreak: 'break-all', maxHeight: 60, overflow: 'hidden' }}>
|
||||
{sqlFileExecState.currentSQL}
|
||||
</div>
|
||||
)}
|
||||
{sqlFileExecState.resultMessage && sqlFileExecState.status !== 'running' && (
|
||||
<div style={{ fontSize: 12, marginTop: 12, maxHeight: 200, overflow: 'auto', whiteSpace: 'pre-wrap', background: 'rgba(128,128,128,0.06)', borderRadius: 6, padding: '8px 12px' }}>
|
||||
{sqlFileExecState.resultMessage}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<FindInDatabaseModal
|
||||
open={findInDbContext.open}
|
||||
onClose={() => setFindInDbContext({ open: false, connectionId: '', dbName: '' })}
|
||||
connectionId={findInDbContext.connectionId}
|
||||
dbName={findInDbContext.dbName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import RedisViewer from './RedisViewer';
|
||||
import RedisCommandEditor from './RedisCommandEditor';
|
||||
import TriggerViewer from './TriggerViewer';
|
||||
import DefinitionViewer from './DefinitionViewer';
|
||||
import TableOverview from './TableOverview';
|
||||
import type { TabData } from '../types';
|
||||
|
||||
const detectConnectionEnvLabel = (connectionName: string): string | null => {
|
||||
@@ -28,7 +29,7 @@ const detectConnectionEnvLabel = (connectionName: string): string | null => {
|
||||
};
|
||||
|
||||
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
|
||||
if (tab.type !== 'table' && tab.type !== 'design') return tab.title;
|
||||
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title;
|
||||
if (!connectionName) return tab.title;
|
||||
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
|
||||
return `[${prefix}] ${tab.title}`;
|
||||
@@ -144,12 +145,8 @@ const TabManager: React.FC = () => {
|
||||
const items = useMemo(() => tabs.map((tab, index) => {
|
||||
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
|
||||
const displayTitle = buildTabDisplayTitle(tab, connectionName);
|
||||
const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command';
|
||||
const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive;
|
||||
let content;
|
||||
if (!shouldRenderContent) {
|
||||
content = null;
|
||||
} else if (tab.type === 'query') {
|
||||
if (tab.type === 'query') {
|
||||
content = <QueryEditor tab={tab} />;
|
||||
} else if (tab.type === 'table') {
|
||||
content = <DataViewer tab={tab} />;
|
||||
@@ -163,6 +160,8 @@ const TabManager: React.FC = () => {
|
||||
content = <TriggerViewer tab={tab} />;
|
||||
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
|
||||
content = <DefinitionViewer tab={tab} />;
|
||||
} else if (tab.type === 'table-overview') {
|
||||
content = <TableOverview tab={tab} />;
|
||||
}
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
@@ -203,7 +202,7 @@ const TabManager: React.FC = () => {
|
||||
key: tab.id,
|
||||
children: content,
|
||||
};
|
||||
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -297,6 +296,7 @@ const TabManager: React.FC = () => {
|
||||
<Tabs
|
||||
className="main-tabs"
|
||||
type="editable-card"
|
||||
destroyInactiveTabPane={false}
|
||||
onChange={(newActiveKey) => {
|
||||
if (Date.now() < suppressClickUntilRef.current) return;
|
||||
onChange(newActiveKey);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react';
|
||||
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag } from 'antd';
|
||||
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd';
|
||||
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
@@ -48,6 +48,7 @@ interface ForeignKeyFormState {
|
||||
refColumnNames: string[];
|
||||
}
|
||||
|
||||
// 通用兜底类型列表
|
||||
const COMMON_TYPES = [
|
||||
{ value: 'int' },
|
||||
{ value: 'varchar(255)' },
|
||||
@@ -59,6 +60,148 @@ const COMMON_TYPES = [
|
||||
{ value: 'json' },
|
||||
];
|
||||
|
||||
// 按数据库方言分组的完整字段类型列表
|
||||
const DB_TYPE_OPTIONS: Record<string, { value: string }[]> = {
|
||||
mysql: [
|
||||
// 数值
|
||||
{ value: 'tinyint' },
|
||||
{ value: 'tinyint(1)' },
|
||||
{ value: 'smallint' },
|
||||
{ value: 'mediumint' },
|
||||
{ value: 'int' },
|
||||
{ value: 'bigint' },
|
||||
{ value: 'float' },
|
||||
{ value: 'double' },
|
||||
{ value: 'decimal(10,2)' },
|
||||
// 字符串
|
||||
{ value: 'char(50)' },
|
||||
{ value: 'varchar(255)' },
|
||||
{ value: 'tinytext' },
|
||||
{ value: 'text' },
|
||||
{ value: 'mediumtext' },
|
||||
{ value: 'longtext' },
|
||||
// 二进制
|
||||
{ value: 'binary(255)' },
|
||||
{ value: 'varbinary(255)' },
|
||||
{ value: 'tinyblob' },
|
||||
{ value: 'blob' },
|
||||
{ value: 'mediumblob' },
|
||||
{ value: 'longblob' },
|
||||
// 日期时间
|
||||
{ value: 'date' },
|
||||
{ value: 'time' },
|
||||
{ value: 'datetime' },
|
||||
{ value: 'timestamp' },
|
||||
{ value: 'year' },
|
||||
// 其他
|
||||
{ value: 'json' },
|
||||
{ value: 'enum' },
|
||||
{ value: 'set' },
|
||||
{ value: 'bit(1)' },
|
||||
],
|
||||
postgres: [
|
||||
// 数值
|
||||
{ value: 'smallint' },
|
||||
{ value: 'integer' },
|
||||
{ value: 'bigint' },
|
||||
{ value: 'real' },
|
||||
{ value: 'double precision' },
|
||||
{ value: 'numeric(10,2)' },
|
||||
{ value: 'serial' },
|
||||
{ value: 'bigserial' },
|
||||
// 字符串
|
||||
{ value: 'char(50)' },
|
||||
{ value: 'varchar(255)' },
|
||||
{ value: 'text' },
|
||||
// 布尔
|
||||
{ value: 'boolean' },
|
||||
// 日期时间
|
||||
{ value: 'date' },
|
||||
{ value: 'time' },
|
||||
{ value: 'timestamp' },
|
||||
{ value: 'timestamptz' },
|
||||
{ value: 'interval' },
|
||||
// 二进制
|
||||
{ value: 'bytea' },
|
||||
// JSON
|
||||
{ value: 'json' },
|
||||
{ value: 'jsonb' },
|
||||
// 其他
|
||||
{ value: 'uuid' },
|
||||
{ value: 'inet' },
|
||||
{ value: 'cidr' },
|
||||
{ value: 'macaddr' },
|
||||
{ value: 'xml' },
|
||||
{ value: 'int4range' },
|
||||
{ value: 'tsquery' },
|
||||
{ value: 'tsvector' },
|
||||
],
|
||||
sqlserver: [
|
||||
// 数值
|
||||
{ value: 'tinyint' },
|
||||
{ value: 'smallint' },
|
||||
{ value: 'int' },
|
||||
{ value: 'bigint' },
|
||||
{ value: 'float' },
|
||||
{ value: 'real' },
|
||||
{ value: 'decimal(10,2)' },
|
||||
{ value: 'numeric(10,2)' },
|
||||
{ value: 'money' },
|
||||
{ value: 'smallmoney' },
|
||||
// 字符串
|
||||
{ value: 'char(50)' },
|
||||
{ value: 'varchar(255)' },
|
||||
{ value: 'varchar(max)' },
|
||||
{ value: 'nchar(50)' },
|
||||
{ value: 'nvarchar(255)' },
|
||||
{ value: 'nvarchar(max)' },
|
||||
{ value: 'text' },
|
||||
{ value: 'ntext' },
|
||||
// 日期时间
|
||||
{ value: 'date' },
|
||||
{ value: 'time' },
|
||||
{ value: 'datetime' },
|
||||
{ value: 'datetime2' },
|
||||
{ value: 'datetimeoffset' },
|
||||
{ value: 'smalldatetime' },
|
||||
// 二进制
|
||||
{ value: 'binary(255)' },
|
||||
{ value: 'varbinary(255)' },
|
||||
{ value: 'varbinary(max)' },
|
||||
{ value: 'image' },
|
||||
// 其他
|
||||
{ value: 'bit' },
|
||||
{ value: 'uniqueidentifier' },
|
||||
{ value: 'xml' },
|
||||
],
|
||||
sqlite: [
|
||||
{ value: 'INTEGER' },
|
||||
{ value: 'REAL' },
|
||||
{ value: 'TEXT' },
|
||||
{ value: 'BLOB' },
|
||||
{ value: 'NUMERIC' },
|
||||
],
|
||||
oracle: [
|
||||
{ value: 'NUMBER(10)' },
|
||||
{ value: 'NUMBER(10,2)' },
|
||||
{ value: 'FLOAT' },
|
||||
{ value: 'BINARY_FLOAT' },
|
||||
{ value: 'BINARY_DOUBLE' },
|
||||
{ value: 'CHAR(50)' },
|
||||
{ value: 'VARCHAR2(255)' },
|
||||
{ value: 'NVARCHAR2(255)' },
|
||||
{ value: 'CLOB' },
|
||||
{ value: 'NCLOB' },
|
||||
{ value: 'BLOB' },
|
||||
{ value: 'DATE' },
|
||||
{ value: 'TIMESTAMP' },
|
||||
{ value: 'TIMESTAMP WITH TIME ZONE' },
|
||||
{ value: 'RAW(255)' },
|
||||
{ value: 'LONG RAW' },
|
||||
{ value: 'XMLTYPE' },
|
||||
],
|
||||
};
|
||||
|
||||
const COMMON_DEFAULTS = [
|
||||
{ value: 'CURRENT_TIMESTAMP' },
|
||||
{ value: 'NULL' },
|
||||
@@ -121,7 +264,7 @@ const ResizableTitle = (props: any) => {
|
||||
nextStyle.width = width;
|
||||
}
|
||||
|
||||
if (!width) {
|
||||
if (!onResizeStart) {
|
||||
return <th {...restProps} style={nextStyle} />;
|
||||
}
|
||||
|
||||
@@ -225,7 +368,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [tableCommentDraft, setTableCommentDraft] = useState('');
|
||||
const [isTableCommentModalOpen, setIsTableCommentModalOpen] = useState(false);
|
||||
const [tableCommentSaving, setTableCommentSaving] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState<IndexDisplayRow | null>(null);
|
||||
const [selectedIndexKeys, setSelectedIndexKeys] = useState<string[]>([]);
|
||||
const [isIndexModalOpen, setIsIndexModalOpen] = useState(false);
|
||||
const [indexModalMode, setIndexModalMode] = useState<'create' | 'edit'>('create');
|
||||
const [indexSaving, setIndexSaving] = useState(false);
|
||||
@@ -270,6 +413,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const [tableHeight, setTableHeight] = useState(500);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const pendingFocusColumnKeyRef = useRef<string | null>(null);
|
||||
const focusHighlightTimerRef = useRef<number | null>(null);
|
||||
const [focusColumnKey, setFocusColumnKey] = useState('');
|
||||
@@ -289,47 +433,28 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
setCommentEditorValue('');
|
||||
}, []);
|
||||
|
||||
// 初始化透明 Monaco Editor 主题
|
||||
useEffect(() => {
|
||||
loader.init().then(monaco => {
|
||||
monaco.editor.defineTheme('transparent-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.lineHighlightBackground': '#ffffff10',
|
||||
'editorGutter.background': '#00000000',
|
||||
}
|
||||
});
|
||||
monaco.editor.defineTheme('transparent-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.lineHighlightBackground': '#00000010',
|
||||
'editorGutter.background': '#00000000',
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
|
||||
|
||||
// 监听字段 Tab 容器高度,为所有 Tab 内表格计算 scroll.y
|
||||
// 当 Tab 切换时,字段 Tab 被 display:none 导致 height=0,跳过该次更新保持有效值
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
const h = Math.max(200, entry.contentRect.height - 40);
|
||||
setTableHeight(h);
|
||||
const h = entry.contentRect.height;
|
||||
// 跳过零高度观测(Tab 面板被隐藏时)
|
||||
if (h <= 0) return;
|
||||
setTableHeight(Math.max(200, h - 40));
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [activeKey]); // Re-attach when tab switches
|
||||
}, []); // 不依赖 activeKey,仅挂载一次,通过零高度守卫避免 Tab 切换异常
|
||||
|
||||
// --- Resizable Columns State ---
|
||||
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
||||
const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number } | null>(null);
|
||||
const [indexColumns, setIndexColumns] = useState<any[]>([]);
|
||||
const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number; setter: React.Dispatch<React.SetStateAction<any[]>> } | null>(null);
|
||||
const resizeRafRef = useRef<number | null>(null);
|
||||
const latestResizeXRef = useRef<number | null>(null);
|
||||
const ghostRef = useRef<HTMLDivElement>(null);
|
||||
@@ -413,11 +538,6 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
// Initial Columns Definition
|
||||
useEffect(() => {
|
||||
const initialCols = [
|
||||
...(readOnly ? [] : [{
|
||||
key: 'sort',
|
||||
width: 40,
|
||||
render: () => <MenuOutlined style={{ cursor: 'grab', color: '#999' }} />,
|
||||
}]),
|
||||
{
|
||||
title: '名',
|
||||
dataIndex: 'name',
|
||||
@@ -433,7 +553,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
key: 'type',
|
||||
width: 150,
|
||||
render: (text: string, record: EditableColumn) => readOnly ? text : (
|
||||
<AutoComplete options={COMMON_TYPES} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
|
||||
<AutoComplete options={DB_TYPE_OPTIONS[getDbType()] || COMMON_TYPES} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -548,17 +668,17 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
document.body.style.userSelect = '';
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback((index: number) => (e: React.MouseEvent) => {
|
||||
const createResizeStartHandler = useCallback((columns: any[], setter: React.Dispatch<React.SetStateAction<any[]>>) => (index: number) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startX = e.clientX;
|
||||
const currentWidth = Number(tableColumns[index]?.width || 200);
|
||||
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0;
|
||||
resizeDragRef.current = { startX, startWidth: currentWidth, index, containerLeft };
|
||||
const currentWidth = Number(columns[index]?.width || 200);
|
||||
const containerLeft = shellRef.current?.getBoundingClientRect().left ?? 0;
|
||||
resizeDragRef.current = { startX, startWidth: currentWidth, index, containerLeft, setter };
|
||||
latestResizeXRef.current = startX;
|
||||
|
||||
if (ghostRef.current && containerRef.current) {
|
||||
if (ghostRef.current && shellRef.current) {
|
||||
const relativeLeft = startX - containerLeft;
|
||||
ghostRef.current.style.transform = `translateX(${relativeLeft}px)`;
|
||||
ghostRef.current.style.display = 'block';
|
||||
@@ -575,10 +695,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const onUp = (event: MouseEvent) => {
|
||||
if (resizeDragRef.current) {
|
||||
const { startX: dragStartX, startWidth, index: dragIndex } = resizeDragRef.current;
|
||||
const { startX: dragStartX, startWidth, index: dragIndex, setter: dragSetter } = resizeDragRef.current;
|
||||
const deltaX = event.clientX - dragStartX;
|
||||
const newWidth = Math.max(50, startWidth + deltaX);
|
||||
setTableColumns((prevColumns) => {
|
||||
dragSetter((prevColumns) => {
|
||||
if (!prevColumns[dragIndex]) return prevColumns;
|
||||
const nextColumns = [...prevColumns];
|
||||
nextColumns[dragIndex] = {
|
||||
@@ -598,7 +718,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, [cleanupResizeState, detachResizeListeners, flushResizeGhost, tableColumns]);
|
||||
}, [cleanupResizeState, detachResizeListeners, flushResizeGhost]);
|
||||
|
||||
const handleResizeStart = useMemo(() => createResizeStartHandler(tableColumns, setTableColumns), [createResizeStartHandler, tableColumns]);
|
||||
const handleIndexResizeStart = useMemo(() => createResizeStartHandler(indexColumns, setIndexColumns), [createResizeStartHandler, indexColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1083,6 +1206,11 @@ ${selectedTrigger.statement}`;
|
||||
});
|
||||
}, [indexes]);
|
||||
|
||||
const selectedIndex = useMemo(() => {
|
||||
if (selectedIndexKeys.length === 0) return null;
|
||||
return groupedIndexes.find(idx => selectedIndexKeys.includes(idx.key)) || null;
|
||||
}, [selectedIndexKeys, groupedIndexes]);
|
||||
|
||||
const groupedIndexFieldCount = useMemo(
|
||||
() => groupedIndexes.reduce((total, row) => total + row.columnNames.length, 0),
|
||||
[groupedIndexes]
|
||||
@@ -1161,11 +1289,12 @@ ${selectedTrigger.statement}`;
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedIndex) return;
|
||||
if (!groupedIndexes.some(idx => idx.key === selectedIndex.key)) {
|
||||
setSelectedIndex(null);
|
||||
if (selectedIndexKeys.length === 0) return;
|
||||
const validKeys = selectedIndexKeys.filter(key => groupedIndexes.some(idx => idx.key === key));
|
||||
if (validKeys.length !== selectedIndexKeys.length) {
|
||||
setSelectedIndexKeys(validKeys);
|
||||
}
|
||||
}, [groupedIndexes, selectedIndex]);
|
||||
}, [groupedIndexes, selectedIndexKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedForeignKey) return;
|
||||
@@ -1397,14 +1526,23 @@ ${selectedTrigger.statement}`;
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
try {
|
||||
const res = await DBQuery(config as any, tab.dbName || '', sql);
|
||||
if (res.success) {
|
||||
message.success(successMessage);
|
||||
await fetchData();
|
||||
return true;
|
||||
// 多条 DDL 语句(如 DROP INDEX + CREATE INDEX)需要逐条执行,
|
||||
// 因为 Go MySQL 驱动默认不支持多语句 Exec。
|
||||
const statements = sql.split(/;\s*\n/).map(s => s.trim()).filter(Boolean);
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
let stmt = statements[i];
|
||||
if (!stmt.endsWith(';')) stmt += ';';
|
||||
const res = await DBQuery(config as any, tab.dbName || '', stmt);
|
||||
if (!res.success) {
|
||||
const prefix = statements.length > 1 ? `第 ${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: ';
|
||||
message.error(prefix + res.message);
|
||||
if (i > 0) await fetchData();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
message.error('执行失败: ' + res.message);
|
||||
return false;
|
||||
message.success(successMessage);
|
||||
await fetchData();
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
message.error('执行失败: ' + (e?.message || String(e)));
|
||||
return false;
|
||||
@@ -1696,28 +1834,44 @@ END;`;
|
||||
};
|
||||
|
||||
const handleDeleteIndex = () => {
|
||||
if (!selectedIndex) {
|
||||
message.warning('请先选择一个索引');
|
||||
if (selectedIndexKeys.length === 0) {
|
||||
message.warning('请先选择要删除的索引');
|
||||
return;
|
||||
}
|
||||
if (!supportsIndexSchemaOps()) {
|
||||
message.warning('当前数据库暂不支持在此维护索引');
|
||||
return;
|
||||
}
|
||||
// 根据选中的 key 找到对应的索引对象
|
||||
const toDelete = groupedIndexes.filter(idx => selectedIndexKeys.includes(idx.key));
|
||||
if (toDelete.length === 0) {
|
||||
message.warning('请先选择要删除的索引');
|
||||
return;
|
||||
}
|
||||
const names = toDelete.map(idx => `"${idx.name}"`).join('、');
|
||||
Modal.confirm({
|
||||
title: '确认删除索引',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `确定删除索引 "${selectedIndex.name}" 吗?`,
|
||||
content: toDelete.length === 1
|
||||
? `确定删除索引 ${names} 吗?`
|
||||
: `确定删除以下 ${toDelete.length} 个索引吗?\n${names}`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const sql = buildIndexDropSql(selectedIndex.name);
|
||||
if (!sql) {
|
||||
message.warning('当前数据库暂不支持删除该索引');
|
||||
return;
|
||||
const sqls: string[] = [];
|
||||
for (const idx of toDelete) {
|
||||
const sql = buildIndexDropSql(idx.name);
|
||||
if (!sql) {
|
||||
message.warning(`当前数据库暂不支持删除索引 "${idx.name}"`);
|
||||
return;
|
||||
}
|
||||
sqls.push(sql);
|
||||
}
|
||||
const ok = await executeSchemaSql(sqls.join('\n'), toDelete.length === 1 ? '索引删除成功' : `${toDelete.length} 个索引删除成功`);
|
||||
if (ok) {
|
||||
setSelectedIndexKeys([]);
|
||||
}
|
||||
await executeSchemaSql(sql, '索引删除成功');
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -2000,13 +2154,163 @@ END;`;
|
||||
};
|
||||
|
||||
// Merge columns with resize handler
|
||||
const resizableColumns = tableColumns.map((col, index) => ({
|
||||
const resizableColumns = useMemo(() => tableColumns.map((col, index) => ({
|
||||
...col,
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResizeStart: handleResizeStart(index),
|
||||
}),
|
||||
}));
|
||||
})), [tableColumns]);
|
||||
|
||||
// 字段表 Checkbox 选择列(不参与 resize,支持全选)
|
||||
const allColumnKeys = useMemo(() => columns.map(c => c._key), [columns]);
|
||||
const isAllColumnsSelected = allColumnKeys.length > 0 && selectedColumnRowKeys.length === allColumnKeys.length;
|
||||
const isColumnsIndeterminate = selectedColumnRowKeys.length > 0 && selectedColumnRowKeys.length < allColumnKeys.length;
|
||||
|
||||
const columnSelectCol = useMemo(() => ({
|
||||
title: () => (
|
||||
<Checkbox
|
||||
checked={isAllColumnsSelected}
|
||||
indeterminate={isColumnsIndeterminate}
|
||||
onChange={(e: any) => setSelectedColumnRowKeys(e.target.checked ? allColumnKeys : [])}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
),
|
||||
dataIndex: '_select',
|
||||
key: '_select',
|
||||
width: 48,
|
||||
render: (_: any, record: any) => (
|
||||
<Checkbox
|
||||
checked={selectedColumnRowKeys.includes(record._key)}
|
||||
onChange={(e: any) => {
|
||||
e.stopPropagation();
|
||||
setSelectedColumnRowKeys((prev: string[]) =>
|
||||
e.target.checked
|
||||
? [...prev, record._key]
|
||||
: prev.filter((k: string) => k !== record._key)
|
||||
);
|
||||
}}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
),
|
||||
}), [selectedColumnRowKeys, allColumnKeys, isAllColumnsSelected, isColumnsIndeterminate]);
|
||||
|
||||
// sort 拖拽列(不参与 resize)
|
||||
const sortColumn = useMemo(() => ({
|
||||
key: 'sort',
|
||||
width: 40,
|
||||
render: () => <MenuOutlined style={{ cursor: 'grab', color: '#999' }} />,
|
||||
}), []);
|
||||
|
||||
const columnsWithSelect = useMemo(() =>
|
||||
readOnly
|
||||
? resizableColumns
|
||||
: [columnSelectCol, sortColumn, ...resizableColumns],
|
||||
[readOnly, columnSelectCol, sortColumn, resizableColumns]
|
||||
);
|
||||
|
||||
// --- Index Columns Init ---
|
||||
useEffect(() => {
|
||||
setIndexColumns([
|
||||
{
|
||||
title: '索引名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 240,
|
||||
render: (text: string) => (
|
||||
<Tooltip title={text}>
|
||||
<span style={{ display: 'inline-block', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '字段',
|
||||
dataIndex: 'columnNames',
|
||||
key: 'columnNames',
|
||||
width: 320,
|
||||
render: (columnNames: string[]) => {
|
||||
if (!columnNames || columnNames.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{columnNames.map((columnName: string, idx: number) => (
|
||||
<Tag key={`${columnName}-${idx}`}>
|
||||
{columnName}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '索引类型',
|
||||
dataIndex: 'indexType',
|
||||
key: 'indexType',
|
||||
width: 140,
|
||||
render: (text: string) => text || '-',
|
||||
},
|
||||
{
|
||||
title: '唯一性',
|
||||
dataIndex: 'nonUnique',
|
||||
key: 'nonUnique',
|
||||
width: 110,
|
||||
render: (v: number) => (
|
||||
<Tag color={v === 0 ? 'gold' : 'default'}>
|
||||
{v === 0 ? '唯一' : '普通'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Checkbox 选择列(不参与 resize,支持全选)
|
||||
const allIndexKeys = groupedIndexes.map(idx => idx.key);
|
||||
const isAllSelected = allIndexKeys.length > 0 && selectedIndexKeys.length === allIndexKeys.length;
|
||||
const isIndeterminate = selectedIndexKeys.length > 0 && selectedIndexKeys.length < allIndexKeys.length;
|
||||
|
||||
const selectColumn = {
|
||||
title: () => (
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => {
|
||||
setSelectedIndexKeys(e.target.checked ? allIndexKeys : []);
|
||||
}}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
),
|
||||
dataIndex: '_select',
|
||||
key: '_select',
|
||||
width: 48,
|
||||
render: (_: any, record: any) => (
|
||||
<Checkbox
|
||||
checked={selectedIndexKeys.includes(record.key)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedIndexKeys(prev =>
|
||||
e.target.checked
|
||||
? [...prev, record.key]
|
||||
: prev.filter(k => k !== record.key)
|
||||
);
|
||||
}}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const resizableIndexColumns = [
|
||||
selectColumn,
|
||||
...indexColumns.map((col, index) => ({
|
||||
...col,
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResizeStart: handleIndexResizeStart(index),
|
||||
}),
|
||||
})),
|
||||
];
|
||||
|
||||
const columnsTabContent = (
|
||||
<div
|
||||
@@ -2030,7 +2334,7 @@ END;`;
|
||||
{readOnly ? (
|
||||
<Table
|
||||
dataSource={columns}
|
||||
columns={resizableColumns}
|
||||
columns={columnsWithSelect}
|
||||
rowKey="_key"
|
||||
rowClassName={(record: EditableColumn) => record._key === focusColumnKey ? 'table-designer-focus-row' : ''}
|
||||
size="small"
|
||||
@@ -2049,11 +2353,7 @@ END;`;
|
||||
<SortableContext items={columns.map(c => c._key)} strategy={verticalListSortingStrategy}>
|
||||
<Table
|
||||
dataSource={columns}
|
||||
columns={resizableColumns}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedColumnRowKeys,
|
||||
onChange: (nextSelectedRowKeys) => setSelectedColumnRowKeys(nextSelectedRowKeys as string[]),
|
||||
}}
|
||||
columns={columnsWithSelect}
|
||||
rowKey="_key"
|
||||
rowClassName={(record: EditableColumn) => record._key === focusColumnKey ? 'table-designer-focus-row' : ''}
|
||||
size="small"
|
||||
@@ -2069,6 +2369,86 @@ END;`;
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={shellRef} className="table-designer-shell" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: '6px 0', position: 'relative' }}>
|
||||
<style>{`
|
||||
.table-designer-shell .ant-table,
|
||||
.table-designer-shell .ant-table-wrapper,
|
||||
.table-designer-shell .ant-table-container {
|
||||
background: transparent !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-wrapper {
|
||||
border: none !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-container {
|
||||
border: none !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-thead > tr > th {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'} !important;
|
||||
border-inline-end: 1px solid transparent !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-tbody > tr > td,
|
||||
.table-designer-shell .ant-table-tbody .ant-table-row > .ant-table-cell {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important;
|
||||
border-inline-end: 1px solid transparent !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-tbody td .ant-input {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-tbody td .ant-select .ant-select-selector {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-thead > tr > th::before {
|
||||
display: none !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-thead > tr > th {
|
||||
cursor: default !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-tbody > tr:hover > td,
|
||||
.table-designer-shell .ant-table-tbody .ant-table-row:hover > .ant-table-cell {
|
||||
background: ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.02)'} !important;
|
||||
}
|
||||
.table-designer-shell .ant-tabs-nav {
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
.table-designer-shell .ant-tabs-nav::before {
|
||||
border-bottom-color: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'} !important;
|
||||
}
|
||||
.table-designer-shell .ant-tabs-ink-bar {
|
||||
will-change: transform;
|
||||
transition: width 0.15s ease, left 0.15s ease, transform 0.15s ease !important;
|
||||
}
|
||||
.table-designer-shell .ant-tabs-tab {
|
||||
transition: color 0.15s ease !important;
|
||||
}
|
||||
.table-designer-shell .ant-tabs-content-holder,
|
||||
.table-designer-shell .ant-tabs-content,
|
||||
.table-designer-shell .ant-tabs-tabpane {
|
||||
height: 100%;
|
||||
}
|
||||
.table-designer-shell .react-resizable-handle {
|
||||
position: absolute !important;
|
||||
right: 0 !important;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 10px !important;
|
||||
height: auto !important;
|
||||
background-position: top right !important;
|
||||
cursor: col-resize !important;
|
||||
z-index: 10;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
`}</style>
|
||||
<div
|
||||
ref={ghostRef}
|
||||
style={{
|
||||
@@ -2084,52 +2464,6 @@ END;`;
|
||||
willChange: 'transform',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="table-designer-shell" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: '6px 0' }}>
|
||||
<style>{`
|
||||
.table-designer-shell .ant-table,
|
||||
.table-designer-shell .ant-table-wrapper,
|
||||
.table-designer-shell .ant-table-container {
|
||||
background: transparent !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-wrapper,
|
||||
.table-designer-shell .ant-table-container {
|
||||
border: none !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-thead > tr > th {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'} !important;
|
||||
border-inline-end: 1px solid transparent !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-tbody > tr > td,
|
||||
.table-designer-shell .ant-table-tbody .ant-table-row > .ant-table-cell {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important;
|
||||
border-inline-end: 1px solid transparent !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-thead > tr > th::before {
|
||||
display: none !important;
|
||||
}
|
||||
.table-designer-shell .ant-table-tbody > tr:hover > td,
|
||||
.table-designer-shell .ant-table-tbody .ant-table-row:hover > .ant-table-cell {
|
||||
background: ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.02)'} !important;
|
||||
}
|
||||
.table-designer-shell .ant-tabs-nav {
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
.table-designer-shell .ant-tabs-nav::before {
|
||||
border-bottom-color: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'} !important;
|
||||
}
|
||||
.table-designer-shell .ant-tabs-content-holder,
|
||||
.table-designer-shell .ant-tabs-content,
|
||||
.table-designer-shell .ant-tabs-tabpane {
|
||||
height: 100%;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px 8px 12px',
|
||||
@@ -2202,7 +2536,7 @@ END;`;
|
||||
</div>
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
onChange={(key) => React.startTransition(() => setActiveKey(key))}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -2225,20 +2559,20 @@ END;`;
|
||||
key: 'indexes',
|
||||
label: '索引',
|
||||
children: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="index-table-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{!readOnly && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button size="small" icon={<PlusOutlined />} disabled={!supportsIndexSchemaOps()} onClick={openCreateIndexModal}>新增</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={openEditIndexModal}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={handleDeleteIndex}>删除</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length !== 1} onClick={openEditIndexModal}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length === 0} onClick={handleDeleteIndex}>删除</Button>
|
||||
{!supportsIndexSchemaOps() && (
|
||||
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
|
||||
当前数据库暂不支持索引编辑,仅支持查看
|
||||
</span>
|
||||
)}
|
||||
{supportsIndexSchemaOps() && selectedIndex && (
|
||||
{supportsIndexSchemaOps() && selectedIndexKeys.length > 0 && (
|
||||
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
|
||||
已选择:{selectedIndex.name}
|
||||
已选择:{selectedIndexKeys.length} 个索引
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -2248,75 +2582,22 @@ END;`;
|
||||
</div>
|
||||
<Table
|
||||
dataSource={groupedIndexes}
|
||||
columns={[
|
||||
{
|
||||
title: '索引名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 240,
|
||||
render: (text: string) => (
|
||||
<Tooltip title={text}>
|
||||
<span style={{ display: 'inline-block', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '字段',
|
||||
dataIndex: 'columnNames',
|
||||
key: 'columnNames',
|
||||
render: (columnNames: string[]) => {
|
||||
if (!columnNames || columnNames.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{columnNames.map((columnName, idx) => (
|
||||
<Tag key={`${columnName}-${idx}`}>
|
||||
{columnName}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '索引类型',
|
||||
dataIndex: 'indexType',
|
||||
key: 'indexType',
|
||||
width: 140,
|
||||
render: (text: string) => text || '-',
|
||||
},
|
||||
{
|
||||
title: '唯一性',
|
||||
dataIndex: 'nonUnique',
|
||||
key: 'nonUnique',
|
||||
width: 110,
|
||||
render: (v: number) => (
|
||||
<Tag color={v === 0 ? 'gold' : 'default'}>
|
||||
{v === 0 ? '唯一' : '普通'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
columns={resizableIndexColumns}
|
||||
rowKey="key"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
scroll={{ x: 960, y: tableHeight }}
|
||||
rowSelection={{
|
||||
type: 'radio',
|
||||
selectedRowKeys: selectedIndex ? [selectedIndex.key] : [],
|
||||
onChange: (_, selectedRows) => setSelectedIndex((selectedRows[0] as IndexDisplayRow) || null),
|
||||
components={{
|
||||
header: { cell: ResizableTitle },
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
if (selectedIndex?.key === record.key) {
|
||||
setSelectedIndex(null);
|
||||
} else {
|
||||
setSelectedIndex(record);
|
||||
}
|
||||
setSelectedIndexKeys(prev =>
|
||||
prev.includes(record.key)
|
||||
? prev.filter(k => k !== record.key)
|
||||
: [...prev, record.key]
|
||||
);
|
||||
},
|
||||
style: { cursor: 'pointer' }
|
||||
})}
|
||||
@@ -2420,6 +2701,7 @@ END;`;
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
scroll={{ y: tableHeight }}
|
||||
locale={{ emptyText: <Empty description="该表暂无触发器" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
rowSelection={{
|
||||
type: 'radio',
|
||||
@@ -2676,7 +2958,7 @@ END;`;
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
<pre style={{ background: '#f5f5f5', padding: '10px', borderRadius: '4px', border: '1px solid #eee', whiteSpace: 'pre-wrap' }}>
|
||||
<pre style={{ background: darkMode ? '#1e1e1e' : '#f5f5f5', color: darkMode ? '#d4d4d4' : 'inherit', padding: '10px', borderRadius: '4px', border: darkMode ? '1px solid #333' : '1px solid #eee', whiteSpace: 'pre-wrap' }}>
|
||||
{previewSql}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
456
frontend/src/components/TableOverview.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
|
||||
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
|
||||
import type { TabData } from '../types';
|
||||
|
||||
interface TableOverviewProps {
|
||||
tab: TabData;
|
||||
}
|
||||
|
||||
interface TableStatRow {
|
||||
name: string;
|
||||
comment: string;
|
||||
rows: number;
|
||||
dataSize: number;
|
||||
indexSize: number;
|
||||
engine: string;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
type SortField = 'name' | 'rows' | 'dataSize';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (!bytes || bytes <= 0) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const formatRows = (count: number): string => {
|
||||
if (count === undefined || count === null || count < 0) return '—';
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
|
||||
return String(count);
|
||||
};
|
||||
|
||||
const getMetadataDialect = (connType: string, driver?: string): string => {
|
||||
const type = (connType || '').trim().toLowerCase();
|
||||
if (type === 'custom') {
|
||||
const d = (driver || '').trim().toLowerCase();
|
||||
if (d === 'diros' || d === 'doris') return 'mysql';
|
||||
return d;
|
||||
}
|
||||
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'dameng') return 'dm';
|
||||
return type;
|
||||
};
|
||||
|
||||
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
|
||||
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'vastbase':
|
||||
case 'highgo': {
|
||||
const schema = schemaName || 'public';
|
||||
return `
|
||||
SELECT
|
||||
n.nspname || '.' || c.relname AS table_name,
|
||||
obj_description(c.oid, 'pg_class') AS table_comment,
|
||||
c.reltuples::bigint AS table_rows,
|
||||
pg_total_relation_size(c.oid) AS data_length,
|
||||
pg_indexes_size(c.oid) AS index_length
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind = 'r'
|
||||
AND n.nspname = '${escapeLiteral(schema)}'
|
||||
ORDER BY c.relname`;
|
||||
}
|
||||
case 'sqlserver': {
|
||||
const safeDB = `[${dbName.replace(/]/g, ']]')}]`;
|
||||
return `
|
||||
SELECT
|
||||
s.name + '.' + t.name AS table_name,
|
||||
ep.value AS table_comment,
|
||||
SUM(p.rows) AS table_rows,
|
||||
SUM(a.total_pages) * 8 * 1024 AS data_length,
|
||||
SUM(a.used_pages) * 8 * 1024 AS index_length
|
||||
FROM ${safeDB}.sys.tables t
|
||||
JOIN ${safeDB}.sys.schemas s ON t.schema_id = s.schema_id
|
||||
LEFT JOIN ${safeDB}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description'
|
||||
LEFT JOIN ${safeDB}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)
|
||||
LEFT JOIN ${safeDB}.sys.allocation_units a ON p.partition_id = a.container_id
|
||||
WHERE t.type = 'U'
|
||||
GROUP BY s.name, t.name, ep.value
|
||||
ORDER BY s.name, t.name`;
|
||||
}
|
||||
case 'clickhouse':
|
||||
return `SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length FROM system.tables WHERE database = '${escapeLiteral(dbName)}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`;
|
||||
case 'dm':
|
||||
case 'oracle': {
|
||||
const owner = (schemaName || dbName).toUpperCase();
|
||||
return `SELECT table_name, comments AS table_comment, num_rows AS table_rows, 0 AS data_length, 0 AS index_length FROM all_tab_comments JOIN all_tables USING (table_name, owner) WHERE owner = '${escapeLiteral(owner)}' ORDER BY table_name`;
|
||||
}
|
||||
default:
|
||||
return `SELECT table_name, '' AS table_comment, 0 AS table_rows, 0 AS data_length, 0 AS index_length FROM information_schema.tables WHERE table_schema = '${escapeLiteral(dbName)}' AND table_type = 'BASE TABLE' ORDER BY table_name`;
|
||||
}
|
||||
};
|
||||
|
||||
const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableStatRow[] => {
|
||||
return rows.map((row) => {
|
||||
const get = (keys: string[]): any => {
|
||||
for (const k of keys) {
|
||||
for (const rk of Object.keys(row)) {
|
||||
if (rk.toLowerCase() === k.toLowerCase() && row[rk] !== null && row[rk] !== undefined) return row[rk];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const strVal = (keys: string[]) => String(get(keys) ?? '').trim();
|
||||
const numVal = (keys: string[]) => {
|
||||
const v = get(keys);
|
||||
if (v === null || v === undefined || v === '') return 0;
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? 0 : Math.max(0, Math.round(n));
|
||||
};
|
||||
|
||||
return {
|
||||
name: strVal(['Name', 'table_name', 'tablename', 'TABLE_NAME']),
|
||||
comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']),
|
||||
rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']),
|
||||
dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']),
|
||||
indexSize: numVal(['Index_length', 'index_length', 'INDEX_LENGTH']),
|
||||
engine: strVal(['Engine', 'engine']),
|
||||
createTime: strVal(['Create_time', 'create_time']),
|
||||
updateTime: strVal(['Update_time', 'update_time']),
|
||||
};
|
||||
}).filter(t => t.name);
|
||||
};
|
||||
|
||||
const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const darkMode = theme === 'dark';
|
||||
|
||||
const [tables, setTables] = useState<TableStatRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
|
||||
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!connection) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = {
|
||||
...connection.config,
|
||||
port: Number(connection.config.port),
|
||||
password: connection.config.password || '',
|
||||
database: connection.config.database || '',
|
||||
useSSH: connection.config.useSSH || false,
|
||||
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
|
||||
};
|
||||
const dialect = getMetadataDialect(connection.config.type, (connection.config as any)?.driver);
|
||||
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const res = await DBQuery(config as any, tab.dbName || '', sql);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setTables(parseTableStats(dialect, res.data));
|
||||
} else {
|
||||
message.error('获取表信息失败: ' + (res.message || '未知错误'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('获取表信息失败: ' + (e?.message || String(e)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connection, tab.dbName]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const sortedFiltered = useMemo(() => {
|
||||
let list = [...tables];
|
||||
if (searchText.trim()) {
|
||||
const kw = searchText.trim().toLowerCase();
|
||||
list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw));
|
||||
}
|
||||
list.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
else if (sortField === 'rows') cmp = a.rows - b.rows;
|
||||
else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize;
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return list;
|
||||
}, [tables, searchText, sortField, sortOrder]);
|
||||
|
||||
const openTable = useCallback((tableName: string) => {
|
||||
if (!connection) return;
|
||||
addTab({
|
||||
id: `${connection.id}-${tab.dbName}-${tableName}`,
|
||||
title: tableName,
|
||||
type: 'table',
|
||||
connectionId: connection.id,
|
||||
dbName: tab.dbName,
|
||||
tableName,
|
||||
});
|
||||
}, [connection, tab.dbName, addTab]);
|
||||
|
||||
const openDesign = useCallback((tableName: string) => {
|
||||
if (!connection) return;
|
||||
addTab({
|
||||
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
|
||||
title: `设计表 (${tableName})`,
|
||||
type: 'design',
|
||||
connectionId: connection.id,
|
||||
dbName: tab.dbName,
|
||||
tableName,
|
||||
initialTab: 'columns',
|
||||
readOnly: false,
|
||||
});
|
||||
}, [connection, tab.dbName, addTab]);
|
||||
|
||||
const buildConfig = useCallback(() => {
|
||||
if (!connection) return null;
|
||||
return {
|
||||
...connection.config,
|
||||
port: Number(connection.config.port),
|
||||
password: connection.config.password || '',
|
||||
database: connection.config.database || '',
|
||||
useSSH: connection.config.useSSH || false,
|
||||
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
|
||||
};
|
||||
}, [connection]);
|
||||
|
||||
const handleCopyStructure = useCallback(async (tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
const res = await DBShowCreateTable(config as any, tab.dbName || '', tableName);
|
||||
if (res.success) {
|
||||
navigator.clipboard.writeText(res.data as string);
|
||||
message.success('表结构已复制到剪贴板');
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
}, [buildConfig, tab.dbName]);
|
||||
|
||||
const handleExport = useCallback(async (tableName: string, format: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0);
|
||||
const res = await ExportTable(config as any, tab.dbName || '', tableName, format);
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error('导出失败: ' + res.message);
|
||||
}
|
||||
}, [buildConfig, tab.dbName]);
|
||||
|
||||
const handleDeleteTable = useCallback((tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
Modal.confirm({
|
||||
title: '确认删除表',
|
||||
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const res = await DropTable(config as any, tab.dbName || '', tableName);
|
||||
if (res.success) {
|
||||
message.success('表删除成功');
|
||||
loadData();
|
||||
} else {
|
||||
message.error('删除失败: ' + res.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [buildConfig, tab.dbName, loadData]);
|
||||
|
||||
const handleRenameTable = useCallback((tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
let newName = tableName;
|
||||
Modal.confirm({
|
||||
title: '重命名表',
|
||||
content: (
|
||||
<Input
|
||||
defaultValue={tableName}
|
||||
onChange={e => { newName = e.target.value; }}
|
||||
placeholder="输入新表名"
|
||||
autoFocus
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
),
|
||||
onOk: async () => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); }
|
||||
if (trimmed === tableName) { message.warning('新旧表名相同'); return; }
|
||||
const res = await RenameTable(config as any, tab.dbName || '', tableName, trimmed);
|
||||
if (res.success) {
|
||||
message.success('表重命名成功');
|
||||
loadData();
|
||||
} else {
|
||||
message.error('重命名失败: ' + res.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [buildConfig, tab.dbName, loadData]);
|
||||
|
||||
// --- Theme ---
|
||||
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
|
||||
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
|
||||
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
|
||||
const textPrimary = darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)';
|
||||
const textSecondary = darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)';
|
||||
const textMuted = darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)';
|
||||
const accentColor = '#1677ff';
|
||||
const containerBg = darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.01)';
|
||||
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder(field === 'name' ? 'asc' : 'desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortMenuItems = [
|
||||
{ key: 'name', label: `按名称${sortField === 'name' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('name') },
|
||||
{ key: 'rows', label: `按行数${sortField === 'rows' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('rows') },
|
||||
{ key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') },
|
||||
];
|
||||
|
||||
const totalRows = tables.reduce((s, t) => s + t.rows, 0);
|
||||
const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
|
||||
<Spin size="large" tip="加载表信息..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
|
||||
<DatabaseOutlined style={{ fontSize: 16, color: accentColor }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
|
||||
<span style={{ fontSize: 12, color: textMuted }}>
|
||||
{tables.length} 张表 · {formatRows(totalRows)} 行 · {formatSize(totalSize)}
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Input
|
||||
placeholder="搜索表名或注释..."
|
||||
prefix={<SearchOutlined style={{ color: textMuted }} />}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 240 }}
|
||||
size="small"
|
||||
/>
|
||||
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
|
||||
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||
</Dropdown>
|
||||
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
|
||||
{sortedFiltered.length === 0 ? (
|
||||
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
}}>
|
||||
{sortedFiltered.map(t => (
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: `SELECT * FROM ${t.name};`,
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
|
||||
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
|
||||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
|
||||
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
|
||||
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
|
||||
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
|
||||
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
|
||||
]},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onDoubleClick={() => openTable(t.name)}
|
||||
style={{
|
||||
background: cardBg,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
borderRadius: 10,
|
||||
padding: '14px 16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
|
||||
<Tooltip title={t.name} mouseEnterDelay={0.4}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
|
||||
{t.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{t.comment && (
|
||||
<Tooltip title={t.comment} mouseEnterDelay={0.4}>
|
||||
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.comment}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
|
||||
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
|
||||
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
|
||||
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableOverview;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Editor, { loader } from '@monaco-editor/react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { Spin, Alert } from 'antd';
|
||||
import { TabData } from '../types';
|
||||
import { useStore } from '../store';
|
||||
@@ -18,31 +18,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
|
||||
const theme = useStore(state => state.theme);
|
||||
const darkMode = theme === 'dark';
|
||||
|
||||
// 初始化透明 Monaco Editor 主题
|
||||
useEffect(() => {
|
||||
loader.init().then(monaco => {
|
||||
monaco.editor.defineTheme('transparent-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.lineHighlightBackground': '#ffffff10',
|
||||
'editorGutter.background': '#00000000',
|
||||
}
|
||||
});
|
||||
monaco.editor.defineTheme('transparent-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.lineHighlightBackground': '#00000010',
|
||||
'editorGutter.background': '#00000000',
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
|
||||
|
||||
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
||||
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
|
||||
|
||||
69
frontend/src/components/dataGridLayout.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
|
||||
assertEqual(
|
||||
calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: false,
|
||||
floatingScrollbarHeight: 10,
|
||||
floatingScrollbarGap: 6,
|
||||
}),
|
||||
0,
|
||||
'无横向滚动条时不应增加底部间距'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: true,
|
||||
floatingScrollbarHeight: 10,
|
||||
floatingScrollbarGap: 6,
|
||||
}),
|
||||
28,
|
||||
'默认悬浮滚动条应预留滚动条高度、间距和额外安全区'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: true,
|
||||
floatingScrollbarHeight: 14,
|
||||
floatingScrollbarGap: 4,
|
||||
}),
|
||||
30,
|
||||
'较粗滚动条场景下应同步放大底部安全区'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 646,
|
||||
tableViewportWidth: 1200,
|
||||
isMacLike: false,
|
||||
}),
|
||||
1200,
|
||||
'列总宽小于视口时应按视口宽度返回 scroll.x,避免 header/body 走两套宽度'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 646,
|
||||
tableViewportWidth: 0,
|
||||
isMacLike: false,
|
||||
}),
|
||||
646,
|
||||
'未拿到视口宽度时应退回列宽总和'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 1200,
|
||||
tableViewportWidth: 800,
|
||||
isMacLike: true,
|
||||
}),
|
||||
1202,
|
||||
'macOS 横向溢出时仍需额外预留 2px 以稳定滚动轨道'
|
||||
);
|
||||
|
||||
console.log('dataGridLayout tests passed');
|
||||
48
frontend/src/components/dataGridLayout.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface TableBodyBottomPaddingOptions {
|
||||
hasHorizontalOverflow: boolean;
|
||||
floatingScrollbarHeight: number;
|
||||
floatingScrollbarGap: number;
|
||||
}
|
||||
|
||||
export interface VirtualTableScrollXOptions {
|
||||
totalWidth: number;
|
||||
tableViewportWidth: number;
|
||||
isMacLike: boolean;
|
||||
}
|
||||
|
||||
const MIN_SCROLLBAR_CLEARANCE = 8;
|
||||
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
|
||||
|
||||
export const calculateTableBodyBottomPadding = ({
|
||||
hasHorizontalOverflow,
|
||||
floatingScrollbarHeight,
|
||||
floatingScrollbarGap,
|
||||
}: TableBodyBottomPaddingOptions): number => {
|
||||
if (!hasHorizontalOverflow) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const safeScrollbarHeight = Math.max(0, Math.ceil(floatingScrollbarHeight));
|
||||
const safeScrollbarGap = Math.max(0, Math.ceil(floatingScrollbarGap));
|
||||
|
||||
return safeScrollbarHeight + FLOATING_SCROLLBAR_VISUAL_EXTRA + safeScrollbarGap + MIN_SCROLLBAR_CLEARANCE;
|
||||
};
|
||||
|
||||
export const calculateVirtualTableScrollX = ({
|
||||
totalWidth,
|
||||
tableViewportWidth,
|
||||
isMacLike,
|
||||
}: VirtualTableScrollXOptions): number => {
|
||||
const safeTotalWidth = Math.max(0, Math.ceil(totalWidth));
|
||||
const safeViewportWidth = Math.max(0, Math.floor(tableViewportWidth));
|
||||
|
||||
if (safeViewportWidth > 0 && safeTotalWidth < safeViewportWidth) {
|
||||
return safeViewportWidth;
|
||||
}
|
||||
|
||||
if (isMacLike && safeViewportWidth > 0 && safeTotalWidth > safeViewportWidth) {
|
||||
return safeTotalWidth + 2;
|
||||
}
|
||||
|
||||
return safeTotalWidth;
|
||||
};
|
||||
105
frontend/src/components/redisViewerTree.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { RedisKeyInfo } from '../types';
|
||||
import {
|
||||
applyRenamedRedisKeyState,
|
||||
applyTreeNodeCheck,
|
||||
buildCheckedTreeNodeState,
|
||||
buildRedisKeyTree,
|
||||
isGroupFullyChecked,
|
||||
} from './redisViewerTree';
|
||||
|
||||
const assert = (condition: unknown, message: string) => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
const actualText = JSON.stringify(actual);
|
||||
const expectedText = JSON.stringify(expected);
|
||||
if (actualText !== expectedText) {
|
||||
throw new Error(`${message}\nactual: ${actualText}\nexpected: ${expectedText}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sampleKeys: RedisKeyInfo[] = [
|
||||
{ key: 'app:user:1', type: 'string', ttl: -1 },
|
||||
{ key: 'app:user:2', type: 'string', ttl: -1 },
|
||||
{ key: 'app:order:1', type: 'hash', ttl: 120 },
|
||||
{ key: 'misc', type: 'set', ttl: -1 },
|
||||
];
|
||||
|
||||
const tree = buildRedisKeyTree(sampleKeys, true);
|
||||
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
|
||||
const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
|
||||
|
||||
assert(appGroup, '应生成 group:app 节点');
|
||||
assert(userGroup, '应生成 group:app:user 节点');
|
||||
assertEqual(
|
||||
appGroup?.descendantRawKeys,
|
||||
['app:order:1', 'app:user:1', 'app:user:2'],
|
||||
'app 分组应收集全部后代 key'
|
||||
);
|
||||
|
||||
const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
|
||||
assertEqual(
|
||||
selectedAfterGroupCheck,
|
||||
['app:order:1', 'app:user:1', 'app:user:2'],
|
||||
'勾选分组应递归选中全部后代 key'
|
||||
);
|
||||
|
||||
const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
|
||||
assertEqual(
|
||||
checkedState.checked,
|
||||
['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app'],
|
||||
'全部后代已选中时,父分组和叶子都应进入 checked'
|
||||
);
|
||||
assertEqual(checkedState.halfChecked, [], '全部后代已选中时不应有 halfChecked');
|
||||
assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck), true, '全部后代已选中时,分组应视为 fully checked');
|
||||
|
||||
const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
|
||||
assertEqual(selectedAfterGroupUncheck, [], '取消勾选分组应移除全部后代 key');
|
||||
assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck), false, '取消后分组不应再是 fully checked');
|
||||
|
||||
const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
|
||||
assertEqual(
|
||||
partialState.halfChecked,
|
||||
['group:app:user', 'group:app'],
|
||||
'仅部分后代选中时,相关分组应进入 halfChecked'
|
||||
);
|
||||
assertEqual(isGroupFullyChecked(appGroup!, ['app:user:1']), false, '部分选中时分组不应是 fully checked');
|
||||
|
||||
const renamedState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'app:user:2',
|
||||
selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
|
||||
},
|
||||
'app:user:2',
|
||||
'app:user:200'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
renamedState.keys.map((item) => item.key),
|
||||
['app:user:1', 'app:user:200', 'app:order:1', 'misc'],
|
||||
'重命名后 keys 列表应替换旧 key'
|
||||
);
|
||||
assertEqual(renamedState.selectedKey, 'app:user:200', '当前详情选中的 key 应切换为新 key');
|
||||
assertEqual(
|
||||
renamedState.selectedKeys,
|
||||
['app:user:1', 'app:user:200', 'misc'],
|
||||
'批量选中集合中的旧 key 应映射为新 key'
|
||||
);
|
||||
|
||||
const unrelatedRenameState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'misc',
|
||||
selectedKeys: ['app:user:1'],
|
||||
},
|
||||
'app:order:1',
|
||||
'app:order:9'
|
||||
);
|
||||
assertEqual(unrelatedRenameState.selectedKey, 'misc', '非当前详情 key 的重命名不应影响 selectedKey');
|
||||
assertEqual(unrelatedRenameState.selectedKeys, ['app:user:1'], '非已勾选 key 的重命名不应污染选中集合');
|
||||
|
||||
console.log('redisViewerTree tests passed');
|
||||
260
frontend/src/components/redisViewerTree.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { RedisKeyInfo } from '../types';
|
||||
|
||||
const KEY_GROUP_DELIMITER = ':';
|
||||
const EMPTY_SEGMENT_LABEL = '(empty)';
|
||||
|
||||
type RedisKeyTreeLeaf = {
|
||||
keyInfo: RedisKeyInfo;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type RedisKeyTreeGroup = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, RedisKeyTreeGroup>;
|
||||
leaves: RedisKeyTreeLeaf[];
|
||||
leafCount: number;
|
||||
};
|
||||
|
||||
export type RedisTreeDataNode = DataNode & {
|
||||
nodeType: 'group' | 'leaf';
|
||||
groupName?: string;
|
||||
groupLeafCount?: number;
|
||||
leafLabel?: string;
|
||||
rawKey?: string;
|
||||
keyType?: string;
|
||||
ttl?: number;
|
||||
descendantRawKeys?: string[];
|
||||
};
|
||||
|
||||
export type RedisKeyTreeResult = {
|
||||
treeData: RedisTreeDataNode[];
|
||||
groupKeys: string[];
|
||||
};
|
||||
|
||||
export type RedisTreeCheckedState = {
|
||||
checked: string[];
|
||||
halfChecked: string[];
|
||||
};
|
||||
|
||||
export type RenamedRedisKeyStateInput = {
|
||||
keys: RedisKeyInfo[];
|
||||
selectedKey: string | null;
|
||||
selectedKeys: string[];
|
||||
};
|
||||
|
||||
export type RenamedRedisKeyStateResult = {
|
||||
keys: RedisKeyInfo[];
|
||||
selectedKey: string | null;
|
||||
selectedKeys: string[];
|
||||
};
|
||||
|
||||
const normalizeKeySegment = (segment: string): string => {
|
||||
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
|
||||
};
|
||||
|
||||
const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => {
|
||||
return { name, path, children: new Map(), leaves: [], leafCount: 0 };
|
||||
};
|
||||
|
||||
const calculateGroupLeafCount = (group: RedisKeyTreeGroup): number => {
|
||||
let count = group.leaves.length;
|
||||
group.children.forEach((child) => {
|
||||
count += calculateGroupLeafCount(child);
|
||||
});
|
||||
group.leafCount = count;
|
||||
return count;
|
||||
};
|
||||
|
||||
export const buildLeafNodeKey = (rawKey: string): string => `key:${rawKey}`;
|
||||
|
||||
export const parseRawKeyFromNodeKey = (nodeKey: React.Key): string | null => {
|
||||
const keyText = String(nodeKey);
|
||||
if (!keyText.startsWith('key:')) {
|
||||
return null;
|
||||
}
|
||||
return keyText.slice(4);
|
||||
};
|
||||
|
||||
export const buildRedisKeyTree = (
|
||||
keys: RedisKeyInfo[],
|
||||
sortLeafNodes: boolean
|
||||
): RedisKeyTreeResult => {
|
||||
const root = createTreeGroup('__root__', '__root__');
|
||||
|
||||
keys.forEach((keyInfo) => {
|
||||
const segments = keyInfo.key.split(KEY_GROUP_DELIMITER);
|
||||
if (segments.length <= 1) {
|
||||
root.leaves.push({ keyInfo, label: keyInfo.key });
|
||||
return;
|
||||
}
|
||||
|
||||
const groupSegments = segments.slice(0, -1);
|
||||
const leafLabel = normalizeKeySegment(segments[segments.length - 1]);
|
||||
let current = root;
|
||||
const pathParts: string[] = [];
|
||||
|
||||
groupSegments.forEach((segment) => {
|
||||
const normalized = normalizeKeySegment(segment);
|
||||
pathParts.push(normalized);
|
||||
const groupPath = pathParts.join(KEY_GROUP_DELIMITER);
|
||||
let child = current.children.get(normalized);
|
||||
if (!child) {
|
||||
child = createTreeGroup(normalized, groupPath);
|
||||
current.children.set(normalized, child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
|
||||
current.leaves.push({ keyInfo, label: leafLabel });
|
||||
});
|
||||
|
||||
calculateGroupLeafCount(root);
|
||||
const groupKeys: string[] = [];
|
||||
|
||||
const toTreeNodes = (group: RedisKeyTreeGroup): RedisTreeDataNode[] => {
|
||||
const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const childLeaves = sortLeafNodes
|
||||
? [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key))
|
||||
: group.leaves;
|
||||
|
||||
const groupNodes: RedisTreeDataNode[] = childGroups.map((child) => {
|
||||
const children = toTreeNodes(child);
|
||||
const descendantRawKeys = children.flatMap((node) => {
|
||||
if (node.nodeType === 'leaf') {
|
||||
return node.rawKey ? [node.rawKey] : [];
|
||||
}
|
||||
return node.descendantRawKeys || [];
|
||||
});
|
||||
const groupNodeKey = `group:${child.path}`;
|
||||
groupKeys.push(groupNodeKey);
|
||||
return {
|
||||
key: groupNodeKey,
|
||||
title: child.name,
|
||||
nodeType: 'group',
|
||||
groupName: child.name,
|
||||
groupLeafCount: child.leafCount,
|
||||
selectable: false,
|
||||
descendantRawKeys,
|
||||
children,
|
||||
};
|
||||
});
|
||||
|
||||
const leafNodes: RedisTreeDataNode[] = childLeaves.map((leaf) => {
|
||||
return {
|
||||
key: buildLeafNodeKey(leaf.keyInfo.key),
|
||||
isLeaf: true,
|
||||
title: leaf.label,
|
||||
nodeType: 'leaf',
|
||||
leafLabel: leaf.label,
|
||||
rawKey: leaf.keyInfo.key,
|
||||
keyType: leaf.keyInfo.type,
|
||||
ttl: leaf.keyInfo.ttl,
|
||||
};
|
||||
});
|
||||
|
||||
return [...groupNodes, ...leafNodes];
|
||||
};
|
||||
|
||||
return {
|
||||
treeData: toTreeNodes(root),
|
||||
groupKeys,
|
||||
};
|
||||
};
|
||||
|
||||
export const applyTreeNodeCheck = (
|
||||
selectedKeys: string[],
|
||||
node: RedisTreeDataNode,
|
||||
checked: boolean
|
||||
): string[] => {
|
||||
if (node.nodeType === 'leaf') {
|
||||
if (!node.rawKey) {
|
||||
return selectedKeys;
|
||||
}
|
||||
if (checked) {
|
||||
return Array.from(new Set([...selectedKeys, node.rawKey]));
|
||||
}
|
||||
return selectedKeys.filter((item) => item !== node.rawKey);
|
||||
}
|
||||
|
||||
const descendantRawKeys = node.descendantRawKeys || [];
|
||||
if (descendantRawKeys.length === 0) {
|
||||
return selectedKeys;
|
||||
}
|
||||
if (checked) {
|
||||
return Array.from(new Set([...selectedKeys, ...descendantRawKeys]));
|
||||
}
|
||||
const removeSet = new Set(descendantRawKeys);
|
||||
return selectedKeys.filter((item) => !removeSet.has(item));
|
||||
};
|
||||
|
||||
const walkGroupStates = (
|
||||
nodes: RedisTreeDataNode[],
|
||||
selectedKeySet: Set<string>,
|
||||
checked: string[],
|
||||
halfChecked: string[]
|
||||
) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.nodeType === 'leaf') {
|
||||
if (node.rawKey && selectedKeySet.has(node.rawKey)) {
|
||||
checked.push(String(node.key));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
walkGroupStates((node.children || []) as RedisTreeDataNode[], selectedKeySet, checked, halfChecked);
|
||||
const descendantRawKeys = node.descendantRawKeys || [];
|
||||
if (descendantRawKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = descendantRawKeys.filter((rawKey) => selectedKeySet.has(rawKey)).length;
|
||||
if (selectedCount === descendantRawKeys.length) {
|
||||
checked.push(String(node.key));
|
||||
return;
|
||||
}
|
||||
if (selectedCount > 0) {
|
||||
halfChecked.push(String(node.key));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const buildCheckedTreeNodeState = (
|
||||
selectedKeys: string[],
|
||||
keyTree: RedisKeyTreeResult
|
||||
): RedisTreeCheckedState => {
|
||||
const selectedKeySet = new Set(selectedKeys);
|
||||
const checked: string[] = [];
|
||||
const halfChecked: string[] = [];
|
||||
|
||||
walkGroupStates(keyTree.treeData, selectedKeySet, checked, halfChecked);
|
||||
return { checked, halfChecked };
|
||||
};
|
||||
|
||||
export const isGroupFullyChecked = (
|
||||
node: RedisTreeDataNode,
|
||||
selectedKeys: string[]
|
||||
): boolean => {
|
||||
if (node.nodeType !== 'group') {
|
||||
return false;
|
||||
}
|
||||
const descendantRawKeys = node.descendantRawKeys || [];
|
||||
if (descendantRawKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const selectedKeySet = new Set(selectedKeys);
|
||||
return descendantRawKeys.every((rawKey) => selectedKeySet.has(rawKey));
|
||||
};
|
||||
|
||||
export const applyRenamedRedisKeyState = (
|
||||
state: RenamedRedisKeyStateInput,
|
||||
oldKey: string,
|
||||
newKey: string
|
||||
): RenamedRedisKeyStateResult => {
|
||||
return {
|
||||
keys: state.keys.map((item) => (item.key === oldKey ? { ...item, key: newKey } : item)),
|
||||
selectedKey: state.selectedKey === oldKey ? newKey : state.selectedKey,
|
||||
selectedKeys: state.selectedKeys.map((item) => (item === oldKey ? newKey : item)),
|
||||
};
|
||||
};
|
||||
50
frontend/src/components/redisViewerWorkbenchTheme.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertNotEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual === expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nnotExpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertMatch = (value: string, pattern: RegExp, message: string) => {
|
||||
if (!pattern.test(value)) {
|
||||
throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const darkTheme = buildRedisWorkbenchTheme({
|
||||
darkMode: true,
|
||||
opacity: 0.72,
|
||||
blur: 14,
|
||||
});
|
||||
|
||||
assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true');
|
||||
assertMatch(darkTheme.panelBg, /^rgba\(/, 'dark 主题面板背景应为 rgba');
|
||||
assertMatch(darkTheme.toolbarPrimaryBg, /^linear-gradient\(/, '工具栏主按钮应使用渐变背景');
|
||||
assertNotEqual(darkTheme.actionDangerBg, darkTheme.actionSecondaryBg, '危险态按钮背景不应与普通按钮相同');
|
||||
assertNotEqual(darkTheme.treeSelectedBg, darkTheme.treeHoverBg, '树节点选中态与悬浮态不应相同');
|
||||
assertMatch(darkTheme.appBg, /rgba\(15, 15, 17,/, 'dark 背景应保持中性黑基底');
|
||||
assertMatch(darkTheme.panelBg, /rgba\(24, 24, 28,/, 'dark 面板背景应保持中性黑灰');
|
||||
assertMatch(darkTheme.panelBgStrong, /rgba\(31, 31, 36,/, 'dark 强面板背景应保持中性黑灰');
|
||||
assertEqual(darkTheme.backdropFilter, 'blur(14px)', 'blur 参数应映射为 backdropFilter');
|
||||
|
||||
const lightTheme = buildRedisWorkbenchTheme({
|
||||
darkMode: false,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
});
|
||||
|
||||
assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false');
|
||||
assertMatch(lightTheme.panelBg, /^rgba\(/, 'light 主题面板背景应为 rgba');
|
||||
assertMatch(lightTheme.contentEmptyBg, /^linear-gradient\(/, 'light 空状态背景应为渐变');
|
||||
assertNotEqual(lightTheme.textPrimary, lightTheme.textSecondary, '主次文本颜色应区分');
|
||||
assertNotEqual(lightTheme.statusTagBg, lightTheme.statusTagMutedBg, '状态 tag 应区分普通与弱化样式');
|
||||
assertEqual(lightTheme.backdropFilter, 'none', 'blur=0 时 backdropFilter 应为 none');
|
||||
|
||||
console.log('redisViewerWorkbenchTheme tests passed');
|
||||
129
frontend/src/components/redisViewerWorkbenchTheme.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
type RedisWorkbenchThemeInput = {
|
||||
darkMode: boolean;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
};
|
||||
|
||||
type RedisWorkbenchTheme = {
|
||||
isDark: boolean;
|
||||
appBg: string;
|
||||
panelBg: string;
|
||||
panelBgStrong: string;
|
||||
panelBgSubtle: string;
|
||||
panelBorder: string;
|
||||
panelInset: string;
|
||||
toolbarPrimaryBg: string;
|
||||
contentEmptyBg: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
textMuted: string;
|
||||
accent: string;
|
||||
accentSoft: string;
|
||||
accentBorder: string;
|
||||
actionSecondaryBg: string;
|
||||
actionSecondaryBorder: string;
|
||||
actionDangerBg: string;
|
||||
actionDangerBorder: string;
|
||||
actionDangerText: string;
|
||||
statusTagBg: string;
|
||||
statusTagBorder: string;
|
||||
statusTagMutedBg: string;
|
||||
statusTagMutedBorder: string;
|
||||
treeHoverBg: string;
|
||||
treeSelectedBg: string;
|
||||
treeSelectedBorder: string;
|
||||
divider: string;
|
||||
shadow: string;
|
||||
backdropFilter: string;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
export const buildRedisWorkbenchTheme = ({
|
||||
darkMode,
|
||||
opacity,
|
||||
blur,
|
||||
}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => {
|
||||
const normalizedOpacity = clamp(opacity, 0.1, 1);
|
||||
const normalizedBlur = Math.max(0, Math.round(blur));
|
||||
const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0;
|
||||
|
||||
if (darkMode) {
|
||||
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
|
||||
const appBottomAlpha = isTranslucent ? Math.max(0.12, Math.min(0.28, normalizedOpacity * 0.22)) : 0.96;
|
||||
const panelAlpha = isTranslucent ? Math.max(0.06, Math.min(0.16, normalizedOpacity * 0.1)) : 0.34;
|
||||
const strongAlpha = isTranslucent ? Math.max(0.1, Math.min(0.22, normalizedOpacity * 0.16)) : 0.42;
|
||||
const subtleAlpha = isTranslucent ? Math.max(0.03, Math.min(0.08, normalizedOpacity * 0.05)) : 0.08;
|
||||
return {
|
||||
isDark: true,
|
||||
appBg: `linear-gradient(180deg, rgba(15, 15, 17, ${appTopAlpha}) 0%, rgba(11, 11, 13, ${appBottomAlpha}) 100%)`,
|
||||
panelBg: `rgba(24, 24, 28, ${panelAlpha})`,
|
||||
panelBgStrong: `rgba(31, 31, 36, ${strongAlpha})`,
|
||||
panelBgSubtle: `rgba(255, 255, 255, ${subtleAlpha})`,
|
||||
panelBorder: `1px solid rgba(255, 255, 255, ${isTranslucent ? Math.max(0.12, Math.min(0.24, normalizedOpacity * 0.2)) : 0.08})`,
|
||||
panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? Math.max(0.05, Math.min(0.12, normalizedOpacity * 0.1)) : 0.04})`,
|
||||
toolbarPrimaryBg: `linear-gradient(135deg, rgba(246,196,83,0.22) 0%, rgba(246,196,83,0.12) 100%)`,
|
||||
contentEmptyBg: `linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.015) 100%)`,
|
||||
textPrimary: 'rgba(245, 247, 251, 0.96)',
|
||||
textSecondary: 'rgba(218, 224, 235, 0.82)',
|
||||
textMuted: 'rgba(168, 177, 194, 0.72)',
|
||||
accent: '#f6c453',
|
||||
accentSoft: 'rgba(246, 196, 83, 0.18)',
|
||||
accentBorder: 'rgba(246, 196, 83, 0.3)',
|
||||
actionSecondaryBg: 'rgba(255, 255, 255, 0.04)',
|
||||
actionSecondaryBorder: 'rgba(255, 255, 255, 0.09)',
|
||||
actionDangerBg: 'rgba(255, 95, 95, 0.12)',
|
||||
actionDangerBorder: 'rgba(255, 95, 95, 0.28)',
|
||||
actionDangerText: '#ff8f8f',
|
||||
statusTagBg: 'rgba(25, 106, 255, 0.16)',
|
||||
statusTagBorder: 'rgba(25, 106, 255, 0.28)',
|
||||
statusTagMutedBg: 'rgba(255, 255, 255, 0.04)',
|
||||
statusTagMutedBorder: 'rgba(255, 255, 255, 0.08)',
|
||||
treeHoverBg: 'rgba(255, 255, 255, 0.045)',
|
||||
treeSelectedBg: 'linear-gradient(90deg, rgba(246,196,83,0.2) 0%, rgba(246,196,83,0.08) 100%)',
|
||||
treeSelectedBorder: 'rgba(246, 196, 83, 0.24)',
|
||||
divider: 'rgba(255, 255, 255, 0.07)',
|
||||
shadow: '0 20px 48px rgba(0, 0, 0, 0.26)',
|
||||
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
const appTopAlpha = isTranslucent ? Math.max(0.16, Math.min(0.36, normalizedOpacity * 0.24)) : 0.98;
|
||||
const appBottomAlpha = isTranslucent ? Math.max(0.22, Math.min(0.44, normalizedOpacity * 0.32)) : 0.96;
|
||||
const panelAlpha = isTranslucent ? Math.max(0.18, Math.min(0.4, normalizedOpacity * 0.26)) : 0.94;
|
||||
const strongAlpha = isTranslucent ? Math.max(0.26, Math.min(0.52, normalizedOpacity * 0.34)) : 0.98;
|
||||
return {
|
||||
isDark: false,
|
||||
appBg: `linear-gradient(180deg, rgba(248, 250, 252, ${appTopAlpha}) 0%, rgba(242, 245, 248, ${appBottomAlpha}) 100%)`,
|
||||
panelBg: `rgba(255, 255, 255, ${panelAlpha})`,
|
||||
panelBgStrong: `rgba(255, 255, 255, ${strongAlpha})`,
|
||||
panelBgSubtle: 'rgba(15, 23, 42, 0.03)',
|
||||
panelBorder: `1px solid rgba(15, 23, 42, ${isTranslucent ? Math.max(0.1, Math.min(0.18, normalizedOpacity * 0.12)) : 0.08})`,
|
||||
panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? 0.38 : 0.72})`,
|
||||
toolbarPrimaryBg: 'linear-gradient(135deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.06) 100%)',
|
||||
contentEmptyBg: 'linear-gradient(180deg, rgba(15,23,42,0.02) 0%, rgba(15,23,42,0.01) 100%)',
|
||||
textPrimary: 'rgba(15, 23, 42, 0.92)',
|
||||
textSecondary: 'rgba(51, 65, 85, 0.82)',
|
||||
textMuted: 'rgba(100, 116, 139, 0.76)',
|
||||
accent: '#1677ff',
|
||||
accentSoft: 'rgba(22, 119, 255, 0.12)',
|
||||
accentBorder: 'rgba(22, 119, 255, 0.22)',
|
||||
actionSecondaryBg: 'rgba(255, 255, 255, 0.72)',
|
||||
actionSecondaryBorder: 'rgba(15, 23, 42, 0.08)',
|
||||
actionDangerBg: 'rgba(255, 77, 79, 0.08)',
|
||||
actionDangerBorder: 'rgba(255, 77, 79, 0.24)',
|
||||
actionDangerText: '#cf1322',
|
||||
statusTagBg: 'rgba(22, 119, 255, 0.1)',
|
||||
statusTagBorder: 'rgba(22, 119, 255, 0.16)',
|
||||
statusTagMutedBg: 'rgba(15, 23, 42, 0.04)',
|
||||
statusTagMutedBorder: 'rgba(15, 23, 42, 0.08)',
|
||||
treeHoverBg: 'rgba(15, 23, 42, 0.035)',
|
||||
treeSelectedBg: 'linear-gradient(90deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.05) 100%)',
|
||||
treeSelectedBorder: 'rgba(22, 119, 255, 0.18)',
|
||||
divider: 'rgba(15, 23, 42, 0.08)',
|
||||
shadow: '0 22px 52px rgba(15, 23, 42, 0.08)',
|
||||
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
};
|
||||
};
|
||||
|
||||
export type { RedisWorkbenchTheme, RedisWorkbenchThemeInput };
|
||||
@@ -5,6 +5,8 @@ import App from './App'
|
||||
|
||||
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
|
||||
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
|
||||
// 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。
|
||||
import 'monaco-editor/esm/nls.messages.zh-cn'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
loader.config({ monaco })
|
||||
@@ -42,11 +44,11 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
|
||||
monaco.editor.defineTheme('transparent-dark', {
|
||||
base: 'vs-dark', inherit: true, rules: [],
|
||||
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000' }
|
||||
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#1e1e1e', 'editorStickyScrollHover.background': '#2a2a2a' }
|
||||
})
|
||||
monaco.editor.defineTheme('transparent-light', {
|
||||
base: 'vs', inherit: true, rules: [],
|
||||
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000' }
|
||||
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#ffffff', 'editorStickyScrollHover.background': '#f5f5f5' }
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -420,6 +420,9 @@ interface AppState {
|
||||
enableColumnOrderMemory: boolean;
|
||||
tableHiddenColumns: Record<string, string[]>;
|
||||
enableHiddenColumnMemory: boolean;
|
||||
windowBounds: { width: number; height: number; x: number; y: number } | null;
|
||||
windowState: 'normal' | 'fullscreen' | 'maximized';
|
||||
sidebarWidth: number;
|
||||
|
||||
addConnection: (conn: SavedConnection) => void;
|
||||
updateConnection: (conn: SavedConnection) => void;
|
||||
@@ -469,6 +472,9 @@ interface AppState {
|
||||
setTableHiddenColumns: (connectionId: string, dbName: string, tableName: string, hiddenColumns: string[]) => void;
|
||||
setEnableHiddenColumnMemory: (enabled: boolean) => void;
|
||||
clearTableHiddenColumns: (connectionId: string, dbName: string, tableName: string) => void;
|
||||
setWindowBounds: (bounds: { width: number; height: number; x: number; y: number }) => void;
|
||||
setWindowState: (state: 'normal' | 'fullscreen' | 'maximized') => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
}
|
||||
|
||||
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
|
||||
@@ -599,6 +605,29 @@ const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeWindowState = (value: unknown): 'normal' | 'fullscreen' | 'maximized' => {
|
||||
if (value === 'fullscreen' || value === 'maximized') return value;
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
const sanitizeSidebarWidth = (value: unknown): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 330;
|
||||
return Math.max(200, Math.min(600, Math.trunc(parsed)));
|
||||
};
|
||||
|
||||
const sanitizeWindowBounds = (value: unknown): { width: number; height: number; x: number; y: number } | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const width = Number(raw.width);
|
||||
const height = Number(raw.height);
|
||||
const x = Number(raw.x);
|
||||
const y = Number(raw.y);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
if (width < 400 || height < 300) return null;
|
||||
return { width: Math.trunc(width), height: Math.trunc(height), x: Math.trunc(x), y: Math.trunc(y) };
|
||||
};
|
||||
|
||||
const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknown> => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return {};
|
||||
@@ -635,6 +664,9 @@ export const useStore = create<AppState>()(
|
||||
enableColumnOrderMemory: true,
|
||||
tableHiddenColumns: {},
|
||||
enableHiddenColumnMemory: true,
|
||||
windowBounds: null,
|
||||
windowState: 'normal' as const,
|
||||
sidebarWidth: 330,
|
||||
|
||||
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
|
||||
updateConnection: (conn) => set((state) => ({
|
||||
@@ -689,6 +721,33 @@ export const useStore = create<AppState>()(
|
||||
newTabs[index] = { ...newTabs[index], ...tab };
|
||||
return { tabs: newTabs, activeTabId: tab.id };
|
||||
}
|
||||
// 语义去重:对 table/design 类型按 connectionId+dbName+tableName 匹配已有 Tab
|
||||
if ((tab.type === 'table' || tab.type === 'design') && tab.tableName && tab.connectionId && tab.dbName) {
|
||||
const semanticIndex = state.tabs.findIndex(t =>
|
||||
t.type === tab.type &&
|
||||
t.connectionId === tab.connectionId &&
|
||||
t.dbName === tab.dbName &&
|
||||
t.tableName === tab.tableName
|
||||
);
|
||||
if (semanticIndex !== -1) {
|
||||
const existingTab = state.tabs[semanticIndex];
|
||||
const newTabs = [...state.tabs];
|
||||
newTabs[semanticIndex] = { ...existingTab, ...tab, id: existingTab.id };
|
||||
return { tabs: newTabs, activeTabId: existingTab.id };
|
||||
}
|
||||
}
|
||||
// 语义去重:对 query 类型按 savedQueryId 匹配已有 Tab(避免保存后重复打开)
|
||||
if (tab.type === 'query' && tab.savedQueryId) {
|
||||
const savedQueryIndex = state.tabs.findIndex(t =>
|
||||
t.type === 'query' && (t.savedQueryId === tab.savedQueryId || t.id === tab.savedQueryId)
|
||||
);
|
||||
if (savedQueryIndex !== -1) {
|
||||
const existingTab = state.tabs[savedQueryIndex];
|
||||
const newTabs = [...state.tabs];
|
||||
newTabs[savedQueryIndex] = { ...existingTab, ...tab, id: existingTab.id };
|
||||
return { tabs: newTabs, activeTabId: existingTab.id };
|
||||
}
|
||||
}
|
||||
return { tabs: [...state.tabs, tab], activeTabId: tab.id };
|
||||
}),
|
||||
|
||||
@@ -875,6 +934,19 @@ export const useStore = create<AppState>()(
|
||||
}),
|
||||
|
||||
setEnableHiddenColumnMemory: (enabled) => set({ enableHiddenColumnMemory: !!enabled }),
|
||||
|
||||
setWindowBounds: (bounds) => set({
|
||||
windowBounds: {
|
||||
width: Math.max(400, Math.trunc(bounds.width)),
|
||||
height: Math.max(300, Math.trunc(bounds.height)),
|
||||
x: Math.trunc(bounds.x),
|
||||
y: Math.trunc(bounds.y),
|
||||
}
|
||||
}),
|
||||
|
||||
setWindowState: (state) => set({ windowState: state }),
|
||||
|
||||
setSidebarWidth: (width) => set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }),
|
||||
}),
|
||||
{
|
||||
name: 'lite-db-storage', // name of the item in the storage (must be unique)
|
||||
@@ -906,7 +978,10 @@ export const useStore = create<AppState>()(
|
||||
nextState.enableColumnOrderMemory = state.enableColumnOrderMemory !== false;
|
||||
const safeHidden = sanitizeTableHiddenColumns(state.tableHiddenColumns);
|
||||
nextState.tableHiddenColumns = safeHidden;
|
||||
nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false;
|
||||
nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false;
|
||||
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
|
||||
nextState.windowState = sanitizeWindowState(state.windowState);
|
||||
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
|
||||
return nextState as AppState;
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
@@ -928,6 +1003,9 @@ export const useStore = create<AppState>()(
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
|
||||
tableHiddenColumns: sanitizeTableHiddenColumns(state.tableHiddenColumns),
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory !== false,
|
||||
windowBounds: sanitizeWindowBounds(state.windowBounds),
|
||||
windowState: sanitizeWindowState(state.windowState),
|
||||
sidebarWidth: sanitizeSidebarWidth(state.sidebarWidth),
|
||||
|
||||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||||
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
||||
@@ -953,7 +1031,10 @@ export const useStore = create<AppState>()(
|
||||
tableColumnOrders: state.tableColumnOrders,
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory,
|
||||
tableHiddenColumns: state.tableHiddenColumns,
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
|
||||
windowBounds: state.windowBounds,
|
||||
windowState: state.windowState,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
}), // Don't persist logs
|
||||
}
|
||||
)
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface SavedConnection {
|
||||
config: ConnectionConfig;
|
||||
includeDatabases?: string[];
|
||||
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
|
||||
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
|
||||
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
|
||||
}
|
||||
|
||||
export interface ConnectionTag {
|
||||
@@ -116,7 +118,7 @@ export interface TriggerDefinition {
|
||||
export interface TabData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def';
|
||||
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
|
||||
connectionId: string;
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
@@ -128,6 +130,7 @@ export interface TabData {
|
||||
viewName?: string; // View name for view definition tabs
|
||||
routineName?: string; // Routine name for function/procedure definition tabs
|
||||
routineType?: string; // 'FUNCTION' or 'PROCEDURE'
|
||||
savedQueryId?: string; // Saved query identity for quick-save behavior
|
||||
}
|
||||
|
||||
export interface DatabaseNode {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { FilterCondition } from './sql';
|
||||
import { parseListValues } from './sql';
|
||||
|
||||
type SortInfo = {
|
||||
type SortInfoItem = {
|
||||
columnKey?: string;
|
||||
order?: string;
|
||||
} | null | undefined;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type SortInfo = SortInfoItem | SortInfoItem[] | null | undefined;
|
||||
|
||||
type ShellConvertResult = {
|
||||
recognized: boolean;
|
||||
@@ -607,14 +610,24 @@ export const buildMongoSort = (
|
||||
sortInfo: SortInfo,
|
||||
fallbackColumns: string[] = [],
|
||||
): Record<string, 1 | -1> | undefined => {
|
||||
const sortColumn = String(sortInfo?.columnKey || '').trim();
|
||||
const sortOrder = String(sortInfo?.order || '');
|
||||
if (sortColumn && (sortOrder === 'ascend' || sortOrder === 'descend')) {
|
||||
return { [sortColumn]: sortOrder === 'ascend' ? 1 : -1 };
|
||||
const items = Array.isArray(sortInfo) ? sortInfo : (sortInfo ? [sortInfo] : []);
|
||||
const sort: Record<string, 1 | -1> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item?.enabled === false) continue;
|
||||
const col = String(item?.columnKey || '').trim();
|
||||
const order = String(item?.order || '');
|
||||
if (col && (order === 'ascend' || order === 'descend')) {
|
||||
const key = col.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
sort[col] = order === 'ascend' ? 1 : -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(sort).length > 0) return sort;
|
||||
|
||||
const uniqueColumns: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
(fallbackColumns || []).forEach((col) => {
|
||||
const key = String(col || '').trim();
|
||||
if (!key) return;
|
||||
@@ -625,7 +638,6 @@ export const buildMongoSort = (
|
||||
});
|
||||
if (uniqueColumns.length === 0) return undefined;
|
||||
|
||||
const sort: Record<string, 1 | -1> = {};
|
||||
uniqueColumns.forEach((col) => {
|
||||
sort[col] = 1;
|
||||
});
|
||||
|
||||
27
frontend/src/utils/overlayWorkbenchTheme.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertMatch = (value: string, pattern: RegExp, message: string) => {
|
||||
if (!pattern.test(value)) {
|
||||
throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true);
|
||||
assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true');
|
||||
assertMatch(darkTheme.shellBg, /rgba\(15, 15, 17,/, 'dark 弹层背景应保持中性黑');
|
||||
assertMatch(darkTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/, 'dark section 背景透明度应匹配');
|
||||
assertEqual(darkTheme.iconColor, '#ffd666', 'dark 图标色应为金色强调');
|
||||
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false');
|
||||
assertMatch(lightTheme.shellBg, /rgba\(255,255,255,0\.98\)/, 'light 弹层背景透明度应匹配');
|
||||
assertMatch(lightTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/, 'light section 背景透明度应匹配');
|
||||
assertEqual(lightTheme.iconColor, '#1677ff', 'light 图标色应为蓝色强调');
|
||||
|
||||
console.log('overlayWorkbenchTheme tests passed');
|
||||
59
frontend/src/utils/overlayWorkbenchTheme.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
type OverlayWorkbenchTheme = {
|
||||
isDark: boolean;
|
||||
shellBg: string;
|
||||
shellBorder: string;
|
||||
shellShadow: string;
|
||||
shellBackdropFilter: string;
|
||||
sectionBg: string;
|
||||
sectionBorder: string;
|
||||
mutedText: string;
|
||||
titleText: string;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
hoverBg: string;
|
||||
selectedBg: string;
|
||||
selectedText: string;
|
||||
divider: string;
|
||||
};
|
||||
|
||||
export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchTheme => {
|
||||
if (darkMode) {
|
||||
return {
|
||||
isDark: true,
|
||||
shellBg: 'linear-gradient(180deg, rgba(15, 15, 17, 0.96) 0%, rgba(11, 11, 13, 0.98) 100%)',
|
||||
shellBorder: '1px solid rgba(255,255,255,0.08)',
|
||||
shellShadow: '0 24px 56px rgba(0,0,0,0.34)',
|
||||
shellBackdropFilter: 'blur(18px)',
|
||||
sectionBg: 'rgba(255,255,255,0.03)',
|
||||
sectionBorder: '1px solid rgba(255,255,255,0.08)',
|
||||
mutedText: 'rgba(255,255,255,0.5)',
|
||||
titleText: '#f5f7ff',
|
||||
iconBg: 'rgba(255,214,102,0.12)',
|
||||
iconColor: '#ffd666',
|
||||
hoverBg: 'rgba(255,214,102,0.10)',
|
||||
selectedBg: 'rgba(255,214,102,0.14)',
|
||||
selectedText: '#ffd666',
|
||||
divider: 'rgba(255,255,255,0.08)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isDark: false,
|
||||
shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
shellBorder: '1px solid rgba(16,24,40,0.08)',
|
||||
shellShadow: '0 18px 42px rgba(15,23,42,0.12)',
|
||||
shellBackdropFilter: 'none',
|
||||
sectionBg: 'rgba(255,255,255,0.84)',
|
||||
sectionBorder: '1px solid rgba(16,24,40,0.08)',
|
||||
mutedText: 'rgba(16,24,40,0.55)',
|
||||
titleText: '#162033',
|
||||
iconBg: 'rgba(24,144,255,0.1)',
|
||||
iconColor: '#1677ff',
|
||||
hoverBg: 'rgba(24,144,255,0.08)',
|
||||
selectedBg: 'rgba(24,144,255,0.12)',
|
||||
selectedText: '#1677ff',
|
||||
divider: 'rgba(16,24,40,0.08)',
|
||||
};
|
||||
};
|
||||
|
||||
export type { OverlayWorkbenchTheme };
|
||||
@@ -69,10 +69,13 @@ export const quoteQualifiedIdent = (dbType: string, ident: string) => {
|
||||
|
||||
export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''");
|
||||
|
||||
type SortInfo = {
|
||||
type SortInfoItem = {
|
||||
columnKey?: string;
|
||||
order?: string;
|
||||
} | null | undefined;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type SortInfo = SortInfoItem | SortInfoItem[] | null | undefined;
|
||||
|
||||
// 为排序查询按库类型注入 sort_buffer 提升参数(仅影响当前语句)。
|
||||
// MySQL: 使用 Optimizer Hint `SET_VAR`。
|
||||
@@ -101,17 +104,50 @@ export const withSortBufferTuningSQL = (
|
||||
return rawSql;
|
||||
};
|
||||
|
||||
/** 将 SortInfo(单字段或多字段)标准化为 SortInfoItem 数组 */
|
||||
const normalizeSortInfoItems = (sortInfo: SortInfo): SortInfoItem[] => {
|
||||
if (!sortInfo) return [];
|
||||
if (Array.isArray(sortInfo)) return sortInfo;
|
||||
return [sortInfo];
|
||||
};
|
||||
|
||||
/** 判断 SortInfo 中是否存在至少一个有效排序 */
|
||||
export const hasExplicitSort = (sortInfo: SortInfo): boolean => {
|
||||
const items = normalizeSortInfoItems(sortInfo);
|
||||
return items.some(item => {
|
||||
if (item?.enabled === false) return false;
|
||||
const col = String(item?.columnKey || '').trim();
|
||||
const order = String(item?.order || '');
|
||||
return !!col && (order === 'ascend' || order === 'descend');
|
||||
});
|
||||
};
|
||||
|
||||
export const buildOrderBySQL = (
|
||||
dbType: string,
|
||||
sortInfo: SortInfo,
|
||||
fallbackColumns: string[] = [],
|
||||
) => {
|
||||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||||
const sortColumn = normalizeIdentPart(String(sortInfo?.columnKey || ''));
|
||||
const sortOrder = String(sortInfo?.order || '');
|
||||
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
|
||||
if (sortColumn && direction) {
|
||||
return ` ORDER BY ${quoteIdentPart(dbType, sortColumn)} ${direction}`;
|
||||
const items = normalizeSortInfoItems(sortInfo);
|
||||
const seen = new Set<string>();
|
||||
const sortParts: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item?.enabled === false) continue;
|
||||
const sortColumn = normalizeIdentPart(String(item?.columnKey || ''));
|
||||
const sortOrder = String(item?.order || '');
|
||||
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
|
||||
if (sortColumn && direction) {
|
||||
const key = sortColumn.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
sortParts.push(`${quoteIdentPart(dbType, sortColumn)} ${direction}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sortParts.length > 0) {
|
||||
return ` ORDER BY ${sortParts.join(', ')}`;
|
||||
}
|
||||
|
||||
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY(即使按主键)可能触发 filesort,
|
||||
@@ -121,7 +157,6 @@ export const buildOrderBySQL = (
|
||||
return '';
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stableColumns = (fallbackColumns || [])
|
||||
.map((col) => normalizeIdentPart(String(col || '')))
|
||||
.filter((col) => {
|
||||
|
||||
2
frontend/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist', // Standard Wails output directory
|
||||
emptyOutDir: true,
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
|
||||
10
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -9,6 +9,8 @@ export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:s
|
||||
|
||||
export function CancelQuery(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function CancelSQLFileExecution(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
@@ -41,6 +43,8 @@ export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string
|
||||
|
||||
export function DBQueryIsolated(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBQueryMulti(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
@@ -63,6 +67,8 @@ export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:stri
|
||||
|
||||
export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
|
||||
@@ -131,6 +137,8 @@ export function RedisGetServerInfo(arg1:connection.ConnectionConfig):Promise<con
|
||||
|
||||
export function RedisGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisKeyExists(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;
|
||||
@@ -188,3 +196,5 @@ export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;
|
||||
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -10,6 +10,10 @@ export function CancelQuery(arg1) {
|
||||
return window['go']['app']['App']['CancelQuery'](arg1);
|
||||
}
|
||||
|
||||
export function CancelSQLFileExecution(arg1) {
|
||||
return window['go']['app']['App']['CancelSQLFileExecution'](arg1);
|
||||
}
|
||||
|
||||
export function CheckDriverNetworkStatus() {
|
||||
return window['go']['app']['App']['CheckDriverNetworkStatus']();
|
||||
}
|
||||
@@ -74,6 +78,10 @@ export function DBQueryIsolated(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DBQueryIsolated'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DBQueryMulti(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DBQueryMulti'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function DBQueryWithCancel(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DBQueryWithCancel'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -118,6 +126,10 @@ export function DropView(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DropView'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ExecuteSQLFile(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExecuteSQLFile'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ExportData(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -254,6 +266,10 @@ export function RedisGetValue(arg1, arg2) {
|
||||
return window['go']['app']['App']['RedisGetValue'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function RedisKeyExists(arg1, arg2) {
|
||||
return window['go']['app']['App']['RedisKeyExists'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function RedisListPush(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['RedisListPush'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -369,3 +385,7 @@ export function SetWindowTranslucency(arg1, arg2) {
|
||||
export function TestConnection(arg1) {
|
||||
return window['go']['app']['App']['TestConnection'](arg1);
|
||||
}
|
||||
|
||||
export function TruncateTables(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['TruncateTables'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
@@ -109,6 +109,8 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
|
||||
// MariaDB uses same syntax as MySQL
|
||||
} else if dbType == "sphinx" {
|
||||
return connection.QueryResult{Success: false, Message: "Sphinx 暂不支持创建数据库"}
|
||||
} else if dbType == "oracle" || dbType == "dameng" {
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)的「数据库」实际为用户/Schema,暂不支持通过此入口创建,请使用 SQL 编辑器执行 CREATE USER 语句", dbType)}
|
||||
}
|
||||
|
||||
_, err = dbInst.Exec(query)
|
||||
@@ -116,7 +118,7 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "Database created successfully"}
|
||||
return connection.QueryResult{Success: true, Message: "数据库创建成功"}
|
||||
}
|
||||
|
||||
func resolveDDLDBType(config connection.ConnectionConfig) string {
|
||||
@@ -487,6 +489,165 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin
|
||||
}
|
||||
}
|
||||
|
||||
// DBQueryMulti 执行可能包含多条 SQL 语句的查询,返回多个结果集。
|
||||
// 如果底层驱动支持 MultiResultQuerier,一次性执行所有语句;
|
||||
// 否则按分号拆分后逐条执行,模拟多结果集。
|
||||
func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
|
||||
if queryID == "" {
|
||||
queryID = generateQueryID()
|
||||
}
|
||||
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQueryMulti 获取连接失败:%s", formatConnSummary(runConfig))
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
|
||||
query = sanitizeSQLForPgLike(runConfig.Type, query)
|
||||
timeoutSeconds := runConfig.Timeout
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = 30
|
||||
}
|
||||
ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second)
|
||||
defer cancel()
|
||||
|
||||
a.queryMu.Lock()
|
||||
a.runningQueries[queryID] = queryContext{
|
||||
cancel: cancel,
|
||||
started: time.Now(),
|
||||
}
|
||||
a.queryMu.Unlock()
|
||||
defer func() {
|
||||
a.queryMu.Lock()
|
||||
delete(a.runningQueries, queryID)
|
||||
a.queryMu.Unlock()
|
||||
}()
|
||||
|
||||
// 尝试使用驱动原生多结果集支持。
|
||||
// 注意:原生 conn.Query() 执行写操作(UPDATE/INSERT/DELETE)时,
|
||||
// sql.Rows 不暴露 RowsAffected,导致影响行数丢失。
|
||||
// 因此仅在全部语句皆为读操作时才使用原生路径。
|
||||
allReadOnly := true
|
||||
for _, stmt := range splitSQLStatements(query) {
|
||||
if strings.TrimSpace(stmt) != "" && !isReadOnlySQLQuery(runConfig.Type, stmt) {
|
||||
allReadOnly = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
runMultiQuery := func(inst db.Database) ([]connection.ResultSetData, error) {
|
||||
if !allReadOnly {
|
||||
return nil, nil // 包含写操作,走逐条执行路径
|
||||
}
|
||||
if q, ok := inst.(db.MultiResultQuerierContext); ok {
|
||||
return q.QueryMultiContext(ctx, query)
|
||||
}
|
||||
if q, ok := inst.(db.MultiResultQuerier); ok {
|
||||
return q.QueryMulti(query)
|
||||
}
|
||||
return nil, nil // 返回 nil 表示不支持
|
||||
}
|
||||
|
||||
results, err := runMultiQuery(dbInst)
|
||||
if err != nil && shouldRefreshCachedConnection(err) {
|
||||
if a.invalidateCachedDatabase(runConfig, err) {
|
||||
retryInst, retryErr := a.getDatabaseForcePing(runConfig)
|
||||
if retryErr != nil {
|
||||
logger.Error(retryErr, "DBQueryMulti 重建连接失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||
return connection.QueryResult{Success: false, Message: retryErr.Error(), QueryID: queryID}
|
||||
}
|
||||
results, err = runMultiQuery(retryInst)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQueryMulti 执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
|
||||
// 驱动支持多结果集,直接返回
|
||||
if results != nil {
|
||||
return connection.QueryResult{Success: true, Data: results, QueryID: queryID}
|
||||
}
|
||||
|
||||
// 驱动不支持多结果集,回退到逐条执行
|
||||
statements := splitSQLStatements(query)
|
||||
if len(statements) == 0 {
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Data: []connection.ResultSetData{},
|
||||
QueryID: queryID,
|
||||
}
|
||||
}
|
||||
|
||||
var resultSets []connection.ResultSetData
|
||||
for idx, stmt := range statements {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if isReadOnlySQLQuery(runConfig.Type, stmt) {
|
||||
var data []map[string]interface{}
|
||||
var columns []string
|
||||
if q, ok := dbInst.(interface {
|
||||
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
|
||||
}); ok {
|
||||
data, columns, err = q.QueryContext(ctx, stmt)
|
||||
} else {
|
||||
data, columns, err = dbInst.Query(stmt)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQueryMulti 逐条查询失败(第 %d/%d 条):%s SQL片段=%q", idx+1, len(statements), formatConnSummary(runConfig), sqlSnippet(stmt))
|
||||
errMsg := fmt.Sprintf("第 %d 条语句执行失败: %v", idx+1, err)
|
||||
if len(resultSets) > 0 {
|
||||
errMsg += fmt.Sprintf("(前 %d 条已执行成功)", len(resultSets))
|
||||
}
|
||||
return connection.QueryResult{Success: false, Message: errMsg, QueryID: queryID}
|
||||
}
|
||||
if data == nil {
|
||||
data = make([]map[string]interface{}, 0)
|
||||
}
|
||||
if columns == nil {
|
||||
columns = []string{}
|
||||
}
|
||||
resultSets = append(resultSets, connection.ResultSetData{Rows: data, Columns: columns})
|
||||
} else {
|
||||
var affected int64
|
||||
if e, ok := dbInst.(interface {
|
||||
ExecContext(context.Context, string) (int64, error)
|
||||
}); ok {
|
||||
affected, err = e.ExecContext(ctx, stmt)
|
||||
} else {
|
||||
affected, err = dbInst.Exec(stmt)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQueryMulti 逐条执行失败(第 %d/%d 条):%s SQL片段=%q", idx+1, len(statements), formatConnSummary(runConfig), sqlSnippet(stmt))
|
||||
errMsg := fmt.Sprintf("第 %d 条语句执行失败: %v", idx+1, err)
|
||||
if len(resultSets) > 0 {
|
||||
errMsg += fmt.Sprintf("(前 %d 条已执行成功)", len(resultSets))
|
||||
}
|
||||
return connection.QueryResult{Success: false, Message: errMsg, QueryID: queryID}
|
||||
}
|
||||
resultSets = append(resultSets, connection.ResultSetData{
|
||||
Rows: []map[string]interface{}{{"affectedRows": affected}},
|
||||
Columns: []string{"affectedRows"},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if resultSets == nil {
|
||||
resultSets = []connection.ResultSetData{}
|
||||
}
|
||||
// 回退到逐条执行且有多条语句时,附加提示信息
|
||||
var fallbackMsg string
|
||||
if len(statements) > 1 {
|
||||
fallbackMsg = fmt.Sprintf("当前数据源(%s)不支持原生多语句执行,已自动拆分为 %d 条语句逐条执行。", runConfig.Type, len(statements))
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: resultSets, QueryID: queryID, Message: fallbackMsg}
|
||||
}
|
||||
|
||||
func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
|
||||
|
||||
112
internal/app/methods_db_conn_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestNormalizeTestConnectionConfig_CapsTimeout(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{Timeout: 60}
|
||||
got := normalizeTestConnectionConfig(cfg)
|
||||
if got.Timeout != testConnectionTimeoutUpperBoundSeconds {
|
||||
t.Fatalf("timeout 应被限制为 %d, got=%d", testConnectionTimeoutUpperBoundSeconds, got.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTestConnectionConfig_KeepSmallTimeout(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{Timeout: 5}
|
||||
got := normalizeTestConnectionConfig(cfg)
|
||||
if got.Timeout != 5 {
|
||||
t.Fatalf("timeout 不应被修改, got=%d", got.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTestConnectionConfig_ZeroTimeout(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{Timeout: 0}
|
||||
got := normalizeTestConnectionConfig(cfg)
|
||||
if got.Timeout != testConnectionTimeoutUpperBoundSeconds {
|
||||
t.Fatalf("零值 timeout 应被修正, got=%d", got.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatConnSummary_BasicMySQL(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Host: "127.0.0.1",
|
||||
Port: 3306,
|
||||
User: "root",
|
||||
Database: "test_db",
|
||||
Timeout: 30,
|
||||
}
|
||||
got := formatConnSummary(cfg)
|
||||
for _, want := range []string{"类型=mysql", "127.0.0.1:3306", "test_db", "root"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("formatConnSummary 应包含 %q, got=%q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatConnSummary_SQLitePath(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "sqlite",
|
||||
Host: "/data/test.db",
|
||||
}
|
||||
got := formatConnSummary(cfg)
|
||||
if !strings.Contains(got, "类型=sqlite") {
|
||||
t.Fatalf("formatConnSummary 缺少类型, got=%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "/data/test.db") {
|
||||
t.Fatalf("formatConnSummary 缺少路径, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatConnSummary_SSH(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Host: "db.internal",
|
||||
Port: 3306,
|
||||
User: "app",
|
||||
UseSSH: true,
|
||||
SSH: connection.SSHConfig{
|
||||
Host: "jump.server",
|
||||
Port: 22,
|
||||
User: "admin",
|
||||
},
|
||||
}
|
||||
got := formatConnSummary(cfg)
|
||||
if !strings.Contains(got, "SSH=jump.server:22") {
|
||||
t.Fatalf("formatConnSummary 应包含 SSH 信息, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatConnSummary_Proxy(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Host: "db.internal",
|
||||
Port: 3306,
|
||||
UseProxy: true,
|
||||
Proxy: connection.ProxyConfig{
|
||||
Type: "socks5",
|
||||
Host: "proxy.local",
|
||||
Port: 1080,
|
||||
},
|
||||
}
|
||||
got := formatConnSummary(cfg)
|
||||
if !strings.Contains(got, "代理=socks5://proxy.local:1080") {
|
||||
t.Fatalf("formatConnSummary 应包含代理信息, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatConnSummary_DefaultTimeout(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Host: "localhost",
|
||||
Port: 3306,
|
||||
}
|
||||
got := formatConnSummary(cfg)
|
||||
if !strings.Contains(got, "超时=30s") {
|
||||
t.Fatalf("formatConnSummary 默认超时应为30s, got=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -353,7 +353,7 @@ func (a *App) SelectDriverDownloadDirectory(currentDir string) connection.QueryR
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
|
||||
resolved, err := resolveDriverDownloadDirectory(selection)
|
||||
@@ -392,7 +392,7 @@ func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
|
||||
if abs, err := filepath.Abs(selection); err == nil {
|
||||
@@ -423,7 +423,7 @@ func (a *App) SelectDriverPackageDirectory(currentPath string) connection.QueryR
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
if abs, err := filepath.Abs(selection); err == nil {
|
||||
selection = abs
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -48,7 +49,28 @@ func (a *App) OpenSQLFile() connection.QueryResult {
|
||||
}
|
||||
|
||||
if selection == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
const maxSQLFileSize int64 = 50 * 1024 * 1024 // 50MB
|
||||
fi, err := os.Stat(selection)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err)}
|
||||
}
|
||||
|
||||
// 大文件:只返回文件路径和大小,不读取内容
|
||||
if fi.Size() > maxSQLFileSize {
|
||||
sizeMB := float64(fi.Size()) / (1024 * 1024)
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"isLargeFile": true,
|
||||
"filePath": selection,
|
||||
"fileSize": fi.Size(),
|
||||
"fileSizeMB": fmt.Sprintf("%.1f", sizeMB),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(selection)
|
||||
@@ -59,6 +81,184 @@ func (a *App) OpenSQLFile() connection.QueryResult {
|
||||
return connection.QueryResult{Success: true, Data: string(content)}
|
||||
}
|
||||
|
||||
// ExecuteSQLFile 在后端流式读取并执行大 SQL 文件,通过事件推送进度。
|
||||
// 前端通过 EventsOn("sqlfile:progress", ...) 监听进度。
|
||||
func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string, filePath string, jobID string) connection.QueryResult {
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "文件路径为空"}
|
||||
}
|
||||
if strings.TrimSpace(jobID) == "" {
|
||||
jobID = fmt.Sprintf("sqlfile-%d", time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
logger.Warnf("ExecuteSQLFile 开始:file=%s db=%s jobID=%s", filePath, dbName, jobID)
|
||||
|
||||
// 获取数据库连接
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
logger.Error(err, "ExecuteSQLFile 获取连接失败:%s", formatConnSummary(runConfig))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
// 打开文件
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法打开文件: %v", err)}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 获取文件大小用于计算进度
|
||||
fi, _ := f.Stat()
|
||||
totalSize := fi.Size()
|
||||
|
||||
// 设置取消上下文
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
a.queryMu.Lock()
|
||||
a.runningQueries[jobID] = queryContext{
|
||||
cancel: cancel,
|
||||
started: time.Now(),
|
||||
}
|
||||
a.queryMu.Unlock()
|
||||
defer func() {
|
||||
a.queryMu.Lock()
|
||||
delete(a.runningQueries, jobID)
|
||||
a.queryMu.Unlock()
|
||||
}()
|
||||
|
||||
// 发送进度事件的辅助函数
|
||||
emitProgress := func(status string, executed, failed, total int, bytesRead int64, currentSQL string, errMsg string) {
|
||||
percent := 0.0
|
||||
if totalSize > 0 {
|
||||
percent = float64(bytesRead) / float64(totalSize) * 100
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "sqlfile:progress", map[string]interface{}{
|
||||
"jobId": jobID,
|
||||
"status": status,
|
||||
"executed": executed,
|
||||
"failed": failed,
|
||||
"total": total,
|
||||
"percent": percent,
|
||||
"bytesRead": bytesRead,
|
||||
"totalBytes": totalSize,
|
||||
"currentSQL": currentSQL,
|
||||
"error": errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
emitProgress("running", 0, 0, 0, 0, "", "")
|
||||
|
||||
// 使用 countingReader 追踪已读取字节数
|
||||
cr := &countingReader{r: f}
|
||||
|
||||
var executedCount int
|
||||
var failedCount int
|
||||
var errorLogs []string
|
||||
startTime := time.Now()
|
||||
|
||||
_, streamErr := streamSQLFile(cr, func(index int, stmt string) error {
|
||||
// 检查是否已取消
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("已取消")
|
||||
default:
|
||||
}
|
||||
|
||||
// 执行语句
|
||||
_, execErr := dbInst.Exec(stmt)
|
||||
if execErr != nil {
|
||||
failedCount++
|
||||
snippet := stmt
|
||||
if len(snippet) > 200 {
|
||||
snippet = snippet[:200] + "..."
|
||||
}
|
||||
errLog := fmt.Sprintf("第 %d 条语句执行失败: %v\n SQL: %s", index+1, execErr, snippet)
|
||||
errorLogs = append(errorLogs, errLog)
|
||||
logger.Warnf("ExecuteSQLFile %s", errLog)
|
||||
} else {
|
||||
executedCount++
|
||||
}
|
||||
|
||||
// 每条语句执行后推送进度(但限频:每 100 条或每秒推一次)
|
||||
total := executedCount + failedCount
|
||||
if total%100 == 0 || total <= 10 {
|
||||
snippet := stmt
|
||||
if len(snippet) > 100 {
|
||||
snippet = snippet[:100] + "..."
|
||||
}
|
||||
emitProgress("running", executedCount, failedCount, total, cr.n, snippet, "")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if streamErr != nil && streamErr.Error() == "已取消" {
|
||||
emitProgress("cancelled", executedCount, failedCount, executedCount+failedCount, cr.n, "", "用户取消执行")
|
||||
logger.Warnf("ExecuteSQLFile 已取消:executed=%d failed=%d duration=%v", executedCount, failedCount, duration)
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("执行已取消。已执行 %d 条,失败 %d 条,耗时 %v。", executedCount, failedCount, duration.Round(time.Millisecond)),
|
||||
}
|
||||
}
|
||||
|
||||
if streamErr != nil {
|
||||
emitProgress("error", executedCount, failedCount, executedCount+failedCount, cr.n, "", streamErr.Error())
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("文件读取错误: %v。已执行 %d 条。", streamErr, executedCount),
|
||||
}
|
||||
}
|
||||
|
||||
emitProgress("done", executedCount, failedCount, executedCount+failedCount, totalSize, "", "")
|
||||
|
||||
summary := fmt.Sprintf("执行完成。成功 %d 条,失败 %d 条,耗时 %v。", executedCount, failedCount, duration.Round(time.Millisecond))
|
||||
if len(errorLogs) > 0 {
|
||||
maxShow := 20
|
||||
if len(errorLogs) < maxShow {
|
||||
maxShow = len(errorLogs)
|
||||
}
|
||||
summary += "\n\n错误详情(前 " + fmt.Sprintf("%d", maxShow) + " 条):\n" + strings.Join(errorLogs[:maxShow], "\n")
|
||||
if len(errorLogs) > maxShow {
|
||||
summary += fmt.Sprintf("\n...还有 %d 条错误未显示", len(errorLogs)-maxShow)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Warnf("ExecuteSQLFile 完成:executed=%d failed=%d duration=%v", executedCount, failedCount, duration)
|
||||
return connection.QueryResult{Success: failedCount == 0, Message: summary}
|
||||
}
|
||||
|
||||
// CancelSQLFileExecution 取消正在执行的 SQL 文件任务。
|
||||
func (a *App) CancelSQLFileExecution(jobID string) connection.QueryResult {
|
||||
a.queryMu.Lock()
|
||||
defer a.queryMu.Unlock()
|
||||
|
||||
if ctx, exists := a.runningQueries[jobID]; exists {
|
||||
ctx.cancel()
|
||||
delete(a.runningQueries, jobID)
|
||||
return connection.QueryResult{Success: true, Message: "已发送取消请求"}
|
||||
}
|
||||
return connection.QueryResult{Success: false, Message: "未找到该任务"}
|
||||
}
|
||||
|
||||
// countingReader 包装 io.Reader,追踪已读取的字节数。
|
||||
type countingReader struct {
|
||||
r io.Reader
|
||||
n int64
|
||||
}
|
||||
|
||||
func (cr *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := cr.r.Read(p)
|
||||
cr.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (a *App) ImportConfigFile() connection.QueryResult {
|
||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Config File",
|
||||
@@ -75,7 +275,7 @@ func (a *App) ImportConfigFile() connection.QueryResult {
|
||||
}
|
||||
|
||||
if selection == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(selection)
|
||||
@@ -120,7 +320,7 @@ func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
if abs, err := filepath.Abs(selection); err == nil {
|
||||
selection = abs
|
||||
@@ -192,7 +392,7 @@ func (a *App) SelectDatabaseFile(currentPath string, driverType string) connecti
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
if abs, err := filepath.Abs(selection); err == nil {
|
||||
selection = abs
|
||||
@@ -203,7 +403,7 @@ func (a *App) SelectDatabaseFile(currentPath string, driverType string) connecti
|
||||
// PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据
|
||||
func (a *App) PreviewImportFile(filePath string) connection.QueryResult {
|
||||
if filePath == "" {
|
||||
return connection.QueryResult{Success: false, Message: "File path required"}
|
||||
return connection.QueryResult{Success: false, Message: "文件路径不能为空"}
|
||||
}
|
||||
|
||||
rows, columns, err := parseImportFile(filePath)
|
||||
@@ -243,7 +443,7 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s
|
||||
}
|
||||
|
||||
if selection == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
|
||||
// 返回文件路径供前端预览
|
||||
@@ -492,7 +692,7 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName,
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return connection.QueryResult{Success: true, Message: "No data to import"}
|
||||
return connection.QueryResult{Success: true, Message: "无可导入数据"}
|
||||
}
|
||||
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
@@ -584,7 +784,7 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
|
||||
})
|
||||
|
||||
if err != nil || filename == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
@@ -616,7 +816,7 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(runConfig.Type, tableName))
|
||||
@@ -632,10 +832,10 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
|
||||
}
|
||||
defer f.Close()
|
||||
if err := writeRowsToFile(f, data, columns, format); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
|
||||
func (a *App) ExportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeData bool) connection.QueryResult {
|
||||
@@ -648,7 +848,7 @@ func (a *App) ExportTablesDataSQL(config connection.ConnectionConfig, dbName str
|
||||
|
||||
func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeSchema bool, includeData bool) connection.QueryResult {
|
||||
if !includeSchema && !includeData {
|
||||
return connection.QueryResult{Success: false, Message: "invalid export mode"}
|
||||
return connection.QueryResult{Success: false, Message: "无效的导出模式"}
|
||||
}
|
||||
|
||||
safeDbName := strings.TrimSpace(dbName)
|
||||
@@ -671,7 +871,7 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
DefaultFilename: defaultFilename,
|
||||
})
|
||||
if err != nil || filename == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
@@ -717,13 +917,13 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
|
||||
func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName string, includeData bool) connection.QueryResult {
|
||||
safeDbName := strings.TrimSpace(dbName)
|
||||
if safeDbName == "" {
|
||||
return connection.QueryResult{Success: false, Message: "dbName required"}
|
||||
return connection.QueryResult{Success: false, Message: "数据库名称不能为空"}
|
||||
}
|
||||
suffix := "schema"
|
||||
if includeData {
|
||||
@@ -735,7 +935,7 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
|
||||
DefaultFilename: fmt.Sprintf("%s_%s.sql", safeDbName, suffix),
|
||||
})
|
||||
if err != nil || filename == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
@@ -772,7 +972,92 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
|
||||
// TruncateTables 清空指定表的数据(针对 MySQL 使用 TRUNCATE,MongoDB 使用 delete,否则使用 DELETE)。
|
||||
// 注意:MySQL 的 TRUNCATE TABLE 是 DDL 操作,无法事务回滚;批量清空为逐表执行,
|
||||
// 如果中途失败,已清空的表无法恢复。错误结果会附带已执行的 SQL 列表供排查。
|
||||
func (a *App) TruncateTables(config connection.ConnectionConfig, dbName string, tableNames []string) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
|
||||
// 参数校验
|
||||
if len(tableNames) == 0 {
|
||||
return connection.QueryResult{Success: false, Message: "未指定要清空的表"}
|
||||
}
|
||||
|
||||
objects := make([]string, 0, len(tableNames))
|
||||
seen := make(map[string]struct{}, len(tableNames))
|
||||
for _, t := range tableNames {
|
||||
tt := strings.TrimSpace(t)
|
||||
if tt == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[tt]; ok {
|
||||
continue
|
||||
}
|
||||
seen[tt] = struct{}{}
|
||||
objects = append(objects, tt)
|
||||
}
|
||||
|
||||
if len(objects) == 0 {
|
||||
return connection.QueryResult{Success: false, Message: "未指定要清空的表"}
|
||||
}
|
||||
const maxBatchSize = 200
|
||||
if len(objects) > maxBatchSize {
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("单次最多清空 %d 张表,当前选中 %d 张", maxBatchSize, len(objects))}
|
||||
}
|
||||
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
// 审计日志:记录清空操作的发起
|
||||
logger.Warnf("TruncateTables 开始:%s db=%s tables=%v(共 %d 张)", formatConnSummary(runConfig), dbName, objects, len(objects))
|
||||
|
||||
dbType := strings.ToLower(strings.TrimSpace(runConfig.Type))
|
||||
var executedSQLs []string
|
||||
for i, objectName := range objects {
|
||||
var sql string
|
||||
if dbType == "mysql" || dbType == "mariadb" {
|
||||
sql = fmt.Sprintf("TRUNCATE TABLE %s", quoteQualifiedIdentByType(runConfig.Type, objectName))
|
||||
} else if dbType == "mongodb" {
|
||||
// MongoDB 使用 delete 命令清空集合中的所有文档
|
||||
// deletes 的 limit 为 0 表示删除所有匹配的文档
|
||||
sql = fmt.Sprintf(`{"delete":"%s","deletes":[{"q":{},"limit":0}]}`, objectName)
|
||||
} else {
|
||||
sql = fmt.Sprintf("DELETE FROM %s", quoteQualifiedIdentByType(runConfig.Type, objectName))
|
||||
}
|
||||
|
||||
if _, err := dbInst.Exec(sql); err != nil {
|
||||
logger.Warnf("TruncateTables 第 %d/%d 张表失败:%s table=%s err=%v(已成功清空 %d 张)", i+1, len(objects), formatConnSummary(runConfig), objectName, err, len(executedSQLs))
|
||||
errMsg := fmt.Sprintf("清空 %s 失败: %v", objectName, err)
|
||||
if len(executedSQLs) > 0 {
|
||||
errMsg += fmt.Sprintf("(注意:前 %d 张表已清空且无法恢复)", len(executedSQLs))
|
||||
}
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
Data: map[string]interface{}{
|
||||
"executedSQLs": executedSQLs,
|
||||
"count": len(executedSQLs),
|
||||
},
|
||||
}
|
||||
}
|
||||
executedSQLs = append(executedSQLs, sql)
|
||||
}
|
||||
|
||||
logger.Warnf("TruncateTables 完成:%s db=%s 共清空 %d 张表", formatConnSummary(runConfig), dbName, len(executedSQLs))
|
||||
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: "清空成功",
|
||||
Data: map[string]interface{}{
|
||||
"executedSQLs": executedSQLs,
|
||||
"count": len(executedSQLs),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func quoteIdentByType(dbType string, ident string) string {
|
||||
@@ -1471,7 +1756,7 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
|
||||
|
||||
if err != nil || filename == "" {
|
||||
logger.Infof("ExportData 已取消或未选择文件:err=%v", err)
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
logger.Infof("ExportData 选定文件:%s", filename)
|
||||
|
||||
@@ -1482,11 +1767,11 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
|
||||
defer f.Close()
|
||||
if err := writeRowsToFile(f, data, columns, format); err != nil {
|
||||
logger.Warnf("ExportData 写入失败:file=%s err=%v", filename, err)
|
||||
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
|
||||
}
|
||||
|
||||
logger.Infof("ExportData 完成:file=%s rows=%d", filename, len(data))
|
||||
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
|
||||
// ExportQuery exports by executing the provided SELECT query on backend side.
|
||||
@@ -1494,7 +1779,7 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
|
||||
func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, query string, defaultName string, format string) connection.QueryResult {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return connection.QueryResult{Success: false, Message: "query required"}
|
||||
return connection.QueryResult{Success: false, Message: "查询语句不能为空"}
|
||||
}
|
||||
|
||||
if defaultName == "" {
|
||||
@@ -1507,7 +1792,7 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
|
||||
})
|
||||
if err != nil || filename == "" {
|
||||
logger.Infof("ExportQuery 已取消或未选择文件:err=%v", err)
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
logger.Infof("ExportQuery 开始:type=%s db=%s format=%s file=%s sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), strings.ToLower(strings.TrimSpace(format)), filename, sqlSnippet(query))
|
||||
|
||||
@@ -1520,7 +1805,7 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
|
||||
query = sanitizeSQLForPgLike(runConfig.Type, query)
|
||||
lowerQuery := strings.ToLower(strings.TrimSpace(query))
|
||||
if !(strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "with")) {
|
||||
return connection.QueryResult{Success: false, Message: "Only SELECT/WITH queries are supported"}
|
||||
return connection.QueryResult{Success: false, Message: "仅支持 SELECT/WITH 查询导出"}
|
||||
}
|
||||
|
||||
data, columns, err := queryDataForExport(dbInst, runConfig, query)
|
||||
@@ -1537,11 +1822,11 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
|
||||
|
||||
if err := writeRowsToFile(f, data, columns, format); err != nil {
|
||||
logger.Warnf("ExportQuery 写入失败:file=%s err=%v", filename, err)
|
||||
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
|
||||
}
|
||||
|
||||
logger.Infof("ExportQuery 完成:file=%s rows=%d cols=%d", filename, len(data), len(columns))
|
||||
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
|
||||
func queryDataForExport(dbInst db.Database, config connection.ConnectionConfig, query string) ([]map[string]interface{}, []string, error) {
|
||||
@@ -1922,7 +2207,12 @@ func formatExportCellText(val interface{}) string {
|
||||
}
|
||||
return text
|
||||
default:
|
||||
return fmt.Sprintf("%v", val)
|
||||
text := fmt.Sprintf("%v", val)
|
||||
// 字符串型日期时间值(如 RFC3339 "2026-03-10T17:01:55+08:00")格式化为本地时区 yyyy-MM-dd HH:mm:ss
|
||||
if parsed, ok := parseTemporalString(text); ok {
|
||||
return parsed.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1932,6 +2222,18 @@ func normalizeExportJSONValue(val interface{}) interface{} {
|
||||
}
|
||||
|
||||
switch v := val.(type) {
|
||||
case time.Time:
|
||||
return v.Local().Format("2006-01-02 15:04:05")
|
||||
case *time.Time:
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return v.Local().Format("2006-01-02 15:04:05")
|
||||
case string:
|
||||
if parsed, ok := parseTemporalString(v); ok {
|
||||
return parsed.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return v
|
||||
case float32:
|
||||
f := float64(v)
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
|
||||
@@ -453,6 +453,23 @@ func (a *App) RedisRenameKey(config connection.ConnectionConfig, oldKey, newKey
|
||||
return connection.QueryResult{Success: true, Message: "重命名成功"}
|
||||
}
|
||||
|
||||
// RedisKeyExists checks whether a key already exists
|
||||
func (a *App) RedisKeyExists(config connection.ConnectionConfig, key string) connection.QueryResult {
|
||||
config.Type = "redis"
|
||||
client, err := a.getRedisClient(config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
exists, err := client.KeyExists(key)
|
||||
if err != nil {
|
||||
logger.Error(err, "RedisKeyExists 检查失败:key=%s", key)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Data: map[string]bool{"exists": exists}}
|
||||
}
|
||||
|
||||
// RedisDeleteHashField deletes fields from a hash
|
||||
func (a *App) RedisDeleteHashField(config connection.ConnectionConfig, key string, fields []string) connection.QueryResult {
|
||||
config.Type = "redis"
|
||||
|
||||
@@ -957,8 +957,25 @@ if %ERRORLEVEL%==0 (
|
||||
)
|
||||
call :log host process exited
|
||||
|
||||
rem -- Win10 needs extra time for kernel to release exe file handles --
|
||||
timeout /t 3 /nobreak >nul
|
||||
call :log cooldown finished, starting file replace
|
||||
|
||||
set /a RETRY=0
|
||||
:move_retry
|
||||
call :log attempt !RETRY!: trying rename-then-copy strategy
|
||||
ren "%TARGET%" "%TARGET_NAME%.old" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 (
|
||||
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if !ERRORLEVEL!==0 (
|
||||
del /F /Q "%TARGET%.old" >> "%LOG_FILE%" 2>&1
|
||||
goto move_done
|
||||
)
|
||||
call :log copy after rename failed, restoring old file
|
||||
ren "%TARGET_NAME%.old" "%TARGET_NAME%" >> "%LOG_FILE%" 2>&1
|
||||
)
|
||||
|
||||
call :log rename strategy failed, trying direct move
|
||||
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 goto move_done
|
||||
|
||||
@@ -966,8 +983,13 @@ copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 goto move_done
|
||||
|
||||
set /a RETRY+=1
|
||||
if !RETRY! LSS 20 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
if !RETRY! LSS 15 (
|
||||
set /a WAIT=1
|
||||
if !RETRY! GEQ 3 set /a WAIT=2
|
||||
if !RETRY! GEQ 6 set /a WAIT=3
|
||||
if !RETRY! GEQ 9 set /a WAIT=5
|
||||
call :log waiting !WAIT! seconds before retry
|
||||
timeout /t !WAIT! /nobreak >nul
|
||||
goto move_retry
|
||||
)
|
||||
|
||||
@@ -975,6 +997,7 @@ call :log replace failed after retries (portable mode, no elevation): check dire
|
||||
exit /b 1
|
||||
|
||||
:move_done
|
||||
del /F /Q "%TARGET%.old" >> "%LOG_FILE%" 2>&1
|
||||
start "" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log cmd start failed, trying powershell Start-Process
|
||||
|
||||
@@ -38,3 +38,37 @@ func TestBuildWindowsScriptKeepsBatchForSyntax(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWindowsScriptWin10Fixes(t *testing.T) {
|
||||
script := buildWindowsScript(
|
||||
`C:\tmp\GoNavi-v0.5.0-windows-amd64.exe`,
|
||||
`C:\Program Files\GoNavi\GoNavi.exe`,
|
||||
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.5.0`,
|
||||
`C:\Program Files\GoNavi\logs\update-install.log`,
|
||||
99999,
|
||||
)
|
||||
|
||||
// 验证 Win10 关键修复点
|
||||
win10Fixes := []struct {
|
||||
desc string
|
||||
token string
|
||||
}{
|
||||
{"cooldown after process exit", `timeout /t 3 /nobreak >nul`},
|
||||
{"cooldown log", `call :log cooldown finished, starting file replace`},
|
||||
{"rename-before-replace strategy", `ren "%TARGET%" "%TARGET_NAME%.old"`},
|
||||
{"copy after rename", `copy /Y "%SOURCE_EXE%" "%TARGET%"`},
|
||||
{"restore on copy failure", `ren "%TARGET_NAME%.old" "%TARGET_NAME%"`},
|
||||
{"direct move fallback", `call :log rename strategy failed, trying direct move`},
|
||||
{"exponential backoff tier 1", `if !RETRY! GEQ 3 set /a WAIT=2`},
|
||||
{"exponential backoff tier 2", `if !RETRY! GEQ 6 set /a WAIT=3`},
|
||||
{"exponential backoff tier 3", `if !RETRY! GEQ 9 set /a WAIT=5`},
|
||||
{"retry limit 15", `if !RETRY! LSS 15`},
|
||||
{"cleanup old file", `del /F /Q "%TARGET%.old"`},
|
||||
}
|
||||
for _, fix := range win10Fixes {
|
||||
if !strings.Contains(script, fix.token) {
|
||||
t.Errorf("Win10 fix missing [%s]: expected token: %s", fix.desc, fix.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
175
internal/app/sql_split.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package app
|
||||
|
||||
import "strings"
|
||||
|
||||
// splitSQLStatements 按分号拆分 SQL 文本为独立语句。
|
||||
// 正确处理单引号/双引号/反引号字符串、行注释(-- / #)、块注释(/* */)和
|
||||
// PostgreSQL/Kingbase 的 $$...$$ dollar-quoting,避免在这些上下文中错误拆分。
|
||||
// 同时支持 SQL 标准的转义单引号(两个连续单引号 '' 表示字面量引号)。
|
||||
func splitSQLStatements(sql string) []string {
|
||||
text := strings.ReplaceAll(sql, "\r\n", "\n")
|
||||
var statements []string
|
||||
|
||||
var cur strings.Builder
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
inBacktick := false
|
||||
escaped := false
|
||||
inLineComment := false
|
||||
inBlockComment := false
|
||||
var dollarTag string // postgres/kingbase: $$...$$ or $tag$...$tag$
|
||||
|
||||
push := func() {
|
||||
s := strings.TrimSpace(cur.String())
|
||||
if s != "" {
|
||||
statements = append(statements, s)
|
||||
}
|
||||
cur.Reset()
|
||||
}
|
||||
|
||||
for i := 0; i < len(text); i++ {
|
||||
ch := text[i]
|
||||
next := byte(0)
|
||||
if i+1 < len(text) {
|
||||
next = text[i+1]
|
||||
}
|
||||
|
||||
// 行注释
|
||||
if inLineComment {
|
||||
if ch == '\n' {
|
||||
inLineComment = false
|
||||
}
|
||||
cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// 块注释
|
||||
if inBlockComment {
|
||||
cur.WriteByte(ch)
|
||||
if ch == '*' && next == '/' {
|
||||
cur.WriteByte('/')
|
||||
i++
|
||||
inBlockComment = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Dollar-quoting
|
||||
if dollarTag != "" {
|
||||
if strings.HasPrefix(text[i:], dollarTag) {
|
||||
cur.WriteString(dollarTag)
|
||||
i += len(dollarTag) - 1
|
||||
dollarTag = ""
|
||||
} else {
|
||||
cur.WriteByte(ch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 转义字符(反斜杠转义,MySQL 风格)
|
||||
if escaped {
|
||||
escaped = false
|
||||
cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if (inSingle || inDouble) && ch == '\\' {
|
||||
escaped = true
|
||||
cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// 字符串开闭
|
||||
if !inDouble && !inBacktick && ch == '\'' {
|
||||
if inSingle && next == '\'' {
|
||||
// SQL 标准转义:两个连续单引号 '' 表示字面量引号,保持在引号内
|
||||
cur.WriteByte(ch)
|
||||
cur.WriteByte(next)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
inSingle = !inSingle
|
||||
cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if !inSingle && !inBacktick && ch == '"' {
|
||||
inDouble = !inDouble
|
||||
cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if !inSingle && !inDouble && ch == '`' {
|
||||
inBacktick = !inBacktick
|
||||
cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// 在引号/反引号内部不做任何判断
|
||||
if inSingle || inDouble || inBacktick {
|
||||
cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// 行注释开始
|
||||
if ch == '-' && next == '-' {
|
||||
inLineComment = true
|
||||
cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if ch == '#' {
|
||||
inLineComment = true
|
||||
cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// 块注释开始
|
||||
if ch == '/' && next == '*' {
|
||||
inBlockComment = true
|
||||
cur.WriteString("/*")
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Dollar-quoting 开始
|
||||
if ch == '$' {
|
||||
if tag := parseSQLDollarTag(text[i:]); tag != "" {
|
||||
dollarTag = tag
|
||||
cur.WriteString(tag)
|
||||
i += len(tag) - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 分号分隔(支持全角分号";")
|
||||
if ch == ';' {
|
||||
push()
|
||||
continue
|
||||
}
|
||||
// 全角分号 UTF-8 序列: 0xEF 0xBC 0x9B
|
||||
if ch == 0xEF && i+2 < len(text) && text[i+1] == 0xBC && text[i+2] == 0x9B {
|
||||
push()
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
cur.WriteByte(ch)
|
||||
}
|
||||
|
||||
push()
|
||||
return statements
|
||||
}
|
||||
|
||||
// parseSQLDollarTag 解析 PostgreSQL/Kingbase 的 dollar-quoting 标签。
|
||||
func parseSQLDollarTag(s string) string {
|
||||
if len(s) < 2 || s[0] != '$' {
|
||||
return ""
|
||||
}
|
||||
for i := 1; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c == '$' {
|
||||
return s[:i+1]
|
||||
}
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
209
internal/app/sql_split_stream.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sqlStreamSplitter 是一个流式 SQL 语句拆分器,适用于处理大文件。
|
||||
// 调用方通过 Feed(chunk) 逐块喂入数据,通过 Flush() 获取最后一条残余语句。
|
||||
// 内部维护与 splitSQLStatements 完全一致的状态机逻辑。
|
||||
type sqlStreamSplitter struct {
|
||||
cur strings.Builder
|
||||
inSingle bool
|
||||
inDouble bool
|
||||
inBacktick bool
|
||||
escaped bool
|
||||
inLineComment bool
|
||||
inBlockComment bool
|
||||
dollarTag string
|
||||
}
|
||||
|
||||
// Feed 将一个 chunk 喂入拆分器,返回在此 chunk 中完成的 SQL 语句列表。
|
||||
func (s *sqlStreamSplitter) Feed(chunk []byte) []string {
|
||||
var statements []string
|
||||
text := string(chunk)
|
||||
|
||||
for i := 0; i < len(text); i++ {
|
||||
ch := text[i]
|
||||
next := byte(0)
|
||||
if i+1 < len(text) {
|
||||
next = text[i+1]
|
||||
}
|
||||
|
||||
// 行注释
|
||||
if s.inLineComment {
|
||||
if ch == '\n' {
|
||||
s.inLineComment = false
|
||||
}
|
||||
s.cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// 块注释
|
||||
if s.inBlockComment {
|
||||
s.cur.WriteByte(ch)
|
||||
if ch == '*' && next == '/' {
|
||||
s.cur.WriteByte('/')
|
||||
i++
|
||||
s.inBlockComment = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Dollar-quoting
|
||||
if s.dollarTag != "" {
|
||||
if strings.HasPrefix(text[i:], s.dollarTag) {
|
||||
s.cur.WriteString(s.dollarTag)
|
||||
i += len(s.dollarTag) - 1
|
||||
s.dollarTag = ""
|
||||
} else {
|
||||
s.cur.WriteByte(ch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 转义字符
|
||||
if s.escaped {
|
||||
s.escaped = false
|
||||
s.cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if (s.inSingle || s.inDouble) && ch == '\\' {
|
||||
s.escaped = true
|
||||
s.cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// 字符串开闭
|
||||
if !s.inDouble && !s.inBacktick && ch == '\'' {
|
||||
if s.inSingle && next == '\'' {
|
||||
// SQL 标准转义:两个连续单引号
|
||||
s.cur.WriteByte(ch)
|
||||
s.cur.WriteByte(next)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
s.inSingle = !s.inSingle
|
||||
s.cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if !s.inSingle && !s.inBacktick && ch == '"' {
|
||||
s.inDouble = !s.inDouble
|
||||
s.cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if !s.inSingle && !s.inDouble && ch == '`' {
|
||||
s.inBacktick = !s.inBacktick
|
||||
s.cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// 在引号/反引号内部不做任何判断
|
||||
if s.inSingle || s.inDouble || s.inBacktick {
|
||||
s.cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// 行注释开始
|
||||
if ch == '-' && next == '-' {
|
||||
s.inLineComment = true
|
||||
s.cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if ch == '#' {
|
||||
s.inLineComment = true
|
||||
s.cur.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// 块注释开始
|
||||
if ch == '/' && next == '*' {
|
||||
s.inBlockComment = true
|
||||
s.cur.WriteString("/*")
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Dollar-quoting 开始
|
||||
if ch == '$' {
|
||||
if tag := parseSQLDollarTag(text[i:]); tag != "" {
|
||||
s.dollarTag = tag
|
||||
s.cur.WriteString(tag)
|
||||
i += len(tag) - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 分号分隔
|
||||
if ch == ';' {
|
||||
stmt := strings.TrimSpace(s.cur.String())
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
s.cur.Reset()
|
||||
continue
|
||||
}
|
||||
// 全角分号
|
||||
if ch == 0xEF && i+2 < len(text) && text[i+1] == 0xBC && text[i+2] == 0x9B {
|
||||
stmt := strings.TrimSpace(s.cur.String())
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
s.cur.Reset()
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
s.cur.WriteByte(ch)
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
// Flush 返回缓冲区中剩余的不完整语句(文件结束时调用)。
|
||||
func (s *sqlStreamSplitter) Flush() string {
|
||||
stmt := strings.TrimSpace(s.cur.String())
|
||||
s.cur.Reset()
|
||||
return stmt
|
||||
}
|
||||
|
||||
// streamSQLFile 从 reader 中流式读取 SQL 并逐条回调。
|
||||
// onStatement 返回 error 时停止读取并返回该 error。
|
||||
// 返回总处理语句数和可能的错误。
|
||||
func streamSQLFile(reader io.Reader, onStatement func(index int, stmt string) error) (int, error) {
|
||||
splitter := &sqlStreamSplitter{}
|
||||
scanner := bufio.NewScanner(reader)
|
||||
// 设置最大 token 为 4MB,处理超长单行
|
||||
const maxLineSize = 4 * 1024 * 1024
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
|
||||
|
||||
count := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
// 保持换行符,因为行注释依赖 \n 来结束
|
||||
lineWithNewline := append(line, '\n')
|
||||
stmts := splitter.Feed(lineWithNewline)
|
||||
for _, stmt := range stmts {
|
||||
if err := onStatement(count, stmt); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return count, err
|
||||
}
|
||||
|
||||
// 处理文件末尾不以分号结尾的最后一条语句
|
||||
if last := splitter.Flush(); last != "" {
|
||||
if err := onStatement(count, last); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
113
internal/app/sql_split_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitSQLStatements_BasicSplit(t *testing.T) {
|
||||
input := "SELECT 1; SELECT 2; SELECT 3"
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{"SELECT 1", "SELECT 2", "SELECT 3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_QuotedSemicolon(t *testing.T) {
|
||||
input := `SELECT 'hello;world'; SELECT 2`
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{`SELECT 'hello;world'`, "SELECT 2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_LineComment(t *testing.T) {
|
||||
input := "SELECT 1; -- this is a comment;\nSELECT 2"
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{"SELECT 1", "-- this is a comment;\nSELECT 2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_BlockComment(t *testing.T) {
|
||||
input := "SELECT /* ; */ 1; SELECT 2"
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{"SELECT /* ; */ 1", "SELECT 2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_EmptyInput(t *testing.T) {
|
||||
got := splitSQLStatements("")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("splitSQLStatements(\"\") = %v, want empty slice", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_SingleStatement(t *testing.T) {
|
||||
input := "SELECT * FROM users WHERE id = 1"
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{"SELECT * FROM users WHERE id = 1"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_DollarQuoting(t *testing.T) {
|
||||
input := "SELECT $tag$hello;world$tag$; SELECT 2"
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{"SELECT $tag$hello;world$tag$", "SELECT 2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_FullWidthSemicolon(t *testing.T) {
|
||||
input := "SELECT 1;SELECT 2"
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{"SELECT 1", "SELECT 2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_Backtick(t *testing.T) {
|
||||
input := "SELECT `col;name` FROM t; SELECT 2"
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{"SELECT `col;name` FROM t", "SELECT 2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_TrailingSemicolon(t *testing.T) {
|
||||
input := "SELECT 1; SELECT 2;"
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{"SELECT 1", "SELECT 2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_SQLEscapedQuote(t *testing.T) {
|
||||
input := "SELECT 'it''s a test'; SELECT 2"
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{"SELECT 'it''s a test'", "SELECT 2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLStatements_SQLEscapedQuoteMultiple(t *testing.T) {
|
||||
input := "INSERT INTO t VALUES ('O''Brien', 'it''s OK'); SELECT 1"
|
||||
got := splitSQLStatements(input)
|
||||
want := []string{"INSERT INTO t VALUES ('O''Brien', 'it''s OK')", "SELECT 1"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package connection
|
||||
|
||||
// SSHConfig holds SSH connection details
|
||||
// SSHConfig 存储 SSH 隧道连接配置。
|
||||
type SSHConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
@@ -9,7 +9,7 @@ type SSHConfig struct {
|
||||
KeyPath string `json:"keyPath"`
|
||||
}
|
||||
|
||||
// ProxyConfig holds proxy connection details
|
||||
// ProxyConfig 存储代理连接配置。
|
||||
type ProxyConfig struct {
|
||||
Type string `json:"type"` // socks5 | http
|
||||
Host string `json:"host"`
|
||||
@@ -18,7 +18,7 @@ type ProxyConfig struct {
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPTunnelConfig holds independent HTTP CONNECT tunnel details
|
||||
// HTTPTunnelConfig 存储 HTTP CONNECT 隧道配置。
|
||||
type HTTPTunnelConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
@@ -26,7 +26,7 @@ type HTTPTunnelConfig struct {
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// ConnectionConfig holds database connection details including SSH
|
||||
// ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。
|
||||
type ConnectionConfig struct {
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
@@ -63,7 +63,13 @@ type ConnectionConfig struct {
|
||||
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
|
||||
}
|
||||
|
||||
// QueryResult is the standard response format for Wails methods
|
||||
// ResultSetData 表示一个查询结果集(行 + 列名),用于多结果集场景。
|
||||
type ResultSetData struct {
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
Columns []string `json:"columns"`
|
||||
}
|
||||
|
||||
// QueryResult 是 Wails 绑定方法的统一响应格式,前端通过此结构体接收后端结果。
|
||||
type QueryResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
@@ -72,7 +78,7 @@ type QueryResult struct {
|
||||
QueryID string `json:"queryId,omitempty"` // Unique ID for query cancellation
|
||||
}
|
||||
|
||||
// ColumnDefinition represents a table column
|
||||
// ColumnDefinition 描述表的一个列定义。
|
||||
type ColumnDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
@@ -83,7 +89,7 @@ type ColumnDefinition struct {
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// IndexDefinition represents a table index
|
||||
// IndexDefinition 描述表的一个索引定义。
|
||||
type IndexDefinition struct {
|
||||
Name string `json:"name"`
|
||||
ColumnName string `json:"columnName"`
|
||||
@@ -93,7 +99,7 @@ type IndexDefinition struct {
|
||||
SubPart int `json:"subPart,omitempty"`
|
||||
}
|
||||
|
||||
// ForeignKeyDefinition represents a foreign key
|
||||
// ForeignKeyDefinition 描述表的一个外键定义。
|
||||
type ForeignKeyDefinition struct {
|
||||
Name string `json:"name"`
|
||||
ColumnName string `json:"columnName"`
|
||||
@@ -102,7 +108,7 @@ type ForeignKeyDefinition struct {
|
||||
ConstraintName string `json:"constraintName"`
|
||||
}
|
||||
|
||||
// TriggerDefinition represents a trigger
|
||||
// TriggerDefinition 描述表的一个触发器定义。
|
||||
type TriggerDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Timing string `json:"timing"` // BEFORE/AFTER
|
||||
@@ -110,26 +116,27 @@ type TriggerDefinition struct {
|
||||
Statement string `json:"statement"`
|
||||
}
|
||||
|
||||
// ColumnDefinitionWithTable represents a column with its table name (for search/autocomplete)
|
||||
// ColumnDefinitionWithTable 带有表名标识的列定义,用于跨表搜索和 SQL 自动补全。
|
||||
type ColumnDefinitionWithTable struct {
|
||||
TableName string `json:"tableName"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// UpdateRow represents a row update with keys (WHERE) and values (SET)
|
||||
// UpdateRow 表示一行更新操作,Keys 为 WHERE 条件,Values 为 SET 值。
|
||||
type UpdateRow struct {
|
||||
Keys map[string]interface{} `json:"keys"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
}
|
||||
|
||||
// ChangeSet represents a batch of changes
|
||||
// ChangeSet 表示一组批量变更,包含新增、修改和删除操作。
|
||||
type ChangeSet struct {
|
||||
Inserts []map[string]interface{} `json:"inserts"`
|
||||
Updates []UpdateRow `json:"updates"`
|
||||
Deletes []map[string]interface{} `json:"deletes"`
|
||||
}
|
||||
|
||||
// MongoMemberInfo 描述 MongoDB 副本集成员的信息。
|
||||
type MongoMemberInfo struct {
|
||||
Host string `json:"host"`
|
||||
Role string `json:"role"`
|
||||
|
||||
@@ -271,7 +271,7 @@ func (c *ClickHouseDB) Close() error {
|
||||
|
||||
func (c *ClickHouseDB) Ping() error {
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := c.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -284,7 +284,7 @@ func (c *ClickHouseDB) Ping() error {
|
||||
|
||||
func (c *ClickHouseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if c.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := c.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -296,7 +296,7 @@ func (c *ClickHouseDB) QueryContext(ctx context.Context, query string) ([]map[st
|
||||
|
||||
func (c *ClickHouseDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if c.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := c.conn.Query(query)
|
||||
if err != nil {
|
||||
@@ -308,7 +308,7 @@ func (c *ClickHouseDB) Query(query string) ([]map[string]interface{}, []string,
|
||||
|
||||
func (c *ClickHouseDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if c.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := c.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -319,7 +319,7 @@ func (c *ClickHouseDB) ExecContext(ctx context.Context, query string) (int64, er
|
||||
|
||||
func (c *ClickHouseDB) Exec(query string) (int64, error) {
|
||||
if c.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := c.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -404,7 +404,7 @@ func (c *ClickHouseDB) GetCreateStatement(dbName, tableName string) (string, err
|
||||
return "", err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
return "", fmt.Errorf("未找到建表语句")
|
||||
}
|
||||
row := data[0]
|
||||
if val, ok := getClickHouseValueFromRow(row, "statement", "create_statement", "sql", "query"); ok {
|
||||
@@ -427,7 +427,7 @@ func (c *ClickHouseDB) GetCreateStatement(dbName, tableName string) (string, err
|
||||
if longest != "" {
|
||||
return longest, nil
|
||||
}
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
return "", fmt.Errorf("未找到建表语句")
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
@@ -582,7 +582,7 @@ func (c *ClickHouseDB) GetTriggers(dbName, tableName string) ([]connection.Trigg
|
||||
func (c *ClickHouseDB) resolveDatabaseAndTable(dbName, tableName string) (string, string, error) {
|
||||
rawTable := strings.TrimSpace(tableName)
|
||||
if rawTable == "" {
|
||||
return "", "", fmt.Errorf("table name required")
|
||||
return "", "", fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
resolvedDB := strings.TrimSpace(dbName)
|
||||
@@ -603,7 +603,7 @@ func (c *ClickHouseDB) resolveDatabaseAndTable(dbName, tableName string) (string
|
||||
resolvedDB = defaultClickHouseDatabase
|
||||
}
|
||||
if resolvedTable == "" {
|
||||
return "", "", fmt.Errorf("table name required")
|
||||
return "", "", fmt.Errorf("表名不能为空")
|
||||
}
|
||||
return resolvedDB, resolvedTable, nil
|
||||
}
|
||||
@@ -682,7 +682,7 @@ func isClickHouseTruthy(value interface{}) bool {
|
||||
|
||||
func (c *ClickHouseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
database, table, err := c.resolveDatabaseAndTable(c.database, tableName)
|
||||
@@ -723,7 +723,7 @@ func (c *ClickHouseDB) ApplyChanges(tableName string, changes connection.ChangeS
|
||||
continue
|
||||
}
|
||||
if _, err := c.conn.Exec(query); err != nil {
|
||||
return fmt.Errorf("insert error: %v; sql=%s", err, query)
|
||||
return fmt.Errorf("插入失败:%v; sql=%s", err, query)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -47,7 +47,7 @@ func (c *CustomDB) Close() error {
|
||||
|
||||
func (c *CustomDB) Ping() error {
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := c.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -60,7 +60,7 @@ func (c *CustomDB) Ping() error {
|
||||
|
||||
func (c *CustomDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if c.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := c.conn.QueryContext(ctx, query)
|
||||
@@ -74,7 +74,7 @@ func (c *CustomDB) QueryContext(ctx context.Context, query string) ([]map[string
|
||||
|
||||
func (c *CustomDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if c.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := c.conn.Query(query)
|
||||
@@ -87,7 +87,7 @@ func (c *CustomDB) Query(query string) ([]map[string]interface{}, []string, erro
|
||||
|
||||
func (c *CustomDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if c.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := c.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -98,7 +98,7 @@ func (c *CustomDB) ExecContext(ctx context.Context, query string) (int64, error)
|
||||
|
||||
func (c *CustomDB) Exec(query string) (int64, error) {
|
||||
if c.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := c.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -249,7 +249,7 @@ func (c *CustomDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
||||
|
||||
func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := c.conn.Begin()
|
||||
@@ -321,7 +321,7 @@ func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,12 +349,12 @@ func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ func (d *DamengDB) Close() error {
|
||||
|
||||
func (d *DamengDB) Ping() error {
|
||||
if d.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := d.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -156,7 +156,7 @@ func (d *DamengDB) Ping() error {
|
||||
|
||||
func (d *DamengDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if d.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := d.conn.QueryContext(ctx, query)
|
||||
@@ -170,7 +170,7 @@ func (d *DamengDB) QueryContext(ctx context.Context, query string) ([]map[string
|
||||
|
||||
func (d *DamengDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if d.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := d.conn.Query(query)
|
||||
@@ -183,7 +183,7 @@ func (d *DamengDB) Query(query string) ([]map[string]interface{}, []string, erro
|
||||
|
||||
func (d *DamengDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if d.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := d.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -194,7 +194,7 @@ func (d *DamengDB) ExecContext(ctx context.Context, query string) (int64, error)
|
||||
|
||||
func (d *DamengDB) Exec(query string) (int64, error) {
|
||||
if d.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := d.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -260,7 +260,7 @@ func (d *DamengDB) GetCreateStatement(dbName, tableName string) (string, error)
|
||||
return fmt.Sprintf("%v", val), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
return "", fmt.Errorf("未找到建表语句")
|
||||
}
|
||||
|
||||
func (d *DamengDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
@@ -390,7 +390,7 @@ func (d *DamengDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
||||
|
||||
func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if d.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := d.conn.Begin()
|
||||
@@ -438,7 +438,7 @@ func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,12 +466,12 @@ func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +495,7 @@ func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,15 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/logger"
|
||||
)
|
||||
|
||||
var damengDatabaseQueries = []string{
|
||||
// 优先使用达梦原生系统表
|
||||
"SELECT DISTINCT OBJECT_NAME AS DATABASE_NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND OBJECT_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','__RECYCLE_USER__') ORDER BY OBJECT_NAME",
|
||||
"SELECT SCHEMA_NAME AS DATABASE_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','INFORMATION_SCHEMA') ORDER BY SCHEMA_NAME",
|
||||
// Oracle 兼容层
|
||||
"SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS DATABASE_NAME FROM DUAL",
|
||||
"SELECT SYS_CONTEXT('USERENV', 'CURRENT_USER') AS DATABASE_NAME FROM DUAL",
|
||||
"SELECT USERNAME AS DATABASE_NAME FROM USER_USERS",
|
||||
@@ -24,12 +30,14 @@ func collectDamengDatabaseNames(query damengQueryFunc) ([]string, error) {
|
||||
dbs := make([]string, 0, 64)
|
||||
var lastErr error
|
||||
|
||||
for _, q := range damengDatabaseQueries {
|
||||
for idx, q := range damengDatabaseQueries {
|
||||
data, _, err := query(q)
|
||||
if err != nil {
|
||||
logger.Warnf("达梦 GetDatabases 查询[%d]失败:%v(SQL: %.80s…)", idx, err, q)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
newCount := 0
|
||||
for _, row := range data {
|
||||
name := getDamengRowString(row,
|
||||
"DATABASE_NAME",
|
||||
@@ -58,10 +66,14 @@ func collectDamengDatabaseNames(query damengQueryFunc) ([]string, error) {
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
dbs = append(dbs, name)
|
||||
newCount++
|
||||
}
|
||||
logger.Infof("达梦 GetDatabases 查询[%d]成功:返回 %d 行,新增 %d 条(SQL: %.80s…)", idx, len(data), newCount, q)
|
||||
}
|
||||
|
||||
logger.Infof("达梦 GetDatabases 最终结果:共 %d 条数据库/schema", len(dbs))
|
||||
if len(dbs) == 0 && lastErr != nil {
|
||||
logger.Warnf("达梦 GetDatabases 所有查询均失败,返回最后错误:%v", lastErr)
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
|
||||
@@ -2,27 +2,58 @@ package db
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Database 定义了统一的数据源访问接口。
|
||||
// 所有数据库驱动(MySQL、PostgreSQL、Oracle 等)均需实现此接口。
|
||||
// 方法调用方可通过 NewDatabase 工厂函数获取对应驱动的实例。
|
||||
type Database interface {
|
||||
// Connect 根据连接配置建立数据库连接。
|
||||
Connect(config connection.ConnectionConfig) error
|
||||
// Close 关闭数据库连接并释放底层资源。
|
||||
Close() error
|
||||
// Ping 测试连接是否仍然可用。
|
||||
Ping() error
|
||||
// Query 执行查询语句,返回结果行(列名→值映射)和列名列表。
|
||||
Query(query string) ([]map[string]interface{}, []string, error)
|
||||
// Exec 执行非查询语句(INSERT/UPDATE/DELETE 等),返回受影响行数。
|
||||
Exec(query string) (int64, error)
|
||||
// GetDatabases 返回当前连接可访问的数据库列表。
|
||||
GetDatabases() ([]string, error)
|
||||
// GetTables 返回指定数据库下的表列表。
|
||||
GetTables(dbName string) ([]string, error)
|
||||
// GetCreateStatement 返回指定表的建表 DDL 语句。
|
||||
GetCreateStatement(dbName, tableName string) (string, error)
|
||||
// GetColumns 返回指定表的列定义列表。
|
||||
GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error)
|
||||
// GetAllColumns 返回指定数据库下所有表的列定义(含表名标识)。
|
||||
GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error)
|
||||
// GetIndexes 返回指定表的索引定义列表。
|
||||
GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error)
|
||||
// GetForeignKeys 返回指定表的外键定义列表。
|
||||
GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error)
|
||||
// GetTriggers 返回指定表的触发器定义列表。
|
||||
GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error)
|
||||
}
|
||||
|
||||
// MultiResultQuerier 是可选接口,支持多结果集的驱动实现此接口。
|
||||
// 执行可能包含多条 SQL 语句的查询,返回所有结果集。
|
||||
type MultiResultQuerier interface {
|
||||
QueryMulti(query string) ([]connection.ResultSetData, error)
|
||||
}
|
||||
|
||||
// MultiResultQuerierContext 是带 context 的多结果集查询接口。
|
||||
type MultiResultQuerierContext interface {
|
||||
QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error)
|
||||
}
|
||||
|
||||
// BatchApplier 定义了批量变更提交接口。
|
||||
// 支持批量编辑的驱动实现此接口,用于一次性提交前端 DataGrid 中的增删改操作。
|
||||
type BatchApplier interface {
|
||||
// ApplyChanges 将一组变更(新增、修改、删除)批量提交到指定表。
|
||||
ApplyChanges(tableName string, changes connection.ChangeSet) error
|
||||
}
|
||||
|
||||
@@ -72,7 +103,9 @@ func normalizeDatabaseType(dbType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Factory
|
||||
// NewDatabase 根据数据库类型创建对应的 Database 实例。
|
||||
// dbType 为数据库类型标识(如 "mysql"、"postgres"、"oracle" 等),大小写不敏感。
|
||||
// 如果指定类型未注册,返回错误。
|
||||
func NewDatabase(dbType string) (Database, error) {
|
||||
normalized := normalizeDatabaseType(dbType)
|
||||
if normalized == "" {
|
||||
@@ -80,7 +113,7 @@ func NewDatabase(dbType string) (Database, error) {
|
||||
}
|
||||
factory, ok := databaseFactories[normalized]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
||||
return nil, fmt.Errorf("不支持的数据库类型:%s", dbType)
|
||||
}
|
||||
return factory(), nil
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func (d *DirosDB) getDSN(config connection.ConnectionConfig) (string, error) {
|
||||
tlsMode := resolveMySQLTLSMode(config)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
|
||||
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s&multiStatements=true",
|
||||
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode),
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// coreBuiltinDrivers 是始终内置可用的核心驱动,无需额外安装即可使用。
|
||||
var coreBuiltinDrivers = map[string]struct{}{
|
||||
"mysql": {},
|
||||
"redis": {},
|
||||
@@ -91,6 +92,8 @@ func driverDisplayName(driverType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// IsOptionalGoDriver 返回指定驱动类型是否为可选的纯 Go 驱动。
|
||||
// 可选驱动需要用户在驱动管理界面点击“安装启用”后才能使用。
|
||||
func IsOptionalGoDriver(driverType string) bool {
|
||||
_, ok := optionalGoDrivers[normalizeRuntimeDriverType(driverType)]
|
||||
return ok
|
||||
@@ -100,6 +103,7 @@ func IsOptionalGoDriverBuildIncluded(driverType string) bool {
|
||||
return optionalGoDriverBuildIncluded(normalizeRuntimeDriverType(driverType))
|
||||
}
|
||||
|
||||
// IsBuiltinDriver 返回指定驱动类型是否为核心内置驱动(始终可用,无需安装)。
|
||||
func IsBuiltinDriver(driverType string) bool {
|
||||
_, ok := coreBuiltinDrivers[normalizeRuntimeDriverType(driverType)]
|
||||
return ok
|
||||
@@ -146,6 +150,8 @@ func currentExternalDriverDownloadDirectory() string {
|
||||
return defaultExternalDriverDownloadDirectory()
|
||||
}
|
||||
|
||||
// SetExternalDriverDownloadDirectory 设置可选驱动的下载存储目录。
|
||||
// 如果路径解析失败,会回退到默认目录(~/.gonavi/drivers)。
|
||||
func SetExternalDriverDownloadDirectory(downloadDir string) {
|
||||
root, err := resolveExternalDriverRoot(downloadDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -55,7 +55,7 @@ func (d *DuckDB) Close() error {
|
||||
|
||||
func (d *DuckDB) Ping() error {
|
||||
if d.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := d.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -68,7 +68,7 @@ func (d *DuckDB) Ping() error {
|
||||
|
||||
func (d *DuckDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if d.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := d.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -80,7 +80,7 @@ func (d *DuckDB) QueryContext(ctx context.Context, query string) ([]map[string]i
|
||||
|
||||
func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if d.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := d.conn.Query(query)
|
||||
if err != nil {
|
||||
@@ -92,7 +92,7 @@ func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error)
|
||||
|
||||
func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if d.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := d.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -103,7 +103,7 @@ func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
|
||||
func (d *DuckDB) Exec(query string) (int64, error) {
|
||||
if d.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := d.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -174,7 +174,7 @@ ORDER BY table_schema, table_name`
|
||||
func (d *DuckDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
schema, pureTable := normalizeDuckDBSchemaAndTable(dbName, tableName)
|
||||
if pureTable == "" {
|
||||
return "", fmt.Errorf("table name required")
|
||||
return "", fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
escapedTable := escapeDuckDBLiteral(pureTable)
|
||||
@@ -204,13 +204,13 @@ func (d *DuckDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
return "", fmt.Errorf("未找到建表语句")
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
schema, pureTable := normalizeDuckDBSchemaAndTable(dbName, tableName)
|
||||
if pureTable == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
@@ -303,7 +303,7 @@ func (d *DuckDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefi
|
||||
|
||||
func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if d.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := d.conn.Begin()
|
||||
@@ -346,7 +346,7 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,12 +367,12 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ func (h *HighGoDB) Close() error {
|
||||
|
||||
func (h *HighGoDB) Ping() error {
|
||||
if h.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := h.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -138,7 +138,7 @@ func (h *HighGoDB) Ping() error {
|
||||
|
||||
func (h *HighGoDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if h.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := h.conn.QueryContext(ctx, query)
|
||||
@@ -152,7 +152,7 @@ func (h *HighGoDB) QueryContext(ctx context.Context, query string) ([]map[string
|
||||
|
||||
func (h *HighGoDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if h.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := h.conn.Query(query)
|
||||
@@ -165,7 +165,7 @@ func (h *HighGoDB) Query(query string) ([]map[string]interface{}, []string, erro
|
||||
|
||||
func (h *HighGoDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if h.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := h.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -176,7 +176,7 @@ func (h *HighGoDB) ExecContext(ctx context.Context, query string) (int64, error)
|
||||
|
||||
func (h *HighGoDB) Exec(query string) (int64, error) {
|
||||
if h.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := h.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -232,7 +232,7 @@ func (h *HighGoDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefi
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -302,7 +302,7 @@ func (h *HighGoDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefin
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -407,7 +407,7 @@ func (h *HighGoDB) GetForeignKeys(dbName, tableName string) ([]connection.Foreig
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -467,7 +467,7 @@ func (h *HighGoDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -531,7 +531,7 @@ ORDER BY table_schema, table_name, ordinal_position`
|
||||
|
||||
func (h *HighGoDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if h.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := h.conn.Begin()
|
||||
@@ -579,7 +579,7 @@ func (h *HighGoDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,12 +607,12 @@ func (h *HighGoDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,7 +636,7 @@ func (h *HighGoDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,3 +162,45 @@ func findKingbaseQualifiedSeparator(raw string) int {
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// buildKingbaseSearchPathCommon 统一构建 Kingbase search_path。
|
||||
// 返回 search_path SQL 片段和规范化后的 schema 列表(用于调试/扩展)。
|
||||
func buildKingbaseSearchPathCommon(rawSchemas []string) (string, []string) {
|
||||
if len(rawSchemas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(rawSchemas)+1)
|
||||
quotedParts := make([]string, 0, len(rawSchemas)+1)
|
||||
normalizedSchemas := make([]string, 0, len(rawSchemas)+1)
|
||||
|
||||
appendSchema := func(raw string) {
|
||||
cleaned := normalizeKingbaseIdentCommon(raw)
|
||||
if cleaned == "" {
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(cleaned, "public") {
|
||||
cleaned = "public"
|
||||
}
|
||||
key := strings.ToLower(cleaned)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalizedSchemas = append(normalizedSchemas, cleaned)
|
||||
escaped := strings.ReplaceAll(cleaned, `"`, `""`)
|
||||
quotedParts = append(quotedParts, `"`+escaped+`"`)
|
||||
}
|
||||
|
||||
for _, raw := range rawSchemas {
|
||||
appendSchema(raw)
|
||||
}
|
||||
if _, ok := seen["public"]; !ok {
|
||||
appendSchema("public")
|
||||
}
|
||||
|
||||
if len(quotedParts) == 0 {
|
||||
return "", normalizedSchemas
|
||||
}
|
||||
return strings.Join(quotedParts, ", "), normalizedSchemas
|
||||
}
|
||||
|
||||
@@ -50,3 +50,43 @@ func TestSplitKingbaseQualifiedNameCommon(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKingbaseSearchPathCommon(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
wantLen int
|
||||
}{
|
||||
{
|
||||
name: "normal schemas",
|
||||
in: []string{"ldf_server", "public"},
|
||||
want: `"ldf_server", "public"`,
|
||||
wantLen: 2,
|
||||
},
|
||||
{
|
||||
name: "quoted and escaped schemas should not be double quoted",
|
||||
in: []string{`"ldf_server"`, `""bcs_barcode""`, `\"public\"`},
|
||||
want: `"ldf_server", "bcs_barcode", "public"`,
|
||||
wantLen: 3,
|
||||
},
|
||||
{
|
||||
name: "dedupe ignoring case and keep public fallback",
|
||||
in: []string{"LDF_SERVER", "ldf_server", "PUBLIC"},
|
||||
want: `"LDF_SERVER", "public"`,
|
||||
wantLen: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, parts := buildKingbaseSearchPathCommon(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("buildKingbaseSearchPathCommon(%v)=%q,want=%q", tt.in, got, tt.want)
|
||||
}
|
||||
if len(parts) != tt.wantLen {
|
||||
t.Fatalf("buildKingbaseSearchPathCommon(%v) parts=%v, len=%d, wantLen=%d", tt.in, parts, len(parts), tt.wantLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ func (k *KingbaseDB) getSearchPathStr() string {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var schemas []string
|
||||
var rawSchemas []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
@@ -206,17 +206,12 @@ func (k *KingbaseDB) getSearchPathStr() string {
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
// 使用 SQL 标准的双引号包裹标识符
|
||||
escaped := strings.ReplaceAll(name, `"`, `""`)
|
||||
schemas = append(schemas, `"`+escaped+`"`)
|
||||
rawSchemas = append(rawSchemas, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(schemas) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.Join(schemas, ", ")
|
||||
searchPath, _ := buildKingbaseSearchPathCommon(rawSchemas)
|
||||
return searchPath
|
||||
}
|
||||
|
||||
func (k *KingbaseDB) Close() error {
|
||||
@@ -237,7 +232,7 @@ func (k *KingbaseDB) Close() error {
|
||||
|
||||
func (k *KingbaseDB) Ping() error {
|
||||
if k.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := k.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -250,7 +245,7 @@ func (k *KingbaseDB) Ping() error {
|
||||
|
||||
func (k *KingbaseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if k.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := k.conn.QueryContext(ctx, query)
|
||||
@@ -264,7 +259,7 @@ func (k *KingbaseDB) QueryContext(ctx context.Context, query string) ([]map[stri
|
||||
|
||||
func (k *KingbaseDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if k.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := k.conn.Query(query)
|
||||
@@ -277,7 +272,7 @@ func (k *KingbaseDB) Query(query string) ([]map[string]interface{}, []string, er
|
||||
|
||||
func (k *KingbaseDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if k.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := k.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -288,7 +283,7 @@ func (k *KingbaseDB) ExecContext(ctx context.Context, query string) (int64, erro
|
||||
|
||||
func (k *KingbaseDB) Exec(query string) (int64, error) {
|
||||
if k.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := k.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -372,7 +367,7 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
|
||||
}
|
||||
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
// 转义函数:处理单引号,移除双引号
|
||||
@@ -445,7 +440,7 @@ ORDER BY a.attnum`, esc(schema), esc(table))
|
||||
func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection.ColumnDefinition, error) {
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
// 转义函数
|
||||
@@ -529,7 +524,7 @@ func (k *KingbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDef
|
||||
}
|
||||
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
// 转义函数:处理单引号,移除双引号
|
||||
@@ -627,7 +622,7 @@ func (k *KingbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.Fore
|
||||
}
|
||||
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
// 转义函数:处理单引号,移除双引号
|
||||
@@ -709,7 +704,7 @@ func (k *KingbaseDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
|
||||
}
|
||||
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
// 转义函数:处理单引号,移除双引号
|
||||
@@ -752,7 +747,7 @@ func (k *KingbaseDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
|
||||
|
||||
func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if k.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := k.conn.Begin()
|
||||
@@ -763,7 +758,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
|
||||
schema, table := splitKingbaseQualifiedTable(tableName)
|
||||
if table == "" {
|
||||
return fmt.Errorf("table name required")
|
||||
return fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
qualifiedTable := ""
|
||||
@@ -816,7 +811,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||
@@ -845,7 +840,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, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v; sql=%s", err, query)
|
||||
return fmt.Errorf("插入失败:%v; sql=%s", err, query)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ func (m *MariaDB) getDSN(config connection.ConnectionConfig) (string, error) {
|
||||
tlsMode := resolveMySQLTLSMode(config)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
|
||||
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s&multiStatements=true",
|
||||
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode),
|
||||
), nil
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (m *MariaDB) Close() error {
|
||||
|
||||
func (m *MariaDB) Ping() error {
|
||||
if m.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := m.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -84,9 +84,33 @@ func (m *MariaDB) Ping() error {
|
||||
return m.conn.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (m *MariaDB) QueryMulti(query string) ([]connection.ResultSetData, error) {
|
||||
if m.conn == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := m.conn.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMultiRows(rows)
|
||||
}
|
||||
|
||||
func (m *MariaDB) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
|
||||
if m.conn == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := m.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMultiRows(rows)
|
||||
}
|
||||
|
||||
func (m *MariaDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if m.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := m.conn.QueryContext(ctx, query)
|
||||
@@ -100,7 +124,7 @@ func (m *MariaDB) QueryContext(ctx context.Context, query string) ([]map[string]
|
||||
|
||||
func (m *MariaDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if m.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := m.conn.Query(query)
|
||||
@@ -113,7 +137,7 @@ func (m *MariaDB) Query(query string) ([]map[string]interface{}, []string, error
|
||||
|
||||
func (m *MariaDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if m.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := m.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -124,7 +148,7 @@ func (m *MariaDB) ExecContext(ctx context.Context, query string) (int64, error)
|
||||
|
||||
func (m *MariaDB) Exec(query string) (int64, error) {
|
||||
if m.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := m.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -186,7 +210,7 @@ func (m *MariaDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return fmt.Sprintf("%v", val), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
return "", fmt.Errorf("未找到建表语句")
|
||||
}
|
||||
|
||||
func (m *MariaDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
@@ -320,7 +344,7 @@ func (m *MariaDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDef
|
||||
|
||||
func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if m.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := m.conn.Begin()
|
||||
@@ -342,7 +366,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,12 +391,12 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE `%s` SET %s WHERE %s", tableName, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +418,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tableName, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +428,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
func (m *MariaDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
|
||||
if dbName == "" {
|
||||
return nil, fmt.Errorf("database name required for GetAllColumns")
|
||||
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
|
||||
}
|
||||
|
||||
data, _, err := m.Query(query)
|
||||
|
||||
@@ -237,9 +237,6 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
|
||||
params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000))
|
||||
|
||||
authSource := strings.TrimSpace(config.AuthSource)
|
||||
if authSource == "" && strings.TrimSpace(config.Database) != "" {
|
||||
authSource = strings.TrimSpace(config.Database)
|
||||
}
|
||||
if authSource == "" {
|
||||
authSource = "admin"
|
||||
}
|
||||
@@ -480,7 +477,7 @@ func (m *MongoDB) Close() error {
|
||||
|
||||
func (m *MongoDB) Ping() error {
|
||||
if m.client == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := m.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -684,7 +681,7 @@ func buildMembersFromHello(raw bson.M) []connection.MongoMemberInfo {
|
||||
|
||||
func (m *MongoDB) DiscoverMembers() (string, []connection.MongoMemberInfo, error) {
|
||||
if m.client == nil {
|
||||
return "", nil, fmt.Errorf("connection not open")
|
||||
return "", nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
timeout := m.pingTimeout
|
||||
@@ -835,7 +832,7 @@ func extractCollectionFromSQL(sql string) string {
|
||||
|
||||
func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if m.client == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
query = strings.TrimSpace(query)
|
||||
@@ -1079,7 +1076,7 @@ func (m *MongoDB) ExecContext(ctx context.Context, query string) (int64, error)
|
||||
|
||||
func (m *MongoDB) GetDatabases() ([]string, error) {
|
||||
if m.client == nil {
|
||||
return nil, fmt.Errorf("connection not open")
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -1094,7 +1091,7 @@ func (m *MongoDB) GetDatabases() ([]string, error) {
|
||||
|
||||
func (m *MongoDB) GetTables(dbName string) ([]string, error) {
|
||||
if m.client == nil {
|
||||
return nil, fmt.Errorf("connection not open")
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
targetDB := dbName
|
||||
@@ -1130,7 +1127,7 @@ func (m *MongoDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWit
|
||||
// GetIndexes returns indexes for a MongoDB collection
|
||||
func (m *MongoDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
if m.client == nil {
|
||||
return nil, fmt.Errorf("connection not open")
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
targetDB := dbName
|
||||
@@ -1197,7 +1194,7 @@ func (m *MongoDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDef
|
||||
// ApplyChanges implements batch changes for MongoDB
|
||||
func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if m.client == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
@@ -1213,7 +1210,7 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
if _, err := collection.DeleteOne(ctx, filter); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1225,7 +1222,7 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
filter[k] = v
|
||||
}
|
||||
if len(filter) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
updateDoc := bson.M{"$set": bson.M{}}
|
||||
@@ -1234,7 +1231,7 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
}
|
||||
|
||||
if _, err := collection.UpdateOne(ctx, filter, updateDoc); err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1246,7 +1243,7 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
}
|
||||
if len(doc) > 0 {
|
||||
if _, err := collection.InsertOne(ctx, doc); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,9 +238,6 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string {
|
||||
params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000))
|
||||
|
||||
authSource := strings.TrimSpace(config.AuthSource)
|
||||
if authSource == "" && strings.TrimSpace(config.Database) != "" {
|
||||
authSource = strings.TrimSpace(config.Database)
|
||||
}
|
||||
if authSource == "" {
|
||||
authSource = "admin"
|
||||
}
|
||||
@@ -483,7 +480,7 @@ func (m *MongoDBV1) Close() error {
|
||||
|
||||
func (m *MongoDBV1) Ping() error {
|
||||
if m.client == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := m.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -687,7 +684,7 @@ func buildMembersFromHello(raw bson.M) []connection.MongoMemberInfo {
|
||||
|
||||
func (m *MongoDBV1) DiscoverMembers() (string, []connection.MongoMemberInfo, error) {
|
||||
if m.client == nil {
|
||||
return "", nil, fmt.Errorf("connection not open")
|
||||
return "", nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
timeout := m.pingTimeout
|
||||
@@ -838,7 +835,7 @@ func extractCollectionFromSQL(sql string) string {
|
||||
|
||||
func (m *MongoDBV1) queryWithContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if m.client == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
query = strings.TrimSpace(query)
|
||||
@@ -1082,7 +1079,7 @@ func (m *MongoDBV1) ExecContext(ctx context.Context, query string) (int64, error
|
||||
|
||||
func (m *MongoDBV1) GetDatabases() ([]string, error) {
|
||||
if m.client == nil {
|
||||
return nil, fmt.Errorf("connection not open")
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -1097,7 +1094,7 @@ func (m *MongoDBV1) GetDatabases() ([]string, error) {
|
||||
|
||||
func (m *MongoDBV1) GetTables(dbName string) ([]string, error) {
|
||||
if m.client == nil {
|
||||
return nil, fmt.Errorf("connection not open")
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
targetDB := dbName
|
||||
@@ -1133,7 +1130,7 @@ func (m *MongoDBV1) GetAllColumns(dbName string) ([]connection.ColumnDefinitionW
|
||||
// GetIndexes returns indexes for a MongoDB collection
|
||||
func (m *MongoDBV1) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
if m.client == nil {
|
||||
return nil, fmt.Errorf("connection not open")
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
targetDB := dbName
|
||||
@@ -1200,7 +1197,7 @@ func (m *MongoDBV1) GetTriggers(dbName, tableName string) ([]connection.TriggerD
|
||||
// ApplyChanges implements batch changes for MongoDB
|
||||
func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if m.client == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
@@ -1216,7 +1213,7 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
if _, err := collection.DeleteOne(ctx, filter); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1228,7 +1225,7 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
filter[k] = v
|
||||
}
|
||||
if len(filter) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
updateDoc := bson.M{"$set": bson.M{}}
|
||||
@@ -1237,7 +1234,7 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
|
||||
if _, err := collection.UpdateOne(ctx, filter, updateDoc); err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1249,7 +1246,7 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
if len(doc) > 0 {
|
||||
if _, err := collection.InsertOne(ctx, doc); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,7 +429,7 @@ func (m *MySQLAgentDB) ApplyChanges(tableName string, changes connection.ChangeS
|
||||
|
||||
func (m *MySQLAgentDB) requireClient() (*mysqlAgentClient, error) {
|
||||
if m.client == nil {
|
||||
return nil, fmt.Errorf("connection not open")
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
return m.client, nil
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func (m *MySQLDB) getDSN(config connection.ConnectionConfig) (string, error) {
|
||||
tlsMode := resolveMySQLTLSMode(config)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
|
||||
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s&multiStatements=true",
|
||||
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode),
|
||||
), nil
|
||||
}
|
||||
@@ -267,7 +267,7 @@ func (m *MySQLDB) Close() error {
|
||||
|
||||
func (m *MySQLDB) Ping() error {
|
||||
if m.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := m.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -278,9 +278,33 @@ func (m *MySQLDB) Ping() error {
|
||||
return m.conn.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (m *MySQLDB) QueryMulti(query string) ([]connection.ResultSetData, error) {
|
||||
if m.conn == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := m.conn.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMultiRows(rows)
|
||||
}
|
||||
|
||||
func (m *MySQLDB) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
|
||||
if m.conn == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := m.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMultiRows(rows)
|
||||
}
|
||||
|
||||
func (m *MySQLDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if m.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := m.conn.QueryContext(ctx, query)
|
||||
@@ -294,7 +318,7 @@ func (m *MySQLDB) QueryContext(ctx context.Context, query string) ([]map[string]
|
||||
|
||||
func (m *MySQLDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if m.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := m.conn.Query(query)
|
||||
@@ -307,7 +331,7 @@ func (m *MySQLDB) Query(query string) ([]map[string]interface{}, []string, error
|
||||
|
||||
func (m *MySQLDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if m.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := m.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -318,7 +342,7 @@ func (m *MySQLDB) ExecContext(ctx context.Context, query string) (int64, error)
|
||||
|
||||
func (m *MySQLDB) Exec(query string) (int64, error) {
|
||||
if m.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := m.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -380,7 +404,7 @@ func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return fmt.Sprintf("%v", val), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
return "", fmt.Errorf("未找到建表语句")
|
||||
}
|
||||
|
||||
func (m *MySQLDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
@@ -514,7 +538,7 @@ func (m *MySQLDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDef
|
||||
|
||||
func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if m.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
columnTypeMap := m.loadColumnTypeMap(tableName)
|
||||
@@ -539,7 +563,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, strings.Join(wheres, " AND "))
|
||||
res, err := tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("删除未生效:未匹配到任何行")
|
||||
@@ -567,13 +591,13 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE `%s` SET %s WHERE %s", tableName, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||
res, err := tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("更新未生效:未匹配到任何行")
|
||||
@@ -600,7 +624,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
query := fmt.Sprintf("INSERT INTO `%s` () VALUES ()", tableName)
|
||||
res, err := tx.Exec(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("插入未生效:未影响任何行")
|
||||
@@ -611,7 +635,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tableName, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
res, err := tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("插入未生效:未影响任何行")
|
||||
@@ -774,7 +798,7 @@ func formatMySQLDateTime(t time.Time) string {
|
||||
func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
|
||||
if dbName == "" {
|
||||
return nil, fmt.Errorf("database name required for GetAllColumns")
|
||||
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
|
||||
}
|
||||
|
||||
data, _, err := m.Query(query)
|
||||
|
||||
@@ -510,7 +510,7 @@ func (d *OptionalDriverAgentDB) ApplyChanges(tableName string, changes connectio
|
||||
|
||||
func (d *OptionalDriverAgentDB) requireClient() (*optionalDriverAgentClient, error) {
|
||||
if d.client == nil {
|
||||
return nil, fmt.Errorf("connection not open")
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
return d.client, nil
|
||||
}
|
||||
@@ -582,27 +582,8 @@ func (d *OptionalDriverAgentDB) listKingbaseSchemas(ctx context.Context) ([]stri
|
||||
}
|
||||
|
||||
func buildKingbaseSearchPathFromSchemas(schemas []string) string {
|
||||
if len(schemas) == 0 {
|
||||
return ""
|
||||
}
|
||||
seen := make(map[string]struct{}, len(schemas)+1)
|
||||
parts := make([]string, 0, len(schemas)+1)
|
||||
for _, name := range schemas {
|
||||
trimmed := normalizeKingbaseAgentIdent(name)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(trimmed)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
parts = append(parts, quoteKingbaseAgentIdent(trimmed))
|
||||
}
|
||||
if _, ok := seen["public"]; !ok {
|
||||
parts = append(parts, "public")
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
searchPath, _ := buildKingbaseSearchPathCommon(schemas)
|
||||
return searchPath
|
||||
}
|
||||
|
||||
func quoteKingbaseAgentIdent(name string) string {
|
||||
|
||||
@@ -135,7 +135,7 @@ func (o *OracleDB) Close() error {
|
||||
|
||||
func (o *OracleDB) Ping() error {
|
||||
if o.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := o.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -148,7 +148,7 @@ func (o *OracleDB) Ping() error {
|
||||
|
||||
func (o *OracleDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if o.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := o.conn.QueryContext(ctx, query)
|
||||
@@ -162,7 +162,7 @@ func (o *OracleDB) QueryContext(ctx context.Context, query string) ([]map[string
|
||||
|
||||
func (o *OracleDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if o.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := o.conn.Query(query)
|
||||
@@ -175,7 +175,7 @@ func (o *OracleDB) Query(query string) ([]map[string]interface{}, []string, erro
|
||||
|
||||
func (o *OracleDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if o.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := o.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -186,7 +186,7 @@ func (o *OracleDB) ExecContext(ctx context.Context, query string) (int64, error)
|
||||
|
||||
func (o *OracleDB) Exec(query string) (int64, error) {
|
||||
if o.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := o.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -259,7 +259,7 @@ func (o *OracleDB) GetCreateStatement(dbName, tableName string) (string, error)
|
||||
return fmt.Sprintf("%v", val), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
return "", fmt.Errorf("未找到建表语句")
|
||||
}
|
||||
|
||||
func (o *OracleDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
@@ -391,7 +391,7 @@ func (o *OracleDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
||||
|
||||
func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if o.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := o.conn.Begin()
|
||||
@@ -439,7 +439,7 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,12 +467,12 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,7 +496,7 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ func (p *PostgresDB) Close() error {
|
||||
|
||||
func (p *PostgresDB) Ping() error {
|
||||
if p.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := p.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -208,7 +208,7 @@ func (p *PostgresDB) Ping() error {
|
||||
|
||||
func (p *PostgresDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if p.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := p.conn.QueryContext(ctx, query)
|
||||
@@ -222,7 +222,7 @@ func (p *PostgresDB) QueryContext(ctx context.Context, query string) ([]map[stri
|
||||
|
||||
func (p *PostgresDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if p.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := p.conn.Query(query)
|
||||
@@ -235,7 +235,7 @@ func (p *PostgresDB) Query(query string) ([]map[string]interface{}, []string, er
|
||||
|
||||
func (p *PostgresDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if p.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := p.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -246,7 +246,7 @@ func (p *PostgresDB) ExecContext(ctx context.Context, query string) (int64, erro
|
||||
|
||||
func (p *PostgresDB) Exec(query string) (int64, error) {
|
||||
if p.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := p.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -302,7 +302,7 @@ func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -372,7 +372,7 @@ func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]connection.IndexDef
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -478,7 +478,7 @@ func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]connection.Fore
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -538,7 +538,7 @@ func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -602,7 +602,7 @@ ORDER BY table_schema, table_name, ordinal_position`
|
||||
|
||||
func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if p.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := p.conn.Begin()
|
||||
@@ -650,7 +650,7 @@ func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,12 +678,12 @@ func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,7 +707,7 @@ func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func scanRows(rows *sql.Rows) ([]map[string]interface{}, []string, error) {
|
||||
@@ -44,3 +46,38 @@ func scanRows(rows *sql.Rows) ([]map[string]interface{}, []string, error) {
|
||||
}
|
||||
return resultData, columns, nil
|
||||
}
|
||||
|
||||
// scanMultiRows 遍历 sql.Rows 中的所有结果集,将每个结果集作为 ResultSetData 返回。
|
||||
// 利用 rows.NextResultSet() 支持一次 query 返回多个结果集的场景。
|
||||
func scanMultiRows(rows *sql.Rows) ([]connection.ResultSetData, error) {
|
||||
var results []connection.ResultSetData
|
||||
for {
|
||||
data, cols, err := scanRows(rows)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
if data == nil {
|
||||
data = make([]map[string]interface{}, 0)
|
||||
}
|
||||
if cols == nil {
|
||||
cols = []string{}
|
||||
}
|
||||
results = append(results, connection.ResultSetData{
|
||||
Rows: data,
|
||||
Columns: cols,
|
||||
})
|
||||
if !rows.NextResultSet() {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(results) == 0 {
|
||||
results = []connection.ResultSetData{{
|
||||
Rows: make([]map[string]interface{}, 0),
|
||||
Columns: []string{},
|
||||
}}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return results, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ func (s *SQLiteDB) Close() error {
|
||||
|
||||
func (s *SQLiteDB) Ping() error {
|
||||
if s.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := s.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -197,7 +197,7 @@ func (s *SQLiteDB) Ping() error {
|
||||
|
||||
func (s *SQLiteDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if s.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := s.conn.QueryContext(ctx, query)
|
||||
@@ -211,7 +211,7 @@ func (s *SQLiteDB) QueryContext(ctx context.Context, query string) ([]map[string
|
||||
|
||||
func (s *SQLiteDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if s.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := s.conn.Query(query)
|
||||
@@ -224,7 +224,7 @@ func (s *SQLiteDB) Query(query string) ([]map[string]interface{}, []string, erro
|
||||
|
||||
func (s *SQLiteDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if s.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := s.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -235,7 +235,7 @@ func (s *SQLiteDB) ExecContext(ctx context.Context, query string) (int64, error)
|
||||
|
||||
func (s *SQLiteDB) Exec(query string) (int64, error) {
|
||||
if s.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := s.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -275,13 +275,13 @@ func (s *SQLiteDB) GetCreateStatement(dbName, tableName string) (string, error)
|
||||
return fmt.Sprintf("%v", val), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
return "", fmt.Errorf("未找到建表语句")
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
|
||||
@@ -372,7 +372,7 @@ func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefi
|
||||
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
|
||||
@@ -463,7 +463,7 @@ func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefin
|
||||
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
|
||||
@@ -537,7 +537,7 @@ func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.Foreig
|
||||
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
|
||||
@@ -588,7 +588,7 @@ func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
||||
|
||||
func (s *SQLiteDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if s.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := s.conn.Begin()
|
||||
@@ -634,7 +634,7 @@ func (s *SQLiteDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,12 +659,12 @@ func (s *SQLiteDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,7 +686,7 @@ func (s *SQLiteDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ func (s *SqlServerDB) Close() error {
|
||||
|
||||
func (s *SqlServerDB) Ping() error {
|
||||
if s.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := s.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -128,9 +128,33 @@ func (s *SqlServerDB) Ping() error {
|
||||
return s.conn.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (s *SqlServerDB) QueryMulti(query string) ([]connection.ResultSetData, error) {
|
||||
if s.conn == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := s.conn.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMultiRows(rows)
|
||||
}
|
||||
|
||||
func (s *SqlServerDB) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
|
||||
if s.conn == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := s.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMultiRows(rows)
|
||||
}
|
||||
|
||||
func (s *SqlServerDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if s.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := s.conn.QueryContext(ctx, query)
|
||||
@@ -144,7 +168,7 @@ func (s *SqlServerDB) QueryContext(ctx context.Context, query string) ([]map[str
|
||||
|
||||
func (s *SqlServerDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if s.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := s.conn.Query(query)
|
||||
@@ -157,7 +181,7 @@ func (s *SqlServerDB) Query(query string) ([]map[string]interface{}, []string, e
|
||||
|
||||
func (s *SqlServerDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if s.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := s.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -168,7 +192,7 @@ func (s *SqlServerDB) ExecContext(ctx context.Context, query string) (int64, err
|
||||
|
||||
func (s *SqlServerDB) Exec(query string) (int64, error) {
|
||||
if s.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := s.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -236,7 +260,7 @@ func (s *SqlServerDB) GetColumns(dbName, tableName string) ([]connection.ColumnD
|
||||
}
|
||||
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -344,7 +368,7 @@ func (s *SqlServerDB) GetIndexes(dbName, tableName string) ([]connection.IndexDe
|
||||
}
|
||||
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -425,7 +449,7 @@ func (s *SqlServerDB) GetForeignKeys(dbName, tableName string) ([]connection.For
|
||||
}
|
||||
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -483,7 +507,7 @@ func (s *SqlServerDB) GetTriggers(dbName, tableName string) ([]connection.Trigge
|
||||
}
|
||||
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -530,7 +554,7 @@ ORDER BY tr.name`,
|
||||
|
||||
func (s *SqlServerDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if s.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := s.conn.Begin()
|
||||
@@ -573,7 +597,7 @@ func (s *SqlServerDB) ApplyChanges(tableName string, changes connection.ChangeSe
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,12 +625,12 @@ func (s *SqlServerDB) ApplyChanges(tableName string, changes connection.ChangeSe
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,7 +654,7 @@ func (s *SqlServerDB) ApplyChanges(tableName string, changes connection.ChangeSe
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ func (t *TDengineDB) Close() error {
|
||||
|
||||
func (t *TDengineDB) Ping() error {
|
||||
if t.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := t.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -133,7 +133,7 @@ func (t *TDengineDB) Ping() error {
|
||||
|
||||
func (t *TDengineDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if t.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := t.conn.QueryContext(ctx, query)
|
||||
@@ -147,7 +147,7 @@ func (t *TDengineDB) QueryContext(ctx context.Context, query string) ([]map[stri
|
||||
|
||||
func (t *TDengineDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if t.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := t.conn.Query(query)
|
||||
@@ -161,7 +161,7 @@ func (t *TDengineDB) Query(query string) ([]map[string]interface{}, []string, er
|
||||
|
||||
func (t *TDengineDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if t.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := t.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -172,7 +172,7 @@ func (t *TDengineDB) ExecContext(ctx context.Context, query string) (int64, erro
|
||||
|
||||
func (t *TDengineDB) Exec(query string) (int64, error) {
|
||||
if t.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := t.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -274,7 +274,7 @@ func (t *TDengineDB) GetCreateStatement(dbName, tableName string) (string, error
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
return "", fmt.Errorf("未找到建表语句")
|
||||
}
|
||||
|
||||
func (t *TDengineDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
@@ -325,7 +325,7 @@ func (t *TDengineDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
|
||||
|
||||
func (t *TDengineDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return nil, fmt.Errorf("database name required for GetAllColumns")
|
||||
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
|
||||
}
|
||||
|
||||
tables, err := t.GetTables(dbName)
|
||||
@@ -365,10 +365,10 @@ func (t *TDengineDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
|
||||
|
||||
func (t *TDengineDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if t.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
if strings.TrimSpace(tableName) == "" {
|
||||
return fmt.Errorf("table name required")
|
||||
return fmt.Errorf("表名不能为空")
|
||||
}
|
||||
if len(changes.Updates) > 0 || len(changes.Deletes) > 0 {
|
||||
return fmt.Errorf("TDengine 目标端当前仅支持 INSERT 写入,暂不支持 UPDATE/DELETE 差异同步,请改用仅插入或全量覆盖模式")
|
||||
@@ -384,7 +384,7 @@ func (t *TDengineDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
continue
|
||||
}
|
||||
if _, err := t.conn.Exec(query); err != nil {
|
||||
return fmt.Errorf("insert error: %v; sql=%s", err, query)
|
||||
return fmt.Errorf("插入失败:%v; sql=%s", err, query)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -392,7 +392,7 @@ func (t *TDengineDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
|
||||
func buildTDengineInsertSQL(qualifiedTable string, row map[string]interface{}) (string, error) {
|
||||
if strings.TrimSpace(qualifiedTable) == "" {
|
||||
return "", fmt.Errorf("qualified table required")
|
||||
return "", fmt.Errorf("需要指定完整的表名")
|
||||
}
|
||||
if len(row) == 0 {
|
||||
return "", nil
|
||||
|
||||
@@ -124,7 +124,7 @@ func (v *VastbaseDB) Close() error {
|
||||
|
||||
func (v *VastbaseDB) Ping() error {
|
||||
if v.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := v.pingTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -137,7 +137,7 @@ func (v *VastbaseDB) Ping() error {
|
||||
|
||||
func (v *VastbaseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if v.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := v.conn.QueryContext(ctx, query)
|
||||
@@ -151,7 +151,7 @@ func (v *VastbaseDB) QueryContext(ctx context.Context, query string) ([]map[stri
|
||||
|
||||
func (v *VastbaseDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if v.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
rows, err := v.conn.Query(query)
|
||||
@@ -164,7 +164,7 @@ func (v *VastbaseDB) Query(query string) ([]map[string]interface{}, []string, er
|
||||
|
||||
func (v *VastbaseDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if v.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := v.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -175,7 +175,7 @@ func (v *VastbaseDB) ExecContext(ctx context.Context, query string) (int64, erro
|
||||
|
||||
func (v *VastbaseDB) Exec(query string) (int64, error) {
|
||||
if v.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := v.conn.Exec(query)
|
||||
if err != nil {
|
||||
@@ -231,7 +231,7 @@ func (v *VastbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -301,7 +301,7 @@ func (v *VastbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDef
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -406,7 +406,7 @@ func (v *VastbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.Fore
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -466,7 +466,7 @@ func (v *VastbaseDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
|
||||
}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
@@ -530,7 +530,7 @@ ORDER BY table_schema, table_name, ordinal_position`
|
||||
|
||||
func (v *VastbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if v.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
tx, err := v.conn.Begin()
|
||||
@@ -578,7 +578,7 @@ func (v *VastbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,12 +606,12 @@ func (v *VastbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
return fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,7 +635,7 @@ func (v *VastbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
65
internal/logger/logger_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestErrorChain_NilError(t *testing.T) {
|
||||
if got := ErrorChain(nil); got != "" {
|
||||
t.Errorf("ErrorChain(nil) = %q; want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorChain_SingleError(t *testing.T) {
|
||||
err := errors.New("single error")
|
||||
got := ErrorChain(err)
|
||||
if got != "single error" {
|
||||
t.Errorf("ErrorChain(single) = %q; want %q", got, "single error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorChain_WrappedErrors(t *testing.T) {
|
||||
inner := errors.New("root cause")
|
||||
middle := fmt.Errorf("middle: %w", inner)
|
||||
outer := fmt.Errorf("outer: %w", middle)
|
||||
|
||||
got := ErrorChain(outer)
|
||||
// Should contain all three distinct messages
|
||||
if got == "" {
|
||||
t.Fatal("ErrorChain returned empty string for wrapped errors")
|
||||
}
|
||||
// The chain should start with the outermost error
|
||||
if len(got) < len("outer:") {
|
||||
t.Errorf("ErrorChain result too short: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorChain_DeduplicatesMessages(t *testing.T) {
|
||||
// Create a chain where wrapping doesn't add new text
|
||||
inner := errors.New("same message")
|
||||
outer := fmt.Errorf("%w", inner)
|
||||
|
||||
got := ErrorChain(outer)
|
||||
// Should not repeat "same message"
|
||||
if got != "same message" {
|
||||
t.Errorf("ErrorChain should deduplicate: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorChain_TruncatesLongChain(t *testing.T) {
|
||||
// Build a chain of 25 errors (exceeds the 20-level limit)
|
||||
var err error = errors.New("base")
|
||||
for i := 0; i < 25; i++ {
|
||||
err = fmt.Errorf("level-%d: %w", i, err)
|
||||
}
|
||||
got := ErrorChain(err)
|
||||
if got == "" {
|
||||
t.Fatal("ErrorChain returned empty for long chain")
|
||||
}
|
||||
// Should contain truncation notice
|
||||
if len(got) == 0 {
|
||||
t.Error("expected non-empty result for long chain")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package redis
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var ErrRedisKeyGone = errors.New("Redis Key 不存在或已过期")
|
||||
|
||||
// RedisClientImpl implements RedisClient using go-redis
|
||||
type RedisClientImpl struct {
|
||||
client redis.UniversalClient
|
||||
@@ -385,6 +388,65 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
|
||||
ctx, cancel := context.WithTimeout(context.Background(), maxDuration+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 集群模式:逐 master 节点 SCAN 后合并去重
|
||||
if r.isCluster && r.clusterClient != nil {
|
||||
keys := make([]string, 0, int(targetCount))
|
||||
seen := make(map[string]struct{}, int(targetCount))
|
||||
var mu sync.Mutex
|
||||
|
||||
err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error {
|
||||
var nodeCursor uint64
|
||||
round := 0
|
||||
scanStartedAt := time.Now()
|
||||
for {
|
||||
if time.Since(scanStartedAt) >= maxDuration {
|
||||
break
|
||||
}
|
||||
mu.Lock()
|
||||
enough := len(keys) >= int(targetCount)
|
||||
mu.Unlock()
|
||||
if enough {
|
||||
break
|
||||
}
|
||||
|
||||
batch, nextCursor, err := node.Scan(nodeCtx, nodeCursor, physicalPattern, scanStepCount).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
for _, key := range batch {
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
keys = append(keys, key)
|
||||
if len(keys) >= int(targetCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
nodeCursor = nextCursor
|
||||
round++
|
||||
if nodeCursor == 0 || round >= maxRounds {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 集群模式 cursor 无意义,始终返回 "0" 表示扫描完成
|
||||
return &RedisScanResult{
|
||||
Keys: r.loadRedisKeyInfos(ctx, keys),
|
||||
Cursor: "0",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 非集群模式:原逻辑
|
||||
currentCursor := cursor
|
||||
round := 0
|
||||
scanStartedAt := time.Now()
|
||||
@@ -472,20 +534,29 @@ func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string)
|
||||
if ttlErr != nil && ttlErr != redis.Nil {
|
||||
ttlValue = -2
|
||||
}
|
||||
ttlSeconds := toRedisTTLSeconds(ttlValue)
|
||||
if isRedisKeyGone(keyType, ttlSeconds) {
|
||||
continue
|
||||
}
|
||||
result = append(result, RedisKeyInfo{
|
||||
Key: r.toDisplayKey(key),
|
||||
Type: keyType,
|
||||
TTL: toRedisTTLSeconds(ttlValue),
|
||||
TTL: ttlSeconds,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
for i, key := range keys {
|
||||
keyType := typeResults[i].Val()
|
||||
ttlSeconds := toRedisTTLSeconds(ttlResults[i].Val())
|
||||
if isRedisKeyGone(keyType, ttlSeconds) {
|
||||
continue
|
||||
}
|
||||
result = append(result, RedisKeyInfo{
|
||||
Key: r.toDisplayKey(key),
|
||||
Type: typeResults[i].Val(),
|
||||
TTL: toRedisTTLSeconds(ttlResults[i].Val()),
|
||||
Type: keyType,
|
||||
TTL: ttlSeconds,
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -501,6 +572,17 @@ func toRedisTTLSeconds(ttl time.Duration) int64 {
|
||||
return int64(ttl.Seconds())
|
||||
}
|
||||
|
||||
func isRedisKeyGone(keyType string, ttl int64) bool {
|
||||
return keyType == "none" || ttl == -2
|
||||
}
|
||||
|
||||
func normalizeRedisGetValueError(keyType string, ttl int64) error {
|
||||
if isRedisKeyGone(keyType, ttl) {
|
||||
return ErrRedisKeyGone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKeyType returns the type of a key
|
||||
func (r *RedisClientImpl) GetKeyType(key string) (string, error) {
|
||||
if r.client == nil {
|
||||
@@ -594,6 +676,9 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
}
|
||||
|
||||
ttl, _ := r.GetTTL(key)
|
||||
if err := normalizeRedisGetValueError(keyType, ttl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
physicalKey := r.toPhysicalKey(key)
|
||||
|
||||
result := &RedisValue{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package redis
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeRedisPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -79,3 +82,40 @@ func TestSanitizeRedisPassword(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRedisKeyGone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keyType string
|
||||
ttl int64
|
||||
want bool
|
||||
}{
|
||||
{name: "type none", keyType: "none", ttl: -2, want: true},
|
||||
{name: "type none without ttl", keyType: "none", ttl: -1, want: true},
|
||||
{name: "missing by ttl", keyType: "string", ttl: -2, want: true},
|
||||
{name: "normal string", keyType: "string", ttl: 30, want: false},
|
||||
{name: "permanent hash", keyType: "hash", ttl: -1, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isRedisKeyGone(tt.keyType, tt.ttl); got != tt.want {
|
||||
t.Fatalf("isRedisKeyGone(%q, %d)=%v, want %v", tt.keyType, tt.ttl, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRedisGetValueError(t *testing.T) {
|
||||
err := normalizeRedisGetValueError("none", -2)
|
||||
if !errors.Is(err, ErrRedisKeyGone) {
|
||||
t.Fatalf("expected ErrRedisKeyGone, got %v", err)
|
||||
}
|
||||
if err == nil || err.Error() != "Redis Key 不存在或已过期" {
|
||||
t.Fatalf("unexpected error text: %v", err)
|
||||
}
|
||||
|
||||
if normalizeRedisGetValueError("hash", -1) != nil {
|
||||
t.Fatal("expected nil for supported existing key")
|
||||
}
|
||||
}
|
||||
|
||||