mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
Compare commits
32 Commits
release/0.
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9d5f0e98 | ||
|
|
7dc9da0fd0 | ||
|
|
a11d39f981 | ||
|
|
4ce920cc86 | ||
|
|
1965564386 | ||
|
|
f3d325ddab | ||
|
|
c0ae40c638 | ||
|
|
947bdbbe0c | ||
|
|
c99287dc10 | ||
|
|
49c20bef89 | ||
|
|
d26d7d2ff0 | ||
|
|
30f3ac86aa | ||
|
|
741fba4c27 | ||
|
|
baed7a2721 | ||
|
|
4ad074a90c | ||
|
|
6a0f3f3a73 | ||
|
|
ecdbe09c6c | ||
|
|
8d8366c190 | ||
|
|
faef619413 | ||
|
|
0c2b112234 | ||
|
|
ff0661d285 | ||
|
|
5052c7fa6f | ||
|
|
ab420e3d24 | ||
|
|
1616ba8ae4 | ||
|
|
da9a76715a | ||
|
|
3c68325132 | ||
|
|
5f9adcac37 | ||
|
|
d2dad75167 | ||
|
|
98c62fd6bd | ||
|
|
7fd6d78c83 | ||
|
|
c92959f3e8 | ||
|
|
c65e429072 |
51
.github/workflows/dev-build.yml
vendored
51
.github/workflows/dev-build.yml
vendored
@@ -261,9 +261,26 @@ jobs:
|
||||
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||
GOOS="${TARGET_PLATFORM%%/*}"
|
||||
GOARCH="${TARGET_PLATFORM##*/}"
|
||||
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||
DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
|
||||
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
|
||||
|
||||
prepare_duckdb_windows_library() {
|
||||
local lib_dir="$RUNNER_TEMP/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
|
||||
local zip_path="$RUNNER_TEMP/libduckdb-windows-amd64.zip"
|
||||
if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/duckdb.lib" ]; then
|
||||
echo "$lib_dir"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$lib_dir"
|
||||
curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"
|
||||
unzip -qo "$zip_path" -d "$lib_dir"
|
||||
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a"
|
||||
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a"
|
||||
echo "$lib_dir"
|
||||
}
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
BUILD_DRIVER="$DRIVER"
|
||||
@@ -275,22 +292,38 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
TAG="gonavi_${BUILD_DRIVER}_driver"
|
||||
BUILD_TAGS="$TAG"
|
||||
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUTPUT="${OUTPUT}.exe"
|
||||
fi
|
||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
|
||||
DUCKDB_LIB_DIR=""
|
||||
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then
|
||||
DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)"
|
||||
BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib"
|
||||
fi
|
||||
echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})"
|
||||
if [ "$DRIVER" = "duckdb" ]; then
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
if [ -n "$DUCKDB_LIB_DIR" ]; then
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="-L${DUCKDB_LIB_DIR} -lduckdb" PATH="${DUCKDB_LIB_DIR}:$PATH" go build \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll"
|
||||
else
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
fi
|
||||
else
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
|
||||
52
.github/workflows/release.yml
vendored
52
.github/workflows/release.yml
vendored
@@ -252,9 +252,26 @@ jobs:
|
||||
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||
GOOS="${TARGET_PLATFORM%%/*}"
|
||||
GOARCH="${TARGET_PLATFORM##*/}"
|
||||
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||
DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
|
||||
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
|
||||
|
||||
prepare_duckdb_windows_library() {
|
||||
local lib_dir="$RUNNER_TEMP/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
|
||||
local zip_path="$RUNNER_TEMP/libduckdb-windows-amd64.zip"
|
||||
if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/duckdb.lib" ]; then
|
||||
echo "$lib_dir"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$lib_dir"
|
||||
curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"
|
||||
unzip -qo "$zip_path" -d "$lib_dir"
|
||||
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a"
|
||||
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a"
|
||||
echo "$lib_dir"
|
||||
}
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
BUILD_DRIVER="$DRIVER"
|
||||
@@ -266,22 +283,38 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
TAG="gonavi_${BUILD_DRIVER}_driver"
|
||||
BUILD_TAGS="$TAG"
|
||||
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUTPUT="${OUTPUT}.exe"
|
||||
fi
|
||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
|
||||
DUCKDB_LIB_DIR=""
|
||||
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then
|
||||
DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)"
|
||||
BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib"
|
||||
fi
|
||||
echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})"
|
||||
if [ "$DRIVER" = "duckdb" ]; then
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
if [ -n "$DUCKDB_LIB_DIR" ]; then
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="-L${DUCKDB_LIB_DIR} -lduckdb" PATH="${DUCKDB_LIB_DIR}:$PATH" go build \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll"
|
||||
else
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
fi
|
||||
else
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
@@ -534,6 +567,7 @@ jobs:
|
||||
|
||||
REQUIRED_FILES=(
|
||||
"drivers/Windows/duckdb-driver-agent-windows-amd64.exe"
|
||||
"drivers/Windows/duckdb.dll"
|
||||
"drivers/MacOS/duckdb-driver-agent-darwin-amd64"
|
||||
"drivers/MacOS/duckdb-driver-agent-darwin-arm64"
|
||||
"drivers/Linux/duckdb-driver-agent-linux-amd64"
|
||||
|
||||
@@ -5,7 +5,11 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
DEFAULT_DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||
DEFAULT_DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
|
||||
DEFAULT_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64)
|
||||
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
|
||||
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
|
||||
DUCKDB_WINDOWS_SUPPORT_DLL="duckdb.dll"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -14,8 +18,8 @@ usage() {
|
||||
|
||||
选项:
|
||||
--drivers <列表> 指定驱动列表(逗号分隔),例如:kingbase,mongodb
|
||||
--platform <GOOS/GOARCH>
|
||||
目标平台,默认使用当前 Go 环境(go env GOOS/GOARCH)
|
||||
--platform <目标> 目标平台:current、all、GOOS/GOARCH,或逗号分隔列表
|
||||
默认 current(当前 Go 环境)
|
||||
--out-dir <目录> 输出目录根路径,默认:dist/driver-agents
|
||||
--bundle-name <文件名> 驱动总包 zip 名称,默认:GoNavi-DriverAgents.zip
|
||||
--strict 任一驱动构建失败即中断(默认失败后继续,最后汇总)
|
||||
@@ -25,6 +29,8 @@ usage() {
|
||||
./build-driver-agents.sh
|
||||
./build-driver-agents.sh --drivers kingbase
|
||||
./build-driver-agents.sh --platform windows/amd64 --drivers kingbase,mongodb
|
||||
./build-driver-agents.sh --platform all
|
||||
./build-driver-agents.sh --platform darwin/arm64,windows/amd64,linux/amd64
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -33,7 +39,8 @@ normalize_driver() {
|
||||
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)"
|
||||
case "$name" in
|
||||
doris|diros) echo "doris" ;;
|
||||
mariadb|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|mongodb|tdengine|clickhouse)
|
||||
open_gauss|open-gauss) echo "opengauss" ;;
|
||||
mariadb|oceanbase|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|mongodb|tdengine|clickhouse)
|
||||
echo "$name"
|
||||
;;
|
||||
*)
|
||||
@@ -58,6 +65,136 @@ platform_dir_name() {
|
||||
esac
|
||||
}
|
||||
|
||||
current_platform() {
|
||||
echo "$(go env GOOS)/$(go env GOARCH)"
|
||||
}
|
||||
|
||||
append_platform() {
|
||||
local candidate
|
||||
candidate="$1"
|
||||
if [[ "$platform_seen" == *"|$candidate|"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
platforms+=("$candidate")
|
||||
platform_seen="${platform_seen}${candidate}|"
|
||||
}
|
||||
|
||||
normalize_platform() {
|
||||
local value goos goarch platform_dir
|
||||
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
case "$value" in
|
||||
current|"")
|
||||
current_platform
|
||||
;;
|
||||
*/*)
|
||||
goos="${value%%/*}"
|
||||
goarch="${value##*/}"
|
||||
platform_dir="$(platform_dir_name "$goos")"
|
||||
if [[ -z "$goos" || -z "$goarch" || "$platform_dir" == "Unknown" ]]; then
|
||||
return 1
|
||||
fi
|
||||
echo "$goos/$goarch"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
zip_bundle() {
|
||||
local bundle_zip_path="$1"
|
||||
local bundle_stage_dir="$2"
|
||||
local -a bundle_dirs=()
|
||||
local dir
|
||||
|
||||
for dir in "$bundle_stage_dir"/*; do
|
||||
[[ -d "$dir" ]] || continue
|
||||
bundle_dirs+=("$(basename "$dir")")
|
||||
done
|
||||
|
||||
if [[ ${#bundle_dirs[@]} -eq 0 ]]; then
|
||||
echo "❌ 驱动总包 staging 目录为空。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$bundle_zip_path"
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$bundle_stage_dir"
|
||||
zip -qry "$bundle_zip_path" "${bundle_dirs[@]}"
|
||||
)
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
BUNDLE_STAGE_DIR="$bundle_stage_dir" BUNDLE_ZIP_PATH="$bundle_zip_path" python3 - <<'PY'
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
stage = Path(os.environ["BUNDLE_STAGE_DIR"])
|
||||
target = Path(os.environ["BUNDLE_ZIP_PATH"])
|
||||
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in stage.rglob("*"):
|
||||
if path.is_file():
|
||||
zf.write(path, path.relative_to(stage).as_posix())
|
||||
PY
|
||||
else
|
||||
echo "❌ 未找到 zip 或 python3,无法生成驱动总包 zip。"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_duckdb_windows_library() {
|
||||
local cache_root="$1"
|
||||
local lib_dir="$cache_root/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
|
||||
local zip_path="$cache_root/libduckdb-windows-amd64.zip"
|
||||
|
||||
if [[ -f "$lib_dir/duckdb.dll" && -f "$lib_dir/duckdb.lib" ]]; then
|
||||
printf '%s\n' "$lib_dir"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$lib_dir"
|
||||
echo "⬇️ 下载 DuckDB Windows 官方动态库:$DUCKDB_WINDOWS_LIBRARY_URL" >&2
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q "$DUCKDB_WINDOWS_LIBRARY_URL" -O "$zip_path"
|
||||
else
|
||||
echo "❌ 未找到 curl 或 wget,无法下载 DuckDB Windows 动态库。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -qo "$zip_path" -d "$lib_dir"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
DUCKDB_LIB_ZIP="$zip_path" DUCKDB_LIB_DIR="$lib_dir" python3 - <<'PY'
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
zip_path = os.environ["DUCKDB_LIB_ZIP"]
|
||||
target = os.environ["DUCKDB_LIB_DIR"]
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
zf.extractall(target)
|
||||
PY
|
||||
else
|
||||
echo "❌ 未找到 unzip 或 python3,无法解压 DuckDB Windows 动态库。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$lib_dir/duckdb.dll" || ! -f "$lib_dir/duckdb.lib" ]]; then
|
||||
echo "❌ DuckDB Windows 动态库包缺少 duckdb.dll 或 duckdb.lib。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a"
|
||||
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a"
|
||||
printf '%s\n' "$lib_dir"
|
||||
}
|
||||
|
||||
join_by_comma() {
|
||||
local IFS=,
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
driver_csv=""
|
||||
target_platform=""
|
||||
out_root="dist/driver-agents"
|
||||
@@ -103,20 +240,6 @@ if ! command -v go >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$target_platform" ]]; then
|
||||
target_platform="$(go env GOOS)/$(go env GOARCH)"
|
||||
fi
|
||||
|
||||
if [[ "$target_platform" != */* ]]; then
|
||||
echo "❌ --platform 参数格式错误,应为 GOOS/GOARCH,例如 darwin/arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
goos="${target_platform%%/*}"
|
||||
goarch="${target_platform##*/}"
|
||||
platform_key="${goos}-${goarch}"
|
||||
platform_dir="$(platform_dir_name "$goos")"
|
||||
|
||||
declare -a drivers=()
|
||||
if [[ -n "$driver_csv" ]]; then
|
||||
IFS=',' read -r -a raw_drivers <<<"$driver_csv"
|
||||
@@ -130,69 +253,133 @@ if [[ -n "$driver_csv" ]]; then
|
||||
else
|
||||
drivers=("${DEFAULT_DRIVERS[@]}")
|
||||
fi
|
||||
revision_driver_csv="$(join_by_comma "${drivers[@]}")"
|
||||
|
||||
output_dir="${out_root%/}/${platform_key}"
|
||||
declare -a platforms=()
|
||||
platform_seen="|"
|
||||
if [[ -z "$target_platform" ]]; then
|
||||
target_platform="current"
|
||||
fi
|
||||
IFS=',' read -r -a raw_platforms <<<"$target_platform"
|
||||
for item in "${raw_platforms[@]}"; do
|
||||
normalized_platform="$(printf '%s' "$item" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
if [[ "$normalized_platform" == "all" ]]; then
|
||||
for default_platform in "${DEFAULT_PLATFORMS[@]}"; do
|
||||
append_platform "$default_platform"
|
||||
done
|
||||
continue
|
||||
fi
|
||||
normalized_platform="$(normalize_platform "$item")" || {
|
||||
echo "❌ --platform 参数格式错误,应为 current、all、GOOS/GOARCH 或逗号分隔列表,例如 darwin/arm64,windows/amd64"
|
||||
exit 1
|
||||
}
|
||||
append_platform "$normalized_platform"
|
||||
done
|
||||
|
||||
if [[ ${#platforms[@]} -eq 0 ]]; then
|
||||
echo "❌ 未指定有效目标平台。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$out_root"
|
||||
out_root_abs="$(cd "$out_root" && pwd)"
|
||||
bundle_stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-driver-bundle.XXXXXX")"
|
||||
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$bundle_stage_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$output_dir" "$bundle_platform_dir"
|
||||
output_dir_abs="$(cd "$output_dir" && pwd)"
|
||||
bundle_zip_path="$output_dir_abs/$bundle_name"
|
||||
if [[ ${#platforms[@]} -eq 1 ]]; then
|
||||
single_platform="${platforms[0]}"
|
||||
single_platform_key="${single_platform/\//-}"
|
||||
single_output_dir="${out_root%/}/$single_platform_key"
|
||||
mkdir -p "$single_output_dir"
|
||||
bundle_zip_path="$(cd "$single_output_dir" && pwd)/$bundle_name"
|
||||
else
|
||||
bundle_zip_path="$out_root_abs/$bundle_name"
|
||||
fi
|
||||
|
||||
declare -a built_assets=()
|
||||
declare -a failed_drivers=()
|
||||
declare -a skipped_drivers=()
|
||||
|
||||
echo "🚀 开始构建 optional-driver-agent"
|
||||
echo " 平台:$goos/$goarch"
|
||||
echo " 输出目录:$output_dir_abs"
|
||||
echo " 平台:${platforms[*]}"
|
||||
echo " 输出根目录:$out_root_abs"
|
||||
echo " 驱动列表:${drivers[*]}"
|
||||
echo "🧭 生成 driver-agent revision 指纹"
|
||||
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$target_platform"
|
||||
|
||||
for driver in "${drivers[@]}"; do
|
||||
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
|
||||
echo "⚠️ 跳过 duckdb(仅支持 windows/amd64)"
|
||||
skipped_drivers+=("$driver")
|
||||
continue
|
||||
fi
|
||||
for platform in "${platforms[@]}"; do
|
||||
goos="${platform%%/*}"
|
||||
goarch="${platform##*/}"
|
||||
platform_key="${goos}-${goarch}"
|
||||
platform_dir="$(platform_dir_name "$goos")"
|
||||
output_dir="${out_root%/}/${platform_key}"
|
||||
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
|
||||
|
||||
build_driver="$(build_driver_name "$driver")"
|
||||
tag="gonavi_${build_driver}_driver"
|
||||
asset_name="${driver}-driver-agent-${goos}-${goarch}"
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
asset_name="${asset_name}.exe"
|
||||
fi
|
||||
output_path="$output_dir_abs/$asset_name"
|
||||
mkdir -p "$output_dir" "$bundle_platform_dir"
|
||||
output_dir_abs="$(cd "$output_dir" && pwd)"
|
||||
|
||||
cgo_enabled=0
|
||||
if [[ "$driver" == "duckdb" ]]; then
|
||||
cgo_enabled=1
|
||||
fi
|
||||
echo ""
|
||||
echo "🧭 生成 driver-agent revision 指纹:$platform"
|
||||
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$platform" --drivers "$revision_driver_csv"
|
||||
|
||||
echo "🔧 构建 $driver -> $asset_name (tag=$tag, CGO_ENABLED=$cgo_enabled)"
|
||||
set +e
|
||||
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
|
||||
go build -tags "$tag" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
|
||||
build_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ $build_exit -ne 0 ]]; then
|
||||
echo "❌ 构建失败:$driver"
|
||||
failed_drivers+=("$driver")
|
||||
if [[ "$strict_mode" == "true" ]]; then
|
||||
exit $build_exit
|
||||
for driver in "${drivers[@]}"; do
|
||||
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
|
||||
echo "⚠️ 跳过 duckdb($platform 仅支持 windows/amd64)"
|
||||
skipped_drivers+=("duckdb($platform)")
|
||||
continue
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
cp "$output_path" "$bundle_platform_dir/$asset_name"
|
||||
built_assets+=("$asset_name")
|
||||
build_driver="$(build_driver_name "$driver")"
|
||||
tag="gonavi_${build_driver}_driver"
|
||||
build_tags="$tag"
|
||||
asset_name="${driver}-driver-agent-${goos}-${goarch}"
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
asset_name="${asset_name}.exe"
|
||||
fi
|
||||
output_path="$output_dir_abs/$asset_name"
|
||||
|
||||
cgo_enabled=0
|
||||
if [[ "$driver" == "duckdb" ]]; then
|
||||
cgo_enabled=1
|
||||
fi
|
||||
duckdb_lib_dir=""
|
||||
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" == "amd64" ]]; then
|
||||
duckdb_lib_dir="$(prepare_duckdb_windows_library "$bundle_stage_dir")"
|
||||
build_tags="$build_tags duckdb_use_lib"
|
||||
fi
|
||||
|
||||
echo "🔧 构建 $driver -> $asset_name (platform=$platform, tags=$build_tags, CGO_ENABLED=$cgo_enabled)"
|
||||
set +e
|
||||
if [[ -n "$duckdb_lib_dir" ]]; then
|
||||
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
|
||||
CGO_LDFLAGS="-L${duckdb_lib_dir} -lduckdb" PATH="${duckdb_lib_dir}:$PATH" \
|
||||
go build -tags "$build_tags" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
|
||||
else
|
||||
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
|
||||
go build -tags "$build_tags" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
|
||||
fi
|
||||
build_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ $build_exit -ne 0 ]]; then
|
||||
echo "❌ 构建失败:$driver ($platform)"
|
||||
failed_drivers+=("$driver($platform)")
|
||||
if [[ "$strict_mode" == "true" ]]; then
|
||||
exit $build_exit
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
cp "$output_path" "$bundle_platform_dir/$asset_name"
|
||||
if [[ -n "$duckdb_lib_dir" ]]; then
|
||||
cp "$duckdb_lib_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" "$output_dir_abs/$DUCKDB_WINDOWS_SUPPORT_DLL"
|
||||
cp "$duckdb_lib_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" "$bundle_platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL"
|
||||
built_assets+=("$platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL")
|
||||
fi
|
||||
built_assets+=("$platform_dir/$asset_name")
|
||||
done
|
||||
done
|
||||
|
||||
if [[ ${#built_assets[@]} -eq 0 ]]; then
|
||||
@@ -200,25 +387,11 @@ if [[ ${#built_assets[@]} -eq 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$bundle_zip_path"
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$bundle_stage_dir"
|
||||
zip -qry "$bundle_zip_path" "$platform_dir"
|
||||
)
|
||||
elif command -v ditto >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$bundle_stage_dir"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$platform_dir" "$bundle_zip_path"
|
||||
)
|
||||
else
|
||||
echo "❌ 未找到 zip/ditto,无法生成驱动总包 zip。"
|
||||
exit 1
|
||||
fi
|
||||
zip_bundle "$bundle_zip_path" "$bundle_stage_dir"
|
||||
|
||||
echo ""
|
||||
echo "✅ 构建完成"
|
||||
echo " 单文件输出目录:$output_dir_abs"
|
||||
echo " 单文件输出根目录:$out_root_abs"
|
||||
echo " 驱动总包:$bundle_zip_path"
|
||||
echo " 已构建:${built_assets[*]}"
|
||||
if [[ ${#skipped_drivers[@]} -gt 0 ]]; then
|
||||
|
||||
@@ -46,6 +46,13 @@ RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
BUILD_FAILURES=()
|
||||
|
||||
record_build_failure() {
|
||||
local target="$1"
|
||||
BUILD_FAILURES+=("$target")
|
||||
}
|
||||
|
||||
get_file_size_bytes() {
|
||||
local target="$1"
|
||||
if [ ! -f "$target" ]; then
|
||||
@@ -159,6 +166,7 @@ package_macos_release() {
|
||||
wails build -platform "darwin/${platform}" -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ macOS ${platform} 构建失败。${NC}"
|
||||
record_build_failure "macOS ${platform}"
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -213,6 +221,7 @@ if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||
else
|
||||
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
|
||||
record_build_failure "Windows amd64"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 MinGW 工具 (x86_64-w64-mingw32-gcc),跳过 Windows amd64 构建。${NC}"
|
||||
@@ -230,6 +239,7 @@ if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
|
||||
else
|
||||
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
|
||||
record_build_failure "Windows arm64"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 MinGW ARM64 工具 (aarch64-w64-mingw32-gcc),跳过 Windows arm64 构建。${NC}"
|
||||
@@ -259,6 +269,7 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux amd64 构建失败。${NC}"
|
||||
record_build_failure "Linux amd64"
|
||||
fi
|
||||
elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
|
||||
# macOS 或其他系统,尝试交叉编译
|
||||
@@ -279,6 +290,7 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux amd64 交叉编译失败。${NC}"
|
||||
record_build_failure "Linux amd64"
|
||||
fi
|
||||
unset CC CXX CGO_ENABLED
|
||||
else
|
||||
@@ -304,6 +316,7 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux arm64 构建失败。${NC}"
|
||||
record_build_failure "Linux arm64"
|
||||
fi
|
||||
elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
|
||||
# 交叉编译
|
||||
@@ -324,6 +337,7 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux arm64 交叉编译失败。${NC}"
|
||||
record_build_failure "Linux arm64"
|
||||
fi
|
||||
unset CC CXX CGO_ENABLED
|
||||
else
|
||||
@@ -357,12 +371,21 @@ else
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
|
||||
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
|
||||
echo -e "${RED}❌ 构建未完全成功,失败平台:${BUILD_FAILURES[*]}${NC}"
|
||||
echo -e "${YELLOW}📦 已成功生成的产物在 'dist/' 目录下:${NC}"
|
||||
else
|
||||
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
|
||||
fi
|
||||
ls -lh "$DIST_DIR"
|
||||
echo ""
|
||||
echo -e "${GREEN}📋 支持的平台:${NC}"
|
||||
echo " • macOS (Intel/Apple Silicon): .dmg"
|
||||
echo " • macOS (Intel/Apple Silicon): .zip"
|
||||
echo " • Windows (x64/ARM64): .exe"
|
||||
echo " • Linux (x64/ARM64): .tar.gz"
|
||||
echo ""
|
||||
echo -e "${YELLOW}💡 提示:Linux AppImage 包请使用 GitHub Actions CI/CD 构建。${NC}"
|
||||
|
||||
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
12
cmd/optional-driver-agent/provider_oceanbase.go
Normal file
12
cmd/optional-driver-agent/provider_oceanbase.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_oceanbase_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "oceanbase"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.OceanBaseDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_opengauss.go
Normal file
12
cmd/optional-driver-agent/provider_opengauss.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_opengauss_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "opengauss"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.OpenGaussDB{}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mariadb"
|
||||
},
|
||||
"oceanbase": {
|
||||
"engine": "go",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/oceanbase"
|
||||
},
|
||||
"doris": {
|
||||
"engine": "go",
|
||||
"version": "1.9.3",
|
||||
@@ -61,6 +67,12 @@
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/vastbase"
|
||||
},
|
||||
"opengauss": {
|
||||
"engine": "go",
|
||||
"version": "1.11.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/opengauss"
|
||||
},
|
||||
"mongodb": {
|
||||
"engine": "go",
|
||||
"version": "2.5.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,483 +0,0 @@
|
||||
# JVM 缓存可视化编辑设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前用户在公司 Java 项目中经常把缓存或运行时状态直接保存在 JVM 内存中。出现数据脏值、缓存穿透、临时纠偏或排障时,通常只有两种方式:
|
||||
|
||||
- 为特定业务临时补管理接口
|
||||
- 重启应用并依赖重新初始化
|
||||
|
||||
这两种方式都存在明显问题:
|
||||
|
||||
- 临时接口会污染业务代码,并带来后续维护和权限风险
|
||||
- 重启应用成本高,且不适合用于精确修复单个缓存项
|
||||
|
||||
GoNavi 现有已具备三类可复用基础:
|
||||
|
||||
- 统一连接与工作台能力:`frontend/src/components/ConnectionModal.tsx`、`frontend/src/components/Sidebar.tsx`、`frontend/src/components/TabManager.tsx`
|
||||
- 独立运行时能力样板:Redis 通过 `internal/app/methods_redis.go` 和专用前端视图实现,不依赖 SQL `Database` 抽象
|
||||
- AI 与日志能力底座:`frontend/src/components/AIChatPanel.tsx`、`frontend/src/components/QueryEditor.tsx`、`frontend/src/components/LogPanel.tsx`
|
||||
|
||||
因此,GoNavi 有条件扩展出 JVM 运行时连接与受控编辑能力,但不能简单把该需求理解为“新数据库驱动”。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 为 GoNavi 增加统一的 `JVM Connector` 子系统,用于连接和浏览 Java 服务的运行时缓存/管理对象
|
||||
- 在同一套 UI 下支持多种接入模式,并根据目标 JVM 能力自动协商或手动切换
|
||||
- 提供结构化的缓存浏览、值检查、受控修改、操作预览和审计记录
|
||||
- 允许 AI 参与解释、分析和生成修改计划,但不默认开放 AI 自动执行
|
||||
- 尽量避免强依赖 `-javaagent` 或运行时动态 attach,适配企业内对生产进程注入普遍敏感的环境
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不承诺“任意 JVM 内任意对象均可直接读写”
|
||||
- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测
|
||||
- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象
|
||||
- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入
|
||||
- 不绕过目标服务现有认证、鉴权和网络边界
|
||||
|
||||
## 4. 需求与约束
|
||||
|
||||
### 4.1 需求清单
|
||||
|
||||
- 统一配置 JVM 连接
|
||||
- 探测当前 JVM 支持的接入模式与可用能力
|
||||
- 浏览缓存空间、管理对象和受控操作
|
||||
- 查看值快照与元数据
|
||||
- 执行受控修改,并提供 before/after 预览
|
||||
- 将操作结果写入审计记录
|
||||
- 支持 AI 对资源结构和修改方案进行分析
|
||||
|
||||
### 4.2 已确认约束
|
||||
|
||||
- 用户倾向通用型产品形态,但目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
|
||||
- 企业环境下,稳定性与安全性优先级高于“黑科技式通用能力”
|
||||
- 一期应优先基于标准协议和业务可控接入面,而不是侵入式 runtime 操作
|
||||
|
||||
## 5. 现状分析
|
||||
|
||||
### 5.1 GoNavi 架构启示
|
||||
|
||||
- `internal/db/database.go` 面向标准化数据源 CRUD,适合 SQL 类资源
|
||||
- `internal/app/methods_redis.go` 证明 GoNavi 已支持“独立运行时系统能力线”
|
||||
- `frontend/src/components/RedisViewer.tsx` 与 `frontend/src/components/RedisCommandEditor.tsx` 提供了树形浏览、结构化值编辑和控制台交互样板
|
||||
- `frontend/src/components/AIChatPanel.tsx` 与 `frontend/src/components/ai/AIMessageBubble.tsx` 已具备 AI 交互和危险执行确认能力
|
||||
|
||||
### 5.2 结论
|
||||
|
||||
JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector` 子系统,而不是复用 SQL `Database` 接口。
|
||||
|
||||
## 6. 方案比较
|
||||
|
||||
### 方案 A:单一路径通用 Agent
|
||||
|
||||
- 描述:统一要求目标 JVM 通过 `-javaagent` 或运行时 attach 暴露运行时对象访问能力
|
||||
- 优点:
|
||||
- 理论能力上限最高
|
||||
- 可覆盖更多自研缓存和深层对象
|
||||
- 缺点:
|
||||
- 与已知企业约束直接冲突
|
||||
- 风险最高,部署与安全成本高
|
||||
- 与首期产品化目标不匹配
|
||||
|
||||
### 方案 B:多接入模式 + 能力协商
|
||||
|
||||
- 描述:统一做 `JVM Connector`,底层同时支持 `JMX`、`Management Endpoint`、`Agent`
|
||||
- 优点:
|
||||
- 产品形态统一
|
||||
- 能根据目标 JVM 能力降级
|
||||
- 可先做低风险路径,后续再扩展高级模式
|
||||
- 缺点:
|
||||
- 不同模式能力不一致,UI 与权限模型更复杂
|
||||
|
||||
### 方案 C:只做业务侧管理端点
|
||||
|
||||
- 描述:完全放弃通用接入,只提供官方 Starter/管理端点接入
|
||||
- 优点:
|
||||
- 结构最稳,AI 最容易接入
|
||||
- 权限、审计、预览、回滚最好做
|
||||
- 缺点:
|
||||
- 不满足“尽量通用”的产品定位
|
||||
- 无法覆盖仅开放 JMX 的存量系统
|
||||
|
||||
## 7. 选型
|
||||
|
||||
采用方案 B。当前已落地:
|
||||
|
||||
- `JMX Provider`
|
||||
- `Management Endpoint Provider`
|
||||
- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent)
|
||||
|
||||
## 8. 目标架构
|
||||
|
||||
### 8.1 总体结构
|
||||
|
||||
新增统一的 `JVM Connector` 子系统,分为五层:
|
||||
|
||||
- `Connection Layer`
|
||||
- 新增 `jvm` 连接类型
|
||||
- 保存目标地址、认证、允许模式、首选模式、环境标签等配置
|
||||
- `Capability Layer`
|
||||
- 建立连接后探测当前支持的 provider 与能力矩阵
|
||||
- `Provider Layer`
|
||||
- `JMX Provider`
|
||||
- `Management Endpoint Provider`
|
||||
- `Agent Provider`(预留)
|
||||
- `Resource Layer`
|
||||
- 将不同来源统一映射为结构化资源
|
||||
- `Guard Layer`
|
||||
- 统一负责预览、确认、审计、回读验证、错误归一化
|
||||
|
||||
### 8.2 设计原则
|
||||
|
||||
- UI 统一,协议多态
|
||||
- 读写分离,修改必须经过 Guard Layer
|
||||
- provider 不得自行绕过权限与审计链路
|
||||
- 能力不足时显式降级,不提供“看似可用、实际不可执行”的假入口
|
||||
|
||||
## 9. Provider 设计
|
||||
|
||||
### 9.1 JMX Provider
|
||||
|
||||
- 负责:
|
||||
- 建立 JMX/RMI 连接
|
||||
- 发现 MBean
|
||||
- 读取属性
|
||||
- 调用白名单操作
|
||||
- 写入允许修改的白名单属性
|
||||
- 适用场景:
|
||||
- 目标 JVM 已开放 JMX
|
||||
- 缓存或管理对象已暴露为 MBean
|
||||
- 特点:
|
||||
- 低侵入、标准化、可落地
|
||||
- key/value 级资源能力通常有限
|
||||
|
||||
### 9.2 Management Endpoint Provider
|
||||
|
||||
- 负责:
|
||||
- 调用业务服务暴露的 GoNavi 管理端点或 Starter
|
||||
- 返回结构化缓存资源、元数据和受控动作
|
||||
- 提供修改预览与回滚信息
|
||||
- 适用场景:
|
||||
- 业务方愿意接入轻量 Starter/管理端点
|
||||
- 需要更强的 key/value 级浏览与修改能力
|
||||
- 特点:
|
||||
- 最适合产品化和 AI 协同
|
||||
- 权限、脱敏、审计、回滚最容易做
|
||||
|
||||
### 9.3 Agent Provider
|
||||
|
||||
- 负责:
|
||||
- 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口
|
||||
- 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力
|
||||
- 定位:
|
||||
- 高级模式
|
||||
- 不默认启用
|
||||
- 需要目标 Java 服务以 `-javaagent` 方式显式启动
|
||||
|
||||
## 10. 统一资源模型
|
||||
|
||||
建议统一抽象以下资源:
|
||||
|
||||
- `runtime`
|
||||
- 目标 JVM 实例
|
||||
- `cacheNamespace`
|
||||
- 缓存空间,如某个 CacheManager 下的 cacheName
|
||||
- `cacheEntry`
|
||||
- 具体缓存项 key/value
|
||||
- `managedBean`
|
||||
- 可读写的托管对象或 MBean
|
||||
- `operation`
|
||||
- 受控操作,如 `evict`、`put`、`refresh`、`clear`
|
||||
- `auditRecord`
|
||||
- 每次读写与 AI 建议的审计记录
|
||||
|
||||
统一资源模型要求:
|
||||
|
||||
- 每个资源都有稳定 ID、显示名、provider 来源、能力标签、敏感级别
|
||||
- 值快照必须区分原始值、展示值和可编辑值
|
||||
- 资源定位信息必须可写入审计
|
||||
|
||||
## 11. AI 协同设计
|
||||
|
||||
### 11.1 AI 的角色
|
||||
|
||||
AI 在 JVM 场景中只能作为“受控编排者”,不能作为直接执行者。
|
||||
|
||||
AI 可以:
|
||||
|
||||
- 解释缓存/Bean 的结构和当前状态
|
||||
- 生成筛选条件和定位建议
|
||||
- 生成结构化修改计划
|
||||
- 生成风险说明和回滚建议
|
||||
- 对执行前后结果做对比分析
|
||||
|
||||
AI 不应默认做:
|
||||
|
||||
- 直接执行 JVM 修改
|
||||
- 自由生成任意脚本并直写内存
|
||||
- 绕过人工确认直接调用 provider
|
||||
|
||||
### 11.2 AI 输出形态
|
||||
|
||||
AI 不直接输出脚本,而输出结构化变更计划,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"targetType": "cacheEntry",
|
||||
"selector": {
|
||||
"namespace": "userSessionCache",
|
||||
"key": "user:1001"
|
||||
},
|
||||
"action": "updateValue",
|
||||
"payload": {
|
||||
"format": "json",
|
||||
"value": {
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
},
|
||||
"reason": "修复错误缓存态"
|
||||
}
|
||||
```
|
||||
|
||||
### 11.3 AI 执行链路
|
||||
|
||||
1. AI 读取结构化上下文
|
||||
2. AI 产出结构化变更计划
|
||||
3. Guard Layer 校验目标资源、能力和权限
|
||||
4. UI 展示修改预览与风险提示
|
||||
5. 用户确认
|
||||
6. provider 执行
|
||||
7. 系统回读验证并写审计
|
||||
|
||||
### 11.4 一期 AI 边界
|
||||
|
||||
- 支持 AI 分析资源
|
||||
- 支持 AI 生成修改计划
|
||||
- 不默认支持 AI 自动执行修改
|
||||
|
||||
## 12. 页面与交互设计
|
||||
|
||||
### 12.1 连接层
|
||||
|
||||
在 `ConnectionModal` 中新增 `JVM` 类型,建议配置:
|
||||
|
||||
- 连接名称
|
||||
- 目标地址/端口
|
||||
- 认证信息
|
||||
- 允许模式列表
|
||||
- 首选模式
|
||||
- 环境标签(DEV/UAT/PROD)
|
||||
- 默认权限级别(只读/读写)
|
||||
|
||||
### 12.2 侧边栏
|
||||
|
||||
展示结构:
|
||||
|
||||
- 连接
|
||||
- 模式能力
|
||||
- 资源类型
|
||||
- `cacheNamespace` / `managedBean` / `operation`
|
||||
|
||||
每个连接或节点显示能力徽标,例如:
|
||||
|
||||
- `JMX`
|
||||
- `Endpoint`
|
||||
- `Agent`
|
||||
- `只读`
|
||||
- `可写`
|
||||
|
||||
### 12.3 主工作区 Tab
|
||||
|
||||
建议新增以下 Tab 类型:
|
||||
|
||||
- `概览`
|
||||
- `资源浏览`
|
||||
- `值检查器`
|
||||
- `修改预览`
|
||||
- `AI 助手`
|
||||
- `审计记录`
|
||||
|
||||
### 12.4 标准操作流
|
||||
|
||||
1. 用户连接 JVM
|
||||
2. 系统探测 provider 能力
|
||||
3. 用户选择资源并读取快照
|
||||
4. 用户手工修改或让 AI 生成计划
|
||||
5. 系统生成 before/after 预览
|
||||
6. 用户二次确认
|
||||
7. provider 执行
|
||||
8. 系统回读验证
|
||||
9. 写入审计与操作日志
|
||||
|
||||
## 13. 权限与审计
|
||||
|
||||
### 13.1 权限模型
|
||||
|
||||
权限建议分四层:
|
||||
|
||||
- `连接级`
|
||||
- 决定默认 `readonly` / `readwrite`
|
||||
- `模式级`
|
||||
- 决定某 provider 支持哪些动作
|
||||
- `资源级`
|
||||
- 某些资源永远只读
|
||||
- `环境级`
|
||||
- `PROD` 默认强制二次确认,禁用 AI 自动执行
|
||||
|
||||
### 13.2 审计要求
|
||||
|
||||
JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogPanel 样式。
|
||||
|
||||
建议记录:
|
||||
|
||||
- 连接 ID / 名称
|
||||
- provider 类型
|
||||
- 资源定位信息
|
||||
- 动作类型
|
||||
- 修改原因
|
||||
- AI 是否参与
|
||||
- 执行前摘要
|
||||
- 执行后摘要
|
||||
- 结果状态
|
||||
- 耗时
|
||||
- 错误信息
|
||||
|
||||
建议本地独立落盘为 `jvm_audit.jsonl` 或等价结构,不混入 `sqlLogs`。
|
||||
|
||||
## 14. 错误处理与兼容性边界
|
||||
|
||||
### 14.1 错误分层
|
||||
|
||||
- `连接层失败`
|
||||
- 认证失败、证书失败、JMX/RMI 不通、端点 401/403
|
||||
- `能力层失败`
|
||||
- 连接成功但不支持列 key、写值或批量操作
|
||||
- `执行层失败`
|
||||
- 资源不存在、值格式非法、provider 拒绝写入
|
||||
- `验证层失败`
|
||||
- 执行返回成功但回读校验不一致
|
||||
|
||||
所有错误都应显式标明是哪个 provider、哪一层失败,避免泛化为“修改失败”。
|
||||
|
||||
### 14.2 首期兼容性承诺
|
||||
|
||||
优先承诺以下边界:
|
||||
|
||||
- Java 8 / 11 / 17 / 21
|
||||
- Spring Boot 服务优先
|
||||
- JMX 标准 MBean
|
||||
- Management Endpoint 模式下优先支持:
|
||||
- Caffeine
|
||||
- Ehcache
|
||||
- Guava Cache
|
||||
- Spring Cache 抽象下可枚举缓存
|
||||
- 接入 GoNavi Starter 的自研缓存
|
||||
- 值类型首期优先:
|
||||
- string
|
||||
- number
|
||||
- boolean
|
||||
- JSON object / JSON array
|
||||
- map / list 的结构化展示
|
||||
|
||||
### 14.3 首期不承诺
|
||||
|
||||
- 任意 Java 对象深度反射编辑
|
||||
- 无类型信息的二进制对象直接改写
|
||||
- 跨 classloader 任意对象定位
|
||||
- 生产环境默认开放批量危险写入
|
||||
|
||||
## 15. MVP 分期
|
||||
|
||||
### Phase 1:连接与只读探测
|
||||
|
||||
- JVM 连接类型
|
||||
- JMX / Endpoint 能力探测
|
||||
- 资源树浏览
|
||||
- 值查看
|
||||
- 概览页与能力徽标
|
||||
- 不开放写入
|
||||
|
||||
### Phase 2:受控修改与审计
|
||||
|
||||
- 白名单资源写入
|
||||
- before/after 预览
|
||||
- 二次确认
|
||||
- 审计日志
|
||||
- 回读验证
|
||||
- 环境级保护策略
|
||||
|
||||
### Phase 3:AI 协同
|
||||
|
||||
- AI 解释资源
|
||||
- AI 生成修改计划
|
||||
- AI 风险分析
|
||||
- AI 回滚建议
|
||||
- 仍默认不允许 AI 自动执行
|
||||
|
||||
### Phase 4:高级模式
|
||||
|
||||
- Agent Provider
|
||||
- 预埋 Java Agent 的 runtime 资源治理能力
|
||||
- 仅在特殊环境启用
|
||||
|
||||
## 16. 验证策略
|
||||
|
||||
### 16.1 功能验证
|
||||
|
||||
- 能连接 JMX 目标
|
||||
- 能连接 Endpoint 目标
|
||||
- 能列出缓存空间
|
||||
- 能查看 key/value
|
||||
- 能完成受控修改并回读成功
|
||||
|
||||
### 16.2 兼容性验证
|
||||
|
||||
- Java 8 / 11 / 17 / 21
|
||||
- 本地、容器、K8s 内网场景
|
||||
- 开启认证 / 不开启认证
|
||||
- 仅 JMX、仅 Endpoint、双模式并存
|
||||
|
||||
### 16.3 安全验证
|
||||
|
||||
- 只读连接无法写入
|
||||
- `PROD` 环境必须二次确认
|
||||
- AI 无法绕过人工确认直接执行
|
||||
- 审计日志完整记录修改链路
|
||||
|
||||
### 16.4 稳定性验证
|
||||
|
||||
- 目标 JVM 不可达时 UI 不假死
|
||||
- 资源树大数量时支持分页或懒加载
|
||||
- 回读失败时标识“不确定状态”
|
||||
- provider 超时、部分失败、降级路径清晰
|
||||
|
||||
## 17. 风险与缓解
|
||||
|
||||
### 17.1 风险
|
||||
|
||||
- 多 provider 模式会带来能力不一致,用户可能误解“所有 JVM 都能随便改”
|
||||
- JMX 模式的 key/value 级能力可能明显不足
|
||||
- 管理端点模式需要业务接入,推广成本高于纯客户端方案
|
||||
- 若未来引入 Agent 模式,可能引入新的安全审核和兼容性成本
|
||||
|
||||
### 17.2 缓解
|
||||
|
||||
- 在 UI 中显式展示能力矩阵和当前 provider 来源
|
||||
- 所有修改都强制经过预览、确认与审计
|
||||
- 首期将“通用”定义为“统一入口 + 多模式协商”,而不是“单通道万能能力”
|
||||
- Agent 仅作为高级扩展位,避免污染 MVP 边界
|
||||
|
||||
## 18. 最终结论
|
||||
|
||||
JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采用“统一入口、多 provider、能力协商、强 Guard Layer”的产品化方案。
|
||||
|
||||
推荐结论如下:
|
||||
|
||||
- 新增独立的 `JVM Connector` 子系统
|
||||
- 首期支持 `JMX + Management Endpoint`
|
||||
- `Agent` 作为高级可选模式交付
|
||||
- AI 首期支持分析与生成修改计划,不默认开放自动执行
|
||||
- 所有修改必须经过预览、确认、审计和回读验证
|
||||
|
||||
这一路径能够在兼顾企业安全约束的前提下,为用户提供可持续演进的 JVM 运行时缓存治理能力。
|
||||
@@ -1,73 +0,0 @@
|
||||
# 需求进度追踪 - AI聊天发送快捷键
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:AI 聊天发送快捷键
|
||||
- 提出日期:2026-04-28
|
||||
- 负责人:Claude Code
|
||||
- 目标:将 AI 聊天发送快捷键纳入工具中心快捷键管理,支持录制自定义 Enter 相关组合键,降低输入法 Enter 上屏时误发送的风险。
|
||||
- 非目标:不调整后端 AI 服务配置,不改发送按钮行为,不把 AI 发送快捷键放在 AI 设置弹窗的独立入口。
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:工具中心快捷键管理、AI 聊天输入框、本地前端偏好持久化。
|
||||
- 验收标准:工具中心出现“AI 聊天发送”快捷键;默认 Enter 发送;可录制 Enter / Cmd+Enter / Ctrl+Enter / Alt+Enter 等 Enter 相关组合;普通字符键不可录制为 AI 发送;Shift+Enter 始终换行;输入法 composing 状态不发送;刷新后快捷键保持;AI 设置弹窗不再出现独立“聊天输入”快捷键入口。
|
||||
- 依赖与约束:沿用 Zustand `lite-db-storage` 中的 `shortcutOptions` 持久化;保持现有 AI 后端接口不变。
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认输入法 Enter 上屏导致误发送,需要支持录制自定义快捷键,并复用工具中心快捷键体系。
|
||||
- [x] 阶段 2(影响分析):影响工具中心快捷键配置、AIChatPanel、AIChatInput、store 和相关测试。
|
||||
- [x] 阶段 3(方案设计):采用共享 `shortcutOptions` action,AI 输入框局部消费,不走全局快捷键执行器。
|
||||
- [x] 阶段 4(实施计划):计划已按用户反馈调整为工具中心统一方案。
|
||||
- [x] 阶段 5(实现与自检):目标红灯测试已补充,新方案核心实现已完成。
|
||||
- [x] 阶段 6(评审与交付):已完成代码审查反馈修复、目标测试、全量测试、构建、diff 检查和浏览器手工验证。
|
||||
- [ ] 阶段 7(发布与观察):发布后观察用户输入法场景反馈。
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:新增工具中心 AI 发送 action 目标测试;实现 Enter 默认快捷键、Enter 组合录制规则、AI 输入框按 `shortcutOptions` 判定发送;移除 AI 设置独立入口;修复刷新后录制值被启动配置刷新覆盖的问题;限制 AI 发送快捷键只能录制 0 或 1 个修饰键的 Enter 组合;消费 AI 发送快捷键后阻止事件继续冒泡;更新 store、工具函数和输入框提示测试。
|
||||
- 进行中:无。
|
||||
- 待处理:发布后观察输入法场景反馈。
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:默认 Enter 发送在少数未标记 composing 的输入法中仍可能误发。
|
||||
- 阻塞:无。
|
||||
- 缓解措施:用户可在工具中心录制 Cmd+Enter / Ctrl+Enter / Alt+Enter,普通 Enter 不再触发发送;AI 发送录制限制为 Enter 相关组合并保留 Shift+Enter 换行;输入法 composing 状态始终不发送。
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。
|
||||
- 决策 2:`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。
|
||||
- 决策 3:AI 发送快捷键允许默认无修饰键 Enter,但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。
|
||||
- 决策 4:输入法 composing 状态始终不发送。
|
||||
- 决策 5:AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter,拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。
|
||||
- 决策 6:AI 输入框命中发送快捷键后同时执行 `preventDefault` 和 `stopPropagation`,避免事件继续冒泡到全局快捷键处理器。
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:初版两档下拉方案红灯测试。
|
||||
- 结果:已确认旧实现失败。
|
||||
- 证据:`aiChatSendShortcut.test.ts` 缺模块失败;`store.test.ts` 新增字段缺失失败;`AIChatInput.notice.test.tsx` placeholder 仍为 Enter 失败。
|
||||
- 验证项:工具中心统一方案红灯测试。
|
||||
- 结果:已确认旧实现失败。
|
||||
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts` 显示缺少 `sendAIChatMessage` action、`canRecordShortcutForAction` 和自定义 binding 判定失败;`src/store.test.ts` 显示 `shortcutOptions.sendAIChatMessage` 缺失;`src/components/ai/AIChatInput.notice.test.tsx` 显示 placeholder 未渲染 `Meta+Enter 发送`。
|
||||
- 验证项:工具中心统一方案目标绿灯测试。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts`(6 passed)、`src/components/ai/AIChatInput.notice.test.tsx`(2 passed)、`src/store.test.ts`(10 passed)。
|
||||
- 验证项:代码审查反馈红灯测试。
|
||||
- 结果:已确认旧实现失败。
|
||||
- 证据:多修饰键 Enter 组合被误放行、缺少 `consumeAIChatSendShortcutOnKeyDown`、脏持久化 `sendAIChatMessage: A` 未回退到 Enter。
|
||||
- 验证项:代码审查反馈修复后目标测试。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts src/components/ai/AIChatInput.notice.test.tsx src/store.test.ts`(3 files passed,22 tests passed)。
|
||||
- 验证项:浏览器手工验证。
|
||||
- 结果:已通过。
|
||||
- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持;AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送,Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送;Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。
|
||||
- 验证项:前端全量测试。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend test -- --run`(88 files passed,421 tests passed)。
|
||||
- 验证项:diff 空白检查。
|
||||
- 结果:已通过。
|
||||
- 证据:`git diff --check` 无输出。
|
||||
- 验证项:生产构建。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。
|
||||
- 负责人:Claude Code
|
||||
@@ -1,246 +0,0 @@
|
||||
# 需求进度追踪 - JVM缓存可视化编辑
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:JVM缓存可视化编辑
|
||||
- 提出日期:2026-04-22
|
||||
- 负责人:Codex
|
||||
- 目标:完成 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的通用能力交付,降低“改缓存只能写接口或重启应用”的运维与排障成本
|
||||
- 非目标:不承诺覆盖所有 Java 框架/所有对象类型,不绕过目标应用现有安全控制,不在首期开放脚本式任意表达式执行
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:
|
||||
- 交付 JVM 共享契约、连接配置、provider 注册、连接测试与能力探测
|
||||
- 交付 Endpoint / JMX / Agent 三种接入模式及其资源浏览、读值、预览、执行链路
|
||||
- 交付 JVM 资源页、预览弹窗、审计查看、AI 草稿生成与回填能力
|
||||
- 交付 Guard、审计、来源标记、真实集成测试与构建验证
|
||||
- 验收标准:
|
||||
- 可以在 GoNavi 中新增 JVM 连接并完成连接测试
|
||||
- 可以按资源树浏览 JVM 对象并查看结构化快照
|
||||
- 可以对支持写入的资源执行预览和确认写入,且带 Guard 与审计
|
||||
- 可以通过 AI 生成结构化修改草稿,但不会跳过人工确认直接执行
|
||||
- 可以通过真实 JMX 与真实 HTTP contract 完成端到端验证,并通过前后端构建回归
|
||||
- 依赖与约束:
|
||||
- 需复用 GoNavi 当前 Wails + React + driver-agent 架构
|
||||
- 新能力不得破坏现有数据库/Redis 工作流
|
||||
- 高风险写操作必须具备明确鉴权、审计与回滚思路
|
||||
- JMX 模式要求 GoNavi 运行机器本地可用 `java` 可执行文件
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):完成
|
||||
- [x] 阶段 2(影响分析):完成
|
||||
- [x] 阶段 3(方案设计):完成(已形成正式设计文档)
|
||||
- [x] 阶段 4(实施计划):完成(已形成正式实施计划)
|
||||
- [x] 阶段 5(实现与自检):完成(Task 1 至 Task 7 已完成,代码与构建回归通过)
|
||||
- [x] 阶段 6(评审与交付):完成(已完成契约复核、上下文隔离修正、文档回填与交付检查)
|
||||
- [ ] 阶段 7(发布与观察):未开始
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:
|
||||
- 确认 GoNavi 当前存在统一驱动接口与可选 driver-agent 机制
|
||||
- 确认前端已有 Redis 结构化浏览、命令编辑器、Monaco 编辑器、DataGrid 编辑能力可复用
|
||||
- 初步判断 JVM 运行时对象编辑不适合直接复用 SQL/Database 抽象,需新增非数据库协议层
|
||||
- 用户已确认目标方向为“通用型 JVM 接入”
|
||||
- 用户已确认升级到完整模式,开始高风险架构评估
|
||||
- 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
|
||||
- 已形成 JVM 缓存可视化编辑正式设计文档
|
||||
- 已形成 JVM Connector MVP 正式实施计划文档
|
||||
- 已完成 Task 1:JVM 共享契约与配置归一化
|
||||
- 已完成 Task 2:Provider 注册、连接测试与能力探测 API
|
||||
- 已完成 Task 3:JVM 连接表单、图标与展示文案接入
|
||||
- 已完成 Task 4:只读资源浏览与 JVM Tab
|
||||
- 已完成 Task 5:写入预览、Guard 和审计记录
|
||||
- 已完成 Task 6:AI 结构化变更计划
|
||||
- 已完成 Task 7:全量回归、文档回填与交付检查
|
||||
- 已完成 JVM AI 计划解析、资源定位解析、AI 计划到当前 JVM 变更草稿的显式映射,避免把 `payload.format/value` 包装层直接透传到现有 JVM 写入契约
|
||||
- 已完成 AI 聊天面板 JVM 上下文注入、AI 气泡“应用到 JVM 预览”入口以及 JVM 资源页草稿回填闭环
|
||||
- 已完成 JVM AI 计划来源上下文绑定:消息现在绑定生成时的 `tabId + connectionId + providerMode + resourcePath`,避免切换 JVM 页签后误投递到当前激活页
|
||||
- 已完成 Endpoint provider 真实 HTTP contract 与补测,支持资源浏览、读值、预览和执行
|
||||
- 已完成可手工启动的 Java Endpoint fixture 与真实集成补测,可直接验证 Endpoint 模式端到端行为
|
||||
- 已完成 JMX provider 真实 helper 接入与补测,支持 `domain -> mbean -> attribute/operation` 浏览、attribute `set`、operation `invoke`
|
||||
- 已完成 JMX helper 预编译 runtime jar 内嵌分发,运行时不再依赖仓库源码目录,也不再要求本地 `javac`
|
||||
- 已完成 JVM 快照动作提示与 payload 模板回填,前端可直接根据 `supportedActions` 生成草稿
|
||||
- 已完成 AI 参与来源写入 JVM 审计记录,审计页可区分“手工”与“AI 辅助”
|
||||
- 已完成 Agent provider、Agent 连接表单与概览展示,支持通过独立 Agent Base URL 接入 GoNavi Java Agent
|
||||
- 已完成真实 Java Agent fixture 与集成验证,可通过 `-javaagent` 方式真实验证 Agent 模式资源浏览、预览与执行
|
||||
- 已完成 JVM 收口优化:Endpoint 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义
|
||||
- 待处理:
|
||||
- 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:
|
||||
- 直接修改 JVM 内对象属于高风险运行时操作,误改可能造成业务状态污染
|
||||
- 不同缓存框架(Caffeine/Ehcache/Guava/自研 Map)缺少统一标准协议
|
||||
- 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性
|
||||
- 若目标 JVM 不允许预埋或动态注入 Agent,则“通用型”能力边界会明显收缩
|
||||
- 多接入模式会带来能力不一致问题,UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
|
||||
- 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力
|
||||
- 当前 AI 计划若只提供 `namespace + key`,仍更适合 endpoint/cache 风格资源;JMX 复杂 target 仍建议优先使用 `resourcePath`
|
||||
- JMX helper 已改为内嵌 jar 分发,但操作者机器仍需本地存在可用 `java`
|
||||
- Agent 模式要求目标 Java 服务显式以 `-javaagent` 方式启动 GoNavi Java Agent,并额外暴露管理端口
|
||||
- JMX operation preview 仅做参数/签名校验和预览快照,不预测真实副作用
|
||||
- JMX 参数转换当前覆盖基础类型、`ObjectName` 和部分数组;复杂对象写入仍是后续扩展项
|
||||
- 历史旧 AI 消息不包含 JVM 来源上下文,若需要应用到预览,需在目标 JVM 资源页重新生成计划
|
||||
- 阻塞:
|
||||
- 当前开发收口阶段无新增阻塞
|
||||
- 缓解措施:
|
||||
- 优先收敛到标准接入面(JMX / Spring Actuator / Java Agent 三选一)
|
||||
- 首期只支持白名单对象类型与受控写操作
|
||||
- 要求变更审计、预览、确认与失败回滚路径
|
||||
- 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入”
|
||||
- JMX helper 改为内嵌 runtime jar,默认写入用户缓存目录;必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath
|
||||
- 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:先做可行性评估与方案设计,不直接进入实现
|
||||
- 决策 2:默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程
|
||||
- 决策 3:已按完整模式推进,后续方案将优先评估通用 Agent 路径是否成立
|
||||
- 决策 4:由于目标服务大概率不允许 agent/attach,后续推荐方向转为“多接入模式 + 能力协商”
|
||||
- 决策 5:AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入
|
||||
- 决策 6:AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
|
||||
- 决策 7:当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
|
||||
- 决策 8:JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
|
||||
- 决策 9:JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java`
|
||||
- 决策 10:Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:
|
||||
- GoNavi 驱动代理机制核查
|
||||
- GoNavi 现有 Redis/编辑器/UI 复用能力核查
|
||||
- JVM Connector 正式设计文档自检
|
||||
- JVM Connector 实施计划文档自检
|
||||
- Task 1:JVM 共享契约与配置归一化
|
||||
- Task 2:Provider 注册、连接测试与能力探测 API
|
||||
- Task 6:AI 计划解析、资源定位解析、契约映射与页签上下文隔离
|
||||
- Task 7:Java Endpoint fixture 真实集成验证
|
||||
- Task 7:JMX helper 内嵌分发与运行时缓存验证
|
||||
- Task 7:Agent provider 与真实 Java Agent 集成验证
|
||||
- Task 7:后端全量测试
|
||||
- Task 7:前端全量测试
|
||||
- Task 7:前端生产构建
|
||||
- Task 7:Wails 生产构建
|
||||
- 结果:
|
||||
- 已确认存在可复用的连接桥接与编辑器基础设施
|
||||
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
|
||||
- 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节
|
||||
- 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测
|
||||
- Task 1 已完成规格审查与代码质量审查,结论均通过
|
||||
- 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API
|
||||
- Task 2 已完成规格审查与代码质量审查,结论均通过
|
||||
- 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入
|
||||
- Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题
|
||||
- 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点
|
||||
- Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题
|
||||
- 已完成 Task 5:后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力
|
||||
- Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底
|
||||
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
|
||||
- 已完成 Task 6:AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
|
||||
- 已完成 Task 6:AI 聊天消息与 JVM 来源页签绑定,AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
|
||||
- 已完成 Task 7:Java Endpoint fixture,可真实验证 `resources / value / preview / apply` 四个 endpoint contract
|
||||
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过
|
||||
- 已完成 Task 7:JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测
|
||||
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过
|
||||
- 已完成 Task 7:Agent provider、Java agent fixture 与真实 `-javaagent` 集成测试
|
||||
- `go test ./internal/jvm -run 'TestAgentProvider' -count=1` 通过
|
||||
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过(11 tests)
|
||||
- `go test ./... -count=1` 通过
|
||||
- `cd frontend && npm test -- --run` 通过(61 files,259 tests)
|
||||
- `cd frontend && npm run build` 通过;构建中存在既有 chunk size / dynamic import 警告,但未阻塞产物生成
|
||||
- `wails build -clean` 通过,成功生成 macOS 应用包
|
||||
- 已完成 JVM 收口优化:模式能力探测现在按当前 mode 做业务化错误翻译,避免概览页继续回显 `non-JRMP server`、`baseURL is required` 这类原始报错
|
||||
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 再次通过(Endpoint 能力探测只读语义回归)
|
||||
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` 再次通过(能力探测模式透传与中文错误翻译回归)
|
||||
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` 通过(JVM 资源页布局回归)
|
||||
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` 通过(风险等级、审计结果等本地化展示回归)
|
||||
- `cd frontend && npm run build` 再次通过
|
||||
- `wails build -clean` 再次通过,成功生成最新可验收桌面包
|
||||
- 证据(日志/截图/链接):
|
||||
- `cmd/optional-driver-agent/main.go`
|
||||
- `internal/db/database.go`
|
||||
- `frontend/src/components/RedisViewer.tsx`
|
||||
- `frontend/src/components/RedisCommandEditor.tsx`
|
||||
- `frontend/src/components/QueryEditor.tsx`
|
||||
- `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md`
|
||||
- `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`
|
||||
- `internal/connection/types.go`
|
||||
- `internal/jvm/types.go`
|
||||
- `internal/jvm/config.go`
|
||||
- `internal/jvm/config_test.go`
|
||||
- `frontend/src/types.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.test.ts`
|
||||
- `go test ./internal/jvm -count=1`
|
||||
- `go test ./...`
|
||||
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
|
||||
- `cd frontend && npm test -- --run`
|
||||
- `cd frontend && npm run build`
|
||||
- `internal/jvm/provider.go`
|
||||
- `internal/jvm/jmx_provider.go`
|
||||
- `internal/jvm/http_provider.go`
|
||||
- `internal/jvm/http_provider_test.go`
|
||||
- `internal/jvm/jmx_helper.go`
|
||||
- `internal/jvm/jmx_helper_test.go`
|
||||
- `internal/jvm/provider_contract_test.go`
|
||||
- `internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar`
|
||||
- `internal/jvm/jmxhelper_assets/README.md`
|
||||
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java`
|
||||
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java`
|
||||
- `tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java`
|
||||
- `internal/app/methods_jvm.go`
|
||||
- `internal/app/methods_jvm_test.go`
|
||||
- `frontend/wailsjs/go/app/App.d.ts`
|
||||
- `frontend/wailsjs/go/app/App.js`
|
||||
- `frontend/wailsjs/go/models.ts`
|
||||
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1`
|
||||
- `go test ./internal/jvm ./internal/app -count=1`
|
||||
- `wails build -clean`
|
||||
- `frontend/src/components/DatabaseIcons.tsx`
|
||||
- `frontend/src/components/ConnectionModal.tsx`
|
||||
- `frontend/src/utils/jvmRuntimePresentation.ts`
|
||||
- `frontend/src/utils/jvmRuntimePresentation.test.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.test.ts`
|
||||
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
|
||||
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
|
||||
- `cd frontend && npm run build`
|
||||
- `internal/app/methods_jvm.go`
|
||||
- `internal/app/methods_jvm_test.go`
|
||||
- `frontend/src/components/Sidebar.tsx`
|
||||
- `frontend/src/components/TabManager.tsx`
|
||||
- `frontend/src/components/JVMOverview.tsx`
|
||||
- `frontend/src/components/JVMResourceBrowser.tsx`
|
||||
- `frontend/src/components/jvm/JVMModeBadge.tsx`
|
||||
- `frontend/src/store.ts`
|
||||
- `frontend/src/types.ts`
|
||||
- `go test ./internal/app -run 'TestJVM(ListResources|GetValue)' -count=1`
|
||||
- `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1`
|
||||
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
|
||||
- `cd frontend && npm run build`
|
||||
- `internal/jvm/guard.go`
|
||||
- `internal/jvm/guard_test.go`
|
||||
- `internal/jvm/audit_store.go`
|
||||
- `internal/jvm/audit_store_test.go`
|
||||
- `internal/app/methods_jvm.go`
|
||||
- `internal/app/methods_jvm_test.go`
|
||||
- `frontend/src/components/JVMAuditViewer.tsx`
|
||||
- `frontend/src/components/jvm/JVMChangePreviewModal.tsx`
|
||||
- `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1`
|
||||
- `go test ./internal/jvm ./internal/app -count=1`
|
||||
- `cd frontend && npm run build`
|
||||
- `frontend/src/utils/jvmAiPlan.ts`
|
||||
- `frontend/src/utils/jvmAiPlan.test.ts`
|
||||
- `frontend/src/components/AIChatPanel.tsx`
|
||||
- `frontend/src/components/ai/AIMessageBubble.tsx`
|
||||
- `frontend/src/components/JVMResourceBrowser.tsx`
|
||||
- `frontend/src/types.ts`
|
||||
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts`
|
||||
- `go test ./... -count=1`
|
||||
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1`
|
||||
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1`
|
||||
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx`
|
||||
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts`
|
||||
- `cd frontend && npm test -- --run`
|
||||
- `wails build -clean`
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入
|
||||
- 负责人:Codex
|
||||
@@ -1,24 +0,0 @@
|
||||
# SQL 方言适配需求进度追踪
|
||||
|
||||
## 背景
|
||||
|
||||
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
|
||||
- GitHub 相关问题:Refs #402(金仓字段类型/DDL 方言)、Refs #409(Oracle 删除数据 DATE 字面量)。
|
||||
|
||||
## 范围
|
||||
|
||||
- 表设计 ALTER TABLE 预览:按 MySQL-family、PostgreSQL-family、Oracle/Dameng、SQL Server、SQLite、DuckDB、ClickHouse、TDengine 分支生成。
|
||||
- 新建表 DDL 预览:避免 Oracle/Dameng/SQL Server/SQLite/DuckDB/ClickHouse/TDengine 输出 MySQL 表选项。
|
||||
- SQL 自动补全:按当前连接方言解析关键字和函数,避免 Oracle/SQL Server 出现 MySQL-only 提示。
|
||||
- 表设计字段类型:按数据源给出候选类型,不再大量回退到 MySQL 通用类型。
|
||||
- Oracle/Dameng 数据复制/删除 SQL:DATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
|
||||
- `npm run build`
|
||||
|
||||
## 风险与后续
|
||||
|
||||
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER,并用中文注释阻止 MySQL 专属子句外溢。
|
||||
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。
|
||||
@@ -1,71 +0,0 @@
|
||||
# 需求进度追踪 - 发布脚本测试版号与 Mac 打包无交互
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:发布脚本测试版号与 Mac 打包无交互
|
||||
- 提出日期:2026-04-24
|
||||
- 负责人:Codex
|
||||
- 目标:
|
||||
- `build-release.sh` 不再触发 macOS DMG/Finder 排版交互。
|
||||
- `build-release.sh` 与开发态应用内版本号统一使用测试版号来源。
|
||||
- 非目标:
|
||||
- 不调整 GitHub Release 工作流。
|
||||
- 不修改正式发布 tag 版本策略。
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:
|
||||
- 发布脚本 `build-release.sh`
|
||||
- 版本解析逻辑 `internal/app/version.go`
|
||||
- 共享测试版号文件
|
||||
- 验收标准:
|
||||
- `bash build-release.sh` 的 macOS 打包不再调用 `create-dmg` 或触发 Finder 排版。
|
||||
- 本地开发态版本显示与发布脚本默认版本号一致。
|
||||
- 保留环境变量覆盖版本号能力。
|
||||
- 依赖与约束:
|
||||
- 维持现有 Windows/Linux 构建逻辑不变。
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认去掉 DMG 排版,统一测试版号来源
|
||||
- [x] 阶段 2(影响分析):锁定 `build-release.sh` 与 `internal/app/version.go`
|
||||
- [x] 阶段 3(方案设计):共享 `version/dev-version.txt`,macOS 改 ZIP 打包
|
||||
- [x] 阶段 4(实施计划):先补版本回归测试,再改实现
|
||||
- [ ] 阶段 5(实现与自检):
|
||||
- [ ] 阶段 6(评审与交付):
|
||||
- [ ] 阶段 7(发布与观察):
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:
|
||||
- 新增共享测试版号文件。
|
||||
- 新增版本回归测试。
|
||||
- 改造发布脚本 macOS 打包为无交互 ZIP。
|
||||
- 进行中:
|
||||
- 自检验证。
|
||||
- 待处理:
|
||||
- 无。
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:
|
||||
- 正式发版若未覆盖 `GONAVI_VERSION`,默认会使用测试版号。
|
||||
- 阻塞:
|
||||
- 无。
|
||||
- 缓解措施:
|
||||
- 允许通过 `GONAVI_VERSION` 环境变量显式覆盖。
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:以 `version/dev-version.txt` 作为本地开发/测试共享版本号来源。
|
||||
- 决策 2:发布脚本的 macOS 产物改为 ZIP,避免 `create-dmg` 的 Finder 交互。
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:
|
||||
- 版本回归测试
|
||||
- 发布脚本语法检查
|
||||
- 发布脚本运行输出
|
||||
- 结果:
|
||||
- 进行中
|
||||
- 证据(日志/截图/链接):
|
||||
- 待补充
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:
|
||||
- 跑通回归测试和脚本验证,确认输出产物与版本号
|
||||
- 负责人:
|
||||
- Codex
|
||||
@@ -1 +1 @@
|
||||
d0464f9da25e9356e61652e638c99ffe
|
||||
d0464f9da25e9356e61652e638c99ffe
|
||||
|
||||
@@ -63,6 +63,25 @@ body, #root {
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-node-content-wrapper {
|
||||
width: auto !important;
|
||||
min-width: 0;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-switcher {
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-iconEle {
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-title {
|
||||
@@ -326,35 +345,194 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */
|
||||
.driver-manager-table .ant-table-sticky-scroll {
|
||||
display: none !important;
|
||||
.driver-manager-modal .ant-modal-body {
|
||||
background: var(--ant-color-bg-layout, #f5f5f5);
|
||||
}
|
||||
|
||||
/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content,
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body {
|
||||
overflow-x: auto !important;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal,
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.driver-manager-table-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.driver-manager-footer {
|
||||
width: 100%;
|
||||
.driver-manager-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.driver-manager-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.driver-manager-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-manager-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(64px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.driver-manager-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
min-height: 58px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(5, 5, 5, 0.02);
|
||||
}
|
||||
|
||||
.driver-manager-stat span:first-child {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.driver-manager-stat-warning span:first-child {
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.driver-manager-directory-panel {
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.driver-manager-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.driver-manager-search {
|
||||
min-width: 280px;
|
||||
flex: 1 1 360px;
|
||||
}
|
||||
|
||||
.driver-manager-toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.driver-manager-list-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.driver-manager-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.driver-manager-card {
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.driver-manager-card-warning {
|
||||
border-color: rgba(250, 173, 20, 0.35);
|
||||
}
|
||||
|
||||
.driver-manager-card-ready {
|
||||
border-color: rgba(82, 196, 26, 0.22);
|
||||
}
|
||||
|
||||
.driver-manager-card-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 38%);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.driver-manager-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-manager-title-row,
|
||||
.driver-manager-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-manager-driver-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.driver-manager-meta-row {
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.driver-manager-update-note {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(250, 173, 20, 0.1);
|
||||
}
|
||||
|
||||
.driver-manager-note-text,
|
||||
.driver-manager-muted-message {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.driver-manager-muted-message {
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.driver-manager-card-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-manager-control-block {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.driver-manager-control-label,
|
||||
.driver-manager-small-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.driver-manager-version-control {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.driver-manager-version-lock {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.driver-manager-card-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.driver-manager-card-actions .ant-btn {
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.driver-manager-footer-actions {
|
||||
@@ -363,17 +541,20 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.driver-manager-hscroll {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
background: transparent;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.driver-manager-header,
|
||||
.driver-manager-card-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.driver-manager-hscroll-inner {
|
||||
height: 1px;
|
||||
.driver-manager-stats {
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.driver-manager-card-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.security-update-action-btn.ant-btn,
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
import SnippetSettingsModal from './components/SnippetSettingsModal';
|
||||
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
|
||||
import DataSyncModal from './components/DataSyncModal';
|
||||
import DriverManagerModal from './components/DriverManagerModal';
|
||||
@@ -19,7 +20,7 @@ import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModa
|
||||
import { DEFAULT_APPEARANCE, useStore } from './store';
|
||||
import { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
|
||||
import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
|
||||
import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
|
||||
import { DENSITY_OPTIONS, sanitizeDataTableDensity } from './utils/dataGridDisplay';
|
||||
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
|
||||
import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics';
|
||||
import { resolveAboutDisplayVersion } from './utils/appVersionDisplay';
|
||||
@@ -64,12 +65,15 @@ import {
|
||||
ShortcutAction,
|
||||
canRecordShortcutForAction,
|
||||
eventToShortcut,
|
||||
findReservedConflicts,
|
||||
getShortcutDisplay,
|
||||
isEditableElement,
|
||||
isShortcutMatch,
|
||||
normalizeShortcutCombo,
|
||||
splitConflictsByContext,
|
||||
type ConflictInfo,
|
||||
} from './utils/shortcuts';
|
||||
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './utils/windowStateUi';
|
||||
import { resolveTitleBarToggleIconKey, resolveWindowsScaleCheckDelayMs, shouldApplyWindowsScaleFix, shouldToggleMaximisedWindowForScaleFix, type WindowsScaleCheckTrigger } from './utils/windowStateUi';
|
||||
import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds';
|
||||
import {
|
||||
SIDEBAR_UTILITY_ITEM_KEYS,
|
||||
@@ -630,6 +634,7 @@ function App() {
|
||||
let lastRatio = Number(window.devicePixelRatio) || 1;
|
||||
let lastFixAt = 0;
|
||||
let activationTimer: number | null = null;
|
||||
let resizeTimer: number | null = null;
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
@@ -669,12 +674,12 @@ function App() {
|
||||
}
|
||||
|
||||
try {
|
||||
WindowToggleMaximise();
|
||||
await wait(48);
|
||||
WindowToggleMaximise();
|
||||
await wait(64);
|
||||
WindowUnmaximise();
|
||||
await wait(96);
|
||||
WindowMaximise();
|
||||
await wait(96);
|
||||
} catch (e) {
|
||||
console.warn("Wails Window maximise toggle unavailable in fixWindowScaleIfNeeded", e);
|
||||
console.warn("Wails Window maximise restore unavailable in fixWindowScaleIfNeeded", e);
|
||||
}
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
lastFixAt = Date.now();
|
||||
@@ -687,7 +692,7 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason !== 'ratio-change' && !hasViewportScaleDrift) {
|
||||
if (!shouldApplyWindowsScaleFix(reason, hasViewportScaleDrift)) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
lastFixAt = Date.now();
|
||||
return;
|
||||
@@ -718,6 +723,24 @@ function App() {
|
||||
void fixWindowScaleIfNeeded('ratio-change');
|
||||
};
|
||||
|
||||
const scheduleDevicePixelRatioCheck = (trigger: WindowsScaleCheckTrigger) => {
|
||||
if (cancelled) return;
|
||||
const delayMs = resolveWindowsScaleCheckDelayMs(trigger);
|
||||
if (delayMs <= 0) {
|
||||
checkDevicePixelRatio();
|
||||
return;
|
||||
}
|
||||
|
||||
if (resizeTimer !== null) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
}
|
||||
resizeTimer = window.setTimeout(() => {
|
||||
resizeTimer = null;
|
||||
if (cancelled) return;
|
||||
checkDevicePixelRatio();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const scheduleActivationFix = () => {
|
||||
if (cancelled) return;
|
||||
if (activationTimer !== null) {
|
||||
@@ -732,7 +755,7 @@ function App() {
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
if (cancelled) return;
|
||||
checkDevicePixelRatio();
|
||||
scheduleDevicePixelRatioCheck('focus');
|
||||
scheduleActivationFix();
|
||||
};
|
||||
|
||||
@@ -741,18 +764,22 @@ function App() {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
checkDevicePixelRatio();
|
||||
scheduleDevicePixelRatioCheck('visibilitychange');
|
||||
scheduleActivationFix();
|
||||
};
|
||||
|
||||
const handlePageShow = () => {
|
||||
if (cancelled) return;
|
||||
checkDevicePixelRatio();
|
||||
scheduleDevicePixelRatioCheck('pageshow');
|
||||
scheduleActivationFix();
|
||||
};
|
||||
|
||||
const handleWindowResize = () => {
|
||||
scheduleDevicePixelRatioCheck('resize');
|
||||
};
|
||||
|
||||
const pollTimer = window.setInterval(checkDevicePixelRatio, 900);
|
||||
window.addEventListener('resize', checkDevicePixelRatio);
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
window.addEventListener('pageshow', handlePageShow);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
@@ -762,8 +789,11 @@ function App() {
|
||||
if (activationTimer !== null) {
|
||||
window.clearTimeout(activationTimer);
|
||||
}
|
||||
if (resizeTimer !== null) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
}
|
||||
window.clearInterval(pollTimer);
|
||||
window.removeEventListener('resize', checkDevicePixelRatio);
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
window.removeEventListener('pageshow', handlePageShow);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
@@ -1860,7 +1890,20 @@ function App() {
|
||||
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
|
||||
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
|
||||
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
|
||||
const [isSnippetModalOpen, setIsSnippetModalOpen] = useState(false);
|
||||
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
|
||||
const shortcutConflictMap = useMemo(() => {
|
||||
const map: Partial<Record<ShortcutAction, ConflictInfo[]>> = {};
|
||||
for (const action of SHORTCUT_ACTION_ORDER) {
|
||||
const binding = shortcutOptions[action];
|
||||
if (!binding?.enabled || !binding.combo) continue;
|
||||
const conflicts = findReservedConflicts(normalizeShortcutCombo(binding.combo));
|
||||
if (conflicts.length > 0) {
|
||||
map[action] = conflicts;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [shortcutOptions]);
|
||||
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
|
||||
const [isDataRootModalOpen, setIsDataRootModalOpen] = useState(false);
|
||||
const [dataRootInfo, setDataRootInfo] = useState<any>(null);
|
||||
@@ -2202,9 +2245,15 @@ function App() {
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-fullscreen');
|
||||
return;
|
||||
}
|
||||
await WindowToggleMaximise();
|
||||
const isMaximised = await WindowIsMaximised().catch(() => false);
|
||||
if (isMaximised) {
|
||||
WindowUnmaximise();
|
||||
} else {
|
||||
WindowMaximise();
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 96));
|
||||
await syncWindowStateFromRuntime();
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-toggle-maximise');
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-set-maximise-state');
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
@@ -2222,10 +2271,37 @@ function App() {
|
||||
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
|
||||
const rafRef = React.useRef<number | null>(null);
|
||||
const ghostRef = React.useRef<HTMLDivElement>(null);
|
||||
const sidebarDragBodyStyleRef = React.useRef<{ cursor: string; userSelect: string; webkitUserSelect: string } | null>(null);
|
||||
const latestMouseX = React.useRef<number>(0); // Store latest mouse position
|
||||
const sidebarResizeHandleWidth = Math.max(16, Math.round(16 * effectiveUiScale));
|
||||
|
||||
const restoreSidebarDragBodyStyles = () => {
|
||||
if (!sidebarDragBodyStyleRef.current || typeof document === 'undefined') {
|
||||
sidebarDragBodyStyleRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = sidebarDragBodyStyleRef.current;
|
||||
document.body.style.cursor = previous.cursor;
|
||||
document.body.style.userSelect = previous.userSelect;
|
||||
(document.body.style as any).WebkitUserSelect = previous.webkitUserSelect;
|
||||
sidebarDragBodyStyleRef.current = null;
|
||||
};
|
||||
|
||||
const handleSidebarMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
sidebarDragBodyStyleRef.current = {
|
||||
cursor: document.body.style.cursor,
|
||||
userSelect: document.body.style.userSelect,
|
||||
webkitUserSelect: (document.body.style as any).WebkitUserSelect || '',
|
||||
};
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
(document.body.style as any).WebkitUserSelect = 'none';
|
||||
}
|
||||
|
||||
if (ghostRef.current) {
|
||||
ghostRef.current.style.left = `${sidebarWidth}px`;
|
||||
@@ -2271,6 +2347,7 @@ function App() {
|
||||
if (ghostRef.current) {
|
||||
ghostRef.current.style.display = 'none';
|
||||
}
|
||||
restoreSidebarDragBodyStyles();
|
||||
|
||||
sidebarDragRef.current = null;
|
||||
document.removeEventListener('mousemove', handleSidebarMouseMove);
|
||||
@@ -2365,13 +2442,29 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenSnippetSettingsEvent = () => {
|
||||
setIsSnippetModalOpen(true);
|
||||
};
|
||||
window.addEventListener('gonavi:open-snippet-settings', handleOpenSnippetSettingsEvent as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('gonavi:open-snippet-settings', handleOpenSnippetSettingsEvent as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMacRuntime || !useNativeMacWindowControls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMacNativeEscapeCapture = (event: KeyboardEvent) => {
|
||||
if (!shouldSuppressMacNativeEscapeExit(isMacRuntime, useNativeMacWindowControls, useStore.getState().windowState === 'fullscreen', event)) {
|
||||
if (!shouldSuppressMacNativeEscapeExit(
|
||||
isMacRuntime,
|
||||
useNativeMacWindowControls,
|
||||
useStore.getState().windowState === 'fullscreen',
|
||||
event,
|
||||
{ isEditableTarget: isEditableElement(event.target) },
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
@@ -2483,6 +2576,17 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
const reservedConflicts = findReservedConflicts(normalizedCombo);
|
||||
if (reservedConflicts.length > 0) {
|
||||
const { hasMonaco, hasOther, monacoLabels, otherLabels, otherContexts } = splitConflictsByContext(reservedConflicts);
|
||||
if (hasMonaco) {
|
||||
void message.info(`已覆盖编辑器「${monacoLabels}」默认快捷键`, 4);
|
||||
}
|
||||
if (hasOther) {
|
||||
void message.warning(`与${otherContexts}「${otherLabels}」冲突,可能失效`, 4);
|
||||
}
|
||||
}
|
||||
|
||||
updateShortcut(capturingShortcutAction, { combo: normalizedCombo, enabled: true });
|
||||
setCapturingShortcutAction(null);
|
||||
};
|
||||
@@ -2660,7 +2764,7 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58, position: 'relative' }}>
|
||||
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58, paddingRight: sidebarResizeHandleWidth, position: 'relative' }}>
|
||||
<div style={{ height: '100%', opacity: connectionWorkbenchState.ready ? 1 : 0.72, pointerEvents: connectionWorkbenchState.ready ? 'auto' : 'none' }}>
|
||||
<Sidebar onEditConnection={handleEditConnection} />
|
||||
</div>
|
||||
@@ -2698,6 +2802,25 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
onMouseDown={handleSidebarMouseDown}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title="拖动调整宽度"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: sidebarResizeHandleWidth,
|
||||
cursor: 'col-resize',
|
||||
zIndex: 3,
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating SQL Log Toggle */}
|
||||
@@ -2737,22 +2860,6 @@ function App() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Resize Handle */}
|
||||
<div
|
||||
onMouseDown={handleSidebarMouseDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '5px',
|
||||
cursor: 'col-resize',
|
||||
zIndex: 100,
|
||||
// background: 'transparent' // transparent usually, visible on hover if desired
|
||||
}}
|
||||
title="拖动调整宽度"
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ background: bgContent, overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
|
||||
{securityUpdateEntryVisibility.showBanner && !isSecurityUpdateBannerDismissed && (
|
||||
@@ -3396,15 +3503,15 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>数据表列宽模式</div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>表格密度</div>
|
||||
<Segmented
|
||||
block
|
||||
options={DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS}
|
||||
value={appearance.dataTableColumnWidthMode}
|
||||
onChange={(value) => setAppearance({ dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value) })}
|
||||
options={DENSITY_OPTIONS}
|
||||
value={appearance.dataTableDensity}
|
||||
onChange={(value) => setAppearance({ dataTableDensity: sanitizeDataTableDensity(value) })}
|
||||
/>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 8 }}>
|
||||
标准模式默认列宽 200px;紧凑模式默认列宽 140px。已手动拖拽调整的列宽优先保留。
|
||||
控制行高、列宽和内边距。舒适适合大屏细看;紧凑适合最大化信息密度。已手动拖拽的列宽优先保留。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3499,6 +3606,8 @@ function App() {
|
||||
}
|
||||
const binding = shortcutOptions[action] ?? { combo: '', enabled: false };
|
||||
const isCapturing = capturingShortcutAction === action;
|
||||
const conflicts = shortcutConflictMap[action];
|
||||
const conflictInfo = conflicts?.length ? splitConflictsByContext(conflicts) : null;
|
||||
return (
|
||||
<div
|
||||
key={action}
|
||||
@@ -3514,6 +3623,16 @@ function App() {
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{meta.label}</div>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{meta.description}</div>
|
||||
{conflictInfo && (
|
||||
<div style={{ fontSize: 11, color: darkMode ? '#faad14' : '#d48806', marginTop: 2 }}>
|
||||
{conflictInfo.hasMonaco && (
|
||||
<>⚠ 已覆盖编辑器「{conflictInfo.monacoLabels}」默认快捷键</>
|
||||
)}
|
||||
{conflictInfo.hasOther && (
|
||||
<>⚠ 与{conflictInfo.otherContexts}「{conflictInfo.otherLabels}」冲突,可能失效</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Input
|
||||
@@ -3537,6 +3656,12 @@ function App() {
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
<SnippetSettingsModal
|
||||
open={isSnippetModalOpen}
|
||||
onClose={() => setIsSnippetModalOpen(false)}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
/>
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<GlobalOutlined />, '全局代理设置', '统一配置更新检查、驱动管理与未单独指定代理的连接网络出口。')}
|
||||
open={isProxyModalOpen}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit';
|
||||
import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool';
|
||||
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
|
||||
import { toAIRequestMessage } from '../utils/aiMessagePayload';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
@@ -74,7 +75,7 @@ export const getDynamicMaxContextChars = (modelName?: string) => {
|
||||
// 当超出指定字符上限时触发上下文自建压缩
|
||||
const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number) => {
|
||||
try {
|
||||
const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0);
|
||||
const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0);
|
||||
if (chars < maxLimit) return null;
|
||||
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
@@ -508,7 +509,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
let isFirstCompletion = false;
|
||||
|
||||
// 新增:利用 requestAnimationFrame 缓冲高频事件,避免 React 重绘阻塞导致感官吞吐变慢
|
||||
const streamBuffer = { thinking: '', content: '' };
|
||||
const streamBuffer = { thinking: '', reasoningContent: '', content: '' };
|
||||
let flushPending = false;
|
||||
|
||||
const flushStreamBuffer = () => {
|
||||
@@ -523,6 +524,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
updates.phase = 'thinking';
|
||||
streamBuffer.thinking = '';
|
||||
}
|
||||
if (streamBuffer.reasoningContent) {
|
||||
updates.reasoning_content = (existing.reasoning_content || '') + streamBuffer.reasoningContent;
|
||||
streamBuffer.reasoningContent = '';
|
||||
}
|
||||
if (streamBuffer.content) {
|
||||
updates.content = (existing.content || '') + streamBuffer.content;
|
||||
updates.phase = 'generating';
|
||||
@@ -535,7 +540,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
flushPending = false;
|
||||
};
|
||||
|
||||
const handler = (data: { content?: string; thinking?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => {
|
||||
const handler = (data: { content?: string; thinking?: string; reasoning_content?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => {
|
||||
// Find connecting message if there's no active assistant string
|
||||
if (!assistantMsgId) {
|
||||
const history = useStore.getState().aiChatHistory[sid] || [];
|
||||
@@ -589,7 +594,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}
|
||||
|
||||
// 处理 thinking(模型思考过程)
|
||||
if (data.thinking) {
|
||||
const displayThinking = data.thinking || data.reasoning_content || '';
|
||||
if (displayThinking || data.reasoning_content) {
|
||||
if (!assistantMsgId) {
|
||||
assistantMsgId = genId();
|
||||
addAIChatMessage(sid, {
|
||||
@@ -597,7 +603,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
role: 'assistant',
|
||||
phase: 'thinking',
|
||||
content: '',
|
||||
thinking: data.thinking,
|
||||
thinking: displayThinking || undefined,
|
||||
reasoning_content: data.reasoning_content || undefined,
|
||||
timestamp: Date.now(),
|
||||
loading: true,
|
||||
jvmPlanContext: pendingJVMPlanContextRef.current,
|
||||
@@ -605,7 +612,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
});
|
||||
if (sending) setSending(false);
|
||||
} else {
|
||||
streamBuffer.thinking += data.thinking;
|
||||
streamBuffer.thinking += displayThinking;
|
||||
if (data.reasoning_content) {
|
||||
streamBuffer.reasoningContent += data.reasoning_content;
|
||||
}
|
||||
if (sending) setSending(false);
|
||||
}
|
||||
}
|
||||
@@ -632,7 +642,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (streamBuffer.thinking || streamBuffer.content) {
|
||||
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
|
||||
if (!flushPending) {
|
||||
flushPending = true;
|
||||
requestAnimationFrame(flushStreamBuffer);
|
||||
@@ -641,7 +651,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
|
||||
if (data.done) {
|
||||
// 如果有残留未 flush 的 buffer,立刻推入状态树
|
||||
if (streamBuffer.thinking || streamBuffer.content) {
|
||||
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
|
||||
flushStreamBuffer();
|
||||
}
|
||||
const doneAssistantId = assistantMsgId;
|
||||
@@ -676,12 +686,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
(async () => {
|
||||
try {
|
||||
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
|
||||
const messagesPayload = currentHistory.map(m => {
|
||||
const mapped: any = { role: m.role, content: m.content, images: m.images };
|
||||
if (m.tool_calls) mapped.tool_calls = m.tool_calls;
|
||||
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
|
||||
return mapped;
|
||||
});
|
||||
const messagesPayload = currentHistory.map(toAIRequestMessage);
|
||||
const sysMessages = await buildSystemContextMessages(
|
||||
existing.jvmPlanContext,
|
||||
existing.jvmDiagnosticPlanContext,
|
||||
@@ -804,7 +809,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
addAIChatMessage(sid, connectingMsg);
|
||||
|
||||
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
|
||||
const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content, images: m.images }));
|
||||
const messagesPayload = truncatedHistory.map(toAIRequestMessage);
|
||||
|
||||
try {
|
||||
const sysMessages = await buildSystemContextMessages(
|
||||
@@ -823,6 +828,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
addAIChatMessage(sid, {
|
||||
id: genId(), role: 'assistant',
|
||||
content: result?.success ? result.content : `❌ ${errClean}`,
|
||||
thinking: result?.success ? result.reasoning_content : undefined,
|
||||
reasoning_content: result?.success ? result.reasoning_content : undefined,
|
||||
rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined,
|
||||
timestamp: Date.now(),
|
||||
jvmPlanContext: retryJVMPlanContext,
|
||||
@@ -1268,12 +1275,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
setSending(true);
|
||||
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
|
||||
// 过滤掉 connecting 占位消息,不发给模型
|
||||
const messagesPayload = currentHistory.filter(m => m.phase !== 'connecting').map(m => {
|
||||
const mapped: any = { role: m.role, content: m.content, images: m.images };
|
||||
if (m.tool_calls) mapped.tool_calls = m.tool_calls;
|
||||
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
|
||||
return mapped;
|
||||
});
|
||||
const messagesPayload = currentHistory.filter(m => m.phase !== 'connecting').map(toAIRequestMessage);
|
||||
const sysMessages = await buildSystemContextMessages(
|
||||
inheritedJVMPlanContext,
|
||||
inheritedJVMDiagnosticPlanContext,
|
||||
@@ -1313,6 +1315,8 @@ SELECT * FROM users WHERE status = 1;
|
||||
useStore.getState().addAIChatMessage(sid, {
|
||||
id: genId(), role: 'assistant',
|
||||
content: result?.success ? result.content : `❌ ${errC}`,
|
||||
thinking: result?.success ? result.reasoning_content : undefined,
|
||||
reasoning_content: result?.success ? result.reasoning_content : undefined,
|
||||
rawError: (!result?.success && errC !== errR) ? errR : undefined,
|
||||
timestamp: Date.now(),
|
||||
jvmPlanContext: inheritedJVMPlanContext,
|
||||
@@ -1380,12 +1384,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
// 【过渡状态 2】上下文已组装完成,即将接入模型
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: '模型接入中' });
|
||||
|
||||
const chatMessages = [...messages, userMsg].map(m => {
|
||||
const mapped: any = { role: m.role, content: m.content, images: m.images };
|
||||
if (m.tool_calls) mapped.tool_calls = m.tool_calls;
|
||||
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
|
||||
return mapped;
|
||||
});
|
||||
const chatMessages = [...messages, userMsg].map(toAIRequestMessage);
|
||||
|
||||
let finalMessagesPayload = chatMessages;
|
||||
const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model);
|
||||
@@ -1421,6 +1420,8 @@ SELECT * FROM users WHERE status = 1;
|
||||
const assistantMsg: AIChatMessage = {
|
||||
id: genId(), role: 'assistant',
|
||||
content: result?.success ? result.content : `❌ ${errC2}`,
|
||||
thinking: result?.success ? result.reasoning_content : undefined,
|
||||
reasoning_content: result?.success ? result.reasoning_content : undefined,
|
||||
rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined,
|
||||
timestamp: Date.now(),
|
||||
jvmPlanContext: currentJVMPlanContext,
|
||||
@@ -1588,7 +1589,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
return connection ? buildRpcConnectionConfig(connection.config) : undefined;
|
||||
}, [inferredConnectionId, connections]);
|
||||
const contextUsageChars = useMemo(() =>
|
||||
messages.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0),
|
||||
messages.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0),
|
||||
[messages]);
|
||||
const contextTableNames = useMemo(() => {
|
||||
const ck = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
|
||||
|
||||
@@ -61,6 +61,8 @@ import {
|
||||
} from "../utils/connectionModalPresentation";
|
||||
import { resolveConnectionSecretDraft } from "../utils/connectionSecretDraft";
|
||||
import { getCustomConnectionDsnValidationMessage } from "../utils/customConnectionDsn";
|
||||
import { mergeParsedUriValuesForForm } from "../utils/connectionUriMerge";
|
||||
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||
import { CUSTOM_CONNECTION_DRIVER_HELP } from "../utils/driverImportGuidance";
|
||||
import {
|
||||
applyNoAutoCapAttributes,
|
||||
@@ -96,7 +98,9 @@ type ChoiceCardOption = {
|
||||
description?: string;
|
||||
};
|
||||
type ClickHouseProtocolChoice = "auto" | "http" | "native";
|
||||
type OceanBaseProtocolChoice = "mysql" | "oracle";
|
||||
const MAX_URI_LENGTH = 4096;
|
||||
const MAX_CONNECTION_PARAMS_LENGTH = 4096;
|
||||
const MAX_URI_HOSTS = 32;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const CONNECTION_MODAL_WIDTH = 960;
|
||||
@@ -111,6 +115,21 @@ const CLICKHOUSE_PROTOCOL_OPTIONS: Array<{
|
||||
{ value: "http", label: "HTTP" },
|
||||
{ value: "native", label: "Native" },
|
||||
];
|
||||
const OCEANBASE_PROTOCOL_OPTIONS: Array<{
|
||||
value: OceanBaseProtocolChoice;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "mysql", label: "MySQL" },
|
||||
{ value: "oracle", label: "Oracle" },
|
||||
];
|
||||
const OCEANBASE_PROTOCOL_PARAM_KEYS = [
|
||||
"protocol",
|
||||
"oceanBaseProtocol",
|
||||
"oceanbaseProtocol",
|
||||
"tenantMode",
|
||||
"compatMode",
|
||||
"mode",
|
||||
];
|
||||
|
||||
const normalizeClickHouseProtocolValue = (
|
||||
value: unknown,
|
||||
@@ -122,6 +141,55 @@ const normalizeClickHouseProtocolValue = (
|
||||
if (text === "native" || text === "tcp") return "native";
|
||||
return "auto";
|
||||
};
|
||||
const normalizeOceanBaseProtocolValue = (
|
||||
value: unknown,
|
||||
): OceanBaseProtocolChoice => {
|
||||
const text = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return text === "oracle" ? "oracle" : "mysql";
|
||||
};
|
||||
const resolveOceanBaseProtocolValue = (
|
||||
value: unknown,
|
||||
): OceanBaseProtocolChoice | undefined => {
|
||||
const text = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!text) return undefined;
|
||||
return ["oracle", "oracle-mode", "oracle_mode", "oboracle"].includes(text)
|
||||
? "oracle"
|
||||
: "mysql";
|
||||
};
|
||||
const resolveOceanBaseProtocolFromQueryText = (
|
||||
value: unknown,
|
||||
): OceanBaseProtocolChoice | undefined => {
|
||||
let text = String(value || "").trim();
|
||||
if (!text) return undefined;
|
||||
const queryIndex = text.indexOf("?");
|
||||
if (queryIndex >= 0) {
|
||||
text = text.slice(queryIndex + 1);
|
||||
}
|
||||
const hashIndex = text.indexOf("#");
|
||||
if (hashIndex >= 0) {
|
||||
text = text.slice(0, hashIndex);
|
||||
}
|
||||
const params = new URLSearchParams(text.replace(/^[?&]+/, ""));
|
||||
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
|
||||
const protocol = resolveOceanBaseProtocolValue(params.get(key));
|
||||
if (protocol) return protocol;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const resolveOceanBaseProtocolForConfig = (
|
||||
config: Partial<ConnectionConfig>,
|
||||
): OceanBaseProtocolChoice => {
|
||||
return (
|
||||
resolveOceanBaseProtocolValue(config.oceanBaseProtocol) ||
|
||||
resolveOceanBaseProtocolFromQueryText(config.connectionParams) ||
|
||||
resolveOceanBaseProtocolFromQueryText(config.uri) ||
|
||||
"mysql"
|
||||
);
|
||||
};
|
||||
type ConnectionSecretKey =
|
||||
| "primaryPassword"
|
||||
| "sshPassword"
|
||||
@@ -152,6 +220,8 @@ const getDefaultPortByType = (type: string) => {
|
||||
return 9010;
|
||||
case "mysql":
|
||||
return 3306;
|
||||
case "oceanbase":
|
||||
return 2881;
|
||||
case "doris":
|
||||
case "diros":
|
||||
return 9030;
|
||||
@@ -160,6 +230,7 @@ const getDefaultPortByType = (type: string) => {
|
||||
case "clickhouse":
|
||||
return 9000;
|
||||
case "postgres":
|
||||
case "opengauss":
|
||||
return 5432;
|
||||
case "redis":
|
||||
return 6379;
|
||||
@@ -192,6 +263,7 @@ const getDefaultPortByType = (type: string) => {
|
||||
|
||||
const singleHostUriSchemesByType: Record<string, string[]> = {
|
||||
postgres: ["postgresql", "postgres"],
|
||||
opengauss: ["opengauss", "jdbc:opengauss", "postgresql", "postgres"],
|
||||
clickhouse: ["clickhouse"],
|
||||
oracle: ["oracle"],
|
||||
sqlserver: ["sqlserver"],
|
||||
@@ -206,6 +278,7 @@ const singleHostUriSchemesByType: Record<string, string[]> = {
|
||||
const sslSupportedTypes = new Set([
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"oceanbase",
|
||||
"doris",
|
||||
"diros",
|
||||
"sphinx",
|
||||
@@ -217,6 +290,7 @@ const sslSupportedTypes = new Set([
|
||||
"kingbase",
|
||||
"highgo",
|
||||
"vastbase",
|
||||
"opengauss",
|
||||
"mongodb",
|
||||
"redis",
|
||||
"tdengine",
|
||||
@@ -232,6 +306,28 @@ const supportsSSLForType = (type: string) =>
|
||||
const isFileDatabaseType = (type: string) =>
|
||||
type === "sqlite" || type === "duckdb";
|
||||
|
||||
const isMySQLCompatibleType = (type: string) =>
|
||||
type === "mysql" ||
|
||||
type === "mariadb" ||
|
||||
type === "oceanbase" ||
|
||||
type === "doris" ||
|
||||
type === "diros" ||
|
||||
type === "sphinx";
|
||||
|
||||
const supportsConnectionParamsForType = (type: string) =>
|
||||
isMySQLCompatibleType(type) ||
|
||||
type === "postgres" ||
|
||||
type === "kingbase" ||
|
||||
type === "highgo" ||
|
||||
type === "vastbase" ||
|
||||
type === "opengauss" ||
|
||||
type === "oracle" ||
|
||||
type === "sqlserver" ||
|
||||
type === "clickhouse" ||
|
||||
type === "mongodb" ||
|
||||
type === "dameng" ||
|
||||
type === "tdengine";
|
||||
|
||||
type DriverStatusSnapshot = {
|
||||
type: string;
|
||||
name: string;
|
||||
@@ -249,6 +345,12 @@ const normalizeDriverType = (value: string): string => {
|
||||
.toLowerCase();
|
||||
if (normalized === "postgresql") return "postgres";
|
||||
if (normalized === "doris") return "diros";
|
||||
if (
|
||||
normalized === "open_gauss" ||
|
||||
normalized === "open-gauss" ||
|
||||
normalized === "opengauss"
|
||||
)
|
||||
return "opengauss";
|
||||
return normalized;
|
||||
};
|
||||
|
||||
@@ -330,6 +432,9 @@ const ConnectionModal: React.FC<{
|
||||
const mongoTopology = Form.useWatch("mongoTopology", form) || "single";
|
||||
const mongoSrv = Form.useWatch("mongoSrv", form) || false;
|
||||
const redisTopology = Form.useWatch("redisTopology", form) || "single";
|
||||
const oceanBaseProtocol = normalizeOceanBaseProtocolValue(
|
||||
Form.useWatch("oceanBaseProtocol", form),
|
||||
);
|
||||
const sslMode = Form.useWatch("sslMode", form) || "preferred";
|
||||
const proxyType = Form.useWatch("proxyType", form) || "socks5";
|
||||
const customDriver = Form.useWatch("driver", form) || "";
|
||||
@@ -355,16 +460,15 @@ const ConnectionModal: React.FC<{
|
||||
}),
|
||||
[jvmAllowedModes, jvmPreferredMode],
|
||||
);
|
||||
const isMySQLLike =
|
||||
dbType === "mysql" ||
|
||||
dbType === "mariadb" ||
|
||||
dbType === "doris" ||
|
||||
dbType === "diros" ||
|
||||
dbType === "sphinx";
|
||||
const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle";
|
||||
const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle;
|
||||
const supportsConnectionParams = supportsConnectionParamsForType(dbType);
|
||||
const isSSLType = supportsSSLForType(dbType);
|
||||
const sslHintText = isMySQLLike
|
||||
? "当 MySQL/MariaDB/Doris/Sphinx 开启安全传输策略时,请启用 SSL;本地自签证书场景可先用 Preferred 或 Skip Verify。"
|
||||
: dbType === "dameng"
|
||||
: isOceanBaseOracle
|
||||
? "OceanBase Oracle 租户使用 Oracle 协议连接,SSL 参数按 Oracle 驱动规则传递。"
|
||||
: dbType === "dameng"
|
||||
? "达梦驱动启用 SSL 需要客户端证书与私钥路径(sslCertPath / sslKeyPath)。"
|
||||
: dbType === "sqlserver"
|
||||
? "SQL Server 推荐在生产环境使用 Required,并关闭 TrustServerCertificate。"
|
||||
@@ -1047,6 +1151,56 @@ const ConnectionModal: React.FC<{
|
||||
return text === "1" || text === "true" || text === "yes" || text === "on";
|
||||
};
|
||||
|
||||
const normalizeConnectionParamsText = (raw: unknown) => {
|
||||
let text = String(raw || "").trim();
|
||||
if (!text) return "";
|
||||
const queryIndex = text.indexOf("?");
|
||||
if (queryIndex >= 0) {
|
||||
text = text.slice(queryIndex + 1);
|
||||
}
|
||||
const hashIndex = text.indexOf("#");
|
||||
if (hashIndex >= 0) {
|
||||
text = text.slice(0, hashIndex);
|
||||
}
|
||||
return text.replace(/^[?&]+/, "").trim().slice(0, MAX_CONNECTION_PARAMS_LENGTH);
|
||||
};
|
||||
|
||||
const serializeConnectionParams = (params: URLSearchParams) => {
|
||||
const cloned = new URLSearchParams();
|
||||
params.forEach((value, key) => {
|
||||
if (String(key || "").trim()) {
|
||||
cloned.append(key, value);
|
||||
}
|
||||
});
|
||||
return cloned.toString().slice(0, MAX_CONNECTION_PARAMS_LENGTH);
|
||||
};
|
||||
|
||||
const normalizeOceanBaseConnectionParamsText = (
|
||||
rawParams: unknown,
|
||||
selectedProtocol: OceanBaseProtocolChoice,
|
||||
) => {
|
||||
const params = new URLSearchParams(normalizeConnectionParamsText(rawParams));
|
||||
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
|
||||
params.delete(key);
|
||||
}
|
||||
params.set("protocol", selectedProtocol);
|
||||
return params.toString().slice(0, MAX_CONNECTION_PARAMS_LENGTH);
|
||||
};
|
||||
|
||||
const mergeConnectionParams = (
|
||||
params: URLSearchParams,
|
||||
rawParams: unknown,
|
||||
) => {
|
||||
const text = normalizeConnectionParamsText(rawParams);
|
||||
if (!text) return;
|
||||
const extra = new URLSearchParams(text);
|
||||
extra.forEach((value, key) => {
|
||||
if (String(key || "").trim()) {
|
||||
params.set(key, value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeFileDbPath = (rawPath: string): string => {
|
||||
let pathText = String(rawPath || "").trim();
|
||||
if (!pathText) {
|
||||
@@ -1199,6 +1353,7 @@ const ConnectionModal: React.FC<{
|
||||
clickHouseProtocol: "http",
|
||||
useSSL: isHttps,
|
||||
sslMode: isHttps ? (skipVerify ? "skip-verify" : "required") : "disable",
|
||||
connectionParams: serializeConnectionParams(parsed.params),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1214,15 +1369,13 @@ const ConnectionModal: React.FC<{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
type === "mysql" ||
|
||||
type === "mariadb" ||
|
||||
type === "diros" ||
|
||||
type === "sphinx"
|
||||
) {
|
||||
if (isMySQLCompatibleType(type)) {
|
||||
const mysqlDefaultPort = getDefaultPortByType(type);
|
||||
const parsed =
|
||||
parseMultiHostUri(trimmedUri, "mysql") ||
|
||||
parseMultiHostUri(trimmedUri, "jdbc:mysql") ||
|
||||
parseMultiHostUri(trimmedUri, "oceanbase") ||
|
||||
parseMultiHostUri(trimmedUri, "jdbc:oceanbase") ||
|
||||
parseMultiHostUri(trimmedUri, "diros") ||
|
||||
parseMultiHostUri(trimmedUri, "doris");
|
||||
if (!parsed) {
|
||||
@@ -1246,9 +1399,22 @@ const ConnectionModal: React.FC<{
|
||||
const topology = String(
|
||||
parsed.params.get("topology") || "",
|
||||
).toLowerCase();
|
||||
const tlsValue = String(parsed.params.get("tls") || "")
|
||||
const tlsValue = String(
|
||||
parsed.params.get("tls") || parsed.params.get("useSSL") || "",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const parsedOceanBaseProtocol =
|
||||
type === "oceanbase"
|
||||
? normalizeOceanBaseProtocolValue(
|
||||
parsed.params.get("protocol") ||
|
||||
parsed.params.get("oceanBaseProtocol") ||
|
||||
parsed.params.get("oceanbaseProtocol") ||
|
||||
parsed.params.get("tenantMode") ||
|
||||
parsed.params.get("compatMode") ||
|
||||
parsed.params.get("mode"),
|
||||
)
|
||||
: undefined;
|
||||
const sslMode =
|
||||
tlsValue === "true"
|
||||
? "required"
|
||||
@@ -1265,9 +1431,15 @@ const ConnectionModal: React.FC<{
|
||||
database: parsed.database || "",
|
||||
useSSL: sslMode !== "disable",
|
||||
sslMode,
|
||||
oceanBaseProtocol: parsedOceanBaseProtocol,
|
||||
mysqlTopology:
|
||||
hostList.length > 1 || topology === "replica" ? "replica" : "single",
|
||||
parsedOceanBaseProtocol === "oracle"
|
||||
? "single"
|
||||
: hostList.length > 1 || topology === "replica"
|
||||
? "replica"
|
||||
: "single",
|
||||
mysqlReplicaHosts: hostList.slice(1),
|
||||
connectionParams: serializeConnectionParams(parsed.params),
|
||||
timeout:
|
||||
Number.isFinite(timeoutValue) && timeoutValue > 0
|
||||
? Math.min(3600, Math.trunc(timeoutValue))
|
||||
@@ -1414,6 +1586,7 @@ const ConnectionModal: React.FC<{
|
||||
mongoAuthSource: parsed.params.get("authSource") || "",
|
||||
mongoReadPreference: parsed.params.get("readPreference") || "primary",
|
||||
mongoAuthMechanism: parsed.params.get("authMechanism") || "",
|
||||
connectionParams: serializeConnectionParams(parsed.params),
|
||||
timeout:
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? Math.min(MAX_TIMEOUT_SECONDS, Math.ceil(timeoutMs / 1000))
|
||||
@@ -1450,6 +1623,9 @@ const ConnectionModal: React.FC<{
|
||||
password: parsed.password,
|
||||
database: parsed.database,
|
||||
};
|
||||
if (supportsConnectionParamsForType(type)) {
|
||||
parsedValues.connectionParams = serializeConnectionParams(parsed.params);
|
||||
}
|
||||
|
||||
if (supportsSSLForType(type)) {
|
||||
const normalizeBool = (raw: unknown) => {
|
||||
@@ -1464,7 +1640,8 @@ const ConnectionModal: React.FC<{
|
||||
type === "postgres" ||
|
||||
type === "kingbase" ||
|
||||
type === "highgo" ||
|
||||
type === "vastbase"
|
||||
type === "vastbase" ||
|
||||
type === "opengauss"
|
||||
) {
|
||||
const sslMode = String(parsed.params.get("sslmode") || "")
|
||||
.trim()
|
||||
@@ -1619,14 +1796,13 @@ const ConnectionModal: React.FC<{
|
||||
});
|
||||
|
||||
const getUriPlaceholder = () => {
|
||||
if (
|
||||
dbType === "mysql" ||
|
||||
dbType === "mariadb" ||
|
||||
dbType === "diros" ||
|
||||
dbType === "sphinx"
|
||||
) {
|
||||
if (isMySQLCompatibleType(dbType)) {
|
||||
const defaultPort = getDefaultPortByType(dbType);
|
||||
const scheme = dbType === "diros" ? "doris" : "mysql";
|
||||
const scheme =
|
||||
dbType === "diros" ? "doris" : dbType === "oceanbase" ? "oceanbase" : "mysql";
|
||||
if (dbType === "oceanbase") {
|
||||
return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}/SERVICE_NAME?protocol=oracle`;
|
||||
}
|
||||
return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
|
||||
}
|
||||
if (isFileDatabaseType(dbType)) {
|
||||
@@ -1646,9 +1822,45 @@ const ConnectionModal: React.FC<{
|
||||
if (dbType === "oracle") {
|
||||
return "oracle://user:pass@127.0.0.1:1521/ORCLPDB1";
|
||||
}
|
||||
if (dbType === "opengauss") {
|
||||
return "opengauss://user:pass@127.0.0.1:5432/db_name";
|
||||
}
|
||||
return "例如: postgres://user:pass@127.0.0.1:5432/db_name";
|
||||
};
|
||||
|
||||
const getConnectionParamsPlaceholder = () => {
|
||||
if (dbType === "oceanbase") {
|
||||
return oceanBaseProtocol === "oracle"
|
||||
? "PREFETCH_ROWS=5000"
|
||||
: "useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false";
|
||||
}
|
||||
if (isMySQLCompatibleType(dbType)) {
|
||||
return "useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false";
|
||||
}
|
||||
switch (dbType) {
|
||||
case "postgres":
|
||||
case "kingbase":
|
||||
case "highgo":
|
||||
case "vastbase":
|
||||
case "opengauss":
|
||||
return "application_name=GoNavi&statement_timeout=30000";
|
||||
case "oracle":
|
||||
return "PREFETCH_ROWS=5000&TRACE FILE=/tmp/go-ora.trc";
|
||||
case "sqlserver":
|
||||
return "app name=GoNavi&packet size=32767";
|
||||
case "clickhouse":
|
||||
return "max_execution_time=60&compress=lz4";
|
||||
case "mongodb":
|
||||
return "retryWrites=true&readPreference=secondaryPreferred";
|
||||
case "dameng":
|
||||
return "schema=SYSDBA";
|
||||
case "tdengine":
|
||||
return "timezone=Asia%2FShanghai";
|
||||
default:
|
||||
return "key=value&another=value";
|
||||
}
|
||||
};
|
||||
|
||||
const buildUriFromValues = (values: any) => {
|
||||
const type = String(values.type || "")
|
||||
.trim()
|
||||
@@ -1664,15 +1876,14 @@ const ConnectionModal: React.FC<{
|
||||
? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ""}@`
|
||||
: "";
|
||||
|
||||
if (
|
||||
type === "mysql" ||
|
||||
type === "mariadb" ||
|
||||
type === "diros" ||
|
||||
type === "sphinx"
|
||||
) {
|
||||
if (isMySQLCompatibleType(type)) {
|
||||
const selectedOceanBaseProtocol =
|
||||
type === "oceanbase"
|
||||
? normalizeOceanBaseProtocolValue(values.oceanBaseProtocol)
|
||||
: "mysql";
|
||||
const primary = toAddress(host, port, defaultPort);
|
||||
const replicas =
|
||||
values.mysqlTopology === "replica"
|
||||
selectedOceanBaseProtocol !== "oracle" && values.mysqlTopology === "replica"
|
||||
? normalizeAddressList(values.mysqlReplicaHosts, defaultPort)
|
||||
: [];
|
||||
const hosts = normalizeAddressList([primary, ...replicas], defaultPort);
|
||||
@@ -1695,9 +1906,14 @@ const ConnectionModal: React.FC<{
|
||||
if (Number.isFinite(timeout) && timeout > 0) {
|
||||
params.set("timeout", String(timeout));
|
||||
}
|
||||
mergeConnectionParams(params, values.connectionParams);
|
||||
if (type === "oceanbase") {
|
||||
params.set("protocol", selectedOceanBaseProtocol);
|
||||
}
|
||||
const dbPath = database ? `/${encodeURIComponent(database)}` : "/";
|
||||
const query = params.toString();
|
||||
const scheme = type === "diros" ? "doris" : "mysql";
|
||||
const scheme =
|
||||
type === "diros" ? "doris" : type === "oceanbase" ? "oceanbase" : "mysql";
|
||||
return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -1797,6 +2013,7 @@ const ConnectionModal: React.FC<{
|
||||
params.set("connectTimeoutMS", String(timeout * 1000));
|
||||
params.set("serverSelectionTimeoutMS", String(timeout * 1000));
|
||||
}
|
||||
mergeConnectionParams(params, values.connectionParams);
|
||||
const dbPath = database ? `/${encodeURIComponent(database)}` : "/";
|
||||
const query = params.toString();
|
||||
return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`;
|
||||
@@ -1824,7 +2041,8 @@ const ConnectionModal: React.FC<{
|
||||
type === "postgres" ||
|
||||
type === "kingbase" ||
|
||||
type === "highgo" ||
|
||||
type === "vastbase"
|
||||
type === "vastbase" ||
|
||||
type === "opengauss"
|
||||
) {
|
||||
params.set("sslmode", "require");
|
||||
} else if (type === "sqlserver") {
|
||||
@@ -1863,7 +2081,8 @@ const ConnectionModal: React.FC<{
|
||||
type === "postgres" ||
|
||||
type === "kingbase" ||
|
||||
type === "highgo" ||
|
||||
type === "vastbase"
|
||||
type === "vastbase" ||
|
||||
type === "opengauss"
|
||||
) {
|
||||
params.set("sslmode", "disable");
|
||||
} else if (type === "sqlserver") {
|
||||
@@ -1876,6 +2095,9 @@ const ConnectionModal: React.FC<{
|
||||
if (type === "clickhouse" && clickHouseProtocol !== "auto") {
|
||||
params.set("protocol", clickHouseProtocol);
|
||||
}
|
||||
if (supportsConnectionParamsForType(type)) {
|
||||
mergeConnectionParams(params, values.connectionParams);
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}${query ? `?${query}` : ""}`;
|
||||
};
|
||||
@@ -1909,7 +2131,13 @@ const ConnectionModal: React.FC<{
|
||||
});
|
||||
return;
|
||||
}
|
||||
form.setFieldsValue({ ...parsedValues, uri: uriText });
|
||||
form.setFieldsValue(
|
||||
mergeParsedUriValuesForForm(
|
||||
form.getFieldsValue(true),
|
||||
parsedValues,
|
||||
uriText,
|
||||
),
|
||||
);
|
||||
if (testResult) {
|
||||
setTestResult(null);
|
||||
}
|
||||
@@ -2043,6 +2271,7 @@ const ConnectionModal: React.FC<{
|
||||
const mysqlReplicaHosts =
|
||||
configType === "mysql" ||
|
||||
configType === "mariadb" ||
|
||||
configType === "oceanbase" ||
|
||||
configType === "diros" ||
|
||||
configType === "sphinx"
|
||||
? normalizedHosts.slice(1)
|
||||
@@ -2082,10 +2311,19 @@ const ConnectionModal: React.FC<{
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
uri: config.uri || "",
|
||||
connectionParams:
|
||||
config.connectionParams ||
|
||||
(config.uri
|
||||
? parseUriToValues(config.uri, configType)?.connectionParams || ""
|
||||
: ""),
|
||||
clickHouseProtocol:
|
||||
configType === "clickhouse"
|
||||
? normalizeClickHouseProtocolValue(config.clickHouseProtocol)
|
||||
: "auto",
|
||||
oceanBaseProtocol:
|
||||
configType === "oceanbase"
|
||||
? resolveOceanBaseProtocolForConfig(config)
|
||||
: "mysql",
|
||||
includeDatabases: initialValues.includeDatabases,
|
||||
includeRedisDatabases: initialValues.includeRedisDatabases,
|
||||
useSSL: !!config.useSSL,
|
||||
@@ -2294,11 +2532,7 @@ const ConnectionModal: React.FC<{
|
||||
forceClear: !config.useHttpTunnel,
|
||||
});
|
||||
const mysqlReplicaEnabled =
|
||||
(config.type === "mysql" ||
|
||||
config.type === "mariadb" ||
|
||||
config.type === "diros" ||
|
||||
config.type === "sphinx") &&
|
||||
config.topology === "replica";
|
||||
isMySQLCompatibleType(config.type) && config.topology === "replica";
|
||||
const mysqlReplicaDraft = resolveConnectionSecretDraft({
|
||||
hasSecret: initialValues?.hasMySQLReplicaPassword,
|
||||
valueInput: config.mysqlReplicaPassword,
|
||||
@@ -2528,10 +2762,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
if (
|
||||
clearSecrets.mysqlReplicaPassword &&
|
||||
(values.type === "mysql" ||
|
||||
values.type === "mariadb" ||
|
||||
values.type === "diros" ||
|
||||
values.type === "sphinx") &&
|
||||
isMySQLCompatibleType(values.type) &&
|
||||
values.mysqlTopology === "replica" &&
|
||||
String(values.mysqlReplicaPassword ?? "") === ""
|
||||
) {
|
||||
@@ -2611,12 +2842,14 @@ const ConnectionModal: React.FC<{
|
||||
// Use different API for Redis / JVM
|
||||
const isRedisType = values.type === "redis";
|
||||
const isJVMType = values.type === "jvm";
|
||||
const dbTestConfig =
|
||||
!isRedisType && !isJVMType ? buildRpcConnectionConfig(config as any) : config;
|
||||
const res = await withClientTimeout(
|
||||
isJVMType
|
||||
? TestJVMConnection(config as any)
|
||||
: isRedisType
|
||||
? RedisConnect(config as any)
|
||||
: TestConnection(config as any),
|
||||
: TestConnection(dbTestConfig as any),
|
||||
rpcTimeoutMs,
|
||||
`连接测试超时(>${timeoutSeconds} 秒),请检查网络/代理/SSH配置后重试`,
|
||||
);
|
||||
@@ -2629,7 +2862,7 @@ const ConnectionModal: React.FC<{
|
||||
} else if (!isJVMType) {
|
||||
// Other databases: fetch database list
|
||||
const dbRes = await withClientTimeout(
|
||||
DBGetDatabases(config as any),
|
||||
DBGetDatabases(dbTestConfig as any),
|
||||
rpcTimeoutMs,
|
||||
`连接成功但拉取数据库列表超时(>${timeoutSeconds} 秒)`,
|
||||
);
|
||||
@@ -2884,6 +3117,10 @@ const ConnectionModal: React.FC<{
|
||||
|
||||
const type = String(mergedValues.type || "").toLowerCase();
|
||||
const defaultPort = getDefaultPortByType(type);
|
||||
const selectedOceanBaseProtocol =
|
||||
type === "oceanbase"
|
||||
? normalizeOceanBaseProtocolValue(mergedValues.oceanBaseProtocol)
|
||||
: "mysql";
|
||||
if (type === "clickhouse") {
|
||||
const requestedProtocol = normalizeClickHouseProtocolValue(
|
||||
mergedValues.clickHouseProtocol,
|
||||
@@ -2983,12 +3220,7 @@ const ConnectionModal: React.FC<{
|
||||
const savePassword =
|
||||
type === "mongodb" ? mergedValues.savePassword !== false : true;
|
||||
|
||||
if (
|
||||
type === "mysql" ||
|
||||
type === "mariadb" ||
|
||||
type === "diros" ||
|
||||
type === "sphinx"
|
||||
) {
|
||||
if (isMySQLCompatibleType(type) && selectedOceanBaseProtocol !== "oracle") {
|
||||
const replicas =
|
||||
mergedValues.mysqlTopology === "replica"
|
||||
? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort)
|
||||
@@ -3129,6 +3361,14 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
|
||||
const keepPassword = !forPersist || savePassword;
|
||||
const normalizedConnectionParams = supportsConnectionParamsForType(type)
|
||||
? type === "oceanbase"
|
||||
? normalizeOceanBaseConnectionParamsText(
|
||||
mergedValues.connectionParams,
|
||||
selectedOceanBaseProtocol,
|
||||
)
|
||||
: normalizeConnectionParamsText(mergedValues.connectionParams)
|
||||
: "";
|
||||
|
||||
return {
|
||||
type: mergedValues.type,
|
||||
@@ -3150,6 +3390,7 @@ const ConnectionModal: React.FC<{
|
||||
httpTunnel: httpTunnelConfig,
|
||||
driver: mergedValues.driver,
|
||||
dsn: mergedValues.dsn,
|
||||
connectionParams: normalizedConnectionParams,
|
||||
timeout: Number(mergedValues.timeout || 30),
|
||||
redisDB: Number.isFinite(Number(mergedValues.redisDB))
|
||||
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
|
||||
@@ -3159,6 +3400,8 @@ const ConnectionModal: React.FC<{
|
||||
type === "clickhouse"
|
||||
? normalizeClickHouseProtocolValue(mergedValues.clickHouseProtocol)
|
||||
: undefined,
|
||||
oceanBaseProtocol:
|
||||
type === "oceanbase" ? selectedOceanBaseProtocol : undefined,
|
||||
hosts: hosts,
|
||||
topology: topology,
|
||||
mysqlReplicaUser: mysqlReplicaUser,
|
||||
@@ -3189,6 +3432,7 @@ const ConnectionModal: React.FC<{
|
||||
form.setFieldsValue({
|
||||
type: type,
|
||||
clickHouseProtocol: type === "clickhouse" ? "auto" : undefined,
|
||||
oceanBaseProtocol: type === "oceanbase" ? "mysql" : undefined,
|
||||
});
|
||||
|
||||
const defaultPort = getDefaultPortByType(type);
|
||||
@@ -3226,6 +3470,7 @@ const ConnectionModal: React.FC<{
|
||||
httpTunnelPassword: "",
|
||||
timeout: 30,
|
||||
uri: "",
|
||||
connectionParams: "",
|
||||
includeDatabases: undefined,
|
||||
includeRedisDatabases: undefined,
|
||||
mysqlTopology: "single",
|
||||
@@ -3303,6 +3548,7 @@ const ConnectionModal: React.FC<{
|
||||
mongoReplicaUser: "",
|
||||
mongoReplicaPassword: "",
|
||||
redisDB: 0,
|
||||
connectionParams: "",
|
||||
});
|
||||
} else if (type !== "custom") {
|
||||
const defaultUser =
|
||||
@@ -3340,6 +3586,7 @@ const ConnectionModal: React.FC<{
|
||||
mongoReplicaUser: "",
|
||||
mongoReplicaPassword: "",
|
||||
redisDB: 0,
|
||||
connectionParams: "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3441,6 +3688,11 @@ const ConnectionModal: React.FC<{
|
||||
{
|
||||
label: "国产数据库",
|
||||
items: [
|
||||
{
|
||||
key: "oceanbase",
|
||||
name: "OceanBase",
|
||||
icon: getDbIcon("oceanbase", undefined, 36),
|
||||
},
|
||||
{
|
||||
key: "dameng",
|
||||
name: "Dameng (达梦)",
|
||||
@@ -3461,6 +3713,11 @@ const ConnectionModal: React.FC<{
|
||||
name: "Vastbase (海量)",
|
||||
icon: getDbIcon("vastbase", undefined, 36),
|
||||
},
|
||||
{
|
||||
key: "opengauss",
|
||||
name: "OpenGauss",
|
||||
icon: getDbIcon("opengauss", undefined, 36),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -3516,6 +3773,8 @@ const ConnectionModal: React.FC<{
|
||||
return "单机 / 集群";
|
||||
case "mongodb":
|
||||
return "单机 / 副本集";
|
||||
case "oceanbase":
|
||||
return "MySQL / Oracle 租户";
|
||||
case "sqlite":
|
||||
case "duckdb":
|
||||
return "本地文件连接";
|
||||
@@ -3749,6 +4008,19 @@ const ConnectionModal: React.FC<{
|
||||
placeholder={getUriPlaceholder()}
|
||||
/>
|
||||
</Form.Item>
|
||||
{supportsConnectionParams && (
|
||||
<Form.Item
|
||||
name="connectionParams"
|
||||
label="额外连接参数"
|
||||
help="按当前数据源驱动支持的 URI/DSN query 格式填写;认证密码请使用上方密码字段。"
|
||||
>
|
||||
<Input.TextArea
|
||||
{...noAutoCapInputProps}
|
||||
rows={2}
|
||||
placeholder={getConnectionParamsPlaceholder()}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space
|
||||
size={8}
|
||||
style={{ marginBottom: uriFeedback ? 12 : 16 }}
|
||||
@@ -4495,10 +4767,33 @@ const ConnectionModal: React.FC<{
|
||||
),
|
||||
})}
|
||||
|
||||
{dbType === "oceanbase" &&
|
||||
renderConfigSectionCard({
|
||||
sectionKey: "oceanBaseProtocol",
|
||||
icon: <ClusterOutlined />,
|
||||
children: (
|
||||
<Form.Item
|
||||
name="oceanBaseProtocol"
|
||||
label="OceanBase 协议"
|
||||
help="MySQL 租户选择 MySQL;Oracle 租户选择 Oracle。该选择会同时影响连接测试、浏览表结构和 SQL 方言。"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={OCEANBASE_PROTOCOL_OPTIONS}
|
||||
onChange={() => {
|
||||
form.setFieldsValue({ mysqlTopology: "single" });
|
||||
clearConnectionTestResultForChoice();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
),
|
||||
})}
|
||||
|
||||
{(dbType === "postgres" ||
|
||||
dbType === "kingbase" ||
|
||||
dbType === "highgo" ||
|
||||
dbType === "vastbase") &&
|
||||
dbType === "vastbase" ||
|
||||
dbType === "opengauss") &&
|
||||
renderConfigSectionCard({
|
||||
sectionKey: "service",
|
||||
icon: <DatabaseOutlined />,
|
||||
@@ -4514,20 +4809,26 @@ const ConnectionModal: React.FC<{
|
||||
),
|
||||
})}
|
||||
|
||||
{dbType === "oracle" &&
|
||||
{(dbType === "oracle" || isOceanBaseOracle) &&
|
||||
renderConfigSectionCard({
|
||||
sectionKey: "service",
|
||||
icon: <DatabaseOutlined />,
|
||||
children: (
|
||||
<Form.Item
|
||||
name="database"
|
||||
label="服务名 (Service Name)"
|
||||
label={isOceanBaseOracle ? "OceanBase Oracle 服务名 (Service Name)" : "服务名 (Service Name)"}
|
||||
rules={[
|
||||
createUriAwareRequiredRule(
|
||||
"请输入 Oracle 服务名(例如 ORCLPDB1)",
|
||||
isOceanBaseOracle
|
||||
? "请输入 OceanBase Oracle 服务名"
|
||||
: "请输入 Oracle 服务名(例如 ORCLPDB1)",
|
||||
),
|
||||
]}
|
||||
help="请填写监听器注册的 SERVICE_NAME(不是用户名)。例如:ORCLPDB1"
|
||||
help={
|
||||
isOceanBaseOracle
|
||||
? "Oracle 租户必须填写监听器注册的 SERVICE_NAME;用户名仍按 OceanBase 租户格式填写。"
|
||||
: "请填写监听器注册的 SERVICE_NAME(不是用户名)。例如:ORCLPDB1"
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
@@ -5926,6 +6227,8 @@ const ConnectionModal: React.FC<{
|
||||
httpTunnelPort: 8080,
|
||||
timeout: 30,
|
||||
uri: "",
|
||||
connectionParams: "",
|
||||
oceanBaseProtocol: "mysql",
|
||||
mysqlTopology: "single",
|
||||
redisTopology: "single",
|
||||
mongoTopology: "single",
|
||||
@@ -5971,7 +6274,12 @@ const ConnectionModal: React.FC<{
|
||||
setTestResult(null);
|
||||
setTestErrorLogOpen(false);
|
||||
}
|
||||
if (changed.uri !== undefined || changed.type !== undefined) {
|
||||
if (
|
||||
changed.uri !== undefined ||
|
||||
changed.connectionParams !== undefined ||
|
||||
changed.type !== undefined ||
|
||||
changed.oceanBaseProtocol !== undefined
|
||||
) {
|
||||
setUriFeedback(null);
|
||||
}
|
||||
if (changed.useSSL !== undefined) {
|
||||
|
||||
@@ -27,7 +27,7 @@ const storeState = vi.hoisted(() => ({
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
dataTableDensity: 'comfortable',
|
||||
},
|
||||
queryOptions: {
|
||||
showColumnComment: false,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGrid from './DataGrid';
|
||||
import DataGrid, { formatCellDisplayText, resolveContextMenuFieldName } from './DataGrid';
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
@@ -14,7 +14,7 @@ vi.mock('../store', () => ({
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
dataTableDensity: 'comfortable',
|
||||
},
|
||||
queryOptions: {
|
||||
showColumnComment: false,
|
||||
@@ -83,6 +83,15 @@ describe('DataGrid layout', () => {
|
||||
expect(markup).toContain('当前页查找...');
|
||||
});
|
||||
|
||||
it('preserves fractional seconds when rendering datetime values', () => {
|
||||
expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456');
|
||||
});
|
||||
|
||||
it('resolves the field name copied from the cell context menu', () => {
|
||||
expect(resolveContextMenuFieldName('created_at', '创建时间')).toBe('created_at');
|
||||
expect(resolveContextMenuFieldName('', 'fallback_name')).toBe('fallback_name');
|
||||
});
|
||||
|
||||
it('renders a DDL action for table data pages only', () => {
|
||||
const tableMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
@@ -103,6 +112,7 @@ describe('DataGrid layout', () => {
|
||||
|
||||
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
|
||||
expect(tableMarkup).toContain('查看 DDL');
|
||||
expect(tableMarkup).not.toContain('data-grid-locate-sidebar-action="true"');
|
||||
|
||||
const schemaTableMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
@@ -169,6 +179,26 @@ describe('DataGrid layout', () => {
|
||||
expect(markup).toContain('粘贴行');
|
||||
});
|
||||
|
||||
it('renders a clickable copy action for aggregate query results', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
'COUNT(*)': 12,
|
||||
},
|
||||
]}
|
||||
columnNames={['COUNT(*)']}
|
||||
loading={false}
|
||||
exportScope="queryResult"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-grid-query-copy-action="true"');
|
||||
expect(markup).not.toMatch(/data-grid-query-copy-action="true"[^>]*disabled/);
|
||||
expect(markup).toContain('复制');
|
||||
});
|
||||
|
||||
it('renders a quick WHERE condition editor when table filters are visible', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,7 @@ const quoteSqlIdent = (dbType: string, ident: string): string => {
|
||||
const raw = String(ident || '').trim();
|
||||
if (!raw) return raw;
|
||||
const t = String(dbType || '').toLowerCase();
|
||||
if (t === 'mysql' || t === 'mariadb' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
|
||||
if (t === 'mysql' || t === 'mariadb' || t === 'oceanbase' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
|
||||
return `\`${raw.replace(/`/g, '``')}\``;
|
||||
}
|
||||
if (t === 'sqlserver') {
|
||||
|
||||
@@ -176,6 +176,46 @@ describe('DataViewer safe editing locator', () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('does not add fallback ORDER BY for DuckDB table preview when a primary key is available', async () => {
|
||||
storeState.connections[0].config.type = 'duckdb';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
|
||||
});
|
||||
|
||||
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-order', dbName: 'main', tableName: 'events', title: 'events' }));
|
||||
|
||||
const tableQueries = backendApp.DBQuery.mock.calls
|
||||
.map((call: any[]) => String(call[2] || ''))
|
||||
.filter((sql: string) => sql.includes('FROM "events"'));
|
||||
expect(tableQueries.length).toBeGreaterThan(0);
|
||||
expect(tableQueries.every((sql: string) => !/\border\s+by\b/i.test(sql))).toBe(true);
|
||||
expect(tableQueries[tableQueries.length - 1]).toContain('LIMIT 101 OFFSET 0');
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('shows an actionable message for DuckDB timeout interruption errors', async () => {
|
||||
storeState.connections[0].config.type = 'duckdb';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
|
||||
});
|
||||
backendApp.DBQuery.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'context deadline exceeded INTERRUPT Error: Interrupted!',
|
||||
fields: [],
|
||||
data: [],
|
||||
});
|
||||
|
||||
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-timeout', dbName: 'main', tableName: 'events', title: 'events' }));
|
||||
|
||||
expect(messageApi.error).toHaveBeenCalledWith('DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。');
|
||||
expect(storeState.addSqlLog.mock.calls.some((call: any[]) => String(call[0]?.message || '').includes('context deadline exceeded'))).toBe(true);
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('keeps non-Oracle table preview read-only when no safe locator exists', async () => {
|
||||
storeState.connections[0].config.type = 'mysql';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
|
||||
@@ -165,6 +165,20 @@ const isDuckDBComplexColumnType = (columnType?: string): boolean => {
|
||||
return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list');
|
||||
};
|
||||
|
||||
const formatDataViewerQueryError = (dbType: string, messageText: unknown): string => {
|
||||
const rawMessage = String(messageText || '查询失败').trim() || '查询失败';
|
||||
const lower = rawMessage.toLowerCase();
|
||||
const isTimeout = lower.includes('context deadline exceeded') || lower.includes('deadline exceeded') || lower.includes('timeout') || lower.includes('timed out') || lower.includes('超时');
|
||||
const isDuckDBInterrupted = String(dbType || '').trim().toLowerCase() === 'duckdb' && (lower.includes('interrupt error') || lower.includes('interrupted'));
|
||||
if (isTimeout || isDuckDBInterrupted) {
|
||||
if (String(dbType || '').trim().toLowerCase() === 'duckdb') {
|
||||
return 'DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。';
|
||||
}
|
||||
return '查询超过连接超时时间,已中断。请调大连接超时时间,或减少查询范围后重试。';
|
||||
}
|
||||
return rawMessage;
|
||||
};
|
||||
|
||||
const reverseOrderBySQL = (orderBySQL: string): string => {
|
||||
const raw = String(orderBySQL || '').trim();
|
||||
if (!raw) return '';
|
||||
@@ -457,7 +471,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
|
||||
const dbType = resolveDataSourceType(config);
|
||||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||||
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
|
||||
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros';
|
||||
const normalizedQuickWhereCondition = normalizeQuickWhereCondition(quickWhereCondition);
|
||||
const quickWhereValidation = validateQuickWhereCondition(normalizedQuickWhereCondition);
|
||||
if (!quickWhereValidation.ok) {
|
||||
@@ -929,11 +943,11 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error(String(resData.message || '查询失败'));
|
||||
message.error(formatDataViewerQueryError(dbTypeLower, resData.message));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (fetchSeqRef.current !== seq) return;
|
||||
message.error("Error fetching data: " + e.message);
|
||||
message.error(formatDataViewerQueryError(dbTypeLower, e?.message || e));
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-error`,
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface DbIconProps {
|
||||
const DB_DEFAULT_COLORS: Record<string, string> = {
|
||||
mysql: '#00758F',
|
||||
mariadb: '#003545',
|
||||
oceanbase: '#0052CC',
|
||||
postgres: '#336791',
|
||||
redis: '#DC382D',
|
||||
mongodb: '#47A248',
|
||||
@@ -24,6 +25,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
|
||||
sqlite: '#003B57',
|
||||
duckdb: '#FFC107',
|
||||
vastbase: '#0066CC',
|
||||
opengauss: '#2446A8',
|
||||
highgo: '#00A86B',
|
||||
tdengine: '#2962FF',
|
||||
diros: '#0050B3',
|
||||
@@ -90,6 +92,9 @@ const MySQLIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
const MariaDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="mariadb" size={size} color={color} />
|
||||
);
|
||||
const OceanBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oceanbase} label="OB" />
|
||||
);
|
||||
const PostgresIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="postgres" size={size} color={color} />
|
||||
);
|
||||
@@ -131,6 +136,9 @@ const DamengIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
const VastBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.vastbase} label="VB" />
|
||||
);
|
||||
const OpenGaussIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.opengauss} label="OG" />
|
||||
);
|
||||
const HighGoIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.highgo} label="HG" />
|
||||
);
|
||||
@@ -165,6 +173,7 @@ const SphinxIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
||||
mysql: MySQLIcon,
|
||||
mariadb: MariaDBIcon,
|
||||
oceanbase: OceanBaseIcon,
|
||||
diros: DorisIcon,
|
||||
sphinx: SphinxIcon,
|
||||
postgres: PostgresIcon,
|
||||
@@ -179,6 +188,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
||||
sqlite: SQLiteIcon,
|
||||
duckdb: DuckDBIcon,
|
||||
vastbase: VastBaseIcon,
|
||||
opengauss: OpenGaussIcon,
|
||||
highgo: HighGoIcon,
|
||||
tdengine: TDengineIcon,
|
||||
custom: CustomIcon,
|
||||
@@ -186,9 +196,9 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
||||
|
||||
/** 可选图标类型列表(用于图标选择器 UI) */
|
||||
export const DB_ICON_TYPES: string[] = [
|
||||
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'jvm',
|
||||
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
|
||||
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse',
|
||||
'kingbase', 'dameng', 'vastbase', 'highgo', 'tdengine', 'custom',
|
||||
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'tdengine', 'custom',
|
||||
];
|
||||
|
||||
/** 该类型是否有品牌 SVG 文件 */
|
||||
@@ -204,12 +214,12 @@ export const getDbIcon = (type: string, color?: string, size?: number): React.Re
|
||||
/** 获取数据库图标显示名称(中文) */
|
||||
export const getDbIconLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
mysql: 'MySQL', mariadb: 'MariaDB', postgres: 'PostgreSQL',
|
||||
mysql: 'MySQL', mariadb: 'MariaDB', oceanbase: 'OceanBase', postgres: 'PostgreSQL',
|
||||
redis: 'Redis', mongodb: 'MongoDB', jvm: 'JVM',
|
||||
oracle: 'Oracle',
|
||||
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
|
||||
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
|
||||
vastbase: 'VastBase', highgo: '瀚高', tdengine: 'TDengine',
|
||||
vastbase: 'VastBase', opengauss: 'OpenGauss', highgo: '瀚高', tdengine: 'TDengine',
|
||||
custom: '自定义',
|
||||
};
|
||||
return labels[type?.toLowerCase()] || type;
|
||||
|
||||
@@ -43,9 +43,12 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
if (type === 'custom') {
|
||||
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
|
||||
if (driver === 'diros' || driver === 'doris') return 'mysql';
|
||||
if (driver === 'oceanbase') return 'mysql';
|
||||
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
|
||||
return driver;
|
||||
}
|
||||
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
|
||||
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'dameng') return 'dm';
|
||||
return type;
|
||||
};
|
||||
@@ -133,7 +136,8 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase': {
|
||||
case 'vastbase':
|
||||
case 'opengauss': {
|
||||
const schemaRef = schema || 'public';
|
||||
return [`SELECT pg_get_viewdef('${escapeSQLLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`];
|
||||
}
|
||||
@@ -179,7 +183,8 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase': {
|
||||
case 'vastbase':
|
||||
case 'opengauss': {
|
||||
const schemaRef = schema || 'public';
|
||||
return [`SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${safeName}' LIMIT 1`];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Button, Collapse, Input, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
|
||||
import { Alert, Button, Collapse, Empty, Input, Modal, Progress, Select, Space, Switch, Tag, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, InfoCircleFilled, ReloadOutlined } from '@ant-design/icons';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { useStore } from '../store';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { buildDriverManagerWorkbenchTheme } from '../utils/driverManagerWorkbenchTheme';
|
||||
import {
|
||||
DRIVER_LOCAL_IMPORT_BUTTON_LABEL,
|
||||
DRIVER_LOCAL_IMPORT_DIRECTORY_HELP,
|
||||
@@ -113,7 +114,6 @@ type DriverVersionOption = {
|
||||
|
||||
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
|
||||
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
|
||||
const DRIVER_TABLE_SCROLL_X = 1450;
|
||||
const DRIVER_STATUS_CACHE_TTL_MS = 60 * 1000;
|
||||
const DRIVER_NETWORK_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase();
|
||||
@@ -179,11 +179,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
const darkMode = theme === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const modalContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableScrollTargetsRef = useRef<HTMLElement[]>([]);
|
||||
const externalHScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const horizontalSyncSourceRef = useRef<'table' | 'external' | ''>('');
|
||||
const driverManagerTheme = useMemo(
|
||||
() => buildDriverManagerWorkbenchTheme(darkMode, opacity),
|
||||
[darkMode, opacity],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadDir, setDownloadDir] = useState('');
|
||||
const [networkChecking, setNetworkChecking] = useState(false);
|
||||
@@ -201,13 +200,39 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
|
||||
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
|
||||
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
|
||||
const [horizontalScrollWidth, setHorizontalScrollWidth] = useState(DRIVER_TABLE_SCROLL_X);
|
||||
const downloadDirRef = useRef(downloadDir);
|
||||
|
||||
useEffect(() => {
|
||||
downloadDirRef.current = downloadDir;
|
||||
}, [downloadDir]);
|
||||
|
||||
const modalBodyStyle = useMemo<React.CSSProperties>(() => ({
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 18,
|
||||
background: driverManagerTheme.pageBg,
|
||||
color: driverManagerTheme.titleText,
|
||||
}), [driverManagerTheme]);
|
||||
|
||||
const managerSectionStyle = useMemo<React.CSSProperties>(() => ({
|
||||
border: driverManagerTheme.sectionBorder,
|
||||
borderRadius: 8,
|
||||
background: driverManagerTheme.sectionBg,
|
||||
}), [driverManagerTheme]);
|
||||
|
||||
const managerStatStyle = useMemo<React.CSSProperties>(() => ({
|
||||
border: driverManagerTheme.statBorder,
|
||||
borderRadius: 8,
|
||||
background: driverManagerTheme.statBg,
|
||||
}), [driverManagerTheme]);
|
||||
|
||||
const managerUpdateNoteStyle = useMemo<React.CSSProperties>(() => ({
|
||||
border: driverManagerTheme.updateNoteBorder,
|
||||
borderRadius: 8,
|
||||
background: driverManagerTheme.updateNoteBg,
|
||||
}), [driverManagerTheme]);
|
||||
|
||||
const appendOperationLog = useCallback((
|
||||
driverType: string,
|
||||
text: string,
|
||||
@@ -254,76 +279,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshHorizontalScrollState = useCallback(() => {
|
||||
const tableContainer = tableContainerRef.current;
|
||||
const targets = tableContainer
|
||||
? [
|
||||
...new Set(
|
||||
[
|
||||
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
|
||||
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
|
||||
].filter((node): node is HTMLElement => node instanceof HTMLElement),
|
||||
),
|
||||
]
|
||||
: tableScrollTargetsRef.current;
|
||||
if (!targets || targets.length === 0) {
|
||||
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWidth = Math.max(
|
||||
DRIVER_TABLE_SCROLL_X,
|
||||
...targets.map((target) => Math.max(0, target.scrollWidth)),
|
||||
);
|
||||
setHorizontalScrollWidth((prev) => (prev === nextWidth ? prev : nextWidth));
|
||||
|
||||
const externalScroll = externalHScrollRef.current;
|
||||
if (!externalScroll || horizontalSyncSourceRef.current === 'external') {
|
||||
return;
|
||||
}
|
||||
const preferredTarget =
|
||||
targets.find((target) => target.scrollWidth > target.clientWidth + 1) ||
|
||||
targets[0];
|
||||
const targetScrollLeft = preferredTarget?.scrollLeft || 0;
|
||||
if (Math.abs(externalScroll.scrollLeft - targetScrollLeft) > 1) {
|
||||
externalScroll.scrollLeft = targetScrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyExternalScrollToTableTargets = useCallback(() => {
|
||||
const tableContainer = tableContainerRef.current;
|
||||
const externalScroll = externalHScrollRef.current;
|
||||
if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) {
|
||||
return;
|
||||
}
|
||||
if (horizontalSyncSourceRef.current === 'table') {
|
||||
return;
|
||||
}
|
||||
|
||||
const liveTargets = [
|
||||
...new Set(
|
||||
[
|
||||
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
|
||||
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
|
||||
].filter((node): node is HTMLElement => node instanceof HTMLElement),
|
||||
),
|
||||
];
|
||||
if (liveTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
horizontalSyncSourceRef.current = 'external';
|
||||
liveTargets.forEach((target) => {
|
||||
if (target.scrollWidth <= target.clientWidth + 1) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(target.scrollLeft - externalScroll.scrollLeft) > 1) {
|
||||
target.scrollLeft = externalScroll.scrollLeft;
|
||||
}
|
||||
});
|
||||
horizontalSyncSourceRef.current = '';
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async (
|
||||
toastOnError = true,
|
||||
options?: { showLoading?: boolean },
|
||||
@@ -601,8 +556,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
|
||||
tableScrollTargetsRef.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -630,117 +583,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
}
|
||||
}, [checkNetworkStatus, open, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const tableContainer = tableContainerRef.current;
|
||||
const externalScroll = externalHScrollRef.current;
|
||||
if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentTargets: HTMLElement[] = [];
|
||||
let rafId: number | null = null;
|
||||
let bodyResizeObserver: ResizeObserver | null = null;
|
||||
let containerResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const pickSyncTarget = () => {
|
||||
if (currentTargets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return currentTargets.find((target) => target.scrollWidth > target.clientWidth + 1) || currentTargets[0];
|
||||
};
|
||||
|
||||
const syncFromTableTarget = (event?: Event) => {
|
||||
const source = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
const activeTarget = source || pickSyncTarget();
|
||||
if (!activeTarget) {
|
||||
return;
|
||||
}
|
||||
if (horizontalSyncSourceRef.current === 'external') {
|
||||
return;
|
||||
}
|
||||
horizontalSyncSourceRef.current = 'table';
|
||||
if (Math.abs(externalScroll.scrollLeft - activeTarget.scrollLeft) > 1) {
|
||||
externalScroll.scrollLeft = activeTarget.scrollLeft;
|
||||
}
|
||||
horizontalSyncSourceRef.current = '';
|
||||
};
|
||||
|
||||
const bindCurrentTableTargets = () => {
|
||||
const nextTargets = [
|
||||
...new Set(
|
||||
[
|
||||
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
|
||||
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
|
||||
].filter((node): node is HTMLElement => node instanceof HTMLElement),
|
||||
),
|
||||
];
|
||||
|
||||
const sameTargets =
|
||||
nextTargets.length === currentTargets.length &&
|
||||
nextTargets.every((target, index) => target === currentTargets[index]);
|
||||
if (sameTargets) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTargets.forEach((target) => {
|
||||
target.removeEventListener('scroll', syncFromTableTarget);
|
||||
bodyResizeObserver?.unobserve(target);
|
||||
});
|
||||
|
||||
currentTargets = nextTargets;
|
||||
tableScrollTargetsRef.current = nextTargets;
|
||||
currentTargets.forEach((target) => {
|
||||
target.addEventListener('scroll', syncFromTableTarget, { passive: true });
|
||||
bodyResizeObserver?.observe(target);
|
||||
});
|
||||
|
||||
refreshHorizontalScrollState();
|
||||
syncFromTableTarget();
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
bindCurrentTableTargets();
|
||||
refreshHorizontalScrollState();
|
||||
});
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(scheduleRefresh);
|
||||
mutationObserver.observe(tableContainer, { childList: true, subtree: true });
|
||||
|
||||
bodyResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null;
|
||||
containerResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null;
|
||||
containerResizeObserver?.observe(tableContainer);
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
modalContentRef.current && containerResizeObserver?.observe(modalContentRef.current);
|
||||
}
|
||||
window.addEventListener('resize', scheduleRefresh);
|
||||
|
||||
scheduleRefresh();
|
||||
return () => {
|
||||
mutationObserver.disconnect();
|
||||
window.removeEventListener('resize', scheduleRefresh);
|
||||
currentTargets.forEach((target) => {
|
||||
target.removeEventListener('scroll', syncFromTableTarget);
|
||||
});
|
||||
if (bodyResizeObserver) {
|
||||
bodyResizeObserver.disconnect();
|
||||
}
|
||||
if (containerResizeObserver) {
|
||||
containerResizeObserver.disconnect();
|
||||
}
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [open, refreshHorizontalScrollState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
@@ -1011,221 +853,155 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
}
|
||||
}, [appendOperationLog, downloadDir, refreshStatus]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: '数据源',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 220,
|
||||
render: (_: string, row: DriverStatusRow) => (
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<Text strong>{row.name}</Text>
|
||||
{row.message ? (
|
||||
<Text type={row.needsUpdate ? 'warning' : 'secondary'} style={{ fontSize: 12 }}>
|
||||
{row.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '安装包大小',
|
||||
dataIndex: 'packageSizeText',
|
||||
key: 'packageSizeText',
|
||||
width: 120,
|
||||
render: (_: string | undefined, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return row.packageSizeText || '-';
|
||||
}
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
|
||||
const selectedOption =
|
||||
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
options.find((item) => item.recommended) ||
|
||||
options[0];
|
||||
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
|
||||
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
|
||||
return '计算中...';
|
||||
}
|
||||
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 140,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Tag color="success">内置可用</Tag>;
|
||||
}
|
||||
const progress = progressMap[row.type];
|
||||
if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
|
||||
return <Tag color="processing">安装中 {Math.round(progress.percent)}%</Tag>;
|
||||
}
|
||||
if (row.needsUpdate) {
|
||||
return <Tag color="warning">强烈建议重装</Tag>;
|
||||
}
|
||||
if (row.connectable) {
|
||||
return <Tag color="success">已启用</Tag>;
|
||||
}
|
||||
if (row.packageInstalled) {
|
||||
return <Tag color="warning">已安装</Tag>;
|
||||
}
|
||||
return <Tag color="default">未启用</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '安装进度',
|
||||
key: 'progress',
|
||||
width: 170,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
const resolvePackageSizeText = (row: DriverStatusRow): string => {
|
||||
if (row.builtIn) {
|
||||
return row.packageSizeText || '-';
|
||||
}
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
|
||||
const selectedOption =
|
||||
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
options.find((item) => item.recommended) ||
|
||||
options[0];
|
||||
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
|
||||
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
|
||||
return '计算中...';
|
||||
}
|
||||
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
|
||||
};
|
||||
|
||||
const progress = progressMap[row.type];
|
||||
let percent = 0;
|
||||
let status: 'normal' | 'exception' | 'active' | 'success' = 'normal';
|
||||
const resolveDriverStatusTag = (row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Tag color="success">内置可用</Tag>;
|
||||
}
|
||||
const progress = progressMap[row.type];
|
||||
if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
|
||||
return <Tag color="processing">安装中 {Math.round(progress.percent)}%</Tag>;
|
||||
}
|
||||
if (row.needsUpdate) {
|
||||
return <Tag color="warning">建议重装</Tag>;
|
||||
}
|
||||
if (row.connectable) {
|
||||
return <Tag color="success">已启用</Tag>;
|
||||
}
|
||||
if (row.packageInstalled) {
|
||||
return <Tag color="warning">已安装未启用</Tag>;
|
||||
}
|
||||
return <Tag>未启用</Tag>;
|
||||
};
|
||||
|
||||
if (progress?.status === 'error') {
|
||||
percent = Math.max(0, Math.min(100, Math.round(progress.percent || 0)));
|
||||
status = 'exception';
|
||||
} else if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
|
||||
percent = Math.max(1, Math.min(99, Math.round(progress.percent || 0)));
|
||||
status = 'active';
|
||||
} else if (row.connectable || row.packageInstalled) {
|
||||
percent = 100;
|
||||
status = 'success';
|
||||
}
|
||||
const resolveDriverProgress = (row: DriverStatusRow) => {
|
||||
const progress = progressMap[row.type];
|
||||
let percent = 0;
|
||||
let status: 'normal' | 'exception' | 'active' | 'success' = 'normal';
|
||||
|
||||
return <Progress percent={percent} status={status} size="small" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '驱动版本',
|
||||
key: 'driverVersion',
|
||||
width: 230,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
const versionLocked = row.packageInstalled || row.connectable;
|
||||
if (versionLocked) {
|
||||
const installedVersion = String(row.installedVersion || '').trim();
|
||||
const revisionHint = row.needsUpdate ? ',需重装' : '';
|
||||
if (installedVersion) {
|
||||
return <Text type="secondary">{installedVersion}(已安装{revisionHint},移除后可更换)</Text>;
|
||||
if (progress?.status === 'error') {
|
||||
percent = Math.max(0, Math.min(100, Math.round(progress.percent || 0)));
|
||||
status = 'exception';
|
||||
} else if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
|
||||
percent = Math.max(1, Math.min(99, Math.round(progress.percent || 0)));
|
||||
status = 'active';
|
||||
} else if (row.connectable || row.packageInstalled) {
|
||||
percent = 100;
|
||||
status = 'success';
|
||||
}
|
||||
|
||||
return { percent, status };
|
||||
};
|
||||
|
||||
const renderVersionControl = (row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">内置驱动无需安装</Text>;
|
||||
}
|
||||
|
||||
const versionLocked = row.packageInstalled || row.connectable;
|
||||
if (versionLocked) {
|
||||
const installedVersion = String(row.installedVersion || '').trim();
|
||||
const revisionHint = row.needsUpdate ? ',需重装' : '';
|
||||
return (
|
||||
<Text type="secondary" className="driver-manager-version-lock">
|
||||
{installedVersion ? `${installedVersion}(已安装${revisionHint})` : `已安装${row.needsUpdate ? ',需重装' : ''}`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectOptions = buildVersionSelectOptions(options);
|
||||
const mongoHint = row.type === 'mongodb'
|
||||
? '当前仅支持 MongoDB 1.17.x 和 2.x;更老 1.x 暂不提供安装。'
|
||||
: '';
|
||||
return (
|
||||
<div className="driver-manager-version-control">
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
loading={!!versionLoadingMap[row.type]}
|
||||
disabled={actionState.driverType === row.type}
|
||||
placeholder={options.length > 0 ? '选择驱动版本' : '点击加载版本'}
|
||||
value={selectedKey}
|
||||
options={selectOptions as any}
|
||||
onOpenChange={(open) => {
|
||||
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
|
||||
void loadVersionOptions(row, true);
|
||||
return;
|
||||
}
|
||||
return <Text type="secondary">已安装({row.needsUpdate ? '需重装,' : ''}移除后可更换)</Text>;
|
||||
}
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectOptions = buildVersionSelectOptions(options);
|
||||
const mongoHint = row.type === 'mongodb'
|
||||
? '当前仅支持 MongoDB 1.17.x 和 2.x;更老 1.x 暂不提供安装。'
|
||||
: '';
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
loading={!!versionLoadingMap[row.type]}
|
||||
disabled={actionState.driverType === row.type}
|
||||
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
|
||||
value={selectedKey}
|
||||
options={selectOptions as any}
|
||||
onOpenChange={(open) => {
|
||||
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
|
||||
void loadVersionOptions(row, true);
|
||||
return;
|
||||
}
|
||||
if (open && selectedKey) {
|
||||
void loadVersionPackageSize(row, selectedKey);
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
|
||||
void loadVersionPackageSize(row, value);
|
||||
}}
|
||||
/>
|
||||
{mongoHint ? <Text type="secondary" style={{ fontSize: 12 }}>{mongoHint}</Text> : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 320,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
const isSlimBuildUnavailable = (row.message || '').includes('精简构建');
|
||||
const loadingInstallOrRemove =
|
||||
actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove');
|
||||
const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local';
|
||||
if (isSlimBuildUnavailable && !row.packageInstalled) {
|
||||
return <Text type="secondary">需 Full 版</Text>;
|
||||
}
|
||||
if (open && selectedKey) {
|
||||
void loadVersionPackageSize(row, selectedKey);
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
|
||||
void loadVersionPackageSize(row, value);
|
||||
}}
|
||||
/>
|
||||
{mongoHint ? <Text type="secondary" className="driver-manager-small-text">{mongoHint}</Text> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const logs = operationLogMap[row.type] || [];
|
||||
const hasLogs = logs.length > 0;
|
||||
const renderDriverActions = (row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return null;
|
||||
}
|
||||
const isSlimBuildUnavailable = (row.message || '').includes('精简构建');
|
||||
const loadingInstallOrRemove =
|
||||
actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove');
|
||||
const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local';
|
||||
const logs = operationLogMap[row.type] || [];
|
||||
const hasLogs = logs.length > 0;
|
||||
|
||||
const mainAction = row.needsUpdate ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={loadingInstallOrRemove}
|
||||
onClick={() => installDriver(row)}
|
||||
>
|
||||
重装驱动
|
||||
</Button>
|
||||
) : row.connectable ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={loadingInstallOrRemove}
|
||||
onClick={() => removeDriver(row)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={loadingInstallOrRemove}
|
||||
onClick={() => installDriver(row)}
|
||||
>
|
||||
安装启用
|
||||
</Button>
|
||||
);
|
||||
if (isSlimBuildUnavailable && !row.packageInstalled) {
|
||||
return <Text type="secondary">当前精简版不可安装,请使用 Full 版</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space size={8} wrap>
|
||||
{mainAction}
|
||||
<Button
|
||||
icon={<FileSearchOutlined />}
|
||||
loading={loadingLocal}
|
||||
onClick={() => installDriverFromLocalFile(row)}
|
||||
>
|
||||
{DRIVER_LOCAL_IMPORT_BUTTON_LABEL}
|
||||
</Button>
|
||||
<Button
|
||||
type={hasLogs ? 'default' : 'text'}
|
||||
disabled={!hasLogs}
|
||||
onClick={() => openDriverLog(row.type)}
|
||||
>
|
||||
日志
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [actionState, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
|
||||
const mainAction = row.needsUpdate ? (
|
||||
<Button type="primary" icon={<DownloadOutlined />} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
|
||||
重装驱动
|
||||
</Button>
|
||||
) : row.connectable ? (
|
||||
<Button danger icon={<DeleteOutlined />} loading={loadingInstallOrRemove} onClick={() => removeDriver(row)}>
|
||||
移除
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<DownloadOutlined />} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
|
||||
安装启用
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Space size={8} wrap className="driver-manager-card-actions">
|
||||
{mainAction}
|
||||
<Button icon={<FileSearchOutlined />} loading={loadingLocal} onClick={() => installDriverFromLocalFile(row)}>
|
||||
{DRIVER_LOCAL_IMPORT_BUTTON_LABEL}
|
||||
</Button>
|
||||
<Button type={hasLogs ? 'default' : 'text'} disabled={!hasLogs} onClick={() => openDriverLog(row.type)}>
|
||||
日志
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const activeLogRow = useMemo(() => {
|
||||
if (!logDriverType) {
|
||||
@@ -1259,6 +1035,93 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
}
|
||||
return `共 ${rows.length} 个驱动`;
|
||||
}, [filteredRows.length, normalizedSearchKeyword, rows.length]);
|
||||
const statusSummary = useMemo(() => {
|
||||
const optionalRows = rows.filter((row) => !row.builtIn);
|
||||
return {
|
||||
total: rows.length,
|
||||
enabled: optionalRows.filter((row) => row.connectable).length,
|
||||
needsUpdate: optionalRows.filter((row) => row.needsUpdate).length,
|
||||
notEnabled: optionalRows.filter((row) => !row.connectable && !row.packageInstalled).length,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
const renderDriverCard = (row: DriverStatusRow) => {
|
||||
const progress = resolveDriverProgress(row);
|
||||
const hasActiveProgress = !!progressMap[row.type] || row.connectable || row.packageInstalled;
|
||||
const issueText = String(row.updateReason || row.message || '').trim();
|
||||
const affectedText = row.affectedConnections && row.affectedConnections > 0
|
||||
? `影响 ${row.affectedConnections} 个已保存连接`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.type}
|
||||
className={[
|
||||
'driver-manager-card',
|
||||
row.needsUpdate ? 'driver-manager-card-warning' : '',
|
||||
row.connectable ? 'driver-manager-card-ready' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={{
|
||||
border: row.needsUpdate
|
||||
? driverManagerTheme.cardWarningBorder
|
||||
: (row.connectable ? driverManagerTheme.cardReadyBorder : driverManagerTheme.cardBorder),
|
||||
background: driverManagerTheme.cardBg,
|
||||
}}
|
||||
>
|
||||
<div className="driver-manager-card-main">
|
||||
<div className="driver-manager-card-info">
|
||||
<div className="driver-manager-title-row">
|
||||
<Text strong className="driver-manager-driver-name">{row.name}</Text>
|
||||
<Tag>{row.type}</Tag>
|
||||
{resolveDriverStatusTag(row)}
|
||||
</div>
|
||||
<div className="driver-manager-meta-row">
|
||||
<Text type="secondary">大小:{resolvePackageSizeText(row)}</Text>
|
||||
<Text type="secondary">版本:{row.installedVersion || row.pinnedVersion || '-'}</Text>
|
||||
{affectedText ? <Text type="secondary">{affectedText}</Text> : null}
|
||||
</div>
|
||||
{row.needsUpdate && issueText ? (
|
||||
<div className="driver-manager-update-note" style={managerUpdateNoteStyle}>
|
||||
<Text strong type="warning">需要重装</Text>
|
||||
<Paragraph
|
||||
className="driver-manager-note-text"
|
||||
ellipsis={{ rows: 2, expandable: true, symbol: '展开原因' }}
|
||||
>
|
||||
{issueText}
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : issueText ? (
|
||||
<Paragraph
|
||||
className="driver-manager-muted-message"
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
|
||||
>
|
||||
{issueText}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="driver-manager-card-controls">
|
||||
<div className="driver-manager-control-block">
|
||||
<Text type="secondary" className="driver-manager-control-label">驱动版本</Text>
|
||||
{renderVersionControl(row)}
|
||||
</div>
|
||||
<div className="driver-manager-control-block">
|
||||
<Text type="secondary" className="driver-manager-control-label">状态进度</Text>
|
||||
{row.builtIn ? (
|
||||
<Text type="secondary">无需安装</Text>
|
||||
) : hasActiveProgress ? (
|
||||
<Progress percent={progress.percent} status={progress.status} size="small" />
|
||||
) : (
|
||||
<Progress percent={0} size="small" />
|
||||
)}
|
||||
</div>
|
||||
{renderDriverActions(row)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const activeDriverLogs = operationLogMap[logDriverType] || [];
|
||||
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
|
||||
@@ -1286,44 +1149,54 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
title="驱动管理"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={980}
|
||||
width={1120}
|
||||
style={{ top: 24 }}
|
||||
className="driver-manager-modal"
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 18,
|
||||
},
|
||||
body: modalBodyStyle,
|
||||
}}
|
||||
destroyOnHidden
|
||||
footer={(
|
||||
<div className="driver-manager-footer">
|
||||
<div
|
||||
ref={externalHScrollRef}
|
||||
className="driver-manager-hscroll"
|
||||
aria-hidden={false}
|
||||
onScroll={applyExternalScrollToTableTargets}
|
||||
>
|
||||
<div className="driver-manager-hscroll-inner" style={{ width: `${Math.max(horizontalScrollWidth, 1)}px` }} />
|
||||
</div>
|
||||
<Space className="driver-manager-footer-actions" size={8}>
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
|
||||
网络检测
|
||||
</Button>
|
||||
<Button key="close" type="primary" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Space className="driver-manager-footer-actions" size={8}>
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
|
||||
网络检测
|
||||
</Button>
|
||||
<Button key="close" type="primary" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<div ref={modalContentRef}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Text type="secondary">除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。</Text>
|
||||
<div className="driver-manager-shell" data-driver-theme={driverManagerTheme.isDark ? 'dark' : 'light'}>
|
||||
<div className="driver-manager-header" style={managerSectionStyle}>
|
||||
<div className="driver-manager-heading">
|
||||
<Text type="secondary">除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。</Text>
|
||||
<Text type="secondary">驱动代理独立运行,GoNavi 升级后如提示重装,请重新安装对应驱动以应用新的 agent 逻辑。</Text>
|
||||
</div>
|
||||
<div className="driver-manager-stats">
|
||||
<div className="driver-manager-stat" style={managerStatStyle}>
|
||||
<span>{statusSummary.total}</span>
|
||||
<Text type="secondary">全部</Text>
|
||||
</div>
|
||||
<div className="driver-manager-stat" style={managerStatStyle}>
|
||||
<span>{statusSummary.enabled}</span>
|
||||
<Text type="secondary">已启用</Text>
|
||||
</div>
|
||||
<div className="driver-manager-stat driver-manager-stat-warning" style={managerStatStyle}>
|
||||
<span style={{ color: driverManagerTheme.warningText }}>{statusSummary.needsUpdate}</span>
|
||||
<Text type="secondary">需重装</Text>
|
||||
</div>
|
||||
<div className="driver-manager-stat" style={managerStatStyle}>
|
||||
<span>{statusSummary.notEnabled}</span>
|
||||
<Text type="secondary">未启用</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{networkStatus ? (
|
||||
networkUnreachable ? (
|
||||
<Alert
|
||||
@@ -1399,51 +1272,43 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
/>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
icon={sharedInfoAlertIcon}
|
||||
message="驱动目录与复用说明"
|
||||
description={(
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'driver-directory',
|
||||
label: '查看驱动目录与复用说明',
|
||||
children: (
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary">自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。</Text>
|
||||
<Text type="secondary">{DRIVER_LOCAL_IMPORT_DIRECTORY_HELP}</Text>
|
||||
<Text type="secondary">{DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP}</Text>
|
||||
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
|
||||
驱动根目录:{downloadDir || '-'}
|
||||
<div className="driver-manager-directory-panel" style={managerSectionStyle}>
|
||||
<Collapse
|
||||
size="small"
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'driver-directory',
|
||||
label: '驱动目录与手动导入说明',
|
||||
children: (
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary">自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。</Text>
|
||||
<Text type="secondary">{DRIVER_LOCAL_IMPORT_DIRECTORY_HELP}</Text>
|
||||
<Text type="secondary">{DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP}</Text>
|
||||
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
|
||||
驱动根目录:{downloadDir || '-'}
|
||||
</Paragraph>
|
||||
{networkStatus?.logPath ? (
|
||||
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
|
||||
运行日志文件:{networkStatus.logPath}
|
||||
</Paragraph>
|
||||
<Button icon={<FolderOpenOutlined />} onClick={() => void openDriverDirectory()}>
|
||||
打开驱动目录
|
||||
</Button>
|
||||
{networkStatus?.logPath ? (
|
||||
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
|
||||
运行日志文件:{networkStatus.logPath}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ width: '100%', display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<div className="driver-manager-toolbar">
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索驱动名称/类型(如 DuckDB、clickhouse)"
|
||||
value={searchKeyword}
|
||||
onChange={(event) => setSearchKeyword(event.target.value)}
|
||||
style={{ minWidth: 300, flex: '1 1 360px' }}
|
||||
className="driver-manager-search"
|
||||
/>
|
||||
<Space size={8}>
|
||||
<Space size={8} wrap className="driver-manager-toolbar-actions">
|
||||
<Text type="secondary">覆盖已安装</Text>
|
||||
<Switch
|
||||
checked={forceOverwriteInstalled}
|
||||
@@ -1465,30 +1330,22 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Text type="secondary">{filterSummaryText}</Text>
|
||||
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className="driver-manager-table-wrap driver-manager-table-wrap-external-active"
|
||||
>
|
||||
<Table
|
||||
className="driver-manager-table"
|
||||
rowKey="type"
|
||||
loading={loading}
|
||||
columns={columns as any}
|
||||
dataSource={filteredRows}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
sticky={false}
|
||||
scroll={{ x: DRIVER_TABLE_SCROLL_X }}
|
||||
locale={{
|
||||
emptyText: normalizedSearchKeyword
|
||||
? `未找到匹配“${String(searchKeyword || '').trim()}”的驱动`
|
||||
: '暂无驱动数据',
|
||||
}}
|
||||
/>
|
||||
<div className="driver-manager-list-head">
|
||||
<Text type="secondary">{filterSummaryText}</Text>
|
||||
{loading ? <Text type="secondary">正在刷新状态...</Text> : null}
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<div className="driver-manager-list">
|
||||
{filteredRows.length > 0 ? (
|
||||
filteredRows.map(renderDriverCard)
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={normalizedSearchKeyword ? `未找到匹配“${String(searchKeyword || '').trim()}”的驱动` : '暂无驱动数据'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
<Modal
|
||||
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}
|
||||
|
||||
@@ -216,7 +216,7 @@ describe('QueryEditor external SQL save', () => {
|
||||
});
|
||||
|
||||
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
let renderer!: ReactTestRenderer;
|
||||
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
|
||||
|
||||
await act(async () => {
|
||||
@@ -240,7 +240,7 @@ describe('QueryEditor external SQL save', () => {
|
||||
});
|
||||
|
||||
it('does not create saved queries when external SQL file writes fail', async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
let renderer!: ReactTestRenderer;
|
||||
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
|
||||
backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
|
||||
|
||||
@@ -272,7 +272,7 @@ describe('QueryEditor external SQL save', () => {
|
||||
},
|
||||
];
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
|
||||
});
|
||||
@@ -412,6 +412,49 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rewrites Oracle SELECT * queries before injecting hidden ROWID locator columns', async () => {
|
||||
storeState.connections[0].config.type = 'oracle';
|
||||
storeState.connections[0].config.database = 'ORCLPDB1';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['WAFER_ID', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ WAFER_ID: 'R015Z10F08', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
|
||||
});
|
||||
backendApp.DBGetColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ name: 'WAFER_ID', key: '' }],
|
||||
});
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT * FROM MYCIMLED.EDC_LOG' })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]);
|
||||
expect(executedSql).toContain('FROM MYCIMLED.EDC_LOG');
|
||||
expect(executedSql).toContain('FROM MYCIMLED.EDC_LOG gonavi_query_source');
|
||||
expect(executedSql).not.toContain('__gonavi_query_source__');
|
||||
expect(executedSql).not.toContain('SELECT *, ROWID AS');
|
||||
expect(executedSql).toMatch(/SELECT\s+gonavi_query_source\.\*\s*,\s+gonavi_query_source\.ROWID\s+AS\s+"__gonavi_oracle_rowid__"/i);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'oracle-rowid',
|
||||
columns: ['ROWID'],
|
||||
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
renderer?.unmount();
|
||||
});
|
||||
|
||||
it('keeps non-Oracle query results read-only when no safe locator exists', async () => {
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
@@ -494,12 +537,14 @@ describe('QueryEditor external SQL save', () => {
|
||||
it.each([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'oceanbase',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'postgres',
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'opengauss',
|
||||
'sqlserver',
|
||||
'sqlite',
|
||||
'duckdb',
|
||||
|
||||
@@ -9,8 +9,8 @@ import { useStore } from '../store';
|
||||
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from '../utils/mongodb';
|
||||
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
|
||||
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb";
|
||||
import { getShortcutDisplay, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding } from "../utils/shortcuts";
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
|
||||
@@ -178,8 +178,13 @@ const SQL_FUNCTIONS: { name: string; detail: string }[] = [
|
||||
{ name: 'SLEEP', detail: '工具 - 延时' },
|
||||
];
|
||||
|
||||
// 模块级标志:确保 SQL completion provider 全局只注册一次
|
||||
let sqlCompletionRegistered = false;
|
||||
// HMR 重载时释放旧注册避免补全项重复
|
||||
const _g = globalThis as any;
|
||||
if (!_g.__gonaviSqlCompletionState) {
|
||||
_g.__gonaviSqlCompletionState = { registered: false, disposables: [] as any[] };
|
||||
}
|
||||
let sqlCompletionRegistered = _g.__gonaviSqlCompletionState.registered;
|
||||
let sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables;
|
||||
|
||||
// 模块级共享变量:completion provider 从这些变量读取当前活跃 Tab 的状态。
|
||||
// 每个 QueryEditor 实例在成为活跃 Tab 时更新这些变量,确保 provider 始终使用正确的上下文。
|
||||
@@ -203,6 +208,7 @@ const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({
|
||||
|
||||
type SimpleSelectInfo = {
|
||||
selectsAll: boolean;
|
||||
selectsBareAll: boolean;
|
||||
writableColumns: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -282,6 +288,7 @@ const splitTopLevelComma = (text: string): string[] => {
|
||||
const SIMPLE_IDENTIFIER_PATH_RE = /^(?:[`"\[]?[A-Za-z_][\w$]*[`"\]]?\s*\.\s*){0,2}[`"\[]?[A-Za-z_][\w$]*[`"\]]?$/;
|
||||
const QUERY_ALIAS_RESERVED = new Set([
|
||||
'where', 'group', 'order', 'having', 'limit', 'fetch', 'offset', 'join', 'left', 'right', 'inner', 'outer', 'on', 'union',
|
||||
'for', 'connect', 'start', 'window', 'sample', 'pivot', 'unpivot', 'qualify', 'model',
|
||||
]);
|
||||
|
||||
const getLastIdentifierPart = (path: string): string => {
|
||||
@@ -325,16 +332,21 @@ const parseSimpleSelectInfo = (sql: string): SimpleSelectInfo | undefined => {
|
||||
|
||||
const writableColumns: Record<string, string> = {};
|
||||
let selectsAll = false;
|
||||
let selectsBareAll = false;
|
||||
for (const item of splitTopLevelComma(selectList)) {
|
||||
const trimmedItem = String(item || '').trim();
|
||||
const resolved = resolveSimpleSelectItemColumn(item);
|
||||
if (!resolved) continue;
|
||||
if (resolved === 'all') {
|
||||
selectsAll = true;
|
||||
if (trimmedItem === '*') {
|
||||
selectsBareAll = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
writableColumns[resolved.resultName] = resolved.sourceName;
|
||||
}
|
||||
return { selectsAll, writableColumns };
|
||||
return { selectsAll, selectsBareAll, writableColumns };
|
||||
};
|
||||
|
||||
const appendQuerySelectExpressions = (sql: string, expressions: string[]): string => {
|
||||
@@ -345,6 +357,89 @@ const appendQuerySelectExpressions = (sql: string, expressions: string[]): strin
|
||||
);
|
||||
};
|
||||
|
||||
const QUERY_LOCATOR_SOURCE_ALIAS = 'gonavi_query_source';
|
||||
|
||||
const rewriteOracleSelectAllWithExpressions = (sql: string, expressions: string[]): string | undefined => {
|
||||
if (expressions.length === 0) return undefined;
|
||||
|
||||
const match = String(sql || '').match(/^(\s*SELECT\s+)([\s\S]+?)(\s+FROM\s+)([\s\S]*)$/i);
|
||||
if (!match) return undefined;
|
||||
|
||||
const prefix = match[1];
|
||||
const selectList = match[2].trim();
|
||||
const fromKeyword = match[3];
|
||||
const fromTail = match[4];
|
||||
const selectItems = splitTopLevelComma(selectList);
|
||||
if (selectItems.length === 0) return undefined;
|
||||
|
||||
let selectAllFound = false;
|
||||
for (const item of selectItems) {
|
||||
if (String(item || '').trim() === '*') {
|
||||
selectAllFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!selectAllFound) return undefined;
|
||||
|
||||
const fromTrimmed = fromTail.trimStart();
|
||||
const tableMatch = fromTrimmed.match(/^((?:[`"\[]?\w+[`"\]]?)(?:\s*\.\s*(?:[`"\[]?\w+[`"\]]?)){0,2})([\s\S]*)$/);
|
||||
if (!tableMatch) return undefined;
|
||||
|
||||
const tableText = tableMatch[1];
|
||||
const afterTable = tableMatch[2] || '';
|
||||
|
||||
const parseAlias = (tail: string): { alias: string; remainder: string } => {
|
||||
const trimmedTail = String(tail || '').trimStart();
|
||||
if (!trimmedTail) {
|
||||
return { alias: '', remainder: tail };
|
||||
}
|
||||
|
||||
const asMatch = trimmedTail.match(/^AS\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)([\s\S]*)$/i);
|
||||
if (asMatch) {
|
||||
const candidate = stripQueryIdentifierQuotes(asMatch[1]);
|
||||
if (candidate && !QUERY_ALIAS_RESERVED.has(candidate.toLowerCase())) {
|
||||
return { alias: candidate, remainder: asMatch[2] || '' };
|
||||
}
|
||||
}
|
||||
|
||||
const bareMatch = trimmedTail.match(/^([`"\[]?[A-Za-z_][\w$]*[`"\]]?)([\s\S]*)$/);
|
||||
if (bareMatch) {
|
||||
const candidate = stripQueryIdentifierQuotes(bareMatch[1]);
|
||||
if (candidate && !QUERY_ALIAS_RESERVED.has(candidate.toLowerCase())) {
|
||||
return { alias: candidate, remainder: bareMatch[2] || '' };
|
||||
}
|
||||
}
|
||||
|
||||
return { alias: '', remainder: tail };
|
||||
};
|
||||
|
||||
const parsedAlias = parseAlias(afterTable);
|
||||
const sourceAlias = parsedAlias.alias || QUERY_LOCATOR_SOURCE_ALIAS;
|
||||
const qualifiedExpressions = expressions
|
||||
.map((expression) => {
|
||||
const trimmed = String(expression || '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (/^ROWID\b/i.test(trimmed)) {
|
||||
return trimmed.replace(/^(\s*)ROWID\b/i, `$1${sourceAlias}.ROWID`);
|
||||
}
|
||||
return trimmed;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (qualifiedExpressions.length === 0) return undefined;
|
||||
|
||||
const rewrittenSelectItems = selectItems.map((item) => {
|
||||
const trimmed = String(item || '').trim();
|
||||
if (trimmed === '*') {
|
||||
return `${sourceAlias}.*`;
|
||||
}
|
||||
return item.trimEnd();
|
||||
});
|
||||
|
||||
const aliasClause = parsedAlias.alias ? ` ${parsedAlias.alias}` : ` ${sourceAlias}`;
|
||||
const finalSelectItems = [...rewrittenSelectItems, ...qualifiedExpressions];
|
||||
return `${prefix}${finalSelectItems.join(', ')}${fromKeyword}${tableText}${aliasClause}${parsedAlias.remainder}`;
|
||||
};
|
||||
|
||||
const findWritableResultColumnForSource = (writableColumns: Record<string, string>, target: string): string | undefined => {
|
||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||
return Object.entries(writableColumns || {}).find(([, sourceColumn]) => (
|
||||
@@ -361,8 +456,8 @@ const buildQueryLocatorColumnExpression = (dbType: string, column: string, alias
|
||||
`${quoteIdentPart(dbType, column)} AS ${quoteIdentPart(dbType, alias)}`
|
||||
);
|
||||
|
||||
const buildQueryRowIDExpression = (dbType: string): string => (
|
||||
`ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}`
|
||||
const buildQueryRowIDExpression = (dbType: string, sourceAlias?: string): string => (
|
||||
`${sourceAlias ? `${sourceAlias}.` : ''}ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}`
|
||||
);
|
||||
|
||||
const resolveQueryLocatorPlan = async ({
|
||||
@@ -428,6 +523,7 @@ const resolveQueryLocatorPlan = async ({
|
||||
});
|
||||
const appendExpressions: string[] = [];
|
||||
const hiddenColumns: string[] = [];
|
||||
let needsOracleRowIDExpression = false;
|
||||
|
||||
const buildColumnLocator = (strategy: 'primary-key' | 'unique-key', locatorColumns: string[]): EditRowLocator => {
|
||||
const valueColumns = locatorColumns.map((column, index) => {
|
||||
@@ -457,7 +553,7 @@ const resolveQueryLocatorPlan = async ({
|
||||
if (uniqueKeyGroup) {
|
||||
plan.editLocator = buildColumnLocator('unique-key', uniqueKeyGroup);
|
||||
} else if (isOracleLikeDialect(dbType)) {
|
||||
appendExpressions.push(buildQueryRowIDExpression(dbType));
|
||||
needsOracleRowIDExpression = true;
|
||||
plan.editLocator = {
|
||||
strategy: 'oracle-rowid',
|
||||
columns: ['ROWID'],
|
||||
@@ -475,7 +571,25 @@ const resolveQueryLocatorPlan = async ({
|
||||
}
|
||||
}
|
||||
|
||||
plan.executedSql = appendQuerySelectExpressions(statement, appendExpressions);
|
||||
const executableAppendExpressions = [
|
||||
...(needsOracleRowIDExpression ? [buildQueryRowIDExpression(dbType)] : []),
|
||||
...appendExpressions,
|
||||
];
|
||||
|
||||
if (executableAppendExpressions.length > 0 && isOracleLikeDialect(dbType) && selectInfo.selectsBareAll) {
|
||||
const rewritten = rewriteOracleSelectAllWithExpressions(statement, executableAppendExpressions);
|
||||
if (rewritten) {
|
||||
plan.executedSql = rewritten;
|
||||
return plan;
|
||||
}
|
||||
|
||||
const reason = 'Oracle 查询使用 * 时无法自动注入 ROWID 定位列,已保持只读。';
|
||||
plan.editLocator = buildQueryReadOnlyLocator(reason);
|
||||
plan.warning = `查询结果保持只读:${reason}`;
|
||||
return plan;
|
||||
}
|
||||
|
||||
plan.executedSql = appendQuerySelectExpressions(statement, executableAppendExpressions);
|
||||
return plan;
|
||||
} catch {
|
||||
const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`;
|
||||
@@ -522,6 +636,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const [editorHeight, setEditorHeight] = useState(300);
|
||||
const editorRef = useRef<any>(null);
|
||||
const monacoRef = useRef<any>(null);
|
||||
const runQueryActionRef = useRef<any>(null);
|
||||
const lastExternalQueryRef = useRef<string>(tab.query || '');
|
||||
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
||||
const queryEditorRootRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -809,10 +924,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
});
|
||||
});
|
||||
|
||||
// 全局只注册一次 SQL completion provider,避免多 tab 重复注册导致补全项重复
|
||||
// Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding
|
||||
const runBinding = shortcutOptions.runQuery;
|
||||
if (runBinding?.enabled && runBinding.combo) {
|
||||
const keyBinding = comboToMonacoKeyBinding(
|
||||
runBinding.combo, monaco.KeyMod, monaco.KeyCode
|
||||
);
|
||||
if (keyBinding) {
|
||||
runQueryActionRef.current = editor.addAction({
|
||||
id: 'gonavi.runQuery',
|
||||
label: 'GoNavi: 执行 SQL',
|
||||
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
|
||||
run: () => {
|
||||
window.dispatchEvent(new CustomEvent('gonavi:run-active-query'));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// HMR 重载时释放旧注册避免补全项重复
|
||||
if (!sqlCompletionRegistered) {
|
||||
sqlCompletionRegistered = true;
|
||||
monaco.languages.registerCompletionItemProvider('sql', {
|
||||
_g.__gonaviSqlCompletionState.registered = true;
|
||||
sqlCompletionDisposables.forEach((d: any) => d?.dispose?.());
|
||||
sqlCompletionDisposables.length = 0;
|
||||
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: async (model: any, position: any) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
@@ -826,6 +962,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const activeDialect = resolveSqlDialect(
|
||||
String(activeConnection?.config?.type || ''),
|
||||
String(activeConnection?.config?.driver || ''),
|
||||
{ oceanBaseProtocol: activeConnection?.config?.oceanBaseProtocol },
|
||||
);
|
||||
const dialectKeywords = resolveSqlKeywords(activeDialect);
|
||||
const dialectFunctions = resolveSqlFunctions(activeDialect);
|
||||
@@ -1218,7 +1355,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
];
|
||||
return { suggestions };
|
||||
}
|
||||
});
|
||||
}));
|
||||
// 注册 / 斜杠命令 AI 快捷补全
|
||||
const slashCmdDefs = [
|
||||
{ cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' },
|
||||
@@ -1233,7 +1370,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
// 全局变量存储命令定义,供 onDidChangeModelContent 使用
|
||||
(window as any).__gonaviSlashCmdDefs = slashCmdDefs;
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('sql', {
|
||||
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
|
||||
triggerCharacters: ['/'],
|
||||
provideCompletionItems: (model: any, position: any) => {
|
||||
const lineContent = model.getLineContent(position.lineNumber);
|
||||
@@ -1260,6 +1397,42 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
})),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
// SQL snippet completion provider
|
||||
monaco.languages.registerCompletionItemProvider('sql', {
|
||||
provideCompletionItems: (model: any, position: any) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const prefix = word.word.toLowerCase();
|
||||
if (!prefix) return { suggestions: [] };
|
||||
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const allSnippets = useStore.getState().sqlSnippets;
|
||||
const matched = allSnippets.filter(s =>
|
||||
s.prefix.toLowerCase().startsWith(prefix) ||
|
||||
s.name.toLowerCase().includes(prefix)
|
||||
);
|
||||
|
||||
return {
|
||||
suggestions: matched.map(s => ({
|
||||
label: s.prefix,
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText: s.body,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
detail: s.name,
|
||||
documentation: s.description || s.body,
|
||||
range,
|
||||
sortText: '04' + s.prefix,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
} // end sqlCompletionRegistered guard
|
||||
@@ -1352,6 +1525,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
onClick: () => setSqlFormatOptions({ keywordCase: 'lower' })
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'snippet-settings',
|
||||
label: '代码片段管理...',
|
||||
onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-snippet-settings')),
|
||||
},
|
||||
{
|
||||
key: 'shortcut-settings',
|
||||
label: '快捷键管理...',
|
||||
@@ -1612,7 +1790,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const rpcConfig = buildRpcConnectionConfig(config) as any;
|
||||
const dbType = String(rpcConfig.type || 'mysql');
|
||||
const driver = String((config as any).driver || '');
|
||||
const normalizedDbType = String(resolveSqlDialect(dbType, driver)).trim().toLowerCase();
|
||||
const normalizedDbType = String(resolveSqlDialect(dbType, driver, {
|
||||
oceanBaseProtocol: (config as any).oceanBaseProtocol,
|
||||
})).trim().toLowerCase();
|
||||
const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';');
|
||||
|
||||
// MongoDB 仍走逐条执行的旧路径
|
||||
@@ -2006,12 +2186,46 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
void handleRun();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleRunShortcut);
|
||||
window.addEventListener('keydown', handleRunShortcut, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleRunShortcut);
|
||||
window.removeEventListener('keydown', handleRunShortcut, true);
|
||||
};
|
||||
}, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]);
|
||||
|
||||
// Re-register Monaco internal keybinding when runQuery shortcut changes
|
||||
useEffect(() => {
|
||||
if (runQueryActionRef.current) {
|
||||
runQueryActionRef.current.dispose();
|
||||
runQueryActionRef.current = null;
|
||||
}
|
||||
|
||||
const editor = editorRef.current;
|
||||
const monaco = monacoRef.current;
|
||||
if (!editor || !monaco) return;
|
||||
|
||||
const binding = shortcutOptions.runQuery;
|
||||
if (!binding?.enabled || !binding.combo) return;
|
||||
|
||||
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
|
||||
if (keyBinding) {
|
||||
runQueryActionRef.current = editor.addAction({
|
||||
id: 'gonavi.runQuery',
|
||||
label: 'GoNavi: 执行 SQL',
|
||||
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
|
||||
run: () => {
|
||||
window.dispatchEvent(new CustomEvent('gonavi:run-active-query'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (runQueryActionRef.current) {
|
||||
runQueryActionRef.current.dispose();
|
||||
runQueryActionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [shortcutOptions.runQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRunActiveQuery = () => {
|
||||
if (activeTabId !== tab.id) {
|
||||
|
||||
92
frontend/src/components/Sidebar.locate-toolbar.test.tsx
Normal file
92
frontend/src/components/Sidebar.locate-toolbar.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
noop: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
connections: [],
|
||||
savedQueries: [],
|
||||
externalSQLDirectories: [],
|
||||
deleteQuery: mocks.noop,
|
||||
saveExternalSQLDirectory: mocks.noop,
|
||||
deleteExternalSQLDirectory: mocks.noop,
|
||||
addConnection: mocks.noop,
|
||||
addTab: mocks.noop,
|
||||
tabs: [{
|
||||
id: 'conn-1-main-users',
|
||||
title: 'users',
|
||||
type: 'table',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'users',
|
||||
}],
|
||||
activeTabId: 'conn-1-main-users',
|
||||
setActiveContext: mocks.noop,
|
||||
removeConnection: mocks.noop,
|
||||
connectionTags: [],
|
||||
addConnectionTag: mocks.noop,
|
||||
updateConnectionTag: mocks.noop,
|
||||
removeConnectionTag: mocks.noop,
|
||||
moveConnectionToTag: mocks.noop,
|
||||
reorderTags: mocks.noop,
|
||||
closeTabsByConnection: mocks.noop,
|
||||
closeTabsByDatabase: mocks.noop,
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
},
|
||||
tableAccessCount: {},
|
||||
tableSortPreference: {},
|
||||
recordTableAccess: mocks.noop,
|
||||
setTableSortPreference: mocks.noop,
|
||||
addSqlLog: mocks.noop,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
DBGetDatabases: mocks.noop,
|
||||
DBGetTables: mocks.noop,
|
||||
DBQuery: mocks.noop,
|
||||
DBShowCreateTable: mocks.noop,
|
||||
ExportTable: mocks.noop,
|
||||
OpenSQLFile: mocks.noop,
|
||||
ExecuteSQLFile: mocks.noop,
|
||||
CancelSQLFileExecution: mocks.noop,
|
||||
CreateDatabase: mocks.noop,
|
||||
RenameDatabase: mocks.noop,
|
||||
DropDatabase: mocks.noop,
|
||||
RenameTable: mocks.noop,
|
||||
DropTable: mocks.noop,
|
||||
DropView: mocks.noop,
|
||||
DropFunction: mocks.noop,
|
||||
RenameView: mocks.noop,
|
||||
SelectSQLDirectory: mocks.noop,
|
||||
ListSQLDirectory: mocks.noop,
|
||||
ReadSQLFile: mocks.noop,
|
||||
JVMProbeCapabilities: mocks.noop,
|
||||
GetDriverStatusList: mocks.noop,
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/runtime/runtime', () => ({
|
||||
EventsOn: mocks.noop,
|
||||
}));
|
||||
|
||||
describe('Sidebar locate toolbar', () => {
|
||||
it('renders the current table locate action in the sidebar toolbar', () => {
|
||||
const markup = renderToStaticMarkup(<Sidebar />);
|
||||
const externalSqlActionIndex = markup.indexOf('data-sidebar-open-external-sql-file-action="true"');
|
||||
const locateActionIndex = markup.indexOf('data-sidebar-locate-current-tab-action="true"');
|
||||
|
||||
expect(markup).toContain('data-sidebar-locate-current-tab-action="true"');
|
||||
expect(markup).toContain('aria-label="定位当前打开表"');
|
||||
expect(locateActionIndex).toBeGreaterThan(externalSqlActionIndex);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
||||
CheckOutlined,
|
||||
FilterOutlined,
|
||||
DashboardOutlined,
|
||||
WarningOutlined
|
||||
WarningOutlined,
|
||||
AimOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
@@ -48,6 +49,14 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
|
||||
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
|
||||
import {
|
||||
findSidebarNodePathByKey,
|
||||
findSidebarNodePathForLocate,
|
||||
normalizeSidebarLocateObjectRequest,
|
||||
normalizeSidebarLocateObjectRequestFromTab,
|
||||
resolveSidebarLocateTarget,
|
||||
type SidebarLocateTreeNodeLike,
|
||||
} from '../utils/sidebarLocate';
|
||||
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
|
||||
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
|
||||
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
|
||||
@@ -91,12 +100,31 @@ type DriverStatusSnapshot = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => {
|
||||
if (!conn) return '';
|
||||
return JSON.stringify({
|
||||
config: conn.config || {},
|
||||
includeDatabases: conn.includeDatabases || [],
|
||||
includeRedisDatabases: conn.includeRedisDatabases || [],
|
||||
});
|
||||
};
|
||||
|
||||
const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => {
|
||||
const text = String(key);
|
||||
return text === connectionId || text.startsWith(`${connectionId}-`);
|
||||
};
|
||||
|
||||
const DRIVER_STATUS_CACHE_TTL_MS = 30_000;
|
||||
|
||||
const normalizeDriverType = (value: string): string => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'postgresql') return 'postgres';
|
||||
if (normalized === 'doris') return 'diros';
|
||||
if (
|
||||
normalized === 'open_gauss' ||
|
||||
normalized === 'open-gauss' ||
|
||||
normalized === 'opengauss'
|
||||
) return 'opengauss';
|
||||
return normalized;
|
||||
};
|
||||
|
||||
@@ -156,6 +184,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
|
||||
const addConnection = useStore(state => state.addConnection);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
const removeConnection = useStore(state => state.removeConnection);
|
||||
const connectionTags = useStore(state => state.connectionTags);
|
||||
@@ -179,6 +209,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
const activeTab = useMemo(() => tabs.find(tab => tab.id === activeTabId) || null, [tabs, activeTabId]);
|
||||
const activeTabLocateRequest = useMemo(() => normalizeSidebarLocateObjectRequestFromTab(activeTab), [activeTab]);
|
||||
const canLocateActiveTab = !!activeTabLocateRequest;
|
||||
|
||||
// Background Helper (Duplicate logic for now, ideally shared)
|
||||
const getBg = (darkHex: string) => {
|
||||
@@ -238,16 +271,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
||||
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const selectedNodesRef = useRef<any[]>([]);
|
||||
const loadingNodesRef = useRef<Set<string>>(new Set());
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
|
||||
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
|
||||
const selectedNodesRef = useRef<any[]>([]);
|
||||
const loadingNodesRef = useRef<Set<string>>(new Set());
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
|
||||
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
|
||||
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
|
||||
|
||||
// Virtual Scroll State
|
||||
const [treeHeight, setTreeHeight] = useState(500);
|
||||
const treeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const treeRef = useRef<any>(null);
|
||||
const treeDataRef = useRef<TreeNode[]>([]);
|
||||
useEffect(() => {
|
||||
treeDataRef.current = treeData;
|
||||
}, [treeData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!treeContainerRef.current) return;
|
||||
@@ -370,6 +409,47 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}, [autoFetchVisible, externalSQLDirectories, savedQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousSignatures = connectionReloadSignaturesRef.current;
|
||||
const nextSignatures: Record<string, string> = {};
|
||||
const staleConnectionIds = new Set<string>();
|
||||
|
||||
connections.forEach((conn) => {
|
||||
const signature = buildConnectionReloadSignature(conn);
|
||||
nextSignatures[conn.id] = signature;
|
||||
if (previousSignatures[conn.id] && previousSignatures[conn.id] !== signature) {
|
||||
staleConnectionIds.add(conn.id);
|
||||
}
|
||||
});
|
||||
connectionReloadSignaturesRef.current = nextSignatures;
|
||||
|
||||
if (staleConnectionIds.size > 0) {
|
||||
const staleIds = Array.from(staleConnectionIds);
|
||||
setLoadedKeys((prev) =>
|
||||
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
|
||||
);
|
||||
setExpandedKeys((prev) =>
|
||||
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
|
||||
);
|
||||
setConnectionStates((prev) => {
|
||||
const next = { ...prev };
|
||||
staleIds.forEach((id) => {
|
||||
Object.keys(next).forEach((key) => {
|
||||
if (isConnectionTreeKey(key, id)) {
|
||||
delete next[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
return next;
|
||||
});
|
||||
staleIds.forEach((id) => {
|
||||
Array.from(loadingNodesRef.current).forEach((key) => {
|
||||
if (key === `dbs-${id}` || key.startsWith(`tables-${id}-`)) {
|
||||
loadingNodesRef.current.delete(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setTreeData((prev) => {
|
||||
const prevMap = new Map<string, TreeNode>();
|
||||
|
||||
@@ -390,6 +470,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const existing = prevMap.get(conn.id);
|
||||
const iconType = resolveConnectionIconType(conn);
|
||||
const iconColor = resolveConnectionAccentColor(conn);
|
||||
const preserveChildren = existing && !staleConnectionIds.has(conn.id);
|
||||
return {
|
||||
title: conn.name,
|
||||
key: conn.id,
|
||||
@@ -397,7 +478,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
type: 'connection',
|
||||
dataRef: conn,
|
||||
isLeaf: false,
|
||||
children: existing?.children,
|
||||
children: preserveChildren ? existing.children : undefined,
|
||||
} as TreeNode;
|
||||
};
|
||||
|
||||
@@ -473,6 +554,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return null;
|
||||
};
|
||||
|
||||
const replaceTreeNodeChildren = (key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
|
||||
const nextTreeData = updateTreeData(treeDataRef.current, key, children);
|
||||
treeDataRef.current = nextTreeData;
|
||||
setTreeData(nextTreeData);
|
||||
return nextTreeData;
|
||||
};
|
||||
|
||||
const mergeExpandedTreeKeys = (requiredKeys: React.Key[]) => {
|
||||
setExpandedKeys(prev => {
|
||||
const merged = [...prev];
|
||||
requiredKeys.forEach(key => {
|
||||
if (!merged.includes(key)) merged.push(key);
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
setAutoExpandParent(true);
|
||||
};
|
||||
|
||||
const scrollSidebarTreeToKey = (key: React.Key) => {
|
||||
const runAfterFrame = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
|
||||
? window.requestAnimationFrame.bind(window)
|
||||
: (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0);
|
||||
|
||||
runAfterFrame(() => {
|
||||
treeRef.current?.scrollTo?.({ key, align: 'auto' });
|
||||
runAfterFrame(() => {
|
||||
const selectedNode = treeContainerRef.current?.querySelector('.ant-tree-treenode-selected') as HTMLElement | null;
|
||||
selectedNode?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const decorateExternalSQLTreeNode = (node: ExternalSQLTreeNode): TreeNode => {
|
||||
const icon = (() => {
|
||||
switch (node.type) {
|
||||
@@ -525,6 +638,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'opengauss',
|
||||
'open_gauss',
|
||||
'open-gauss',
|
||||
'sqlserver',
|
||||
'oracle',
|
||||
'dameng',
|
||||
@@ -535,6 +651,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'opengauss',
|
||||
'open_gauss',
|
||||
'open-gauss',
|
||||
'sqlserver',
|
||||
'oracle',
|
||||
'dm',
|
||||
@@ -563,9 +682,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
if (type === 'custom') {
|
||||
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
|
||||
if (driver === 'diros' || driver === 'doris') return 'mysql';
|
||||
if (driver === 'oceanbase') return 'mysql';
|
||||
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
|
||||
return driver;
|
||||
}
|
||||
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
|
||||
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'dameng') return 'dm';
|
||||
return type;
|
||||
};
|
||||
@@ -730,7 +852,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
return [{ sql: `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg_%' ORDER BY schemaname, viewname` }];
|
||||
case 'opengauss':
|
||||
return [{ sql: `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY schemaname, viewname` }];
|
||||
case 'sqlserver': {
|
||||
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
|
||||
return [{ sql: `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name` }];
|
||||
@@ -774,7 +897,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
return [{ sql: `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg_%' ORDER BY event_object_schema, event_object_table, trigger_name` }];
|
||||
case 'opengauss':
|
||||
return [{ sql: `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY event_object_schema, event_object_table, trigger_name` }];
|
||||
case 'sqlserver': {
|
||||
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
|
||||
return [{ sql: `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name` }];
|
||||
@@ -821,18 +945,19 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
case 'opengauss':
|
||||
return normalizeMetadataQuerySpecs([
|
||||
{
|
||||
// PostgreSQL 11+ / 部分 PG-like:通过 prokind 区分 FUNCTION/PROCEDURE
|
||||
sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%' ORDER BY n.nspname, routine_type, p.proname`,
|
||||
sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, routine_type, p.proname`,
|
||||
},
|
||||
{
|
||||
// PostgreSQL 10 / 不支持 prokind 的兼容路径
|
||||
sql: `SELECT r.routine_schema AS schema_name, r.routine_name AS routine_name, COALESCE(NULLIF(UPPER(r.routine_type), ''), 'FUNCTION') AS routine_type FROM information_schema.routines r WHERE r.routine_schema NOT IN ('pg_catalog', 'information_schema') AND r.routine_schema NOT LIKE 'pg_%' ORDER BY r.routine_schema, routine_type, r.routine_name`,
|
||||
sql: `SELECT r.routine_schema AS schema_name, r.routine_name AS routine_name, COALESCE(NULLIF(UPPER(r.routine_type), ''), 'FUNCTION') AS routine_type FROM information_schema.routines r WHERE r.routine_schema NOT IN ('pg_catalog', 'information_schema') AND r.routine_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY r.routine_schema, routine_type, r.routine_name`,
|
||||
},
|
||||
{
|
||||
// 最后兜底:仅函数列表,确保 prokind/routines 视图异常时仍可展示
|
||||
sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, 'FUNCTION' AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%' ORDER BY n.nspname, p.proname`,
|
||||
sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, 'FUNCTION' AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, p.proname`,
|
||||
},
|
||||
]);
|
||||
case 'sqlserver': {
|
||||
@@ -1095,12 +1220,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
isLeaf: true,
|
||||
}));
|
||||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||||
setTreeData(origin => updateTreeData(origin, node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]));
|
||||
replaceTreeNodeChildren(node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]);
|
||||
} else {
|
||||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
if (diagnosticNode.length > 0) {
|
||||
setTreeData(origin => updateTreeData(origin, node.key, diagnosticNode));
|
||||
replaceTreeNodeChildren(node.key, diagnosticNode);
|
||||
message.warning({ content: `JVM Provider 探测失败:${res.message || '未知错误'};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
|
||||
} else {
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
@@ -1111,7 +1236,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
if (diagnosticNode.length > 0) {
|
||||
setTreeData(origin => updateTreeData(origin, node.key, diagnosticNode));
|
||||
replaceTreeNodeChildren(node.key, diagnosticNode);
|
||||
message.warning({ content: `JVM Provider 探测异常:${e?.message || String(e)};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
|
||||
} else {
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
@@ -1143,7 +1268,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) {
|
||||
dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex));
|
||||
}
|
||||
setTreeData(origin => updateTreeData(origin, node.key, dbs));
|
||||
replaceTreeNodeChildren(node.key, dbs);
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
|
||||
@@ -1177,7 +1302,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
|
||||
if (dbs.length > 0) {
|
||||
setTreeData(origin => updateTreeData(origin, node.key, dbs));
|
||||
replaceTreeNodeChildren(node.key, dbs);
|
||||
} else {
|
||||
// 空列表:清理 loadedKeys 以允许重新加载,不设置 children = []
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
@@ -1231,7 +1356,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
},
|
||||
isLeaf: item.hasChildren !== true,
|
||||
}));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, resourceNodes));
|
||||
replaceTreeNodeChildren(node.key, resourceNodes);
|
||||
} else {
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
message.error({ content: res.message, key: `jvm-resource-${node.key}` });
|
||||
@@ -1542,7 +1667,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
});
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...schemaNodes]));
|
||||
replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...schemaNodes]);
|
||||
} else {
|
||||
const groupedNodes: TreeNode[] = [
|
||||
buildObjectGroup(key as string, 'tables', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
|
||||
@@ -1551,7 +1676,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
|
||||
];
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...groupedNodes]));
|
||||
replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...groupedNodes]);
|
||||
}
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||||
@@ -1565,6 +1690,102 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
};
|
||||
|
||||
const locateObjectInSidebarRef = useRef<(detail: unknown) => Promise<void>>(async () => {});
|
||||
|
||||
const waitForSidebarLoadKey = async (loadKey: string) => {
|
||||
for (let attempt = 0; attempt < 30 && loadingNodesRef.current.has(loadKey); attempt += 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 50));
|
||||
}
|
||||
};
|
||||
|
||||
const locateObjectInSidebar = async (detail: unknown) => {
|
||||
const request = normalizeSidebarLocateObjectRequest(detail);
|
||||
if (!request) {
|
||||
message.warning('当前标签页没有可定位的表上下文');
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = connections.find(item => item.id === request.connectionId);
|
||||
if (!conn) {
|
||||
message.warning('未找到当前表对应的连接');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = resolveSidebarLocateTarget(request, {
|
||||
groupBySchema: shouldHideSchemaPrefix(conn),
|
||||
});
|
||||
const objectLabel = request.objectGroup === 'views' ? '视图' : '表';
|
||||
|
||||
let path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
|
||||
const dbLoadKey = `dbs-${request.connectionId}`;
|
||||
const tableLoadKey = `tables-${request.connectionId}-${request.dbName}`;
|
||||
|
||||
if (!path && !findSidebarNodePathByKey(treeDataRef.current as SidebarLocateTreeNodeLike[], target.databaseKey)) {
|
||||
const connectionNode = findTreeNodeByKey(treeDataRef.current, target.connectionKey);
|
||||
if (!connectionNode) {
|
||||
message.warning('未在左侧树找到当前连接');
|
||||
return;
|
||||
}
|
||||
if (loadingNodesRef.current.has(dbLoadKey)) {
|
||||
await waitForSidebarLoadKey(dbLoadKey);
|
||||
} else {
|
||||
await loadDatabases(connectionNode);
|
||||
}
|
||||
}
|
||||
|
||||
const dbNode = findTreeNodeByKey(treeDataRef.current, target.databaseKey);
|
||||
if (!dbNode) {
|
||||
message.warning(`未在左侧树找到数据库:${request.dbName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
|
||||
if (!path) {
|
||||
if (loadingNodesRef.current.has(tableLoadKey)) {
|
||||
await waitForSidebarLoadKey(tableLoadKey);
|
||||
} else {
|
||||
await loadTables(dbNode);
|
||||
}
|
||||
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
message.warning(`${objectLabel}未在左侧树中找到:${request.tableName},请刷新数据库节点后重试`);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetKey = path[path.length - 1];
|
||||
const targetNode = findTreeNodeByKey(treeDataRef.current, targetKey);
|
||||
setSearchValue('');
|
||||
mergeExpandedTreeKeys(path.slice(0, -1));
|
||||
setSelectedKeys([targetKey]);
|
||||
selectedNodesRef.current = targetNode ? [targetNode] : [];
|
||||
setActiveContext({ connectionId: request.connectionId, dbName: request.dbName });
|
||||
scrollSidebarTreeToKey(targetKey);
|
||||
};
|
||||
|
||||
const handleLocateActiveTabInSidebar = () => {
|
||||
if (!activeTabLocateRequest) {
|
||||
message.warning('当前标签页没有可定位的表上下文');
|
||||
return;
|
||||
}
|
||||
void locateObjectInSidebar(activeTabLocateRequest);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
locateObjectInSidebarRef.current = locateObjectInSidebar;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleLocateSidebarObject = (event: Event) => {
|
||||
void locateObjectInSidebarRef.current((event as CustomEvent).detail);
|
||||
};
|
||||
window.addEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onLoadData = async ({ key, children, dataRef, type }: any) => {
|
||||
if (type === 'tag') return;
|
||||
if (children) return;
|
||||
@@ -1614,7 +1835,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
];
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, folders));
|
||||
replaceTreeNodeChildren(key, folders);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2605,6 +2826,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
conn?.config?.database,
|
||||
overrideDatabase,
|
||||
clearDatabase,
|
||||
conn?.config?.oceanBaseProtocol,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -2921,7 +3143,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
case 'mysql':
|
||||
query = `SHOW CREATE VIEW \`${viewName.replace(/`/g, '``')}\``;
|
||||
break;
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': {
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': {
|
||||
const parts = viewName.split('.');
|
||||
const schema = parts.length > 1 ? parts[0] : 'public';
|
||||
const name = parts.length > 1 ? parts[1] : viewName;
|
||||
@@ -2977,7 +3199,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
case 'mysql':
|
||||
template = `CREATE VIEW \`view_name\` AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||||
break;
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase':
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss':
|
||||
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||||
break;
|
||||
case 'sqlserver':
|
||||
@@ -3088,7 +3310,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
case 'mysql':
|
||||
query = `SHOW CREATE ${routineType} \`${name.replace(/`/g, '``')}\``;
|
||||
break;
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': {
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': {
|
||||
const schemaRef = schema || 'public';
|
||||
query = `SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${escapeSQLLiteral(name)}' LIMIT 1`;
|
||||
break;
|
||||
@@ -3158,7 +3380,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
? `DELIMITER $$\nCREATE PROCEDURE proc_name(IN param1 INT)\nBEGIN\n SELECT * FROM table_name WHERE id = param1;\nEND$$\nDELIMITER ;`
|
||||
: `DELIMITER $$\nCREATE FUNCTION func_name(param1 INT)\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN param1 * 2;\nEND$$\nDELIMITER ;`;
|
||||
break;
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase':
|
||||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss':
|
||||
template = isProc
|
||||
? `CREATE OR REPLACE PROCEDURE proc_name(param1 integer)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- procedure body\nEND;\n$$;`
|
||||
: `CREATE OR REPLACE FUNCTION func_name(param1 integer)\nRETURNS integer\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RETURN param1 * 2;\nEND;\n$$;`;
|
||||
@@ -3633,7 +3855,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
replaceTreeNodeChildren(node.key, undefined);
|
||||
closeTabsByConnection(String(node.key));
|
||||
message.success("已断开连接");
|
||||
}
|
||||
@@ -3783,7 +4005,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
// Reset loaded state recursively
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
// Clear children (undefined to trigger reload)
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
replaceTreeNodeChildren(node.key, undefined);
|
||||
closeTabsByConnection(String(node.key));
|
||||
message.success("已断开连接");
|
||||
}
|
||||
@@ -3931,7 +4153,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
replaceTreeNodeChildren(node.key, undefined);
|
||||
if (dbConnId && dbName) {
|
||||
closeTabsByDatabase(dbConnId, dbName);
|
||||
}
|
||||
@@ -4180,13 +4402,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onOk: () => {
|
||||
deleteQuery(q.id);
|
||||
// 从树中移除节点
|
||||
setTreeData(origin => {
|
||||
const removeNode = (list: TreeNode[]): TreeNode[] =>
|
||||
list
|
||||
.filter(n => n.key !== node.key)
|
||||
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
|
||||
return removeNode(origin);
|
||||
});
|
||||
const removeNode = (list: TreeNode[]): TreeNode[] =>
|
||||
list
|
||||
.filter(n => n.key !== node.key)
|
||||
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
|
||||
const nextTreeData = removeNode(treeDataRef.current);
|
||||
treeDataRef.current = nextTreeData;
|
||||
setTreeData(nextTreeData);
|
||||
message.success('查询已删除');
|
||||
}
|
||||
});
|
||||
@@ -4477,13 +4699,35 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
<Button size="small" type="text" icon={<DatabaseOutlined />} onClick={() => openBatchDatabaseModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="运行外部SQL文件">
|
||||
<Button size="small" type="text" icon={<FileAddOutlined />} onClick={handleOpenSQLFileFromToolbar} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<FileAddOutlined />}
|
||||
data-sidebar-open-external-sql-file-action="true"
|
||||
onClick={handleOpenSQLFileFromToolbar}
|
||||
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={canLocateActiveTab ? '定位当前打开表' : '当前标签页没有可定位的表'}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<AimOutlined />}
|
||||
aria-label="定位当前打开表"
|
||||
data-sidebar-locate-current-tab-action="true"
|
||||
disabled={!canLocateActiveTab}
|
||||
onClick={handleLocateActiveTabInSidebar}
|
||||
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div ref={treeContainerRef} className="sidebar-tree-scroll-shell" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className="sidebar-tree-scroll-content">
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
showIcon
|
||||
draggable={{
|
||||
icon: false,
|
||||
|
||||
422
frontend/src/components/SnippetSettingsModal.tsx
Normal file
422
frontend/src/components/SnippetSettingsModal.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Modal, Button, Input, List, Tag, Popconfirm, message, Collapse, Typography } from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
UndoOutlined,
|
||||
SaveOutlined,
|
||||
CodeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { SqlSnippet } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { BUILTIN_SNIPPET_MAP } from '../utils/sqlSnippetDefaults';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
|
||||
interface SnippetSettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
}
|
||||
|
||||
type DraftSnippet = Omit<SqlSnippet, 'createdAt'> & { createdAt?: number };
|
||||
|
||||
const emptyDraft = (): DraftSnippet => ({
|
||||
id: uuidv4(),
|
||||
prefix: '',
|
||||
name: '',
|
||||
description: '',
|
||||
body: '',
|
||||
isBuiltin: false,
|
||||
});
|
||||
|
||||
export default function SnippetSettingsModal({
|
||||
open,
|
||||
onClose,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
}: SnippetSettingsModalProps) {
|
||||
const sqlSnippets = useStore((s) => s.sqlSnippets);
|
||||
const saveSqlSnippet = useStore((s) => s.saveSqlSnippet);
|
||||
const deleteSqlSnippet = useStore((s) => s.deleteSqlSnippet);
|
||||
const resetBuiltinSqlSnippet = useStore((s) => s.resetBuiltinSqlSnippet);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<DraftSnippet>(emptyDraft());
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const shellStyle = useMemo(
|
||||
() => ({
|
||||
background: overlayTheme.shellBg,
|
||||
border: overlayTheme.shellBorder,
|
||||
boxShadow: overlayTheme.shellShadow,
|
||||
backdropFilter: overlayTheme.shellBackdropFilter,
|
||||
}),
|
||||
[overlayTheme],
|
||||
);
|
||||
|
||||
const panelStyle = useMemo(
|
||||
() => ({
|
||||
padding: 16,
|
||||
borderRadius: 14,
|
||||
border: overlayTheme.sectionBorder,
|
||||
background: overlayTheme.sectionBg,
|
||||
}),
|
||||
[overlayTheme],
|
||||
);
|
||||
|
||||
const textColor = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(16,24,40,0.9)';
|
||||
const mutedColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)';
|
||||
const selectedBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
|
||||
|
||||
const sortedSnippets = useMemo(
|
||||
() => [...sqlSnippets].sort((a, b) => a.prefix.localeCompare(b.prefix)),
|
||||
[sqlSnippets],
|
||||
);
|
||||
|
||||
const selectedSnippet = useMemo(
|
||||
() => sqlSnippets.find((s) => s.id === selectedId) ?? null,
|
||||
[sqlSnippets, selectedId],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(snippet: SqlSnippet) => {
|
||||
setIsCreating(false);
|
||||
setSelectedId(snippet.id);
|
||||
setDraft({ ...snippet });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
setIsCreating(true);
|
||||
setSelectedId(null);
|
||||
setDraft(emptyDraft());
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const prefix = draft.prefix.toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20);
|
||||
if (!prefix) {
|
||||
void message.warning('前缀不能为空');
|
||||
return;
|
||||
}
|
||||
if (!draft.name.trim()) {
|
||||
void message.warning('名称不能为空');
|
||||
return;
|
||||
}
|
||||
if (!draft.body.trim()) {
|
||||
void message.warning('片段内容不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicate = sqlSnippets.find(
|
||||
(s) => s.prefix.toLowerCase() === prefix && s.id !== draft.id,
|
||||
);
|
||||
if (duplicate) {
|
||||
void message.warning(`前缀 "${prefix}" 已被其他片段使用`);
|
||||
return;
|
||||
}
|
||||
|
||||
const toSave: SqlSnippet = {
|
||||
id: draft.id,
|
||||
prefix,
|
||||
name: draft.name.trim(),
|
||||
description: draft.description?.trim() || undefined,
|
||||
body: draft.body,
|
||||
isBuiltin: draft.isBuiltin,
|
||||
createdAt: draft.createdAt ?? Date.now(),
|
||||
};
|
||||
|
||||
saveSqlSnippet(toSave);
|
||||
setSelectedId(toSave.id);
|
||||
setIsCreating(false);
|
||||
void message.success('片段已保存');
|
||||
}, [draft, sqlSnippets, saveSqlSnippet]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
deleteSqlSnippet(id);
|
||||
if (selectedId === id) {
|
||||
setSelectedId(null);
|
||||
setDraft(emptyDraft());
|
||||
}
|
||||
void message.success('片段已删除');
|
||||
},
|
||||
[deleteSqlSnippet, selectedId],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(
|
||||
(id: string) => {
|
||||
resetBuiltinSqlSnippet(id);
|
||||
const original = BUILTIN_SNIPPET_MAP[id];
|
||||
if (original && selectedId === id) {
|
||||
setDraft({ ...original });
|
||||
}
|
||||
void message.success('已重置为默认');
|
||||
},
|
||||
[resetBuiltinSqlSnippet, selectedId],
|
||||
);
|
||||
|
||||
const syntaxHelpItems = [
|
||||
{
|
||||
key: 'syntax',
|
||||
label: '片段语法说明',
|
||||
children: (
|
||||
<div style={{ fontSize: 12, lineHeight: 1.8, color: mutedColor, fontFamily: 'monospace' }}>
|
||||
<div>{'${1:占位符} 第一个 Tab 位,占位符为提示文字'}</div>
|
||||
<div>{'${2:默认值} 第二个 Tab 位,默认值可直接确认'}</div>
|
||||
<div>{'$0 最终光标位置'}</div>
|
||||
<div>{'${1:表名} 同一数字在多处出现时会同步编辑'}</div>
|
||||
<div style={{ marginTop: 6, fontWeight: 600, color: textColor }}>{'内置变量(展开时自动替换为实际值):'}</div>
|
||||
<div>{'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} 当前日期'}</div>
|
||||
<div>{'${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND} 当前时间'}</div>
|
||||
<div>{'${CURRENT_SECONDS_UNIX} Unix 时间戳'}</div>
|
||||
<div>{'${UUID} 随机 UUID'}</div>
|
||||
<div>{'${RANDOM} 6 位随机数'}</div>
|
||||
<div style={{ marginTop: 8, fontFamily: 'inherit', color: textColor }}>
|
||||
{'示例:SELECT ${1:列名} FROM ${2:表名} WHERE date >= \'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}\';$0'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const showEditor = isCreating || selectedSnippet;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<CodeOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: textColor }}>代码片段管理</div>
|
||||
<div style={{ fontSize: 12, color: mutedColor, lineHeight: 1.5 }}>
|
||||
管理 SQL 代码片段,输入前缀后按 Tab 展开
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={820}
|
||||
styles={{
|
||||
content: shellStyle,
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8 },
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 40 },
|
||||
}}
|
||||
footer={[
|
||||
<Button key="close" type="primary" onClick={onClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16, minHeight: 420 }}>
|
||||
{/* Left: snippet list */}
|
||||
<div
|
||||
style={{
|
||||
width: 220,
|
||||
flexShrink: 0,
|
||||
borderRadius: 14,
|
||||
border: overlayTheme.sectionBorder,
|
||||
background: overlayTheme.sectionBg,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px 12px 4px', fontSize: 12, color: mutedColor, fontWeight: 600 }}>
|
||||
片段列表
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={sortedSnippets}
|
||||
renderItem={(snippet) => (
|
||||
<List.Item
|
||||
onClick={() => handleSelect(snippet)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '6px 12px',
|
||||
background: selectedId === snippet.id ? selectedBg : 'transparent',
|
||||
borderLeft:
|
||||
selectedId === snippet.id
|
||||
? `3px solid ${overlayTheme.iconBg}`
|
||||
: '3px solid transparent',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden' }}>
|
||||
<Typography.Text
|
||||
code
|
||||
style={{ fontSize: 12, flexShrink: 0, color: textColor }}
|
||||
>
|
||||
{snippet.prefix}
|
||||
</Typography.Text>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: textColor,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{snippet.name}
|
||||
</span>
|
||||
{snippet.isBuiltin && (
|
||||
<Tag
|
||||
style={{
|
||||
fontSize: 10,
|
||||
lineHeight: '16px',
|
||||
padding: '0 4px',
|
||||
margin: 0,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
color="blue"
|
||||
>
|
||||
内置
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ padding: 8 }}>
|
||||
<Button type="dashed" icon={<PlusOutlined />} block size="small" onClick={handleNew}>
|
||||
新建片段
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: editor */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{showEditor ? (
|
||||
<div
|
||||
style={{
|
||||
...panelStyle,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 0.4 }}>
|
||||
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>前缀</div>
|
||||
<Input
|
||||
value={draft.prefix}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, prefix: e.target.value.toLowerCase() }))
|
||||
}
|
||||
placeholder="如 sel, ins"
|
||||
maxLength={20}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 0.6 }}>
|
||||
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>名称</div>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
placeholder="片段显示名称"
|
||||
maxLength={60}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>描述(可选)</div>
|
||||
<Input
|
||||
value={draft.description || ''}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
|
||||
placeholder="补全详情中的描述文字"
|
||||
maxLength={200}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>片段内容</div>
|
||||
<Input.TextArea
|
||||
value={draft.body}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
|
||||
placeholder={'SELECT ${1:columns} FROM ${2:table_name}$0;'}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 120,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
resize: 'none',
|
||||
}}
|
||||
/>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={syntaxHelpItems}
|
||||
style={{ marginTop: 8, background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
|
||||
{draft.isBuiltin && draft.createdAt && (
|
||||
<Popconfirm
|
||||
title="重置为默认"
|
||||
description="将恢复此内置片段的原始内容"
|
||||
onConfirm={() => handleReset(draft.id)}
|
||||
>
|
||||
<Button icon={<UndoOutlined />} size="small">
|
||||
重置为默认
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{!draft.isBuiltin && !isCreating && (
|
||||
<Popconfirm
|
||||
title="删除片段"
|
||||
description="确定要删除此片段吗?"
|
||||
onConfirm={() => handleDelete(draft.id)}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />} size="small">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Button type="primary" icon={<SaveOutlined />} size="small" onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
...panelStyle,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
height: '100%',
|
||||
color: mutedColor,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
选择左侧片段编辑,或点击「新建片段」
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -836,6 +836,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
if (normalized === 'postgresql' || normalized === 'pg') return 'postgres';
|
||||
if (normalized === 'mssql' || normalized === 'sql_server' || normalized === 'sql-server') return 'sqlserver';
|
||||
if (normalized === 'doris') return 'diros';
|
||||
if (normalized === 'open_gauss' || normalized === 'open-gauss') return 'opengauss';
|
||||
return normalized;
|
||||
};
|
||||
|
||||
@@ -861,7 +862,9 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
const rawType = String(conn?.config?.type || '').trim();
|
||||
if (!rawType) return '';
|
||||
return resolveSqlDialect(rawType, String(conn?.config?.driver || ''));
|
||||
return resolveSqlDialect(rawType, String(conn?.config?.driver || ''), {
|
||||
oceanBaseProtocol: conn?.config?.oceanBaseProtocol,
|
||||
});
|
||||
};
|
||||
|
||||
const generateTriggerTemplate = (): string => {
|
||||
@@ -871,6 +874,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
switch (dbType) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
case 'oceanbase':
|
||||
case 'diros':
|
||||
return `CREATE TRIGGER trigger_name
|
||||
BEFORE INSERT ON \`${tblName}\`
|
||||
@@ -882,6 +886,7 @@ END;`;
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
case 'opengauss':
|
||||
return `CREATE OR REPLACE FUNCTION trigger_function_name()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
@@ -931,12 +936,14 @@ END;`;
|
||||
switch (dbType) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
case 'oceanbase':
|
||||
case 'diros':
|
||||
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
case 'opengauss':
|
||||
return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${tblName}"`;
|
||||
case 'sqlserver':
|
||||
return `DROP TRIGGER IF EXISTS [${triggerName}]`;
|
||||
|
||||
@@ -52,14 +52,17 @@ const formatRows = (count: number): string => {
|
||||
return String(count);
|
||||
};
|
||||
|
||||
const getMetadataDialect = (connType: string, driver?: string): string => {
|
||||
const getMetadataDialect = (connType: string, driver?: string, oceanBaseProtocol?: string): string => {
|
||||
const type = (connType || '').trim().toLowerCase();
|
||||
if (type === 'custom') {
|
||||
const d = (driver || '').trim().toLowerCase();
|
||||
if (d === 'diros' || d === 'doris') return 'mysql';
|
||||
if (d === 'oceanbase') return 'mysql';
|
||||
if (d === 'opengauss' || d === 'open_gauss' || d === 'open-gauss') return 'opengauss';
|
||||
return d;
|
||||
}
|
||||
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'oceanbase' && String(oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
|
||||
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'dameng') return 'dm';
|
||||
return type;
|
||||
};
|
||||
@@ -85,7 +88,8 @@ ORDER BY table_name`;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'vastbase':
|
||||
case 'highgo': {
|
||||
case 'highgo':
|
||||
case 'opengauss': {
|
||||
const schema = schemaName || 'public';
|
||||
return `
|
||||
SELECT
|
||||
@@ -180,8 +184,8 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
|
||||
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
|
||||
const metadataDialect = useMemo(
|
||||
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver),
|
||||
[connection?.config?.driver, connection?.config?.type]
|
||||
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver, connection?.config?.oceanBaseProtocol),
|
||||
[connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type]
|
||||
);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
|
||||
@@ -29,9 +29,12 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
|
||||
if (type === 'custom') {
|
||||
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
|
||||
if (driver === 'diros' || driver === 'doris') return 'mysql';
|
||||
if (driver === 'oceanbase') return 'mysql';
|
||||
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
|
||||
return driver;
|
||||
}
|
||||
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
|
||||
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'dameng') return 'dm';
|
||||
return type;
|
||||
};
|
||||
@@ -62,6 +65,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
case 'opengauss':
|
||||
return [`SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition
|
||||
FROM pg_trigger t
|
||||
JOIN pg_class c ON t.tgrelid = c.oid
|
||||
@@ -179,7 +183,8 @@ LIMIT 1`];
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase': {
|
||||
case 'vastbase':
|
||||
case 'opengauss': {
|
||||
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
|
||||
}
|
||||
case 'sqlserver': {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const AUTO_FIT_DEFAULT_MIN_WIDTH = 80;
|
||||
const AUTO_FIT_DEFAULT_MAX_WIDTH = 720;
|
||||
const AUTO_FIT_DEFAULT_PADDING = 40;
|
||||
const AUTO_FIT_DEFAULT_PADDING = 20;
|
||||
const AUTO_FIT_DEFAULT_SAMPLE_LIMIT = 200;
|
||||
const AUTO_FIT_MAX_PREVIEW_CHARS = 120;
|
||||
|
||||
|
||||
55
frontend/src/components/dataGridClipboardExport.test.ts
Normal file
55
frontend/src/components/dataGridClipboardExport.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildClipboardCsv,
|
||||
buildClipboardJson,
|
||||
buildClipboardMarkdown,
|
||||
pickRowsForClipboard,
|
||||
} from './dataGridClipboardExport';
|
||||
|
||||
describe('dataGridClipboardExport', () => {
|
||||
it('copies aggregate query rows without treating aggregate columns as table fields', () => {
|
||||
const rows = pickRowsForClipboard({
|
||||
rows: [
|
||||
{ __gonavi_row_key__: 0, 'COUNT(*)': 12, 'sum(price)': 99.5 },
|
||||
],
|
||||
selectedRowKeys: [],
|
||||
columnNames: ['COUNT(*)', 'sum(price)'],
|
||||
rowKeyField: '__gonavi_row_key__',
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ 'COUNT(*)': 12, 'sum(price)': 99.5 }]);
|
||||
expect(buildClipboardCsv(rows, ['COUNT(*)', 'sum(price)'])).toBe('"COUNT(*)","sum(price)"\n"12","99.5"');
|
||||
expect(buildClipboardMarkdown(rows, ['COUNT(*)', 'sum(price)'])).toBe('| COUNT(*) | sum(price) |\n| --- | --- |\n| 12 | 99.5 |');
|
||||
expect(buildClipboardJson(rows)).toBe('[\n {\n "COUNT(*)": 12,\n "sum(price)": 99.5\n }\n]');
|
||||
});
|
||||
|
||||
it('copies only selected rows when row selection exists', () => {
|
||||
const rows = pickRowsForClipboard({
|
||||
rows: [
|
||||
{ __gonavi_row_key__: 'row-1', total: 1 },
|
||||
{ __gonavi_row_key__: 'row-2', total: 2 },
|
||||
],
|
||||
selectedRowKeys: ['row-2'],
|
||||
columnNames: ['total'],
|
||||
rowKeyField: '__gonavi_row_key__',
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ total: 2 }]);
|
||||
});
|
||||
|
||||
it('keeps copied row fields in the provided display column order', () => {
|
||||
const rows = pickRowsForClipboard({
|
||||
rows: [
|
||||
{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
|
||||
],
|
||||
selectedRowKeys: [],
|
||||
columnNames: ['name', 'id'],
|
||||
rowKeyField: '__gonavi_row_key__',
|
||||
});
|
||||
|
||||
expect(Object.keys(rows[0])).toEqual(['name', 'id']);
|
||||
expect(buildClipboardCsv(rows, ['name', 'id'])).toBe('"name","id"\n"alpha","1"');
|
||||
expect(buildClipboardJson(rows)).toBe('[\n {\n "name": "alpha",\n "id": 1\n }\n]');
|
||||
});
|
||||
});
|
||||
83
frontend/src/components/dataGridClipboardExport.ts
Normal file
83
frontend/src/components/dataGridClipboardExport.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
type RowKeyToString = (key: any) => string;
|
||||
|
||||
const defaultRowKeyToString: RowKeyToString = (key: unknown) => String(key);
|
||||
|
||||
const normalizeClipboardValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const escapeCsvCell = (value: unknown): string => {
|
||||
const text = normalizeClipboardValue(value).replace(/"/g, '""');
|
||||
return `"${text}"`;
|
||||
};
|
||||
|
||||
const escapeMarkdownCell = (value: unknown): string => (
|
||||
normalizeClipboardValue(value)
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/\r?\n/g, ' ')
|
||||
);
|
||||
|
||||
export const pickRowsForClipboard = ({
|
||||
rows,
|
||||
selectedRowKeys = [],
|
||||
columnNames,
|
||||
rowKeyField,
|
||||
rowKeyToString = defaultRowKeyToString,
|
||||
}: {
|
||||
rows: Array<Record<string, unknown>>;
|
||||
selectedRowKeys?: unknown[];
|
||||
columnNames: string[];
|
||||
rowKeyField: string;
|
||||
rowKeyToString?: RowKeyToString;
|
||||
}): Array<Record<string, unknown>> => {
|
||||
if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selected = new Set((selectedRowKeys || []).map(rowKeyToString));
|
||||
const sourceRows = selected.size > 0
|
||||
? rows.filter((row) => selected.has(rowKeyToString(row?.[rowKeyField])))
|
||||
: rows;
|
||||
|
||||
return sourceRows.map((row) => {
|
||||
const next: Record<string, unknown> = {};
|
||||
columnNames.forEach((columnName) => {
|
||||
if (!columnName || columnName === rowKeyField) return;
|
||||
next[columnName] = row?.[columnName];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
export const buildClipboardCsv = (rows: Array<Record<string, unknown>>, columnNames: string[]): string => {
|
||||
if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const header = columnNames.map(escapeCsvCell).join(',');
|
||||
const lines = rows.map((row) => columnNames.map((columnName) => escapeCsvCell(row?.[columnName])).join(','));
|
||||
return [header, ...lines].join('\n');
|
||||
};
|
||||
|
||||
export const buildClipboardMarkdown = (rows: Array<Record<string, unknown>>, columnNames: string[]): string => {
|
||||
if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const header = `| ${columnNames.map(escapeMarkdownCell).join(' | ')} |`;
|
||||
const separator = `| ${columnNames.map(() => '---').join(' | ')} |`;
|
||||
const lines = rows.map((row) => `| ${columnNames.map((columnName) => escapeMarkdownCell(row?.[columnName])).join(' | ')} |`);
|
||||
return [header, separator, ...lines].join('\n');
|
||||
};
|
||||
|
||||
export const buildClipboardJson = (rows: Array<Record<string, unknown>>): string => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return '';
|
||||
return JSON.stringify(rows, null, 2);
|
||||
};
|
||||
@@ -46,6 +46,38 @@ describe('buildCopyInsertSQL', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves fractional seconds for MySQL datetime precision columns', () => {
|
||||
const sql = buildCopyInsertSQL({
|
||||
dbType: 'mysql',
|
||||
tableName: 'events',
|
||||
orderedCols: ['created_at'],
|
||||
record: {
|
||||
created_at: '2026-05-10T09:12:33.456+08:00',
|
||||
},
|
||||
columnTypesByLowerName: {
|
||||
created_at: 'datetime(3)',
|
||||
},
|
||||
});
|
||||
|
||||
expect(sql).toBe(
|
||||
"INSERT INTO `events` (`created_at`) VALUES ('2026-05-10 09:12:33.456');",
|
||||
);
|
||||
});
|
||||
|
||||
it('uses ordered columns for copy-as-insert output', () => {
|
||||
const sql = buildCopyInsertSQL({
|
||||
dbType: 'mysql',
|
||||
tableName: 'users',
|
||||
orderedCols: ['name', 'id'],
|
||||
record: {
|
||||
id: 7,
|
||||
name: 'Ada',
|
||||
},
|
||||
});
|
||||
|
||||
expect(sql).toBe("INSERT INTO `users` (`name`, `id`) VALUES ('Ada', '7');");
|
||||
});
|
||||
|
||||
it('keeps RFC3339-looking text unchanged for non-temporal columns', () => {
|
||||
const sql = buildCopyInsertSQL({
|
||||
dbType: 'postgres',
|
||||
|
||||
@@ -51,9 +51,9 @@ const normalizeDateTimeString = (val: string): string => {
|
||||
}
|
||||
|
||||
const match = val.match(
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
);
|
||||
return match ? `${match[1]} ${match[2]}` : val;
|
||||
return match ? `${match[1]} ${match[2]}${match[3] || ''}` : val;
|
||||
};
|
||||
|
||||
const normalizeTimezoneAwareDateTimeString = (val: string): string => {
|
||||
@@ -66,13 +66,14 @@ const normalizeTimezoneAwareDateTimeString = (val: string): string => {
|
||||
}
|
||||
|
||||
const match = val.match(
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
);
|
||||
if (!match) {
|
||||
return val;
|
||||
}
|
||||
const suffix = match[3] || '';
|
||||
return `${match[1]} ${match[2]}${suffix}`;
|
||||
const fractional = match[3] || '';
|
||||
const suffix = match[4] || '';
|
||||
return `${match[1]} ${match[2]}${fractional}${suffix}`;
|
||||
};
|
||||
|
||||
const isTemporalColumnType = (columnType?: string): boolean => {
|
||||
@@ -165,22 +166,36 @@ const toNormalizedLiteralText = (value: any, columnType?: string): string => {
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const hasFractionalSeconds = (value: string): boolean => /\d{2}:\d{2}:\d{2}\.\d+/.test(value);
|
||||
|
||||
const stripFractionalSeconds = (value: string): string => (
|
||||
value.replace(/(\d{2}:\d{2}:\d{2})\.\d+/, '$1')
|
||||
);
|
||||
|
||||
const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => {
|
||||
if (!isTemporalColumnType(columnType)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = toNormalizedLiteralText(value, columnType);
|
||||
const escaped = escapeLiteral(normalized);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
const rawType = String(columnType || '').toLowerCase();
|
||||
const isTimestamp = rawType.includes('timestamp');
|
||||
const oracleValue = isTimestamp ? normalized : stripFractionalSeconds(normalized);
|
||||
const escaped = escapeLiteral(oracleValue);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(oracleValue)) {
|
||||
return `TO_DATE('${escaped}', 'YYYY-MM-DD')`;
|
||||
}
|
||||
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) {
|
||||
const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
|
||||
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`;
|
||||
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(oracleValue)) {
|
||||
const compactOffset = oracleValue.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
|
||||
const temporalFormat = hasFractionalSeconds(oracleValue)
|
||||
? 'YYYY-MM-DD HH24:MI:SS.FFTZH:TZM'
|
||||
: 'YYYY-MM-DD HH24:MI:SSTZH:TZM';
|
||||
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', '${temporalFormat}')`;
|
||||
}
|
||||
const rawType = String(columnType || '').toLowerCase();
|
||||
if (rawType.includes('timestamp')) {
|
||||
return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
|
||||
if (isTimestamp) {
|
||||
const temporalFormat = hasFractionalSeconds(oracleValue)
|
||||
? 'YYYY-MM-DD HH24:MI:SS.FF'
|
||||
: 'YYYY-MM-DD HH24:MI:SS';
|
||||
return `TO_TIMESTAMP('${escaped}', '${temporalFormat}')`;
|
||||
}
|
||||
return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
|
||||
};
|
||||
|
||||
37
frontend/src/components/dataGridOutput.test.ts
Normal file
37
frontend/src/components/dataGridOutput.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildDataGridSelectBaseSql,
|
||||
pickDataGridOutputRows,
|
||||
resolveDataGridOutputColumnNames,
|
||||
} from './dataGridOutput';
|
||||
|
||||
const rowKeyField = '__gonavi_row_key__';
|
||||
|
||||
describe('dataGridOutput helpers', () => {
|
||||
it('resolves exportable columns in display order without the internal row key', () => {
|
||||
expect(resolveDataGridOutputColumnNames(['name', rowKeyField, 'id'], rowKeyField)).toEqual(['name', 'id']);
|
||||
});
|
||||
|
||||
it('keeps exact column names when resolving output order', () => {
|
||||
expect(resolveDataGridOutputColumnNames([' full name ', 'id'], rowKeyField)).toEqual([' full name ', 'id']);
|
||||
});
|
||||
|
||||
it('picks row values in display column order', () => {
|
||||
const rows = pickDataGridOutputRows([
|
||||
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
|
||||
], ['name', 'id']);
|
||||
|
||||
expect(Object.keys(rows[0])).toEqual(['name', 'id']);
|
||||
expect(rows[0]).toEqual({ name: 'alpha', id: 1 });
|
||||
});
|
||||
|
||||
it('builds table SELECT SQL with explicit display columns', () => {
|
||||
expect(buildDataGridSelectBaseSql({
|
||||
dbType: 'mysql',
|
||||
tableName: 'users',
|
||||
columnNames: ['name', 'id'],
|
||||
whereSql: "WHERE `id` = '7'",
|
||||
})).toBe("SELECT `name`, `id` FROM `users` WHERE `id` = '7'");
|
||||
});
|
||||
});
|
||||
41
frontend/src/components/dataGridOutput.ts
Normal file
41
frontend/src/components/dataGridOutput.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||
|
||||
export const resolveDataGridOutputColumnNames = (
|
||||
displayColumnNames: string[],
|
||||
rowKeyField: string,
|
||||
): string[] => (
|
||||
(displayColumnNames || [])
|
||||
.map((columnName) => String(columnName ?? ''))
|
||||
.filter((columnName) => columnName && columnName !== rowKeyField)
|
||||
);
|
||||
|
||||
export const pickDataGridOutputRows = (
|
||||
rows: Array<Record<string, any>>,
|
||||
columnNames: string[],
|
||||
): Array<Record<string, any>> => (
|
||||
(rows || []).map((row) => {
|
||||
const next: Record<string, any> = {};
|
||||
(columnNames || []).forEach((columnName) => {
|
||||
next[columnName] = row?.[columnName];
|
||||
});
|
||||
return next;
|
||||
})
|
||||
);
|
||||
|
||||
export const buildDataGridSelectBaseSql = ({
|
||||
dbType,
|
||||
tableName,
|
||||
columnNames,
|
||||
whereSql = '',
|
||||
}: {
|
||||
dbType: string;
|
||||
tableName: string;
|
||||
columnNames: string[];
|
||||
whereSql?: string;
|
||||
}): string => {
|
||||
const selectList = columnNames.length > 0
|
||||
? columnNames.map((columnName) => quoteIdentPart(dbType, columnName)).join(', ')
|
||||
: '*';
|
||||
const wherePart = String(whereSql || '').trim();
|
||||
return `SELECT ${selectList} FROM ${quoteQualifiedIdent(dbType, tableName)}${wherePart ? ` ${wherePart}` : ''}`;
|
||||
};
|
||||
@@ -22,6 +22,20 @@ describe('dataGridRowClipboard', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies row fields in display column order', () => {
|
||||
const copiedRows = buildCopiedRowsForPaste({
|
||||
rows: [
|
||||
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
|
||||
],
|
||||
selectedRowKeys: ['row-1'],
|
||||
columnNames: ['name', 'id'],
|
||||
rowKeyField,
|
||||
});
|
||||
|
||||
expect(Object.keys(copiedRows[0])).toEqual(['name', 'id']);
|
||||
expect(copiedRows[0]).toEqual({ name: 'alpha', id: 1 });
|
||||
});
|
||||
|
||||
it('builds pasted rows as new rows with fresh internal keys', () => {
|
||||
const pastedRows = buildPastedRowsFromCopiedRows({
|
||||
rows: [
|
||||
|
||||
@@ -5,7 +5,9 @@ import { supportsTableTruncateAction } from './tableDataDangerActions';
|
||||
describe('tableDataDangerActions', () => {
|
||||
it('supports native truncate for known relational dialects', () => {
|
||||
expect(supportsTableTruncateAction('mysql')).toBe(true);
|
||||
expect(supportsTableTruncateAction('oceanbase')).toBe(true);
|
||||
expect(supportsTableTruncateAction('postgres')).toBe(true);
|
||||
expect(supportsTableTruncateAction('opengauss')).toBe(true);
|
||||
expect(supportsTableTruncateAction('custom', 'postgresql')).toBe(true);
|
||||
expect(supportsTableTruncateAction('custom', 'kingbase8')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ const resolveCustomDriverDialect = (driver: string): string => {
|
||||
case 'pq':
|
||||
case 'pgx':
|
||||
return 'postgres';
|
||||
case 'opengauss':
|
||||
case 'open_gauss':
|
||||
case 'open-gauss':
|
||||
return 'opengauss';
|
||||
case 'dm':
|
||||
case 'dameng':
|
||||
case 'dm8':
|
||||
@@ -21,6 +25,8 @@ const resolveCustomDriverDialect = (driver: string): string => {
|
||||
case 'diros':
|
||||
case 'doris':
|
||||
return 'diros';
|
||||
case 'oceanbase':
|
||||
return 'oceanbase';
|
||||
case 'kingbase':
|
||||
case 'kingbase8':
|
||||
case 'kingbasees':
|
||||
@@ -34,7 +40,9 @@ const resolveCustomDriverDialect = (driver: string): string => {
|
||||
break;
|
||||
}
|
||||
|
||||
if (normalized.includes('opengauss') || normalized.includes('open_gauss') || normalized.includes('open-gauss')) return 'opengauss';
|
||||
if (normalized.includes('postgres')) return 'postgres';
|
||||
if (normalized.includes('oceanbase')) return 'oceanbase';
|
||||
if (normalized.includes('kingbase')) return 'kingbase';
|
||||
if (normalized.includes('highgo')) return 'highgo';
|
||||
if (normalized.includes('vastbase')) return 'vastbase';
|
||||
@@ -56,10 +64,12 @@ export const supportsTableTruncateAction = (type: string, driver?: string): bool
|
||||
switch (resolveTableDataActionDBType(type, driver)) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
case 'oceanbase':
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
case 'opengauss':
|
||||
case 'sqlserver':
|
||||
case 'oracle':
|
||||
case 'dameng':
|
||||
|
||||
@@ -162,6 +162,40 @@ describe('tableDesignerSchemaSql', () => {
|
||||
expect(sql).not.toContain('MODIFY COLUMN');
|
||||
});
|
||||
|
||||
it('builds doris alter preview without mysql-only syntax or metadata extra', () => {
|
||||
const sql = buildAlterTablePreviewSql(buildInput({
|
||||
dbType: 'doris',
|
||||
tableName: 'sales.orders',
|
||||
originalColumns: [
|
||||
baseColumn({
|
||||
_key: 'carrier',
|
||||
name: 'carrier_id',
|
||||
type: 'bigint',
|
||||
nullable: 'YES',
|
||||
extra: 'NONE',
|
||||
comment: '承运商id',
|
||||
}),
|
||||
],
|
||||
columns: [
|
||||
baseColumn({
|
||||
_key: 'carrier',
|
||||
name: 'carrier_code',
|
||||
type: 'bigint',
|
||||
nullable: 'YES',
|
||||
extra: 'NONE',
|
||||
comment: '承运商id1',
|
||||
}),
|
||||
],
|
||||
}));
|
||||
|
||||
expect(sql).toContain('ALTER TABLE `sales`.`orders`\nRENAME COLUMN `carrier_id` `carrier_code`;');
|
||||
expect(sql).toContain("ALTER TABLE `sales`.`orders`\nMODIFY COLUMN `carrier_code` bigint NULL COMMENT '承运商id1';");
|
||||
expect(sql).not.toContain('CHANGE COLUMN');
|
||||
expect(sql).not.toContain('AFTER');
|
||||
expect(sql).not.toContain(' FIRST');
|
||||
expect(sql).not.toContain('NONE');
|
||||
});
|
||||
|
||||
it('uses native limited alter syntax for clickhouse and tdengine instead of mysql syntax', () => {
|
||||
const clickhouseSql = buildAlterTablePreviewSql(buildInput({
|
||||
dbType: 'clickhouse',
|
||||
@@ -184,8 +218,8 @@ describe('tableDesignerSchemaSql', () => {
|
||||
expect(tdengineSql).not.toContain('AFTER');
|
||||
});
|
||||
|
||||
it('treats mariadb doris and sphinx as mysql-family only where mysql syntax is intended', () => {
|
||||
for (const dbType of ['mariadb', 'diros', 'sphinx']) {
|
||||
it('treats mariadb and sphinx as mysql-family only where mysql syntax is intended', () => {
|
||||
for (const dbType of ['mariadb', 'sphinx']) {
|
||||
const sql = buildAlterTablePreviewSql(buildInput({ dbType }));
|
||||
expect(sql).toContain('ALTER TABLE `users`');
|
||||
expect(sql).toContain('ADD COLUMN `age` int NULL');
|
||||
|
||||
@@ -125,6 +125,37 @@ const buildMySqlColumnDefinition = (column: EditableColumnSnapshot, dbType: stri
|
||||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
const DORIS_AGG_TYPES = new Set([
|
||||
'SUM',
|
||||
'MIN',
|
||||
'MAX',
|
||||
'REPLACE',
|
||||
'REPLACE_IF_NOT_NULL',
|
||||
'HLL_UNION',
|
||||
'BITMAP_UNION',
|
||||
'QUANTILE_UNION',
|
||||
'GENERIC',
|
||||
]);
|
||||
|
||||
const buildDorisColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
|
||||
const defaultSql = buildDefaultSql(column.default, dbType);
|
||||
const autoIncrementSql = column.isAutoIncrement ? 'AUTO_INCREMENT' : '';
|
||||
const keyText = String(column.key || '').trim().toUpperCase();
|
||||
const extraText = String(column.extra || '').trim().toUpperCase();
|
||||
const keyOrAggSql = ['PRI', 'KEY', 'TRUE'].includes(keyText)
|
||||
? 'KEY'
|
||||
: (DORIS_AGG_TYPES.has(extraText) ? extraText : '');
|
||||
return [
|
||||
quoteIdentifierPart(column.name, dbType),
|
||||
String(column.type || '').trim(),
|
||||
keyOrAggSql,
|
||||
column.nullable === 'NO' ? 'NOT NULL' : 'NULL',
|
||||
defaultSql,
|
||||
autoIncrementSql,
|
||||
`COMMENT '${escapeSqlString(column.comment || '')}'`,
|
||||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
const buildStandardColumnDefinition = (
|
||||
column: EditableColumnSnapshot,
|
||||
dbType: string,
|
||||
@@ -226,6 +257,44 @@ const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: s
|
||||
return alters.length === 0 ? '' : `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
|
||||
};
|
||||
|
||||
const buildDorisAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
|
||||
const tableName = quoteIdentifierPath(input.tableName, dbType);
|
||||
const statements: string[] = [];
|
||||
|
||||
input.originalColumns.forEach((orig) => {
|
||||
if (!input.columns.find((col) => col._key === orig._key)) {
|
||||
statements.push(`ALTER TABLE ${tableName}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
|
||||
}
|
||||
});
|
||||
|
||||
input.columns.forEach((curr) => {
|
||||
const orig = input.originalColumns.find((col) => col._key === curr._key);
|
||||
if (!orig) {
|
||||
statements.push(`ALTER TABLE ${tableName}\nADD COLUMN ${buildDorisColumnDefinition(curr, dbType)};`);
|
||||
return;
|
||||
}
|
||||
|
||||
let currentName = orig.name;
|
||||
if (curr.name !== orig.name) {
|
||||
statements.push(`ALTER TABLE ${tableName}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} ${quoteIdentifierPart(curr.name, dbType)};`);
|
||||
currentName = curr.name;
|
||||
}
|
||||
|
||||
if (definitionChanged(curr, orig)) {
|
||||
statements.push(`ALTER TABLE ${tableName}\nMODIFY COLUMN ${buildDorisColumnDefinition({ ...curr, name: currentName }, dbType)};`);
|
||||
}
|
||||
});
|
||||
|
||||
const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key);
|
||||
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
|
||||
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
|
||||
if (keysChanged) {
|
||||
statements.push('-- Doris 修改主键/Key 模型需要按表模型手工迁移,已避免生成 MySQL 专属的 DROP/ADD PRIMARY KEY。');
|
||||
}
|
||||
|
||||
return statements.join('\n');
|
||||
};
|
||||
|
||||
const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
|
||||
const tableParts = splitQualifiedName(input.tableName);
|
||||
const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName);
|
||||
@@ -537,6 +606,7 @@ export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): s
|
||||
if (isSqlServerDialect(dbType)) return buildSqlServerAlterPreviewSql({ ...input, dbType });
|
||||
if (dbType === 'sqlite') return buildSqliteAlterPreviewSql({ ...input, dbType });
|
||||
if (dbType === 'duckdb') return buildDuckDbAlterPreviewSql({ ...input, dbType });
|
||||
if (dbType === 'diros') return buildDorisAlterPreviewSql({ ...input, dbType }, dbType);
|
||||
if (dbType === 'clickhouse') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'ClickHouse');
|
||||
if (dbType === 'tdengine') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'TDengine');
|
||||
if (isMysqlFamilyDialect(dbType)) return buildMySqlAlterPreviewSql({ ...input, dbType }, dbType);
|
||||
|
||||
@@ -18,6 +18,9 @@ describe('sidebar tree horizontal scroll css', () => {
|
||||
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-node-content-wrapper\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*0;/s);
|
||||
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-node-content-wrapper\s*\{[^}]*max-content/s);
|
||||
|
||||
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-switcher\s*\{[^}]*width:\s*24px;[^}]*min-width:\s*24px;/s);
|
||||
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-iconEle\s*\{[^}]*width:\s*16px;[^}]*min-width:\s*16px;/s);
|
||||
|
||||
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-title\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*visible;/s);
|
||||
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-title\s*\{[^}]*max-content/s);
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('store appearance persistence', () => {
|
||||
expect(appearance.blur).toBe(6);
|
||||
expect(appearance.useNativeMacWindowControls).toBe(true);
|
||||
expect(appearance.showDataTableVerticalBorders).toBe(false);
|
||||
expect(appearance.dataTableColumnWidthMode).toBe('standard');
|
||||
expect(appearance.dataTableDensity).toBe('comfortable');
|
||||
});
|
||||
|
||||
it('persists DataGrid appearance settings and restores them after reload', async () => {
|
||||
@@ -77,19 +77,19 @@ describe('store appearance persistence', () => {
|
||||
|
||||
useStore.getState().setAppearance({
|
||||
showDataTableVerticalBorders: true,
|
||||
dataTableColumnWidthMode: 'compact',
|
||||
dataTableDensity: 'compact',
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.appearance.showDataTableVerticalBorders).toBe(true);
|
||||
expect(persisted.state.appearance.dataTableColumnWidthMode).toBe('compact');
|
||||
expect(persisted.state.appearance.dataTableDensity).toBe('compact');
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await importStore();
|
||||
const appearance = reloaded.useStore.getState().appearance;
|
||||
|
||||
expect(appearance.showDataTableVerticalBorders).toBe(true);
|
||||
expect(appearance.dataTableColumnWidthMode).toBe('compact');
|
||||
expect(appearance.dataTableDensity).toBe('compact');
|
||||
});
|
||||
|
||||
it('does not clear persisted legacy connections during hydration migration', async () => {
|
||||
@@ -210,6 +210,126 @@ describe('store appearance persistence', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes OceanBase protocol override when replacing saved connections', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().replaceConnections([
|
||||
{
|
||||
id: 'oceanbase-oracle',
|
||||
name: 'OceanBase Oracle',
|
||||
config: {
|
||||
id: 'oceanbase-oracle',
|
||||
type: 'oceanbase',
|
||||
host: 'ob.local',
|
||||
port: 2881,
|
||||
user: 'sys@oracle001',
|
||||
oceanBaseProtocol: 'oracle',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
|
||||
'oracle',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores OceanBase protocol from saved URI or connection params', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().replaceConnections([
|
||||
{
|
||||
id: 'oceanbase-uri-oracle',
|
||||
name: 'OceanBase URI Oracle',
|
||||
config: {
|
||||
id: 'oceanbase-uri-oracle',
|
||||
type: 'oceanbase',
|
||||
host: 'ob.local',
|
||||
port: 2881,
|
||||
user: 'sys@oracle001',
|
||||
uri: 'oceanbase://sys%40oracle001:pass@ob.local:2881/OBORCL?protocol=oracle',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'oceanbase-param-oracle',
|
||||
name: 'OceanBase Param Oracle',
|
||||
config: {
|
||||
id: 'oceanbase-param-oracle',
|
||||
type: 'oceanbase',
|
||||
host: 'ob.local',
|
||||
port: 2881,
|
||||
user: 'sys@oracle001',
|
||||
connectionParams: 'tenantMode=oracle&PREFETCH_ROWS=5000',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
|
||||
'oracle',
|
||||
);
|
||||
expect(useStore.getState().connections[1]?.config.oceanBaseProtocol).toBe(
|
||||
'oracle',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers OceanBase protocol query key over legacy aliases when restoring saved connections', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().replaceConnections([
|
||||
{
|
||||
id: 'oceanbase-conflict',
|
||||
name: 'OceanBase Conflict',
|
||||
config: {
|
||||
id: 'oceanbase-conflict',
|
||||
type: 'oceanbase',
|
||||
host: 'ob.local',
|
||||
port: 2881,
|
||||
user: 'root@test',
|
||||
connectionParams: 'protocol=mysql&tenantMode=oracle',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
|
||||
'mysql',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes OceanBase protocol when updating a saved connection', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().replaceConnections([
|
||||
{
|
||||
id: 'oceanbase-existing',
|
||||
name: 'OceanBase Existing',
|
||||
config: {
|
||||
id: 'oceanbase-existing',
|
||||
type: 'oceanbase',
|
||||
host: 'ob.local',
|
||||
port: 2881,
|
||||
user: 'root@test',
|
||||
connectionParams: 'protocol=mysql',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
useStore.getState().updateConnection({
|
||||
id: 'oceanbase-existing',
|
||||
name: 'OceanBase Existing',
|
||||
config: {
|
||||
id: 'oceanbase-existing',
|
||||
type: 'oceanbase',
|
||||
host: 'ob.local',
|
||||
port: 2881,
|
||||
user: 'sys@oracle001',
|
||||
connectionParams: 'protocol=oracle',
|
||||
},
|
||||
});
|
||||
|
||||
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
|
||||
'oracle',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ExternalSQLDirectory,
|
||||
JVMDiagnosticCommandDraft,
|
||||
JVMDiagnosticEventChunk,
|
||||
SqlSnippet,
|
||||
} from "./types";
|
||||
import {
|
||||
ShortcutAction,
|
||||
@@ -23,6 +24,10 @@ import {
|
||||
sanitizeShortcutOptions,
|
||||
} from "./utils/shortcuts";
|
||||
import { buildExternalSQLDirectoryId } from "./utils/externalSqlTree";
|
||||
import {
|
||||
DEFAULT_SQL_SNIPPETS,
|
||||
BUILTIN_SNIPPET_MAP,
|
||||
} from "./utils/sqlSnippetDefaults";
|
||||
import { toPersistedGlobalProxy } from "./utils/globalProxyDraft";
|
||||
import {
|
||||
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||||
@@ -60,7 +65,7 @@ const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
|
||||
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
|
||||
const PERSIST_VERSION = 8;
|
||||
const PERSIST_VERSION = 9;
|
||||
const PERSIST_STORAGE_KEY = "lite-db-storage";
|
||||
const DEFAULT_CONNECTION_TYPE = "mysql";
|
||||
const DEFAULT_JVM_PORT = 9010;
|
||||
@@ -73,9 +78,73 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
password: "",
|
||||
hasPassword: false,
|
||||
};
|
||||
const OCEANBASE_PROTOCOL_PARAM_KEYS = [
|
||||
"protocol",
|
||||
"oceanBaseProtocol",
|
||||
"oceanbaseProtocol",
|
||||
"tenantMode",
|
||||
"compatMode",
|
||||
"mode",
|
||||
];
|
||||
const normalizeOceanBaseProtocol = (
|
||||
value: unknown,
|
||||
): "mysql" | "oracle" | undefined => {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized === "oracle" ||
|
||||
normalized === "oracle-mode" ||
|
||||
normalized === "oracle_mode" ||
|
||||
normalized === "oboracle"
|
||||
? "oracle"
|
||||
: "mysql";
|
||||
};
|
||||
const resolveOceanBaseProtocolFromQueryText = (
|
||||
value: unknown,
|
||||
): "mysql" | "oracle" | undefined => {
|
||||
let text = String(value ?? "").trim();
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
const queryIndex = text.indexOf("?");
|
||||
if (queryIndex >= 0) {
|
||||
text = text.slice(queryIndex + 1);
|
||||
}
|
||||
const hashIndex = text.indexOf("#");
|
||||
if (hashIndex >= 0) {
|
||||
text = text.slice(0, hashIndex);
|
||||
}
|
||||
const params = new URLSearchParams(text.replace(/^[?&]+/, ""));
|
||||
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
|
||||
const protocol = normalizeOceanBaseProtocol(params.get(key));
|
||||
if (protocol) {
|
||||
return protocol;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const resolveOceanBaseProtocol = (
|
||||
raw: Record<string, unknown>,
|
||||
normalizedConnectionParams: string,
|
||||
normalizedUri: string,
|
||||
): "mysql" | "oracle" => {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "oceanBaseProtocol")) {
|
||||
const explicitProtocol = normalizeOceanBaseProtocol(raw.oceanBaseProtocol);
|
||||
if (explicitProtocol) {
|
||||
return explicitProtocol;
|
||||
}
|
||||
}
|
||||
return (
|
||||
resolveOceanBaseProtocolFromQueryText(normalizedConnectionParams) ||
|
||||
resolveOceanBaseProtocolFromQueryText(normalizedUri) ||
|
||||
"mysql"
|
||||
);
|
||||
};
|
||||
const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"oceanbase",
|
||||
"doris",
|
||||
"diros",
|
||||
"sphinx",
|
||||
@@ -90,6 +159,7 @@ const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
"mongodb",
|
||||
"highgo",
|
||||
"vastbase",
|
||||
"opengauss",
|
||||
"jvm",
|
||||
"sqlite",
|
||||
"duckdb",
|
||||
@@ -98,6 +168,7 @@ const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
const SSL_SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"oceanbase",
|
||||
"diros",
|
||||
"sphinx",
|
||||
"dameng",
|
||||
@@ -108,6 +179,7 @@ const SSL_SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
"kingbase",
|
||||
"highgo",
|
||||
"vastbase",
|
||||
"opengauss",
|
||||
"mongodb",
|
||||
"redis",
|
||||
"tdengine",
|
||||
@@ -120,6 +192,8 @@ const getDefaultPortByType = (type: string): number => {
|
||||
case "mysql":
|
||||
case "mariadb":
|
||||
return 3306;
|
||||
case "oceanbase":
|
||||
return 2881;
|
||||
case "doris":
|
||||
case "diros":
|
||||
return 9030;
|
||||
@@ -131,6 +205,7 @@ const getDefaultPortByType = (type: string): number => {
|
||||
return 9000;
|
||||
case "postgres":
|
||||
case "vastbase":
|
||||
case "opengauss":
|
||||
return 5432;
|
||||
case "redis":
|
||||
return 6379;
|
||||
@@ -270,6 +345,13 @@ const normalizeConnectionType = (value: unknown): string => {
|
||||
if (type === "doris") {
|
||||
return "diros";
|
||||
}
|
||||
if (
|
||||
type === "open_gauss" ||
|
||||
type === "open-gauss" ||
|
||||
type === "opengauss"
|
||||
) {
|
||||
return "opengauss";
|
||||
}
|
||||
return SUPPORTED_CONNECTION_TYPES.has(type) ? type : DEFAULT_CONNECTION_TYPE;
|
||||
};
|
||||
|
||||
@@ -490,6 +572,10 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
useHttpTunnel,
|
||||
httpTunnel,
|
||||
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
|
||||
connectionParams: toTrimmedString(raw.connectionParams).slice(
|
||||
0,
|
||||
MAX_URI_LENGTH,
|
||||
),
|
||||
hosts: sanitizeAddressList(raw.hosts),
|
||||
topology:
|
||||
raw.topology === "replica"
|
||||
@@ -528,6 +614,14 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "oceanbase") {
|
||||
safeConfig.oceanBaseProtocol = resolveOceanBaseProtocol(
|
||||
raw,
|
||||
safeConfig.connectionParams || "",
|
||||
safeConfig.uri || "",
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "custom") {
|
||||
safeConfig.driver = toTrimmedString(raw.driver);
|
||||
safeConfig.dsn = toTrimmedString(raw.dsn).slice(0, MAX_URI_LENGTH);
|
||||
@@ -699,6 +793,7 @@ interface AppState {
|
||||
sqlFormatOptions: { keywordCase: "upper" | "lower" };
|
||||
queryOptions: QueryOptions;
|
||||
shortcutOptions: ShortcutOptions;
|
||||
sqlSnippets: SqlSnippet[];
|
||||
sqlLogs: SqlLog[];
|
||||
tableAccessCount: Record<string, number>;
|
||||
tableSortPreference: Record<string, "name" | "frequency">;
|
||||
@@ -786,6 +881,9 @@ interface AppState {
|
||||
binding: Partial<ShortcutBinding>,
|
||||
) => void;
|
||||
resetShortcutOptions: () => void;
|
||||
saveSqlSnippet: (snippet: SqlSnippet) => void;
|
||||
deleteSqlSnippet: (id: string) => void;
|
||||
resetBuiltinSqlSnippet: (id: string) => void;
|
||||
|
||||
addSqlLog: (log: SqlLog) => void;
|
||||
clearSqlLogs: () => void;
|
||||
@@ -878,6 +976,37 @@ const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeSqlSnippets = (value: unknown): SqlSnippet[] => {
|
||||
if (!Array.isArray(value)) return DEFAULT_SQL_SNIPPETS;
|
||||
const result: SqlSnippet[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
value.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") return;
|
||||
const raw = entry as Record<string, unknown>;
|
||||
const prefix = toTrimmedString(raw.prefix)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, "")
|
||||
.slice(0, 20);
|
||||
const body = toTrimmedString(raw.body);
|
||||
if (!prefix || !body) return;
|
||||
const id = toTrimmedString(raw.id, `snippet-${index + 1}`) || `snippet-${index + 1}`;
|
||||
if (seenIds.has(id)) return;
|
||||
seenIds.add(id);
|
||||
result.push({
|
||||
id,
|
||||
prefix,
|
||||
name: toTrimmedString(raw.name, `片段-${index + 1}`) || `片段-${index + 1}`,
|
||||
description: toTrimmedString(raw.description) || undefined,
|
||||
body,
|
||||
isBuiltin: raw.isBuiltin === true,
|
||||
createdAt: Number.isFinite(Number(raw.createdAt))
|
||||
? Number(raw.createdAt)
|
||||
: Date.now(),
|
||||
});
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeExternalSQLDirectories = (
|
||||
value: unknown,
|
||||
): ExternalSQLDirectory[] => {
|
||||
@@ -1064,7 +1193,7 @@ const sanitizeAppearance = (
|
||||
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
|
||||
showDataTableVerticalBorders:
|
||||
dataGridDisplaySettings.showDataTableVerticalBorders,
|
||||
dataTableColumnWidthMode: dataGridDisplaySettings.dataTableColumnWidthMode,
|
||||
dataTableDensity: dataGridDisplaySettings.dataTableDensity,
|
||||
};
|
||||
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
@@ -1313,6 +1442,7 @@ export const useStore = create<AppState>()(
|
||||
showColumnType: true,
|
||||
},
|
||||
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
|
||||
sqlSnippets: DEFAULT_SQL_SNIPPETS,
|
||||
sqlLogs: [],
|
||||
tableAccessCount: {},
|
||||
tableSortPreference: {},
|
||||
@@ -1334,13 +1464,31 @@ export const useStore = create<AppState>()(
|
||||
jvmDiagnosticOutputs: {},
|
||||
|
||||
addConnection: (conn) =>
|
||||
set((state) => ({ connections: [...state.connections, conn] })),
|
||||
set((state) => {
|
||||
const sanitized = sanitizeSavedConnection(
|
||||
conn,
|
||||
state.connections.length,
|
||||
);
|
||||
if (!sanitized) {
|
||||
return { connections: state.connections };
|
||||
}
|
||||
return { connections: [...state.connections, sanitized] };
|
||||
}),
|
||||
updateConnection: (conn) =>
|
||||
set((state) => ({
|
||||
connections: state.connections.map((c) =>
|
||||
c.id === conn.id ? conn : c,
|
||||
),
|
||||
})),
|
||||
set((state) => {
|
||||
const sanitized = sanitizeSavedConnection(
|
||||
conn,
|
||||
state.connections.length,
|
||||
);
|
||||
if (!sanitized) {
|
||||
return { connections: state.connections };
|
||||
}
|
||||
return {
|
||||
connections: state.connections.map((c) =>
|
||||
c.id === conn.id ? sanitized : c,
|
||||
),
|
||||
};
|
||||
}),
|
||||
removeConnection: (id) =>
|
||||
set((state) => ({
|
||||
connections: state.connections.filter((c) => c.id !== id),
|
||||
@@ -1698,6 +1846,33 @@ export const useStore = create<AppState>()(
|
||||
});
|
||||
},
|
||||
|
||||
saveSqlSnippet: (snippet) =>
|
||||
set((state) => {
|
||||
const existing = state.sqlSnippets.findIndex((s) => s.id === snippet.id);
|
||||
if (existing >= 0) {
|
||||
const updated = [...state.sqlSnippets];
|
||||
updated[existing] = snippet;
|
||||
return { sqlSnippets: updated };
|
||||
}
|
||||
return { sqlSnippets: [...state.sqlSnippets, snippet] };
|
||||
}),
|
||||
deleteSqlSnippet: (id) =>
|
||||
set((state) => ({
|
||||
sqlSnippets: state.sqlSnippets.filter(
|
||||
(s) => s.id !== id || s.isBuiltin,
|
||||
),
|
||||
})),
|
||||
resetBuiltinSqlSnippet: (id) =>
|
||||
set((state) => {
|
||||
const original = BUILTIN_SNIPPET_MAP[id];
|
||||
if (!original) return state;
|
||||
return {
|
||||
sqlSnippets: state.sqlSnippets.map((s) =>
|
||||
s.id === id ? { ...original } : s,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
addSqlLog: (log) =>
|
||||
set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
|
||||
clearSqlLogs: () => set({ sqlLogs: [] }),
|
||||
@@ -2033,6 +2208,15 @@ export const useStore = create<AppState>()(
|
||||
nextState.shortcutOptions = sanitizeShortcutOptions(
|
||||
state.shortcutOptions,
|
||||
);
|
||||
const existingSnippets = sanitizeSqlSnippets(state.sqlSnippets);
|
||||
const existingSnippetIds = new Set(existingSnippets.map((s) => s.id));
|
||||
const missingSnippets = DEFAULT_SQL_SNIPPETS.filter(
|
||||
(d) => !existingSnippetIds.has(d.id),
|
||||
);
|
||||
nextState.sqlSnippets =
|
||||
missingSnippets.length > 0
|
||||
? [...existingSnippets, ...missingSnippets]
|
||||
: existingSnippets;
|
||||
nextState.tableAccessCount = sanitizeTableAccessCount(
|
||||
state.tableAccessCount,
|
||||
);
|
||||
@@ -2097,6 +2281,7 @@ export const useStore = create<AppState>()(
|
||||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||||
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
||||
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
|
||||
sqlSnippets: sanitizeSqlSnippets(state.sqlSnippets),
|
||||
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
|
||||
|
||||
// AI 会话数据不再从 localStorage 恢复,改为从后端文件加载
|
||||
@@ -2121,6 +2306,7 @@ export const useStore = create<AppState>()(
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
queryOptions: state.queryOptions,
|
||||
shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
|
||||
sqlSnippets: state.sqlSnippets,
|
||||
tableAccessCount: state.tableAccessCount,
|
||||
tableSortPreference: state.tableSortPreference,
|
||||
tableColumnOrders: state.tableColumnOrders,
|
||||
|
||||
@@ -294,10 +294,12 @@ export interface ConnectionConfig {
|
||||
httpTunnel?: HTTPTunnelConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
connectionParams?: string;
|
||||
timeout?: number;
|
||||
redisDB?: number; // Redis database index (0-15)
|
||||
uri?: string; // Connection URI for copy/paste
|
||||
clickHouseProtocol?: "auto" | "http" | "native"; // ClickHouse connection protocol override
|
||||
oceanBaseProtocol?: "mysql" | "oracle"; // OceanBase tenant protocol
|
||||
hosts?: string[]; // Multi-host addresses: host:port
|
||||
topology?: "single" | "replica" | "cluster";
|
||||
mysqlReplicaUser?: string;
|
||||
@@ -452,6 +454,16 @@ export interface SavedQuery {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SqlSnippet {
|
||||
id: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
body: string;
|
||||
isBuiltin: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ExternalSQLDirectory {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -552,6 +564,7 @@ export interface AIChatMessage {
|
||||
phase?: ChatPhase;
|
||||
content: string;
|
||||
thinking?: string;
|
||||
reasoning_content?: string;
|
||||
timestamp: number;
|
||||
loading?: boolean;
|
||||
images?: string[]; // base64 encoded images with data URI prefix
|
||||
|
||||
78
frontend/src/utils/aiMessagePayload.test.ts
Normal file
78
frontend/src/utils/aiMessagePayload.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AIChatMessage, AIToolCall } from '../types';
|
||||
import { toAIRequestMessage } from './aiMessagePayload';
|
||||
|
||||
const toolCall: AIToolCall = {
|
||||
id: 'call_schema',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'inspect_table_schema',
|
||||
arguments: '{"table":"orders"}',
|
||||
},
|
||||
};
|
||||
|
||||
const message = (overrides: Partial<AIChatMessage>): AIChatMessage => ({
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('toAIRequestMessage', () => {
|
||||
it('keeps reasoning_content on assistant tool-call messages', () => {
|
||||
const payload = toAIRequestMessage(message({
|
||||
tool_calls: [toolCall],
|
||||
reasoning_content: '需要先检查表结构',
|
||||
}));
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
role: 'assistant',
|
||||
tool_calls: [toolCall],
|
||||
reasoning_content: '需要先检查表结构',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps reasoning_content on assistant messages without tool calls', () => {
|
||||
const payload = toAIRequestMessage(message({
|
||||
content: '最终分析',
|
||||
reasoning_content: '工具调用轮次的最终思考也需要保留',
|
||||
}));
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: '最终分析',
|
||||
reasoning_content: '工具调用轮次的最终思考也需要保留',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits reasoning_content from tool result messages while keeping tool_call_id', () => {
|
||||
const payload = toAIRequestMessage(message({
|
||||
role: 'tool',
|
||||
content: '{"ok":true}',
|
||||
tool_call_id: 'call_schema',
|
||||
reasoning_content: '不应回传',
|
||||
}));
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
role: 'tool',
|
||||
content: '{"ok":true}',
|
||||
tool_call_id: 'call_schema',
|
||||
});
|
||||
expect(payload).not.toHaveProperty('reasoning_content');
|
||||
});
|
||||
|
||||
it('keeps user images without adding empty tool fields', () => {
|
||||
const payload = toAIRequestMessage(message({
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: ['data:image/png;base64,abc'],
|
||||
}));
|
||||
|
||||
expect(payload).toEqual({
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: ['data:image/png;base64,abc'],
|
||||
});
|
||||
});
|
||||
});
|
||||
32
frontend/src/utils/aiMessagePayload.ts
Normal file
32
frontend/src/utils/aiMessagePayload.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { AIChatMessage, AIToolCall } from '../types';
|
||||
|
||||
export interface AIRequestMessage {
|
||||
role: AIChatMessage['role'];
|
||||
content: string;
|
||||
images?: string[];
|
||||
tool_calls?: AIToolCall[];
|
||||
tool_call_id?: string;
|
||||
reasoning_content?: string;
|
||||
}
|
||||
|
||||
export const toAIRequestMessage = (message: AIChatMessage): AIRequestMessage => {
|
||||
const payload: AIRequestMessage = {
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
};
|
||||
|
||||
if (message.images && message.images.length > 0) {
|
||||
payload.images = message.images;
|
||||
}
|
||||
if (message.tool_calls && message.tool_calls.length > 0) {
|
||||
payload.tool_calls = message.tool_calls;
|
||||
}
|
||||
if (message.tool_call_id) {
|
||||
payload.tool_call_id = message.tool_call_id;
|
||||
}
|
||||
if (message.role === 'assistant' && message.reasoning_content) {
|
||||
payload.reasoning_content = message.reasoning_content;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
@@ -68,6 +68,7 @@ describe('connectionModalPresentation', () => {
|
||||
const allTypes = [
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'oceanbase',
|
||||
'doris',
|
||||
'diros',
|
||||
'sphinx',
|
||||
@@ -81,6 +82,7 @@ describe('connectionModalPresentation', () => {
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'opengauss',
|
||||
'mongodb',
|
||||
'redis',
|
||||
'tdengine',
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ConnectionConfigSectionKey =
|
||||
| 'target'
|
||||
| 'fileTarget'
|
||||
| 'connectionMode'
|
||||
| 'oceanBaseProtocol'
|
||||
| 'mongoDiscovery'
|
||||
| 'replica'
|
||||
| 'service'
|
||||
@@ -55,6 +56,7 @@ type ConnectionConfigSectionCopy = {
|
||||
const mysqlCompatibleTypes = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'oceanbase',
|
||||
'doris',
|
||||
'diros',
|
||||
'sphinx',
|
||||
@@ -64,6 +66,7 @@ const postgresCompatibleTypes = new Set([
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'opengauss',
|
||||
]);
|
||||
const fileDatabaseTypes = new Set(['sqlite', 'duckdb']);
|
||||
|
||||
@@ -91,6 +94,10 @@ const CONNECTION_CONFIG_SECTION_COPY: Record<
|
||||
title: '连接模式',
|
||||
description: '选择单机、主从、副本集或集群等拓扑模式。',
|
||||
},
|
||||
oceanBaseProtocol: {
|
||||
title: 'OceanBase 协议',
|
||||
description: '明确选择 MySQL 租户协议或 Oracle 租户协议。',
|
||||
},
|
||||
mongoDiscovery: {
|
||||
title: 'MongoDB 寻址',
|
||||
description: '选择标准 host:port 或 mongodb+srv DNS 发现方式。',
|
||||
|
||||
@@ -52,6 +52,77 @@ describe('buildRpcConnectionConfig', () => {
|
||||
expect(result.clickHouseProtocol).toBe('http');
|
||||
});
|
||||
|
||||
it('injects OceanBase protocol override into RPC connection params', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-oceanbase-oracle',
|
||||
type: 'oceanbase',
|
||||
host: 'ob.local',
|
||||
port: 2881,
|
||||
user: 'sys@oracle001',
|
||||
database: 'ORCL',
|
||||
oceanBaseProtocol: 'oracle',
|
||||
} as any);
|
||||
|
||||
expect(result.connectionParams).toBe('protocol=oracle');
|
||||
expect((result as any).oceanBaseProtocol).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps OceanBase URI protocol when no form override exists', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-oceanbase-uri',
|
||||
type: 'oceanbase',
|
||||
host: 'ob.local',
|
||||
port: 2881,
|
||||
user: 'sys@oracle001',
|
||||
database: 'ORCL',
|
||||
uri: 'oceanbase://sys%40oracle001:pass@ob.local:2881/ORCL?protocol=oracle',
|
||||
} as any);
|
||||
|
||||
expect(result.connectionParams).toBe('protocol=oracle');
|
||||
});
|
||||
|
||||
it('lets OceanBase form protocol override legacy connection param aliases', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-oceanbase-mysql',
|
||||
type: 'oceanbase',
|
||||
host: 'ob.local',
|
||||
port: 2881,
|
||||
user: 'root@test',
|
||||
database: 'app',
|
||||
oceanBaseProtocol: 'mysql',
|
||||
connectionParams: 'tenantMode=oracle&connectTimeout=10',
|
||||
} as any);
|
||||
|
||||
expect(result.connectionParams).toBe('connectTimeout=10&protocol=mysql');
|
||||
});
|
||||
|
||||
it('keeps OceanBase protocol query key ahead of compatibility aliases', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-oceanbase-conflict',
|
||||
type: 'oceanbase',
|
||||
host: 'ob.local',
|
||||
port: 2881,
|
||||
user: 'root@test',
|
||||
database: 'app',
|
||||
connectionParams: 'protocol=mysql&tenantMode=oracle',
|
||||
} as any);
|
||||
|
||||
expect(result.connectionParams).toBe('protocol=mysql');
|
||||
});
|
||||
|
||||
it('preserves extra connection params for RPC calls', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-mysql',
|
||||
type: 'mysql',
|
||||
host: 'db.local',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
connectionParams: 'characterEncoding=utf8&useSSL=false',
|
||||
} as any);
|
||||
|
||||
expect(result.connectionParams).toBe('characterEncoding=utf8&useSSL=false');
|
||||
});
|
||||
|
||||
it('fills default nested config blocks needed by RPC calls', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-redis',
|
||||
|
||||
@@ -11,6 +11,15 @@ type ConnectionConfigInput = {
|
||||
type SSHConfigInput = Record<string, any>;
|
||||
type ProxyConfigInput = Record<string, any>;
|
||||
type HttpTunnelConfigInput = Record<string, any>;
|
||||
type OceanBaseProtocol = 'mysql' | 'oracle';
|
||||
const OCEANBASE_PROTOCOL_PARAM_KEYS = [
|
||||
'protocol',
|
||||
'oceanBaseProtocol',
|
||||
'oceanbaseProtocol',
|
||||
'tenantMode',
|
||||
'compatMode',
|
||||
'mode',
|
||||
];
|
||||
|
||||
const toStringValue = (value: unknown, fallback = ''): string => {
|
||||
if (typeof value === 'string') {
|
||||
@@ -70,6 +79,70 @@ const normalizeHttpTunnelConfig = (value: unknown): connection.HTTPTunnelConfig
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeOceanBaseProtocol = (value: unknown): OceanBaseProtocol | undefined => {
|
||||
const normalized = toStringValue(value).trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized === 'oracle' || normalized === 'oracle-mode' || normalized === 'oracle_mode' || normalized === 'oboracle'
|
||||
? 'oracle'
|
||||
: 'mysql';
|
||||
};
|
||||
|
||||
const resolveOceanBaseProtocolFromQueryText = (raw: unknown): OceanBaseProtocol | undefined => {
|
||||
let text = toStringValue(raw).trim();
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
const queryStart = text.indexOf('?');
|
||||
if (queryStart >= 0) {
|
||||
text = text.slice(queryStart + 1);
|
||||
}
|
||||
const hashStart = text.indexOf('#');
|
||||
if (hashStart >= 0) {
|
||||
text = text.slice(0, hashStart);
|
||||
}
|
||||
const params = new URLSearchParams(text.replace(/^[?&]+/, ''));
|
||||
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
|
||||
const protocol = normalizeOceanBaseProtocol(params.get(key));
|
||||
if (protocol) {
|
||||
return protocol;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resolveOceanBaseProtocol = (config: ConnectionConfigInput): OceanBaseProtocol => {
|
||||
if (Object.prototype.hasOwnProperty.call(config, 'oceanBaseProtocol')) {
|
||||
const explicitProtocol = normalizeOceanBaseProtocol(config.oceanBaseProtocol);
|
||||
if (explicitProtocol) {
|
||||
return explicitProtocol;
|
||||
}
|
||||
}
|
||||
return (
|
||||
resolveOceanBaseProtocolFromQueryText(config.connectionParams) ||
|
||||
resolveOceanBaseProtocolFromQueryText(config.uri) ||
|
||||
'mysql'
|
||||
);
|
||||
};
|
||||
|
||||
const withOceanBaseProtocolParam = (config: ConnectionConfigInput): ConnectionConfigInput => {
|
||||
const type = toStringValue(config.type).trim().toLowerCase();
|
||||
if (type !== 'oceanbase') {
|
||||
return config;
|
||||
}
|
||||
const selectedProtocol = resolveOceanBaseProtocol(config);
|
||||
const params = new URLSearchParams(toStringValue(config.connectionParams));
|
||||
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
|
||||
params.delete(key);
|
||||
}
|
||||
params.set('protocol', selectedProtocol);
|
||||
return {
|
||||
...config,
|
||||
connectionParams: params.toString(),
|
||||
};
|
||||
};
|
||||
|
||||
export function buildRpcConnectionConfig(
|
||||
config: ConnectionConfigInput,
|
||||
overrides: ConnectionConfigInput = {},
|
||||
@@ -93,25 +166,27 @@ export function buildRpcConnectionConfig(
|
||||
proxy: mergedProxy,
|
||||
httpTunnel: mergedHttpTunnel,
|
||||
};
|
||||
const rpcMerged = withOceanBaseProtocolParam(merged);
|
||||
const { oceanBaseProtocol: _oceanBaseProtocol, ...rpcPayload } = rpcMerged;
|
||||
|
||||
const baseId = toStringValue(config.id).trim() || toStringValue(overrides.id).trim() || undefined;
|
||||
const timeout = toOptionalInteger(merged.timeout, toOptionalInteger(config.timeout));
|
||||
const redisDB = toOptionalInteger(merged.redisDB, toOptionalInteger(config.redisDB));
|
||||
const timeout = toOptionalInteger(rpcMerged.timeout, toOptionalInteger(config.timeout));
|
||||
const redisDB = toOptionalInteger(rpcMerged.redisDB, toOptionalInteger(config.redisDB));
|
||||
|
||||
const rpcConfig = new connection.ConnectionConfig({
|
||||
...merged,
|
||||
type: toStringValue(merged.type),
|
||||
host: toStringValue(merged.host),
|
||||
port: toOptionalInteger(merged.port, toOptionalInteger(config.port, 0)) ?? 0,
|
||||
user: toStringValue(merged.user),
|
||||
password: toStringValue(merged.password),
|
||||
database: toStringValue(merged.database),
|
||||
useSSH: merged.useSSH === true,
|
||||
ssh: normalizeSSHConfig(merged.ssh),
|
||||
useProxy: merged.useProxy === true,
|
||||
proxy: normalizeProxyConfig(merged.proxy),
|
||||
useHttpTunnel: merged.useHttpTunnel === true,
|
||||
httpTunnel: normalizeHttpTunnelConfig(merged.httpTunnel),
|
||||
...rpcPayload,
|
||||
type: toStringValue(rpcMerged.type),
|
||||
host: toStringValue(rpcMerged.host),
|
||||
port: toOptionalInteger(rpcMerged.port, toOptionalInteger(config.port, 0)) ?? 0,
|
||||
user: toStringValue(rpcMerged.user),
|
||||
password: toStringValue(rpcMerged.password),
|
||||
database: toStringValue(rpcMerged.database),
|
||||
useSSH: rpcMerged.useSSH === true,
|
||||
ssh: normalizeSSHConfig(rpcMerged.ssh),
|
||||
useProxy: rpcMerged.useProxy === true,
|
||||
proxy: normalizeProxyConfig(rpcMerged.proxy),
|
||||
useHttpTunnel: rpcMerged.useHttpTunnel === true,
|
||||
httpTunnel: normalizeHttpTunnelConfig(rpcMerged.httpTunnel),
|
||||
timeout,
|
||||
redisDB,
|
||||
}) as RpcConnectionConfig;
|
||||
@@ -119,4 +194,3 @@ export function buildRpcConnectionConfig(
|
||||
rpcConfig.id = baseId;
|
||||
return rpcConfig;
|
||||
}
|
||||
|
||||
|
||||
77
frontend/src/utils/connectionUriMerge.test.ts
Normal file
77
frontend/src/utils/connectionUriMerge.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mergeParsedUriValuesForForm } from "./connectionUriMerge";
|
||||
|
||||
describe("mergeParsedUriValuesForForm", () => {
|
||||
it("keeps saved credentials when parsed URI has no auth section", () => {
|
||||
const result = mergeParsedUriValuesForForm(
|
||||
{
|
||||
user: "root",
|
||||
password: "saved-password",
|
||||
host: "192.168.1.10",
|
||||
port: 3306,
|
||||
database: "old_db",
|
||||
connectionParams: "application_name=GoNavi",
|
||||
timeout: 30,
|
||||
},
|
||||
{
|
||||
host: "192.168.1.240",
|
||||
port: 3306,
|
||||
user: "",
|
||||
password: "",
|
||||
database: "mkefu_location_dev_local",
|
||||
connectionParams: "",
|
||||
timeout: undefined,
|
||||
useSSL: false,
|
||||
},
|
||||
"jdbc:mysql://192.168.1.240:3306/mkefu_location_dev_local?characterEncoding=UTF-8",
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
uri: "jdbc:mysql://192.168.1.240:3306/mkefu_location_dev_local?characterEncoding=UTF-8",
|
||||
host: "192.168.1.240",
|
||||
port: 3306,
|
||||
database: "mkefu_location_dev_local",
|
||||
useSSL: false,
|
||||
});
|
||||
expect(result).not.toHaveProperty("user");
|
||||
expect(result).not.toHaveProperty("password");
|
||||
expect(result).not.toHaveProperty("connectionParams");
|
||||
expect(result).not.toHaveProperty("timeout");
|
||||
});
|
||||
|
||||
it("allows URI credentials to replace existing credentials when provided", () => {
|
||||
const result = mergeParsedUriValuesForForm(
|
||||
{
|
||||
user: "root",
|
||||
password: "old-password",
|
||||
},
|
||||
{
|
||||
user: "uri_user",
|
||||
password: "uri-password",
|
||||
},
|
||||
"mysql://uri_user:uri-password@127.0.0.1:3306/app",
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
user: "uri_user",
|
||||
password: "uri-password",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps existing database when URI omits a database path", () => {
|
||||
const result = mergeParsedUriValuesForForm(
|
||||
{
|
||||
database: "saved_db",
|
||||
},
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
database: "",
|
||||
},
|
||||
"mysql://127.0.0.1:3306",
|
||||
);
|
||||
|
||||
expect(result.database).toBeUndefined();
|
||||
expect(result.host).toBe("127.0.0.1");
|
||||
});
|
||||
});
|
||||
36
frontend/src/utils/connectionUriMerge.ts
Normal file
36
frontend/src/utils/connectionUriMerge.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const EMPTY_PRESERVED_URI_FIELDS = new Set([
|
||||
"user",
|
||||
"password",
|
||||
"database",
|
||||
"connectionParams",
|
||||
]);
|
||||
|
||||
const isEmptyParsedValue = (value: unknown): boolean =>
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === "" ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
export const mergeParsedUriValuesForForm = (
|
||||
currentValues: Record<string, unknown>,
|
||||
parsedValues: Record<string, unknown>,
|
||||
uriText: string,
|
||||
): Record<string, unknown> => {
|
||||
const nextValues: Record<string, unknown> = { uri: uriText };
|
||||
|
||||
Object.entries(parsedValues).forEach(([key, value]) => {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
EMPTY_PRESERVED_URI_FIELDS.has(key) &&
|
||||
isEmptyParsedValue(value) &&
|
||||
!isEmptyParsedValue(currentValues[key])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
nextValues[key] = value;
|
||||
});
|
||||
|
||||
return nextValues;
|
||||
};
|
||||
@@ -11,17 +11,18 @@ import {
|
||||
describe('dataGridDisplay helpers', () => {
|
||||
it('sanitizes missing display settings to safe defaults', () => {
|
||||
expect(sanitizeDataGridDisplaySettings(undefined)).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
|
||||
expect(sanitizeDataGridDisplaySettings({ dataTableColumnWidthMode: 'invalid' as never })).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
|
||||
expect(sanitizeDataGridDisplaySettings({ dataTableDensity: 'invalid' as never })).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
|
||||
});
|
||||
|
||||
it('resolves standard and compact default column widths', () => {
|
||||
expect(resolveDataTableDefaultColumnWidth('standard')).toBe(200);
|
||||
expect(resolveDataTableDefaultColumnWidth('compact')).toBe(140);
|
||||
it('resolves density-based default column widths', () => {
|
||||
expect(resolveDataTableDefaultColumnWidth('comfortable')).toBe(180);
|
||||
expect(resolveDataTableDefaultColumnWidth('standard')).toBe(140);
|
||||
expect(resolveDataTableDefaultColumnWidth('compact')).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps manual column widths ahead of mode defaults', () => {
|
||||
expect(resolveDataTableColumnWidth({ manualWidth: 320, widthMode: 'compact' })).toBe(320);
|
||||
expect(resolveDataTableColumnWidth({ manualWidth: undefined, widthMode: 'compact' })).toBe(140);
|
||||
it('keeps manual column widths ahead of density defaults', () => {
|
||||
expect(resolveDataTableColumnWidth({ manualWidth: 320, density: 'compact' })).toBe(320);
|
||||
expect(resolveDataTableColumnWidth({ manualWidth: undefined, density: 'compact' })).toBe(100);
|
||||
});
|
||||
|
||||
it('uses subtle themed vertical border colors and transparent when disabled', () => {
|
||||
|
||||
@@ -1,25 +1,64 @@
|
||||
export type DataTableColumnWidthMode = 'standard' | 'compact';
|
||||
export type DataTableDensity = 'comfortable' | 'standard' | 'compact';
|
||||
|
||||
export interface DataGridDisplaySettings {
|
||||
showDataTableVerticalBorders: boolean;
|
||||
dataTableColumnWidthMode: DataTableColumnWidthMode;
|
||||
dataTableDensity: DataTableDensity;
|
||||
}
|
||||
|
||||
export const DEFAULT_DATA_GRID_DISPLAY_SETTINGS: DataGridDisplaySettings = {
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
dataTableDensity: 'comfortable',
|
||||
};
|
||||
|
||||
export const DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS = [
|
||||
{ label: '标准 200px', value: 'standard' as const },
|
||||
{ label: '紧凑 140px', value: 'compact' as const },
|
||||
interface DensityParams {
|
||||
defaultColumnWidth: number;
|
||||
cellPadding: string;
|
||||
inputCellPadding: string;
|
||||
headerMinHeight: number;
|
||||
dataFontSize: number;
|
||||
metaFontSize: number;
|
||||
}
|
||||
|
||||
const DENSITY_PARAMS: Record<DataTableDensity, DensityParams> = {
|
||||
comfortable: {
|
||||
defaultColumnWidth: 180,
|
||||
cellPadding: '8px',
|
||||
inputCellPadding: '0px 4px',
|
||||
headerMinHeight: 40,
|
||||
dataFontSize: 13,
|
||||
metaFontSize: 11,
|
||||
},
|
||||
standard: {
|
||||
defaultColumnWidth: 140,
|
||||
cellPadding: '5px 8px',
|
||||
inputCellPadding: '0px 3px',
|
||||
headerMinHeight: 34,
|
||||
dataFontSize: 13,
|
||||
metaFontSize: 10,
|
||||
},
|
||||
compact: {
|
||||
defaultColumnWidth: 100,
|
||||
cellPadding: '2px 6px',
|
||||
inputCellPadding: '0px 2px',
|
||||
headerMinHeight: 28,
|
||||
dataFontSize: 12,
|
||||
metaFontSize: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const DENSITY_OPTIONS = [
|
||||
{ label: '舒适', value: 'comfortable' as const },
|
||||
{ label: '标准', value: 'standard' as const },
|
||||
{ label: '紧凑', value: 'compact' as const },
|
||||
];
|
||||
|
||||
const STANDARD_DATA_TABLE_COLUMN_WIDTH = 200;
|
||||
const COMPACT_DATA_TABLE_COLUMN_WIDTH = 140;
|
||||
export const sanitizeDataTableDensity = (value: unknown): DataTableDensity => {
|
||||
if (value === 'standard' || value === 'compact') return value;
|
||||
return 'comfortable';
|
||||
};
|
||||
|
||||
export const sanitizeDataTableColumnWidthMode = (value: unknown): DataTableColumnWidthMode => {
|
||||
return value === 'compact' ? 'compact' : 'standard';
|
||||
export const getDensityParams = (density: DataTableDensity): DensityParams => {
|
||||
return DENSITY_PARAMS[density] || DENSITY_PARAMS.comfortable;
|
||||
};
|
||||
|
||||
export const sanitizeDataGridDisplaySettings = (
|
||||
@@ -31,30 +70,28 @@ export const sanitizeDataGridDisplaySettings = (
|
||||
|
||||
return {
|
||||
showDataTableVerticalBorders: value.showDataTableVerticalBorders === true,
|
||||
dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value.dataTableColumnWidthMode),
|
||||
dataTableDensity: sanitizeDataTableDensity(value.dataTableDensity),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveDataTableDefaultColumnWidth = (
|
||||
widthMode: DataTableColumnWidthMode | null | undefined
|
||||
density: DataTableDensity | null | undefined
|
||||
): number => {
|
||||
return sanitizeDataTableColumnWidthMode(widthMode) === 'compact'
|
||||
? COMPACT_DATA_TABLE_COLUMN_WIDTH
|
||||
: STANDARD_DATA_TABLE_COLUMN_WIDTH;
|
||||
return getDensityParams(sanitizeDataTableDensity(density)).defaultColumnWidth;
|
||||
};
|
||||
|
||||
export const resolveDataTableColumnWidth = ({
|
||||
manualWidth,
|
||||
widthMode,
|
||||
density,
|
||||
}: {
|
||||
manualWidth: number | null | undefined;
|
||||
widthMode: DataTableColumnWidthMode | null | undefined;
|
||||
density: DataTableDensity | null | undefined;
|
||||
}): number => {
|
||||
if (typeof manualWidth === 'number' && Number.isFinite(manualWidth) && manualWidth > 0) {
|
||||
return manualWidth;
|
||||
}
|
||||
|
||||
return resolveDataTableDefaultColumnWidth(widthMode);
|
||||
return resolveDataTableDefaultColumnWidth(density);
|
||||
};
|
||||
|
||||
export const resolveDataTableVerticalBorderColor = ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
normalizeQuickWhereCondition,
|
||||
resolveWhereConditionSuggestions,
|
||||
resolveWhereConditionSelectedValue,
|
||||
shouldApplyQuickWhereOnEnter,
|
||||
validateQuickWhereCondition,
|
||||
} from './dataGridWhereFilter';
|
||||
|
||||
@@ -110,4 +111,30 @@ describe('dataGridWhereFilter', () => {
|
||||
}),
|
||||
).toBe('`username` = ');
|
||||
});
|
||||
|
||||
it('lets autocomplete consume enter while quick where suggestions are open', () => {
|
||||
expect(shouldApplyQuickWhereOnEnter({
|
||||
key: 'Enter',
|
||||
suggestionsOpen: true,
|
||||
suggestionCount: 1,
|
||||
activeSuggestionId: 'quick-where-list-0',
|
||||
})).toBe(false);
|
||||
expect(shouldApplyQuickWhereOnEnter({
|
||||
key: 'Enter',
|
||||
suggestionsOpen: true,
|
||||
suggestionCount: 1,
|
||||
})).toBe(true);
|
||||
expect(shouldApplyQuickWhereOnEnter({
|
||||
key: 'Enter',
|
||||
suggestionsOpen: false,
|
||||
suggestionCount: 1,
|
||||
activeSuggestionId: 'quick-where-list-0',
|
||||
})).toBe(true);
|
||||
expect(shouldApplyQuickWhereOnEnter({
|
||||
key: 'Enter',
|
||||
shiftKey: true,
|
||||
suggestionsOpen: false,
|
||||
suggestionCount: 0,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,6 +182,26 @@ export const resolveWhereConditionSelectedValue = ({
|
||||
return applyWhereConditionSuggestion(String(currentInput ?? ''), insertTextValue);
|
||||
};
|
||||
|
||||
export const shouldApplyQuickWhereOnEnter = ({
|
||||
key,
|
||||
shiftKey = false,
|
||||
isComposing = false,
|
||||
suggestionsOpen = false,
|
||||
suggestionCount = 0,
|
||||
activeSuggestionId = '',
|
||||
}: {
|
||||
key: unknown;
|
||||
shiftKey?: boolean;
|
||||
isComposing?: boolean;
|
||||
suggestionsOpen?: boolean;
|
||||
suggestionCount?: number;
|
||||
activeSuggestionId?: unknown;
|
||||
}): boolean => {
|
||||
if (String(key || '') !== 'Enter') return false;
|
||||
if (shiftKey || isComposing) return false;
|
||||
return !(suggestionsOpen && suggestionCount > 0 && String(activeSuggestionId ?? '').trim());
|
||||
};
|
||||
|
||||
export const resolveWhereConditionSuggestions = ({
|
||||
input,
|
||||
columnNames,
|
||||
|
||||
@@ -29,4 +29,15 @@ describe('dataSourceCapabilities', () => {
|
||||
supportsApproximateTotalPages: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats OceanBase Oracle protocol as Oracle capabilities', () => {
|
||||
expect(getDataSourceCapabilities({
|
||||
type: 'oceanbase',
|
||||
oceanBaseProtocol: 'oracle',
|
||||
})).toMatchObject({
|
||||
type: 'oracle',
|
||||
preferManualTotalCount: true,
|
||||
supportsApproximateTableCount: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConnectionConfig } from '../types';
|
||||
|
||||
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver'> | null | undefined;
|
||||
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver' | 'oceanBaseProtocol'> | null | undefined;
|
||||
|
||||
const normalizeDataSourceToken = (raw: string): string => {
|
||||
const normalized = String(raw || '').trim().toLowerCase();
|
||||
@@ -9,6 +9,10 @@ const normalizeDataSourceToken = (raw: string): string => {
|
||||
return 'diros';
|
||||
case 'postgresql':
|
||||
return 'postgres';
|
||||
case 'opengauss':
|
||||
case 'open_gauss':
|
||||
case 'open-gauss':
|
||||
return 'opengauss';
|
||||
case 'dm':
|
||||
return 'dameng';
|
||||
default:
|
||||
@@ -23,18 +27,23 @@ export const resolveDataSourceType = (config: ConnectionLike): string => {
|
||||
const driver = normalizeDataSourceToken(String(config.driver || ''));
|
||||
return driver || 'custom';
|
||||
}
|
||||
if (type === 'oceanbase' && String(config.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') {
|
||||
return 'oracle';
|
||||
}
|
||||
return type;
|
||||
};
|
||||
|
||||
const SQL_QUERY_EXPORT_TYPES = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'oceanbase',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'postgres',
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'opengauss',
|
||||
'sqlserver',
|
||||
'sqlite',
|
||||
'duckdb',
|
||||
@@ -47,12 +56,14 @@ const SQL_QUERY_EXPORT_TYPES = new Set([
|
||||
const COPY_INSERT_TYPES = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'oceanbase',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'postgres',
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'opengauss',
|
||||
'sqlserver',
|
||||
'sqlite',
|
||||
'duckdb',
|
||||
|
||||
@@ -17,6 +17,8 @@ describe('driver import guidance', () => {
|
||||
it('documents custom driver aliases for kingbase and related fallbacks', () => {
|
||||
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('kingbase8');
|
||||
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('pgx');
|
||||
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('open_gauss');
|
||||
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('oceanbase');
|
||||
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('JDBC Jar');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,4 +7,4 @@ export const DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP =
|
||||
'行内“导入驱动包”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`),不支持直接导入 JDBC Jar;批量导入请使用上方“导入驱动目录”。';
|
||||
|
||||
export const CUSTOM_CONNECTION_DRIVER_HELP =
|
||||
'已支持: mysql, postgres, sqlite, oracle, dm, kingbase;别名支持 postgresql/pgx、dm8、kingbase8/kingbasees/kingbasev8。当前不支持通过 JDBC Jar 扩展驱动。';
|
||||
'已支持: mysql, oceanbase, postgres, opengauss, sqlite, oracle, dm, kingbase;别名支持 postgresql/pgx、open_gauss/open-gauss、dm8、kingbase8/kingbasees/kingbasev8。当前不支持通过 JDBC Jar 扩展驱动。';
|
||||
|
||||
31
frontend/src/utils/driverManagerWorkbenchTheme.test.ts
Normal file
31
frontend/src/utils/driverManagerWorkbenchTheme.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildDriverManagerWorkbenchTheme } from './driverManagerWorkbenchTheme';
|
||||
|
||||
describe('driverManagerWorkbenchTheme', () => {
|
||||
it('builds a dark driver manager theme with dark surfaces', () => {
|
||||
const theme = buildDriverManagerWorkbenchTheme(true, 0.72);
|
||||
|
||||
expect(theme.isDark).toBe(true);
|
||||
expect(theme.pageBg).toBe('rgb(31, 31, 31)');
|
||||
expect(theme.sectionBg).toBe('rgb(31, 31, 31)');
|
||||
expect(theme.cardBg).toBe('rgb(31, 31, 31)');
|
||||
expect(theme.statBg).toBe('rgb(31, 31, 31)');
|
||||
expect(theme.updateNoteBg).toBe('rgb(31, 31, 31)');
|
||||
expect(theme.titleText).toBe('#f5f7ff');
|
||||
expect(theme.warningText).toBe('#f6c453');
|
||||
});
|
||||
|
||||
it('builds a light driver manager theme with light surfaces', () => {
|
||||
const theme = buildDriverManagerWorkbenchTheme(false, 0.92);
|
||||
|
||||
expect(theme.isDark).toBe(false);
|
||||
expect(theme.pageBg).toBe('rgb(255, 255, 255)');
|
||||
expect(theme.sectionBg).toBe('rgb(255, 255, 255)');
|
||||
expect(theme.cardBg).toBe('rgb(255, 255, 255)');
|
||||
expect(theme.statBg).toBe('rgb(255, 255, 255)');
|
||||
expect(theme.updateNoteBg).toBe('rgb(255, 255, 255)');
|
||||
expect(theme.titleText).toBe('rgba(5, 5, 5, 0.92)');
|
||||
expect(theme.warningText).toBe('#d48806');
|
||||
});
|
||||
});
|
||||
61
frontend/src/utils/driverManagerWorkbenchTheme.ts
Normal file
61
frontend/src/utils/driverManagerWorkbenchTheme.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export type DriverManagerWorkbenchTheme = {
|
||||
isDark: boolean;
|
||||
pageBg: string;
|
||||
sectionBg: string;
|
||||
sectionBorder: string;
|
||||
cardBg: string;
|
||||
cardBorder: string;
|
||||
cardWarningBorder: string;
|
||||
cardReadyBorder: string;
|
||||
statBg: string;
|
||||
statBorder: string;
|
||||
updateNoteBg: string;
|
||||
updateNoteBorder: string;
|
||||
mutedText: string;
|
||||
titleText: string;
|
||||
warningText: string;
|
||||
};
|
||||
|
||||
export const buildDriverManagerWorkbenchTheme = (darkMode: boolean, _opacity: number): DriverManagerWorkbenchTheme => {
|
||||
if (darkMode) {
|
||||
const darkSurface = 'rgb(31, 31, 31)';
|
||||
|
||||
return {
|
||||
isDark: true,
|
||||
pageBg: darkSurface,
|
||||
sectionBg: darkSurface,
|
||||
sectionBorder: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
cardBg: darkSurface,
|
||||
cardBorder: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
cardWarningBorder: '1px solid rgba(250, 173, 20, 0.35)',
|
||||
cardReadyBorder: '1px solid rgba(82, 196, 26, 0.22)',
|
||||
statBg: darkSurface,
|
||||
statBorder: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
updateNoteBg: darkSurface,
|
||||
updateNoteBorder: '1px solid rgba(250, 173, 20, 0.24)',
|
||||
mutedText: 'rgba(255, 255, 255, 0.62)',
|
||||
titleText: '#f5f7ff',
|
||||
warningText: '#f6c453',
|
||||
};
|
||||
}
|
||||
|
||||
const lightSurface = 'rgb(255, 255, 255)';
|
||||
|
||||
return {
|
||||
isDark: false,
|
||||
pageBg: lightSurface,
|
||||
sectionBg: lightSurface,
|
||||
sectionBorder: '1px solid rgba(5, 5, 5, 0.08)',
|
||||
cardBg: lightSurface,
|
||||
cardBorder: '1px solid rgba(5, 5, 5, 0.08)',
|
||||
cardWarningBorder: '1px solid rgba(250, 173, 20, 0.35)',
|
||||
cardReadyBorder: '1px solid rgba(82, 196, 26, 0.22)',
|
||||
statBg: lightSurface,
|
||||
statBorder: '1px solid rgba(5, 5, 5, 0.08)',
|
||||
updateNoteBg: lightSurface,
|
||||
updateNoteBorder: '1px solid rgba(250, 173, 20, 0.24)',
|
||||
mutedText: 'rgba(5, 5, 5, 0.62)',
|
||||
titleText: 'rgba(5, 5, 5, 0.92)',
|
||||
warningText: '#d48806',
|
||||
};
|
||||
};
|
||||
@@ -44,4 +44,14 @@ describe('macWindow helpers', () => {
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Enter', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not suppress Escape for editable targets so editor widgets can close', () => {
|
||||
expect(shouldSuppressMacNativeEscapeExit(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
{ key: 'Escape', defaultPrevented: false },
|
||||
{ isEditableTarget: true },
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,10 +31,14 @@ export const shouldSuppressMacNativeEscapeExit = (
|
||||
useNativeMacWindowControls: boolean,
|
||||
isFullscreen: boolean,
|
||||
event: Pick<KeyboardEvent, 'key' | 'defaultPrevented'>,
|
||||
options?: { isEditableTarget?: boolean },
|
||||
): boolean => {
|
||||
if (!isMacRuntime || !useNativeMacWindowControls || !isFullscreen) {
|
||||
return false;
|
||||
}
|
||||
if (options?.isEditableTarget) {
|
||||
return false;
|
||||
}
|
||||
if (event.defaultPrevented) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ describe('applyQueryAutoLimit', () => {
|
||||
const limitDialects = [
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'oceanbase',
|
||||
'diros',
|
||||
'doris',
|
||||
'sphinx',
|
||||
@@ -15,6 +16,7 @@ describe('applyQueryAutoLimit', () => {
|
||||
'kingbase8',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'opengauss',
|
||||
'sqlite',
|
||||
'sqlite3',
|
||||
'duckdb',
|
||||
@@ -32,9 +34,9 @@ describe('applyQueryAutoLimit', () => {
|
||||
['dameng'],
|
||||
['dm'],
|
||||
['dm8'],
|
||||
])('adds FETCH FIRST limit for %s connections', (dbType) => {
|
||||
])('adds ROWNUM limit for %s connections', (dbType) => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM MYCIMLED.EDC_LOG', dbType, 500).sql)
|
||||
.toBe('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY');
|
||||
.toBe('SELECT * FROM (SELECT * FROM MYCIMLED.EDC_LOG) WHERE ROWNUM <= 500');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -53,8 +55,8 @@ describe('applyQueryAutoLimit', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['oracle', 'SELECT * FROM users FETCH FIRST 500 ROWS ONLY'],
|
||||
['dm8', 'SELECT * FROM users FETCH FIRST 500 ROWS ONLY'],
|
||||
['oracle', 'SELECT * FROM (SELECT * FROM users) WHERE ROWNUM <= 500'],
|
||||
['dm8', 'SELECT * FROM (SELECT * FROM users) WHERE ROWNUM <= 500'],
|
||||
['mssql', 'SELECT TOP 500 * FROM users'],
|
||||
['postgresql', 'SELECT * FROM users LIMIT 500'],
|
||||
['doris', 'SELECT * FROM users LIMIT 500'],
|
||||
@@ -66,7 +68,12 @@ describe('applyQueryAutoLimit', () => {
|
||||
|
||||
it('keeps trailing semicolon and comments after injected Oracle limit', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM MYCIMLED.EDC_LOG; -- preview', 'oracle', 500).sql)
|
||||
.toBe('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY; -- preview');
|
||||
.toBe('SELECT * FROM (SELECT * FROM MYCIMLED.EDC_LOG) WHERE ROWNUM <= 500; -- preview');
|
||||
});
|
||||
|
||||
it('uses Oracle 11g compatible ROWNUM limit for simple table queries', () => {
|
||||
expect(applyQueryAutoLimit('select 1 from xxx', 'oracle', 500).sql)
|
||||
.toBe('SELECT * FROM (select 1 from xxx) WHERE ROWNUM <= 500');
|
||||
});
|
||||
|
||||
it('does not add another generic limit when SQL already limits rows', () => {
|
||||
@@ -88,6 +95,11 @@ describe('applyQueryAutoLimit', () => {
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('does not wrap Oracle FOR UPDATE queries', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users FOR UPDATE', 'oracle', 500).applied)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('does not add another SQL Server limit when SQL already uses TOP', () => {
|
||||
expect(applyQueryAutoLimit('SELECT TOP 10 * FROM users', 'sqlserver', 500).applied)
|
||||
.toBe(false);
|
||||
|
||||
@@ -320,7 +320,9 @@ export const applyQueryAutoLimit = (
|
||||
if (rownumPos >= 0) return { sql, applied: false, maxRows };
|
||||
const offsetPos = findTopLevelKeyword(main, 'offset');
|
||||
if (offsetPos >= 0 && (fromPos < 0 || offsetPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
return { sql: `${main.trimEnd()} FETCH FIRST ${maxRows} ROWS ONLY${tail}`, applied: true, maxRows };
|
||||
const forPos = findTopLevelKeyword(main, 'for');
|
||||
if (forPos >= 0 && (fromPos < 0 || forPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
return { sql: `SELECT * FROM (${main.trimEnd()}) WHERE ROWNUM <= ${maxRows}${tail}`, applied: true, maxRows };
|
||||
}
|
||||
|
||||
const offsetPos = findTopLevelKeyword(main, 'offset');
|
||||
|
||||
210
frontend/src/utils/shortcuts.test.ts
Normal file
210
frontend/src/utils/shortcuts.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findReservedConflict,
|
||||
findReservedConflicts,
|
||||
describeConflictContext,
|
||||
normalizeShortcutCombo,
|
||||
RESERVED_SHORTCUTS,
|
||||
comboToMonacoKeyBinding,
|
||||
} from './shortcuts';
|
||||
import type { ConflictInfo } from './shortcuts';
|
||||
|
||||
// ─── findReservedConflict ────────────────────────────────────────────
|
||||
|
||||
describe('findReservedConflict', () => {
|
||||
it('finds Ctrl+F conflict (Monaco Find)', () => {
|
||||
const result = findReservedConflict('Ctrl+F');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.label).toBe('编辑器查找');
|
||||
expect(result!.context).toBe('monaco');
|
||||
expect(result!.monacoCommandId).toBe('actions.find');
|
||||
});
|
||||
|
||||
it('finds Ctrl+S conflict (browser save)', () => {
|
||||
const result = findReservedConflict('Ctrl+S');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.label).toBe('浏览器保存');
|
||||
expect(result!.context).toBe('global');
|
||||
});
|
||||
|
||||
it('returns null for non-reserved combo', () => {
|
||||
expect(findReservedConflict('Ctrl+Shift+R')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(findReservedConflict('')).toBeNull();
|
||||
});
|
||||
|
||||
it('finds Meta+F (macOS variant)', () => {
|
||||
const result = findReservedConflict('Meta+F');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.label).toBe('编辑器查找');
|
||||
expect(result!.context).toBe('monaco');
|
||||
});
|
||||
|
||||
it('matches after normalization (ctrl+f → Ctrl+F)', () => {
|
||||
const result = findReservedConflict(normalizeShortcutCombo('ctrl+f'));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.label).toBe('编辑器查找');
|
||||
});
|
||||
|
||||
it('finds F2 conflict', () => {
|
||||
const result = findReservedConflict('F2');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.context).toBe('monaco');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── findReservedConflicts ───────────────────────────────────────────
|
||||
|
||||
describe('findReservedConflicts', () => {
|
||||
it('returns multiple conflicts for Ctrl+Enter', () => {
|
||||
const results = findReservedConflicts('Ctrl+Enter');
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
const labels = results.map(r => r.label);
|
||||
expect(labels).toContain('编辑器在下方插入行');
|
||||
});
|
||||
|
||||
it('returns empty array for non-reserved combo', () => {
|
||||
expect(findReservedConflicts('Ctrl+Shift+Q')).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves monacoCommandId in results', () => {
|
||||
const results = findReservedConflicts('Ctrl+F');
|
||||
expect(results[0].monacoCommandId).toBe('actions.find');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describeConflictContext ─────────────────────────────────────────
|
||||
|
||||
describe('describeConflictContext', () => {
|
||||
it('describes global context', () => {
|
||||
expect(describeConflictContext('global')).toBe('浏览器');
|
||||
});
|
||||
|
||||
it('describes monaco context', () => {
|
||||
expect(describeConflictContext('monaco')).toBe('编辑器');
|
||||
});
|
||||
|
||||
it('describes datagrid context', () => {
|
||||
expect(describeConflictContext('datagrid')).toBe('数据表格');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RESERVED_SHORTCUTS sanity ───────────────────────────────────────
|
||||
|
||||
describe('RESERVED_SHORTCUTS', () => {
|
||||
it('all combos are already normalized', () => {
|
||||
for (const entry of RESERVED_SHORTCUTS) {
|
||||
expect(entry.combo).toBe(normalizeShortcutCombo(entry.combo));
|
||||
}
|
||||
});
|
||||
|
||||
it('has at least 10 entries', () => {
|
||||
expect(RESERVED_SHORTCUTS.length).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('every entry has a label and context', () => {
|
||||
for (const entry of RESERVED_SHORTCUTS) {
|
||||
expect(entry.label).toBeTruthy();
|
||||
expect(['global', 'monaco', 'datagrid']).toContain(entry.context);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── comboToMonacoKeyBinding ─────────────────────────────────────────
|
||||
|
||||
describe('comboToMonacoKeyBinding', () => {
|
||||
const mockKeyMod = {
|
||||
CtrlCmd: 2048,
|
||||
WinCtrl: 256,
|
||||
Alt: 512,
|
||||
Shift: 1024,
|
||||
};
|
||||
|
||||
const mockKeyCode = {
|
||||
Enter: 3,
|
||||
Tab: 2,
|
||||
Escape: 9,
|
||||
Space: 10,
|
||||
Backspace: 1,
|
||||
Delete: 20,
|
||||
Home: 14,
|
||||
End: 13,
|
||||
PageUp: 11,
|
||||
PageDown: 12,
|
||||
UpArrow: 16,
|
||||
DownArrow: 17,
|
||||
LeftArrow: 15,
|
||||
RightArrow: 18,
|
||||
Insert: 19,
|
||||
KeyA: 31, KeyB: 32, KeyC: 33, KeyD: 34, KeyE: 35,
|
||||
KeyF: 41,
|
||||
KeyG: 42, KeyH: 43, KeyK: 47, KeyN: 50, KeyP: 52, KeyR: 54, KeyS: 55,
|
||||
Digit0: 21, Digit1: 22, Digit2: 23, Digit3: 24, Digit4: 25,
|
||||
Digit5: 26, Digit6: 27, Digit7: 28, Digit8: 29, Digit9: 30,
|
||||
F1: 61, F2: 62, F3: 63, F4: 64, F5: 65, F6: 66,
|
||||
F7: 67, F8: 68, F9: 69, F10: 70, F11: 71, F12: 72,
|
||||
Oem1: 80, Oem2: 81, Oem3: 82, Oem4: 83, Oem5: 84,
|
||||
Oem6: 85, Oem7: 86, OemComma: 87, OemMinus: 88,
|
||||
OemPlus: 89, OemPeriod: 90,
|
||||
};
|
||||
|
||||
it('maps Ctrl+Enter correctly', () => {
|
||||
expect(comboToMonacoKeyBinding('Ctrl+Enter', mockKeyMod, mockKeyCode)).toEqual({
|
||||
keyMod: mockKeyMod.CtrlCmd,
|
||||
keyCode: mockKeyCode.Enter,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Ctrl+Shift+R correctly', () => {
|
||||
expect(comboToMonacoKeyBinding('Ctrl+Shift+R', mockKeyMod, mockKeyCode)).toEqual({
|
||||
keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Shift,
|
||||
keyCode: mockKeyCode.KeyR,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Meta+Enter (macOS variant)', () => {
|
||||
expect(comboToMonacoKeyBinding('Meta+Enter', mockKeyMod, mockKeyCode)).toEqual({
|
||||
keyMod: mockKeyMod.WinCtrl,
|
||||
keyCode: mockKeyCode.Enter,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps F2 key', () => {
|
||||
expect(comboToMonacoKeyBinding('F2', mockKeyMod, mockKeyCode)).toEqual({
|
||||
keyMod: 0,
|
||||
keyCode: mockKeyCode.F2,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Ctrl+, (comma)', () => {
|
||||
expect(comboToMonacoKeyBinding('Ctrl+,', mockKeyMod, mockKeyCode)).toEqual({
|
||||
keyMod: mockKeyMod.CtrlCmd,
|
||||
keyCode: mockKeyCode.OemComma,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for empty combo', () => {
|
||||
expect(comboToMonacoKeyBinding('', mockKeyMod, mockKeyCode)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for combo with only modifiers', () => {
|
||||
expect(comboToMonacoKeyBinding('Ctrl+Shift', mockKeyMod, mockKeyCode)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps Ctrl+Digit1', () => {
|
||||
expect(comboToMonacoKeyBinding('Ctrl+1', mockKeyMod, mockKeyCode)).toEqual({
|
||||
keyMod: mockKeyMod.CtrlCmd,
|
||||
keyCode: mockKeyCode.Digit1,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Ctrl+Alt+Delete', () => {
|
||||
expect(comboToMonacoKeyBinding('Ctrl+Alt+Delete', mockKeyMod, mockKeyCode)).toEqual({
|
||||
keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Alt,
|
||||
keyCode: mockKeyCode.Delete,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -312,3 +312,179 @@ export const getShortcutDisplay = (combo: string): string => {
|
||||
return normalized || '-';
|
||||
};
|
||||
|
||||
export type ConflictContext = 'global' | 'monaco' | 'datagrid';
|
||||
|
||||
export interface ReservedShortcut {
|
||||
combo: string;
|
||||
label: string;
|
||||
context: ConflictContext;
|
||||
monacoCommandId?: string;
|
||||
}
|
||||
|
||||
export interface ConflictInfo {
|
||||
label: string;
|
||||
context: ConflictContext;
|
||||
monacoCommandId?: string;
|
||||
}
|
||||
|
||||
export const RESERVED_SHORTCUTS: ReservedShortcut[] = [
|
||||
// Browser / WebView built-in shortcuts
|
||||
{ combo: 'Ctrl+S', label: '浏览器保存', context: 'global' },
|
||||
{ combo: 'Ctrl+P', label: '浏览器打印', context: 'global' },
|
||||
{ combo: 'Ctrl+W', label: '浏览器关闭标签页', context: 'global' },
|
||||
{ combo: 'Ctrl+T', label: '浏览器新建标签页', context: 'global' },
|
||||
{ combo: 'Ctrl+N', label: '浏览器新建窗口', context: 'global' },
|
||||
{ combo: 'Ctrl+Shift+N', label: '浏览器新建隐身窗口', context: 'global' },
|
||||
|
||||
// Monaco editor built-in shortcuts
|
||||
{ combo: 'Ctrl+F', label: '编辑器查找', context: 'monaco', monacoCommandId: 'actions.find' },
|
||||
{ combo: 'Meta+F', label: '编辑器查找', context: 'monaco', monacoCommandId: 'actions.find' },
|
||||
{ combo: 'Ctrl+H', label: '编辑器替换', context: 'monaco', monacoCommandId: 'editor.action.startFindReplaceAction' },
|
||||
{ combo: 'Meta+H', label: '编辑器替换', context: 'monaco', monacoCommandId: 'editor.action.startFindReplaceAction' },
|
||||
{ combo: 'Ctrl+G', label: '编辑器跳转行', context: 'monaco', monacoCommandId: 'editor.action.gotoLine' },
|
||||
{ combo: 'Meta+G', label: '编辑器跳转行', context: 'monaco', monacoCommandId: 'editor.action.gotoLine' },
|
||||
{ combo: 'Ctrl+P', label: '编辑器快速打开', context: 'monaco', monacoCommandId: 'actions.quickOpen' },
|
||||
{ combo: 'Meta+P', label: '编辑器快速打开', context: 'monaco', monacoCommandId: 'actions.quickOpen' },
|
||||
{ combo: 'Ctrl+Shift+F', label: '编辑器全局查找', context: 'monaco', monacoCommandId: 'actions.quickOpenNavigate' },
|
||||
{ combo: 'Meta+Shift+F', label: '编辑器全局查找', context: 'monaco', monacoCommandId: 'actions.quickOpenNavigate' },
|
||||
{ combo: 'Ctrl+D', label: '编辑器添加选区', context: 'monaco', monacoCommandId: 'editor.action.addSelectionToNextFindMatch' },
|
||||
{ combo: 'Meta+D', label: '编辑器添加选区', context: 'monaco', monacoCommandId: 'editor.action.addSelectionToNextFindMatch' },
|
||||
{ combo: 'Ctrl+Shift+K', label: '编辑器删除行', context: 'monaco', monacoCommandId: 'editor.action.deleteLines' },
|
||||
{ combo: 'Meta+Shift+K', label: '编辑器删除行', context: 'monaco', monacoCommandId: 'editor.action.deleteLines' },
|
||||
{ combo: 'Ctrl+Enter', label: '编辑器在下方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineAfter' },
|
||||
{ combo: 'Meta+Enter', label: '编辑器在下方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineAfter' },
|
||||
{ combo: 'Ctrl+Shift+Enter', label: '编辑器在上方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineBefore' },
|
||||
{ combo: 'Meta+Shift+Enter', label: '编辑器在上方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineBefore' },
|
||||
{ combo: 'F2', label: '编辑器重命名符号', context: 'monaco', monacoCommandId: 'editor.action.rename' },
|
||||
|
||||
// DataGrid shortcuts
|
||||
{ combo: 'Ctrl+C', label: '数据表格复制', context: 'datagrid' },
|
||||
{ combo: 'Meta+C', label: '数据表格复制', context: 'datagrid' },
|
||||
];
|
||||
|
||||
const CONTEXT_DESCRIPTION: Record<ConflictContext, string> = {
|
||||
global: '浏览器',
|
||||
monaco: '编辑器',
|
||||
datagrid: '数据表格',
|
||||
};
|
||||
|
||||
export const describeConflictContext = (context: ConflictContext): string => {
|
||||
return CONTEXT_DESCRIPTION[context] || context;
|
||||
};
|
||||
|
||||
export const splitConflictsByContext = (conflicts: ConflictInfo[]) => {
|
||||
const monaco = conflicts.filter(c => c.context === 'monaco');
|
||||
const other = conflicts.filter(c => c.context !== 'monaco');
|
||||
const dedupe = (items: ConflictInfo[], fn: (c: ConflictInfo) => string) =>
|
||||
[...new Set(items.map(fn))].join('、');
|
||||
return {
|
||||
monacoLabels: dedupe(monaco, c => c.label),
|
||||
otherLabels: dedupe(other, c => c.label),
|
||||
otherContexts: dedupe(other, c => describeConflictContext(c.context)),
|
||||
hasMonaco: monaco.length > 0,
|
||||
hasOther: other.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const findReservedConflict = (normalizedCombo: string): ConflictInfo | null => {
|
||||
const conflict = RESERVED_SHORTCUTS.find((r) => r.combo === normalizedCombo);
|
||||
if (!conflict) return null;
|
||||
return { label: conflict.label, context: conflict.context, monacoCommandId: conflict.monacoCommandId };
|
||||
};
|
||||
|
||||
export const findReservedConflicts = (normalizedCombo: string): ConflictInfo[] => {
|
||||
return RESERVED_SHORTCUTS
|
||||
.filter((r) => r.combo === normalizedCombo)
|
||||
.map((r) => ({ label: r.label, context: r.context, monacoCommandId: r.monacoCommandId }));
|
||||
};
|
||||
|
||||
export interface MonacoKeyBinding {
|
||||
keyMod: number;
|
||||
keyCode: number;
|
||||
}
|
||||
|
||||
/** Map key token (after normalization) to a function that returns KeyCode.
|
||||
* The function receives the KeyCode enum to avoid importing monaco at module level. */
|
||||
type KeyCodeResolver = (kc: Record<string, number>) => number;
|
||||
|
||||
const MONACO_KEY_MAP: Record<string, KeyCodeResolver> = {
|
||||
Enter: (kc) => kc.Enter,
|
||||
Tab: (kc) => kc.Tab,
|
||||
Esc: (kc) => kc.Escape,
|
||||
Space: (kc) => kc.Space,
|
||||
Backspace: (kc) => kc.Backspace,
|
||||
Delete: (kc) => kc.Delete,
|
||||
Home: (kc) => kc.Home,
|
||||
End: (kc) => kc.End,
|
||||
PageUp: (kc) => kc.PageUp,
|
||||
PageDown: (kc) => kc.PageDown,
|
||||
Up: (kc) => kc.UpArrow,
|
||||
Down: (kc) => kc.DownArrow,
|
||||
Left: (kc) => kc.LeftArrow,
|
||||
Right: (kc) => kc.RightArrow,
|
||||
Insert: (kc) => kc.Insert,
|
||||
'/': (kc) => kc.Oem2,
|
||||
',': (kc) => kc.OemComma,
|
||||
'-': (kc) => kc.OemMinus,
|
||||
'=': (kc) => kc.OemPlus,
|
||||
'.': (kc) => kc.OemPeriod,
|
||||
';': (kc) => kc.Oem1,
|
||||
"'": (kc) => kc.Oem7,
|
||||
'[': (kc) => kc.Oem4,
|
||||
']': (kc) => kc.Oem6,
|
||||
'\\': (kc) => kc.Oem5,
|
||||
'`': (kc) => kc.Oem3,
|
||||
};
|
||||
|
||||
function resolveKeyCode(token: string, kc: Record<string, number>): number | null {
|
||||
// F1-F12
|
||||
const fMatch = token.match(/^F([1-9]|1[0-2])$/);
|
||||
if (fMatch) {
|
||||
return kc['F' + fMatch[1]] ?? null;
|
||||
}
|
||||
// A-Z
|
||||
if (/^[A-Z]$/.test(token)) {
|
||||
return kc['Key' + token] ?? null;
|
||||
}
|
||||
// 0-9
|
||||
if (/^[0-9]$/.test(token)) {
|
||||
return kc['Digit' + token] ?? null;
|
||||
}
|
||||
// Special keys map
|
||||
const resolver = MONACO_KEY_MAP[token];
|
||||
if (resolver) {
|
||||
return resolver(kc);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const comboToMonacoKeyBinding = (
|
||||
combo: string,
|
||||
keyModEnum: Record<string, number>,
|
||||
keyCodeEnum: Record<string, number>,
|
||||
): MonacoKeyBinding | null => {
|
||||
const normalized = normalizeShortcutCombo(combo);
|
||||
if (!normalized) return null;
|
||||
|
||||
const pieces = normalized.split('+');
|
||||
let keyMod = 0;
|
||||
let keyCode: number | null = null;
|
||||
|
||||
for (const piece of pieces) {
|
||||
if (piece === 'Ctrl') {
|
||||
keyMod |= keyModEnum.CtrlCmd ?? 0;
|
||||
} else if (piece === 'Meta') {
|
||||
keyMod |= keyModEnum.WinCtrl ?? 0;
|
||||
} else if (piece === 'Alt') {
|
||||
keyMod |= keyModEnum.Alt ?? 0;
|
||||
} else if (piece === 'Shift') {
|
||||
keyMod |= keyModEnum.Shift ?? 0;
|
||||
} else {
|
||||
keyCode = resolveKeyCode(piece, keyCodeEnum);
|
||||
}
|
||||
}
|
||||
|
||||
if (keyCode == null) return null;
|
||||
return { keyMod, keyCode };
|
||||
};
|
||||
|
||||
|
||||
163
frontend/src/utils/sidebarLocate.test.ts
Normal file
163
frontend/src/utils/sidebarLocate.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findSidebarNodePathByKey,
|
||||
findSidebarNodePathForLocate,
|
||||
normalizeSidebarLocateObjectRequest,
|
||||
normalizeSidebarLocateObjectRequestFromTab,
|
||||
resolveSidebarLocateTarget,
|
||||
} from './sidebarLocate';
|
||||
|
||||
describe('sidebarLocate', () => {
|
||||
it('normalizes a table locate request and builds the direct tree path', () => {
|
||||
const request = normalizeSidebarLocateObjectRequest({
|
||||
tabId: 'conn-1-main-users',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'users',
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
tabId: 'conn-1-main-users',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'users',
|
||||
schemaName: '',
|
||||
objectGroup: 'tables',
|
||||
});
|
||||
|
||||
expect(resolveSidebarLocateTarget(request!, { groupBySchema: false })).toMatchObject({
|
||||
targetKey: 'conn-1-main-users',
|
||||
expectedAncestorKeys: ['conn-1', 'conn-1-main', 'conn-1-main-tables'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps view tabs on the views branch and includes schema ancestors', () => {
|
||||
const request = normalizeSidebarLocateObjectRequest({
|
||||
tabId: 'conn-1-main-view-public.orders_view',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'public.orders_view',
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
objectGroup: 'views',
|
||||
schemaName: 'public',
|
||||
});
|
||||
|
||||
expect(resolveSidebarLocateTarget(request!, { groupBySchema: true })).toMatchObject({
|
||||
targetKey: 'conn-1-main-view-public.orders_view',
|
||||
schemaKey: 'conn-1-main-schema-public',
|
||||
objectGroupKey: 'conn-1-main-schema-public-views',
|
||||
expectedAncestorKeys: [
|
||||
'conn-1',
|
||||
'conn-1-main',
|
||||
'conn-1-main-schema-public',
|
||||
'conn-1-main-schema-public-views',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a locate request from the active table tab', () => {
|
||||
expect(normalizeSidebarLocateObjectRequestFromTab({
|
||||
id: 'conn-1-main-public.users',
|
||||
type: 'table',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'public.users',
|
||||
})).toMatchObject({
|
||||
tabId: 'conn-1-main-public.users',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'public.users',
|
||||
schemaName: 'public',
|
||||
objectGroup: 'tables',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a view locate request from view tabs and rejects non-object tabs', () => {
|
||||
expect(normalizeSidebarLocateObjectRequestFromTab({
|
||||
id: 'view-def-conn-1-main-public.orders_view',
|
||||
type: 'view-def',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
viewName: 'public.orders_view',
|
||||
})).toMatchObject({
|
||||
tableName: 'public.orders_view',
|
||||
schemaName: 'public',
|
||||
objectGroup: 'views',
|
||||
});
|
||||
|
||||
expect(normalizeSidebarLocateObjectRequestFromTab({
|
||||
id: 'query-1',
|
||||
type: 'query',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('finds a locate path from loaded tree data even when the target key is absent', () => {
|
||||
const target = resolveSidebarLocateTarget(
|
||||
{
|
||||
tabId: 'stale-tab-id',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'public.users',
|
||||
schemaName: 'public',
|
||||
objectGroup: 'tables',
|
||||
},
|
||||
{ groupBySchema: true },
|
||||
);
|
||||
|
||||
const tree = [
|
||||
{
|
||||
key: 'conn-1',
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-main',
|
||||
dataRef: { id: 'conn-1', dbName: 'main' },
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-main-schema-public',
|
||||
dataRef: { id: 'conn-1', dbName: 'main', schemaName: 'public' },
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-main-schema-public-tables',
|
||||
dataRef: { id: 'conn-1', dbName: 'main', groupKey: 'tables', schemaName: 'public' },
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-main-public.users',
|
||||
type: 'table',
|
||||
dataRef: {
|
||||
id: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'public.users',
|
||||
schemaName: 'public',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(findSidebarNodePathByKey(tree, 'conn-1-main-public.users')).toEqual([
|
||||
'conn-1',
|
||||
'conn-1-main',
|
||||
'conn-1-main-schema-public',
|
||||
'conn-1-main-schema-public-tables',
|
||||
'conn-1-main-public.users',
|
||||
]);
|
||||
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
|
||||
'conn-1',
|
||||
'conn-1-main',
|
||||
'conn-1-main-schema-public',
|
||||
'conn-1-main-schema-public-tables',
|
||||
'conn-1-main-public.users',
|
||||
]);
|
||||
});
|
||||
});
|
||||
221
frontend/src/utils/sidebarLocate.ts
Normal file
221
frontend/src/utils/sidebarLocate.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
export type SidebarLocateObjectGroup = 'tables' | 'views';
|
||||
|
||||
export interface SidebarLocateObjectRequest {
|
||||
tabId?: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
tableName: string;
|
||||
schemaName?: string;
|
||||
objectGroup: SidebarLocateObjectGroup;
|
||||
}
|
||||
|
||||
export interface SidebarLocateTarget {
|
||||
connectionKey: string;
|
||||
databaseKey: string;
|
||||
targetKey: string;
|
||||
objectGroup: SidebarLocateObjectGroup;
|
||||
objectGroupKey: string;
|
||||
schemaKey?: string;
|
||||
expectedAncestorKeys: string[];
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
tableName: string;
|
||||
schemaName: string;
|
||||
}
|
||||
|
||||
export interface SidebarLocateTreeNodeLike {
|
||||
key: string | number;
|
||||
type?: string;
|
||||
dataRef?: Record<string, any>;
|
||||
children?: SidebarLocateTreeNodeLike[];
|
||||
}
|
||||
|
||||
export interface SidebarLocateTabLike {
|
||||
id?: string;
|
||||
type?: string;
|
||||
connectionId?: string;
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
viewName?: string;
|
||||
}
|
||||
|
||||
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
|
||||
|
||||
export const splitSidebarQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
|
||||
const raw = toTrimmedString(qualifiedName);
|
||||
if (!raw) return { schemaName: '', objectName: '' };
|
||||
const idx = raw.lastIndexOf('.');
|
||||
if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw };
|
||||
return {
|
||||
schemaName: raw.substring(0, idx).trim(),
|
||||
objectName: raw.substring(idx + 1).trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const inferObjectGroup = (detail: Record<string, unknown>, connectionId: string, dbName: string): SidebarLocateObjectGroup => {
|
||||
const explicitGroup = toTrimmedString(detail.objectGroup);
|
||||
if (explicitGroup === 'views' || explicitGroup === 'view') return 'views';
|
||||
|
||||
const explicitType = toTrimmedString(detail.objectType);
|
||||
if (explicitType === 'view' || explicitType === 'views') return 'views';
|
||||
|
||||
const tabId = toTrimmedString(detail.tabId);
|
||||
const dbNodeKey = `${connectionId}-${dbName}`;
|
||||
if (tabId.startsWith(`${dbNodeKey}-view-`)) return 'views';
|
||||
|
||||
return 'tables';
|
||||
};
|
||||
|
||||
export const normalizeSidebarLocateObjectRequest = (detail: unknown): SidebarLocateObjectRequest | null => {
|
||||
const raw = (detail || {}) as Record<string, unknown>;
|
||||
const connectionId = toTrimmedString(raw.connectionId);
|
||||
const dbName = toTrimmedString(raw.dbName);
|
||||
const tableName = toTrimmedString(raw.tableName || raw.objectName || raw.viewName);
|
||||
|
||||
if (!connectionId || !dbName || !tableName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = splitSidebarQualifiedName(tableName);
|
||||
const schemaName = toTrimmedString(raw.schemaName) || parsed.schemaName;
|
||||
|
||||
return {
|
||||
tabId: toTrimmedString(raw.tabId) || undefined,
|
||||
connectionId,
|
||||
dbName,
|
||||
tableName,
|
||||
schemaName,
|
||||
objectGroup: inferObjectGroup(raw, connectionId, dbName),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeSidebarLocateObjectRequestFromTab = (tab: SidebarLocateTabLike | null | undefined): SidebarLocateObjectRequest | null => {
|
||||
if (!tab) return null;
|
||||
const objectName = tab.type === 'view-def'
|
||||
? toTrimmedString(tab.viewName || tab.tableName)
|
||||
: toTrimmedString(tab.tableName || tab.viewName);
|
||||
if (tab.type !== 'table' && tab.type !== 'view-def') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSidebarLocateObjectRequest({
|
||||
tabId: tab.id,
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
tableName: objectName,
|
||||
objectGroup: tab.type === 'view-def' ? 'views' : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export const resolveSidebarLocateTarget = (
|
||||
request: SidebarLocateObjectRequest,
|
||||
options: { groupBySchema: boolean },
|
||||
): SidebarLocateTarget => {
|
||||
const connectionKey = request.connectionId;
|
||||
const databaseKey = `${request.connectionId}-${request.dbName}`;
|
||||
const fallbackTargetKey = request.objectGroup === 'views'
|
||||
? `${databaseKey}-view-${request.tableName}`
|
||||
: `${databaseKey}-${request.tableName}`;
|
||||
const targetKey = request.tabId || fallbackTargetKey;
|
||||
const schemaSegment = request.schemaName || 'default';
|
||||
const schemaKey = options.groupBySchema ? `${databaseKey}-schema-${schemaSegment}` : undefined;
|
||||
const objectGroupKey = options.groupBySchema
|
||||
? `${schemaKey}-${request.objectGroup}`
|
||||
: `${databaseKey}-${request.objectGroup}`;
|
||||
const expectedAncestorKeys = [
|
||||
connectionKey,
|
||||
databaseKey,
|
||||
...(schemaKey ? [schemaKey] : []),
|
||||
objectGroupKey,
|
||||
];
|
||||
|
||||
return {
|
||||
connectionKey,
|
||||
databaseKey,
|
||||
targetKey,
|
||||
objectGroup: request.objectGroup,
|
||||
objectGroupKey,
|
||||
schemaKey,
|
||||
expectedAncestorKeys,
|
||||
connectionId: request.connectionId,
|
||||
dbName: request.dbName,
|
||||
tableName: request.tableName,
|
||||
schemaName: request.schemaName || '',
|
||||
};
|
||||
};
|
||||
|
||||
export const findSidebarNodePathByKey = (
|
||||
nodes: SidebarLocateTreeNodeLike[],
|
||||
targetKey: string,
|
||||
): string[] | null => {
|
||||
for (const node of nodes) {
|
||||
const nodeKey = String(node.key);
|
||||
if (nodeKey === targetKey) {
|
||||
return [nodeKey];
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const childPath = findSidebarNodePathByKey(node.children, targetKey);
|
||||
if (childPath) {
|
||||
return [nodeKey, ...childPath];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const matchesLocateObjectName = (target: SidebarLocateTarget, nodeObjectName: string, nodeSchemaName: string): boolean => {
|
||||
const normalizedNodeName = toTrimmedString(nodeObjectName);
|
||||
if (!normalizedNodeName) return false;
|
||||
if (normalizedNodeName === target.tableName) return true;
|
||||
|
||||
if (!target.schemaName) return false;
|
||||
|
||||
const nodeParsed = splitSidebarQualifiedName(normalizedNodeName);
|
||||
const targetParsed = splitSidebarQualifiedName(target.tableName);
|
||||
const nodeObject = nodeParsed.objectName || normalizedNodeName;
|
||||
const targetObject = targetParsed.objectName || target.tableName;
|
||||
const resolvedNodeSchema = toTrimmedString(nodeSchemaName) || nodeParsed.schemaName;
|
||||
return resolvedNodeSchema === target.schemaName && nodeObject === targetObject;
|
||||
};
|
||||
|
||||
const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: SidebarLocateTarget): boolean => {
|
||||
const dataRef = node.dataRef || {};
|
||||
const nodeConnectionId = toTrimmedString(dataRef.id || dataRef.connectionId);
|
||||
const nodeDbName = toTrimmedString(dataRef.dbName);
|
||||
|
||||
if (nodeConnectionId !== target.connectionId || nodeDbName !== target.dbName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.objectGroup === 'views') {
|
||||
if (node.type !== 'view') return false;
|
||||
return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName));
|
||||
}
|
||||
|
||||
if (node.type !== 'table') return false;
|
||||
return matchesLocateObjectName(target, toTrimmedString(dataRef.tableName), toTrimmedString(dataRef.schemaName));
|
||||
};
|
||||
|
||||
export const findSidebarNodePathForLocate = (
|
||||
nodes: SidebarLocateTreeNodeLike[],
|
||||
target: SidebarLocateTarget,
|
||||
): string[] | null => {
|
||||
const exactPath = findSidebarNodePathByKey(nodes, target.targetKey);
|
||||
if (exactPath) return exactPath;
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeKey = String(node.key);
|
||||
if (matchesLocateObjectNode(node, target)) {
|
||||
return [nodeKey];
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const childPath = findSidebarNodePathForLocate(node.children, target);
|
||||
if (childPath) {
|
||||
return [nodeKey, ...childPath];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -11,15 +11,21 @@ const splitQualifiedName = (qualifiedName: string): { schemaName: string; object
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSidebarConnectionDialect = (type: string, driver: string): string => {
|
||||
const normalizeSidebarConnectionDialect = (type: string, driver: string, oceanBaseProtocol?: string): string => {
|
||||
const normalizedType = String(type || '').trim().toLowerCase();
|
||||
if (normalizedType === 'custom') {
|
||||
const normalizedDriver = String(driver || '').trim().toLowerCase();
|
||||
if (normalizedDriver === 'postgresql' || normalizedDriver === 'postgres' || normalizedDriver === 'pg') return 'postgres';
|
||||
if (normalizedDriver === 'opengauss' || normalizedDriver === 'open_gauss' || normalizedDriver === 'open-gauss') return 'opengauss';
|
||||
if (normalizedDriver === 'dameng' || normalizedDriver === 'dm' || normalizedDriver === 'dm8') return 'dm';
|
||||
if (normalizedDriver === 'oceanbase') return 'mysql';
|
||||
if (normalizedDriver.includes('oracle')) return 'oracle';
|
||||
return normalizedDriver;
|
||||
}
|
||||
if (normalizedType === 'oceanbase') {
|
||||
return String(oceanBaseProtocol || '').trim().toLowerCase() === 'oracle' ? 'oracle' : 'mysql';
|
||||
}
|
||||
if (normalizedType === 'open_gauss' || normalizedType === 'open-gauss') return 'opengauss';
|
||||
if (normalizedType === 'dameng') return 'dm';
|
||||
return normalizedType;
|
||||
};
|
||||
@@ -55,6 +61,7 @@ export const resolveSidebarRuntimeDatabase = (
|
||||
savedDatabase: string,
|
||||
overrideDatabase?: string,
|
||||
clearDatabase: boolean = false,
|
||||
oceanBaseProtocol?: string,
|
||||
): string => {
|
||||
if (clearDatabase) return '';
|
||||
|
||||
@@ -64,7 +71,7 @@ export const resolveSidebarRuntimeDatabase = (
|
||||
return normalizedSavedDatabase;
|
||||
}
|
||||
|
||||
const dialect = normalizeSidebarConnectionDialect(type, driver);
|
||||
const dialect = normalizeSidebarConnectionDialect(type, driver, oceanBaseProtocol);
|
||||
if (dialect === 'oracle' || dialect === 'dm') {
|
||||
return normalizedSavedDatabase || normalizedOverrideDatabase;
|
||||
}
|
||||
|
||||
13
frontend/src/utils/sql.test.ts
Normal file
13
frontend/src/utils/sql.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildOrderBySQL } from './sql';
|
||||
|
||||
describe('buildOrderBySQL', () => {
|
||||
it('does not add fallback ORDER BY for DuckDB without explicit sort', () => {
|
||||
expect(buildOrderBySQL('duckdb', [], ['ID'])).toBe('');
|
||||
});
|
||||
|
||||
it('keeps explicit DuckDB sort', () => {
|
||||
expect(buildOrderBySQL('duckdb', { columnKey: 'ID', order: 'descend' }, ['NAME'])).toBe(' ORDER BY "ID" DESC');
|
||||
});
|
||||
});
|
||||
@@ -37,12 +37,12 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
|
||||
if (!raw) return raw;
|
||||
const dbTypeLower = (dbType || '').toLowerCase();
|
||||
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
|
||||
return `\`${raw.replace(/`/g, '``')}\``;
|
||||
}
|
||||
|
||||
// 对于 KingBase/PostgreSQL,只在必要时加引号
|
||||
if (dbTypeLower === 'kingbase' || dbTypeLower === 'postgres') {
|
||||
if (dbTypeLower === 'kingbase' || dbTypeLower === 'postgres' || dbTypeLower === 'opengauss') {
|
||||
if (needsQuote(raw)) {
|
||||
return `"${raw.replace(/"/g, '""')}"`;
|
||||
}
|
||||
@@ -150,10 +150,10 @@ export const buildOrderBySQL = (
|
||||
return ` ORDER BY ${sortParts.join(', ')}`;
|
||||
}
|
||||
|
||||
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY(即使按主键)可能触发 filesort,
|
||||
// 导致 `Error 1038 (HY001): Out of sort memory`。
|
||||
// 部分数据源在无显式排序需求时强制 ORDER BY(即使按主键)会显著放大大表预览成本:
|
||||
// MySQL/MariaDB 可能触发 filesort 和 sort memory 错误,DuckDB 大文件可能被排序拖到连接超时。
|
||||
// 因此仅在用户主动点击排序时下发 ORDER BY,默认分页查询不加兜底排序。
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros') {
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'duckdb') {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,17 @@ const names = (items: Array<{ name: string }>) => items.map((item) => item.name)
|
||||
describe('sqlDialect', () => {
|
||||
it('normalizes datasource aliases without collapsing all dialects to mysql', () => {
|
||||
expect(resolveSqlDialect('postgresql')).toBe('postgres');
|
||||
expect(resolveSqlDialect('OpenGauss')).toBe('opengauss');
|
||||
expect(resolveSqlDialect('OceanBase')).toBe('oceanbase');
|
||||
expect(resolveSqlDialect('doris')).toBe('diros');
|
||||
expect(resolveSqlDialect('dameng')).toBe('dameng');
|
||||
expect(resolveSqlDialect('custom', 'kingbase8')).toBe('kingbase');
|
||||
expect(resolveSqlDialect('custom', 'dm8')).toBe('dameng');
|
||||
expect(resolveSqlDialect('custom', 'mariadb')).toBe('mariadb');
|
||||
expect(resolveSqlDialect('custom', 'open_gauss')).toBe('opengauss');
|
||||
expect(resolveSqlDialect('OceanBase', '', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
|
||||
expect(isMysqlFamilyDialect('mariadb')).toBe(true);
|
||||
expect(isMysqlFamilyDialect('oceanbase')).toBe(true);
|
||||
expect(isMysqlFamilyDialect('oracle')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -28,6 +33,8 @@ describe('sqlDialect', () => {
|
||||
expect(values(resolveColumnTypeOptions('oracle'))).not.toContain('tinyint(1)');
|
||||
expect(values(resolveColumnTypeOptions('dameng'))).toContain('VARCHAR2(255)');
|
||||
expect(values(resolveColumnTypeOptions('kingbase'))).toContain('integer');
|
||||
expect(values(resolveColumnTypeOptions('opengauss'))).toContain('integer');
|
||||
expect(values(resolveColumnTypeOptions('oceanbase'))).toContain('varchar(255)');
|
||||
expect(values(resolveColumnTypeOptions('kingbase'))).not.toContain('tinyint(1)');
|
||||
expect(values(resolveColumnTypeOptions('diros'))).toContain('LARGEINT');
|
||||
expect(values(resolveColumnTypeOptions('sphinx'))).toContain('text');
|
||||
|
||||
@@ -8,12 +8,14 @@ export type SqlFunctionCompletion = {
|
||||
export type SqlDialect =
|
||||
| 'mysql'
|
||||
| 'mariadb'
|
||||
| 'oceanbase'
|
||||
| 'diros'
|
||||
| 'sphinx'
|
||||
| 'postgres'
|
||||
| 'kingbase'
|
||||
| 'highgo'
|
||||
| 'vastbase'
|
||||
| 'opengauss'
|
||||
| 'oracle'
|
||||
| 'dameng'
|
||||
| 'sqlserver'
|
||||
@@ -32,12 +34,23 @@ const optionValues = (values: string[]): ColumnTypeOption[] => values.map((value
|
||||
|
||||
const normalizeRawDialect = (value: string): string => String(value || '').trim().toLowerCase();
|
||||
|
||||
export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect => {
|
||||
export const normalizeOceanBaseSqlProtocol = (value: unknown): 'mysql' | 'oracle' => (
|
||||
String(value || '').trim().toLowerCase() === 'oracle' ? 'oracle' : 'mysql'
|
||||
);
|
||||
|
||||
export const resolveSqlDialect = (
|
||||
rawType: string,
|
||||
rawDriver = '',
|
||||
options?: { oceanBaseProtocol?: unknown },
|
||||
): SqlDialect => {
|
||||
const normalized = normalizeRawDialect(rawType);
|
||||
const driver = normalizeRawDialect(rawDriver);
|
||||
const source = normalized === 'custom' ? driver : normalized;
|
||||
|
||||
if (!source) return 'unknown';
|
||||
if (source === 'oceanbase' && normalizeOceanBaseSqlProtocol(options?.oceanBaseProtocol) === 'oracle') {
|
||||
return 'oracle';
|
||||
}
|
||||
|
||||
switch (source) {
|
||||
case 'postgresql':
|
||||
@@ -46,6 +59,10 @@ export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect =
|
||||
case 'pq':
|
||||
case 'pgx':
|
||||
return 'postgres';
|
||||
case 'opengauss':
|
||||
case 'open_gauss':
|
||||
case 'open-gauss':
|
||||
return 'opengauss';
|
||||
case 'mssql':
|
||||
case 'sql_server':
|
||||
case 'sql-server':
|
||||
@@ -67,6 +84,7 @@ export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect =
|
||||
case 'kingbasev8':
|
||||
return 'kingbase';
|
||||
case 'mariadb':
|
||||
case 'oceanbase':
|
||||
case 'mysql':
|
||||
case 'sphinx':
|
||||
case 'kingbase':
|
||||
@@ -83,7 +101,9 @@ export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect =
|
||||
break;
|
||||
}
|
||||
|
||||
if (source.includes('opengauss') || source.includes('open_gauss') || source.includes('open-gauss')) return 'opengauss';
|
||||
if (source.includes('postgres')) return 'postgres';
|
||||
if (source.includes('oceanbase')) return 'oceanbase';
|
||||
if (source.includes('mariadb')) return 'mariadb';
|
||||
if (source.includes('mysql')) return 'mysql';
|
||||
if (source.includes('doris') || source.includes('diros')) return 'diros';
|
||||
@@ -103,11 +123,11 @@ export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect =
|
||||
};
|
||||
|
||||
export const isMysqlFamilyDialect = (dbType: string): boolean => (
|
||||
['mysql', 'mariadb', 'diros', 'sphinx', 'tidb', 'oceanbase', 'starrocks'].includes(resolveSqlDialect(dbType))
|
||||
['mysql', 'mariadb', 'oceanbase', 'diros', 'sphinx', 'tidb', 'starrocks'].includes(resolveSqlDialect(dbType))
|
||||
);
|
||||
|
||||
export const isPgLikeDialect = (dbType: string): boolean => (
|
||||
['postgres', 'kingbase', 'highgo', 'vastbase'].includes(resolveSqlDialect(dbType))
|
||||
['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(resolveSqlDialect(dbType))
|
||||
);
|
||||
|
||||
export const isOracleLikeDialect = (dbType: string): boolean => (
|
||||
@@ -423,9 +443,9 @@ const COMMON_TYPES = optionValues(['int', 'varchar(255)', 'text', 'datetime', 'd
|
||||
|
||||
export const resolveColumnTypeOptions = (dbType: string): ColumnTypeOption[] => {
|
||||
const dialect = resolveSqlDialect(dbType);
|
||||
if (dialect === 'mariadb' || dialect === 'mysql') return MYSQL_TYPES;
|
||||
if (dialect === 'diros') return DORIS_TYPES;
|
||||
if (dialect === 'sphinx') return SPHINX_TYPES;
|
||||
if (isMysqlFamilyDialect(dialect)) return MYSQL_TYPES;
|
||||
if (isPgLikeDialect(dialect)) return PG_TYPES;
|
||||
if (dialect === 'oracle') return ORACLE_TYPES;
|
||||
if (dialect === 'dameng') return DAMENG_TYPES;
|
||||
|
||||
73
frontend/src/utils/sqlSnippetDefaults.test.ts
Normal file
73
frontend/src/utils/sqlSnippetDefaults.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DEFAULT_SQL_SNIPPETS, BUILTIN_SNIPPET_MAP } from './sqlSnippetDefaults';
|
||||
import type { SqlSnippet } from '../types';
|
||||
|
||||
describe('sqlSnippetDefaults', () => {
|
||||
it('DEFAULT_SQL_SNIPPETS should be a non-empty array', () => {
|
||||
expect(Array.isArray(DEFAULT_SQL_SNIPPETS)).toBe(true);
|
||||
expect(DEFAULT_SQL_SNIPPETS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every default snippet should have required fields', () => {
|
||||
for (const s of DEFAULT_SQL_SNIPPETS) {
|
||||
expect(s.id).toBeTruthy();
|
||||
expect(s.prefix).toBeTruthy();
|
||||
expect(s.name).toBeTruthy();
|
||||
expect(s.body).toBeTruthy();
|
||||
expect(s.isBuiltin).toBe(true);
|
||||
expect(typeof s.createdAt).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
it('every prefix should be lowercase alphanumeric/underscore', () => {
|
||||
for (const s of DEFAULT_SQL_SNIPPETS) {
|
||||
expect(s.prefix).toMatch(/^[a-z0-9_]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('prefixes should be unique', () => {
|
||||
const prefixes = DEFAULT_SQL_SNIPPETS.map((s) => s.prefix);
|
||||
expect(new Set(prefixes).size).toBe(prefixes.length);
|
||||
});
|
||||
|
||||
it('ids should be unique', () => {
|
||||
const ids = DEFAULT_SQL_SNIPPETS.map((s) => s.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('all default snippets should have snippet syntax in body', () => {
|
||||
for (const s of DEFAULT_SQL_SNIPPETS) {
|
||||
const hasTabStopOrVariable = /\$\d|\$\{|CURRENT_/.test(s.body);
|
||||
expect(hasTabStopOrVariable).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('time-variable snippets should contain CURRENT_ markers', () => {
|
||||
const seld = DEFAULT_SQL_SNIPPETS.find((s) => s.prefix === 'seld');
|
||||
expect(seld).toBeDefined();
|
||||
expect(seld!.body).toContain('CURRENT_YEAR');
|
||||
expect(seld!.body).toContain('CURRENT_MONTH');
|
||||
expect(seld!.body).toContain('CURRENT_DATE');
|
||||
|
||||
const inst = DEFAULT_SQL_SNIPPETS.find((s) => s.prefix === 'inst');
|
||||
expect(inst).toBeDefined();
|
||||
expect(inst!.body).toContain('CURRENT_HOUR');
|
||||
expect(inst!.body).toContain('CURRENT_MINUTE');
|
||||
expect(inst!.body).toContain('CURRENT_SECOND');
|
||||
});
|
||||
|
||||
it('BUILTIN_SNIPPET_MAP should contain all default snippet ids', () => {
|
||||
for (const s of DEFAULT_SQL_SNIPPETS) {
|
||||
expect(BUILTIN_SNIPPET_MAP[s.id]).toBeDefined();
|
||||
expect(BUILTIN_SNIPPET_MAP[s.id].prefix).toBe(s.prefix);
|
||||
expect(BUILTIN_SNIPPET_MAP[s.id].body).toBe(s.body);
|
||||
}
|
||||
});
|
||||
|
||||
it('BUILTIN_SNIPPET_MAP entries should be independent copies', () => {
|
||||
for (const s of DEFAULT_SQL_SNIPPETS) {
|
||||
const mapped = BUILTIN_SNIPPET_MAP[s.id];
|
||||
expect(mapped).not.toBe(s);
|
||||
}
|
||||
});
|
||||
});
|
||||
154
frontend/src/utils/sqlSnippetDefaults.ts
Normal file
154
frontend/src/utils/sqlSnippetDefaults.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { SqlSnippet } from "../types";
|
||||
|
||||
const builtinSnippets: Omit<SqlSnippet, "createdAt">[] = [
|
||||
{
|
||||
id: "builtin-sel",
|
||||
prefix: "sel",
|
||||
name: "SELECT 基本查询",
|
||||
description: "基本 SELECT 查询模板",
|
||||
body: "SELECT ${1:column_list} FROM ${2:table_name}$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-selw",
|
||||
prefix: "selw",
|
||||
name: "SELECT WHERE",
|
||||
description: "带 WHERE 条件的 SELECT 查询",
|
||||
body: "SELECT ${1:columns} FROM ${2:table_name} WHERE ${3:condition}$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-selj",
|
||||
prefix: "selj",
|
||||
name: "SELECT JOIN",
|
||||
description: "带 INNER JOIN 的 SELECT 查询",
|
||||
body: "SELECT ${1:columns}\nFROM ${2:t1}\nINNER JOIN ${3:t2} ON ${4:t1.id} = ${5:t2.id}\nWHERE ${6:condition}$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-ins",
|
||||
prefix: "ins",
|
||||
name: "INSERT",
|
||||
description: "INSERT 插入数据模板",
|
||||
body: "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values})$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-upd",
|
||||
prefix: "upd",
|
||||
name: "UPDATE",
|
||||
description: "UPDATE 更新数据模板",
|
||||
body: "UPDATE ${1:table_name}\nSET ${2:column} = ${3:value}\nWHERE ${4:condition}$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-del",
|
||||
prefix: "del",
|
||||
name: "DELETE",
|
||||
description: "DELETE 删除数据模板",
|
||||
body: "DELETE FROM ${1:table_name}\nWHERE ${2:condition}$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-ct",
|
||||
prefix: "ct",
|
||||
name: "CREATE TABLE",
|
||||
description: "CREATE TABLE 建表模板",
|
||||
body: "CREATE TABLE ${1:table_name} (\n ${2:id} INT PRIMARY KEY AUTO_INCREMENT,\n ${3:col} ${4:VARCHAR(255)} NOT NULL\n)$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-alt",
|
||||
prefix: "alt",
|
||||
name: "ALTER TABLE",
|
||||
description: "ALTER TABLE 添加列模板",
|
||||
body: "ALTER TABLE ${1:table_name}\nADD COLUMN ${2:col} ${3:VARCHAR(255)}$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-dro",
|
||||
prefix: "dro",
|
||||
name: "DROP TABLE",
|
||||
description: "DROP TABLE 删表模板",
|
||||
body: "DROP TABLE IF EXISTS ${1:table_name}$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-grp",
|
||||
prefix: "grp",
|
||||
name: "GROUP BY",
|
||||
description: "带 GROUP BY 的聚合查询模板",
|
||||
body: "SELECT ${1:col}, COUNT(*)\nFROM ${2:table_name}\nGROUP BY ${1:col}$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-ljo",
|
||||
prefix: "ljo",
|
||||
name: "LEFT JOIN",
|
||||
description: "LEFT JOIN 左连接模板",
|
||||
body: "LEFT JOIN ${1:t} ON ${2:left.col} = ${3:right.col}$0",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-sub",
|
||||
prefix: "sub",
|
||||
name: "子查询",
|
||||
description: "IN 子查询模板",
|
||||
body: "SELECT ${1:cols}\nFROM ${2:t1}\nWHERE ${3:col} IN (\n SELECT ${4:col} FROM ${5:t2} WHERE ${6:cond}\n)$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-lim",
|
||||
prefix: "lim",
|
||||
name: "LIMIT 查询",
|
||||
description: "带 LIMIT 的分页查询模板",
|
||||
body: "SELECT ${1:cols} FROM ${2:table_name} LIMIT ${3:10}$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-ord",
|
||||
prefix: "ord",
|
||||
name: "ORDER BY",
|
||||
description: "带排序的查询模板",
|
||||
body: "SELECT ${1:cols} FROM ${2:table_name} ORDER BY ${3:col} ${4|ASC,DESC|}$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-seld",
|
||||
prefix: "seld",
|
||||
name: "SELECT 按日期查询",
|
||||
description: "按日期条件过滤的 SELECT 查询,自动填入当天日期",
|
||||
body: "SELECT ${1:cols} FROM ${2:table_name}\nWHERE ${3:date_col} >= '${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}'$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-ctt",
|
||||
prefix: "ctt",
|
||||
name: "CREATE TABLE(含时间列)",
|
||||
description: "建表模板,含 created_at / updated_at 时间列",
|
||||
body: "CREATE TABLE ${1:table_name} (\n ${2:id} INT PRIMARY KEY AUTO_INCREMENT,\n ${3:col} ${4:VARCHAR(255)},\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n)$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
{
|
||||
id: "builtin-inst",
|
||||
prefix: "inst",
|
||||
name: "INSERT(含时间戳)",
|
||||
description: "INSERT 模板,自动填入当前时间戳",
|
||||
body: "INSERT INTO ${1:table_name} (${2:columns}, created_at)\nVALUES (${3:values}, '${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} ${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}')$0;",
|
||||
isBuiltin: true,
|
||||
},
|
||||
];
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
export const DEFAULT_SQL_SNIPPETS: SqlSnippet[] = builtinSnippets.map(
|
||||
(s, i) => ({
|
||||
...s,
|
||||
createdAt: now + i,
|
||||
})
|
||||
);
|
||||
|
||||
export const BUILTIN_SNIPPET_MAP: Record<string, SqlSnippet> = {};
|
||||
for (const s of DEFAULT_SQL_SNIPPETS) {
|
||||
BUILTIN_SNIPPET_MAP[s.id] = { ...s };
|
||||
}
|
||||
@@ -1,12 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './windowStateUi';
|
||||
import {
|
||||
resolveTitleBarToggleIconKey,
|
||||
resolveWindowsScaleCheckDelayMs,
|
||||
shouldApplyWindowsScaleFix,
|
||||
shouldToggleMaximisedWindowForScaleFix,
|
||||
} from './windowStateUi';
|
||||
|
||||
describe('windowStateUi', () => {
|
||||
it('does not re-toggle a maximized window on activation when focus returns', () => {
|
||||
expect(shouldToggleMaximisedWindowForScaleFix('activation', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('only applies the Windows scale fix on real ratio drift', () => {
|
||||
expect(shouldApplyWindowsScaleFix('activation', true)).toBe(false);
|
||||
expect(shouldApplyWindowsScaleFix('ratio-change', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('debounces resize-triggered Windows scale checks until window transitions settle', () => {
|
||||
expect(resolveWindowsScaleCheckDelayMs('resize')).toBeGreaterThan(0);
|
||||
expect(resolveWindowsScaleCheckDelayMs('focus')).toBe(0);
|
||||
expect(resolveWindowsScaleCheckDelayMs('poll')).toBe(0);
|
||||
});
|
||||
|
||||
it('switches the titlebar toggle icon to restore when the window is maximized', () => {
|
||||
expect(resolveTitleBarToggleIconKey('maximized')).toBe('restore');
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
export type WindowVisualState = 'normal' | 'maximized' | 'fullscreen';
|
||||
export type WindowScaleFixReason = 'activation' | 'ratio-change';
|
||||
export type WindowsScaleCheckTrigger = 'focus' | 'pageshow' | 'poll' | 'resize' | 'visibilitychange';
|
||||
export type TitleBarToggleIconKey = 'maximize' | 'restore';
|
||||
|
||||
export const shouldToggleMaximisedWindowForScaleFix = (
|
||||
export const shouldApplyWindowsScaleFix = (
|
||||
reason: WindowScaleFixReason,
|
||||
hasViewportScaleDrift: boolean,
|
||||
): boolean => reason === 'ratio-change' && hasViewportScaleDrift;
|
||||
|
||||
export const shouldToggleMaximisedWindowForScaleFix = shouldApplyWindowsScaleFix;
|
||||
|
||||
export const resolveWindowsScaleCheckDelayMs = (trigger: WindowsScaleCheckTrigger): number =>
|
||||
trigger === 'resize' ? 240 : 0;
|
||||
|
||||
export const resolveTitleBarToggleIconKey = (windowState: WindowVisualState): TitleBarToggleIconKey =>
|
||||
windowState === 'maximized' ? 'restore' : 'maximize';
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -180,6 +180,8 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise<connection.Quer
|
||||
|
||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function PreviewChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
|
||||
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ReadSQLFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -350,6 +350,10 @@ export function OpenSQLFile() {
|
||||
return window['go']['app']['App']['OpenSQLFile']();
|
||||
}
|
||||
|
||||
export function PreviewChanges(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['PreviewChanges'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function PreviewImportFile(arg1) {
|
||||
return window['go']['app']['App']['PreviewImportFile'](arg1);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
export namespace ai {
|
||||
|
||||
export class ToolCallFunction {
|
||||
name: string;
|
||||
arguments: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ToolCallFunction(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.name = source["name"];
|
||||
this.arguments = source["arguments"];
|
||||
}
|
||||
}
|
||||
export class ToolCall {
|
||||
id: string;
|
||||
type: string;
|
||||
// Go type: struct { Name string "json:\"name\""; Arguments string "json:\"arguments\"" }
|
||||
function: any;
|
||||
function: ToolCallFunction;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ToolCall(source);
|
||||
@@ -14,7 +27,7 @@ export namespace ai {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.type = source["type"];
|
||||
this.function = this.convertValues(source["function"], Object);
|
||||
this.function = this.convertValues(source["function"], ToolCallFunction);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -41,6 +54,7 @@ export namespace ai {
|
||||
images?: string[];
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
reasoning_content?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Message(source);
|
||||
@@ -53,6 +67,7 @@ export namespace ai {
|
||||
this.images = source["images"];
|
||||
this.tool_call_id = source["tool_call_id"];
|
||||
this.tool_calls = this.convertValues(source["tool_calls"], ToolCall);
|
||||
this.reasoning_content = source["reasoning_content"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -176,6 +191,7 @@ export namespace ai {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -667,10 +683,12 @@ export namespace connection {
|
||||
httpTunnel?: HTTPTunnelConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
connectionParams?: string;
|
||||
timeout?: number;
|
||||
redisDB?: number;
|
||||
uri?: string;
|
||||
clickHouseProtocol?: string;
|
||||
oceanBaseProtocol?: string;
|
||||
hosts?: string[];
|
||||
topology?: string;
|
||||
mysqlReplicaUser?: string;
|
||||
@@ -710,10 +728,12 @@ export namespace connection {
|
||||
this.httpTunnel = this.convertValues(source["httpTunnel"], HTTPTunnelConfig);
|
||||
this.driver = source["driver"];
|
||||
this.dsn = source["dsn"];
|
||||
this.connectionParams = source["connectionParams"];
|
||||
this.timeout = source["timeout"];
|
||||
this.redisDB = source["redisDB"];
|
||||
this.uri = source["uri"];
|
||||
this.clickHouseProtocol = source["clickHouseProtocol"];
|
||||
this.oceanBaseProtocol = source["oceanBaseProtocol"];
|
||||
this.hosts = source["hosts"];
|
||||
this.topology = source["topology"];
|
||||
this.mysqlReplicaUser = source["mysqlReplicaUser"];
|
||||
|
||||
83
frontend/wailsjs/runtime/runtime.d.ts
vendored
83
frontend/wailsjs/runtime/runtime.d.ts
vendored
@@ -246,4 +246,85 @@ export function OnFileDropOff() :void
|
||||
export function CanResolveFilePaths(): boolean;
|
||||
|
||||
// Resolves file paths for an array of files
|
||||
export function ResolveFilePaths(files: File[]): void
|
||||
export function ResolveFilePaths(files: File[]): void
|
||||
|
||||
// Notification types
|
||||
export interface NotificationOptions {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string; // macOS and Linux only
|
||||
body?: string;
|
||||
categoryId?: string;
|
||||
data?: { [key: string]: any };
|
||||
}
|
||||
|
||||
export interface NotificationAction {
|
||||
id?: string;
|
||||
title?: string;
|
||||
destructive?: boolean; // macOS-specific
|
||||
}
|
||||
|
||||
export interface NotificationCategory {
|
||||
id?: string;
|
||||
actions?: NotificationAction[];
|
||||
hasReplyField?: boolean;
|
||||
replyPlaceholder?: string;
|
||||
replyButtonTitle?: string;
|
||||
}
|
||||
|
||||
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
|
||||
// Initializes the notification service for the application.
|
||||
// This must be called before sending any notifications.
|
||||
export function InitializeNotifications(): Promise<void>;
|
||||
|
||||
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
|
||||
// Cleans up notification resources and releases any held connections.
|
||||
export function CleanupNotifications(): Promise<void>;
|
||||
|
||||
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
|
||||
// Checks if notifications are available on the current platform.
|
||||
export function IsNotificationAvailable(): Promise<boolean>;
|
||||
|
||||
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
|
||||
// Requests notification authorization from the user (macOS only).
|
||||
export function RequestNotificationAuthorization(): Promise<boolean>;
|
||||
|
||||
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
|
||||
// Checks the current notification authorization status (macOS only).
|
||||
export function CheckNotificationAuthorization(): Promise<boolean>;
|
||||
|
||||
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
|
||||
// Sends a basic notification with the given options.
|
||||
export function SendNotification(options: NotificationOptions): Promise<void>;
|
||||
|
||||
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
|
||||
// Sends a notification with action buttons. Requires a registered category.
|
||||
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
|
||||
|
||||
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
|
||||
// Registers a notification category that can be used with SendNotificationWithActions.
|
||||
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
|
||||
|
||||
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
|
||||
// Removes a previously registered notification category.
|
||||
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
|
||||
|
||||
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
|
||||
// Removes all pending notifications from the notification center.
|
||||
export function RemoveAllPendingNotifications(): Promise<void>;
|
||||
|
||||
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
|
||||
// Removes a specific pending notification by its identifier.
|
||||
export function RemovePendingNotification(identifier: string): Promise<void>;
|
||||
|
||||
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
|
||||
// Removes all delivered notifications from the notification center.
|
||||
export function RemoveAllDeliveredNotifications(): Promise<void>;
|
||||
|
||||
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
|
||||
// Removes a specific delivered notification by its identifier.
|
||||
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
|
||||
|
||||
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
|
||||
// Removes a notification by its identifier (cross-platform convenience function).
|
||||
export function RemoveNotification(identifier: string): Promise<void>;
|
||||
|
||||
@@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
|
||||
|
||||
export function ResolveFilePaths(files) {
|
||||
return window.runtime.ResolveFilePaths(files);
|
||||
}
|
||||
}
|
||||
|
||||
export function InitializeNotifications() {
|
||||
return window.runtime.InitializeNotifications();
|
||||
}
|
||||
|
||||
export function CleanupNotifications() {
|
||||
return window.runtime.CleanupNotifications();
|
||||
}
|
||||
|
||||
export function IsNotificationAvailable() {
|
||||
return window.runtime.IsNotificationAvailable();
|
||||
}
|
||||
|
||||
export function RequestNotificationAuthorization() {
|
||||
return window.runtime.RequestNotificationAuthorization();
|
||||
}
|
||||
|
||||
export function CheckNotificationAuthorization() {
|
||||
return window.runtime.CheckNotificationAuthorization();
|
||||
}
|
||||
|
||||
export function SendNotification(options) {
|
||||
return window.runtime.SendNotification(options);
|
||||
}
|
||||
|
||||
export function SendNotificationWithActions(options) {
|
||||
return window.runtime.SendNotificationWithActions(options);
|
||||
}
|
||||
|
||||
export function RegisterNotificationCategory(category) {
|
||||
return window.runtime.RegisterNotificationCategory(category);
|
||||
}
|
||||
|
||||
export function RemoveNotificationCategory(categoryId) {
|
||||
return window.runtime.RemoveNotificationCategory(categoryId);
|
||||
}
|
||||
|
||||
export function RemoveAllPendingNotifications() {
|
||||
return window.runtime.RemoveAllPendingNotifications();
|
||||
}
|
||||
|
||||
export function RemovePendingNotification(identifier) {
|
||||
return window.runtime.RemovePendingNotification(identifier);
|
||||
}
|
||||
|
||||
export function RemoveAllDeliveredNotifications() {
|
||||
return window.runtime.RemoveAllDeliveredNotifications();
|
||||
}
|
||||
|
||||
export function RemoveDeliveredNotification(identifier) {
|
||||
return window.runtime.RemoveDeliveredNotification(identifier);
|
||||
}
|
||||
|
||||
export function RemoveNotification(identifier) {
|
||||
return window.runtime.RemoveNotification(identifier);
|
||||
}
|
||||
|
||||
@@ -108,13 +108,13 @@ func (p *AnthropicProvider) Validate() error {
|
||||
// --- 请求体类型 ---
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
System string `json:"system,omitempty"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
System string `json:"system,omitempty"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// anthropicTool Anthropic 格式的工具定义
|
||||
@@ -321,10 +321,7 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
|
||||
toolCalls = append(toolCalls, ai.ToolCall{
|
||||
ID: block.ID,
|
||||
Type: "function",
|
||||
Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{
|
||||
Function: ai.ToolCallFunction{
|
||||
Name: block.Name,
|
||||
Arguments: argsStr,
|
||||
},
|
||||
@@ -388,9 +385,9 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
|
||||
// 跟踪当前活跃的 tool_use blocks
|
||||
type activeToolUse struct {
|
||||
id string
|
||||
name string
|
||||
argsJSON strings.Builder
|
||||
id string
|
||||
name string
|
||||
argsJSON strings.Builder
|
||||
}
|
||||
activeBlocks := make(map[int]*activeToolUse) // index -> block
|
||||
|
||||
@@ -443,10 +440,7 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
{
|
||||
ID: block.id,
|
||||
Type: "function",
|
||||
Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{
|
||||
Function: ai.ToolCallFunction{
|
||||
Name: block.name,
|
||||
Arguments: argsStr,
|
||||
},
|
||||
|
||||
@@ -84,21 +84,25 @@ type openAIChatRequest struct {
|
||||
}
|
||||
|
||||
type openAIChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content,omitempty"`
|
||||
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content,omitempty"`
|
||||
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
}
|
||||
|
||||
func buildOpenAIMessages(reqMessages []ai.Message, modelName string, baseURL string) []openAIChatMessage {
|
||||
messages := make([]openAIChatMessage, len(reqMessages))
|
||||
replayReasoningContent := shouldReplayReasoningContent(modelName, baseURL)
|
||||
for i, m := range reqMessages {
|
||||
if m.Role == "tool" {
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content, ToolCallID: m.ToolCallID}
|
||||
continue
|
||||
}
|
||||
if len(m.ToolCalls) > 0 {
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls}
|
||||
msg := openAIChatMessage{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls}
|
||||
attachReasoningContent(&msg, m, replayReasoningContent)
|
||||
messages[i] = msg
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -127,20 +131,37 @@ func buildOpenAIMessages(reqMessages []ai.Message, modelName string, baseURL str
|
||||
},
|
||||
})
|
||||
}
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: contentParts}
|
||||
msg := openAIChatMessage{Role: m.Role, Content: contentParts}
|
||||
attachReasoningContent(&msg, m, replayReasoningContent)
|
||||
messages[i] = msg
|
||||
} else {
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content}
|
||||
msg := openAIChatMessage{Role: m.Role, Content: m.Content}
|
||||
attachReasoningContent(&msg, m, replayReasoningContent)
|
||||
messages[i] = msg
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func attachReasoningContent(msg *openAIChatMessage, source ai.Message, enabled bool) {
|
||||
if enabled && source.Role == "assistant" && source.ReasoningContent != "" {
|
||||
msg.ReasoningContent = source.ReasoningContent
|
||||
}
|
||||
}
|
||||
|
||||
func shouldReplayReasoningContent(modelName string, baseURL string) bool {
|
||||
model := strings.ToLower(strings.TrimSpace(modelName))
|
||||
base := strings.ToLower(strings.TrimSpace(baseURL))
|
||||
return strings.Contains(model, "deepseek") || strings.Contains(base, "deepseek")
|
||||
}
|
||||
|
||||
// openAIChatResponse OpenAI API 响应体
|
||||
type openAIChatResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
@@ -227,7 +248,8 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{
|
||||
Content: result.Choices[0].Message.Content,
|
||||
Content: result.Choices[0].Message.Content,
|
||||
ReasoningContent: result.Choices[0].Message.ReasoningContent,
|
||||
TokensUsed: ai.TokenUsage{
|
||||
PromptTokens: result.Usage.PromptTokens,
|
||||
CompletionTokens: result.Usage.CompletionTokens,
|
||||
@@ -342,7 +364,10 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
|
||||
// 支持 DeepSeek/千问等模型的 reasoning_content 字段
|
||||
if choice.Delta.ReasoningContent != "" {
|
||||
receivedContent = true
|
||||
callback(ai.StreamChunk{Thinking: choice.Delta.ReasoningContent})
|
||||
callback(ai.StreamChunk{
|
||||
Thinking: choice.Delta.ReasoningContent,
|
||||
ReasoningContent: choice.Delta.ReasoningContent,
|
||||
})
|
||||
}
|
||||
|
||||
if choice.FinishReason != nil {
|
||||
|
||||
@@ -2,6 +2,8 @@ package provider
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/ai"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -165,3 +167,80 @@ func TestOpenAIProvider_DefaultMaxTokens(t *testing.T) {
|
||||
t.Fatalf("expected default max tokens 4096, got %d", op.config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpenAIMessages_ReplaysDeepSeekReasoningContentForToolCalls(t *testing.T) {
|
||||
toolCall := testOpenAIToolCall()
|
||||
got := buildOpenAIMessages([]ai.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ToolCalls: []ai.ToolCall{toolCall},
|
||||
ReasoningContent: "需要先检查表结构",
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
Content: `{"ok":true}`,
|
||||
ToolCallID: toolCall.ID,
|
||||
},
|
||||
}, "deepseek-v4", "https://api.deepseek.com/v1")
|
||||
|
||||
if got[0].ReasoningContent != "需要先检查表结构" {
|
||||
t.Fatalf("expected reasoning_content to be replayed for DeepSeek tool call, got %q", got[0].ReasoningContent)
|
||||
}
|
||||
if got[1].ReasoningContent != "" {
|
||||
t.Fatalf("expected tool result message not to carry reasoning_content, got %q", got[1].ReasoningContent)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(got[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"reasoning_content":"需要先检查表结构"`) {
|
||||
t.Fatalf("expected JSON payload to include reasoning_content, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpenAIMessages_OmitsReasoningContentForNonDeepSeekProviders(t *testing.T) {
|
||||
got := buildOpenAIMessages([]ai.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ToolCalls: []ai.ToolCall{testOpenAIToolCall()},
|
||||
ReasoningContent: "reasoning should stay local",
|
||||
},
|
||||
}, "gpt-4o", "https://api.openai.com/v1")
|
||||
|
||||
if got[0].ReasoningContent != "" {
|
||||
t.Fatalf("expected non-DeepSeek provider to omit reasoning_content, got %q", got[0].ReasoningContent)
|
||||
}
|
||||
body, err := json.Marshal(got[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message: %v", err)
|
||||
}
|
||||
if strings.Contains(string(body), "reasoning_content") {
|
||||
t.Fatalf("expected JSON payload to omit reasoning_content for non-DeepSeek provider, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpenAIMessages_ReplaysDeepSeekAssistantReasoningContentWithoutToolCalls(t *testing.T) {
|
||||
got := buildOpenAIMessages([]ai.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "最终分析",
|
||||
ReasoningContent: "工具调用轮次的最终思考也需要保留",
|
||||
},
|
||||
}, "deepseek-v4", "https://api.deepseek.com/v1")
|
||||
|
||||
if got[0].ReasoningContent != "工具调用轮次的最终思考也需要保留" {
|
||||
t.Fatalf("expected DeepSeek assistant reasoning_content to be replayed, got %q", got[0].ReasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
func testOpenAIToolCall() ai.ToolCall {
|
||||
var toolCall ai.ToolCall
|
||||
toolCall.ID = "call_schema"
|
||||
toolCall.Type = "function"
|
||||
toolCall.Function.Name = "inspect_table_schema"
|
||||
toolCall.Function.Arguments = `{"table":"orders"}`
|
||||
return toolCall
|
||||
}
|
||||
|
||||
@@ -866,9 +866,10 @@ func (s *Service) AIChatSend(messages []ai.Message, tools []ai.Tool) map[string]
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"content": resp.Content,
|
||||
"tool_calls": resp.ToolCalls,
|
||||
"success": true,
|
||||
"content": resp.Content,
|
||||
"reasoning_content": resp.ReasoningContent,
|
||||
"tool_calls": resp.ToolCalls,
|
||||
"tokensUsed": map[string]int{
|
||||
"promptTokens": resp.TokensUsed.PromptTokens,
|
||||
"completionTokens": resp.TokensUsed.CompletionTokens,
|
||||
@@ -903,11 +904,12 @@ func (s *Service) AIChatStream(sessionID string, messages []ai.Message, tools []
|
||||
|
||||
err = p.ChatStream(streamCtx, ai.ChatRequest{Messages: messages, Tools: tools}, func(chunk ai.StreamChunk) {
|
||||
wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{
|
||||
"content": chunk.Content,
|
||||
"thinking": chunk.Thinking,
|
||||
"tool_calls": chunk.ToolCalls,
|
||||
"done": chunk.Done,
|
||||
"error": chunk.Error,
|
||||
"content": chunk.Content,
|
||||
"thinking": chunk.Thinking,
|
||||
"reasoning_content": chunk.ReasoningContent,
|
||||
"tool_calls": chunk.ToolCalls,
|
||||
"done": chunk.Done,
|
||||
"error": chunk.Error,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ package ai
|
||||
|
||||
// ToolCall 表示 AI 发出的工具调用
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "function"
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "function"
|
||||
Function ToolCallFunction `json:"function"`
|
||||
}
|
||||
|
||||
// ToolCallFunction 表示单次工具调用的函数信息
|
||||
type ToolCallFunction struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
// ToolFunction 表示可使用的函数定义
|
||||
@@ -25,11 +28,12 @@ type Tool struct {
|
||||
|
||||
// Message 表示一条对话消息
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
|
||||
Content string `json:"content"`
|
||||
Images []string `json:"images,omitempty"` // base64 encoded images with data:image/png;base64,... prefix
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // 当 role 为 "tool" 时必须传递
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 当 role 为 "assistant" 并试图调工具时传递
|
||||
Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
|
||||
Content string `json:"content"`
|
||||
Images []string `json:"images,omitempty"` // base64 encoded images with data:image/png;base64,... prefix
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // 当 role 为 "tool" 时必须传递
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 当 role 为 "assistant" 并试图调工具时传递
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek thinking mode 工具调用链路要求原样回传
|
||||
}
|
||||
|
||||
// ChatRequest AI 对话请求
|
||||
@@ -42,9 +46,10 @@ type ChatRequest struct {
|
||||
|
||||
// ChatResponse AI 对话响应
|
||||
type ChatResponse struct {
|
||||
Content string `json:"content"`
|
||||
TokensUsed TokenUsage `json:"tokensUsed"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
TokensUsed TokenUsage `json:"tokensUsed"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// TokenUsage token 用量统计
|
||||
@@ -56,11 +61,12 @@ type TokenUsage struct {
|
||||
|
||||
// StreamChunk 流式响应片段
|
||||
type StreamChunk struct {
|
||||
Content string `json:"content"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// ProviderConfig AI Provider 配置
|
||||
|
||||
@@ -179,6 +179,11 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
|
||||
normalized := config
|
||||
normalized.ID = ""
|
||||
normalized.Type = strings.ToLower(strings.TrimSpace(normalized.Type))
|
||||
if normalized.Type == "oceanbase" {
|
||||
protocol := resolveOceanBaseProtocolForApp(normalized)
|
||||
normalized.ConnectionParams = normalizeOceanBaseConnectionParamsForCacheWithProtocol(normalized.ConnectionParams, protocol)
|
||||
normalized.OceanBaseProtocol = ""
|
||||
}
|
||||
// timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。
|
||||
normalized.Timeout = 0
|
||||
normalized.SavePassword = false
|
||||
@@ -209,6 +214,7 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
|
||||
normalized.User = ""
|
||||
normalized.Password = ""
|
||||
normalized.URI = ""
|
||||
normalized.ConnectionParams = ""
|
||||
normalized.Hosts = nil
|
||||
normalized.Topology = ""
|
||||
normalized.MySQLReplicaUser = ""
|
||||
@@ -450,6 +456,9 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
||||
if strings.TrimSpace(config.URI) != "" {
|
||||
b.WriteString(fmt.Sprintf(" URI=已配置(长度=%d)", len(config.URI)))
|
||||
}
|
||||
if strings.TrimSpace(config.ConnectionParams) != "" {
|
||||
b.WriteString(fmt.Sprintf(" 连接参数=已配置(长度=%d)", len(config.ConnectionParams)))
|
||||
}
|
||||
if strings.TrimSpace(config.MySQLReplicaUser) != "" {
|
||||
b.WriteString(" MySQL从库凭据=已配置")
|
||||
}
|
||||
@@ -474,6 +483,13 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" ClickHouse协议=%s", protocol))
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(config.Type), "oceanbase") {
|
||||
protocol := "mysql"
|
||||
if isOceanBaseOracleProtocol(config) {
|
||||
protocol = "oracle"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" OceanBase协议=%s", protocol))
|
||||
}
|
||||
|
||||
if config.UseSSH {
|
||||
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
|
||||
|
||||
@@ -81,6 +81,26 @@ func TestGetCacheKey_KeepDatabaseIsolation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_KeepConnectionParamsIsolation(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Host: "127.0.0.1",
|
||||
Port: 3306,
|
||||
User: "root",
|
||||
Password: "root",
|
||||
Database: "app",
|
||||
ConnectionParams: "charset=utf8",
|
||||
}
|
||||
modified := base
|
||||
modified.ConnectionParams = "charset=utf8mb4"
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left == right {
|
||||
t.Fatalf("expected different cache key for different connection params")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_KeepClickHouseProtocolIsolation(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "clickhouse",
|
||||
@@ -99,3 +119,95 @@ func TestGetCacheKey_KeepClickHouseProtocolIsolation(t *testing.T) {
|
||||
t.Fatalf("expected different cache key for different ClickHouse protocols")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_KeepOceanBaseProtocolIsolation(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "ob.local",
|
||||
Port: 2881,
|
||||
User: "sys@oracle001",
|
||||
Database: "ORCL",
|
||||
ConnectionParams: "protocol=mysql",
|
||||
}
|
||||
modified := base
|
||||
modified.ConnectionParams = "protocol=oracle"
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left == right {
|
||||
t.Fatalf("expected different cache key for different OceanBase protocols")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_KeepOceanBaseExplicitProtocolIsolation(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "ob.local",
|
||||
Port: 2881,
|
||||
User: "sys@oracle001",
|
||||
Database: "ORCL",
|
||||
}
|
||||
modified := base
|
||||
modified.OceanBaseProtocol = "oracle"
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left == right {
|
||||
t.Fatalf("expected different cache key for explicit OceanBase Oracle protocol")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_KeepOceanBaseDefaultProtocolEquivalentToMySQL(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "ob.local",
|
||||
Port: 2881,
|
||||
User: "root@test",
|
||||
Database: "app",
|
||||
}
|
||||
modified := base
|
||||
modified.ConnectionParams = "protocol=mysql"
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left != right {
|
||||
t.Fatalf("expected default OceanBase protocol to equal mysql, got %s vs %s", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_KeepOceanBaseDefaultProtocolEquivalentToExplicitMySQL(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "ob.local",
|
||||
Port: 2881,
|
||||
User: "root@test",
|
||||
Database: "app",
|
||||
}
|
||||
modified := base
|
||||
modified.OceanBaseProtocol = "mysql"
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left != right {
|
||||
t.Fatalf("expected default OceanBase protocol to equal explicit mysql, got %s vs %s", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_OceanBaseProtocolParamWinsOverAliases(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "ob.local",
|
||||
Port: 2881,
|
||||
User: "root@test",
|
||||
Database: "app",
|
||||
ConnectionParams: "protocol=mysql",
|
||||
}
|
||||
modified := base
|
||||
modified.ConnectionParams = "protocol=mysql&tenantMode=oracle"
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left != right {
|
||||
t.Fatalf("expected explicit protocol=mysql to win over alias, got %s vs %s", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(config.Type)) {
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine", "clickhouse":
|
||||
case "oceanbase":
|
||||
if !isOceanBaseOracleProtocol(config) {
|
||||
runConfig.Database = name
|
||||
}
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "mongodb", "tdengine", "clickhouse":
|
||||
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
|
||||
runConfig.Database = name
|
||||
case "dameng":
|
||||
@@ -42,7 +46,7 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
|
||||
return rawDB, rawTable
|
||||
}
|
||||
|
||||
dbType := strings.ToLower(strings.TrimSpace(config.Type))
|
||||
dbType := resolveDDLDBType(config)
|
||||
if dbType == "sqlserver" {
|
||||
// SQL Server 的 DB 接口约定:第一个参数是数据库名,schema 由 tableName(如 dbo.users) 自行解析。
|
||||
// 不能把 schema(dbo) 传到第一个参数,否则会拼出 dbo.sys.columns 等无效对象名。
|
||||
@@ -62,7 +66,7 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
case "postgres", "kingbase", "highgo", "vastbase", "opengauss":
|
||||
// PG/金仓/瀚高/海量:dbName 在 UI 里是"数据库",schema 需从 tableName 或使用默认 public。
|
||||
return "public", rawTable
|
||||
default:
|
||||
|
||||
@@ -50,6 +50,34 @@ func TestNormalizeSchemaAndTable_PostgresStillSplitsQualifiedName(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRunConfig_OceanBaseOracleKeepsServiceName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
config := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Database: "OBORCL",
|
||||
OceanBaseProtocol: "oracle",
|
||||
}
|
||||
runConfig := normalizeRunConfig(config, "SYS")
|
||||
|
||||
if runConfig.Database != "OBORCL" {
|
||||
t.Fatalf("expected OceanBase Oracle service name to stay OBORCL, got %q", runConfig.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSchemaAndTable_OceanBaseOracleUsesSchemaFromDatabaseTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema, table := normalizeSchemaAndTable(connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
OceanBaseProtocol: "oracle",
|
||||
}, "SYS", "ORDERS")
|
||||
|
||||
if schema != "SYS" || table != "ORDERS" {
|
||||
t.Fatalf("expected OceanBase Oracle schema/table SYS.ORDERS, got %q.%q", schema, table)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteTableIdentByType_KingbaseNormalizesQuotedQualifiedTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user