mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
Compare commits
14 Commits
v0.4.4
...
fix/table-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec5013a44 | ||
|
|
66a3113fa8 | ||
|
|
a435d62d3b | ||
|
|
50d92d3184 | ||
|
|
91658848c9 | ||
|
|
fda30539b6 | ||
|
|
1ba68fcbfe | ||
|
|
f0e1c7e72c | ||
|
|
663717d738 | ||
|
|
5329f212f7 | ||
|
|
d6e967a0d0 | ||
|
|
7ca2d20c17 | ||
|
|
9307ca5e16 | ||
|
|
60a42e3c34 |
126
.github/workflows/release.yml
vendored
126
.github/workflows/release.yml
vendored
@@ -22,26 +22,56 @@ jobs:
|
||||
os_name: MacOS
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-darwin-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: macos-latest
|
||||
platform: darwin/arm64
|
||||
os_name: MacOS
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-build-darwin-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
platform: windows/amd64
|
||||
os_name: Windows
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-windows-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
platform: windows/arm64
|
||||
os_name: Windows
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-build-windows-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: ubuntu-22.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-linux-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
# Debian 13 (trixie) 默认仓库已切到 WebKitGTK 4.1:单独提供 4.1 变体产物
|
||||
- os: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-linux-amd64-webkit41
|
||||
wails_tags: "webkit2_41"
|
||||
artifact_suffix: "-WebKit41"
|
||||
build_optional_agents: false
|
||||
linux_webkit: "4.1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -63,7 +93,17 @@ jobs:
|
||||
if: contains(matrix.platform, 'linux')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libfuse2
|
||||
sudo apt-get install -y libgtk-3-dev
|
||||
|
||||
# WebKitGTK 4.1 需要 libsoup3;4.0 使用 libsoup2(通常由 webkit2gtk dev 包拉起)
|
||||
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
|
||||
else
|
||||
sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
fi
|
||||
|
||||
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
|
||||
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||
|
||||
# Download linuxdeploy tools for AppImage packaging
|
||||
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||
@@ -94,9 +134,15 @@ jobs:
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
set -euo pipefail
|
||||
TAG_ARGS=()
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
TAG_ARGS+=(-tags "${{ matrix.wails_tags }}")
|
||||
fi
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} "${TAG_ARGS[@]}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
|
||||
- name: Build Optional Driver Agents
|
||||
if: ${{ matrix.build_optional_agents }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -104,6 +150,8 @@ jobs:
|
||||
GOOS="${TARGET_PLATFORM%%/*}"
|
||||
GOARCH="${TARGET_PLATFORM##*/}"
|
||||
DRIVERS=(mariadb diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine)
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
TAG="gonavi_${DRIVER}_driver"
|
||||
@@ -111,20 +159,21 @@ jobs:
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUTPUT="${OUTPUT}.exe"
|
||||
fi
|
||||
echo "🔧 构建 ${OUTPUT} (tag=${TAG})"
|
||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
|
||||
if [ "$DRIVER" = "duckdb" ]; then
|
||||
set +e
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT}" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
DUCKDB_RC=$?
|
||||
set -e
|
||||
if [ "${DUCKDB_RC}" -ne 0 ]; then
|
||||
echo "⚠️ DuckDB 代理构建失败(平台 ${GOOS}/${GOARCH}),跳过该资产,不阻断发布"
|
||||
rm -f "${OUTPUT}"
|
||||
rm -f "${OUTPUT_PATH}"
|
||||
continue
|
||||
fi
|
||||
else
|
||||
@@ -132,7 +181,7 @@ jobs:
|
||||
-tags "${TAG}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT}" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
fi
|
||||
done
|
||||
@@ -157,7 +206,7 @@ jobs:
|
||||
codesign --force --options runtime --deep --sign - "$APP_NAME"
|
||||
|
||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}.dmg"
|
||||
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
|
||||
echo "📦 正在生成 DMG: $DMG_NAME..."
|
||||
|
||||
create-dmg \
|
||||
@@ -184,8 +233,8 @@ jobs:
|
||||
$version = $version.Substring(1)
|
||||
}
|
||||
$target = "${{ matrix.build_name }}"
|
||||
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}.exe"
|
||||
$finalZipName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}.zip"
|
||||
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
|
||||
$finalZipName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.zip"
|
||||
|
||||
if (Test-Path "$target.exe") {
|
||||
$finalExe = "$target.exe"
|
||||
@@ -211,8 +260,8 @@ jobs:
|
||||
VERSION="${VERSION#v}"
|
||||
cd build/bin
|
||||
TARGET="${{ matrix.build_name }}"
|
||||
TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}.tar.gz"
|
||||
APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}.AppImage"
|
||||
TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.tar.gz"
|
||||
APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.AppImage"
|
||||
|
||||
if [ ! -f "$TARGET" ]; then
|
||||
echo "❌ 未找到构建产物 '$TARGET'!"
|
||||
@@ -295,7 +344,7 @@ jobs:
|
||||
GoNavi-*.zip
|
||||
GoNavi-*.tar.gz
|
||||
GoNavi-*.AppImage
|
||||
*-driver-agent-*
|
||||
drivers/**
|
||||
retention-days: 1
|
||||
|
||||
# Phase 2: Collect all artifacts and Publish Release (Single Job)
|
||||
@@ -314,6 +363,59 @@ jobs:
|
||||
- name: List Assets
|
||||
run: ls -R release-assets
|
||||
|
||||
- name: Package Driver Agents Bundle
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-assets
|
||||
if [ ! -d drivers ]; then
|
||||
echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then
|
||||
echo "⚠️ drivers 目录为空,跳过驱动总包打包"
|
||||
rm -rf drivers
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📦 打包驱动总包:GoNavi-DriverAgents.zip"
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
out_name = "GoNavi-DriverAgents.zip"
|
||||
index_name = "GoNavi-DriverAgents-Index.json"
|
||||
base = Path("drivers")
|
||||
out_path = Path(out_name)
|
||||
index_path = Path(index_name)
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
if index_path.exists():
|
||||
index_path.unlink()
|
||||
|
||||
size_index = {}
|
||||
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in base.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
arcname = p.relative_to(base).as_posix()
|
||||
zf.write(p, arcname)
|
||||
size_index[p.name] = p.stat().st_size
|
||||
|
||||
index_path.write_text(
|
||||
json.dumps({"assets": size_index}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"created {out_name} size={out_path.stat().st_size} bytes")
|
||||
print(f"created {index_name} entries={len(size_index)}")
|
||||
PY
|
||||
|
||||
# Release 只发布一个驱动总包,避免大量平铺资产污染 Release 页面
|
||||
rm -rf drivers
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
22
README.md
22
README.md
@@ -129,6 +129,7 @@ wails build -clean
|
||||
支持构建:
|
||||
* macOS (AMD64 / ARM64)
|
||||
* Windows (AMD64)
|
||||
* Linux (AMD64,提供 WebKitGTK 4.0 与 4.1 变体产物)
|
||||
|
||||
---
|
||||
|
||||
@@ -146,6 +147,27 @@ wails build -clean
|
||||
```
|
||||
4. 或者:在 Finder 中右键点击应用图标,按住 `Control` 键选择 **打开**,然后在弹出的窗口中再次点击 **打开**。
|
||||
|
||||
### Linux 启动报错缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
|
||||
|
||||
GoNavi 的 Linux 二进制依赖系统 WebKitGTK 运行库。不同发行版默认版本不同:
|
||||
|
||||
- Debian 13 / Ubuntu 24.04 及更新版本:通常为 WebKitGTK 4.1
|
||||
- Ubuntu 22.04 / Debian 12 等:通常为 WebKitGTK 4.0
|
||||
|
||||
如果启动时报错(如 `libwebkit2gtk-4.0.so.37: cannot open shared object file`),请按系统安装对应依赖后重试:
|
||||
|
||||
```bash
|
||||
# Debian 13 / Ubuntu 24.04+
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0
|
||||
|
||||
# Ubuntu 22.04 / Debian 12
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
|
||||
```
|
||||
|
||||
如果你使用的是 Release 中带 `-WebKit41` 后缀的 Linux 产物,请优先在 Debian 13 / Ubuntu 24.04+ 上使用;普通 Linux 产物更适合 WebKitGTK 4.0 运行环境。
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } 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 } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect } from '../../wailsjs/go/app/App';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { MongoMemberInfo, SavedConnection } from '../types';
|
||||
|
||||
const { Meta } = Card;
|
||||
@@ -27,6 +27,7 @@ const getDefaultPortByType = (type: string) => {
|
||||
case 'highgo': return 5866;
|
||||
case 'mariadb': return 3306;
|
||||
case 'vastbase': return 5432;
|
||||
case 'sqlite': return 0;
|
||||
case 'duckdb': return 0;
|
||||
default: return 3306;
|
||||
}
|
||||
@@ -70,6 +71,7 @@ const ConnectionModal: React.FC<{
|
||||
const [typeSelectWarning, setTypeSelectWarning] = useState<{ driverName: string; reason: string } | null>(null);
|
||||
const [driverStatusMap, setDriverStatusMap] = useState<Record<string, DriverStatusSnapshot>>({});
|
||||
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
|
||||
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
|
||||
const testInFlightRef = useRef(false);
|
||||
const testTimerRef = useRef<number | null>(null);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
@@ -236,6 +238,23 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeFileDbPath = (rawPath: string): string => {
|
||||
let pathText = String(rawPath || '').trim();
|
||||
if (!pathText) {
|
||||
return '';
|
||||
}
|
||||
// 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。
|
||||
if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) {
|
||||
pathText = pathText.slice(1);
|
||||
}
|
||||
// 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。
|
||||
const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/);
|
||||
if (legacyMatch?.[1]) {
|
||||
return legacyMatch[1];
|
||||
}
|
||||
return pathText;
|
||||
};
|
||||
|
||||
const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
|
||||
const prefix = `${expectedScheme}://`;
|
||||
if (!uriText.toLowerCase().startsWith(prefix)) {
|
||||
@@ -335,30 +354,6 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
|
||||
if (isFileDatabaseType(type)) {
|
||||
const tryExtractPath = (uri: string, scheme: string): string | null => {
|
||||
const parsed = parseMultiHostUri(uri, scheme);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const host = String(parsed.hosts?.[0] || '').trim();
|
||||
const dbPath = String(parsed.database || '').trim();
|
||||
if (host && dbPath) {
|
||||
return `/${host}/${dbPath}`.replace(/\/+/g, '/');
|
||||
}
|
||||
if (host) {
|
||||
return `/${host}`.replace(/\/+/g, '/');
|
||||
}
|
||||
if (dbPath) {
|
||||
return dbPath.startsWith('/') ? dbPath : `/${dbPath}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const pathFromScheme = tryExtractPath(trimmedUri, type);
|
||||
if (pathFromScheme) {
|
||||
return { host: decodeURIComponent(pathFromScheme) };
|
||||
}
|
||||
|
||||
const rawPath = trimmedUri
|
||||
.replace(/^sqlite:\/\//i, '')
|
||||
.replace(/^duckdb:\/\//i, '')
|
||||
@@ -366,7 +361,7 @@ const ConnectionModal: React.FC<{
|
||||
if (!rawPath) {
|
||||
return null;
|
||||
}
|
||||
return { host: decodeURIComponent(rawPath) };
|
||||
return { host: normalizeFileDbPath(safeDecode(rawPath)) };
|
||||
}
|
||||
|
||||
if (type === 'mongodb') {
|
||||
@@ -481,12 +476,11 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
|
||||
if (isFileDatabaseType(type)) {
|
||||
const pathText = String(values.host || '').trim();
|
||||
const pathText = normalizeFileDbPath(String(values.host || '').trim());
|
||||
if (!pathText) {
|
||||
return `${type}://`;
|
||||
}
|
||||
const normalizedPath = pathText.startsWith('/') ? pathText : `/${pathText}`;
|
||||
return `${type}://${encodeURI(normalizedPath)}`;
|
||||
return `${type}://${encodeURI(pathText)}`;
|
||||
}
|
||||
|
||||
if (type === 'mongodb') {
|
||||
@@ -585,6 +579,30 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSSHKeyFile = async () => {
|
||||
if (selectingSSHKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSelectingSSHKey(true);
|
||||
const currentPath = String(form.getFieldValue('sshKeyPath') || '').trim();
|
||||
const res = await SelectSSHKeyFile(currentPath);
|
||||
if (res?.success) {
|
||||
const data = res.data || {};
|
||||
const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim();
|
||||
if (selectedPath) {
|
||||
form.setFieldValue('sshKeyPath', selectedPath);
|
||||
}
|
||||
} else if (res?.message !== 'Cancelled') {
|
||||
message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(`选择私钥文件失败: ${e?.message || String(e)}`);
|
||||
} finally {
|
||||
setSelectingSSHKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTestResult(null); // Reset test result
|
||||
@@ -602,13 +620,20 @@ const ConnectionModal: React.FC<{
|
||||
const config: any = initialValues.config || {};
|
||||
const configType = String(config.type || 'mysql');
|
||||
const defaultPort = getDefaultPortByType(configType);
|
||||
const normalizedHosts = normalizeAddressList(config.hosts, defaultPort);
|
||||
const primaryAddress = parseHostPort(
|
||||
normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort),
|
||||
defaultPort
|
||||
);
|
||||
const primaryHost = primaryAddress?.host || String(config.host || 'localhost');
|
||||
const primaryPort = primaryAddress?.port || Number(config.port || defaultPort);
|
||||
const isFileDbConfigType = isFileDatabaseType(configType);
|
||||
const normalizedHosts = isFileDbConfigType ? [] : normalizeAddressList(config.hosts, defaultPort);
|
||||
const primaryAddress = isFileDbConfigType
|
||||
? null
|
||||
: parseHostPort(
|
||||
normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort),
|
||||
defaultPort
|
||||
);
|
||||
const primaryHost = isFileDbConfigType
|
||||
? normalizeFileDbPath(String(config.host || ''))
|
||||
: (primaryAddress?.host || String(config.host || 'localhost'));
|
||||
const primaryPort = isFileDbConfigType
|
||||
? 0
|
||||
: (primaryAddress?.port || Number(config.port || defaultPort));
|
||||
const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : [];
|
||||
const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : [];
|
||||
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
|
||||
@@ -847,12 +872,22 @@ const ConnectionModal: React.FC<{
|
||||
|
||||
const type = String(mergedValues.type || '').toLowerCase();
|
||||
const defaultPort = getDefaultPortByType(type);
|
||||
const parsedPrimary = parseHostPort(
|
||||
toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort),
|
||||
defaultPort
|
||||
);
|
||||
const primaryHost = parsedPrimary?.host || 'localhost';
|
||||
const primaryPort = parsedPrimary?.port || defaultPort;
|
||||
const isFileDbType = isFileDatabaseType(type);
|
||||
|
||||
let primaryHost = 'localhost';
|
||||
let primaryPort = defaultPort;
|
||||
if (isFileDbType) {
|
||||
// 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。
|
||||
primaryHost = normalizeFileDbPath(String(mergedValues.host || '').trim());
|
||||
primaryPort = 0;
|
||||
} else {
|
||||
const parsedPrimary = parseHostPort(
|
||||
toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort),
|
||||
defaultPort
|
||||
);
|
||||
primaryHost = parsedPrimary?.host || 'localhost';
|
||||
primaryPort = parsedPrimary?.port || defaultPort;
|
||||
}
|
||||
|
||||
let hosts: string[] = [];
|
||||
let topology: 'single' | 'replica' | undefined;
|
||||
@@ -960,7 +995,36 @@ const ConnectionModal: React.FC<{
|
||||
form.setFieldsValue({ type: type });
|
||||
|
||||
const defaultPort = getDefaultPortByType(type);
|
||||
if (!isFileDatabaseType(type) && type !== 'custom') {
|
||||
if (isFileDatabaseType(type)) {
|
||||
setUseSSH(false);
|
||||
form.setFieldsValue({
|
||||
host: '',
|
||||
port: 0,
|
||||
user: '',
|
||||
password: '',
|
||||
database: '',
|
||||
useSSH: false,
|
||||
sshHost: '',
|
||||
sshPort: 22,
|
||||
sshUser: '',
|
||||
sshPassword: '',
|
||||
sshKeyPath: '',
|
||||
mysqlTopology: 'single',
|
||||
mongoTopology: 'single',
|
||||
mongoSrv: false,
|
||||
mongoReadPreference: 'primary',
|
||||
mongoReplicaSet: '',
|
||||
mongoAuthSource: '',
|
||||
mongoAuthMechanism: '',
|
||||
savePassword: true,
|
||||
mysqlReplicaHosts: [],
|
||||
mongoHosts: [],
|
||||
mysqlReplicaUser: '',
|
||||
mysqlReplicaPassword: '',
|
||||
mongoReplicaUser: '',
|
||||
mongoReplicaPassword: '',
|
||||
});
|
||||
} else if (type !== 'custom') {
|
||||
form.setFieldsValue({
|
||||
port: defaultPort,
|
||||
mysqlTopology: 'single',
|
||||
@@ -1454,8 +1518,15 @@ const ConnectionModal: React.FC<{
|
||||
<Input.Password placeholder="密码" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="sshKeyPath" label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
|
||||
<Input placeholder="绝对路径" />
|
||||
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name="sshKeyPath" noStyle>
|
||||
<Input placeholder="绝对路径" />
|
||||
</Form.Item>
|
||||
<Button onClick={handleSelectSSHKeyFile} loading={selectingSSHKey}>
|
||||
浏览...
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented } from 'antd';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover } from 'antd';
|
||||
import type { SortOrder } from 'antd/es/table/interface';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App';
|
||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import ImportPreviewModal from './ImportPreviewModal';
|
||||
import { useStore } from '../store';
|
||||
import type { ColumnDefinition } from '../types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
@@ -89,6 +90,14 @@ const normalizeDateTimeString = (val: string) => {
|
||||
return `${match[1]} ${match[2]}`;
|
||||
};
|
||||
|
||||
const isTemporalColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
return base === 'date' || base === 'time' || base === 'year';
|
||||
};
|
||||
|
||||
// --- Helper: Format Value ---
|
||||
const formatCellValue = (val: any) => {
|
||||
try {
|
||||
@@ -292,6 +301,7 @@ const DataContext = React.createContext<{
|
||||
handleExportSelected: (format: string, r: any) => void;
|
||||
copyToClipboard: (t: string) => void;
|
||||
tableName?: string;
|
||||
enableRowContextMenu: boolean;
|
||||
} | null>(null);
|
||||
|
||||
interface Item {
|
||||
@@ -434,7 +444,11 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
|
||||
|
||||
if (!record || !context) return <tr {...props}>{children}</tr>;
|
||||
|
||||
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard } = context;
|
||||
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu } = context;
|
||||
|
||||
if (!enableRowContextMenu) {
|
||||
return <tr {...props}>{children}</tr>;
|
||||
}
|
||||
|
||||
const getTargets = () => {
|
||||
const keys = selectedRowKeysRef.current;
|
||||
@@ -513,6 +527,11 @@ type GridFilterCondition = FilterCondition & {
|
||||
|
||||
type GridViewMode = 'table' | 'json' | 'text';
|
||||
|
||||
type ColumnMeta = {
|
||||
type: string;
|
||||
comment: string;
|
||||
};
|
||||
|
||||
const DataGrid: React.FC<DataGridProps> = ({
|
||||
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
|
||||
onReload, onSort, onPageChange, pagination, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter
|
||||
@@ -521,10 +540,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const queryOptions = useStore(state => state.queryOptions);
|
||||
const setQueryOptions = useStore(state => state.setQueryOptions);
|
||||
const isMacLike = useMemo(() => isMacLikePlatform(), []);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const canModifyData = !readOnly && !!tableName;
|
||||
const showColumnComment = queryOptions?.showColumnComment !== false;
|
||||
const showColumnType = queryOptions?.showColumnType !== false;
|
||||
const selectionColumnWidth = 46;
|
||||
|
||||
// Background Helper
|
||||
@@ -538,7 +561,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
};
|
||||
const bgContent = getBg('#1d1d1d');
|
||||
const bgFilter = getBg('#262626');
|
||||
const bgContextMenu = getBg('#1f1f1f');
|
||||
const bgContextMenu = darkMode ? '#1f1f1f' : '#ffffff';
|
||||
|
||||
// Row Colors with Opacity
|
||||
const getRowBg = (r: number, g: number, b: number) => `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
@@ -661,6 +684,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [columnMetaMap, setColumnMetaMap] = useState<Record<string, ColumnMeta>>({});
|
||||
const columnMetaCacheRef = useRef<Record<string, Record<string, ColumnMeta>>>({});
|
||||
const columnMetaSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const nextOrder = sortInfoExternal?.order === 'ascend' || sortInfoExternal?.order === 'descend'
|
||||
@@ -677,6 +703,158 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}
|
||||
}, [sortInfoExternal, sortInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedTableName = String(tableName || '').trim();
|
||||
const normalizedDbName = String(dbName || '').trim();
|
||||
if (!connectionId || !normalizedTableName) {
|
||||
setColumnMetaMap({});
|
||||
return;
|
||||
}
|
||||
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
|
||||
setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {});
|
||||
}, [connectionId, dbName, tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedTableName = String(tableName || '').trim();
|
||||
const normalizedDbName = String(dbName || '').trim();
|
||||
if (!connectionId || !normalizedTableName) return;
|
||||
|
||||
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
|
||||
if (columnMetaCacheRef.current[cacheKey]) return;
|
||||
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
if (!conn) {
|
||||
setColumnMetaMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...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: "" }
|
||||
};
|
||||
|
||||
const seq = ++columnMetaSeqRef.current;
|
||||
DBGetColumns(config as any, normalizedDbName, normalizedTableName)
|
||||
.then((res) => {
|
||||
if (seq !== columnMetaSeqRef.current) return;
|
||||
if (!res.success || !Array.isArray(res.data)) {
|
||||
setColumnMetaMap({});
|
||||
return;
|
||||
}
|
||||
const nextMap: Record<string, ColumnMeta> = {};
|
||||
(res.data as ColumnDefinition[]).forEach((column: any) => {
|
||||
const name = String(column?.name ?? column?.Name ?? '').trim();
|
||||
if (!name) return;
|
||||
const type = String(column?.type ?? column?.Type ?? '').trim();
|
||||
const comment = String(column?.comment ?? column?.Comment ?? '').trim();
|
||||
nextMap[name] = { type, comment };
|
||||
});
|
||||
columnMetaCacheRef.current[cacheKey] = nextMap;
|
||||
setColumnMetaMap(nextMap);
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== columnMetaSeqRef.current) return;
|
||||
setColumnMetaMap({});
|
||||
});
|
||||
}, [connections, connectionId, dbName, tableName]);
|
||||
|
||||
const columnMetaMapByLowerName = useMemo(() => {
|
||||
const next: Record<string, ColumnMeta> = {};
|
||||
Object.entries(columnMetaMap).forEach(([name, meta]) => {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
if (!lowerName || next[lowerName]) return;
|
||||
next[lowerName] = meta;
|
||||
});
|
||||
return next;
|
||||
}, [columnMetaMap]);
|
||||
|
||||
const normalizeCommitCellValue = useCallback(
|
||||
(columnName: string, value: any, mode: 'insert' | 'update') => {
|
||||
if (value === undefined) return undefined;
|
||||
const normalizedName = String(columnName || '').trim();
|
||||
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
|
||||
const temporal = isTemporalColumnType(meta?.type);
|
||||
|
||||
if (!temporal) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const raw = value.trim();
|
||||
if (raw === '') {
|
||||
// INSERT 空时间值直接忽略字段,让数据库默认值生效;UPDATE 空时间值转 NULL。
|
||||
return mode === 'insert' ? undefined : null;
|
||||
}
|
||||
return normalizeDateTimeString(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
[columnMetaMap, columnMetaMapByLowerName]
|
||||
);
|
||||
|
||||
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
|
||||
const normalizedName = String(name || '');
|
||||
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
|
||||
const hoverLines: string[] = [];
|
||||
if (meta?.type) hoverLines.push(`类型:${meta.type}`);
|
||||
if (meta?.comment) hoverLines.push(`备注:${meta.comment}`);
|
||||
|
||||
const titleNode = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, lineHeight: 1.2 }}>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{normalizedName}</span>
|
||||
{showColumnType && meta?.type && (
|
||||
<span
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontSize: 11,
|
||||
color: '#8c8c8c',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{meta.type}
|
||||
</span>
|
||||
)}
|
||||
{showColumnComment && meta?.comment && (
|
||||
<span
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontSize: 11,
|
||||
color: '#8c8c8c',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{meta.comment}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hoverLines.length === 0) return titleNode;
|
||||
return (
|
||||
<Tooltip
|
||||
title={<pre style={{ maxHeight: 260, overflow: 'auto', margin: 0, fontSize: 12, whiteSpace: 'pre-wrap' }}>{hoverLines.join('\n')}</pre>}
|
||||
styles={{ root: { maxWidth: 640 } }}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType]);
|
||||
|
||||
const closeCellEditor = useCallback(() => {
|
||||
setCellEditorOpen(false);
|
||||
setCellEditorMeta(null);
|
||||
@@ -1592,7 +1770,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return columnNames.map(key => ({
|
||||
title: key,
|
||||
title: renderColumnTitle(key),
|
||||
dataIndex: key,
|
||||
key: key,
|
||||
// 不使用 ellipsis,避免 Ant Design 的 Tooltip 展开行为
|
||||
@@ -1608,9 +1786,29 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResizeStart: handleResizeStart(key), // Only need start
|
||||
onClickCapture: (event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!onSort) return;
|
||||
const headerCell = event.currentTarget as HTMLElement;
|
||||
const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null;
|
||||
const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null;
|
||||
const isInArrow = [upArrow, downArrow].some((el) => {
|
||||
if (!el) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
);
|
||||
});
|
||||
if (isInArrow) return;
|
||||
// 仅允许点击上下箭头触发排序,点击字段名或表头其它区域不触发排序。
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
}, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort]);
|
||||
}, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]);
|
||||
|
||||
const mergedColumns = useMemo(() => columns.map(col => {
|
||||
if (!col.editable) return col;
|
||||
@@ -1620,7 +1818,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
record,
|
||||
editable: col.editable,
|
||||
dataIndex: col.dataIndex,
|
||||
title: col.title,
|
||||
title: String(col.dataIndex),
|
||||
handleSave: handleCellSave,
|
||||
focusCell: openCellEditor,
|
||||
}),
|
||||
@@ -1653,7 +1851,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const updates: any[] = [];
|
||||
const deletes: any[] = [];
|
||||
|
||||
addedRows.forEach(row => { const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row; inserts.push(vals); });
|
||||
addedRows.forEach(row => {
|
||||
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row;
|
||||
const normalizedValues: Record<string, any> = {};
|
||||
Object.entries(vals).forEach(([col, val]) => {
|
||||
const normalizedVal = normalizeCommitCellValue(col, val, 'insert');
|
||||
if (normalizedVal !== undefined) {
|
||||
normalizedValues[col] = normalizedVal;
|
||||
}
|
||||
});
|
||||
inserts.push(normalizedValues);
|
||||
});
|
||||
deletedRowKeys.forEach(keyStr => {
|
||||
// Find original data
|
||||
const originalRow = data.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr) || addedRows.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr);
|
||||
@@ -1686,8 +1894,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(values).length === 0) return;
|
||||
updates.push({ keys: pkData, values });
|
||||
const normalizedValues: Record<string, any> = {};
|
||||
Object.entries(values).forEach(([col, val]) => {
|
||||
const normalizedVal = normalizeCommitCellValue(col, val, 'update');
|
||||
if (normalizedVal !== undefined) {
|
||||
normalizedValues[col] = normalizedVal;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(normalizedValues).length === 0) return;
|
||||
updates.push({ keys: pkData, values: normalizedValues });
|
||||
});
|
||||
|
||||
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
||||
@@ -2037,6 +2253,23 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
|
||||
];
|
||||
|
||||
const columnInfoSettingContent = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 168 }}>
|
||||
<Checkbox
|
||||
checked={showColumnComment}
|
||||
onChange={(e) => setQueryOptions({ showColumnComment: e.target.checked })}
|
||||
>
|
||||
下方显示备注
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={showColumnType}
|
||||
onChange={(e) => setQueryOptions({ showColumnType: e.target.checked })}
|
||||
>
|
||||
下方显示类型
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tableComponents = useMemo(() => ({
|
||||
body: { cell: EditableCell, row: ContextMenuRow },
|
||||
header: { cell: ResizableTitle }
|
||||
@@ -2149,6 +2382,15 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
)}
|
||||
|
||||
<div style={{ marginLeft: 'auto' }} />
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={columnInfoSettingContent}
|
||||
>
|
||||
<Button icon={<FileTextOutlined />}>字段信息</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Segmented
|
||||
size="small"
|
||||
@@ -2413,13 +2655,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
{viewMode === 'table' ? (
|
||||
<Form component={false} form={form}>
|
||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName, enableRowContextMenu: !canModifyData }}>
|
||||
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
dataSource={mergedDisplayData}
|
||||
columns={mergedColumns}
|
||||
showSorterTooltip={{ target: 'sorter-icon' }}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: tableScrollX, y: tableHeight }}
|
||||
@@ -2721,6 +2964,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
.${gridId} .ant-table-tbody > tr > td { 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; }
|
||||
.${gridId} .ant-table-thead > tr > th { 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; }
|
||||
.${gridId} .ant-table-thead > tr > th::before { display: none !important; }
|
||||
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorters { cursor: default !important; }
|
||||
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorter,
|
||||
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorter * { cursor: pointer !important; }
|
||||
.${gridId} .ant-table-tbody > tr:hover > td { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; }
|
||||
.${gridId} .ant-table-tbody > tr.ant-table-row-selected > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)'} !important; }
|
||||
.${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.25)' : 'rgba(24, 144, 255, 0.12)'} !important; }
|
||||
|
||||
@@ -46,6 +46,15 @@ interface TreeNode {
|
||||
}
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
type BatchObjectType = 'table' | 'view';
|
||||
|
||||
interface BatchObjectItem {
|
||||
title: string;
|
||||
key: string;
|
||||
objectName: string;
|
||||
objectType: BatchObjectType;
|
||||
dataRef: any;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
@@ -118,12 +127,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
// Batch Operations Modal
|
||||
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
|
||||
const [batchTables, setBatchTables] = useState<any[]>([]);
|
||||
const [batchTables, setBatchTables] = useState<BatchObjectItem[]>([]);
|
||||
const [checkedTableKeys, setCheckedTableKeys] = useState<string[]>([]);
|
||||
const [batchDbContext, setBatchDbContext] = useState<any>(null);
|
||||
const [selectedConnection, setSelectedConnection] = useState<string>('');
|
||||
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
|
||||
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
|
||||
const groupedBatchObjects = useMemo(() => {
|
||||
const tables = batchTables.filter(item => item.objectType === 'table');
|
||||
const views = batchTables.filter(item => item.objectType === 'view');
|
||||
return { tables, views };
|
||||
}, [batchTables]);
|
||||
|
||||
// Batch Database Operations Modal
|
||||
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
|
||||
@@ -573,16 +587,33 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
results.forEach((queryResult) => {
|
||||
queryResult.rows.forEach((row) => {
|
||||
const triggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row);
|
||||
if (!triggerName) return;
|
||||
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']);
|
||||
const tableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']);
|
||||
const fullTableName = buildQualifiedName(schemaName, tableName);
|
||||
const uniqueKey = `${triggerName}@@${fullTableName}`;
|
||||
const rawTriggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row);
|
||||
if (!rawTriggerName) return;
|
||||
|
||||
const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']);
|
||||
const rawTableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']);
|
||||
|
||||
const triggerParts = splitQualifiedName(rawTriggerName);
|
||||
const tableParts = splitQualifiedName(rawTableName);
|
||||
|
||||
const resolvedSchema = (
|
||||
rawSchemaName
|
||||
|| tableParts.schemaName
|
||||
|| triggerParts.schemaName
|
||||
|| dbName
|
||||
).trim();
|
||||
const resolvedTriggerName = (triggerParts.objectName || rawTriggerName).trim();
|
||||
const resolvedTableName = (tableParts.objectName || rawTableName).trim();
|
||||
const fullTableName = buildQualifiedName(resolvedSchema, resolvedTableName);
|
||||
|
||||
// MySQL 下 trigger 名在同 schema 内唯一,直接按 schema+trigger 去重可彻底规避多元数据查询导致的重复
|
||||
const uniqueKey = dialect === 'mysql'
|
||||
? `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}`
|
||||
: `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`;
|
||||
if (seen.has(uniqueKey)) return;
|
||||
seen.add(uniqueKey);
|
||||
const displayName = fullTableName ? `${triggerName} (${fullTableName})` : triggerName;
|
||||
triggers.push({ displayName, triggerName, tableName: fullTableName });
|
||||
const displayName = fullTableName ? `${resolvedTriggerName} (${fullTableName})` : resolvedTriggerName;
|
||||
triggers.push({ displayName, triggerName: resolvedTriggerName, tableName: fullTableName || resolvedTableName });
|
||||
});
|
||||
});
|
||||
return { triggers, supported: hasSuccessfulQuery };
|
||||
@@ -755,19 +786,35 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
});
|
||||
|
||||
const triggerEntries = triggersResult.triggers.map((trigger) => {
|
||||
const triggerParsed = splitQualifiedName(trigger.triggerName);
|
||||
const tableParsed = splitQualifiedName(trigger.tableName);
|
||||
const schemaName = tableParsed.schemaName || triggerParsed.schemaName;
|
||||
const triggerObjectName = triggerParsed.objectName || trigger.triggerName;
|
||||
const tableObjectName = tableParsed.objectName || trigger.tableName;
|
||||
const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName;
|
||||
return {
|
||||
...trigger,
|
||||
schemaName,
|
||||
displayName,
|
||||
};
|
||||
});
|
||||
const triggerEntries = (() => {
|
||||
const deduped: Array<{ displayName: string; triggerName: string; tableName: string; schemaName: string }> = [];
|
||||
const triggerSeen = new Set<string>();
|
||||
const metadataDialect = getMetadataDialect(conn as SavedConnection);
|
||||
|
||||
triggersResult.triggers.forEach((trigger) => {
|
||||
const triggerParsed = splitQualifiedName(trigger.triggerName);
|
||||
const tableParsed = splitQualifiedName(trigger.tableName);
|
||||
const schemaName = tableParsed.schemaName || triggerParsed.schemaName || String(conn.dbName || '').trim();
|
||||
const triggerObjectName = (triggerParsed.objectName || trigger.triggerName).trim();
|
||||
const tableObjectName = (tableParsed.objectName || trigger.tableName).trim();
|
||||
const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName;
|
||||
const dedupeKey = metadataDialect === 'mysql'
|
||||
? `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}`
|
||||
: `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}@@${tableObjectName.toLowerCase()}`;
|
||||
|
||||
if (triggerSeen.has(dedupeKey)) return;
|
||||
triggerSeen.add(dedupeKey);
|
||||
deduped.push({
|
||||
...trigger,
|
||||
schemaName,
|
||||
triggerName: triggerObjectName,
|
||||
tableName: buildQualifiedName(schemaName, tableObjectName) || tableObjectName,
|
||||
displayName,
|
||||
});
|
||||
});
|
||||
|
||||
return deduped;
|
||||
})();
|
||||
|
||||
const routineEntries = routinesResult.routines.map((routine) => {
|
||||
const parsed = splitQualifiedName(routine.routineName);
|
||||
@@ -1061,10 +1108,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
|
||||
}
|
||||
|
||||
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
|
||||
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', true);
|
||||
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', true);
|
||||
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', true);
|
||||
if (type === 'folder-columns') openDesign(info.node, 'columns', false);
|
||||
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false);
|
||||
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false);
|
||||
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false);
|
||||
};
|
||||
|
||||
const onExpand = (newExpandedKeys: React.Key[]) => {
|
||||
@@ -1255,7 +1302,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
if (node.type === 'database') {
|
||||
connId = node.dataRef.id;
|
||||
dbName = node.title;
|
||||
} else if (node.type === 'table') {
|
||||
} else if (node.type === 'table' || node.type === 'view') {
|
||||
connId = node.dataRef.id;
|
||||
dbName = node.dataRef.dbName;
|
||||
}
|
||||
@@ -1323,23 +1370,42 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await DBGetTables(config as any, dbName);
|
||||
if (res.success) {
|
||||
const tables = (res.data as any[]).map((row: any) => {
|
||||
const tableName = Object.values(row)[0] as string;
|
||||
return {
|
||||
title: tableName,
|
||||
key: `${conn.id}-${dbName}-${tableName}`,
|
||||
tableName: tableName,
|
||||
dataRef: { ...conn, tableName, dbName }
|
||||
};
|
||||
});
|
||||
const [res, viewResult] = await Promise.all([
|
||||
DBGetTables(config as any, dbName),
|
||||
loadViews(conn, dbName).catch(() => ({ views: [], supported: false })),
|
||||
]);
|
||||
|
||||
setBatchTables(tables);
|
||||
setCheckedTableKeys([]);
|
||||
} else {
|
||||
if (!res.success) {
|
||||
message.error('获取表列表失败: ' + res.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewSet = new Set(viewResult.views.map(view => view.toLowerCase()));
|
||||
|
||||
const tableObjects: BatchObjectItem[] = (res.data as any[])
|
||||
.map((row: any) => Object.values(row)[0] as string)
|
||||
.filter((tableName: string) => !viewSet.has(tableName.toLowerCase()))
|
||||
.map((tableName: string) => ({
|
||||
title: getSidebarTableDisplayName(conn, tableName),
|
||||
key: `${conn.id}-${dbName}-table-${tableName}`,
|
||||
objectName: tableName,
|
||||
objectType: 'table' as const,
|
||||
dataRef: { ...conn, tableName, dbName, objectType: 'table' },
|
||||
}));
|
||||
|
||||
const viewObjects: BatchObjectItem[] = viewResult.views.map((viewName: string) => ({
|
||||
title: getSidebarTableDisplayName(conn, viewName),
|
||||
key: `${conn.id}-${dbName}-view-${viewName}`,
|
||||
objectName: viewName,
|
||||
objectType: 'view' as const,
|
||||
dataRef: { ...conn, tableName: viewName, dbName, objectType: 'view' },
|
||||
}));
|
||||
|
||||
tableObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||||
viewObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||||
|
||||
setBatchTables([...tableObjects, ...viewObjects]);
|
||||
setCheckedTableKeys([]);
|
||||
};
|
||||
|
||||
const handleConnectionChange = async (connId: string) => {
|
||||
@@ -1364,31 +1430,36 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
|
||||
const handleBatchExport = async (mode: BatchTableExportMode) => {
|
||||
const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||||
if (selectedTables.length === 0) {
|
||||
message.warning('请至少选择一张表');
|
||||
const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||||
if (selectedObjects.length === 0) {
|
||||
message.warning('请至少选择一个对象');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchModalOpen(false);
|
||||
|
||||
const { conn, dbName } = batchDbContext;
|
||||
const tableNames = selectedTables.map(t => t.tableName);
|
||||
const objectNames = selectedObjects.map(t => t.objectName);
|
||||
const selectedViewCount = selectedObjects.filter(item => item.objectType === 'view').length;
|
||||
|
||||
const loadingText = mode === 'backup'
|
||||
? `正在备份选中表 (${tableNames.length})...`
|
||||
? `正在备份选中对象 (${objectNames.length})...`
|
||||
: mode === 'dataOnly'
|
||||
? `正在导出选中表数据 (INSERT) (${tableNames.length})...`
|
||||
: `正在导出选中表结构 (${tableNames.length})...`;
|
||||
? `正在导出选中对象数据 (INSERT) (${objectNames.length})...`
|
||||
: `正在导出选中对象结构 (${objectNames.length})...`;
|
||||
const hide = message.loading(loadingText, 0);
|
||||
try {
|
||||
const app = (window as any).go.app.App;
|
||||
const res = mode === 'dataOnly'
|
||||
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, tableNames)
|
||||
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, mode === 'backup');
|
||||
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, objectNames)
|
||||
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, objectNames, mode === 'backup');
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
if (mode !== 'schema' && selectedViewCount > 0) {
|
||||
message.success(`导出成功(已自动跳过 ${selectedViewCount} 个视图的数据导出)`);
|
||||
} else {
|
||||
message.success('导出成功');
|
||||
}
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
message.error('导出失败: ' + res.message);
|
||||
}
|
||||
@@ -2826,7 +2897,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
反选
|
||||
</Button>
|
||||
<span style={{ color: '#999' }}>
|
||||
已选择 {checkedTableKeys.length} / {batchTables.length} 张表
|
||||
已选择 {checkedTableKeys.length} / {batchTables.length} 个对象
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -2836,14 +2907,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onChange={(values) => setCheckedTableKeys(values as string[])}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{batchTables.map(table => (
|
||||
<Checkbox key={table.key} value={table.key}>
|
||||
<TableOutlined style={{ marginRight: 8 }} />
|
||||
{table.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{groupedBatchObjects.tables.length > 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
|
||||
表 ({groupedBatchObjects.tables.length})
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{groupedBatchObjects.tables.map(table => (
|
||||
<Checkbox key={table.key} value={table.key}>
|
||||
<TableOutlined style={{ marginRight: 8 }} />
|
||||
{table.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{groupedBatchObjects.views.length > 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
|
||||
视图 ({groupedBatchObjects.views.length})
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{groupedBatchObjects.views.map(view => (
|
||||
<Checkbox key={view.key} value={view.key}>
|
||||
<EyeOutlined style={{ marginRight: 8 }} />
|
||||
{view.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,21 @@ import App from './App'
|
||||
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
|
||||
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker'
|
||||
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker'
|
||||
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js'
|
||||
import 'monaco-editor/esm/vs/language/json/monaco.contribution.js'
|
||||
|
||||
(self as any).MonacoEnvironment = {
|
||||
getWorker(_: unknown, label: string) {
|
||||
if (label === 'json') {
|
||||
return new JsonWorker()
|
||||
}
|
||||
return new EditorWorker()
|
||||
},
|
||||
}
|
||||
|
||||
loader.config({ monaco })
|
||||
|
||||
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
|
||||
|
||||
@@ -252,6 +252,12 @@ export interface SqlLog {
|
||||
affectedRows?: number;
|
||||
}
|
||||
|
||||
export interface QueryOptions {
|
||||
maxRows: number;
|
||||
showColumnComment: boolean;
|
||||
showColumnType: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
connections: SavedConnection[];
|
||||
tabs: TabData[];
|
||||
@@ -261,7 +267,7 @@ interface AppState {
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { opacity: number; blur: number };
|
||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||
queryOptions: { maxRows: number };
|
||||
queryOptions: QueryOptions;
|
||||
sqlLogs: SqlLog[];
|
||||
tableAccessCount: Record<string, number>;
|
||||
tableSortPreference: Record<string, 'name' | 'frequency'>;
|
||||
@@ -287,7 +293,7 @@ interface AppState {
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
|
||||
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
||||
|
||||
addSqlLog: (log: SqlLog) => void;
|
||||
clearSqlLogs: () => void;
|
||||
@@ -326,13 +332,15 @@ const sanitizeSqlFormatOptions = (value: unknown): { keywordCase: 'upper' | 'low
|
||||
return { keywordCase: raw.keywordCase === 'lower' ? 'lower' : 'upper' };
|
||||
};
|
||||
|
||||
const sanitizeQueryOptions = (value: unknown): { maxRows: number } => {
|
||||
const sanitizeQueryOptions = (value: unknown): QueryOptions => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const maxRows = Number(raw.maxRows);
|
||||
const showColumnComment = typeof raw.showColumnComment === 'boolean' ? raw.showColumnComment : true;
|
||||
const showColumnType = typeof raw.showColumnType === 'boolean' ? raw.showColumnType : true;
|
||||
if (!Number.isFinite(maxRows) || maxRows <= 0) {
|
||||
return { maxRows: 5000 };
|
||||
return { maxRows: 5000, showColumnComment, showColumnType };
|
||||
}
|
||||
return { maxRows: Math.min(50000, Math.trunc(maxRows)) };
|
||||
return { maxRows: Math.min(50000, Math.trunc(maxRows)), showColumnComment, showColumnType };
|
||||
};
|
||||
|
||||
const sanitizeTableAccessCount = (value: unknown): Record<string, number> => {
|
||||
@@ -383,7 +391,7 @@ export const useStore = create<AppState>()(
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
queryOptions: { maxRows: 5000 },
|
||||
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
|
||||
sqlLogs: [],
|
||||
tableAccessCount: {},
|
||||
tableSortPreference: {},
|
||||
|
||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
2
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const normalizeModuleId = (id: string): string => id.replace(/\\/g, '/')
|
||||
|
||||
const sanitizeChunkToken = (raw: string): string =>
|
||||
String(raw || '')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'misc'
|
||||
|
||||
const firstSegmentAfter = (id: string, marker: string): string => {
|
||||
const idx = id.indexOf(marker)
|
||||
if (idx < 0) return ''
|
||||
const rest = id.substring(idx + marker.length)
|
||||
const [segment] = rest.split('/')
|
||||
return sanitizeChunkToken(segment)
|
||||
}
|
||||
|
||||
const resolveMonacoChunk = (id: string, prefix: string): string | undefined => {
|
||||
if (!id.includes('/node_modules/monaco-editor/')) return undefined
|
||||
|
||||
if (id.includes('/esm/vs/language/typescript/')) {
|
||||
if (id.includes('typescriptServices')) return `${prefix}-ts-services`
|
||||
return `${prefix}-typescript`
|
||||
}
|
||||
if (id.includes('/esm/vs/language/json/')) return `${prefix}-json`
|
||||
if (id.includes('/esm/vs/language/css/')) return `${prefix}-css`
|
||||
if (id.includes('/esm/vs/language/html/')) return `${prefix}-html`
|
||||
|
||||
if (id.includes('/esm/vs/editor/contrib/')) {
|
||||
return `${prefix}-editor-contrib-${firstSegmentAfter(id, '/esm/vs/editor/contrib/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/browser/')) {
|
||||
return `${prefix}-editor-browser-${firstSegmentAfter(id, '/esm/vs/editor/browser/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/common/')) {
|
||||
return `${prefix}-editor-common-${firstSegmentAfter(id, '/esm/vs/editor/common/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/')) return `${prefix}-editor`
|
||||
|
||||
if (id.includes('/esm/vs/base/browser/')) return `${prefix}-base-browser`
|
||||
if (id.includes('/esm/vs/base/common/')) return `${prefix}-base-common`
|
||||
if (id.includes('/esm/vs/base/')) return `${prefix}-base`
|
||||
|
||||
if (id.includes('/esm/vs/platform/')) return `${prefix}-platform`
|
||||
|
||||
return `${prefix}-misc`
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -11,5 +59,61 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist', // Standard Wails output directory
|
||||
emptyOutDir: true,
|
||||
}
|
||||
})
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const moduleId = normalizeModuleId(id)
|
||||
if (!moduleId.includes('node_modules')) return undefined
|
||||
|
||||
const monacoChunk = resolveMonacoChunk(moduleId, 'vendor-monaco')
|
||||
if (monacoChunk) {
|
||||
return monacoChunk
|
||||
}
|
||||
if (moduleId.includes('/node_modules/@monaco-editor/react/')) return 'vendor-monaco-react'
|
||||
|
||||
if (moduleId.includes('/node_modules/antd/es/')) {
|
||||
return `vendor-antd-${firstSegmentAfter(moduleId, '/node_modules/antd/es/')}`
|
||||
}
|
||||
if (moduleId.includes('/node_modules/antd/')) return 'vendor-antd'
|
||||
if (moduleId.includes('/node_modules/@ant-design/icons/')) return 'vendor-antd-icons'
|
||||
if (moduleId.includes('/node_modules/@ant-design/cssinjs/')) return 'vendor-antd-css'
|
||||
if (moduleId.includes('/node_modules/rc-')) return 'vendor-antd-rc'
|
||||
|
||||
if (moduleId.includes('/node_modules/@dnd-kit/')) return 'vendor-dnd-kit'
|
||||
if (moduleId.includes('/node_modules/sql-formatter/')) return 'vendor-sql-formatter'
|
||||
|
||||
if (
|
||||
moduleId.includes('/node_modules/react/')
|
||||
|| moduleId.includes('/node_modules/react-dom/')
|
||||
|| moduleId.includes('/node_modules/scheduler/')
|
||||
) {
|
||||
return 'vendor-react'
|
||||
}
|
||||
|
||||
if (
|
||||
moduleId.includes('/node_modules/zustand/')
|
||||
|| moduleId.includes('/node_modules/uuid/')
|
||||
|| moduleId.includes('/node_modules/clsx/')
|
||||
|| moduleId.includes('/node_modules/react-resizable/')
|
||||
) {
|
||||
return 'vendor-utils'
|
||||
}
|
||||
|
||||
return 'vendor-misc'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const moduleId = normalizeModuleId(id)
|
||||
if (!moduleId.includes('node_modules')) return undefined
|
||||
return resolveMonacoChunk(moduleId, 'worker-monaco')
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -156,6 +156,8 @@ export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.Qu
|
||||
|
||||
export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
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>;
|
||||
|
||||
@@ -306,6 +306,10 @@ export function SelectDriverPackageFile(arg1) {
|
||||
return window['go']['app']['App']['SelectDriverPackageFile'](arg1);
|
||||
}
|
||||
|
||||
export function SelectSSHKeyFile(arg1) {
|
||||
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
|
||||
}
|
||||
|
||||
export function SetWindowTranslucency(arg1, arg2) {
|
||||
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -133,8 +133,17 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
|
||||
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))
|
||||
normalizedType := strings.ToLower(strings.TrimSpace(config.Type))
|
||||
if normalizedType == "sqlite" || normalizedType == "duckdb" {
|
||||
path := strings.TrimSpace(config.Host)
|
||||
if path == "" {
|
||||
path = "(未配置)"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
|
||||
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))
|
||||
}
|
||||
|
||||
if len(config.Hosts) > 0 {
|
||||
b.WriteString(fmt.Sprintf(" 节点数=%d", len(config.Hosts)))
|
||||
|
||||
@@ -465,6 +465,7 @@ func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) con
|
||||
|
||||
func (a *App) DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
dbType := resolveDDLDBType(config)
|
||||
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
@@ -478,10 +479,120 @@ func (a *App) DBShowCreateTable(config connection.ConnectionConfig, dbName strin
|
||||
logger.Error(err, "DBShowCreateTable 获取建表语句失败:%s 表=%s", formatConnSummary(runConfig), tableName)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if shouldFallbackCreateStatement(dbType, sqlStr) {
|
||||
columns, colErr := dbInst.GetColumns(schemaName, pureTableName)
|
||||
if colErr != nil {
|
||||
logger.Error(colErr, "DBShowCreateTable 兜底加载字段失败:%s 表=%s", formatConnSummary(runConfig), tableName)
|
||||
return connection.QueryResult{Success: false, Message: colErr.Error()}
|
||||
}
|
||||
fallbackDDL, buildErr := buildFallbackCreateStatement(dbType, schemaName, pureTableName, columns)
|
||||
if buildErr != nil {
|
||||
logger.Error(buildErr, "DBShowCreateTable 兜底生成 DDL 失败:%s 表=%s", formatConnSummary(runConfig), tableName)
|
||||
return connection.QueryResult{Success: false, Message: buildErr.Error()}
|
||||
}
|
||||
sqlStr = fallbackDDL
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Data: sqlStr}
|
||||
}
|
||||
|
||||
func shouldFallbackCreateStatement(dbType string, ddl string) bool {
|
||||
switch dbType {
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(ddl)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
}
|
||||
if hasCreateTableHead(trimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.Contains(lower, "not fully supported") ||
|
||||
strings.Contains(lower, "not directly supported") ||
|
||||
strings.Contains(lower, "not supported") {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasCreateTableHead(sqlText string) bool {
|
||||
lines := strings.Split(sqlText, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "--") || strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") {
|
||||
continue
|
||||
}
|
||||
return strings.HasPrefix(strings.ToLower(line), "create table")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildFallbackCreateStatement(dbType string, schemaName string, tableName string, columns []connection.ColumnDefinition) (string, error) {
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return "", fmt.Errorf("表名不能为空")
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
return "", fmt.Errorf("未获取到字段定义,无法生成建表语句")
|
||||
}
|
||||
|
||||
qualifiedTable := quoteTableIdentByType(dbType, schemaName, table)
|
||||
columnLines := make([]string, 0, len(columns)+1)
|
||||
primaryKeys := make([]string, 0, 2)
|
||||
|
||||
for _, col := range columns {
|
||||
colNameRaw := strings.TrimSpace(col.Name)
|
||||
if colNameRaw == "" {
|
||||
continue
|
||||
}
|
||||
colType := strings.TrimSpace(col.Type)
|
||||
if colType == "" {
|
||||
colType = "text"
|
||||
}
|
||||
|
||||
colName := quoteIdentByType(dbType, colNameRaw)
|
||||
defParts := []string{fmt.Sprintf("%s %s", colName, colType)}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") {
|
||||
defParts = append(defParts, "NOT NULL")
|
||||
}
|
||||
if col.Default != nil {
|
||||
defVal := strings.TrimSpace(*col.Default)
|
||||
if defVal != "" {
|
||||
defParts = append(defParts, "DEFAULT "+defVal)
|
||||
}
|
||||
}
|
||||
|
||||
columnLines = append(columnLines, " "+strings.Join(defParts, " "))
|
||||
if strings.EqualFold(strings.TrimSpace(col.Key), "PRI") {
|
||||
primaryKeys = append(primaryKeys, colName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(columnLines) == 0 {
|
||||
return "", fmt.Errorf("字段定义为空,无法生成建表语句")
|
||||
}
|
||||
if len(primaryKeys) > 0 {
|
||||
columnLines = append(columnLines, " PRIMARY KEY ("+strings.Join(primaryKeys, ", ")+")")
|
||||
}
|
||||
|
||||
ddl := strings.Builder{}
|
||||
ddl.WriteString("CREATE TABLE ")
|
||||
ddl.WriteString(qualifiedTable)
|
||||
ddl.WriteString(" (\n")
|
||||
ddl.WriteString(strings.Join(columnLines, ",\n"))
|
||||
ddl.WriteString("\n);")
|
||||
return ddl.String(), nil
|
||||
}
|
||||
|
||||
func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -111,13 +112,20 @@ type driverReleaseAssetSizeCacheEntry struct {
|
||||
Err string
|
||||
}
|
||||
|
||||
type driverBundleAssetIndex struct {
|
||||
Assets map[string]int64 `json:"assets"`
|
||||
}
|
||||
|
||||
const (
|
||||
// 默认使用内置 manifest,避免依赖网络与外部仓库 404。
|
||||
defaultDriverManifestURLValue = "builtin://manifest"
|
||||
optionalDriverBundleAssetName = "GoNavi-DriverAgents.zip"
|
||||
optionalDriverBundleIndexAssetName = "GoNavi-DriverAgents-Index.json"
|
||||
driverManifestCacheTTL = 5 * time.Minute
|
||||
driverReleaseAssetSizeCacheTTL = 30 * time.Minute
|
||||
driverReleaseAssetSizeErrorCacheTTL = 30 * time.Second
|
||||
driverReleaseAssetSizeProbeTimeout = 4 * time.Second
|
||||
driverBundleIndexMaxSize = 1 << 20
|
||||
driverManifestMaxSize = 2 << 20
|
||||
driverChecksumPolicyStrict = "strict"
|
||||
driverChecksumPolicyWarn = "warn"
|
||||
@@ -1123,6 +1131,19 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
|
||||
downloadErrs = append(downloadErrs, fmt.Sprintf("%s: %s", candidateURL, strings.TrimSpace(dlErr.Error())))
|
||||
}
|
||||
}
|
||||
bundleURLs := resolveOptionalDriverBundleDownloadURLs()
|
||||
if len(bundleURLs) > 0 {
|
||||
for _, bundleURL := range bundleURLs {
|
||||
if a != nil {
|
||||
a.emitDriverDownloadProgress(driverType, "downloading", 20, 100, fmt.Sprintf("从驱动总包提取 %s 代理", displayName))
|
||||
}
|
||||
source, hash, bundleErr := downloadOptionalDriverAgentFromBundle(a, definition, bundleURL, executablePath)
|
||||
if bundleErr == nil {
|
||||
return source, hash, nil
|
||||
}
|
||||
downloadErrs = append(downloadErrs, fmt.Sprintf("%s: %s", bundleURL, strings.TrimSpace(bundleErr.Error())))
|
||||
}
|
||||
}
|
||||
if a != nil {
|
||||
a.emitDriverDownloadProgress(driverType, "downloading", 92, 100, "未命中预编译包,尝试开发态本地构建")
|
||||
}
|
||||
@@ -1176,6 +1197,112 @@ func downloadOptionalDriverAgentBinary(a *App, definition driverDefinition, urlT
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func downloadOptionalDriverAgentFromBundle(a *App, definition driverDefinition, bundleURL, executablePath string) (string, string, error) {
|
||||
driverType := normalizeDriverType(definition.Type)
|
||||
displayName := resolveDriverDisplayName(definition)
|
||||
trimmedURL := strings.TrimSpace(bundleURL)
|
||||
if trimmedURL == "" {
|
||||
return "", "", fmt.Errorf("驱动总包下载地址为空")
|
||||
}
|
||||
|
||||
bundleTempPath := executablePath + ".bundle.zip.tmp"
|
||||
_ = os.Remove(bundleTempPath)
|
||||
_, err := downloadFileWithHash(trimmedURL, bundleTempPath, func(downloaded, total int64) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
scaledDownloaded, scaledTotal := scaleProgress(downloaded, total, 20, 78)
|
||||
a.emitDriverDownloadProgress(driverType, "downloading", scaledDownloaded, scaledTotal, fmt.Sprintf("下载 %s 驱动总包", displayName))
|
||||
})
|
||||
if err != nil {
|
||||
_ = os.Remove(bundleTempPath)
|
||||
return "", "", fmt.Errorf("下载驱动总包失败:%w", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(bundleTempPath) }()
|
||||
|
||||
reader, err := zip.OpenReader(bundleTempPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("打开驱动总包失败:%w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
entryPath := optionalDriverBundleEntryPath(driverType)
|
||||
expectedBaseName := optionalDriverReleaseAssetName(driverType)
|
||||
findEntry := func() *zip.File {
|
||||
for _, file := range reader.File {
|
||||
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
|
||||
if name == entryPath {
|
||||
return file
|
||||
}
|
||||
}
|
||||
for _, file := range reader.File {
|
||||
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
|
||||
if strings.EqualFold(name, entryPath) {
|
||||
return file
|
||||
}
|
||||
}
|
||||
for _, file := range reader.File {
|
||||
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
|
||||
if strings.EqualFold(filepath.Base(name), expectedBaseName) {
|
||||
return file
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
entry := findEntry()
|
||||
if entry == nil {
|
||||
return "", "", fmt.Errorf("驱动总包内未找到 %s(期望路径 %s)", displayName, entryPath)
|
||||
}
|
||||
if a != nil {
|
||||
a.emitDriverDownloadProgress(driverType, "downloading", 84, 100, fmt.Sprintf("解压 %s 驱动代理", displayName))
|
||||
}
|
||||
|
||||
src, err := entry.Open()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("读取驱动总包条目失败:%w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
tempPath := executablePath + ".tmp"
|
||||
_ = os.Remove(tempPath)
|
||||
dst, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("创建驱动代理临时文件失败:%w", err)
|
||||
}
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
dst.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
return "", "", fmt.Errorf("写入驱动代理失败:%w", err)
|
||||
}
|
||||
if err := dst.Sync(); err != nil {
|
||||
dst.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
return "", "", fmt.Errorf("落盘驱动代理失败:%w", err)
|
||||
}
|
||||
if err := dst.Close(); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return "", "", fmt.Errorf("关闭驱动代理文件失败:%w", err)
|
||||
}
|
||||
if chmodErr := os.Chmod(tempPath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" {
|
||||
_ = os.Remove(tempPath)
|
||||
return "", "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr)
|
||||
}
|
||||
if err := os.Rename(tempPath, executablePath); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return "", "", fmt.Errorf("替换驱动代理失败:%w", err)
|
||||
}
|
||||
if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" {
|
||||
return "", "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr)
|
||||
}
|
||||
hash, err := hashFileSHA256(executablePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("计算驱动代理摘要失败:%w", err)
|
||||
}
|
||||
source := fmt.Sprintf("%s#%s", trimmedURL, filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(entry.Name), "./")))
|
||||
return source, hash, nil
|
||||
}
|
||||
|
||||
func buildOptionalDriverAgentFromSource(definition driverDefinition, executablePath string) (string, error) {
|
||||
driverType := normalizeDriverType(definition.Type)
|
||||
displayName := resolveDriverDisplayName(definition)
|
||||
@@ -1282,6 +1409,46 @@ func optionalDriverReleaseAssetName(driverType string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func optionalDriverBundlePlatformDir(goos string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(goos)) {
|
||||
case "windows":
|
||||
return "Windows"
|
||||
case "darwin":
|
||||
return "MacOS"
|
||||
case "linux":
|
||||
return "Linux"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func optionalDriverBundleEntryPath(driverType string) string {
|
||||
return filepath.ToSlash(filepath.Join(optionalDriverBundlePlatformDir(stdRuntime.GOOS), optionalDriverReleaseAssetName(driverType)))
|
||||
}
|
||||
|
||||
func resolveOptionalDriverBundleDownloadURLs() []string {
|
||||
candidates := make([]string, 0, 2)
|
||||
seen := make(map[string]struct{}, 2)
|
||||
appendURL := func(value string) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
return
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
candidates = append(candidates, trimmed)
|
||||
}
|
||||
|
||||
currentVersion := normalizeVersion(getCurrentVersion())
|
||||
if currentVersion != "" && currentVersion != "0.0.0" {
|
||||
appendURL(fmt.Sprintf("https://github.com/Syngnat/GoNavi/releases/download/v%s/%s", currentVersion, optionalDriverBundleAssetName))
|
||||
}
|
||||
appendURL(fmt.Sprintf("https://github.com/Syngnat/GoNavi/releases/latest/download/%s", optionalDriverBundleAssetName))
|
||||
return candidates
|
||||
}
|
||||
|
||||
func resolveOptionalDriverAgentDownloadURLs(definition driverDefinition, rawURL string) []string {
|
||||
driverType := normalizeDriverType(definition.Type)
|
||||
candidates := make([]string, 0, 3)
|
||||
@@ -1541,6 +1708,15 @@ func loadReleaseAssetSizesCached(cacheKey string, fetch func() (*githubRelease,
|
||||
entry.Err = err.Error()
|
||||
} else {
|
||||
entry.SizeByKey = buildReleaseAssetSizeMap(release)
|
||||
if indexSizes, indexErr := fetchDriverBundleAssetSizeIndex(release); indexErr == nil {
|
||||
for name, size := range indexSizes {
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
if trimmedName == "" || size <= 0 {
|
||||
continue
|
||||
}
|
||||
entry.SizeByKey[trimmedName] = size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
driverReleaseSizeMu.Lock()
|
||||
@@ -1568,6 +1744,50 @@ func buildReleaseAssetSizeMap(release *githubRelease) map[string]int64 {
|
||||
return sizes
|
||||
}
|
||||
|
||||
func fetchDriverBundleAssetSizeIndex(release *githubRelease) (map[string]int64, error) {
|
||||
if release == nil {
|
||||
return nil, fmt.Errorf("release 为空")
|
||||
}
|
||||
indexURL := ""
|
||||
for _, asset := range release.Assets {
|
||||
if strings.EqualFold(strings.TrimSpace(asset.Name), optionalDriverBundleIndexAssetName) {
|
||||
indexURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
||||
break
|
||||
}
|
||||
}
|
||||
if indexURL == "" {
|
||||
return nil, fmt.Errorf("未找到驱动总包索引资产")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: driverReleaseAssetSizeProbeTimeout}
|
||||
req, err := http.NewRequest(http.MethodGet, indexURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "GoNavi-DriverManager")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("拉取驱动总包索引失败:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
limited := io.LimitReader(resp.Body, driverBundleIndexMaxSize)
|
||||
decoder := json.NewDecoder(limited)
|
||||
var index driverBundleAssetIndex
|
||||
if err := decoder.Decode(&index); err != nil {
|
||||
return nil, fmt.Errorf("解析驱动总包索引失败:%w", err)
|
||||
}
|
||||
if len(index.Assets) == 0 {
|
||||
return nil, fmt.Errorf("驱动总包索引为空")
|
||||
}
|
||||
return index.Assets, nil
|
||||
}
|
||||
|
||||
func fetchLatestReleaseForDriverAssets() (*githubRelease, error) {
|
||||
return fetchDriverReleaseByURL(updateAPIURL)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -77,6 +78,48 @@ func (a *App) ImportConfigFile() connection.QueryResult {
|
||||
return connection.QueryResult{Success: true, Data: string(content)}
|
||||
}
|
||||
|
||||
func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult {
|
||||
defaultDir := strings.TrimSpace(currentPath)
|
||||
if defaultDir == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
defaultDir = filepath.Join(home, ".ssh")
|
||||
}
|
||||
}
|
||||
if filepath.Ext(defaultDir) != "" {
|
||||
defaultDir = filepath.Dir(defaultDir)
|
||||
}
|
||||
if defaultDir != "" && !filepath.IsAbs(defaultDir) {
|
||||
if abs, err := filepath.Abs(defaultDir); err == nil {
|
||||
defaultDir = abs
|
||||
}
|
||||
}
|
||||
|
||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "选择 SSH 私钥文件",
|
||||
DefaultDirectory: defaultDir,
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "私钥文件",
|
||||
Pattern: "*.pem;*.key;*.ppk;*id_rsa*",
|
||||
},
|
||||
{
|
||||
DisplayName: "所有文件",
|
||||
Pattern: "*",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
}
|
||||
if abs, err := filepath.Abs(selection); err == nil {
|
||||
selection = abs
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}}
|
||||
}
|
||||
|
||||
// PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据
|
||||
func (a *App) PreviewImportFile(filePath string) connection.QueryResult {
|
||||
if filePath == "" {
|
||||
@@ -485,7 +528,8 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
|
||||
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true); err != nil {
|
||||
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true, viewLookup); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if err := writeSQLFooter(w, runConfig); err != nil {
|
||||
@@ -556,7 +600,7 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
tables := make([]string, 0, len(tableNames))
|
||||
objects := make([]string, 0, len(tableNames))
|
||||
seen := make(map[string]struct{}, len(tableNames))
|
||||
for _, t := range tableNames {
|
||||
t = strings.TrimSpace(t)
|
||||
@@ -567,9 +611,10 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
tables = append(tables, t)
|
||||
objects = append(objects, t)
|
||||
}
|
||||
sort.Strings(tables)
|
||||
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
|
||||
objects = buildExportObjectOrder(runConfig, dbName, objects, viewLookup, false)
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
@@ -583,8 +628,8 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
for _, t := range tables {
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeSchema, includeData); err != nil {
|
||||
for _, objectName := range objects {
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, includeSchema, includeData, viewLookup); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
@@ -623,7 +668,8 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
sort.Strings(tables)
|
||||
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
|
||||
objects := buildExportObjectOrder(runConfig, dbName, tables, viewLookup, true)
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
@@ -637,8 +683,8 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
|
||||
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
for _, t := range tables {
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, true, includeData); err != nil {
|
||||
for _, objectName := range objects {
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, true, includeData, viewLookup); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
@@ -743,6 +789,404 @@ func ensureSQLTerminator(sql string) string {
|
||||
return sql + ";"
|
||||
}
|
||||
|
||||
func buildExportObjectOrder(
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
rawObjects []string,
|
||||
viewLookup map[string]string,
|
||||
includeAllViews bool,
|
||||
) []string {
|
||||
tableSet := make(map[string]string, len(rawObjects))
|
||||
viewSet := make(map[string]string, len(rawObjects))
|
||||
|
||||
for _, rawName := range rawObjects {
|
||||
objectName := strings.TrimSpace(rawName)
|
||||
if objectName == "" {
|
||||
continue
|
||||
}
|
||||
key := normalizeExportObjectKey(config, dbName, objectName)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if canonicalViewName, ok := viewLookup[key]; ok {
|
||||
if strings.TrimSpace(canonicalViewName) == "" {
|
||||
canonicalViewName = objectName
|
||||
}
|
||||
viewSet[key] = canonicalViewName
|
||||
delete(tableSet, key)
|
||||
continue
|
||||
}
|
||||
if _, isView := viewSet[key]; isView {
|
||||
continue
|
||||
}
|
||||
if _, exists := tableSet[key]; !exists {
|
||||
tableSet[key] = objectName
|
||||
}
|
||||
}
|
||||
|
||||
if includeAllViews {
|
||||
for key, viewName := range viewLookup {
|
||||
canonicalViewName := strings.TrimSpace(viewName)
|
||||
if canonicalViewName == "" {
|
||||
continue
|
||||
}
|
||||
viewSet[key] = canonicalViewName
|
||||
delete(tableSet, key)
|
||||
}
|
||||
}
|
||||
|
||||
tables := mapValuesSorted(tableSet)
|
||||
views := mapValuesSorted(viewSet)
|
||||
return append(tables, views...)
|
||||
}
|
||||
|
||||
func mapValuesSorted(values map[string]string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeExportObjectKey(config connection.ConnectionConfig, dbName string, objectName string) string {
|
||||
schemaName, pureName := normalizeSchemaAndTable(config, dbName, objectName)
|
||||
return normalizeExportObjectKeyByParts(schemaName, pureName)
|
||||
}
|
||||
|
||||
func normalizeExportObjectKeyByParts(schemaName, objectName string) string {
|
||||
return strings.ToLower(strings.TrimSpace(qualifyTable(schemaName, objectName)))
|
||||
}
|
||||
|
||||
func listViewNameLookup(dbInst db.Database, config connection.ConnectionConfig, dbName string) map[string]string {
|
||||
viewLookup := make(map[string]string)
|
||||
queries := buildListViewQueries(config, dbName)
|
||||
for _, query := range queries {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
rows, _, err := dbInst.Query(query)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, row := range rows {
|
||||
tableType := strings.ToUpper(exportRowValueCI(row, "table_type", "type"))
|
||||
if tableType != "" && tableType != "VIEW" {
|
||||
continue
|
||||
}
|
||||
schemaName := exportRowValueCI(row, "schema_name", "table_schema", "owner", "schema", "db")
|
||||
viewName := exportRowValueCI(row, "object_name", "view_name", "table_name", "name")
|
||||
if viewName == "" {
|
||||
viewName = exportInferObjectName(row)
|
||||
}
|
||||
if strings.TrimSpace(viewName) == "" {
|
||||
continue
|
||||
}
|
||||
fullName := strings.TrimSpace(qualifyTable(schemaName, viewName))
|
||||
if fullName == "" {
|
||||
fullName = strings.TrimSpace(viewName)
|
||||
}
|
||||
key := normalizeExportObjectKey(config, dbName, fullName)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := viewLookup[key]; !exists {
|
||||
viewLookup[key] = fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
return viewLookup
|
||||
}
|
||||
|
||||
func buildListViewQueries(config connection.ConnectionConfig, dbName string) []string {
|
||||
dbType := resolveDDLDBType(config)
|
||||
escapedDbName := escapeSQLLiteral(dbName)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
queries := []string{
|
||||
fmt.Sprintf(`SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name, TABLE_TYPE AS table_type FROM information_schema.tables WHERE TABLE_TYPE='VIEW' AND TABLE_SCHEMA='%s' ORDER BY TABLE_NAME`, escapedDbName),
|
||||
}
|
||||
if strings.TrimSpace(dbName) != "" {
|
||||
queries = append(queries, fmt.Sprintf("SHOW FULL TABLES FROM %s WHERE Table_type = 'VIEW'", quoteIdentByType("mysql", dbName)))
|
||||
}
|
||||
return queries
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
return []string{
|
||||
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY table_schema, table_name`,
|
||||
}
|
||||
case "sqlserver":
|
||||
safeDBName := strings.TrimSpace(config.Database)
|
||||
if safeDBName == "" {
|
||||
safeDBName = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeDBName == "" {
|
||||
return nil
|
||||
}
|
||||
safeDB := quoteIdentByType("sqlserver", safeDBName)
|
||||
return []string{
|
||||
fmt.Sprintf(`SELECT s.name AS schema_name, v.name AS object_name FROM %s.sys.views v JOIN %s.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name`, safeDB, safeDB),
|
||||
}
|
||||
case "oracle", "dameng":
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return []string{
|
||||
`SELECT VIEW_NAME AS object_name FROM user_views ORDER BY VIEW_NAME`,
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT OWNER AS schema_name, VIEW_NAME AS object_name FROM all_views WHERE OWNER = '%s' ORDER BY VIEW_NAME", strings.ToUpper(escapedDbName)),
|
||||
}
|
||||
case "sqlite":
|
||||
return []string{
|
||||
"SELECT name AS object_name FROM sqlite_master WHERE type='view' ORDER BY name",
|
||||
}
|
||||
case "duckdb":
|
||||
return []string{
|
||||
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name`,
|
||||
}
|
||||
default:
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return []string{
|
||||
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views`,
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf(`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema='%s'`, escapedDbName),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tryGetViewCreateStatement(
|
||||
dbInst db.Database,
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
schemaName string,
|
||||
viewName string,
|
||||
) (string, bool) {
|
||||
queries := buildViewCreateQueries(config, dbName, schemaName, viewName)
|
||||
for _, query := range queries {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
rows, _, err := dbInst.Query(query)
|
||||
if err != nil || len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
createSQL := strings.TrimSpace(extractViewCreateSQL(rows[0]))
|
||||
if createSQL == "" {
|
||||
continue
|
||||
}
|
||||
if looksLikeSelectOrWith(createSQL) {
|
||||
qualifiedView := qualifyTable(schemaName, viewName)
|
||||
createSQL = fmt.Sprintf("CREATE VIEW %s AS %s", quoteQualifiedIdentByType(config.Type, qualifiedView), strings.TrimSuffix(strings.TrimSpace(createSQL), ";"))
|
||||
}
|
||||
return ensureSQLTerminator(createSQL), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func buildViewCreateQueries(config connection.ConnectionConfig, dbName, schemaName, viewName string) []string {
|
||||
dbType := resolveDDLDBType(config)
|
||||
safeSchema := strings.TrimSpace(schemaName)
|
||||
safeView := strings.TrimSpace(viewName)
|
||||
if safeView == "" {
|
||||
return nil
|
||||
}
|
||||
escapedSchema := escapeSQLLiteral(safeSchema)
|
||||
escapedView := escapeSQLLiteral(safeView)
|
||||
escapedDB := escapeSQLLiteral(dbName)
|
||||
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
if safeSchema == "" {
|
||||
safeSchema = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SHOW CREATE VIEW %s.%s", quoteIdentByType("mysql", safeSchema), quoteIdentByType("mysql", safeView)),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SHOW CREATE VIEW %s", quoteIdentByType("mysql", safeView)),
|
||||
}
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
if safeSchema == "" {
|
||||
safeSchema = "public"
|
||||
}
|
||||
regClassName := fmt.Sprintf(`"%s"."%s"`, strings.ReplaceAll(safeSchema, `"`, `""`), strings.ReplaceAll(safeView, `"`, `""`))
|
||||
regClassName = strings.ReplaceAll(regClassName, "'", "''")
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT pg_get_viewdef('%s'::regclass, true) AS ddl", regClassName),
|
||||
}
|
||||
case "sqlserver":
|
||||
schema := safeSchema
|
||||
if schema == "" {
|
||||
schema = "dbo"
|
||||
}
|
||||
safeDBName := strings.TrimSpace(config.Database)
|
||||
if safeDBName == "" {
|
||||
safeDBName = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeDBName == "" {
|
||||
return nil
|
||||
}
|
||||
safeDB := quoteIdentByType("sqlserver", safeDBName)
|
||||
return []string{
|
||||
fmt.Sprintf(`SELECT m.definition AS ddl
|
||||
FROM %s.sys.views v
|
||||
JOIN %s.sys.schemas s ON v.schema_id = s.schema_id
|
||||
JOIN %s.sys.sql_modules m ON v.object_id = m.object_id
|
||||
WHERE s.name = '%s' AND v.name = '%s'`,
|
||||
safeDB, safeDB, safeDB, escapeSQLLiteral(schema), escapedView),
|
||||
}
|
||||
case "oracle", "dameng":
|
||||
if safeSchema == "" {
|
||||
safeSchema = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT DBMS_METADATA.GET_DDL('VIEW', '%s', '%s') AS ddl FROM DUAL", strings.ToUpper(escapedView), strings.ToUpper(escapeSQLLiteral(safeSchema))),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT DBMS_METADATA.GET_DDL('VIEW', '%s') AS ddl FROM DUAL", strings.ToUpper(escapedView)),
|
||||
}
|
||||
case "sqlite":
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT sql AS ddl FROM sqlite_master WHERE type='view' AND name='%s'", escapedView),
|
||||
}
|
||||
case "duckdb":
|
||||
if safeSchema == "" {
|
||||
safeSchema = "main"
|
||||
escapedSchema = "main"
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT sql AS ddl FROM duckdb_views() WHERE view_name = '%s' AND schema_name = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
}
|
||||
default:
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(dbName) != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedDB),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' LIMIT 1", escapedView),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractViewCreateSQL(row map[string]interface{}) string {
|
||||
if row == nil {
|
||||
return ""
|
||||
}
|
||||
ddl := exportRowValueCI(row, "create view", "create_statement", "create_sql", "ddl", "sql", "view_definition", "definition")
|
||||
if ddl != "" {
|
||||
return ddl
|
||||
}
|
||||
for _, value := range row {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
if strings.HasPrefix(lower, "create ") || strings.HasPrefix(lower, "select ") || strings.HasPrefix(lower, "with ") {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func exportRowValueCI(row map[string]interface{}, candidates ...string) string {
|
||||
if len(row) == 0 || len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
for key, value := range row {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
if normalizedKey != candidate {
|
||||
continue
|
||||
}
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func exportInferObjectName(row map[string]interface{}) string {
|
||||
if len(row) == 0 {
|
||||
return ""
|
||||
}
|
||||
for key, value := range row {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
if normalizedKey == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalizedKey, "type") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalizedKey, "table") || strings.Contains(normalizedKey, "view") || strings.Contains(normalizedKey, "name") || strings.Contains(normalizedKey, "ddl") || strings.Contains(normalizedKey, "sql") {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
for _, value := range row {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeSelectOrWith(sql string) bool {
|
||||
trimmed := strings.TrimSpace(strings.TrimSuffix(sql, ";"))
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
return strings.HasPrefix(lower, "select ") || strings.HasPrefix(lower, "with ") || lower == "select" || lower == "with"
|
||||
}
|
||||
|
||||
func escapeSQLLiteral(value string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(value), "'", "''")
|
||||
}
|
||||
|
||||
func isMySQLHexLiteral(s string) bool {
|
||||
if len(s) < 3 || !(strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X")) {
|
||||
return false
|
||||
@@ -798,13 +1242,63 @@ func formatSQLValue(dbType string, v interface{}) string {
|
||||
}
|
||||
}
|
||||
|
||||
func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeSchema bool, includeData bool) error {
|
||||
func dumpTableSQL(
|
||||
w *bufio.Writer,
|
||||
dbInst db.Database,
|
||||
config connection.ConnectionConfig,
|
||||
dbName,
|
||||
tableName string,
|
||||
includeSchema bool,
|
||||
includeData bool,
|
||||
viewLookup map[string]string,
|
||||
) error {
|
||||
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
|
||||
objectKey := normalizeExportObjectKeyByParts(schemaName, pureTableName)
|
||||
_, isView := viewLookup[objectKey]
|
||||
var createSQL string
|
||||
|
||||
if includeSchema {
|
||||
if isView {
|
||||
viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName)
|
||||
if ok {
|
||||
createSQL = viewDDL
|
||||
} else {
|
||||
ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createSQL = ddl
|
||||
}
|
||||
} else {
|
||||
ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||
if err != nil {
|
||||
if viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok {
|
||||
createSQL = viewDDL
|
||||
isView = true
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
createSQL = ddl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if includeData && !includeSchema && !isView {
|
||||
if _, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok {
|
||||
isView = true
|
||||
}
|
||||
}
|
||||
|
||||
objectLabel := "Table"
|
||||
if isView {
|
||||
objectLabel = "View"
|
||||
}
|
||||
|
||||
if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.WriteString(fmt.Sprintf("-- Table: %s\n", qualifyTable(schemaName, pureTableName))); err != nil {
|
||||
if _, err := w.WriteString(fmt.Sprintf("-- %s: %s\n", objectLabel, qualifyTable(schemaName, pureTableName))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.WriteString("-- ----------------------------\n\n"); err != nil {
|
||||
@@ -812,10 +1306,6 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect
|
||||
}
|
||||
|
||||
if includeSchema {
|
||||
createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.WriteString(ensureSQLTerminator(createSQL)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -828,6 +1318,13 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect
|
||||
return nil
|
||||
}
|
||||
|
||||
if isView {
|
||||
if _, err := w.WriteString("-- View data export skipped (INSERT for views is not emitted).\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
qualified := qualifyTable(schemaName, pureTableName)
|
||||
selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.Type, qualified))
|
||||
data, columns, err := dbInst.Query(selectSQL)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
stdRuntime "runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -857,55 +858,55 @@ func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
||||
}
|
||||
|
||||
func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string {
|
||||
return fmt.Sprintf(`@echo off
|
||||
script := `@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
set "SOURCE=%s"
|
||||
set "TARGET=%s"
|
||||
set "STAGED=%s"
|
||||
set "LOG_FILE=%s"
|
||||
set PID=%d
|
||||
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
|
||||
set "TARGET=__GONAVI_UPDATE_TARGET__"
|
||||
set "STAGED=__GONAVI_UPDATE_STAGED__"
|
||||
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
|
||||
set PID=__GONAVI_UPDATE_PID__
|
||||
|
||||
call :log updater started
|
||||
if not exist "%%SOURCE%%" (
|
||||
call :log source file not found: %%SOURCE%%
|
||||
if not exist "%SOURCE%" (
|
||||
call :log source file not found: %SOURCE%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
for %%I in ("%%TARGET%%") do set "TARGET_NAME=%%~nxI"
|
||||
for %%I in ("%%SOURCE%%") do set "SOURCE_EXT=%%~xI"
|
||||
for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
|
||||
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
|
||||
set "SOURCE_EXE="
|
||||
|
||||
if /I "%%SOURCE_EXT%%"==".zip" (
|
||||
set "EXTRACT_DIR=%%STAGED%%\_extract"
|
||||
if exist "%%EXTRACT_DIR%%" (
|
||||
rmdir /S /Q "%%EXTRACT_DIR%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if /I "%SOURCE_EXT%"==".zip" (
|
||||
set "EXTRACT_DIR=%STAGED%\_extract"
|
||||
if exist "%EXTRACT_DIR%" (
|
||||
rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
|
||||
)
|
||||
mkdir "%%EXTRACT_DIR%%" >> "%%LOG_FILE%%" 2>&1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%% NEQ 0 (
|
||||
call :log expand zip failed: %%SOURCE%%
|
||||
mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log expand zip failed: %SOURCE%
|
||||
exit /b 1
|
||||
)
|
||||
if exist "%%EXTRACT_DIR%%\%%TARGET_NAME%%" (
|
||||
set "SOURCE_EXE=%%EXTRACT_DIR%%\%%TARGET_NAME%%"
|
||||
if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
|
||||
set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
|
||||
) else (
|
||||
for /R "%%EXTRACT_DIR%%" %%F in (*.exe) do (
|
||||
for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
|
||||
if not defined SOURCE_EXE (
|
||||
set "SOURCE_EXE=%%~fF"
|
||||
)
|
||||
)
|
||||
)
|
||||
if not defined SOURCE_EXE (
|
||||
call :log no executable found in portable zip: %%SOURCE%%
|
||||
call :log no executable found in portable zip: %SOURCE%
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
set "SOURCE_EXE=%%SOURCE%%"
|
||||
set "SOURCE_EXE=%SOURCE%"
|
||||
)
|
||||
|
||||
:waitloop
|
||||
tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul
|
||||
if %%ERRORLEVEL%%==0 (
|
||||
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
|
||||
if %ERRORLEVEL%==0 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto waitloop
|
||||
)
|
||||
@@ -913,11 +914,11 @@ call :log host process exited
|
||||
|
||||
set /a RETRY=0
|
||||
:move_retry
|
||||
move /Y "%%SOURCE_EXE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%%==0 goto move_done
|
||||
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 goto move_done
|
||||
|
||||
copy /Y "%%SOURCE_EXE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%%==0 goto move_done
|
||||
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 goto move_done
|
||||
|
||||
set /a RETRY+=1
|
||||
if !RETRY! LSS 20 (
|
||||
@@ -929,23 +930,30 @@ call :log replace failed after retries (portable mode, no elevation): check dire
|
||||
exit /b 1
|
||||
|
||||
:move_done
|
||||
start "" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%% NEQ 0 (
|
||||
start "" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log cmd start failed, trying powershell Start-Process
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%%TARGET%%'" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%% NEQ 0 (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%'" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log relaunch failed
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
rmdir /S /Q "%%STAGED%%" >> "%%LOG_FILE%%" 2>&1
|
||||
rmdir /S /Q "%STAGED%" >> "%LOG_FILE%" 2>&1
|
||||
call :log update finished
|
||||
exit /b 0
|
||||
|
||||
:log
|
||||
echo [%%date%% %%time%%] %%*>>"%%LOG_FILE%%"
|
||||
echo [%date% %time%] %*>>"%LOG_FILE%"
|
||||
exit /b 0
|
||||
`, source, target, stagedDir, logPath, pid)
|
||||
`
|
||||
return strings.NewReplacer(
|
||||
"__GONAVI_UPDATE_SOURCE__", source,
|
||||
"__GONAVI_UPDATE_TARGET__", target,
|
||||
"__GONAVI_UPDATE_STAGED__", stagedDir,
|
||||
"__GONAVI_UPDATE_LOG__", logPath,
|
||||
"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
|
||||
).Replace(script)
|
||||
}
|
||||
|
||||
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
|
||||
|
||||
40
internal/app/methods_update_windows_script_test.go
Normal file
40
internal/app/methods_update_windows_script_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildWindowsScriptKeepsBatchForSyntax(t *testing.T) {
|
||||
script := buildWindowsScript(
|
||||
`C:\tmp\GoNavi-v0.4.0-windows-amd64.zip`,
|
||||
`C:\Program Files\GoNavi\GoNavi.exe`,
|
||||
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.4.0`,
|
||||
`C:\Program Files\GoNavi\logs\update-install.log`,
|
||||
13579,
|
||||
)
|
||||
|
||||
mustContain := []string{
|
||||
`for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"`,
|
||||
`for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"`,
|
||||
`for /R "%EXTRACT_DIR%" %%F in (*.exe) do (`,
|
||||
`set "SOURCE_EXE=%%~fF"`,
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("windows update script missing required token: %s\nscript:\n%s", want, script)
|
||||
}
|
||||
}
|
||||
|
||||
mustNotContain := []string{
|
||||
`for %I in ("%TARGET%") do set "TARGET_NAME=%~nxI"`,
|
||||
`for %I in ("%SOURCE%") do set "SOURCE_EXT=%~xI"`,
|
||||
`for /R "%EXTRACT_DIR%" %F in (*.exe) do (`,
|
||||
`set "SOURCE_EXE=%~fF"`,
|
||||
}
|
||||
for _, bad := range mustNotContain {
|
||||
if strings.Contains(script, bad) {
|
||||
t.Fatalf("windows update script contains invalid batch syntax: %s\nscript:\n%s", bad, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
9
internal/db/agent_process_stub.go
Normal file
9
internal/db/agent_process_stub.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package db
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func configureAgentProcess(cmd *exec.Cmd) {
|
||||
_ = cmd
|
||||
}
|
||||
20
internal/db/agent_process_windows.go
Normal file
20
internal/db/agent_process_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const windowsCreateNoWindow = 0x08000000
|
||||
|
||||
func configureAgentProcess(cmd *exec.Cmd) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: windowsCreateNoWindow,
|
||||
}
|
||||
}
|
||||
@@ -67,11 +67,16 @@ func newMySQLAgentClient(executablePath string) (*mysqlAgentClient, error) {
|
||||
if pathText == "" {
|
||||
return nil, fmt.Errorf("MySQL 驱动代理路径为空")
|
||||
}
|
||||
if info, err := os.Stat(pathText); err != nil || info.IsDir() {
|
||||
info, err := os.Stat(pathText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MySQL 驱动代理不存在:%s", pathText)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("MySQL 驱动代理路径是目录:%s", pathText)
|
||||
}
|
||||
|
||||
cmd := exec.Command(pathText)
|
||||
configureAgentProcess(cmd)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 MySQL 驱动代理 stdin 失败:%w", err)
|
||||
|
||||
@@ -501,6 +501,8 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
columnTypeMap := m.loadColumnTypeMap(tableName)
|
||||
|
||||
tx, err := m.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -513,7 +515,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var args []interface{}
|
||||
for k, v := range pk {
|
||||
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
@@ -535,7 +537,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
|
||||
for k, v := range update.Values {
|
||||
sets = append(sets, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
@@ -545,7 +547,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
@@ -569,12 +571,24 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var args []interface{}
|
||||
|
||||
for k, v := range row {
|
||||
normalizedValue, omit := normalizeMySQLValueForInsert(k, v, columnTypeMap)
|
||||
if omit {
|
||||
continue
|
||||
}
|
||||
cols = append(cols, fmt.Sprintf("`%s`", k))
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizedValue)
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
query := fmt.Sprintf("INSERT INTO `%s` () VALUES ()", tableName)
|
||||
res, err := tx.Exec(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("插入未生效:未影响任何行")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -629,6 +643,69 @@ func normalizeMySQLDateTimeValue(value interface{}) interface{} {
|
||||
return value
|
||||
}
|
||||
|
||||
func (m *MySQLDB) loadColumnTypeMap(tableName string) map[string]string {
|
||||
result := map[string]string{}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
columns, err := m.GetColumns("", table)
|
||||
if err != nil {
|
||||
logger.Warnf("加载列元数据失败(不影响提交):表=%s err=%v", table, err)
|
||||
return result
|
||||
}
|
||||
|
||||
for _, col := range columns {
|
||||
name := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
result[name] = strings.TrimSpace(col.Type)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeMySQLValueForInsert(columnName string, value interface{}, columnTypeMap map[string]string) (interface{}, bool) {
|
||||
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
|
||||
if !isMySQLTemporalColumnType(columnType) {
|
||||
return value, false
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if ok && strings.TrimSpace(text) == "" {
|
||||
// INSERT 空时间字段不写入,交给 DB 默认值处理(如 CURRENT_TIMESTAMP)。
|
||||
return nil, true
|
||||
}
|
||||
return normalizeMySQLDateTimeValue(value), false
|
||||
}
|
||||
|
||||
func normalizeMySQLValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} {
|
||||
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
|
||||
if !isMySQLTemporalColumnType(columnType) {
|
||||
return value
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if ok && strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
return normalizeMySQLDateTimeValue(value)
|
||||
}
|
||||
|
||||
func isMySQLTemporalColumnType(columnType string) bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(columnType))
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(raw, "datetime") || strings.Contains(raw, "timestamp") {
|
||||
return true
|
||||
}
|
||||
base := raw
|
||||
if idx := strings.IndexAny(base, "( "); idx >= 0 {
|
||||
base = base[:idx]
|
||||
}
|
||||
return base == "date" || base == "time" || base == "year"
|
||||
}
|
||||
|
||||
func hasTimezoneOffset(text string) bool {
|
||||
pos := strings.LastIndexAny(text, "+-")
|
||||
if pos < 0 || pos < 10 || pos+1 >= len(text) {
|
||||
|
||||
@@ -68,11 +68,16 @@ func newOptionalDriverAgentClient(driverType string, executablePath string) (*op
|
||||
if pathText == "" {
|
||||
return nil, fmt.Errorf("%s 驱动代理路径为空", driverDisplayName(driverType))
|
||||
}
|
||||
if info, err := os.Stat(pathText); err != nil || info.IsDir() {
|
||||
info, err := os.Stat(pathText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s 驱动代理不存在:%s", driverDisplayName(driverType), pathText)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("%s 驱动代理路径是目录:%s", driverDisplayName(driverType), pathText)
|
||||
}
|
||||
|
||||
cmd := exec.Command(pathText)
|
||||
configureAgentProcess(cmd)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 %s 驱动代理 stdin 失败:%w", driverDisplayName(driverType), err)
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,7 +24,14 @@ type SQLiteDB struct {
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
|
||||
dsn := config.Host
|
||||
dsn, err := resolveSQLiteDSN(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureSQLiteParentDir(dsn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
@@ -31,11 +41,140 @@ func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
|
||||
|
||||
// Force verification
|
||||
if err := s.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
s.conn = nil
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveSQLiteDSN(config connection.ConnectionConfig) (string, error) {
|
||||
dsn := strings.TrimSpace(config.Host)
|
||||
if dsn == "" {
|
||||
dsn = strings.TrimSpace(config.Database)
|
||||
}
|
||||
dsn = normalizeSQLitePath(dsn)
|
||||
if dsn == "" {
|
||||
return "", fmt.Errorf("SQLite 需要本地数据库文件路径(例如 /path/to/demo.sqlite)")
|
||||
}
|
||||
if strings.EqualFold(dsn, ":memory:") {
|
||||
return dsn, nil
|
||||
}
|
||||
if looksLikeHostPort(dsn) {
|
||||
return "", fmt.Errorf("SQLite 需要本地数据库文件路径,当前输入看起来是主机地址:%s", dsn)
|
||||
}
|
||||
return dsn, nil
|
||||
}
|
||||
|
||||
func normalizeSQLitePath(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(text, "/") && len(text) > 3 && isWindowsDrivePath(text[1:]) {
|
||||
text = text[1:]
|
||||
}
|
||||
if isWindowsDrivePath(text) {
|
||||
text = trimLegacyPortSuffix(text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func isWindowsDrivePath(path string) bool {
|
||||
if len(path) < 3 {
|
||||
return false
|
||||
}
|
||||
drive := path[0]
|
||||
if !((drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z')) {
|
||||
return false
|
||||
}
|
||||
if path[1] != ':' {
|
||||
return false
|
||||
}
|
||||
sep := path[2]
|
||||
return sep == '\\' || sep == '/'
|
||||
}
|
||||
|
||||
func trimLegacyPortSuffix(path string) string {
|
||||
normalized := path
|
||||
for {
|
||||
idx := strings.LastIndex(normalized, ":")
|
||||
if idx <= 1 || idx+1 >= len(normalized) {
|
||||
return normalized
|
||||
}
|
||||
suffix := normalized[idx+1:]
|
||||
validDigits := true
|
||||
for _, ch := range suffix {
|
||||
if ch < '0' || ch > '9' {
|
||||
validDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validDigits {
|
||||
return normalized
|
||||
}
|
||||
normalized = normalized[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeHostPort(raw string) bool {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(text, `/\`) {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(text), "file:") {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(text, "[") {
|
||||
closing := strings.LastIndex(text, "]")
|
||||
if closing <= 0 || closing+1 >= len(text) {
|
||||
return false
|
||||
}
|
||||
portText := strings.TrimSpace(strings.TrimPrefix(text[closing+1:], ":"))
|
||||
return isValidPortText(portText)
|
||||
}
|
||||
if strings.Count(text, ":") != 1 {
|
||||
return false
|
||||
}
|
||||
split := strings.LastIndex(text, ":")
|
||||
if split <= 0 || split+1 >= len(text) {
|
||||
return false
|
||||
}
|
||||
return isValidPortText(strings.TrimSpace(text[split+1:]))
|
||||
}
|
||||
|
||||
func isValidPortText(text string) bool {
|
||||
port, err := strconv.Atoi(text)
|
||||
return err == nil && port > 0 && port <= 65535
|
||||
}
|
||||
|
||||
func ensureSQLiteParentDir(dsn string) error {
|
||||
text := strings.TrimSpace(dsn)
|
||||
if text == "" || strings.EqualFold(text, ":memory:") {
|
||||
return nil
|
||||
}
|
||||
// file: URI 由驱动处理,避免在这里误判路径格式。
|
||||
if strings.HasPrefix(strings.ToLower(text), "file:") {
|
||||
return nil
|
||||
}
|
||||
path := text
|
||||
if idx := strings.Index(path, "?"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "." || dir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建 SQLite 数据文件目录失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) Close() error {
|
||||
if s.conn != nil {
|
||||
return s.conn.Close()
|
||||
|
||||
79
internal/db/sqlite_impl_test.go
Normal file
79
internal/db/sqlite_impl_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
//go:build gonavi_full_drivers || gonavi_sqlite_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestResolveSQLiteDSNRejectsHostPort(t *testing.T) {
|
||||
_, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: "localhost:3306"})
|
||||
if err == nil {
|
||||
t.Fatalf("期望拦截 host:port 输入")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "本地数据库文件路径") {
|
||||
t.Fatalf("错误提示不符合预期: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSQLiteDSNFallbackDatabase(t *testing.T) {
|
||||
dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Database: "/tmp/demo.sqlite"})
|
||||
if err != nil {
|
||||
t.Fatalf("解析 DSN 失败: %v", err)
|
||||
}
|
||||
if dsn != "/tmp/demo.sqlite" {
|
||||
t.Fatalf("期望使用 database 作为 DSN,实际=%s", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSQLiteDSNNormalizesWindowsLegacyPath(t *testing.T) {
|
||||
dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: `F:\py\py\history.db:3306:3306`})
|
||||
if err != nil {
|
||||
t.Fatalf("解析 DSN 失败: %v", err)
|
||||
}
|
||||
if dsn != `F:\py\py\history.db` {
|
||||
t.Fatalf("期望清理历史端口污染,实际=%s", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSQLiteDSNNormalizesWindowsPathWithLeadingSlash(t *testing.T) {
|
||||
dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: `/F:\py\py\history.db:3306`})
|
||||
if err != nil {
|
||||
t.Fatalf("解析 DSN 失败: %v", err)
|
||||
}
|
||||
if dsn != `F:\py\py\history.db` {
|
||||
t.Fatalf("期望清理前导斜杠与端口污染,实际=%s", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSQLiteParentDirCreatesNestedDir(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
target := filepath.Join(base, "nested", "child", "demo.sqlite")
|
||||
if err := ensureSQLiteParentDir(target); err != nil {
|
||||
t.Fatalf("创建目录失败: %v", err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Dir(target))
|
||||
if err != nil {
|
||||
t.Fatalf("目录不存在: %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("目标不是目录: %s", filepath.Dir(target))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeHostPort(t *testing.T) {
|
||||
if !looksLikeHostPort("localhost:3306") {
|
||||
t.Fatalf("localhost:3306 应识别为 host:port")
|
||||
}
|
||||
if looksLikeHostPort("/tmp/demo.sqlite") {
|
||||
t.Fatalf("/tmp/demo.sqlite 不应识别为 host:port")
|
||||
}
|
||||
if looksLikeHostPort(`C:\sqlite\demo.db`) {
|
||||
t.Fatalf("Windows 路径不应识别为 host:port")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user