mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-26 18:51:22 +08:00
Compare commits
253 Commits
v0.8.1
...
cursor-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8ef4b824a | ||
|
|
d87f8bf36a | ||
|
|
50b4169148 | ||
|
|
4a63a175e4 | ||
|
|
2508b7935a | ||
|
|
22697baa37 | ||
|
|
2fc587fd31 | ||
|
|
2a3bd5bd97 | ||
|
|
ac68729734 | ||
|
|
19c10f2369 | ||
|
|
8af8dd1ae0 | ||
|
|
258fccd56f | ||
|
|
93dc696e90 | ||
|
|
00d70d2934 | ||
|
|
2f39767211 | ||
|
|
5beb4c3ce4 | ||
|
|
8280f342a8 | ||
|
|
e5baf9ead6 | ||
|
|
06c4222aff | ||
|
|
f57f9913d5 | ||
|
|
579891a53a | ||
|
|
061f78b897 | ||
|
|
6671915cf7 | ||
|
|
0cfcfa77d3 | ||
|
|
9e6a56a77b | ||
|
|
714c6fc553 | ||
|
|
d7e15f5eb0 | ||
|
|
b388b08226 | ||
|
|
3bf42a169c | ||
|
|
29ee21e9d7 | ||
|
|
5eafd2aa3c | ||
|
|
b0f077ffbb | ||
|
|
0fe3060cf1 | ||
|
|
c2eaf27c20 | ||
|
|
275d490ca7 | ||
|
|
0707d60f0b | ||
|
|
9b5e785d67 | ||
|
|
69d9f41085 | ||
|
|
ffb4d16950 | ||
|
|
68d4081fcf | ||
|
|
92eb4266da | ||
|
|
b220dd051f | ||
|
|
e466e87fcc | ||
|
|
0428a30071 | ||
|
|
faaf7169da | ||
|
|
04a61a50dc | ||
|
|
749e543e37 | ||
|
|
d94335dc62 | ||
|
|
e135956eba | ||
|
|
66b14766a3 | ||
|
|
923e789211 | ||
|
|
52eacdd9df | ||
|
|
a9bae6bb3f | ||
|
|
b0d8ccbf23 | ||
|
|
4b18075314 | ||
|
|
784d7d60e2 | ||
|
|
ece1119c31 | ||
|
|
b9ffcd2959 | ||
|
|
d900bda756 | ||
|
|
8ac435dc6d | ||
|
|
99942e37d6 | ||
|
|
d2a535e6ff | ||
|
|
61c2e524b4 | ||
|
|
b434247838 | ||
|
|
b3f69ac401 | ||
|
|
93006d8423 | ||
|
|
a7fdab5bb2 | ||
|
|
bedd1eceb5 | ||
|
|
dac9e1f571 | ||
|
|
aed87665f4 | ||
|
|
04bbab3d7e | ||
|
|
4798d3e8ec | ||
|
|
c940e930bc | ||
|
|
8280306f82 | ||
|
|
59e12b0867 | ||
|
|
ded531a3c2 | ||
|
|
3a471bc1aa | ||
|
|
ea4f88a20d | ||
|
|
8e857b9aee | ||
|
|
f55a332ead | ||
|
|
893a0f70fb | ||
|
|
fdb05fb8d3 | ||
|
|
730d4bb1ab | ||
|
|
fee703e879 | ||
|
|
4f2f7003c8 | ||
|
|
97540206b3 | ||
|
|
793e078676 | ||
|
|
7453600ee5 | ||
|
|
6c53ef4eff | ||
|
|
60eb696859 | ||
|
|
e456925c23 | ||
|
|
038ecc8b70 | ||
|
|
3bfcd15dc0 | ||
|
|
6abdb8684f | ||
|
|
080ae0986a | ||
|
|
bfb61c8449 | ||
|
|
23d7511f55 | ||
|
|
2d73bcc6de | ||
|
|
a24f4a2bc1 | ||
|
|
bf51003c66 | ||
|
|
2df9dce78b | ||
|
|
3ed45fab41 | ||
|
|
7c9cf95698 | ||
|
|
af2c358822 | ||
|
|
1226e544ec | ||
|
|
faa7ed1ae2 | ||
|
|
22cdeb677b | ||
|
|
b210d078d0 | ||
|
|
ce30de20b6 | ||
|
|
8dde4c3c6d | ||
|
|
bdb60a656a | ||
|
|
8a56367d02 | ||
|
|
aebe9bab54 | ||
|
|
9ab31a7614 | ||
|
|
f6556f25d5 | ||
|
|
16a8a763f4 | ||
|
|
4b1cd1b727 | ||
|
|
37ccaf7743 | ||
|
|
06a984f39d | ||
|
|
6d1c034052 | ||
|
|
672d05d124 | ||
|
|
322d0a7cb8 | ||
|
|
5725e78931 | ||
|
|
69b6072e37 | ||
|
|
1a9d417c0a | ||
|
|
8a552c4cb3 | ||
|
|
9da9a36cf3 | ||
|
|
d08ab62f92 | ||
|
|
1cb112bcdf | ||
|
|
2fb4851d1f | ||
|
|
32c3ba017e | ||
|
|
d09b968cc0 | ||
|
|
90d84da849 | ||
|
|
d2c4160c6d | ||
|
|
2b4beae9df | ||
|
|
7dab6f2e33 | ||
|
|
1aab48783d | ||
|
|
5089e6b294 | ||
|
|
3be08cb6cd | ||
|
|
6c4833dcd1 | ||
|
|
ce17629a8d | ||
|
|
5e15c4dd2d | ||
|
|
c04d82ee30 | ||
|
|
7962aea008 | ||
|
|
5493b62bb9 | ||
|
|
86c39ac36f | ||
|
|
6bf05c9ed7 | ||
|
|
a2c1b4a7d8 | ||
|
|
07b3b908f9 | ||
|
|
71989af586 | ||
|
|
adacf0b5c5 | ||
|
|
09ae3d74c4 | ||
|
|
839fdd7d66 | ||
|
|
b0a9a995fb | ||
|
|
3205d131c9 | ||
|
|
52d193b2e8 | ||
|
|
d9e52c734a | ||
|
|
3ce85617da | ||
|
|
0ba984b277 | ||
|
|
8da8cc7f91 | ||
|
|
bc63311003 | ||
|
|
3a00ae1f44 | ||
|
|
e8cad189be | ||
|
|
495a985ae1 | ||
|
|
8f1e6cf379 | ||
|
|
05e8dab710 | ||
|
|
0ba2bfe645 | ||
|
|
f282da3bcb | ||
|
|
eba689754c | ||
|
|
d13c153f5e | ||
|
|
4999fd544d | ||
|
|
36233ba9aa | ||
|
|
4408bce159 | ||
|
|
e7b8e78f9c | ||
|
|
29e7e365f1 | ||
|
|
8ea7ecc477 | ||
|
|
99b75378c3 | ||
|
|
5f56859898 | ||
|
|
7f2445a6f5 | ||
|
|
b3a54b3ff8 | ||
|
|
c8c8497a2f | ||
|
|
04019135a0 | ||
|
|
e7935db84b | ||
|
|
0f67305100 | ||
|
|
6179c3fbd9 | ||
|
|
13705f9098 | ||
|
|
1dea343aa2 | ||
|
|
293a8ff3e9 | ||
|
|
6e422aea33 | ||
|
|
39e52469f2 | ||
|
|
3ff5141184 | ||
|
|
db31513c0b | ||
|
|
5da2c7ff1a | ||
|
|
d109f2891f | ||
|
|
f946951580 | ||
|
|
f6ebfc2e44 | ||
|
|
540dbc2a28 | ||
|
|
2c8128724e | ||
|
|
528f3c51a0 | ||
|
|
a4d94624cd | ||
|
|
87bd16c4ba | ||
|
|
dc54d24d2b | ||
|
|
8457f6c4b7 | ||
|
|
a2d83744b5 | ||
|
|
946450874f | ||
|
|
577a417292 | ||
|
|
a74065bdbb | ||
|
|
0c320234fd | ||
|
|
f5ae2e51f9 | ||
|
|
8e24e40fdd | ||
|
|
85648b1e5a | ||
|
|
b997788437 | ||
|
|
542bafe6c4 | ||
|
|
98965a56e1 | ||
|
|
21c427bc39 | ||
|
|
6b67bb24b4 | ||
|
|
06dd9507ee | ||
|
|
b588235b62 | ||
|
|
f457f6aaca | ||
|
|
c8fe90cbee | ||
|
|
6bd87fa568 | ||
|
|
293fc6e0fe | ||
|
|
2a8ae05363 | ||
|
|
9613f6b624 | ||
|
|
ee78b9b57c | ||
|
|
5ce4dddd7a | ||
|
|
e67285fde1 | ||
|
|
4e31d47936 | ||
|
|
954d126a8f | ||
|
|
5b31ab7435 | ||
|
|
b3c321be67 | ||
|
|
d7ad83f0d5 | ||
|
|
3006429a9a | ||
|
|
9364c48ef0 | ||
|
|
3e140c1bc6 | ||
|
|
7ff3e00759 | ||
|
|
0632c5242c | ||
|
|
76b0163bd3 | ||
|
|
6421662f5d | ||
|
|
54195e0591 | ||
|
|
5fc29a6fd3 | ||
|
|
f41a15c7b8 | ||
|
|
938bc53966 | ||
|
|
093b3cae1f | ||
|
|
0816702084 | ||
|
|
c70eb7157f | ||
|
|
23f95d7dc8 | ||
|
|
682017ba96 | ||
|
|
891c8c1200 | ||
|
|
a611c1c04b | ||
|
|
558966a129 | ||
|
|
2f354d2267 | ||
|
|
eca9601ab0 |
18
.containerignore
Normal file
18
.containerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.ace-tool
|
||||
.playwright-mcp
|
||||
.superpowers
|
||||
.worktrees
|
||||
bin
|
||||
build
|
||||
dist
|
||||
frontend/dist
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
tools/__pycache__
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
optional-driver-agent
|
||||
optional-driver-agent.exe
|
||||
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.ace-tool
|
||||
.playwright-mcp
|
||||
.superpowers
|
||||
.worktrees
|
||||
bin
|
||||
build
|
||||
dist
|
||||
frontend/dist
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
tools/__pycache__
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
optional-driver-agent
|
||||
optional-driver-agent.exe
|
||||
184
.github/workflows/dev-build.yml
vendored
184
.github/workflows/dev-build.yml
vendored
@@ -68,6 +68,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
drivers: ${{ steps.detect.outputs.drivers }}
|
||||
drivers_darwin_amd64: ${{ steps.detect.outputs.drivers_darwin_amd64 }}
|
||||
drivers_darwin_arm64: ${{ steps.detect.outputs.drivers_darwin_arm64 }}
|
||||
drivers_windows_amd64: ${{ steps.detect.outputs.drivers_windows_amd64 }}
|
||||
drivers_windows_arm64: ${{ steps.detect.outputs.drivers_windows_arm64 }}
|
||||
drivers_linux_amd64: ${{ steps.detect.outputs.drivers_linux_amd64 }}
|
||||
drivers_linux_arm64: ${{ steps.detect.outputs.drivers_linux_arm64 }}
|
||||
has_changes: ${{ steps.detect.outputs.has_changes }}
|
||||
release_source: ${{ steps.detect.outputs.release_source }}
|
||||
compare_base: ${{ steps.detect.outputs.compare_base }}
|
||||
@@ -80,6 +86,12 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
cache: true
|
||||
|
||||
- name: Resolve published driver release source
|
||||
id: published_source
|
||||
env:
|
||||
@@ -149,6 +161,21 @@ jobs:
|
||||
printf '%s\n' "$merged"
|
||||
}
|
||||
|
||||
platform_output_key() {
|
||||
case "$1" in
|
||||
darwin/amd64) echo "darwin_amd64" ;;
|
||||
darwin/arm64) echo "darwin_arm64" ;;
|
||||
windows/amd64) echo "windows_amd64" ;;
|
||||
windows/arm64) echo "windows_arm64" ;;
|
||||
linux/amd64) echo "linux_amd64" ;;
|
||||
linux/arm64) echo "linux_arm64" ;;
|
||||
*)
|
||||
echo "unknown platform: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
BASE_REF="${{ steps.published_source.outputs.source_commit }}"
|
||||
HAS_MANIFEST="${{ steps.published_source.outputs.has_manifest }}"
|
||||
MANIFEST_VALID="${{ steps.published_source.outputs.manifest_valid }}"
|
||||
@@ -173,27 +200,53 @@ jobs:
|
||||
BASE_REF="all"
|
||||
fi
|
||||
echo "🧭 Final driver detection base: $BASE_REF"
|
||||
FORCE_GLOBAL_DRIVER_BUILDS="$(bash ./tools/should-force-global-driver-builds.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
|
||||
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
|
||||
if [[ "$BASE_REF" != "all" ]]; then
|
||||
PLATFORM_OUTPUTS=()
|
||||
PLATFORM_DIFF_FAILED=false
|
||||
if [[ "$BASE_REF" != "all" && "$FORCE_GLOBAL_DRIVER_BUILDS" != "true" ]]; then
|
||||
REVISION_DRIVERS=""
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
|
||||
PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || {
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
|
||||
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
|
||||
DIFF_ARGS=(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")
|
||||
if [[ -n "$DRIVERS" ]]; then
|
||||
DIFF_ARGS+=(--drivers "$DRIVERS")
|
||||
fi
|
||||
PLATFORM_DRIVERS="$("${DIFF_ARGS[@]}")" || {
|
||||
echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建"
|
||||
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
|
||||
REVISION_DRIVERS=""
|
||||
PLATFORM_DIFF_FAILED=true
|
||||
PLATFORM_OUTPUTS=()
|
||||
break
|
||||
}
|
||||
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=${PLATFORM_DRIVERS}")
|
||||
REVISION_DRIVERS="$(merge_csv "$REVISION_DRIVERS" "$PLATFORM_DRIVERS")"
|
||||
done
|
||||
if [[ -n "$REVISION_DRIVERS" ]]; then
|
||||
if [[ "$PLATFORM_DIFF_FAILED" == "false" && -n "$REVISION_DRIVERS" ]]; then
|
||||
echo "🧭 Revision diff union drivers: $REVISION_DRIVERS"
|
||||
DRIVERS="$(merge_csv "$DRIVERS" "$REVISION_DRIVERS")"
|
||||
fi
|
||||
fi
|
||||
FORCE_GLOBAL_DRIVER_BUILDS="$(bash ./tools/should-force-global-driver-builds.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
|
||||
if [[ "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" || "$PLATFORM_DIFF_FAILED" == "true" ]]; then
|
||||
PLATFORM_OUTPUTS=()
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
|
||||
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
|
||||
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=${DRIVERS}")
|
||||
done
|
||||
elif [[ ${#PLATFORM_OUTPUTS[@]} -eq 0 ]]; then
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
|
||||
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
|
||||
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=")
|
||||
done
|
||||
fi
|
||||
|
||||
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
|
||||
echo "compare_base=${BASE_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "force_global_driver_builds=${FORCE_GLOBAL_DRIVER_BUILDS}" >> "$GITHUB_OUTPUT"
|
||||
for OUTPUT_LINE in "${PLATFORM_OUTPUTS[@]}"; do
|
||||
echo "$OUTPUT_LINE" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
if [ -n "$DRIVERS" ]; then
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "🧭 Changed driver agents: $DRIVERS"
|
||||
@@ -261,6 +314,15 @@ jobs:
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
- os: ubuntu-22.04-arm
|
||||
platform: linux/arm64
|
||||
os_name: Linux
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-build-linux-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
- os: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
@@ -346,6 +408,12 @@ jobs:
|
||||
|
||||
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||
|
||||
if [ "${{ matrix.arch_name }}" != "Amd64" ]; then
|
||||
echo "⚠️ Linux arm64 暂不生成 AppImage,仅产出 tar.gz"
|
||||
touch /tmp/skip-appimage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
|
||||
|
||||
@@ -481,47 +549,50 @@ jobs:
|
||||
wails build -s -skipbindings -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
|
||||
fi
|
||||
|
||||
- name: Resolve Platform Driver Revision Diff
|
||||
id: revision_diff
|
||||
- name: Select Platform Driver Set
|
||||
id: platform_drivers
|
||||
if: ${{ matrix.build_optional_agents && needs.driver_agents.outputs.has_changes == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_REF="${{ needs.driver_agents.outputs.compare_base }}"
|
||||
FALLBACK_DRIVERS="${{ needs.driver_agents.outputs.drivers }}"
|
||||
FORCE_GLOBAL_DRIVER_BUILDS="${{ needs.driver_agents.outputs.force_global_driver_builds }}"
|
||||
|
||||
if [[ -z "$BASE_REF" || "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" ]]; then
|
||||
if [[ "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" && -n "$BASE_REF" && "$BASE_REF" != "all" ]]; then
|
||||
echo "⚠️ 当前提交涉及 driver 构建/发布链路,保留全局驱动重建结果:${FALLBACK_DRIVERS}"
|
||||
else
|
||||
echo "⚠️ 当前 driver 检测基线不可做平台 diff,回退使用全局检测结果:${FALLBACK_DRIVERS}"
|
||||
fi
|
||||
echo "drivers=${FALLBACK_DRIVERS}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "🧭 对比当前平台 revision:base=${BASE_REF} head=${GITHUB_SHA} platform=${{ matrix.platform }}"
|
||||
if DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "${{ matrix.platform }}")"; then
|
||||
echo "🧭 当前平台实际需要重建的 driver agents: ${DRIVERS:-<empty>}"
|
||||
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "⚠️ revision 差异对比失败,保守回退为全量重建"
|
||||
ALL_DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
|
||||
echo "drivers=${ALL_DRIVERS}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
DRIVERS="$(sed -n 's/^drivers=//p' "$GITHUB_OUTPUT" | tail -n 1)"
|
||||
case "${{ matrix.platform }}" in
|
||||
darwin/amd64)
|
||||
DRIVERS='${{ needs.driver_agents.outputs.drivers_darwin_amd64 }}'
|
||||
;;
|
||||
darwin/arm64)
|
||||
DRIVERS='${{ needs.driver_agents.outputs.drivers_darwin_arm64 }}'
|
||||
;;
|
||||
windows/amd64)
|
||||
DRIVERS='${{ needs.driver_agents.outputs.drivers_windows_amd64 }}'
|
||||
;;
|
||||
windows/arm64)
|
||||
DRIVERS='${{ needs.driver_agents.outputs.drivers_windows_arm64 }}'
|
||||
;;
|
||||
linux/amd64)
|
||||
DRIVERS='${{ needs.driver_agents.outputs.drivers_linux_amd64 }}'
|
||||
;;
|
||||
linux/arm64)
|
||||
DRIVERS='${{ needs.driver_agents.outputs.drivers_linux_arm64 }}'
|
||||
;;
|
||||
*)
|
||||
echo "unsupported platform: ${{ matrix.platform }}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
|
||||
if [[ -n "$DRIVERS" ]]; then
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "🧭 当前平台最终需要重建的 driver agents: ${DRIVERS}"
|
||||
else
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "🧭 当前平台无需重建 driver agents"
|
||||
fi
|
||||
|
||||
- name: Build Optional Driver Agents
|
||||
if: ${{ matrix.build_optional_agents && steps.revision_diff.outputs.has_changes == 'true' }}
|
||||
if: ${{ matrix.build_optional_agents && steps.platform_drivers.outputs.has_changes == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
CHANGED_DRIVER_AGENTS: ${{ steps.revision_diff.outputs.drivers }}
|
||||
CHANGED_DRIVER_AGENTS: ${{ steps.platform_drivers.outputs.drivers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||
@@ -537,7 +608,45 @@ jobs:
|
||||
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
|
||||
mkdir -p "$GOCACHE"
|
||||
echo "🧭 可选驱动使用隔离 GOCACHE:$GOCACHE"
|
||||
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
|
||||
normalize_driver_token() {
|
||||
local value
|
||||
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
case "$value" in
|
||||
"") return 1 ;;
|
||||
doris|diros) echo "doris" ;;
|
||||
open_gauss|open-gauss) echo "opengauss" ;;
|
||||
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
|
||||
elastic|elasticsearch) echo "elasticsearch" ;;
|
||||
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse)
|
||||
echo "$value"
|
||||
;;
|
||||
*)
|
||||
echo "❌ 不支持的 driver-agent:${1:-}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_driver_name() {
|
||||
case "$1" in
|
||||
doris) echo "diros" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
declare -a RAW_DRIVERS=()
|
||||
declare -a DRIVERS=()
|
||||
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
|
||||
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
|
||||
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
|
||||
DRIVERS+=("$DRIVER")
|
||||
done
|
||||
if [ ${#DRIVERS[@]} -eq 0 ]; then
|
||||
echo "🧭 没有需要构建的 driver-agent"
|
||||
exit 0
|
||||
fi
|
||||
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
|
||||
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
|
||||
@@ -593,10 +702,7 @@ jobs:
|
||||
}
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
BUILD_DRIVER="$DRIVER"
|
||||
if [ "$DRIVER" = "doris" ]; then
|
||||
BUILD_DRIVER="diros"
|
||||
fi
|
||||
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
|
||||
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
|
||||
echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)"
|
||||
continue
|
||||
@@ -691,7 +797,7 @@ jobs:
|
||||
bash ./tools/verify-driver-agent-revisions.sh \
|
||||
--assets-dir drivers \
|
||||
--platform "$TARGET_PLATFORM" \
|
||||
--drivers "$CHANGED_DRIVER_AGENTS"
|
||||
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
|
||||
|
||||
# macOS Packaging
|
||||
- name: Package macOS DMG
|
||||
|
||||
204
.github/workflows/docker-images.yml
vendored
Normal file
204
.github/workflows/docker-images.yml
vendored
Normal file
@@ -0,0 +1,204 @@
|
||||
name: Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
concurrency:
|
||||
group: docker-images-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.image_name }} (${{ matrix.platform }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
image_name:
|
||||
- gonavi-mcp-server
|
||||
- gonavi-build-env
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
include:
|
||||
- image_name: gonavi-mcp-server
|
||||
dockerfile: Dockerfile.mcp-server
|
||||
description: GoNavi MCP Server container image
|
||||
- image_name: gonavi-build-env
|
||||
dockerfile: Dockerfile.build-env
|
||||
description: GoNavi Linux build environment image
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare build variables
|
||||
id: prep
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
echo "platform_pair=$(printf '%s' "${{ matrix.platform }}" | tr '/' '-')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=${{ matrix.image_name }}
|
||||
org.opencontainers.image.description=${{ matrix.description }}
|
||||
|
||||
- name: Build smoke image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
load: true
|
||||
tags: codex-smoke/${{ matrix.image_name }}:local
|
||||
cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
|
||||
|
||||
- name: Smoke test MCP Server image
|
||||
if: matrix.image_name == 'gonavi-mcp-server'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cid="$(docker run -d -p 8765:8765 -e GONAVI_MCP_HTTP_TOKEN=smoke-token codex-smoke/${{ matrix.image_name }}:local http)"
|
||||
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl --fail --silent http://127.0.0.1:8765/healthz >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
docker logs "$cid"
|
||||
echo "MCP Server image smoke test failed" >&2
|
||||
exit 1
|
||||
|
||||
- name: Smoke test build environment image
|
||||
if: matrix.image_name == 'gonavi-build-env'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm codex-smoke/${{ matrix.image_name }}:local \
|
||||
bash -lc 'go version && node -v && npm -v && wails version'
|
||||
|
||||
- name: Push by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Publish ${{ matrix.image_name }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image_name: gonavi-mcp-server
|
||||
description: GoNavi MCP Server container image
|
||||
- image_name: gonavi-build-env
|
||||
description: GoNavi Linux build environment image
|
||||
steps:
|
||||
- name: Normalize image namespace
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-${{ matrix.image_name }}-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=dev-latest,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||
type=sha,format=short,prefix=dev-,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=${{ matrix.image_name }}
|
||||
org.opencontainers.image.description=${{ matrix.description }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools inspect "ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}:${{ steps.meta.outputs.version }}"
|
||||
22
.github/workflows/release-winget.yml
vendored
22
.github/workflows/release-winget.yml
vendored
@@ -1,22 +0,0 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
required: true
|
||||
description: 'Tag of release you want to publish'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-2025-vs2026
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@v2
|
||||
with:
|
||||
identifier: Syngnat.GoNavi
|
||||
installers-regex: 'GoNavi-windows-(amd64|arm64)\.exe$'
|
||||
release-tag: ${{ inputs.release_tag || github.ref_name }}
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
64
.github/workflows/release.yml
vendored
64
.github/workflows/release.yml
vendored
@@ -177,7 +177,7 @@ jobs:
|
||||
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
|
||||
if [[ "$BASE_REF" != "all" ]]; then
|
||||
REVISION_DRIVERS=""
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
|
||||
PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || {
|
||||
echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建"
|
||||
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
|
||||
@@ -263,6 +263,15 @@ jobs:
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
- os: ubuntu-22.04-arm
|
||||
platform: linux/arm64
|
||||
os_name: Linux
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-build-linux-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
# Debian 13 (trixie) 默认仓库已切到 WebKitGTK 4.1:单独提供 4.1 变体产物
|
||||
- os: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
@@ -354,6 +363,12 @@ jobs:
|
||||
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
|
||||
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||
|
||||
if [ "${{ matrix.arch_name }}" != "Amd64" ]; then
|
||||
echo "⚠️ Linux arm64 暂不生成 AppImage,仅产出 tar.gz"
|
||||
touch /tmp/skip-appimage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Download linuxdeploy tools for AppImage packaging
|
||||
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
|
||||
@@ -535,7 +550,45 @@ jobs:
|
||||
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
|
||||
mkdir -p "$GOCACHE"
|
||||
echo "🧭 可选驱动使用隔离 GOCACHE:$GOCACHE"
|
||||
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
|
||||
normalize_driver_token() {
|
||||
local value
|
||||
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
case "$value" in
|
||||
"") return 1 ;;
|
||||
doris|diros) echo "doris" ;;
|
||||
open_gauss|open-gauss) echo "opengauss" ;;
|
||||
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
|
||||
elastic|elasticsearch) echo "elasticsearch" ;;
|
||||
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse)
|
||||
echo "$value"
|
||||
;;
|
||||
*)
|
||||
echo "❌ 不支持的 driver-agent:${1:-}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_driver_name() {
|
||||
case "$1" in
|
||||
doris) echo "diros" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
declare -a RAW_DRIVERS=()
|
||||
declare -a DRIVERS=()
|
||||
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
|
||||
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
|
||||
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
|
||||
DRIVERS+=("$DRIVER")
|
||||
done
|
||||
if [ ${#DRIVERS[@]} -eq 0 ]; then
|
||||
echo "🧭 没有需要构建的 driver-agent"
|
||||
exit 0
|
||||
fi
|
||||
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
|
||||
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
|
||||
@@ -591,10 +644,7 @@ jobs:
|
||||
}
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
BUILD_DRIVER="$DRIVER"
|
||||
if [ "$DRIVER" = "doris" ]; then
|
||||
BUILD_DRIVER="diros"
|
||||
fi
|
||||
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
|
||||
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
|
||||
echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)"
|
||||
continue
|
||||
@@ -689,7 +739,7 @@ jobs:
|
||||
bash ./tools/verify-driver-agent-revisions.sh \
|
||||
--assets-dir drivers \
|
||||
--platform "$TARGET_PLATFORM" \
|
||||
--drivers "$CHANGED_DRIVER_AGENTS"
|
||||
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
|
||||
|
||||
# macOS Packaging
|
||||
- name: Package macOS DMG
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,6 +17,8 @@ dist/
|
||||
.gemini-clipboard
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
optional-driver-agent
|
||||
optional-driver-agent.exe
|
||||
.ace-tool/
|
||||
.superpowers/
|
||||
.claude/
|
||||
|
||||
55
Dockerfile.build-env
Normal file
55
Dockerfile.build-env
Normal file
@@ -0,0 +1,55 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG GO_IMAGE=golang:1.25-bookworm
|
||||
ARG NODE_IMAGE=node:20-bookworm
|
||||
|
||||
FROM ${NODE_IMAGE} AS node
|
||||
|
||||
FROM ${GO_IMAGE}
|
||||
|
||||
ARG WEBKIT_API=4.0
|
||||
ARG WAILS_VERSION=v2.11.0
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PATH=/go/bin:/usr/local/go/bin:${PATH}
|
||||
|
||||
# Debian's /etc/profile resets PATH in login shells (bash -l), dropping the Go
|
||||
# toolchain; restore it via profile.d which is sourced after that reset.
|
||||
RUN printf 'export PATH=/go/bin:/usr/local/go/bin:$PATH\n' > /etc/profile.d/10-gonavi-build-env.sh
|
||||
|
||||
# Copy only Node.js runtime assets so the Go toolchain from the base image
|
||||
# stays intact for smoke tests and interactive build shells.
|
||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
|
||||
# npm/npx/corepack are symlinks in the node image; COPY dereferences them into
|
||||
# standalone scripts whose relative requires break, so recreate the symlinks.
|
||||
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
|
||||
&& ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
file \
|
||||
git \
|
||||
make \
|
||||
pkg-config \
|
||||
python3 \
|
||||
unzip \
|
||||
zip \
|
||||
libgtk-3-dev \
|
||||
&& if [ "${WEBKIT_API}" = "4.1" ]; then \
|
||||
apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libsoup-3.0-dev; \
|
||||
else \
|
||||
apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev; \
|
||||
fi \
|
||||
&& go install github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
CMD ["bash"]
|
||||
46
Dockerfile.mcp-server
Normal file
46
Dockerfile.mcp-server
Normal file
@@ -0,0 +1,46 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder
|
||||
|
||||
# Declared without defaults so BuildKit injects the real target platform;
|
||||
# a default like TARGETARCH=amd64 would win and ship amd64 binaries to arm64.
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
COPY third_party/highgo-pq/go.mod ./third_party/highgo-pq/go.mod
|
||||
COPY third_party/go-irisnative/go.mod third_party/go-irisnative/go.sum ./third_party/go-irisnative/
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/gonavi-mcp-server ./cmd/gonavi-mcp-server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --create-home --home-dir /var/lib/gonavi --uid 10001 gonavi \
|
||||
&& mkdir -p /data /var/lib/gonavi/logs \
|
||||
&& chown -R gonavi:gonavi /data /var/lib/gonavi
|
||||
|
||||
COPY --from=builder /out/gonavi-mcp-server /usr/local/bin/gonavi-mcp-server
|
||||
|
||||
ENV HOME=/var/lib/gonavi \
|
||||
GONAVI_DATA_ROOT=/data \
|
||||
GONAVI_LOG_DIR=/var/lib/gonavi/logs \
|
||||
GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765 \
|
||||
GONAVI_MCP_HTTP_PATH=/mcp \
|
||||
GONAVI_MCP_SCHEMA_ONLY=true
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8765
|
||||
|
||||
USER gonavi
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/gonavi-mcp-server"]
|
||||
CMD ["http"]
|
||||
92
README.md
92
README.md
@@ -34,9 +34,16 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
| Category | Data Source | Driver Mode | Typical Capabilities |
|
||||
|---|---|---|---|
|
||||
| Relational | MySQL | Built-in | Schema browsing, SQL query, data editing, export/backup |
|
||||
| Domestic DB | GoldenDB | Built-in | MySQL-compatible query workflow and distributed transaction scenarios |
|
||||
| Relational | PostgreSQL | Built-in | Schema browsing, SQL query, data editing, object management |
|
||||
| Relational | Oracle | Built-in | Query execution, object browsing, data editing |
|
||||
| Cache | Redis | Built-in | Key browsing, command execution, encoding/view switch |
|
||||
| Vector Database | Chroma | Built-in | Collection browsing, vector retrieval, metadata filtering |
|
||||
| Vector Database | Qdrant | Built-in | Collection browsing, vector search, payload filtering |
|
||||
| Message Queue | RocketMQ | Built-in | Topic browsing, consumer-group inspection, message-oriented workflow |
|
||||
| Message Queue | MQTT | Built-in | Broker and topic-filter workflow with QoS-aware connection settings |
|
||||
| Message Queue | Kafka | Built-in | Topic browsing, broker metadata, consumer-group workflow |
|
||||
| Message Queue | RabbitMQ | Built-in | Queue/exchange browsing, virtual host inspection, management API workflow |
|
||||
| Relational | MariaDB | Optional driver agent | Querying, object management, data editing |
|
||||
| Relational | Doris | Optional driver agent | Querying, object browsing, SQL execution |
|
||||
| Columnar Analytics | StarRocks | Optional driver agent | Querying, object browsing, SQL execution |
|
||||
@@ -44,13 +51,19 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
| Relational | SQL Server | Optional driver agent | Schema browsing, SQL query, object management |
|
||||
| File-based | SQLite | Optional driver agent | Local DB browsing, editing, export |
|
||||
| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow |
|
||||
| Domestic DB | OceanBase | Optional driver agent | MySQL / Oracle tenant access, object browsing, query workflow |
|
||||
| Domestic DB | Dameng | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | Kingbase | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | HighGo | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | Vastbase | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | OpenGauss | Optional driver agent | PostgreSQL-like schema browsing, SQL query, object management |
|
||||
| Domestic DB | GaussDB | Optional driver agent | PostgreSQL-like schema browsing, SQL query, object management |
|
||||
| Multi-model | InterSystems IRIS | Optional driver agent | Namespace browsing, SQL query, object management |
|
||||
| Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management |
|
||||
| Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying |
|
||||
| Time-series | Apache IoTDB | Optional driver agent | Storage group / device / timeseries browsing and querying |
|
||||
| Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution |
|
||||
| Federated Query | Trino | Optional driver agent | Cross-source SQL via multiple catalogs, `catalog.schema` browsing, SQL execution |
|
||||
| Search | Elasticsearch | Optional driver agent | Index browsing, mapping inspection, JSON DSL / query_string search |
|
||||
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
|
||||
|
||||
@@ -74,6 +87,9 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support.
|
||||
- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance.
|
||||
- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs.
|
||||
- **Built-in MCP Workflow**: Manage MCP servers in AI Settings, install GoNavi MCP to Claude Code / Codex, or expose Streamable HTTP for remote Agents.
|
||||
- **Remote-Agent Boundary**: Keep saved connections and database passwords on the host running GoNavi while cloud Agents consume schema tools over MCP.
|
||||
- **Safety Guardrails**: Remote `schema-only` mode omits `execute_sql`; when SQL execution is enabled, non-read-only statements still require explicit `allowMutating=true`.
|
||||
|
||||
### Performance
|
||||
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
|
||||
@@ -168,6 +184,82 @@ wails build -clean
|
||||
|
||||
Artifacts are generated in `build/bin`.
|
||||
|
||||
### Docker / Podman (MCP Server Only)
|
||||
|
||||
The desktop GUI is not packaged as a container service. Current container support targets `gonavi-mcp-server` only.
|
||||
|
||||
### Browser Access Mode (Experimental)
|
||||
|
||||
The main GoNavi application now also exposes a `web-server` mode that reuses the same Go backend and React frontend for browser access:
|
||||
|
||||
```powershell
|
||||
go build .
|
||||
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
|
||||
```
|
||||
|
||||
The first browser visit is redirected to `/setup` to create the web admin password. Google Authenticator is optional but supported out of the box, together with `web_auth.json`, session cookies, recovery codes, and login rate limiting.
|
||||
|
||||
Current scope already includes:
|
||||
|
||||
- Browser-side Wails bridge (`window.go.*` / `window.runtime.*` -> HTTP / SSE)
|
||||
- First-run setup page, login page, and logout endpoint
|
||||
- Session idle timeout / absolute timeout / remember-login window
|
||||
- Google Authenticator TOTP plus recovery codes
|
||||
|
||||
Still in progress:
|
||||
|
||||
- Browser upload/download workbenches for external SQL and connection-package flows
|
||||
- More web capability gating for desktop-only features
|
||||
- Reverse-proxy / HTTPS / zero-trust deployment guidance
|
||||
|
||||
```bash
|
||||
cp docker.mcp-server.env.example docker.mcp-server.env
|
||||
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
|
||||
```
|
||||
|
||||
Mount the GoNavi active data root into the container. The mounted directory should contain `connections.json`, `daily_secrets.json`, and optional `drivers/` assets when optional driver agents are required.
|
||||
|
||||
The default Compose file pulls the published GHCR image. For local source builds, add the override file:
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker.mcp-server.env \
|
||||
-f docker-compose.mcp-server.yml \
|
||||
-f docker-compose.mcp-server.local.yml \
|
||||
up -d --build
|
||||
```
|
||||
|
||||
For Podman, use the same published OCI image with `podman run`, or the native Quadlet example under [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server). That path is intended for Linux servers / NAS hosts where rootless systemd services are preferred.
|
||||
|
||||
Deployment matrix:
|
||||
|
||||
- Docker Desktop / Linux server / NAS: `docker-compose.mcp-server.yml`
|
||||
- Podman / Quadlet: [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server)
|
||||
- Kubernetes: [deploy/k8s/gonavi-mcp-server](deploy/k8s/gonavi-mcp-server) (`kustomization.yaml` + overlays)
|
||||
- Helm chart: [deploy/helm/gonavi-mcp-server](deploy/helm/gonavi-mcp-server)
|
||||
- Build-only Linux environment: `Dockerfile.build-env`
|
||||
|
||||
See [cmd/gonavi-mcp-server/README.md](cmd/gonavi-mcp-server/README.md) for deployment details and security boundaries.
|
||||
|
||||
### Docker / Podman (Build Environment Only)
|
||||
|
||||
If you only need a reproducible Linux build environment for Wails, use `Dockerfile.build-env`:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
|
||||
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
|
||||
```
|
||||
|
||||
The same Dockerfile also works with Podman, for example `podman build -f Dockerfile.build-env -t localhost/gonavi-build-env:local .` and `podman run --rm -it -v "$PWD:/workspace" -w /workspace localhost/gonavi-build-env:local bash`.
|
||||
|
||||
The default image installs the WebKitGTK 4.0 build toolchain for broader Linux/NAS compatibility. The image bases are multi-arch, so `amd64` and `arm64` follow the target container platform.
|
||||
|
||||
Published images are pushed to GHCR:
|
||||
|
||||
- `ghcr.io/syngnat/gonavi-mcp-server:latest`
|
||||
- `ghcr.io/syngnat/gonavi-build-env:latest`
|
||||
|
||||
This image is for building Linux artifacts only. It does not turn the Wails desktop GUI into a browser-accessible web application.
|
||||
|
||||
### Cross-Platform Release (GitHub Actions)
|
||||
|
||||
The repository includes a release workflow.
|
||||
|
||||
@@ -33,9 +33,16 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
| 类别 | 数据源 | 驱动模式 | 典型能力 |
|
||||
|---|---|---|---|
|
||||
| 关系型 | MySQL | 内置 | 库表浏览、SQL 查询、数据编辑、导出/备份 |
|
||||
| 国产数据库 | GoldenDB | 内置 | MySQL 兼容查询工作流、分布式事务场景 |
|
||||
| 关系型 | PostgreSQL | 内置 | 库表浏览、SQL 查询、数据编辑、对象管理 |
|
||||
| 关系型 | Oracle | 内置 | 连接查询、对象浏览、数据编辑 |
|
||||
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
|
||||
| 向量数据库 | Chroma | 内置 | Collection 浏览、向量检索、元数据过滤 |
|
||||
| 向量数据库 | Qdrant | 内置 | Collection 浏览、向量搜索、Payload 过滤 |
|
||||
| 消息队列 | RocketMQ | 内置 | Topic 浏览、消费组检查、消息型工作流 |
|
||||
| 消息队列 | MQTT | 内置 | Broker / Topic Filter 工作流与 QoS 连接配置 |
|
||||
| 消息队列 | Kafka | 内置 | Topic 浏览、Broker 元数据、消费组工作流 |
|
||||
| 消息队列 | RabbitMQ | 内置 | Queue / Exchange 浏览、Virtual Host 检查、Management API 工作流 |
|
||||
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
|
||||
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
|
||||
| 列式分析 | StarRocks | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
|
||||
@@ -43,13 +50,19 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
|
||||
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
|
||||
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
|
||||
| 国产数据库 | OceanBase | 可选驱动代理 | MySQL / Oracle 租户接入、对象浏览、查询工作流 |
|
||||
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | Vastbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | OpenGauss | 可选驱动代理 | 类 PostgreSQL 的库表浏览、SQL 查询、对象管理 |
|
||||
| 国产数据库 | GaussDB | 可选驱动代理 | 类 PostgreSQL 的库表浏览、SQL 查询、对象管理 |
|
||||
| 多模型数据库 | InterSystems IRIS | 可选驱动代理 | Namespace 浏览、SQL 查询、对象管理 |
|
||||
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
|
||||
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
|
||||
| 时序 | Apache IoTDB | 可选驱动代理 | Storage Group / Device / Timeseries 浏览与查询 |
|
||||
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
|
||||
| 联邦查询 | Trino | 可选驱动代理 | 跨多数据源联邦 SQL、`catalog.schema` 浏览、SQL 执行 |
|
||||
| 搜索 | Elasticsearch | 可选驱动代理 | 索引浏览、Mapping 检查、JSON DSL / query_string 查询 |
|
||||
| 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |
|
||||
|
||||
@@ -73,6 +86,9 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude,同时支持任意自定义兼容 OpenAI 格式的 API。
|
||||
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI,让 SQL 生成、分析变得更精准。
|
||||
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等)。
|
||||
- **内置 MCP 工作流**:可在 AI 设置里管理 MCP 服务,一键安装 GoNavi MCP 到 Claude Code / Codex,或开启 Streamable HTTP 供远端 Agent 使用。
|
||||
- **远端 Agent 边界清晰**:数据库连接与密码继续保留在运行 GoNavi 的主机上,云端 Agent 通过 MCP 读取结构与上下文。
|
||||
- **安全控制可追溯**:远端 `schema-only` 模式默认不暴露 `execute_sql`;如开启 SQL 执行,非只读语句仍需显式传入 `allowMutating=true`。
|
||||
|
||||
### 性能与交互
|
||||
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
|
||||
@@ -162,6 +178,82 @@ wails build -clean
|
||||
|
||||
构建产物位于 `build/bin`。
|
||||
|
||||
### Docker / Podman(仅 MCP Server)
|
||||
|
||||
当前容器化支持仅覆盖 `gonavi-mcp-server`,不包含桌面 GUI 主界面。
|
||||
|
||||
### 浏览器访问版(实验中)
|
||||
|
||||
仓库主程序现在额外提供了一个 `web-server` 运行模式,用同一套 Go 后端 + React 前端提供浏览器访问入口:
|
||||
|
||||
```powershell
|
||||
go build .
|
||||
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
|
||||
```
|
||||
|
||||
首次访问会进入 `/setup` 完成 Web 管理员密码初始化;可选启用 Google Authenticator,服务端会落地 `web_auth.json`、Session Cookie、恢复码与登录失败限流。
|
||||
|
||||
当前阶段已具备:
|
||||
|
||||
- 浏览器端 Wails bridge(`window.go.*` / `window.runtime.*` -> HTTP / SSE)
|
||||
- 首次初始化页、登录页、登出接口
|
||||
- Session 空闲超时 / 绝对超时 / 记住登录时长
|
||||
- Google Authenticator TOTP 与恢复码
|
||||
|
||||
当前仍在继续收口:
|
||||
|
||||
- 外部 SQL / 连接包导入导出等文件型能力的浏览器上传下载工作台
|
||||
- 更多桌面专属能力的 Web 门禁与替代交互
|
||||
- 反向代理 / HTTPS / 零信任部署说明
|
||||
|
||||
```bash
|
||||
cp docker.mcp-server.env.example docker.mcp-server.env
|
||||
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
|
||||
```
|
||||
|
||||
请把 GoNavi 的活动数据目录挂载进容器。该目录内至少应包含 `connections.json`、`daily_secrets.json`,如需可选驱动代理还应包含 `drivers/`。
|
||||
|
||||
默认 Compose 会直接拉取 GHCR 预构建镜像。如果你要用当前仓库源码本地构建,再叠加一个 override:
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker.mcp-server.env \
|
||||
-f docker-compose.mcp-server.yml \
|
||||
-f docker-compose.mcp-server.local.yml \
|
||||
up -d --build
|
||||
```
|
||||
|
||||
如果你使用 Podman,可直接复用同一套 OCI 镜像,走 `podman run` 或 Quadlet。仓库内已经提供 [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server) 作为 Podman 原生部署入口,适合 Linux 服务器 / NAS 的 rootless systemd 常驻服务。
|
||||
|
||||
部署矩阵:
|
||||
|
||||
- Docker Desktop / Linux 服务器 / NAS:`docker-compose.mcp-server.yml`
|
||||
- Podman / Quadlet:[deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server)
|
||||
- Kubernetes:[deploy/k8s/gonavi-mcp-server](deploy/k8s/gonavi-mcp-server)(`kustomization.yaml` + overlays)
|
||||
- Helm Chart:[deploy/helm/gonavi-mcp-server](deploy/helm/gonavi-mcp-server)
|
||||
- 仅构建环境:`Dockerfile.build-env`
|
||||
|
||||
完整部署方式与安全边界见 [cmd/gonavi-mcp-server/README.md](cmd/gonavi-mcp-server/README.md)。
|
||||
|
||||
### Docker / Podman(仅构建环境)
|
||||
|
||||
如果只是想要一套稳定的 Linux 构建环境来编译 Wails,可直接使用 `Dockerfile.build-env`:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
|
||||
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
|
||||
```
|
||||
|
||||
同一个 Dockerfile 也可以直接给 Podman 使用,例如 `podman build -f Dockerfile.build-env -t localhost/gonavi-build-env:local .`,然后执行 `podman run --rm -it -v "$PWD:/workspace" -w /workspace localhost/gonavi-build-env:local bash`。
|
||||
|
||||
这个镜像默认安装 WebKitGTK 4.0 构建依赖,兼容面更适合常见 Linux / NAS 场景。镜像底座本身支持多架构,`amd64` / `arm64` 会跟随容器平台。
|
||||
|
||||
仓库会把预构建镜像推送到 GHCR:
|
||||
|
||||
- `ghcr.io/syngnat/gonavi-mcp-server:latest`
|
||||
- `ghcr.io/syngnat/gonavi-build-env:latest`
|
||||
|
||||
这个镜像只负责构建 Linux 产物,不会把 Wails 桌面 GUI 变成可浏览器访问的 Web 服务。
|
||||
|
||||
### 跨平台发布(GitHub Actions)
|
||||
|
||||
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
- 入参:`connectionId`
|
||||
- `get_tables`
|
||||
- 入参:`connectionId`、可选 `dbName`
|
||||
- 返回表列表,并在 `views` 字段附带视图列表,兼容旧客户端只调用 `get_tables` 的场景
|
||||
- `get_views`
|
||||
- 入参:`connectionId`、可选 `dbName`
|
||||
- 返回视图列表
|
||||
- `get_objects`
|
||||
- 入参:`connectionId`、可选 `dbName`、可选 `objectTypes`
|
||||
- 返回表、视图、触发器、函数、过程、序列、包、事件,以及消息队列类 `topic/queue/exchange` 等对象清单
|
||||
- `get_columns`
|
||||
- 入参:`connectionId`、可选 `dbName`、`tableName`
|
||||
- `get_table_ddl`
|
||||
@@ -20,7 +27,7 @@
|
||||
- 如果 SQL 包含 DDL/DML,必须显式传 `allowMutating=true`
|
||||
- `maxRowsPerResult` 用来限制单个结果集返回的行数,默认 `200`
|
||||
|
||||
远程 Agent 只需要库表结构时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、库表、字段、索引、外键、触发器和 DDL 工具。
|
||||
远程 Agent 只需要结构元数据时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、对象清单、表/视图、字段、索引、外键、触发器和 DDL 工具。
|
||||
|
||||
## 运行方式
|
||||
|
||||
@@ -42,6 +49,187 @@ go run ./cmd/gonavi-mcp-server stdio
|
||||
go build -o .\bin\gonavi-mcp-server.exe .\cmd\gonavi-mcp-server
|
||||
```
|
||||
|
||||
## Docker / Podman / Compose
|
||||
|
||||
当前容器化支持仅覆盖 `gonavi-mcp-server`,不包含 Wails 桌面 GUI。
|
||||
|
||||
当前支持矩阵:
|
||||
|
||||
- Docker Desktop / Linux 服务器 / NAS:直接使用 Compose 或 `docker run`
|
||||
- Podman / Quadlet:使用 `deploy/podman/gonavi-mcp-server`
|
||||
- Kubernetes:使用 `deploy/k8s/gonavi-mcp-server`
|
||||
- Helm:使用 `deploy/helm/gonavi-mcp-server`
|
||||
- 仅构建环境:使用仓库根目录 `Dockerfile.build-env`
|
||||
- 桌面 GUI 浏览器访问版:当前不提供,此仓库主应用仍是 Wails 桌面程序,不是现成的 Web 服务
|
||||
|
||||
仓库根目录已提供以下文件:
|
||||
|
||||
- `Dockerfile.mcp-server`
|
||||
- `docker-compose.mcp-server.yml`
|
||||
- `docker.mcp-server.env.example`
|
||||
- `deploy/podman/gonavi-mcp-server/*`
|
||||
|
||||
推荐流程:
|
||||
|
||||
```bash
|
||||
cp docker.mcp-server.env.example docker.mcp-server.env
|
||||
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
|
||||
```
|
||||
|
||||
默认 Compose 会拉取 GHCR 预构建镜像。如果你要基于当前工作区源码本地构建,再叠加:
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker.mcp-server.env \
|
||||
-f docker-compose.mcp-server.yml \
|
||||
-f docker-compose.mcp-server.local.yml \
|
||||
up -d --build
|
||||
```
|
||||
|
||||
其中 `GONAVI_HOST_DATA_ROOT` 必须指向 GoNavi 当前活动数据目录。该目录内至少应包含:
|
||||
|
||||
- `connections.json`
|
||||
- `daily_secrets.json`
|
||||
- `drivers/`(如果目标连接依赖可选 driver agent)
|
||||
|
||||
容器内默认会设置:
|
||||
|
||||
- `GONAVI_DATA_ROOT=/data`
|
||||
- `GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765`
|
||||
- `GONAVI_MCP_HTTP_PATH=/mcp`
|
||||
|
||||
`GONAVI_DATA_ROOT` 会覆盖默认活动数据目录解析逻辑,避免宿主机路径与容器内路径不一致时依赖 `storage_root.json` 的绝对路径。
|
||||
|
||||
如果你只想手动构建镜像:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.mcp-server -t gonavi-mcp-server:local .
|
||||
docker run --rm -p 8765:8765 \
|
||||
-e GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token \
|
||||
-e GONAVI_MCP_SCHEMA_ONLY=true \
|
||||
-e GONAVI_DATA_ROOT=/data \
|
||||
-v /absolute/path/to/gonavi-data:/data \
|
||||
gonavi-mcp-server:local http
|
||||
```
|
||||
|
||||
如果你直接使用已发布镜像:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8765:8765 \
|
||||
-e GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token \
|
||||
-e GONAVI_MCP_SCHEMA_ONLY=true \
|
||||
-e GONAVI_DATA_ROOT=/data \
|
||||
-v /absolute/path/to/gonavi-data:/data \
|
||||
ghcr.io/syngnat/gonavi-mcp-server:latest http
|
||||
```
|
||||
|
||||
### Podman
|
||||
|
||||
仓库内还提供了 Podman 原生部署样例:
|
||||
|
||||
- `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example`
|
||||
- `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container`
|
||||
- `deploy/podman/gonavi-mcp-server/README.md`
|
||||
|
||||
直接运行已发布镜像:
|
||||
|
||||
```bash
|
||||
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-server.env
|
||||
podman run -d --name gonavi-mcp-server --replace \
|
||||
-p 8765:8765 \
|
||||
--env-file ./gonavi-mcp-server.env \
|
||||
-v /absolute/path/to/gonavi-data:/data:Z \
|
||||
ghcr.io/syngnat/gonavi-mcp-server:latest http
|
||||
```
|
||||
|
||||
如果你要基于当前源码本地构建:
|
||||
|
||||
```bash
|
||||
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
|
||||
podman run -d --name gonavi-mcp-server --replace \
|
||||
-p 8765:8765 \
|
||||
--env-file ./gonavi-mcp-server.env \
|
||||
-v /absolute/path/to/gonavi-data:/data:Z \
|
||||
localhost/gonavi-mcp-server:local http
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `gonavi-mcp-server.env` 用 `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example` 初始化
|
||||
- `:Z` 适用于开启 SELinux 的宿主机;未启用 SELinux 可去掉
|
||||
- 更适合长期运行的方式见 [deploy/podman/gonavi-mcp-server/README.md](../../deploy/podman/gonavi-mcp-server/README.md) 中的 Quadlet 示例
|
||||
|
||||
`podman compose` 本身依赖外部 compose provider,所以仓库对 Podman 的主支持路径是 `podman run` 与 Quadlet,而不是假设所有环境都能直接复用 Compose。
|
||||
|
||||
## Kubernetes
|
||||
|
||||
仓库内置了最小 K8s 示例:
|
||||
|
||||
- `deploy/k8s/gonavi-mcp-server/kustomization.yaml`
|
||||
- `deploy/k8s/gonavi-mcp-server/base/deployment.yaml`
|
||||
- `deploy/k8s/gonavi-mcp-server/base/service.yaml`
|
||||
- `deploy/k8s/gonavi-mcp-server/README.md`
|
||||
- `deploy/k8s/gonavi-mcp-server/overlays/*`
|
||||
|
||||
推荐先从现有 GoNavi 数据目录生成 Secret:
|
||||
|
||||
```bash
|
||||
kubectl create namespace gonavi
|
||||
kubectl -n gonavi create secret generic gonavi-mcp-server-data \
|
||||
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
|
||||
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
|
||||
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server
|
||||
```
|
||||
|
||||
如果需要 NAS hostPath、可选 driver agent PVC、Ingress,或两者组合,可直接使用 `overlays/nas-hostpath`、`overlays/drivers-pvc`、`overlays/ingress`、`overlays/ingress-with-drivers-pvc`。
|
||||
|
||||
更完整的说明见 [deploy/k8s/gonavi-mcp-server/README.md](../../deploy/k8s/gonavi-mcp-server/README.md)。
|
||||
|
||||
## Helm
|
||||
|
||||
如果你希望把镜像、Secret、Ingress、hostPath / PVC 挂载做成参数化部署,而不是维护多份 Kustomize overlay,可直接使用:
|
||||
|
||||
- `deploy/helm/gonavi-mcp-server`
|
||||
|
||||
快速安装:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server -n gonavi --create-namespace
|
||||
```
|
||||
|
||||
Chart 详细说明见 [deploy/helm/gonavi-mcp-server/README.md](../../deploy/helm/gonavi-mcp-server/README.md)。
|
||||
|
||||
## Docker / Podman Build Environment
|
||||
|
||||
如果你的目标不是运行 MCP,而是给 Linux 服务器 / NAS / CI 准备一套可重复的 Wails 构建环境,可直接使用仓库根目录的 `Dockerfile.build-env`:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
|
||||
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
|
||||
```
|
||||
|
||||
如果你使用 Podman,也可以直接执行:
|
||||
|
||||
```bash
|
||||
podman build -f Dockerfile.build-env -t localhost/gonavi-build-env:local .
|
||||
podman run --rm -it -v "$PWD:/workspace" -w /workspace localhost/gonavi-build-env:local bash
|
||||
```
|
||||
|
||||
镜像内已预装 Go、Node、Wails CLI、GTK3 与 WebKitGTK 开发依赖,适合执行:
|
||||
|
||||
```bash
|
||||
wails build
|
||||
```
|
||||
|
||||
这个镜像默认安装 WebKitGTK 4.0 构建依赖,适合作为通用 Linux / NAS 构建环境。镜像基座支持多架构,`amd64` / `arm64` 会跟随容器平台。
|
||||
|
||||
预构建镜像会发布到 GHCR:
|
||||
|
||||
- `ghcr.io/syngnat/gonavi-mcp-server:latest`
|
||||
- `ghcr.io/syngnat/gonavi-build-env:latest`
|
||||
|
||||
它只负责构建 Linux 产物,不会把 Wails 主程序变成浏览器版服务。
|
||||
|
||||
远程 Agent 使用 Streamable HTTP 时必须设置 bearer token:
|
||||
|
||||
```powershell
|
||||
@@ -121,7 +309,7 @@ OpenClaw、Hermans 这类部署在云端或远端 Linux 的 Agent,不能直接
|
||||
2. 在 Windows 本机启动 `GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only`。
|
||||
3. 通过 SSH 隧道、反向代理或内网网关把 `http://127.0.0.1:8765/mcp` 暴露为云端 Agent 可访问的 HTTPS 地址。
|
||||
4. 在 OpenClaw / Hermans 中添加远程 MCP Server,transport 选择 Streamable HTTP,URL 指向 `/mcp` 地址,并设置请求头 `Authorization: Bearer <随机token>`。
|
||||
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases`、`get_tables`、`get_columns`、`get_table_ddl` 等工具读取结构。
|
||||
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases`、`get_objects`、`get_tables`、`get_views`、`get_columns`、`get_table_ddl` 等工具读取结构。
|
||||
|
||||
如果目标 Agent 支持 `mcpServers` JSON,可按下面的通用片段配置:
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -33,6 +36,8 @@ type agentResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
Messages []string `json:"messages,omitempty"`
|
||||
ChunkType string `json:"chunkType,omitempty"`
|
||||
RowsAffected int64 `json:"rowsAffected,omitempty"`
|
||||
}
|
||||
|
||||
@@ -44,6 +49,8 @@ const (
|
||||
agentMethodOpenSession = "openSession"
|
||||
agentMethodCloseSession = "closeSession"
|
||||
agentMethodQuery = "query"
|
||||
agentMethodQueryMulti = "queryMulti"
|
||||
agentMethodStreamQuery = "streamQuery"
|
||||
agentMethodExec = "exec"
|
||||
agentMethodGetDatabases = "getDatabases"
|
||||
agentMethodGetTables = "getTables"
|
||||
@@ -58,9 +65,31 @@ const (
|
||||
|
||||
const legacyClickHouseDefaultTimeout = 2 * time.Hour
|
||||
|
||||
const (
|
||||
agentChunkColumns = "columns"
|
||||
agentChunkRows = "rows"
|
||||
agentChunkDone = "done"
|
||||
// agentStreamBatchSize 控制 driver-agent 向主进程发送 row chunk 的批次大小。
|
||||
// 调小到 64:单批 JSON 编码 + 主进程解码的瞬时内存峰值降为原来的 1/4,
|
||||
// 代价是 IPC 次数变为 4 倍,但每批仅一次 stdin/stdout 行读写,整体影响可忽略。
|
||||
// 重要:减小批次不能根除内存峰值,仍需配合 SetGCPercent + 周期 GC(见 main)。
|
||||
agentStreamBatchSize = 64
|
||||
agentMemoryTrimRowsThreshold = 100000
|
||||
agentMemoryTrimMinInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
agentDriverType string
|
||||
agentDatabaseFactory func() db.Database
|
||||
agentDriverType string
|
||||
agentDatabaseFactory func() db.Database
|
||||
agentMemoryTrimRunning atomic.Bool
|
||||
agentMemoryTrimLastAt atomic.Int64
|
||||
runAgentMemoryTrimAsync = func(fn func()) {
|
||||
go fn()
|
||||
}
|
||||
agentMemoryTrimFn = func() {
|
||||
runtime.GC()
|
||||
debug.FreeOSMemory()
|
||||
}
|
||||
)
|
||||
|
||||
type agentRuntime struct {
|
||||
@@ -75,6 +104,20 @@ func main() {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// driver-agent 是独立进程,主进程无法控制其 GC 行为。
|
||||
// 大结果集(88W+ 行)通过 JSON-lines 跨进程传输时,每行有 5-8 倍内存副本;
|
||||
// Go 默认 GOGC=100 + Windows MADV_FREE 不归还 RSS,会导致 driver-agent 进程
|
||||
// 内存峰值达到数据总量的 10+ 倍(用户实测 88W 普通业务表撑到 8G+)。
|
||||
//
|
||||
// GC 策略组合:
|
||||
// - SetGCPercent(50):堆增长 50% 即触发 GC,比默认 100 更早收敛
|
||||
// - InitMemorySoftLimit:起始 2GB,运行时由 MaybeGrowMemoryLimit 自适应抬升到最多 8GB
|
||||
// (起步保守 + 按需扩张,避免静态 2GB 限制在大表场景触发 GC 硬模式降速 15-25%)
|
||||
//
|
||||
// 代价:CPU 开销增加约 5-10%。导出场景是 I/O 密集型,可忽略。
|
||||
debug.SetGCPercent(50)
|
||||
db.InitMemorySoftLimit(db.MemorySoftLimitInitialBytes)
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 16<<10), 8<<20)
|
||||
writer := bufio.NewWriter(os.Stdout)
|
||||
@@ -99,11 +142,22 @@ func main() {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Method) == agentMethodStreamQuery {
|
||||
if err := handleStreamRequest(runtimeState, req, writer); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "写入流式响应失败:%v\n", err)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
resp := handleRequest(runtimeState, req)
|
||||
if err := writeResponse(writer, resp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err)
|
||||
break
|
||||
}
|
||||
if strings.TrimSpace(req.Method) == agentMethodQuery {
|
||||
maybeReleaseAgentMemory("query-response", countAgentResponseRows(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
runtimeState.close()
|
||||
@@ -184,12 +238,23 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
|
||||
} else if ok {
|
||||
switch method {
|
||||
case agentMethodQuery:
|
||||
data, fields, err := queryStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs)
|
||||
data, fields, messages, err := queryStatementWithMessagesOptionalTimeout(session, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Fields = fields
|
||||
resp.Messages = messages
|
||||
case agentMethodQueryMulti:
|
||||
data, messages, supported, err := queryMultiStatementWithMessagesOptionalTimeout(session, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
if !supported {
|
||||
return fail(resp, "当前事务会话不支持多结果集查询")
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Messages = messages
|
||||
case agentMethodExec:
|
||||
affected, err := execStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
@@ -208,12 +273,23 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
case agentMethodQuery:
|
||||
data, fields, err := queryWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
|
||||
data, fields, messages, err := queryWithMessagesOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Fields = fields
|
||||
resp.Messages = messages
|
||||
case agentMethodQueryMulti:
|
||||
data, messages, supported, err := queryMultiWithMessagesOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
if !supported {
|
||||
return fail(resp, "当前驱动不支持原生多结果集查询")
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Messages = messages
|
||||
case agentMethodExec:
|
||||
affected, err := execWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
@@ -288,6 +364,108 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
|
||||
return resp
|
||||
}
|
||||
|
||||
type agentStreamResponseWriter struct {
|
||||
writer *bufio.Writer
|
||||
requestID int64
|
||||
columns []string
|
||||
rows [][]interface{}
|
||||
rowCount int64
|
||||
}
|
||||
|
||||
func newAgentStreamResponseWriter(writer *bufio.Writer, requestID int64) *agentStreamResponseWriter {
|
||||
return &agentStreamResponseWriter{
|
||||
writer: writer,
|
||||
requestID: requestID,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *agentStreamResponseWriter) SetColumns(columns []string) error {
|
||||
w.columns = append([]string(nil), columns...)
|
||||
return writeResponse(w.writer, agentResponse{
|
||||
ID: w.requestID,
|
||||
Success: true,
|
||||
ChunkType: agentChunkColumns,
|
||||
Fields: w.columns,
|
||||
})
|
||||
}
|
||||
|
||||
func (w *agentStreamResponseWriter) ConsumeRow(row map[string]interface{}) error {
|
||||
if len(w.columns) == 0 {
|
||||
return fmt.Errorf("流式查询缺少列定义")
|
||||
}
|
||||
values := make([]interface{}, len(w.columns))
|
||||
for idx, column := range w.columns {
|
||||
values[idx] = row[column]
|
||||
}
|
||||
return w.ConsumeRowValues(values)
|
||||
}
|
||||
|
||||
func (w *agentStreamResponseWriter) ConsumeRowValues(values []interface{}) error {
|
||||
row := append([]interface{}(nil), values...)
|
||||
w.rows = append(w.rows, row)
|
||||
w.rowCount++
|
||||
if len(w.rows) < agentStreamBatchSize {
|
||||
return nil
|
||||
}
|
||||
return w.flushRows()
|
||||
}
|
||||
|
||||
func (w *agentStreamResponseWriter) flushRows() error {
|
||||
if len(w.rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := w.rows
|
||||
w.rows = nil
|
||||
return writeResponse(w.writer, agentResponse{
|
||||
ID: w.requestID,
|
||||
Success: true,
|
||||
ChunkType: agentChunkRows,
|
||||
Data: rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (w *agentStreamResponseWriter) finish() error {
|
||||
return w.flushRows()
|
||||
}
|
||||
|
||||
func handleStreamRequest(runtimeState *agentRuntime, req agentRequest, writer *bufio.Writer) error {
|
||||
resp := agentResponse{ID: req.ID, Success: true}
|
||||
if runtimeState.inst == nil {
|
||||
return writeResponse(writer, fail(resp, "connection not open"))
|
||||
}
|
||||
|
||||
streamWriter := newAgentStreamResponseWriter(writer, req.ID)
|
||||
if session, ok, err := runtimeState.session(req.SessionID); err != nil {
|
||||
return writeResponse(writer, fail(resp, err.Error()))
|
||||
} else if ok {
|
||||
if err := streamStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs, streamWriter); err != nil {
|
||||
_ = streamWriter.finish()
|
||||
return writeResponse(writer, fail(resp, err.Error()))
|
||||
}
|
||||
if err := streamWriter.finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeResponse(writer, agentResponse{ID: req.ID, Success: true, ChunkType: agentChunkDone}); err != nil {
|
||||
return err
|
||||
}
|
||||
maybeReleaseAgentMemory("stream-query-session", streamWriter.rowCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := streamDatabaseWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs, streamWriter); err != nil {
|
||||
_ = streamWriter.finish()
|
||||
return writeResponse(writer, fail(resp, err.Error()))
|
||||
}
|
||||
if err := streamWriter.finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeResponse(writer, agentResponse{ID: req.ID, Success: true, ChunkType: agentChunkDone}); err != nil {
|
||||
return err
|
||||
}
|
||||
maybeReleaseAgentMemory("stream-query-db", streamWriter.rowCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *agentRuntime) nextID() string {
|
||||
r.ensureSessionMap()
|
||||
r.nextSessionID++
|
||||
@@ -427,6 +605,30 @@ type agentQueryContextRunner interface {
|
||||
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
|
||||
}
|
||||
|
||||
type agentQueryMessageRunner interface {
|
||||
QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error)
|
||||
}
|
||||
|
||||
type agentQueryMessageContextRunner interface {
|
||||
QueryContextWithMessages(context.Context, string) ([]map[string]interface{}, []string, []string, error)
|
||||
}
|
||||
|
||||
type agentMultiResultMessageRunner interface {
|
||||
QueryMultiWithMessages(query string) ([]connection.ResultSetData, []string, error)
|
||||
}
|
||||
|
||||
type agentMultiResultMessageContextRunner interface {
|
||||
QueryMultiContextWithMessages(context.Context, string) ([]connection.ResultSetData, []string, error)
|
||||
}
|
||||
|
||||
type agentMultiResultRunner interface {
|
||||
QueryMulti(query string) ([]connection.ResultSetData, error)
|
||||
}
|
||||
|
||||
type agentMultiResultContextRunner interface {
|
||||
QueryMultiContext(context.Context, string) ([]connection.ResultSetData, error)
|
||||
}
|
||||
|
||||
type agentExecRunner interface {
|
||||
Exec(string) (int64, error)
|
||||
}
|
||||
@@ -435,20 +637,39 @@ type agentExecContextRunner interface {
|
||||
ExecContext(context.Context, string) (int64, error)
|
||||
}
|
||||
|
||||
func queryWithOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
|
||||
func queryWithMessagesOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, []string, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs <= 0 {
|
||||
return inst.Query(query)
|
||||
if q, ok := inst.(agentQueryMessageRunner); ok {
|
||||
return q.QueryWithMessages(query)
|
||||
}
|
||||
data, fields, err := inst.Query(query)
|
||||
return data, fields, nil, err
|
||||
}
|
||||
if q, ok := inst.(agentQueryMessageContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
return q.QueryContextWithMessages(ctx, query)
|
||||
}
|
||||
if q, ok := inst.(agentQueryContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
return q.QueryContext(ctx, query)
|
||||
data, fields, err := q.QueryContext(ctx, query)
|
||||
return data, fields, nil, err
|
||||
}
|
||||
return inst.Query(query)
|
||||
if q, ok := inst.(agentQueryMessageRunner); ok {
|
||||
return q.QueryWithMessages(query)
|
||||
}
|
||||
data, fields, err := inst.Query(query)
|
||||
return data, fields, nil, err
|
||||
}
|
||||
|
||||
func queryWithOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
|
||||
data, fields, _, err := queryWithMessagesOptionalTimeout(inst, query, timeoutMs)
|
||||
return data, fields, err
|
||||
}
|
||||
|
||||
func queryStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
|
||||
@@ -459,6 +680,150 @@ func queryStatementWithOptionalTimeout(inst db.StatementExecer, query string, ti
|
||||
return queryWithOptionalTimeout(queryRunner, query, timeoutMs)
|
||||
}
|
||||
|
||||
func queryStatementWithMessagesOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]map[string]interface{}, []string, []string, error) {
|
||||
queryRunner, ok := inst.(agentQueryRunner)
|
||||
if !ok {
|
||||
return nil, nil, nil, fmt.Errorf("当前事务会话不支持查询语句")
|
||||
}
|
||||
return queryWithMessagesOptionalTimeout(queryRunner, query, timeoutMs)
|
||||
}
|
||||
|
||||
func queryMultiWithMessagesOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]connection.ResultSetData, []string, bool, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs > 0 {
|
||||
if q, ok := inst.(agentMultiResultMessageContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
data, messages, err := q.QueryMultiContextWithMessages(ctx, query)
|
||||
return data, messages, true, err
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
data, err := q.QueryMultiContext(ctx, query)
|
||||
return data, nil, true, err
|
||||
}
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultMessageRunner); ok {
|
||||
data, messages, err := q.QueryMultiWithMessages(query)
|
||||
return data, messages, true, err
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultRunner); ok {
|
||||
data, err := q.QueryMulti(query)
|
||||
return data, nil, true, err
|
||||
}
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
|
||||
func queryMultiStatementWithMessagesOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]connection.ResultSetData, []string, bool, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs > 0 {
|
||||
if q, ok := inst.(agentMultiResultMessageContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
data, messages, err := q.QueryMultiContextWithMessages(ctx, query)
|
||||
return data, messages, true, err
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
data, err := q.QueryMultiContext(ctx, query)
|
||||
return data, nil, true, err
|
||||
}
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultMessageRunner); ok {
|
||||
data, messages, err := q.QueryMultiWithMessages(query)
|
||||
return data, messages, true, err
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultRunner); ok {
|
||||
data, err := q.QueryMulti(query)
|
||||
return data, nil, true, err
|
||||
}
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
|
||||
func streamWithOptionalTimeout(inst db.StreamQueryExecer, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs <= 0 {
|
||||
return inst.StreamQuery(query, consumer)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
return inst.StreamQueryContext(ctx, query, consumer)
|
||||
}
|
||||
|
||||
func streamBufferedQueryResult(fields []string, data []map[string]interface{}, consumer db.QueryStreamConsumer) error {
|
||||
if err := consumer.SetColumns(fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if valueConsumer, ok := consumer.(db.QueryStreamValueConsumer); ok {
|
||||
for _, row := range data {
|
||||
values := make([]interface{}, len(fields))
|
||||
for idx, field := range fields {
|
||||
values[idx] = row[field]
|
||||
}
|
||||
if err := valueConsumer.ConsumeRowValues(values); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, row := range data {
|
||||
if err := consumer.ConsumeRow(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
|
||||
if streamer, ok := inst.(db.StreamQueryExecer); ok {
|
||||
return streamWithOptionalTimeout(streamer, query, timeoutMs, consumer)
|
||||
}
|
||||
data, fields, err := queryStatementWithOptionalTimeout(inst, query, timeoutMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return streamBufferedQueryResult(fields, data, consumer)
|
||||
}
|
||||
|
||||
func streamDatabaseWithOptionalTimeout(inst db.Database, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
|
||||
if streamer, ok := inst.(db.StreamQueryExecer); ok {
|
||||
return streamWithOptionalTimeout(streamer, query, timeoutMs, consumer)
|
||||
}
|
||||
if provider, ok := inst.(db.SessionExecerProvider); ok {
|
||||
openCtx := context.Background()
|
||||
var cancel context.CancelFunc
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs > 0 {
|
||||
openCtx, cancel = context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
session, err := provider.OpenSessionExecer(openCtx)
|
||||
if err == nil {
|
||||
defer session.Close()
|
||||
return streamStatementWithOptionalTimeout(session, query, timeoutMs, consumer)
|
||||
}
|
||||
}
|
||||
data, fields, err := queryWithOptionalTimeout(inst, query, timeoutMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return streamBufferedQueryResult(fields, data, consumer)
|
||||
}
|
||||
|
||||
func execWithOptionalTimeout(inst agentExecRunner, query string, timeoutMs int64) (int64, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
@@ -478,3 +843,41 @@ func execWithOptionalTimeout(inst agentExecRunner, query string, timeoutMs int64
|
||||
func execStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) (int64, error) {
|
||||
return execWithOptionalTimeout(inst, query, timeoutMs)
|
||||
}
|
||||
|
||||
func countAgentResponseRows(data interface{}) int64 {
|
||||
rows, ok := data.([]map[string]interface{})
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int64(len(rows))
|
||||
}
|
||||
|
||||
func maybeReleaseAgentMemory(reason string, rows int64) {
|
||||
if rows < agentMemoryTrimRowsThreshold {
|
||||
return
|
||||
}
|
||||
if !agentMemoryTrimRunning.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
|
||||
runAgentMemoryTrimAsync(func() {
|
||||
defer agentMemoryTrimRunning.Store(false)
|
||||
if delay := nextAgentMemoryTrimDelay(); delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
agentMemoryTrimFn()
|
||||
agentMemoryTrimLastAt.Store(time.Now().UnixNano())
|
||||
})
|
||||
}
|
||||
|
||||
func nextAgentMemoryTrimDelay() time.Duration {
|
||||
lastUnixNano := agentMemoryTrimLastAt.Load()
|
||||
if lastUnixNano <= 0 {
|
||||
return 0
|
||||
}
|
||||
elapsed := time.Since(time.Unix(0, lastUnixNano))
|
||||
if elapsed >= agentMemoryTrimMinInterval {
|
||||
return 0
|
||||
}
|
||||
return agentMemoryTrimMinInterval - elapsed
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@ type fakeAgentTimeoutDB struct {
|
||||
execCalled bool
|
||||
execContextCalled bool
|
||||
deadlineSet bool
|
||||
queryMessages []string
|
||||
multiResults []connection.ResultSetData
|
||||
multiMessages []string
|
||||
}
|
||||
|
||||
func (f *fakeAgentTimeoutDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||||
@@ -117,6 +120,14 @@ func (f *fakeAgentTimeoutDB) QueryContext(ctx context.Context, query string) ([]
|
||||
}
|
||||
return []map[string]interface{}{{"ok": 1}}, []string{"ok"}, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error) {
|
||||
data, fields, err := f.QueryContext(context.Background(), query)
|
||||
return data, fields, append([]string(nil), f.queryMessages...), err
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) QueryContextWithMessages(ctx context.Context, query string) ([]map[string]interface{}, []string, []string, error) {
|
||||
data, fields, err := f.QueryContext(ctx, query)
|
||||
return data, fields, append([]string(nil), f.queryMessages...), err
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) Exec(query string) (int64, error) {
|
||||
f.execCalled = true
|
||||
return 0, errors.New("exec should not be called")
|
||||
@@ -150,6 +161,15 @@ func (f *fakeAgentTimeoutDB) GetForeignKeys(dbName, tableName string) ([]connect
|
||||
func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) QueryMultiWithMessages(query string) ([]connection.ResultSetData, []string, error) {
|
||||
return append([]connection.ResultSetData(nil), f.multiResults...), append([]string(nil), f.multiMessages...), nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) QueryMultiContextWithMessages(ctx context.Context, query string) ([]connection.ResultSetData, []string, error) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
f.deadlineSet = true
|
||||
}
|
||||
return f.QueryMultiWithMessages(query)
|
||||
}
|
||||
|
||||
type fakeAgentSessionDB struct {
|
||||
fakeAgentTimeoutDB
|
||||
@@ -165,6 +185,7 @@ type fakeAgentStatementSession struct {
|
||||
queryCalls int
|
||||
execCalls int
|
||||
closed bool
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (f *fakeAgentStatementSession) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
@@ -175,6 +196,14 @@ func (f *fakeAgentStatementSession) QueryContext(ctx context.Context, query stri
|
||||
f.queryCalls++
|
||||
return []map[string]interface{}{{"session_ok": 1}}, []string{"session_ok"}, nil
|
||||
}
|
||||
func (f *fakeAgentStatementSession) QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error) {
|
||||
data, fields, err := f.QueryContext(context.Background(), query)
|
||||
return data, fields, append([]string(nil), f.messages...), err
|
||||
}
|
||||
func (f *fakeAgentStatementSession) QueryContextWithMessages(ctx context.Context, query string) ([]map[string]interface{}, []string, []string, error) {
|
||||
data, fields, err := f.QueryContext(ctx, query)
|
||||
return data, fields, append([]string(nil), f.messages...), err
|
||||
}
|
||||
|
||||
func (f *fakeAgentStatementSession) Exec(query string) (int64, error) {
|
||||
return f.ExecContext(context.Background(), query)
|
||||
@@ -190,6 +219,64 @@ func (f *fakeAgentStatementSession) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAgentStreamSession struct {
|
||||
closed bool
|
||||
streamCalls int
|
||||
deadlineSet bool
|
||||
}
|
||||
|
||||
func (f *fakeAgentStreamSession) Exec(query string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentStreamSession) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentStreamSession) Close() error {
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentStreamSession) StreamQuery(query string, consumer db.QueryStreamConsumer) error {
|
||||
return f.StreamQueryContext(context.Background(), query, consumer)
|
||||
}
|
||||
|
||||
func (f *fakeAgentStreamSession) StreamQueryContext(ctx context.Context, query string, consumer db.QueryStreamConsumer) error {
|
||||
f.streamCalls++
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
f.deadlineSet = true
|
||||
}
|
||||
if err := consumer.SetColumns([]string{"id", "name"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if valueConsumer, ok := consumer.(db.QueryStreamValueConsumer); ok {
|
||||
if err := valueConsumer.ConsumeRowValues([]interface{}{1, "alice"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := valueConsumer.ConsumeRowValues([]interface{}{2, "bob"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := consumer.ConsumeRow(map[string]interface{}{"id": 1, "name": "alice"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return consumer.ConsumeRow(map[string]interface{}{"id": 2, "name": "bob"})
|
||||
}
|
||||
|
||||
type fakeAgentSessionStreamDB struct {
|
||||
fakeAgentTimeoutDB
|
||||
session *fakeAgentStreamSession
|
||||
openCalls int
|
||||
}
|
||||
|
||||
func (f *fakeAgentSessionStreamDB) OpenSessionExecer(ctx context.Context) (db.StatementExecer, error) {
|
||||
f.openCalls++
|
||||
f.session = &fakeAgentStreamSession{}
|
||||
return f.session, nil
|
||||
}
|
||||
|
||||
func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) {
|
||||
fake := &fakeAgentTimeoutDB{}
|
||||
data, fields, err := queryWithOptionalTimeout(fake, "SELECT 1", int64((2 * time.Second).Milliseconds()))
|
||||
@@ -239,6 +326,77 @@ func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequest_QueryIncludesServerMessages(t *testing.T) {
|
||||
old := agentDriverType
|
||||
defer func() { agentDriverType = old }()
|
||||
agentDriverType = "sqlserver"
|
||||
|
||||
fake := &fakeAgentTimeoutDB{
|
||||
queryMessages: []string{"PRINT sql line 1", "PRINT sql line 2"},
|
||||
}
|
||||
runtimeState := &agentRuntime{inst: fake, sessions: make(map[string]db.StatementExecer)}
|
||||
|
||||
resp := handleRequest(runtimeState, agentRequest{
|
||||
ID: 11,
|
||||
Method: agentMethodQuery,
|
||||
Query: "exec dbo.p_get_select",
|
||||
TimeoutMs: int64((2 * time.Second).Milliseconds()),
|
||||
})
|
||||
if !resp.Success {
|
||||
t.Fatalf("query request failed: %s", resp.Error)
|
||||
}
|
||||
if len(resp.Messages) != 2 || resp.Messages[0] != "PRINT sql line 1" {
|
||||
t.Fatalf("expected query messages to be preserved, got %#v", resp.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequest_QueryMultiIncludesResultSetsAndMessages(t *testing.T) {
|
||||
old := agentDriverType
|
||||
defer func() { agentDriverType = old }()
|
||||
agentDriverType = "sqlserver"
|
||||
|
||||
fake := &fakeAgentTimeoutDB{
|
||||
multiResults: []connection.ResultSetData{
|
||||
{
|
||||
StatementIndex: 1,
|
||||
Rows: []map[string]interface{}{{"name": "master"}},
|
||||
Columns: []string{"name"},
|
||||
},
|
||||
{
|
||||
StatementIndex: 1,
|
||||
Rows: []map[string]interface{}{},
|
||||
Columns: []string{},
|
||||
Messages: []string{"PRINT generated sql"},
|
||||
},
|
||||
},
|
||||
multiMessages: []string{"batch top-level message"},
|
||||
}
|
||||
runtimeState := &agentRuntime{inst: fake, sessions: make(map[string]db.StatementExecer)}
|
||||
|
||||
resp := handleRequest(runtimeState, agentRequest{
|
||||
ID: 12,
|
||||
Method: agentMethodQueryMulti,
|
||||
Query: "exec dbo.p_get_select",
|
||||
TimeoutMs: int64((2 * time.Second).Milliseconds()),
|
||||
})
|
||||
if !resp.Success {
|
||||
t.Fatalf("queryMulti request failed: %s", resp.Error)
|
||||
}
|
||||
if len(resp.Messages) != 1 || resp.Messages[0] != "batch top-level message" {
|
||||
t.Fatalf("expected top-level messages to be preserved, got %#v", resp.Messages)
|
||||
}
|
||||
resultSets, ok := resp.Data.([]connection.ResultSetData)
|
||||
if !ok {
|
||||
t.Fatalf("expected []connection.ResultSetData, got %T", resp.Data)
|
||||
}
|
||||
if len(resultSets) != 2 {
|
||||
t.Fatalf("expected 2 result sets, got %#v", resultSets)
|
||||
}
|
||||
if len(resultSets[1].Messages) != 1 || resultSets[1].Messages[0] != "PRINT generated sql" {
|
||||
t.Fatalf("expected message-only result set to be preserved, got %#v", resultSets[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequest_UsesPinnedSessionForSessionScopedQueryAndExec(t *testing.T) {
|
||||
old := agentDriverType
|
||||
defer func() { agentDriverType = old }()
|
||||
@@ -271,6 +429,9 @@ func TestHandleRequest_UsesPinnedSessionForSessionScopedQueryAndExec(t *testing.
|
||||
if !queryResp.Success {
|
||||
t.Fatalf("session query failed: %s", queryResp.Error)
|
||||
}
|
||||
if len(queryResp.Messages) != 0 {
|
||||
t.Fatalf("expected empty default session messages, got %#v", queryResp.Messages)
|
||||
}
|
||||
if fake.queryCalled || fake.queryContextCalled {
|
||||
t.Fatalf("expected session query to bypass database-level query path, got Query=%v QueryContext=%v", fake.queryCalled, fake.queryContextCalled)
|
||||
}
|
||||
@@ -306,3 +467,135 @@ func TestHandleRequest_UsesPinnedSessionForSessionScopedQueryAndExec(t *testing.
|
||||
t.Fatal("expected pinned session to close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamRequest_UsesSessionStreamerAndWritesChunks(t *testing.T) {
|
||||
old := agentDriverType
|
||||
originalAsync := runAgentMemoryTrimAsync
|
||||
originalTrim := agentMemoryTrimFn
|
||||
originalLastAt := agentMemoryTrimLastAt.Load()
|
||||
defer func() { agentDriverType = old }()
|
||||
defer func() {
|
||||
runAgentMemoryTrimAsync = originalAsync
|
||||
agentMemoryTrimFn = originalTrim
|
||||
agentMemoryTrimRunning.Store(false)
|
||||
agentMemoryTrimLastAt.Store(originalLastAt)
|
||||
}()
|
||||
agentDriverType = "oceanbase"
|
||||
agentMemoryTrimRunning.Store(false)
|
||||
agentMemoryTrimLastAt.Store(0)
|
||||
|
||||
fake := &fakeAgentSessionStreamDB{}
|
||||
runtimeState := &agentRuntime{
|
||||
inst: fake,
|
||||
sessions: make(map[string]db.StatementExecer),
|
||||
}
|
||||
|
||||
trimmed := 0
|
||||
runAgentMemoryTrimAsync = func(fn func()) {
|
||||
fn()
|
||||
}
|
||||
agentMemoryTrimFn = func() {
|
||||
trimmed++
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
writer := bufio.NewWriter(&out)
|
||||
if err := handleStreamRequest(runtimeState, agentRequest{
|
||||
ID: 9,
|
||||
Method: agentMethodStreamQuery,
|
||||
Query: "SELECT * FROM person_info",
|
||||
TimeoutMs: int64((2 * time.Second).Milliseconds()),
|
||||
}, writer); err != nil {
|
||||
t.Fatalf("handleStreamRequest 返回错误: %v", err)
|
||||
}
|
||||
|
||||
if fake.openCalls != 1 {
|
||||
t.Fatalf("expected OpenSessionExecer called once, got %d", fake.openCalls)
|
||||
}
|
||||
if fake.session == nil || fake.session.streamCalls != 1 {
|
||||
t.Fatalf("expected session streamer used once, session=%#v", fake.session)
|
||||
}
|
||||
if !fake.session.deadlineSet {
|
||||
t.Fatal("expected stream query context deadline to be set")
|
||||
}
|
||||
if !fake.session.closed {
|
||||
t.Fatal("expected session to close after streaming")
|
||||
}
|
||||
if fake.queryCalled || fake.queryContextCalled {
|
||||
t.Fatalf("unexpected fallback query path, Query=%v QueryContext=%v", fake.queryCalled, fake.queryContextCalled)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("expected 3 stream responses, got %d: %q", len(lines), out.String())
|
||||
}
|
||||
|
||||
var columnsResp struct {
|
||||
Success bool `json:"success"`
|
||||
ChunkType string `json:"chunkType"`
|
||||
Fields []string `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[0]), &columnsResp); err != nil {
|
||||
t.Fatalf("decode columns response failed: %v", err)
|
||||
}
|
||||
if !columnsResp.Success || columnsResp.ChunkType != agentChunkColumns || len(columnsResp.Fields) != 2 {
|
||||
t.Fatalf("unexpected columns response: %#v", columnsResp)
|
||||
}
|
||||
|
||||
var rowsResp struct {
|
||||
Success bool `json:"success"`
|
||||
ChunkType string `json:"chunkType"`
|
||||
Data [][]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[1]), &rowsResp); err != nil {
|
||||
t.Fatalf("decode rows response failed: %v", err)
|
||||
}
|
||||
if !rowsResp.Success || rowsResp.ChunkType != agentChunkRows || len(rowsResp.Data) != 2 {
|
||||
t.Fatalf("unexpected rows response: %#v", rowsResp)
|
||||
}
|
||||
if got := rowsResp.Data[1][1]; got != "bob" {
|
||||
t.Fatalf("unexpected streamed row payload: %v", rowsResp.Data)
|
||||
}
|
||||
|
||||
var doneResp struct {
|
||||
Success bool `json:"success"`
|
||||
ChunkType string `json:"chunkType"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[2]), &doneResp); err != nil {
|
||||
t.Fatalf("decode done response failed: %v", err)
|
||||
}
|
||||
if !doneResp.Success || doneResp.ChunkType != agentChunkDone {
|
||||
t.Fatalf("unexpected done response: %#v", doneResp)
|
||||
}
|
||||
if trimmed != 0 {
|
||||
t.Fatalf("小流式任务不应触发内存回收,got=%d", trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeReleaseAgentMemory_TriggersTrimForLargeJobs(t *testing.T) {
|
||||
originalAsync := runAgentMemoryTrimAsync
|
||||
originalTrim := agentMemoryTrimFn
|
||||
originalLastAt := agentMemoryTrimLastAt.Load()
|
||||
t.Cleanup(func() {
|
||||
runAgentMemoryTrimAsync = originalAsync
|
||||
agentMemoryTrimFn = originalTrim
|
||||
agentMemoryTrimRunning.Store(false)
|
||||
agentMemoryTrimLastAt.Store(originalLastAt)
|
||||
})
|
||||
|
||||
agentMemoryTrimRunning.Store(false)
|
||||
agentMemoryTrimLastAt.Store(0)
|
||||
triggered := 0
|
||||
runAgentMemoryTrimAsync = func(fn func()) {
|
||||
fn()
|
||||
}
|
||||
agentMemoryTrimFn = func() {
|
||||
triggered++
|
||||
}
|
||||
|
||||
maybeReleaseAgentMemory("test-large-query", agentMemoryTrimRowsThreshold)
|
||||
|
||||
if triggered != 1 {
|
||||
t.Fatalf("大查询完成后应触发一次内存回收,got=%d", triggered)
|
||||
}
|
||||
}
|
||||
|
||||
12
cmd/optional-driver-agent/provider_trino.go
Normal file
12
cmd/optional-driver-agent/provider_trino.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_trino_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "trino"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.TrinoDB{}
|
||||
}
|
||||
}
|
||||
7
deploy/helm/gonavi-mcp-server/Chart.yaml
Normal file
7
deploy/helm/gonavi-mcp-server/Chart.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
apiVersion: v2
|
||||
name: gonavi-mcp-server
|
||||
description: Helm chart for deploying GoNavi MCP Server
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "latest"
|
||||
kubeVersion: ">=1.24.0-0"
|
||||
94
deploy/helm/gonavi-mcp-server/README.md
Normal file
94
deploy/helm/gonavi-mcp-server/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# GoNavi MCP Server Helm Chart
|
||||
|
||||
这个 Chart 用来部署 `gonavi-mcp-server`,覆盖当前仓库 K8s 示例里的几类常见场景:
|
||||
|
||||
- 基于现有 Secret 直接部署
|
||||
- 通过 Helm values 内联创建测试用 Secret
|
||||
- 启用 Ingress
|
||||
- 追加 `drivers/` PVC
|
||||
- NAS / k3s 单机节点直接挂宿主机数据目录
|
||||
|
||||
Chart 目录:
|
||||
|
||||
- `Chart.yaml`
|
||||
- `values.yaml`
|
||||
- `values-examples/*.yaml`
|
||||
- `templates/*.yaml`
|
||||
|
||||
## 1. 默认安装
|
||||
|
||||
默认值假设你已经有一个现成 Secret:
|
||||
|
||||
- 名称:`gonavi-mcp-server-data`
|
||||
- 至少包含:
|
||||
- `connections.json`
|
||||
- `daily_secrets.json`
|
||||
- `GONAVI_MCP_HTTP_TOKEN`
|
||||
|
||||
安装命令:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server -n gonavi --create-namespace
|
||||
```
|
||||
|
||||
## 2. 直接创建测试用 Secret
|
||||
|
||||
如果只是本地验证模板或快速试跑,可以使用示例 values:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
|
||||
-n gonavi --create-namespace \
|
||||
-f deploy/helm/gonavi-mcp-server/values-examples/inline-secret.yaml
|
||||
```
|
||||
|
||||
这个示例只适合最小联调,不适合真实生产连接数据。
|
||||
|
||||
## 3. 常用示例
|
||||
|
||||
启用 Ingress:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
|
||||
-n gonavi --create-namespace \
|
||||
-f deploy/helm/gonavi-mcp-server/values-examples/ingress.yaml
|
||||
```
|
||||
|
||||
挂载 drivers PVC:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
|
||||
-n gonavi --create-namespace \
|
||||
-f deploy/helm/gonavi-mcp-server/values-examples/drivers-pvc.yaml
|
||||
```
|
||||
|
||||
同时启用 Ingress 与 drivers PVC:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
|
||||
-n gonavi --create-namespace \
|
||||
-f deploy/helm/gonavi-mcp-server/values-examples/ingress-with-drivers-pvc.yaml
|
||||
```
|
||||
|
||||
NAS / k3s hostPath:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
|
||||
-n gonavi --create-namespace \
|
||||
-f deploy/helm/gonavi-mcp-server/values-examples/nas-hostpath.yaml
|
||||
```
|
||||
|
||||
## 4. 关键参数
|
||||
|
||||
- `image.repository` / `image.tag`
|
||||
- `secret.create`
|
||||
- `secret.name`
|
||||
- `data.mode=secret|hostPath|pvc`
|
||||
- `drivers.mode=none|pvc|hostPath`
|
||||
- `ingress.enabled`
|
||||
|
||||
## 5. 约束
|
||||
|
||||
- `data.mode=secret` 时,Secret 必须能提供 `connections.json` 和 `daily_secrets.json`
|
||||
- `data.mode=hostPath` 时,宿主机目录需要包含 GoNavi 活动数据目录内容
|
||||
- `drivers.mode!=none` 时,会在 `/data/drivers` 追加独立挂载
|
||||
- Chart 默认只部署 MCP Server,不部署桌面 GUI
|
||||
49
deploy/helm/gonavi-mcp-server/templates/_helpers.tpl
Normal file
49
deploy/helm/gonavi-mcp-server/templates/_helpers.tpl
Normal file
@@ -0,0 +1,49 @@
|
||||
{{- define "gonavi-mcp-server.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := include "gonavi-mcp-server.name" . -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.labels" -}}
|
||||
helm.sh/chart: {{ include "gonavi-mcp-server.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "gonavi-mcp-server.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "gonavi-mcp-server.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.namespace" -}}
|
||||
{{- default .Release.Namespace .Values.namespaceOverride -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.secretName" -}}
|
||||
{{- if .Values.secret.name -}}
|
||||
{{- .Values.secret.name -}}
|
||||
{{- else -}}
|
||||
{{- include "gonavi-mcp-server.fullname" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.serviceName" -}}
|
||||
{{- include "gonavi-mcp-server.fullname" . -}}
|
||||
{{- end -}}
|
||||
130
deploy/helm/gonavi-mcp-server/templates/deployment.yaml
Normal file
130
deploy/helm/gonavi-mcp-server/templates/deployment.yaml
Normal file
@@ -0,0 +1,130 @@
|
||||
{{- $dataMode := .Values.data.mode -}}
|
||||
{{- $driversMode := .Values.drivers.mode -}}
|
||||
{{- if not (or (eq $dataMode "secret") (eq $dataMode "hostPath") (eq $dataMode "pvc")) -}}
|
||||
{{- fail "data.mode must be one of: secret, hostPath, pvc" -}}
|
||||
{{- end -}}
|
||||
{{- if not (or (eq $driversMode "none") (eq $driversMode "pvc") (eq $driversMode "hostPath")) -}}
|
||||
{{- fail "drivers.mode must be one of: none, pvc, hostPath" -}}
|
||||
{{- end -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "gonavi-mcp-server.fullname" . }}
|
||||
namespace: {{ include "gonavi-mcp-server.namespace" . }}
|
||||
labels:
|
||||
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 8 }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: gonavi-mcp-server
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
args:
|
||||
- http
|
||||
{{- range .Values.mcp.extraArgs }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: GONAVI_DATA_ROOT
|
||||
value: {{ .Values.mcp.dataRoot | quote }}
|
||||
- name: GONAVI_LOG_DIR
|
||||
value: {{ .Values.mcp.logDir | quote }}
|
||||
- name: GONAVI_MCP_HTTP_ADDR
|
||||
value: {{ .Values.mcp.httpAddr | quote }}
|
||||
- name: GONAVI_MCP_HTTP_PATH
|
||||
value: {{ .Values.mcp.httpPath | quote }}
|
||||
- name: GONAVI_MCP_SCHEMA_ONLY
|
||||
value: {{ ternary "true" "false" .Values.mcp.schemaOnly | quote }}
|
||||
- name: GONAVI_MCP_HTTP_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "gonavi-mcp-server.secretName" . }}
|
||||
key: {{ .Values.secret.tokenKey }}
|
||||
{{- with .Values.mcp.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: gonavi-data
|
||||
mountPath: {{ .Values.data.mountPath | quote }}
|
||||
readOnly: true
|
||||
{{- if ne $driversMode "none" }}
|
||||
- name: gonavi-drivers
|
||||
mountPath: {{ .Values.drivers.mountPath | quote }}
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
|
||||
volumes:
|
||||
- name: gonavi-data
|
||||
{{- if eq $dataMode "secret" }}
|
||||
projected:
|
||||
sources:
|
||||
- secret:
|
||||
name: {{ include "gonavi-mcp-server.secretName" . }}
|
||||
items:
|
||||
- key: {{ .Values.secret.connectionsJsonKey }}
|
||||
path: connections.json
|
||||
- key: {{ .Values.secret.dailySecretsJsonKey }}
|
||||
path: daily_secrets.json
|
||||
{{- else if eq $dataMode "hostPath" }}
|
||||
hostPath:
|
||||
path: {{ required "data.hostPath.path is required when data.mode=hostPath" .Values.data.hostPath.path | quote }}
|
||||
type: {{ default "Directory" .Values.data.hostPath.type | quote }}
|
||||
{{- else if eq $dataMode "pvc" }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ required "data.pvc.claimName is required when data.mode=pvc" .Values.data.pvc.claimName | quote }}
|
||||
{{- end }}
|
||||
{{- if eq $driversMode "pvc" }}
|
||||
- name: gonavi-drivers
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ required "drivers.pvc.claimName is required when drivers.mode=pvc" .Values.drivers.pvc.claimName | quote }}
|
||||
{{- else if eq $driversMode "hostPath" }}
|
||||
- name: gonavi-drivers
|
||||
hostPath:
|
||||
path: {{ required "drivers.hostPath.path is required when drivers.mode=hostPath" .Values.drivers.hostPath.path | quote }}
|
||||
type: {{ default "Directory" .Values.drivers.hostPath.type | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
34
deploy/helm/gonavi-mcp-server/templates/ingress.yaml
Normal file
34
deploy/helm/gonavi-mcp-server/templates/ingress.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "gonavi-mcp-server.fullname" . }}
|
||||
namespace: {{ include "gonavi-mcp-server.namespace" . }}
|
||||
labels:
|
||||
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className | quote }}
|
||||
{{- with .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path | quote }}
|
||||
pathType: {{ .pathType | quote }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "gonavi-mcp-server.serviceName" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
27
deploy/helm/gonavi-mcp-server/templates/secret.yaml
Normal file
27
deploy/helm/gonavi-mcp-server/templates/secret.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
{{- if .Values.secret.create }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "gonavi-mcp-server.secretName" . }}
|
||||
namespace: {{ include "gonavi-mcp-server.namespace" . }}
|
||||
labels:
|
||||
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{ .Values.secret.tokenKey }}: {{ required "secret.stringData.token is required when secret.create=true" .Values.secret.stringData.token | quote }}
|
||||
{{- if eq .Values.data.mode "secret" }}
|
||||
{{ .Values.secret.connectionsJsonKey }}: |
|
||||
{{ required "secret.stringData.connectionsJson is required when data.mode=secret and secret.create=true" .Values.secret.stringData.connectionsJson | nindent 4 }}
|
||||
{{ .Values.secret.dailySecretsJsonKey }}: |
|
||||
{{ required "secret.stringData.dailySecretsJson is required when data.mode=secret and secret.create=true" .Values.secret.stringData.dailySecretsJson | nindent 4 }}
|
||||
{{- else }}
|
||||
{{- if .Values.secret.stringData.connectionsJson }}
|
||||
{{ .Values.secret.connectionsJsonKey }}: |
|
||||
{{ .Values.secret.stringData.connectionsJson | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.secret.stringData.dailySecretsJson }}
|
||||
{{ .Values.secret.dailySecretsJsonKey }}: |
|
||||
{{ .Values.secret.stringData.dailySecretsJson | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
20
deploy/helm/gonavi-mcp-server/templates/service.yaml
Normal file
20
deploy/helm/gonavi-mcp-server/templates/service.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "gonavi-mcp-server.serviceName" . }}
|
||||
namespace: {{ include "gonavi-mcp-server.namespace" . }}
|
||||
labels:
|
||||
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
selector:
|
||||
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,4 @@
|
||||
drivers:
|
||||
mode: pvc
|
||||
pvc:
|
||||
claimName: gonavi-drivers
|
||||
@@ -0,0 +1,19 @@
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: gonavi-mcp.example.com
|
||||
paths:
|
||||
- path: /mcp
|
||||
pathType: Prefix
|
||||
- path: /healthz
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: gonavi-mcp-server-tls
|
||||
hosts:
|
||||
- gonavi-mcp.example.com
|
||||
|
||||
drivers:
|
||||
mode: pvc
|
||||
pvc:
|
||||
claimName: gonavi-drivers
|
||||
14
deploy/helm/gonavi-mcp-server/values-examples/ingress.yaml
Normal file
14
deploy/helm/gonavi-mcp-server/values-examples/ingress.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: gonavi-mcp.example.com
|
||||
paths:
|
||||
- path: /mcp
|
||||
pathType: Prefix
|
||||
- path: /healthz
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: gonavi-mcp-server-tls
|
||||
hosts:
|
||||
- gonavi-mcp.example.com
|
||||
@@ -0,0 +1,13 @@
|
||||
secret:
|
||||
create: true
|
||||
stringData:
|
||||
token: replace-with-a-random-token
|
||||
connectionsJson: |
|
||||
{
|
||||
"connections": []
|
||||
}
|
||||
dailySecretsJson: |
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"connections": {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
data:
|
||||
mode: hostPath
|
||||
hostPath:
|
||||
path: /volume1/docker/gonavi/data
|
||||
type: Directory
|
||||
|
||||
secret:
|
||||
create: true
|
||||
stringData:
|
||||
token: replace-with-a-random-token
|
||||
99
deploy/helm/gonavi-mcp-server/values.yaml
Normal file
99
deploy/helm/gonavi-mcp-server/values.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
namespaceOverride: ""
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/syngnat/gonavi-mcp-server
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8765
|
||||
annotations: {}
|
||||
|
||||
mcp:
|
||||
dataRoot: /data
|
||||
logDir: /var/lib/gonavi/logs
|
||||
httpAddr: 0.0.0.0:8765
|
||||
httpPath: /mcp
|
||||
schemaOnly: true
|
||||
extraArgs: []
|
||||
extraEnv: []
|
||||
|
||||
secret:
|
||||
create: false
|
||||
name: gonavi-mcp-server-data
|
||||
tokenKey: GONAVI_MCP_HTTP_TOKEN
|
||||
connectionsJsonKey: connections.json
|
||||
dailySecretsJsonKey: daily_secrets.json
|
||||
stringData:
|
||||
token: ""
|
||||
connectionsJson: ""
|
||||
dailySecretsJson: ""
|
||||
|
||||
data:
|
||||
mode: secret
|
||||
mountPath: /data
|
||||
hostPath:
|
||||
path: /volume1/docker/gonavi/data
|
||||
type: Directory
|
||||
pvc:
|
||||
claimName: ""
|
||||
|
||||
drivers:
|
||||
mode: none
|
||||
mountPath: /data/drivers
|
||||
pvc:
|
||||
claimName: gonavi-drivers
|
||||
hostPath:
|
||||
path: /volume1/docker/gonavi/drivers
|
||||
type: Directory
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
hosts:
|
||||
- host: gonavi-mcp.example.com
|
||||
paths:
|
||||
- path: /mcp
|
||||
pathType: Prefix
|
||||
- path: /healthz
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: gonavi-mcp-server-tls
|
||||
hosts:
|
||||
- gonavi-mcp.example.com
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
containerSecurityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
151
deploy/k8s/gonavi-mcp-server/README.md
Normal file
151
deploy/k8s/gonavi-mcp-server/README.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# GoNavi MCP Server Kubernetes 示例
|
||||
|
||||
这个目录提供 `gonavi-mcp-server` 的最小 K8s 部署清单,适合:
|
||||
|
||||
- Linux 服务器上的单集群部署
|
||||
- NAS 自带 K8s / k3s / k8s-lite 环境
|
||||
- 需要把 GoNavi MCP 通过 Ingress / Gateway 暴露给远端 Agent 的场景
|
||||
|
||||
如果你希望通过 values 参数统一控制镜像、Secret、Ingress、PVC/hostPath,而不是维护多份 overlay,请直接使用 [deploy/helm/gonavi-mcp-server](../../helm/gonavi-mcp-server)。
|
||||
|
||||
目录结构:
|
||||
|
||||
- `kustomization.yaml`:基础部署入口
|
||||
- `base/kustomization.yaml`:基础资源集合
|
||||
- `overlays/nas-hostpath`:直接挂 NAS / 单机节点上的 GoNavi 数据目录
|
||||
- `base/deployment.yaml` / `base/service.yaml`:基础资源
|
||||
- `overlays/drivers-pvc`:为 `/data/drivers` 增加 PVC 挂载
|
||||
- `overlays/ingress`:增加 Ingress 暴露 `/mcp`
|
||||
- `overlays/ingress-with-drivers-pvc`:同时启用 Ingress 和 drivers PVC
|
||||
|
||||
## 前提
|
||||
|
||||
容器仍然依赖 GoNavi 的活动数据目录。至少要准备:
|
||||
|
||||
- `connections.json`
|
||||
- `daily_secrets.json`
|
||||
|
||||
如果目标连接依赖可选 driver agent,还需要额外挂载 `/data/drivers`。
|
||||
|
||||
`base/deployment.yaml` 默认镜像指向:
|
||||
|
||||
```text
|
||||
ghcr.io/syngnat/gonavi-mcp-server:latest
|
||||
```
|
||||
|
||||
如果你要跟随 `dev` 分支,可以改成:
|
||||
|
||||
```text
|
||||
ghcr.io/syngnat/gonavi-mcp-server:dev-latest
|
||||
```
|
||||
|
||||
如果你要使用自建镜像,再按下面方式覆盖 `image`:
|
||||
|
||||
- 在当前节点可访问的 Docker / Podman / containerd 环境中构建并导入这个镜像
|
||||
- 把镜像推送到你的私有仓库,并同步修改 `base/deployment.yaml` 里的 `image`
|
||||
|
||||
例如:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.mcp-server -t gonavi-mcp-server:local .
|
||||
# or
|
||||
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
|
||||
```
|
||||
|
||||
## 1. 创建 Secret
|
||||
|
||||
推荐直接从现有 GoNavi 数据文件生成:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic gonavi-mcp-server-data \
|
||||
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
|
||||
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
|
||||
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
|
||||
```
|
||||
|
||||
如需单独 namespace,请先执行:
|
||||
|
||||
```bash
|
||||
kubectl create namespace gonavi
|
||||
kubectl -n gonavi create secret generic gonavi-mcp-server-data \
|
||||
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
|
||||
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
|
||||
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
|
||||
```
|
||||
|
||||
## 2. 应用清单
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server
|
||||
```
|
||||
|
||||
如果使用独立 namespace,请先把 YAML 里的 `namespace` 改成你的目标值,或自行用 `kustomize` / Helm 做二次封装。
|
||||
|
||||
清单内已经带了基础安全上下文和默认资源配额;如果你的连接数量、对象规模或并发访问更高,建议按实际负载调整 `resources`。
|
||||
|
||||
### 常用 Overlay
|
||||
|
||||
仅基础部署:
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server
|
||||
```
|
||||
|
||||
需要可选 driver agent:
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/drivers-pvc
|
||||
```
|
||||
|
||||
NAS / k3s 单机节点直接挂宿主机目录:
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/nas-hostpath
|
||||
```
|
||||
|
||||
需要对外暴露 `/mcp`:
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/ingress
|
||||
```
|
||||
|
||||
同时需要 Ingress 与 drivers PVC:
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/ingress-with-drivers-pvc
|
||||
```
|
||||
|
||||
应用 overlay 前,记得先改里面的占位值,例如:
|
||||
|
||||
- `overlays/ingress/ingress.yaml` 里的域名、TLS Secret、`ingressClassName`
|
||||
- `overlays/drivers-pvc/patch-deployment.yaml` 里的 PVC 名称 `gonavi-drivers`
|
||||
- `overlays/nas-hostpath/patch-deployment.yaml` 里的宿主机目录 `/volume1/docker/gonavi/data`
|
||||
|
||||
`nas-hostpath` overlay 会直接把整个 GoNavi 数据目录挂到 `/data`,因此 `connections.json`、`daily_secrets.json`、`drivers/` 都从宿主机目录读取;此时仍建议保留 `GONAVI_MCP_HTTP_TOKEN` 的 Secret 注入,不要把 token 直接硬编码进 Deployment。
|
||||
|
||||
## 3. 校验
|
||||
|
||||
```bash
|
||||
kubectl get pods -n gonavi
|
||||
kubectl get svc -n gonavi
|
||||
kubectl port-forward -n gonavi svc/gonavi-mcp-server 8765:8765
|
||||
curl -H "Authorization: Bearer replace-with-a-random-token" http://127.0.0.1:8765/healthz
|
||||
```
|
||||
|
||||
`/healthz` 返回 `ok` 说明 Pod 已对外提供 HTTP 服务。
|
||||
|
||||
## 4. 暴露给远端 Agent
|
||||
|
||||
- 集群内使用:直接访问 `http://gonavi-mcp-server.gonavi.svc.cluster.local:8765/mcp`
|
||||
- 集群外使用:通过 Ingress / Gateway / 反向代理暴露 `/mcp`
|
||||
- 远端 Agent 请求头:`Authorization: Bearer <你的 token>`
|
||||
|
||||
## 5. drivers 挂载
|
||||
|
||||
当前示例默认只挂载 `connections.json` 与 `daily_secrets.json`。如果保存连接里包含 Dameng、ClickHouse、DuckDB 等依赖 driver agent 的数据源,请在 Deployment 中额外补一个卷,把宿主机或 PVC 中的 `drivers/` 目录挂载到:
|
||||
|
||||
```text
|
||||
/data/drivers
|
||||
```
|
||||
|
||||
否则对应连接会因为缺少 driver agent 无法使用。
|
||||
89
deploy/k8s/gonavi-mcp-server/base/deployment.yaml
Normal file
89
deploy/k8s/gonavi-mcp-server/base/deployment.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
labels:
|
||||
app.kubernetes.io/name: gonavi-mcp-server
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gonavi-mcp-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: gonavi-mcp-server
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gonavi-mcp-server
|
||||
# 如需跟随 dev 分支,可改成 ghcr.io/syngnat/gonavi-mcp-server:dev-latest
|
||||
image: ghcr.io/syngnat/gonavi-mcp-server:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- http
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8765
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: GONAVI_DATA_ROOT
|
||||
value: /data
|
||||
- name: GONAVI_LOG_DIR
|
||||
value: /var/lib/gonavi/logs
|
||||
- name: GONAVI_MCP_HTTP_ADDR
|
||||
value: 0.0.0.0:8765
|
||||
- name: GONAVI_MCP_HTTP_PATH
|
||||
value: /mcp
|
||||
- name: GONAVI_MCP_SCHEMA_ONLY
|
||||
value: "true"
|
||||
- name: GONAVI_MCP_HTTP_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gonavi-mcp-server-data
|
||||
key: GONAVI_MCP_HTTP_TOKEN
|
||||
volumeMounts:
|
||||
- name: gonavi-data
|
||||
mountPath: /data
|
||||
readOnly: true
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
volumes:
|
||||
- name: gonavi-data
|
||||
projected:
|
||||
sources:
|
||||
- secret:
|
||||
name: gonavi-mcp-server-data
|
||||
items:
|
||||
- key: connections.json
|
||||
path: connections.json
|
||||
- key: daily_secrets.json
|
||||
path: daily_secrets.json
|
||||
6
deploy/k8s/gonavi-mcp-server/base/kustomization.yaml
Normal file
6
deploy/k8s/gonavi-mcp-server/base/kustomization.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
15
deploy/k8s/gonavi-mcp-server/base/service.yaml
Normal file
15
deploy/k8s/gonavi-mcp-server/base/service.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
labels:
|
||||
app.kubernetes.io/name: gonavi-mcp-server
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: gonavi-mcp-server
|
||||
ports:
|
||||
- name: http
|
||||
port: 8765
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
5
deploy/k8s/gonavi-mcp-server/kustomization.yaml
Normal file
5
deploy/k8s/gonavi-mcp-server/kustomization.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- base
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
- path: patch-deployment.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: gonavi-mcp-server
|
||||
volumeMounts:
|
||||
- name: gonavi-drivers
|
||||
mountPath: /data/drivers
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: gonavi-drivers
|
||||
persistentVolumeClaim:
|
||||
claimName: gonavi-drivers
|
||||
@@ -0,0 +1,33 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- gonavi-mcp.example.com
|
||||
secretName: gonavi-mcp-server-tls
|
||||
rules:
|
||||
- host: gonavi-mcp.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /mcp
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gonavi-mcp-server
|
||||
port:
|
||||
number: 8765
|
||||
- path: /healthz
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gonavi-mcp-server
|
||||
port:
|
||||
number: 8765
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
- ingress.yaml
|
||||
|
||||
patches:
|
||||
- path: patch-deployment.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: gonavi-mcp-server
|
||||
volumeMounts:
|
||||
- name: gonavi-drivers
|
||||
mountPath: /data/drivers
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: gonavi-drivers
|
||||
persistentVolumeClaim:
|
||||
claimName: gonavi-drivers
|
||||
33
deploy/k8s/gonavi-mcp-server/overlays/ingress/ingress.yaml
Normal file
33
deploy/k8s/gonavi-mcp-server/overlays/ingress/ingress.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- gonavi-mcp.example.com
|
||||
secretName: gonavi-mcp-server-tls
|
||||
rules:
|
||||
- host: gonavi-mcp.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /mcp
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gonavi-mcp-server
|
||||
port:
|
||||
number: 8765
|
||||
- path: /healthz
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gonavi-mcp-server
|
||||
port:
|
||||
number: 8765
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
- path: patch-deployment.yaml
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
volumes:
|
||||
- $patch: replace
|
||||
name: gonavi-data
|
||||
hostPath:
|
||||
path: /volume1/docker/gonavi/data
|
||||
type: Directory
|
||||
101
deploy/podman/gonavi-mcp-server/README.md
Normal file
101
deploy/podman/gonavi-mcp-server/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# GoNavi MCP Server Podman 示例
|
||||
|
||||
这个目录提供 `gonavi-mcp-server` 的 Podman 原生部署入口,覆盖两类常见场景:
|
||||
|
||||
- 直接用 `podman run` 在 Linux 服务器 / NAS 上启动
|
||||
- 用 rootless Quadlet + systemd 做常驻服务
|
||||
|
||||
目录内容:
|
||||
|
||||
- `gonavi-mcp-server.env.example`:给 `podman run` / Quadlet 共用的容器环境变量示例
|
||||
- `gonavi-mcp-server.container`:Quadlet 示例
|
||||
|
||||
前提仍然一样:宿主机 GoNavi 活动数据目录内至少应包含:
|
||||
|
||||
- `connections.json`
|
||||
- `daily_secrets.json`
|
||||
|
||||
如果目标连接依赖可选 driver agent,还要保证同一数据目录下已有 `drivers/`。
|
||||
|
||||
## 1. 直接运行已发布镜像
|
||||
|
||||
```bash
|
||||
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-server.env
|
||||
```
|
||||
|
||||
把 `GONAVI_MCP_HTTP_TOKEN` 改成随机值后运行:
|
||||
|
||||
```bash
|
||||
podman run -d --name gonavi-mcp-server --replace \
|
||||
-p 8765:8765 \
|
||||
--env-file ./gonavi-mcp-server.env \
|
||||
-v /absolute/path/to/gonavi-data:/data:Z \
|
||||
ghcr.io/syngnat/gonavi-mcp-server:latest http
|
||||
```
|
||||
|
||||
查看状态:
|
||||
|
||||
```bash
|
||||
podman ps
|
||||
podman logs -f gonavi-mcp-server
|
||||
```
|
||||
|
||||
如果你的宿主机没有启用 SELinux,可把挂载参数末尾的 `:Z` 去掉;如果启用了 SELinux,建议保留它。
|
||||
|
||||
## 2. 本地源码构建镜像
|
||||
|
||||
仓库根目录已经提供 `.containerignore`,Podman 会优先读取它来裁剪构建上下文。
|
||||
|
||||
```bash
|
||||
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
|
||||
podman run -d --name gonavi-mcp-server --replace \
|
||||
-p 8765:8765 \
|
||||
--env-file ./gonavi-mcp-server.env \
|
||||
-v /absolute/path/to/gonavi-data:/data:Z \
|
||||
localhost/gonavi-mcp-server:local http
|
||||
```
|
||||
|
||||
## 3. Rootless Quadlet 常驻服务
|
||||
|
||||
Podman Quadlet 适合 Linux 服务器 / NAS 上做 rootless 常驻服务。
|
||||
|
||||
把文件放到用户级目录:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/containers/systemd
|
||||
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container ~/.config/containers/systemd/
|
||||
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ~/.config/containers/systemd/gonavi-mcp-server.env
|
||||
```
|
||||
|
||||
然后修改两处:
|
||||
|
||||
- `~/.config/containers/systemd/gonavi-mcp-server.container` 里的 `Volume=/absolute/path/to/gonavi-data:/data:Z`
|
||||
- `~/.config/containers/systemd/gonavi-mcp-server.env` 里的 `GONAVI_MCP_HTTP_TOKEN`
|
||||
|
||||
启动服务:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now gonavi-mcp-server.service
|
||||
systemctl --user status gonavi-mcp-server.service
|
||||
journalctl --user -u gonavi-mcp-server.service -f
|
||||
```
|
||||
|
||||
如果你希望用户退出登录后服务仍然保持运行,再执行:
|
||||
|
||||
```bash
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
Rootless Quadlet 默认搜索路径是 `~/.config/containers/systemd/`;如果你要做系统级 rootful 部署,可改放到 `/etc/containers/systemd/`。
|
||||
|
||||
## 4. 关于 Compose
|
||||
|
||||
Podman 的 `podman compose` 依赖外部 compose provider。也就是说,是否能直接复用仓库根目录的 `docker-compose.mcp-server.yml`,取决于你的 Podman 环境是否已经安装并配置了对应 provider。
|
||||
|
||||
因此,这个仓库对 Podman 的主支持路径是:
|
||||
|
||||
- `podman run`
|
||||
- Quadlet(推荐长期运行)
|
||||
|
||||
如果你的环境已经有可用的 compose provider,再去复用根目录 Compose 文件即可。
|
||||
22
deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container
Normal file
22
deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=GoNavi MCP Server (rootless Podman)
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=gonavi-mcp-server
|
||||
Image=ghcr.io/syngnat/gonavi-mcp-server:latest
|
||||
Pull=newer
|
||||
EnvironmentFile=./gonavi-mcp-server.env
|
||||
# 修改成你的 GoNavi 活动数据目录;SELinux 主机建议保留 :Z
|
||||
Volume=/absolute/path/to/gonavi-data:/data:Z
|
||||
PublishPort=8765:8765
|
||||
Exec=http
|
||||
LogDriver=journald
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
TimeoutStartSec=900
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,15 @@
|
||||
# 容器内 GoNavi 活动数据根目录。通常不需要修改;Volume= 已把宿主机目录挂到 /data。
|
||||
GONAVI_DATA_ROOT=/data
|
||||
|
||||
# 日志目录。默认与镜像内目录保持一致。
|
||||
GONAVI_LOG_DIR=/var/lib/gonavi/logs
|
||||
|
||||
# MCP HTTP 监听地址与路径。
|
||||
GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765
|
||||
GONAVI_MCP_HTTP_PATH=/mcp
|
||||
|
||||
# 远程 MCP 客户端访问时必须携带的 Bearer Token
|
||||
GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
|
||||
|
||||
# true = 只暴露结构查询工具,不注册 execute_sql
|
||||
GONAVI_MCP_SCHEMA_ONLY=true
|
||||
7
docker-compose.mcp-server.local.yml
Normal file
7
docker-compose.mcp-server.local.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
gonavi-mcp-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.mcp-server
|
||||
image: ${GONAVI_MCP_IMAGE_LOCAL:-gonavi-mcp-server:local}
|
||||
pull_policy: never
|
||||
20
docker-compose.mcp-server.yml
Normal file
20
docker-compose.mcp-server.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
gonavi-mcp-server:
|
||||
image: ${GONAVI_MCP_IMAGE:-ghcr.io/syngnat/gonavi-mcp-server:latest}
|
||||
pull_policy: ${GONAVI_MCP_PULL_POLICY:-missing}
|
||||
container_name: gonavi-mcp-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${GONAVI_MCP_HTTP_PORT:-8765}:8765"
|
||||
environment:
|
||||
GONAVI_DATA_ROOT: /data
|
||||
GONAVI_LOG_DIR: /var/lib/gonavi/logs
|
||||
GONAVI_MCP_HTTP_ADDR: 0.0.0.0:8765
|
||||
GONAVI_MCP_HTTP_PATH: ${GONAVI_MCP_HTTP_PATH:-/mcp}
|
||||
GONAVI_MCP_HTTP_TOKEN: ${GONAVI_MCP_HTTP_TOKEN}
|
||||
GONAVI_MCP_SCHEMA_ONLY: ${GONAVI_MCP_SCHEMA_ONLY:-true}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${GONAVI_HOST_DATA_ROOT}
|
||||
target: /data
|
||||
command: ["http"]
|
||||
20
docker.mcp-server.env.example
Normal file
20
docker.mcp-server.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# 宿主机 GoNavi 活动数据目录,目录内至少应包含 connections.json 与 daily_secrets.json
|
||||
GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data
|
||||
|
||||
# 预构建镜像地址。稳定版通常使用 latest,跟随 dev 分支可改成 ghcr.io/syngnat/gonavi-mcp-server:dev-latest
|
||||
GONAVI_MCP_IMAGE=ghcr.io/syngnat/gonavi-mcp-server:latest
|
||||
|
||||
# 预构建镜像拉取策略:always / missing / never
|
||||
GONAVI_MCP_PULL_POLICY=missing
|
||||
|
||||
# 远程 MCP 客户端访问时必须携带的 Bearer Token
|
||||
GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
|
||||
|
||||
# 宿主机暴露端口
|
||||
GONAVI_MCP_HTTP_PORT=8765
|
||||
|
||||
# MCP HTTP 路径
|
||||
GONAVI_MCP_HTTP_PATH=/mcp
|
||||
|
||||
# true = 只暴露结构查询工具,不注册 execute_sql
|
||||
GONAVI_MCP_SCHEMA_ONLY=true
|
||||
45
docs/需求追踪/需求进度追踪-Oracle对象跳转与存储过程修改执行-20260625.md
Normal file
45
docs/需求追踪/需求进度追踪-Oracle对象跳转与存储过程修改执行-20260625.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 需求进度追踪 - Oracle对象跳转与存储过程修改执行
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:Oracle对象跳转与存储过程修改执行
|
||||
- 提出日期:2026-06-25
|
||||
- 负责人:Codex
|
||||
- 目标:SQL编辑器支持 Oracle 序列、存储包 Ctrl/Cmd 点击跳转;对象编辑执行 Oracle 存储过程时不再生成非法第二条语句。
|
||||
- 非目标:不调整 Oracle 连接驱动、不改数据库 schema、不重构侧栏对象树。
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:QueryEditor 对象元数据、hover/跳转逻辑、DefinitionViewer 对象编辑 SQL 生成、相关 i18n 和回归测试。
|
||||
- 验收标准:序列/存储包在 SQL 编辑器可出现链接提示并打开定义页;带 SQLPlus `/` 的 Oracle PL/SQL 编辑 SQL 不生成 `/;`,执行时保留完整定义交给后端拆分。
|
||||
- 依赖与约束:复用现有侧栏 sequence/package tab 类型;不混入 `frontend/wailsjs/go/models.ts` 既有未提交改动。
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认序列/存储包侧栏已可打开,但 SQL 编辑器跳转缺失;存储过程修改仍报 ORA-00900。
|
||||
- [x] 阶段 2(影响分析):影响前端 QueryEditor、DefinitionViewer、i18n、测试;后端拆分已有基础保护。
|
||||
- [x] 阶段 3(方案设计):补齐元数据与跳转类型;对象编辑 SQL 保留 SQLPlus `/` 语义。
|
||||
- [x] 阶段 4(实施计划):先补导航闭环,再修 PL/SQL 生成/执行,最后跑定向测试。
|
||||
- [x] 阶段 5(实现与自检):已完成定向测试、QueryEditor 全文件测试、前端 build、后端 Oracle 拆分测试。
|
||||
- [ ] 阶段 6(评审与交付):待提交推送。
|
||||
- [ ] 阶段 7(发布与观察):发布后验证 Oracle 对象编辑与 SQL 编辑器跳转。
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:补齐 QueryEditor sequence/package 元数据、hover、点击打开定义页;修复对象编辑 SQLPlus `/;`;执行路径兼容旧编辑页 `/;`。
|
||||
- 进行中:提交并推送。
|
||||
- 待处理:发布后验证 Oracle 实库。
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:三段名称 `schema.package.proc` 与 `db.schema.table` 存在歧义。
|
||||
- 阻塞:无。
|
||||
- 缓解措施:仅当第一段不是可见数据库时,将三段名称按 Oracle schema-qualified sequence/package 解析。
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:序列和存储包复用侧栏现有 `sequence-def` / `package-def` tab 结构。
|
||||
- 决策 2:Oracle PL/SQL 定义执行时保留 SQLPlus `/` 分隔符,由后端拆分器去掉 delimiter,避免前端用分号重组造成 ORA-00900。
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:QueryEditor 导航回归测试、DefinitionViewer 对象编辑测试、SQL 语句拆分测试、i18n catalog 测试、前端 build、后端 Oracle 拆分测试。
|
||||
- 结果:通过。
|
||||
- 证据(日志/截图/链接):`npm test -- src/components/QueryEditor.external-sql-save.test.tsx`;`npm test -- src/components/DefinitionViewer.object-edit.test.tsx src/i18n/catalog.test.ts src/utils/sqlStatementSelection.test.ts`;`go test ./internal/app -run 'TestDBQueryMultiKeepsOracle|TestDBQueryMultiSkipsOracle'`;`npm run build`。
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:提交并推送到 dev。
|
||||
- 负责人:Codex
|
||||
74
docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md
Normal file
74
docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 需求进度追踪 - 生产连接只读保护
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:生产连接只读保护
|
||||
- 提出日期:2026-06-23
|
||||
- 负责人:Codex
|
||||
- 目标:为 SQL 类数据库与 MongoDB 连接增加连接级只读保护,启用后仅允许查询,阻止写入、DDL、导入和同步目标操作
|
||||
- 非目标:不为所有侧栏写操作都新增前端隐藏逻辑;不引入新的环境分级体系;不改造 JVM 只读能力
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:
|
||||
- 连接配置模型、保存/回填与 RPC 序列化链路
|
||||
- 连接弹窗只读开关、查询编辑器本地拦截、DataGrid 导入入口收口
|
||||
- 后端 SQL/Mongo 查询判定与写操作统一守卫
|
||||
- 验收标准:
|
||||
- 支持的数据源出现“生产连接/只允许查询”开关
|
||||
- 启用后普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止
|
||||
- 导入、结构变更、清表、同步目标等关键写入口被后端拒绝
|
||||
- 依赖与约束:
|
||||
- 保持现有数据源能力判定与 QueryEditor 执行链路
|
||||
- MongoDB 前端判定以保守拦截为主,最终正确性由后端守卫兜底
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认采用连接级 `readOnly` 布尔字段,不新建环境系统
|
||||
- [x] 阶段 2(影响分析):梳理前端能力面板、查询执行、导入与后端写入口
|
||||
- [x] 阶段 3(方案设计):确定“前端预拦截 + 后端最终守卫”双层保护
|
||||
- [x] 阶段 4(实施计划):接入配置链路、能力判定、查询判定与写入口守卫
|
||||
- [x] 阶段 5(实现与自检):补文案、测试与定向验证
|
||||
- [x] 阶段 6(评审与交付):确认范围、风险、回滚点和验证命令
|
||||
- [ ] 阶段 7(发布与观察):待体验验证
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:
|
||||
- 新增连接级 `readOnly` 配置字段及前后端序列化支持
|
||||
- 连接弹窗为 SQL 类数据库与 MongoDB 增加生产连接保护开关
|
||||
- QueryEditor 增加本地非查询拦截,DataGrid 导入入口在只读连接下禁用
|
||||
- 后端为查询、DDL、导入、清表、同步等写入口增加统一只读守卫
|
||||
- MongoDB 查询判定改为命令级白名单,不再把所有 JSON 命令都视为只读
|
||||
- 补充前端/后端定向测试与需求追踪文档
|
||||
- 进行中:
|
||||
- 等待体验包验证连接弹窗、查询拦截和写操作拒绝文案
|
||||
- 待处理:
|
||||
- 如需进一步优化体验,再补侧栏对象级写菜单的前端隐藏/禁用
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:
|
||||
- 前端 SQL/Mongo 只读判定是保守策略,边界命令可能仍需后端兜底
|
||||
- 现有部分侧栏写菜单仍可能显示,但执行时会被后端拒绝
|
||||
- 阻塞:
|
||||
- 暂无
|
||||
- 缓解措施:
|
||||
- 关键写入口统一走后端守卫;前端只负责提前反馈与减少误操作
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:只对 SQL 类数据库和 MongoDB 支持连接级生产保护,其他数据源忽略 `readOnly`
|
||||
- 决策 2:采用顶层 `readOnly` 布尔字段,避免新增环境枚举和迁移成本
|
||||
- 决策 3:MongoDB 只读判定按命令白名单处理,防止把写命令误放行
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:
|
||||
- 前端数据源能力判定、RPC 配置、连接配置测试
|
||||
- 后端只读连接守卫与 SQL/Mongo 查询判定测试
|
||||
- 结果:
|
||||
- 通过
|
||||
- 证据(日志/截图/链接):
|
||||
- `go test ./internal/app -run 'Test(EnsureReadOnlyConnectionAllows|SupportsConnectionReadOnlyMode|IsReadOnlySQLQuery)'`
|
||||
- `npm --prefix frontend test -- src/utils/dataSourceCapabilities.test.ts src/utils/connectionRpcConfig.test.ts src/components/connectionModal/connectionModalConfig.keepalive.test.ts`
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:
|
||||
- 用体验包回归验证生产连接下的查询、导入、建库删库、同步目标和 Mongo 命令拦截
|
||||
- 如需更完整 UX,再补侧栏写菜单的只读态收口
|
||||
- 负责人:
|
||||
- Codex
|
||||
160
frontend/package-lock.json
generated
160
frontend/package-lock.json
generated
@@ -14,9 +14,11 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"antd": "^5.12.0",
|
||||
"clsx": "^2.1.0",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"mermaid": "^11.13.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -24,6 +26,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql-formatter": "^15.7.0",
|
||||
@@ -1214,6 +1217,108 @@
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/background": {
|
||||
"version": "11.3.14",
|
||||
"resolved": "https://registry.npmmirror.com/@reactflow/background/-/background-11.3.14.tgz",
|
||||
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/controls": {
|
||||
"version": "11.2.14",
|
||||
"resolved": "https://registry.npmmirror.com/@reactflow/controls/-/controls-11.2.14.tgz",
|
||||
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/core": {
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@reactflow/core/-/core-11.11.4.tgz",
|
||||
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/d3-drag": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/minimap": {
|
||||
"version": "11.7.14",
|
||||
"resolved": "https://registry.npmmirror.com/@reactflow/minimap/-/minimap-11.7.14.tgz",
|
||||
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-resizer": {
|
||||
"version": "2.2.14",
|
||||
"resolved": "https://registry.npmmirror.com/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
|
||||
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.4",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-toolbar": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmmirror.com/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
|
||||
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
@@ -1928,6 +2033,12 @@
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dagre": {
|
||||
"version": "0.7.54",
|
||||
"resolved": "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz",
|
||||
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||
@@ -2509,6 +2620,12 @@
|
||||
"chevrotain": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
@@ -3081,6 +3198,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/dagre": {
|
||||
"version": "0.8.5",
|
||||
"resolved": "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz",
|
||||
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graphlib": "^2.1.8",
|
||||
"lodash": "^4.17.15"
|
||||
}
|
||||
},
|
||||
"node_modules/dagre-d3-es": {
|
||||
"version": "7.0.14",
|
||||
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
|
||||
@@ -3387,6 +3514,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphlib": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz",
|
||||
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.15"
|
||||
}
|
||||
},
|
||||
"node_modules/hachure-fill": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
|
||||
@@ -3675,6 +3811,12 @@
|
||||
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
@@ -5713,6 +5855,24 @@
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reactflow": {
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/reactflow/-/reactflow-11.11.4.tgz",
|
||||
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/background": "11.3.14",
|
||||
"@reactflow/controls": "11.2.14",
|
||||
"@reactflow/core": "11.11.4",
|
||||
"@reactflow/minimap": "11.7.14",
|
||||
"@reactflow/node-resizer": "2.2.14",
|
||||
"@reactflow/node-toolbar": "1.3.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"i18n:scan": "go run ../tools/i18n-scan --root .."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
@@ -16,9 +17,11 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"antd": "^5.12.0",
|
||||
"clsx": "^2.1.0",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"mermaid": "^11.13.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -26,6 +29,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql-formatter": "^15.7.0",
|
||||
|
||||
@@ -1 +1 @@
|
||||
416aaa5c6e66a62430103d6905ad9465
|
||||
1d8f9adbde8018f90d013cc740e0405b
|
||||
@@ -81,8 +81,8 @@ samp {
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-node-content-wrapper {
|
||||
width: auto !important;
|
||||
min-width: 0;
|
||||
width: max-content !important;
|
||||
min-width: 100%;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -106,7 +106,7 @@ samp {
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-title {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
min-width: max-content;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
@@ -239,6 +239,35 @@ body[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.gn-query-monaco-stage .monaco-editor .suggest-details-container {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.gn-query-monaco-stage .monaco-editor .suggest-details {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.gn-query-monaco-stage .monaco-editor .suggest-widget .monaco-list .monaco-list-row > .contents > .main {
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gn-query-monaco-stage .monaco-editor .suggest-widget .monaco-list .monaco-list-row > .contents > .main > .left {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gn-query-monaco-stage .monaco-editor .suggest-widget .monaco-list .monaco-list-row > .contents > .main > .right {
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gn-query-monaco-stage .monaco-editor .suggest-widget .monaco-list .monaco-list-row.string-label > .contents > .main > .right > .details-label {
|
||||
display: inline !important;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
|
||||
body {
|
||||
transition: color 0.3s;
|
||||
@@ -686,8 +715,31 @@ body[data-theme='dark'] .gonavi-query-editor-db-token {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.gonavi-query-editor-ai-inline-ghost {
|
||||
color: rgba(100, 116, 139, 0.7);
|
||||
font-style: italic;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gonavi-query-editor-ai-inline-ghost-overlay {
|
||||
color: rgba(100, 116, 139, 0.7);
|
||||
font-style: italic;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-query-editor-ai-inline-ghost {
|
||||
color: rgba(203, 213, 225, 0.56);
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-query-editor-ai-inline-ghost-overlay {
|
||||
color: rgba(203, 213, 225, 0.56);
|
||||
}
|
||||
|
||||
/* Legacy sidebar resize bounds — mirror v2 .gn-v2-app-sider so Ant Design inline width locks do not collapse drag range. */
|
||||
body[data-ui-version="legacy"] .ant-layout-sider {
|
||||
min-width: 232px !important;
|
||||
max-width: 420px !important;
|
||||
max-width: min(960px, calc(100vw - 360px)) !important;
|
||||
}
|
||||
|
||||
52
frontend/src/App.settings-center.test.ts
Normal file
52
frontend/src/App.settings-center.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('settings center layout', () => {
|
||||
it('uses the same split navigation shell as the tool center', () => {
|
||||
expect(appSource).toContain("type SettingsCenterGroupKey = 'preferences' | 'services' | 'about';");
|
||||
expect(appSource).toContain("type SettingsCenterPaneKey = 'language' | 'sidebar-metadata' | 'proxy' | 'web-auth';");
|
||||
expect(appSource).toContain("const [activeSettingsCenterGroupKey, setActiveSettingsCenterGroupKey] = useState<SettingsCenterGroupKey>('preferences');");
|
||||
expect(appSource).toContain("const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);");
|
||||
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
|
||||
expect(appSource).toContain('style={toolCenterModalSplitStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavScrollStyle}');
|
||||
expect(appSource).toContain('style={toolCenterContentPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterDetailPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterDetailBodyStyle}');
|
||||
expect(appSource).toContain('style={toolCenterScrollableListStyle}');
|
||||
expect(appSource).toContain("title: t('app.settings.group.preferences.title')");
|
||||
expect(appSource).toContain("title: t('app.settings.group.services.title')");
|
||||
expect(appSource).toContain("title: t('app.settings.group.about.title')");
|
||||
});
|
||||
|
||||
it('moves sidebar table metadata configuration into the settings center', () => {
|
||||
expect(appSource).toContain("key: 'sidebar-metadata'");
|
||||
expect(appSource).toContain("title: t('app.settings.sidebar_metadata.title')");
|
||||
expect(appSource).toContain("description: t('app.settings.sidebar_metadata.description')");
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'sidebar-metadata')");
|
||||
expect(appSource).toContain("setSidebarTableMetadataFieldSelected(");
|
||||
expect(appSource).toContain('DndContext');
|
||||
expect(appSource).toContain('SortableContext');
|
||||
expect(appSource).toContain('handleSidebarMetadataDragEnd');
|
||||
expect(appSource).toContain('sidebarTableMetadataFieldOrder');
|
||||
expect(appSource).toContain('data-sidebar-metadata-field={field}');
|
||||
expect(appSource).toContain("sidebarTableMetadataFields: DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS");
|
||||
expect(appSource).toContain("t('sidebar.v2_table_group_menu.display_table_rows')");
|
||||
expect(appSource).not.toContain("setIsLanguageModalOpen(true)");
|
||||
});
|
||||
|
||||
it('adds browser auth management into the services settings group', () => {
|
||||
expect(appSource).toContain("key: 'web-auth' as const");
|
||||
expect(appSource).toContain("title: t('app.settings.entry.web_auth.title')");
|
||||
expect(appSource).toContain("description: t('app.settings.entry.web_auth.description')");
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('services', 'web-auth')");
|
||||
expect(appSource).toContain("<WebAuthSettingsPanel");
|
||||
});
|
||||
});
|
||||
75
frontend/src/App.tab-display.i18n.test.ts
Normal file
75
frontend/src/App.tab-display.i18n.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const tabDisplaySource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./utils/tabDisplay.ts', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('App tab display i18n guards', () => {
|
||||
it('localizes the tab display settings copy and preview labels', () => {
|
||||
[
|
||||
'app.theme.tab_display.title',
|
||||
'app.theme.tab_display.description',
|
||||
'app.theme.tab_display.layout.single',
|
||||
'app.theme.tab_display.layout.double',
|
||||
'app.theme.tab_display.badge.current',
|
||||
'app.theme.tab_display.row.primary',
|
||||
'app.theme.tab_display.row.secondary',
|
||||
'app.theme.tab_display.action.move_up',
|
||||
'app.theme.tab_display.action.move_down',
|
||||
'app.theme.tab_display.preview.prefix',
|
||||
'app.theme.tab_display.preview.default_label',
|
||||
'app.theme.tab_display.preview.secondary',
|
||||
'app.theme.tab_display.preview.focused',
|
||||
].forEach((key) => {
|
||||
expect(appSource).toContain(`t('${key}'`);
|
||||
});
|
||||
|
||||
[
|
||||
'Tab 标签展示',
|
||||
'自定义连接名、对象类型、对象名、数据库、Schema 和 Host/IP 的展示顺序',
|
||||
"'单行'",
|
||||
"'双行'",
|
||||
'当前预览:',
|
||||
'默认标签',
|
||||
',副行',
|
||||
';当前选中',
|
||||
'上移',
|
||||
'下移',
|
||||
].forEach((legacyText) => {
|
||||
expect(appSource).not.toContain(legacyText);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps tab display element metadata as i18n keys', () => {
|
||||
[
|
||||
'connection',
|
||||
'kind',
|
||||
'object',
|
||||
'database',
|
||||
'schema',
|
||||
'host',
|
||||
].forEach((elementKey) => {
|
||||
expect(tabDisplaySource).toContain(`labelKey: 'app.theme.tab_display.element.${elementKey}.label'`);
|
||||
expect(tabDisplaySource).toContain(`descriptionKey: 'app.theme.tab_display.element.${elementKey}.description'`);
|
||||
});
|
||||
|
||||
[
|
||||
'连接名',
|
||||
'连接简称或环境名',
|
||||
'对象类型',
|
||||
'对象名',
|
||||
'当前 DB / catalog 名称',
|
||||
'连接目标地址摘要',
|
||||
].forEach((legacyText) => {
|
||||
expect(tabDisplaySource).not.toContain(legacyText);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,26 @@ const appCss = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.css', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const v2ThemeCss = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./v2-theme.css', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const linuxCJKFontBannerSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./components/LinuxCJKFontBanner.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const appUtilityStylesSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./hooks/useAppUtilityStyles.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const appSidebarResizeSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./hooks/useAppSidebarResize.ts', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const sidebarLayoutSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./utils/sidebarLayout.ts', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const getGlobalShortcutCaseBlock = (action: string) => {
|
||||
const caseToken = `case '${action}':`;
|
||||
@@ -32,8 +48,12 @@ const getGlobalShortcutCaseBlock = (action: string) => {
|
||||
describe('tool center menu entries', () => {
|
||||
it('exposes snippet management next to shortcut management', () => {
|
||||
expect(appSource).toContain("key: 'snippet-settings'");
|
||||
expect(appSource).toContain("title: '代码片段管理'");
|
||||
expect(appSource).toContain('setIsSnippetModalOpen(true)');
|
||||
expect(appSource).toContain("title: t('app.tools.entry.snippets.title')");
|
||||
expect(appSource).toContain("description: t('app.tools.entry.snippets.description')");
|
||||
expect(appSource).toContain("handleOpenToolCenterPane('workspace', 'snippet-settings')");
|
||||
expect(appSource).toContain('gonavi:open-snippet-settings');
|
||||
expect(appSource).toContain("setIsSnippetModalOpen(false);");
|
||||
expect(appSource).not.toContain('setIsSnippetModalOpen(true)');
|
||||
|
||||
const snippetIndex = appSource.indexOf("key: 'snippet-settings'");
|
||||
const shortcutIndex = appSource.indexOf("key: 'shortcut-settings'", snippetIndex);
|
||||
@@ -41,6 +61,122 @@ describe('tool center menu entries', () => {
|
||||
expect(shortcutIndex).toBeGreaterThan(snippetIndex);
|
||||
});
|
||||
|
||||
it('uses scalable side navigation for the tool center instead of horizontal segmented switching', () => {
|
||||
expect(appSource).toContain("type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace';");
|
||||
expect(appSource).toContain("const [activeToolCenterGroupKey, setActiveToolCenterGroupKey] = useState<ToolCenterGroupKey>('config');");
|
||||
expect(appSource).toContain("const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState<ToolCenterGroupKey | null>(null);");
|
||||
expect(appSource).toContain("title: t('app.tools.group.config.title')");
|
||||
expect(appSource).toContain("title: t('app.tools.group.workflow.title')");
|
||||
expect(appSource).toContain("title: t('app.tools.group.workspace.title')");
|
||||
expect(appSource).toContain("toolCenterGroups.find((group) => group.key === activeToolCenterGroupKey)");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("gridTemplateColumns: '232px minmax(0, 1fr)'");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterNavPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterNavScrollStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterContentPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterDetailPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterDetailBodyStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain('role="tablist" aria-orientation="vertical"');
|
||||
expect(appSource).toContain('role="tab"');
|
||||
expect(appSource).toContain('aria-selected={active}');
|
||||
expect(appSource).toContain('title={`${group.title} - ${group.description}`}');
|
||||
expect(appUtilityStylesSource).toContain("borderRight: `1px solid ${overlayTheme.divider}`");
|
||||
expect(appSource).toContain('setActiveToolCenterPane(null);');
|
||||
expect(appSource).toContain('group.items.length');
|
||||
expect(appSource).toContain("const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => {");
|
||||
expect(appSource).toContain("const [activeToolCenterPane, setActiveToolCenterPane] = useState<ToolCenterPaneState | null>(null);");
|
||||
expect(appSource).toContain("const handleReturnToToolCenter = useCallback((closeChild?: () => void) => {");
|
||||
expect(appSource).toContain("t('common.back_to_previous')");
|
||||
expect(appSource).toContain("width={1080}");
|
||||
expect(appSource).toContain('centered');
|
||||
});
|
||||
|
||||
it('keeps the tool center modal height fixed across group switches and scrolls the list area internally', () => {
|
||||
expect(appUtilityStylesSource).toContain('const toolCenterModalContentStyle = useMemo<React.CSSProperties>(() => ({');
|
||||
expect(appUtilityStylesSource).toContain("height: 'min(820px, calc(100vh - 64px))'");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterModalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterScrollableListStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain("body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 }");
|
||||
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
|
||||
expect(appSource).toContain('style={toolCenterModalSplitStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavScrollStyle}');
|
||||
expect(appSource).toContain('style={toolCenterContentPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterDetailPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterDetailBodyStyle}');
|
||||
expect(appSource).toContain('style={toolCenterScrollableListStyle}');
|
||||
expect(appUtilityStylesSource).toContain("overflowY: 'auto'");
|
||||
expect(appSource).toContain("borderTop: index === 0 ? `1px solid ${overlayTheme.divider}` : 'none'");
|
||||
expect(appSource).toContain("borderBottom: `1px solid ${overlayTheme.divider}`");
|
||||
});
|
||||
|
||||
it('lets the tool center detail header own embedded tool titles', () => {
|
||||
const renderPaneStart = appSource.indexOf('const renderToolCenterPane = () => {');
|
||||
const renderPaneSource = appSource.slice(
|
||||
renderPaneStart,
|
||||
appSource.indexOf('};\n\n return (', renderPaneStart),
|
||||
);
|
||||
const connectionPackageSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'connection-package')"),
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'data-root')"),
|
||||
);
|
||||
const dataRootSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'data-root')"),
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'security-update')"),
|
||||
);
|
||||
const securityUpdateSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'security-update')"),
|
||||
renderPaneSource.indexOf("activeToolCenterPane.key === 'schema-compare'"),
|
||||
);
|
||||
const dataSyncSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("activeToolCenterPane.key === 'schema-compare'"),
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'drivers')"),
|
||||
);
|
||||
const driverSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'drivers')"),
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'snippet-settings')"),
|
||||
);
|
||||
const snippetSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'snippet-settings')"),
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'shortcut-settings')"),
|
||||
);
|
||||
const shortcutSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'shortcut-settings')"),
|
||||
renderPaneSource.indexOf('return null;', renderPaneSource.indexOf("if (activeToolCenterPane.key === 'shortcut-settings')")),
|
||||
);
|
||||
|
||||
expect(appSource).toContain('activeToolCenterPaneItem?.title ?? activeToolCenterGroup.title');
|
||||
expect(connectionPackageSource).toContain('<ConnectionPackagePasswordModal');
|
||||
expect(connectionPackageSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(dataRootSource).toContain('title={null}');
|
||||
expect(dataRootSource).toContain('closable={false}');
|
||||
expect(dataRootSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(securityUpdateSource).toContain('<SecurityUpdateSettingsModal');
|
||||
expect(securityUpdateSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(dataSyncSource).toContain('<DataSyncModal');
|
||||
expect(dataSyncSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(driverSource).toContain('<DriverManagerModal');
|
||||
expect(driverSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(snippetSource).toContain('<SnippetSettingsModal');
|
||||
expect(snippetSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(shortcutSource).toContain('title={null}');
|
||||
expect(shortcutSource).toContain('closable={false}');
|
||||
expect(shortcutSource).not.toContain('renderUtilityModalTitle');
|
||||
});
|
||||
|
||||
it('does not render an extra top back button in the tool center detail header', () => {
|
||||
const detailHeaderSource = appSource.slice(
|
||||
appSource.indexOf("{activeToolCenterPane ? ("),
|
||||
appSource.indexOf('<div style={toolCenterDetailBodyStyle}>', appSource.indexOf("{activeToolCenterPane ? (")),
|
||||
);
|
||||
|
||||
expect(detailHeaderSource).toContain('activeToolCenterPaneItem?.title ?? activeToolCenterGroup.title');
|
||||
expect(detailHeaderSource).toContain('activeToolCenterPaneItem?.description ?? activeToolCenterGroup.description');
|
||||
expect(detailHeaderSource).not.toContain('<Button onClick={closeToolCenterPane}>');
|
||||
expect(detailHeaderSource).not.toContain("{t('common.back_to_previous')}");
|
||||
});
|
||||
|
||||
it('keeps the v2 AI entry in the sidebar and the legacy AI entry on the content edge', () => {
|
||||
expect(appSource).toContain('onToggleAI={toggleAIPanel}');
|
||||
expect(appSource).toContain('renderLegacyAIEdgeHandle');
|
||||
@@ -55,26 +191,44 @@ describe('tool center menu entries', () => {
|
||||
expect(appSource).toContain('const handleOpenToolsModal = useCallback(');
|
||||
expect(appSource).toContain('const handleOpenSettingsModal = useCallback(');
|
||||
expect(appSource).toContain('const handleToggleLogPanel = useCallback(');
|
||||
expect(appSource).toContain('new CustomEvent');
|
||||
expect(appSource).toContain("'gonavi:show-sql-execution-log'");
|
||||
expect(appSource).toContain("detail: { mode: 'open' }");
|
||||
expect(appSource).toContain('toggleAppLogPanel();');
|
||||
expect(appSource).toContain('const handleFocusSidebarSearch = useCallback(');
|
||||
expect(appSource).toContain('const antdTheme = useMemo(() => ({');
|
||||
expect(appSource).toContain('theme={antdTheme}');
|
||||
expect(appSource).toContain('const sqlLogCount = useStore(state => state.sqlLogs.length);');
|
||||
expect(appSource).toContain('onOpenTools={handleOpenToolsModal}');
|
||||
expect(appSource).toContain('onOpenSettings={handleOpenSettingsModal}');
|
||||
expect(appSource).toContain('onToggleLogPanel={handleToggleLogPanel}');
|
||||
expect(appSource).toContain('onFocusCommandSearch={handleFocusSidebarSearch}');
|
||||
expect(appSource).toContain('sqlLogCount={sqlLogCount}');
|
||||
expect(appSource).not.toContain('onOpenTools={() => setIsToolsModalOpen(true)}');
|
||||
expect(appSource).not.toContain('onOpenSettings={() => setIsSettingsModalOpen(true)}');
|
||||
expect(appSource).not.toContain('onToggleLogPanel={() => setIsLogPanelOpen((prev) => !prev)}');
|
||||
expect(appSource).not.toContain('sqlLogCount={sqlLogCount}');
|
||||
expect(appSource).not.toContain('theme={{');
|
||||
expect(appSource).not.toContain('const sqlLogs = useStore(state => state.sqlLogs);');
|
||||
});
|
||||
|
||||
it('renders the shared SQL log panel only for legacy layouts', () => {
|
||||
const logPanelIndex = appSource.indexOf('<LogPanel', appSource.indexOf('<Content'));
|
||||
const logPanelGuardIndex = appSource.lastIndexOf('{isLogPanelOpen && (', logPanelIndex);
|
||||
const legacyOnlyGuardIndex = appSource.lastIndexOf('{!isV2Ui && isLogPanelOpen && (', logPanelIndex);
|
||||
|
||||
expect(logPanelIndex).toBeGreaterThan(-1);
|
||||
expect(logPanelGuardIndex).toBe(-1);
|
||||
expect(legacyOnlyGuardIndex).toBeGreaterThan(-1);
|
||||
expect(appSource).toContain('onClose={handleCloseLogPanel}');
|
||||
expect(appSource).toContain('onResizeStart={handleLogResizeStart}');
|
||||
});
|
||||
|
||||
it('lets the v2 Sidebar own the entire left layout instead of stacking legacy controls above it', () => {
|
||||
const siderIndex = appSource.indexOf("className={isV2Ui ? 'gn-v2-app-sider' : undefined}");
|
||||
const legacyGuardIndex = appSource.indexOf('{!isV2Ui && (', siderIndex);
|
||||
const legacyCreateIndex = appSource.indexOf('新建连接', legacyGuardIndex);
|
||||
const legacyCreateIndex = appSource.indexOf('<Button icon={<PlusOutlined />} onClick={handleCreateConnection}', legacyGuardIndex);
|
||||
const legacyCreateTitleIndex = appSource.indexOf("title={t('connection.new')}", legacyCreateIndex);
|
||||
const legacyQueryIndex = appSource.indexOf('<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery}', legacyGuardIndex);
|
||||
const legacyQueryTitleIndex = appSource.indexOf("title={t('query.new')}", legacyQueryIndex);
|
||||
const sidebarIndex = appSource.indexOf('<Sidebar', legacyGuardIndex);
|
||||
const floatingLogIndex = appSource.indexOf('Floating SQL Log Toggle', sidebarIndex);
|
||||
const floatingLogGuardIndex = appSource.indexOf('{!isV2Ui && (', floatingLogIndex);
|
||||
@@ -83,6 +237,10 @@ describe('tool center menu entries', () => {
|
||||
expect(legacyGuardIndex).toBeGreaterThan(siderIndex);
|
||||
expect(legacyCreateIndex).toBeGreaterThan(legacyGuardIndex);
|
||||
expect(legacyCreateIndex).toBeLessThan(sidebarIndex);
|
||||
expect(legacyCreateTitleIndex).toBeGreaterThan(legacyCreateIndex);
|
||||
expect(legacyQueryIndex).toBeGreaterThan(legacyCreateIndex);
|
||||
expect(legacyQueryIndex).toBeLessThan(sidebarIndex);
|
||||
expect(legacyQueryTitleIndex).toBeGreaterThan(legacyQueryIndex);
|
||||
expect(appSource).toContain('paddingBottom: isV2Ui ? 0 : 58');
|
||||
expect(floatingLogIndex).toBeGreaterThan(sidebarIndex);
|
||||
expect(floatingLogGuardIndex).toBeGreaterThan(floatingLogIndex);
|
||||
@@ -94,15 +252,26 @@ describe('tool center menu entries', () => {
|
||||
expect(appSource).toContain("darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)'");
|
||||
});
|
||||
|
||||
it('keeps tool center and settings v2 accents on the green palette instead of legacy yellow or blue tokens', () => {
|
||||
expect(appSource).toContain("const v2AntPrimaryColor = darkMode ? '#22c55e' : '#16a34a';");
|
||||
expect(appSource).toContain("colorPrimary: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#f6c453' : '#1677ff')");
|
||||
expect(appSource).toContain("background: active\n ? overlayTheme.selectedBg");
|
||||
expect(appSource).toContain("background: active\n ? overlayTheme.selectedText");
|
||||
expect(appSource).toContain("background: active\n ? overlayTheme.iconBg");
|
||||
expect(appSource).toContain("color: active\n ? overlayTheme.iconColor");
|
||||
expect(appSource).toContain("background: isV2Ui ? v2AntPrimaryBgColor : (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.10)')");
|
||||
expect(appSource).toContain("color: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff')");
|
||||
});
|
||||
|
||||
it('does not start sidebar resize from right-clicking the resize handle', () => {
|
||||
expect(appSource).toContain('if (e.button !== 0)');
|
||||
expect(appSidebarResizeSource).toContain('if (e.button !== 0)');
|
||||
expect(appSource).toContain('onContextMenu={(event) => {');
|
||||
expect(appSource).toContain('event.preventDefault();');
|
||||
expect(appSource).toContain('event.stopPropagation();');
|
||||
|
||||
const guardIndex = appSource.indexOf('if (e.button !== 0)');
|
||||
const ghostDisplayIndex = appSource.indexOf("ghostRef.current.style.display = 'block'", guardIndex);
|
||||
const dragStartIndex = appSource.indexOf('sidebarDragRef.current = {', guardIndex);
|
||||
const guardIndex = appSidebarResizeSource.indexOf('if (e.button !== 0)');
|
||||
const ghostDisplayIndex = appSidebarResizeSource.indexOf("ghostRef.current.style.display = 'block'", guardIndex);
|
||||
const dragStartIndex = appSidebarResizeSource.indexOf('sidebarDragRef.current = {', guardIndex);
|
||||
|
||||
expect(guardIndex).toBeGreaterThan(-1);
|
||||
expect(ghostDisplayIndex).toBeGreaterThan(guardIndex);
|
||||
@@ -110,18 +279,22 @@ describe('tool center menu entries', () => {
|
||||
});
|
||||
|
||||
it('positions sidebar resize guide from the rendered sider edge', () => {
|
||||
expect(appSource).toContain('const siderRef = React.useRef<HTMLDivElement | null>(null);');
|
||||
expect(appSidebarResizeSource).toContain('const siderRef = useRef<HTMLDivElement | null>(null);');
|
||||
expect(appSource).toContain('ref={siderRef}');
|
||||
expect(appSource).toContain('const siderRect = siderRef.current?.getBoundingClientRect();');
|
||||
expect(appSource).toContain('const startGuideLeft = siderRect?.right ?? sidebarWidth;');
|
||||
expect(appSource).toContain('const startWidth = siderRect?.width ?? sidebarWidth;');
|
||||
expect(appSource).toContain('resolveSidebarResizeBounds(siderRef.current)');
|
||||
expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft}px`;');
|
||||
expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;');
|
||||
expect(appSidebarResizeSource).toContain('const siderRect = siderRef.current?.getBoundingClientRect();');
|
||||
expect(appSidebarResizeSource).toContain('const startGuideLeft = siderRect?.right ?? sidebarWidth;');
|
||||
expect(appSidebarResizeSource).toContain('const startWidth = siderRect?.width ?? sidebarWidth;');
|
||||
expect(appSidebarResizeSource).toContain('resolveSidebarResizeBounds(siderRef.current)');
|
||||
expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft}px`;');
|
||||
expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;');
|
||||
});
|
||||
|
||||
it('keeps legacy sidebar resize bounds aligned with the v2 sider CSS limits', () => {
|
||||
expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*420px\s*!important;/s);
|
||||
it('keeps sidebar resize bounds aligned across drag logic and sider CSS limits', () => {
|
||||
expect(sidebarLayoutSource).toContain('export const SIDEBAR_RESIZE_MAX_WIDTH = 960;');
|
||||
expect(sidebarLayoutSource).toContain('export const SIDEBAR_MIN_WORKBENCH_WIDTH = 360;');
|
||||
expect(appSidebarResizeSource).toContain('resolveSidebarResizeMaxWidth(window.innerWidth, minWidth)');
|
||||
expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s);
|
||||
expect(v2ThemeCss).toMatch(/body\[data-ui-version="v2"\]\s+\.gn-v2-app-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s);
|
||||
});
|
||||
|
||||
it('keeps connection modal warm-mounted while leaving the other heavyweight modals conditional', () => {
|
||||
@@ -139,6 +312,10 @@ describe('tool center menu entries', () => {
|
||||
it('loads editable connection details before opening the edit modal so stored secrets can be shown', () => {
|
||||
expect(appSource).toContain("typeof backendApp?.GetEditableSavedConnection === 'function'");
|
||||
expect(appSource).toContain('const editableConnection = await backendApp.GetEditableSavedConnection(conn.id);');
|
||||
expect(appSource).toContain('const errorMessage = error?.message;');
|
||||
expect(appSource).toContain("typeof errorMessage === 'string'");
|
||||
expect(appSource).toContain("t('app.connection.message.editable_load_failed_with_detail', { detail })");
|
||||
expect(appSource).toContain("t('app.connection.message.editable_load_failed')");
|
||||
expect(appSource).toContain('setEditingConnection(nextConnection);');
|
||||
expect(appSource).toContain('setIsModalOpen(true);');
|
||||
});
|
||||
@@ -179,7 +356,7 @@ describe('tool center menu entries', () => {
|
||||
['newConnection', 'handleCreateConnection();'],
|
||||
['toggleAIPanel', 'toggleAIPanel();'],
|
||||
['toggleLogPanel', 'handleToggleLogPanel();'],
|
||||
['toggleTheme', 'setTheme('],
|
||||
['toggleTheme', 'setThemePreference('],
|
||||
['openShortcutManager', 'setIsShortcutModalOpen(true);'],
|
||||
['toggleMacFullscreen', 'handleTitleBarWindowToggle({ allowMacNativeFullscreen: true });'],
|
||||
['resetWindowZoom', 'handleManualResetWindowZoom();'],
|
||||
@@ -208,9 +385,13 @@ describe('tool center menu entries', () => {
|
||||
|
||||
it('captures window state on startup and lifecycle events instead of waiting only for the polling interval', () => {
|
||||
expect(appSource).toContain('const scheduleWindowStateSave = (delayMs = 120) => {');
|
||||
expect(appSource).toContain('const scheduleWindowBoundsRepair = (delayMs = 80) => {');
|
||||
expect(appSource).toContain('if (hydrated) {');
|
||||
expect(appSource).toContain('scheduleWindowBoundsRepair(360);');
|
||||
expect(appSource).toContain('scheduleWindowStateSave(320);');
|
||||
expect(appSource).toContain('const unsubscribeHydration = useStore.persist.onFinishHydration(() => {');
|
||||
expect(appSource).toContain('scheduleWindowBoundsRepair();');
|
||||
expect(appSource).toContain('scheduleWindowStateSave(260);');
|
||||
expect(appSource).toContain("window.addEventListener('resize', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('focus', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('pageshow', handleWindowRuntimeChange);");
|
||||
@@ -218,6 +399,15 @@ describe('tool center menu entries', () => {
|
||||
expect(appSource).toContain("window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });");
|
||||
});
|
||||
|
||||
it('clamps normal runtime window bounds back into the visible screen after display changes', () => {
|
||||
expect(appSource).toContain('const readCurrentVisibleViewport = () => ({');
|
||||
expect(appSource).toContain('const repairRuntimeWindowBounds = async () => {');
|
||||
expect(appSource).toContain('const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, readCurrentVisibleViewport());');
|
||||
expect(appSource).toContain("void emitWindowDiagnostic('adjust:runtime-window-bounds'");
|
||||
expect(appSource).toContain('WindowSetSize(nextBounds.width, nextBounds.height);');
|
||||
expect(appSource).toContain('WindowSetPosition(nextBounds.x, nextBounds.y);');
|
||||
});
|
||||
|
||||
it('keeps titlebar double-click on maximise while shortcuts may enter macOS fullscreen', () => {
|
||||
expect(appSource).toContain('const handleTitleBarWindowToggle = async (options?: { allowMacNativeFullscreen?: boolean }) => {');
|
||||
expect(appSource).toContain('const allowMacNativeFullscreen = options?.allowMacNativeFullscreen === true;');
|
||||
@@ -255,20 +445,54 @@ describe('global appearance tokens', () => {
|
||||
expect(appSource).toContain("setProperty('--gn-font-size-mono'");
|
||||
expect(appSource).toContain("setProperty('--gn-data-table-font-size'");
|
||||
expect(appSource).toContain("setProperty('--gn-sidebar-tree-font-size'");
|
||||
expect(appSource).toContain("setProperty('--gn-sidebar-rail-scale'");
|
||||
expect(appSource).toContain("setProperty('--gn-control-height'");
|
||||
expect(appSource).toContain("setProperty('--gn-control-height-sm'");
|
||||
expect(appSource).toContain('fontFamily: resolvedUiFontFamily');
|
||||
expect(appSource).toContain('fontFamilyCode: resolvedMonoFontFamily');
|
||||
expect(appSource).toContain('数据表字体大小');
|
||||
expect(appSource).toContain('左侧库表字体大小');
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies)');
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies)');
|
||||
expect(appSource).toContain('const effectiveSidebarRailScale = sanitizeV2SidebarRailScale(appearance.v2SidebarRailScale);');
|
||||
expect(appSource).toContain("t('app.theme.appearance.sidebar_rail_scale_title')");
|
||||
expect(appSource).toContain("t('app.theme.appearance.sidebar_rail_scale_hint')");
|
||||
expect(appSource).toContain('v2SidebarRailScale: sanitizeV2SidebarRailScale(value)');
|
||||
expect(appSource).toContain("t('app.theme.data_table.font_size')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.sidebar_tree_font_size')");
|
||||
expect(v2ThemeCss).toContain('--gn-sidebar-rail-scale');
|
||||
expect(v2ThemeCss).toContain('font-size: calc(var(--gn-font-size-sm, 12px) * var(--gn-sidebar-rail-scale, 1));');
|
||||
expect(v2ThemeCss).toContain('width: calc(38px * var(--gn-v2-rail-scale));');
|
||||
expect(appSource).toContain("const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_data')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_design')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action_hint')");
|
||||
expect(appSource).toContain("setAppearance({ tableDoubleClickAction: value as 'open-data' | 'open-design' })");
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies, t)');
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies, t)');
|
||||
expect(appSource).toContain('ListInstalledFontFamilies()');
|
||||
expect(appSource).toContain('const [installedFontFamilies, setInstalledFontFamilies] = useState<InstalledFontFamily[]>(EMPTY_INSTALLED_FONT_FAMILIES);');
|
||||
expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';");
|
||||
expect(appSource).toContain('<LinuxCJKFontBanner');
|
||||
expect(linuxCJKFontBannerSource).toContain('data-gonavi-linux-cjk-font-banner="true"');
|
||||
expect(linuxCJKFontBannerSource).toContain('Linux CJK fonts missing / Ubuntu 中文字体缺失');
|
||||
expect(linuxCJKFontBannerSource).toContain('useI18n');
|
||||
expect(linuxCJKFontBannerSource).toContain("t('app.linux_cjk_font_banner.title')");
|
||||
expect(linuxCJKFontBannerSource).toContain("t('app.linux_cjk_font_banner.description')");
|
||||
expect(linuxCJKFontBannerSource).toContain("t('app.linux_cjk_font_banner.action.open_font_settings')");
|
||||
expect(linuxCJKFontBannerSource).toContain("t('common.close')");
|
||||
expect(linuxCJKFontBannerSource).not.toContain('Linux CJK fonts missing / Ubuntu 中文字体缺失');
|
||||
expect(linuxCJKFontBannerSource).not.toContain('Chinese text may render as');
|
||||
expect(linuxCJKFontBannerSource).not.toContain('Font Settings');
|
||||
expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_prefix')");
|
||||
expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_suffix')");
|
||||
expect(appSource).toContain("t('app.theme.query_template.title')");
|
||||
expect(appSource).toContain("t('app.theme.query_template.description')");
|
||||
expect(appSource).toContain("t('app.theme.query_template.hint')");
|
||||
expect(appSource).toContain("t('app.theme.query_template.reset_default')");
|
||||
expect(appSource).toContain("const newQuerySqlTemplate = appearance.newQuerySqlTemplate ?? DEFAULT_QUERY_TEMPLATE;");
|
||||
expect(appSource).toContain("onChange={(event) => setAppearance({ newQuerySqlTemplate: event.target.value })}");
|
||||
expect(appSource).toContain("onClick={() => setAppearance({ newQuerySqlTemplate: null })}");
|
||||
expect(appSource).not.toContain('Ubuntu/Linux 未检测到中文 CJK 字体');
|
||||
expect(appSource).not.toContain(',然后重启 GoNavi。');
|
||||
expect(appSource).not.toContain('新建查询默认 SQL');
|
||||
expect(appSource).not.toContain('清空后新建查询将保持空白');
|
||||
expect(appSource).toContain('setIsLinuxCJKFontBannerDismissed(true)');
|
||||
expect(appSource).toContain('matchFontFamilyOption');
|
||||
expect(appSource).toContain('showSearch');
|
||||
|
||||
22
frontend/src/App.tools-entry.i18n.test.ts
Normal file
22
frontend/src/App.tools-entry.i18n.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('App tools entry i18n guards', () => {
|
||||
it('localizes compare tool entry titles and descriptions', () => {
|
||||
expect(appSource).toContain("t('app.tools.entry.schema_compare.title')");
|
||||
expect(appSource).toContain("t('app.tools.entry.schema_compare.description')");
|
||||
expect(appSource).toContain("t('app.tools.entry.data_compare.title')");
|
||||
expect(appSource).toContain("t('app.tools.entry.data_compare.description')");
|
||||
|
||||
expect(appSource).not.toContain("title: '表结构比对'");
|
||||
expect(appSource).not.toContain("description: '对比源表与目标表结构差异,只预览不执行。'");
|
||||
expect(appSource).not.toContain("title: '数据比对'");
|
||||
expect(appSource).not.toContain("description: '按主键分析新增、更新、删除和相同行。'");
|
||||
});
|
||||
});
|
||||
3639
frontend/src/App.tsx
3639
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -15,37 +15,48 @@ describe('UI version switch placement', () => {
|
||||
|
||||
it('keeps the UI version switch in theme mode and outside macOS-only settings', () => {
|
||||
const themeBranchIndex = appSource.indexOf("{themeModalSection === 'theme' ? (");
|
||||
const uiVersionIndex = appSource.indexOf('界面版本', themeBranchIndex);
|
||||
const lightThemeIndex = appSource.indexOf('亮色主题', themeBranchIndex);
|
||||
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')", themeBranchIndex);
|
||||
const lightThemeIndex = appSource.indexOf("t('app.theme.mode.light.label')", themeBranchIndex);
|
||||
const appearanceBranchIndex = appSource.indexOf(') : (', themeBranchIndex);
|
||||
const macWindowIndex = appSource.indexOf('macOS 窗口控制');
|
||||
const macWindowIndex = appSource.indexOf("t('app.theme.mac_window.title')");
|
||||
|
||||
expect(themeBranchIndex).toBeGreaterThan(-1);
|
||||
expect(uiVersionIndex).toBeGreaterThan(themeBranchIndex);
|
||||
expect(uiVersionIndex).toBeLessThan(lightThemeIndex);
|
||||
expect(uiVersionIndex).toBeLessThan(appearanceBranchIndex);
|
||||
expect(macWindowIndex).toBeGreaterThan(uiVersionIndex);
|
||||
expect(appSource).toContain("badge: '默认'");
|
||||
expect(appSource).toContain("badge: 'Beta'");
|
||||
expect(appSource).toContain("badge: t('app.theme.ui_version.legacy.badge')");
|
||||
expect(appSource).toContain("badge: t('app.theme.ui_version.v2.badge')");
|
||||
expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key as 'legacy' | 'v2' })}");
|
||||
expect(appSource).toContain('新版 UI 仍在 Beta');
|
||||
expect(appSource).toContain('Windows、macOS 与 Linux 均可切换');
|
||||
expect(appSource).toContain('新版左侧搜索模式');
|
||||
expect(appSource).toContain("t('app.theme.ui_version.beta_warning')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.platform_hint')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.title')");
|
||||
expect(appSource).toContain("value={appearance.v2SidebarSearchMode ?? 'command'}");
|
||||
expect(appSource).toContain("setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })");
|
||||
});
|
||||
|
||||
it('uses the card-style v2 switch from the redesign instead of the segmented pill', () => {
|
||||
const uiVersionIndex = appSource.indexOf('界面版本');
|
||||
const themeModeIndex = appSource.indexOf('主题模式', uiVersionIndex);
|
||||
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')");
|
||||
const themeModeIndex = appSource.indexOf("t('app.theme.mode_title')", uiVersionIndex);
|
||||
const uiVersionBlock = appSource.slice(uiVersionIndex, themeModeIndex);
|
||||
|
||||
expect(uiVersionBlock).toContain('NEW');
|
||||
expect(uiVersionBlock).toContain("t('app.theme.ui_version.badge.new')");
|
||||
expect(uiVersionBlock).toContain("gridTemplateColumns: 'repeat(2, minmax(0, 1fr))'");
|
||||
expect(uiVersionBlock).toContain("label: '旧版 UI'");
|
||||
expect(uiVersionBlock).toContain("label: '新版 UI'");
|
||||
expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.legacy.label')");
|
||||
expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.v2.label')");
|
||||
expect(uiVersionBlock).toContain('CheckOutlined');
|
||||
expect(uiVersionBlock).toContain('新版左侧搜索模式');
|
||||
expect(uiVersionBlock).toContain("t('app.theme.ui_version.sidebar_search.title')");
|
||||
expect(uiVersionBlock).toContain('<Segmented');
|
||||
});
|
||||
|
||||
it('localizes the v2 sidebar search mode copy', () => {
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.title')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.command')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.filter')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.hint')");
|
||||
expect(appSource).not.toContain('新版左侧搜索模式');
|
||||
expect(appSource).not.toContain('新版命令搜索');
|
||||
expect(appSource).not.toContain('旧版侧栏筛选');
|
||||
expect(appSource).not.toContain('新版命令搜索适合跳转连接、表和动作');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,112 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { t as catalogTranslate } from '../i18n/catalog';
|
||||
|
||||
const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8');
|
||||
const testSource = readFileSync(new URL('./AIChatPanel.message-boundary.test.tsx', import.meta.url), 'utf8');
|
||||
const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8');
|
||||
const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversationView.tsx', import.meta.url), 'utf8');
|
||||
const derivedStateSource = readFileSync(new URL('./ai/aiChatPanelDerivedState.ts', import.meta.url), 'utf8');
|
||||
const autoContextSource = readFileSync(new URL('./ai/useAIChatAutoContext.ts', import.meta.url), 'utf8');
|
||||
const payloadDispatchSource = readFileSync(new URL('./ai/aiChatPayloadDispatch.ts', import.meta.url), 'utf8');
|
||||
const planContextSource = readFileSync(new URL('./ai/useAIChatPlanContexts.ts', import.meta.url), 'utf8');
|
||||
const resizeSource = readFileSync(new URL('./ai/useAIChatPanelResize.ts', import.meta.url), 'utf8');
|
||||
const runtimeResourcesSource = readFileSync(new URL('./ai/useAIChatRuntimeResources.ts', import.meta.url), 'utf8');
|
||||
const sessionStateSource = readFileSync(new URL('./ai/useAIChatSessionState.ts', import.meta.url), 'utf8');
|
||||
const titleGeneratorSource = readFileSync(new URL('./ai/useAIChatSessionTitleGenerator.ts', import.meta.url), 'utf8');
|
||||
const localToolsSource = readFileSync(new URL('./ai/useAIChatLocalTools.ts', import.meta.url), 'utf8');
|
||||
const streamSubscriptionSource = readFileSync(new URL('./ai/useAIChatStreamSubscription.ts', import.meta.url), 'utf8');
|
||||
const inspectionGuidanceSource = readFileSync(new URL('./ai/aiSystemInspectionGuidance.ts', import.meta.url), 'utf8');
|
||||
const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8');
|
||||
const runtimeSource = readFileSync(new URL('../utils/aiChatRuntime.ts', import.meta.url), 'utf8');
|
||||
|
||||
describe('AIChatPanel message render isolation', () => {
|
||||
it('keeps per-message render failures scoped to the broken bubble', () => {
|
||||
const REQUIRED_RENDER_BOUNDARY_KEYS = [
|
||||
'ai_chat.message.render_error.title',
|
||||
'ai_chat.message.render_error.body',
|
||||
'ai_chat.message.render_error.unknown',
|
||||
'ai_chat.message.render_error.retry',
|
||||
'ai_chat.message.render_error.delete',
|
||||
] as const;
|
||||
|
||||
describe('AIChatPanel merge resolution', () => {
|
||||
it('clears conflict markers from the merged files', () => {
|
||||
expect(source).not.toMatch(/^<{7}|^={7}|^>{7}/m);
|
||||
expect(testSource).not.toMatch(/^<{7}|^={7}|^>{7}/m);
|
||||
});
|
||||
|
||||
it('keeps dev split architecture while retaining render-boundary isolation', () => {
|
||||
expect(source).toContain("import AIChatPanelConversationView from './ai/AIChatPanelConversationView';");
|
||||
expect(boundarySource).toContain('class AIMessageRenderBoundary extends React.Component');
|
||||
expect(source).toContain('[AI Message Render Error]');
|
||||
expect(conversationViewSource).toContain("import AIMessageRenderBoundary from './AIMessageRenderBoundary';");
|
||||
expect(boundarySource).toContain('这条 AI 消息渲染失败,已自动隔离');
|
||||
expect(source).toContain('__gonaviLastAIMessageRenderError');
|
||||
expect(conversationViewSource).toContain('<AIMessageRenderBoundary');
|
||||
expect(conversationViewSource).toContain('onDeleteMessage={onDeleteMessage}');
|
||||
});
|
||||
|
||||
it('loads user prompt settings and appends them as system messages', () => {
|
||||
expect(source).toContain("import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources';");
|
||||
expect(source).toContain('useAIChatRuntimeResources({ onOpenSettings })');
|
||||
expect(runtimeResourcesSource).toContain('AIGetUserPromptSettings');
|
||||
expect(runtimeResourcesSource).toContain("window.addEventListener('gonavi:ai:config-changed'");
|
||||
expect(systemContextSource).toContain('以下是当前用户的自定义补充提示词');
|
||||
expect(systemContextSource).toContain("appendCustomPromptGroup(systemMessages, ['database']");
|
||||
});
|
||||
|
||||
it('loads MCP tools and skills into the runtime tool chain', () => {
|
||||
expect(runtimeResourcesSource).toContain('AIListMCPTools');
|
||||
expect(runtimeResourcesSource).toContain('AIGetSkills');
|
||||
expect(source).toContain("import { useAIChatLocalTools } from './ai/useAIChatLocalTools';");
|
||||
expect(localToolsSource).toContain('executeLocalAIToolCall');
|
||||
expect(systemContextSource).toContain('以下是当前启用的 Skill');
|
||||
expect(source).toContain('buildAvailableAIChatTools');
|
||||
});
|
||||
|
||||
it('teaches the runtime to use deeper schema tools when analyzing structure details', () => {
|
||||
expect(systemContextSource).toContain('get_indexes、get_foreign_keys、get_triggers、get_table_ddl');
|
||||
expect(systemContextSource).toContain('inspect_active_tab 读取当前活动页签上下文');
|
||||
expect(systemContextSource).toContain('inspect_workspace_tabs 盘点当前工作区');
|
||||
expect(inspectionGuidanceSource).toContain('inspect_current_connection');
|
||||
expect(inspectionGuidanceSource).toContain('inspect_external_sql_directories');
|
||||
expect(inspectionGuidanceSource).toContain('inspect_external_sql_file');
|
||||
expect(localToolsSource).toContain('tabs: currentState.tabs');
|
||||
expect(localToolsSource).toContain('activeTabId: currentState.activeTabId');
|
||||
expect(localToolsSource).toContain('externalSQLDirectories: currentState.externalSQLDirectories');
|
||||
expect(localToolsSource).toContain('toolContextMap: toolContextMapRef.current');
|
||||
expect(localToolsSource).toContain('buildToolResultMessage');
|
||||
});
|
||||
|
||||
it('extracts chat runtime helpers so context compression and error cleanup stay out of the panel file', () => {
|
||||
expect(source).toContain("import { dispatchAIChatPayload } from './ai/aiChatPayloadDispatch';");
|
||||
expect(source).toContain("import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';");
|
||||
expect(source).toContain('compressContextIfNeeded, getDynamicMaxContextChars');
|
||||
expect(source).toContain('useAIChatStreamSubscription({');
|
||||
expect(source).toContain('useAIChatLocalTools({');
|
||||
expect(runtimeSource).toContain('export const getDynamicMaxContextChars');
|
||||
expect(runtimeSource).toContain('export const compressContextIfNeeded');
|
||||
expect(runtimeSource).toContain('export const sanitizeErrorMsg');
|
||||
expect(payloadDispatchSource).toContain('export const dispatchAIChatPayload');
|
||||
expect(payloadDispatchSource).toContain('sanitizeErrorMsg');
|
||||
expect(localToolsSource).toContain('compressContextIfNeeded');
|
||||
expect(localToolsSource).toContain('dispatchAIChatPayload');
|
||||
expect(streamSubscriptionSource).toContain('EventsOn(eventName, handler);');
|
||||
expect(streamSubscriptionSource).toContain('请直接使用 function call 调用工具执行操作');
|
||||
expect(streamSubscriptionSource).toContain('executeLocalTools(existing.tool_calls!, doneAssistantId)');
|
||||
expect(runtimeSource).toContain('⚙️ 对话已超载,正在启动记忆压缩');
|
||||
});
|
||||
|
||||
it('keeps the v2 history mode sorted by the latest updated session first', () => {
|
||||
expect(source).toContain("import { useAIChatSessionState } from './ai/useAIChatSessionState';");
|
||||
expect(source).toContain('const panelHistorySessions = useMemo(');
|
||||
expect(sessionStateSource).toContain('right.updatedAt - left.updatedAt');
|
||||
expect(sessionStateSource).toContain("const sid = aiActiveSessionId || 'session-fallback';");
|
||||
expect(source).toContain('buildAIChatInlineHistorySessions(orderedAISessions)');
|
||||
expect(derivedStateSource).toContain('export const buildAIChatInlineHistorySessions');
|
||||
expect(derivedStateSource).toContain('sessions.slice(0, limit)');
|
||||
expect(source).toContain('sessions={panelHistorySessions}');
|
||||
});
|
||||
|
||||
it('extracts plan-context, auto-context, title, and resize hooks so the panel file stays focused on orchestration', () => {
|
||||
expect(source).toContain("import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts';");
|
||||
expect(source).toContain("import { useAIChatAutoContext } from './ai/useAIChatAutoContext';");
|
||||
expect(source).toContain("import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator';");
|
||||
expect(source).toContain("import { useAIChatPanelResize } from './ai/useAIChatPanelResize';");
|
||||
expect(source).toContain("import { useAIChatLocalTools } from './ai/useAIChatLocalTools';");
|
||||
expect(planContextSource).toContain('export const useAIChatPlanContexts');
|
||||
expect(planContextSource).toContain('pendingJVMPlanContextRef');
|
||||
expect(autoContextSource).toContain('export const useAIChatAutoContext');
|
||||
expect(autoContextSource).toContain('DBShowCreateTable');
|
||||
expect(titleGeneratorSource).toContain('export const useAIChatSessionTitleGenerator');
|
||||
expect(titleGeneratorSource).toContain('Failed to auto-generate title');
|
||||
expect(resizeSource).toContain('export const useAIChatPanelResize');
|
||||
expect(resizeSource).toContain('document.body.style.pointerEvents = \'none\'');
|
||||
expect(localToolsSource).toContain('export const useAIChatLocalTools');
|
||||
expect(localToolsSource).toContain('MAX_TOOL_CALL_ROUNDS');
|
||||
|
||||
expect(boundarySource).toContain('class AIMessageRenderBoundary extends React.Component');
|
||||
expect(conversationViewSource).toContain("import AIMessageRenderBoundary from './AIMessageRenderBoundary';");
|
||||
expect(conversationViewSource).toContain('<AIMessageRenderBoundary');
|
||||
expect(source).toContain('onMessageRenderError={handleMessageRenderError}');
|
||||
expect(source).toContain('__gonaviLastAIMessageRenderError');
|
||||
expect(source).toContain('[AI Message Render Error]');
|
||||
});
|
||||
|
||||
it('keeps render-boundary recovery chrome translated through catalog keys', () => {
|
||||
expect(boundarySource).toContain('useOptionalI18n()');
|
||||
expect(boundarySource).toContain("catalogTranslate('en-US'");
|
||||
for (const key of REQUIRED_RENDER_BOUNDARY_KEYS) {
|
||||
expect(catalogTranslate('en-US', key)).not.toBe(key);
|
||||
expect(catalogTranslate('zh-CN', key)).not.toBe(key);
|
||||
expect(boundarySource).toContain(key);
|
||||
}
|
||||
|
||||
for (const oldCopy of [
|
||||
'这条 AI 消息渲染失败,已自动隔离',
|
||||
'其余对话仍可继续使用。你可以先删除这条异常消息,再继续操作。',
|
||||
'未知渲染错误',
|
||||
'重试渲染',
|
||||
'删除这条消息',
|
||||
]) {
|
||||
expect(boundarySource).not.toContain(oldCopy);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores panel-level i18n orchestration for composer notices and send lifecycle text', () => {
|
||||
expect(source).toContain("import { useI18n } from '../i18n/provider';");
|
||||
expect(source).toContain("import type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice';");
|
||||
expect(source).toContain("import { buildAIComposerNotice } from '../utils/aiComposerNotice';");
|
||||
expect(source).toContain("const { t } = useI18n();");
|
||||
expect(source).toContain("const [composerNoticeState, setComposerNoticeState] = useState<AIComposerNoticeDescriptor | null>(null);");
|
||||
expect(source).toContain("buildAIComposerNotice(t, composerNoticeState) ?? runtimeComposerNotice");
|
||||
expect(source).toContain("setComposerNoticeState({ kind: 'missing_provider' });");
|
||||
expect(source).toContain("setComposerNoticeState({ kind: 'provider_incomplete', issues: readiness.issues });");
|
||||
expect(source).toContain("setComposerNoticeState({ kind: 'missing_model' });");
|
||||
expect(source).toContain('const chatMessages = [...messages, userMsg].map((message) => toAIRequestMessage(message, t));');
|
||||
expect(source).toContain('toAIRequestMessage(userMsg, t)');
|
||||
|
||||
for (const key of [
|
||||
'ai_chat.panel.status.model_connecting',
|
||||
'ai_chat.panel.status.waking_engine',
|
||||
'ai_chat.panel.status.waiting_response',
|
||||
'ai_chat.panel.status.memory_summary',
|
||||
'ai_chat.panel.message.service_not_ready',
|
||||
]) {
|
||||
expect(source).toContain(`t('${key}'`);
|
||||
}
|
||||
|
||||
expect(source).not.toContain('buildMissingProviderNotice');
|
||||
expect(source).not.toContain('buildIncompleteProviderNotice');
|
||||
expect(source).not.toContain('buildMissingModelNotice');
|
||||
});
|
||||
|
||||
it('keeps translated session and insight chrome in the panel layer instead of falling back to hardcoded copy', () => {
|
||||
expect(source).toContain("() => orderedAISessions.find((session) => session.id === sid)?.title || t('ai_chat.panel.session.default_title')");
|
||||
expect(source).toContain("title: session.title || t('ai_chat.panel.session.default_title')");
|
||||
expect(source).toContain('buildAIChatInsights({');
|
||||
expect(source).toContain('translate: t,');
|
||||
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.context.linked_title', { count: contextCount })");
|
||||
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.context.linked_body', { tables: tablePreview })");
|
||||
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.query.slowest_title', { duration: Math.round(slowest.duration).toLocaleString() })");
|
||||
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.status.recent_body', { count: recentLogs.length })");
|
||||
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.write.detected_title', { count: writeCount })");
|
||||
expect(source).not.toContain("|| '新对话'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,11 +16,8 @@ import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
|
||||
import AIChatPanelConversationView from './ai/AIChatPanelConversationView';
|
||||
import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
buildIncompleteProviderNotice,
|
||||
buildMissingModelNotice,
|
||||
buildMissingProviderNotice,
|
||||
} from '../utils/aiComposerNotice';
|
||||
import type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice';
|
||||
import { buildAIComposerNotice } from '../utils/aiComposerNotice';
|
||||
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
|
||||
import { toAIRequestMessage } from '../utils/aiMessagePayload';
|
||||
import { compressContextIfNeeded, getDynamicMaxContextChars } from '../utils/aiChatRuntime';
|
||||
@@ -28,8 +25,8 @@ import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts'
|
||||
import { isMacLikePlatform } from '../utils/appearance';
|
||||
import { buildAvailableAIChatTools } from '../utils/aiToolRegistry';
|
||||
import {
|
||||
buildAIChatInlineHistorySessions,
|
||||
buildAIChatInsights,
|
||||
buildAIChatInlineHistorySessions,
|
||||
calculateAIContextUsageChars,
|
||||
collectAIChatContextTableNames,
|
||||
inferAIChatConnectionContext,
|
||||
@@ -45,6 +42,7 @@ import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts';
|
||||
import { useAIChatSessionState } from './ai/useAIChatSessionState';
|
||||
import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator';
|
||||
import { useAIChatLocalTools } from './ai/useAIChatLocalTools';
|
||||
import { useI18n } from '../i18n/provider';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
@@ -58,18 +56,20 @@ interface AIChatPanelProps {
|
||||
|
||||
const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme
|
||||
export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [input, setInput] = useState('');
|
||||
const [draftAttachments, setDraftAttachments] = useState<AIChatAttachment[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat');
|
||||
const [composerNoticeState, setComposerNoticeState] = useState<AIComposerNoticeDescriptor | null>(null);
|
||||
const {
|
||||
activeProvider,
|
||||
composerNotice,
|
||||
composerNotice: runtimeComposerNotice,
|
||||
dynamicModels,
|
||||
fetchDynamicModels,
|
||||
handleComposerAction,
|
||||
@@ -77,14 +77,13 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
handleOpenSettingsFromPanel,
|
||||
loadingModels,
|
||||
mcpTools,
|
||||
setComposerNotice,
|
||||
skills,
|
||||
userPromptSettings,
|
||||
} = useAIChatRuntimeResources({ onOpenSettings });
|
||||
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数
|
||||
const nudgeCountRef = useRef(0);
|
||||
const {
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
@@ -92,7 +91,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
} = useAIChatPlanContexts();
|
||||
|
||||
const aiChatHistory = useStore(state => state.aiChatHistory);
|
||||
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const createNewAISession = useStore(state => state.createNewAISession);
|
||||
@@ -101,7 +99,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const deleteAIChatMessage = useStore(state => state.deleteAIChatMessage);
|
||||
const truncateAIChatMessages = useStore(state => state.truncateAIChatMessages);
|
||||
const updateAISessionTitle = useStore(state => state.updateAISessionTitle);
|
||||
|
||||
|
||||
const activeContext = useStore(state => state.activeContext);
|
||||
const aiContexts = useStore(state => state.aiContexts);
|
||||
const connections = useStore(state => state.connections);
|
||||
@@ -125,8 +123,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
onWidthChange,
|
||||
});
|
||||
const availableTools = useMemo(
|
||||
() => buildAvailableAIChatTools(mcpTools),
|
||||
[mcpTools],
|
||||
() => buildAvailableAIChatTools(mcpTools, t),
|
||||
[mcpTools, t],
|
||||
);
|
||||
const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding(
|
||||
state.shortcutOptions,
|
||||
@@ -145,23 +143,32 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
tabs,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (runtimeComposerNotice) {
|
||||
setComposerNoticeState(null);
|
||||
}
|
||||
}, [runtimeComposerNotice]);
|
||||
|
||||
const getConnectionName = useCallback(() => {
|
||||
let connectionId = activeContext?.connectionId;
|
||||
if (!connectionId) {
|
||||
const activeTab = tabs.find(t => t.id === activeTabId);
|
||||
const activeTab = tabs.find(tab => tab.id === activeTabId);
|
||||
connectionId = activeTab?.connectionId;
|
||||
}
|
||||
if (!connectionId) return '';
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
return conn ? conn.name : '';
|
||||
const connection = connections.find(item => item.id === connectionId);
|
||||
return connection ? connection.name : '';
|
||||
}, [activeContext, activeTabId, connections, tabs]);
|
||||
|
||||
const activeConnName = getConnectionName();
|
||||
const composerNotice = useMemo(
|
||||
() => buildAIComposerNotice(t, composerNoticeState) ?? runtimeComposerNotice,
|
||||
[composerNoticeState, runtimeComposerNotice, t],
|
||||
);
|
||||
|
||||
const textColor = overlayTheme.titleText;
|
||||
const mutedColor = overlayTheme.mutedText;
|
||||
const borderColor = overlayTheme.divider;
|
||||
const assistantBubbleBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)';
|
||||
const quickActionBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)';
|
||||
const quickActionBorder = overlayTheme.sectionBorder;
|
||||
|
||||
@@ -178,15 +185,12 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
if (detail?.prompt) {
|
||||
setInput(detail.prompt);
|
||||
setTimeout(() => {
|
||||
const el = textareaRef.current as any;
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
textareaRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
@@ -196,8 +200,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
|
||||
const generateTitleForSession = useAIChatSessionTitleGenerator({ updateAISessionTitle });
|
||||
|
||||
const handleScrollMessages = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const handleScrollMessages = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
|
||||
setShowScrollBottom(!isNearBottom);
|
||||
}, []);
|
||||
@@ -230,8 +234,9 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
userPromptSettings,
|
||||
overrideJVMPlanContext,
|
||||
overrideJVMDiagnosticPlanContext,
|
||||
translate: t,
|
||||
});
|
||||
}, [availableTools, skills, userPromptSettings]);
|
||||
}, [availableTools, skills, t, userPromptSettings]);
|
||||
|
||||
const {
|
||||
executeLocalTools,
|
||||
@@ -249,15 +254,16 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
setSending,
|
||||
skills,
|
||||
translate: t,
|
||||
updateAIChatMessage,
|
||||
userPromptSettings,
|
||||
});
|
||||
|
||||
const handleRetryMessage = useCallback(async (msg: AIChatMessage) => {
|
||||
const historyLocal = useStore.getState().aiChatHistory[sid] || [];
|
||||
const aiIndex = historyLocal.findIndex(m => m.id === msg.id);
|
||||
const aiIndex = historyLocal.findIndex(message => message.id === msg.id);
|
||||
if (aiIndex <= 0) return;
|
||||
|
||||
|
||||
let lastUserMsgIndex = -1;
|
||||
for (let i = aiIndex - 1; i >= 0; i--) {
|
||||
if (historyLocal[i].role === 'user') {
|
||||
@@ -265,10 +271,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (lastUserMsgIndex >= 0) {
|
||||
const userMsg = historyLocal[lastUserMsgIndex];
|
||||
truncateAIChatMessages(sid, userMsg.id);
|
||||
truncateAIChatMessages(sid, userMsg.id);
|
||||
|
||||
resetToolCallState();
|
||||
nudgeCountRef.current = 0;
|
||||
@@ -280,18 +286,21 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
|
||||
setSending(true);
|
||||
|
||||
// 插入 connecting 过渡消息(波纹动画),与 handleSend 保持一致
|
||||
const connectingMsg: AIChatMessage = {
|
||||
id: genId(), role: 'assistant', phase: 'connecting', content: '',
|
||||
timestamp: Date.now(), loading: true,
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
phase: 'connecting',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
loading: true,
|
||||
jvmPlanContext: retryJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
|
||||
};
|
||||
addAIChatMessage(sid, connectingMsg);
|
||||
|
||||
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
|
||||
const messagesPayload = truncatedHistory.map(toAIRequestMessage);
|
||||
|
||||
const messagesPayload = truncatedHistory.map((message) => toAIRequestMessage(message, t));
|
||||
|
||||
try {
|
||||
const sysMessages = await buildSystemContextMessages(
|
||||
retryJVMPlanContext,
|
||||
@@ -309,6 +318,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
pendingAssistantMessageId: connectingMsg.id,
|
||||
jvmPlanContext: retryJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
|
||||
translate: t,
|
||||
});
|
||||
} catch {
|
||||
setSending(false);
|
||||
@@ -323,6 +333,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
resetToolCallState,
|
||||
t,
|
||||
updateAIChatMessage,
|
||||
]);
|
||||
|
||||
@@ -340,6 +351,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
nudgeCountRef,
|
||||
pendingJVMPlanContextRef,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
translate: t,
|
||||
});
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
@@ -355,23 +367,22 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
activeContextItems: aiContexts[connectionKey] || [],
|
||||
});
|
||||
|
||||
// 前置校验:必须配置供应商、补全基础参数并选择模型后才能发送
|
||||
if (readiness.status === 'missing_provider') {
|
||||
setComposerNotice(buildMissingProviderNotice());
|
||||
setComposerNoticeState({ kind: 'missing_provider' });
|
||||
return;
|
||||
}
|
||||
if (readiness.status === 'provider_incomplete') {
|
||||
setComposerNotice(buildIncompleteProviderNotice(readiness.issues));
|
||||
setComposerNoticeState({ kind: 'provider_incomplete', issues: readiness.issues });
|
||||
return;
|
||||
}
|
||||
if (readiness.status === 'missing_model' || readiness.status === 'loading_models') {
|
||||
setComposerNotice(buildMissingModelNotice());
|
||||
setComposerNoticeState({ kind: 'missing_model' });
|
||||
return;
|
||||
}
|
||||
setComposerNotice(null);
|
||||
setComposerNoticeState(null);
|
||||
|
||||
resetToolCallState();
|
||||
nudgeCountRef.current = 0; // 重置催促计数
|
||||
nudgeCountRef.current = 0;
|
||||
const currentJVMPlanContext = getCurrentJVMPlanContext();
|
||||
const currentJVMDiagnosticPlanContext = getCurrentJVMDiagnosticPlanContext();
|
||||
pendingJVMPlanContextRef.current = currentJVMPlanContext;
|
||||
@@ -386,20 +397,25 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
setDraftAttachments([]);
|
||||
setSending(true);
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
textareaRef.current?.focus();
|
||||
|
||||
const userMsg: AIChatMessage = {
|
||||
id: genId(), role: 'user', content: text, timestamp: Date.now(),
|
||||
id: genId(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
images: currentImages.length > 0 ? currentImages : undefined,
|
||||
attachments: currentFileAttachments.length > 0 ? currentFileAttachments : undefined,
|
||||
};
|
||||
addAIChatMessage(sid, userMsg);
|
||||
|
||||
|
||||
const connectingMsg: AIChatMessage = {
|
||||
id: genId(), role: 'assistant', phase: 'connecting', content: '',
|
||||
timestamp: Date.now(), loading: true,
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
phase: 'connecting',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
loading: true,
|
||||
jvmPlanContext: currentJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
|
||||
};
|
||||
@@ -410,33 +426,31 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
currentJVMDiagnosticPlanContext,
|
||||
);
|
||||
|
||||
// 【过渡状态 2】上下文已组装完成,即将接入模型
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: '模型接入中' });
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.model_connecting') });
|
||||
|
||||
const chatMessages = [...messages, userMsg].map(toAIRequestMessage);
|
||||
const chatMessages = [...messages, userMsg].map((message) => toAIRequestMessage(message, t));
|
||||
|
||||
let finalMessagesPayload = chatMessages;
|
||||
const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model);
|
||||
const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit);
|
||||
const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit, t);
|
||||
if (summary) {
|
||||
// 清理原有历史,保留系统生成的总结记录和当前的 userMsg 以及 connectingMsg
|
||||
const compressedMsg: AIChatMessage = {
|
||||
id: genId(), role: 'assistant', content: `【自动记忆重塑】已将超长历史压缩为摘要:\n\n${summary}`, timestamp: Date.now() - 1000
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
content: t('ai_chat.panel.status.memory_summary', { summary }),
|
||||
timestamp: Date.now() - 1000,
|
||||
};
|
||||
useStore.getState().replaceAIChatHistory(sid, [compressedMsg, userMsg, connectingMsg]);
|
||||
finalMessagesPayload = [
|
||||
{ role: 'assistant', content: compressedMsg.content },
|
||||
toAIRequestMessage(userMsg),
|
||||
toAIRequestMessage(userMsg, t),
|
||||
];
|
||||
}
|
||||
|
||||
const allMessages = [...systemMessages, ...finalMessagesPayload];
|
||||
|
||||
// 【过渡状态 3】大脑唤醒
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: '唤醒推理引擎中' });
|
||||
|
||||
// 【过渡状态 4】最后一步,等待第一字节返回
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: '等待模型响应' });
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.waking_engine') });
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.waiting_response') });
|
||||
|
||||
await dispatchAIChatPayload({
|
||||
sid,
|
||||
@@ -449,7 +463,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
pendingAssistantMessageId: connectingMsg.id,
|
||||
jvmPlanContext: currentJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
|
||||
unavailableContent: '❌ AI Service 未就绪',
|
||||
unavailableContent: t('ai_chat.panel.message.service_not_ready'),
|
||||
translate: t,
|
||||
onNonStreamSuccess: messages.length === 0
|
||||
? () => generateTitleForSession(sid)
|
||||
: undefined,
|
||||
@@ -471,11 +486,13 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
loadingModels,
|
||||
resetToolCallState,
|
||||
t,
|
||||
updateAIChatMessage,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, e, handleSend);
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, event, handleSend);
|
||||
}, [aiChatSendShortcutBinding, handleSend]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
@@ -484,8 +501,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
if (Service?.AIChatCancel) {
|
||||
await Service.AIChatCancel(sid);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to stop chat stream', e);
|
||||
} catch (error) {
|
||||
console.warn('Failed to stop chat stream', error);
|
||||
}
|
||||
setSending(false);
|
||||
}, [sid]);
|
||||
@@ -500,7 +517,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
[activeContext?.connectionId, activeContext?.dbName, messages],
|
||||
);
|
||||
|
||||
// useMemo 缓存:避免内联闭包击穿子组件 memo
|
||||
const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]);
|
||||
const handleMessageRenderError = useCallback((error: Error, errorInfo: React.ErrorInfo, msg: AIChatMessage) => {
|
||||
console.error('[AI Message Render Error]', msg.id, error, errorInfo);
|
||||
@@ -519,12 +535,12 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
(globalThis as any).__gonaviLastAIMessageRenderError = renderErrorPayload;
|
||||
}, []);
|
||||
const currentSessionTitle = useMemo(
|
||||
() => orderedAISessions.find((session) => session.id === sid)?.title || '新对话',
|
||||
[orderedAISessions, sid],
|
||||
() => orderedAISessions.find((session) => session.id === sid)?.title || t('ai_chat.panel.session.default_title'),
|
||||
[orderedAISessions, sid, t],
|
||||
);
|
||||
const activeConnectionConfig = useMemo(() => {
|
||||
if (!inferredConnectionId) return undefined;
|
||||
const connection = connections.find(c => c.id === inferredConnectionId);
|
||||
const connection = connections.find(item => item.id === inferredConnectionId);
|
||||
return connection ? buildRpcConnectionConfig(connection.config) : undefined;
|
||||
}, [inferredConnectionId, connections]);
|
||||
const contextUsageChars = useMemo(
|
||||
@@ -539,28 +555,43 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}),
|
||||
[activeContext?.connectionId, activeContext?.dbName, aiContexts],
|
||||
);
|
||||
const aiInsights = useMemo(
|
||||
() => buildAIChatInsights({
|
||||
const aiInsights = useMemo(() => {
|
||||
return buildAIChatInsights({
|
||||
contextTableNames,
|
||||
sqlLogs,
|
||||
}),
|
||||
[contextTableNames, sqlLogs],
|
||||
);
|
||||
translate: t,
|
||||
});
|
||||
}, [contextTableNames, sqlLogs, t]);
|
||||
const panelHistorySessions = useMemo(
|
||||
() => buildAIChatInlineHistorySessions(orderedAISessions),
|
||||
[orderedAISessions],
|
||||
() => buildAIChatInlineHistorySessions(
|
||||
orderedAISessions.map((session) => ({
|
||||
...session,
|
||||
title: session.title || t('ai_chat.panel.session.default_title'),
|
||||
})),
|
||||
),
|
||||
[orderedAISessions, t],
|
||||
);
|
||||
const effectivePanelMode = useMemo(
|
||||
() => resolveAIChatPanelMode(isV2Ui, activePanelMode),
|
||||
[activePanelMode, isV2Ui],
|
||||
);
|
||||
|
||||
const handleComposerActionWithNoticeReset = useCallback((actionKey: 'open-settings' | 'reload-models') => {
|
||||
setComposerNoticeState(null);
|
||||
handleComposerAction(actionKey);
|
||||
}, [handleComposerAction]);
|
||||
|
||||
const handleModelChangeWithNoticeReset = useCallback((model: string) => {
|
||||
setComposerNoticeState(null);
|
||||
void handleModelChange(model);
|
||||
}, [handleModelChange]);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className={`ai-chat-panel${isV2Ui ? ' gn-v2-ai-panel' : ''}`} style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
|
||||
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
|
||||
|
||||
|
||||
{isResizing && panelRect.current && createPortal(
|
||||
<div
|
||||
<div
|
||||
ref={ghostRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -662,8 +693,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
sendShortcutBinding={aiChatSendShortcutBinding}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
composerNotice={composerNotice}
|
||||
onComposerAction={handleComposerAction}
|
||||
onModelChange={handleModelChange}
|
||||
onComposerAction={handleComposerActionWithNoticeReset}
|
||||
onModelChange={handleModelChangeWithNoticeReset}
|
||||
onFetchModels={fetchDynamicModels}
|
||||
textareaRef={textareaRef}
|
||||
darkMode={darkMode}
|
||||
|
||||
@@ -19,6 +19,79 @@ describe('AISettingsModal edit password behavior', () => {
|
||||
expect(source).toContain('<AISettingsPromptsSection');
|
||||
});
|
||||
|
||||
it('localizes user prompt settings toast fallbacks', () => {
|
||||
expect(source).toContain("messageApi.success(t('ai_settings.prompts.message.saved'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.prompts.message.save_failed'))");
|
||||
expect(source).not.toContain("'自定义提示词已保存'");
|
||||
expect(source).not.toContain("'保存自定义提示词失败'");
|
||||
});
|
||||
|
||||
it('localizes MCP server toast fallbacks', () => {
|
||||
expect(source).toContain("messageApi.success(t('ai_settings.mcp_server.message.saved'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.save_failed'))");
|
||||
expect(source).toContain("messageApi.success(t('ai_settings.mcp_server.message.deleted'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.delete_failed'))");
|
||||
expect(source).toContain("messageApi.success(res?.message || t('ai_settings.mcp_server.message.test_success'))");
|
||||
expect(source).toContain("messageApi.error(res?.message || t('ai_settings.mcp_server.message.test_failed'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.test_request_failed'))");
|
||||
expect(source).not.toContain("'MCP 服务已保存'");
|
||||
expect(source).not.toContain("'保存 MCP 服务失败'");
|
||||
expect(source).not.toContain("'MCP 服务已删除'");
|
||||
expect(source).not.toContain("'删除 MCP 服务失败'");
|
||||
expect(source).not.toContain("'MCP 服务连接成功'");
|
||||
expect(source).not.toContain("'MCP 服务测试失败'");
|
||||
expect(source).not.toContain("'测试 MCP 服务失败'");
|
||||
});
|
||||
|
||||
it('localizes Skill toast fallbacks', () => {
|
||||
expect(source).toContain("messageApi.success(t('ai_settings.skill.message.saved'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.skill.message.save_failed'))");
|
||||
expect(source).toContain("messageApi.success(t('ai_settings.skill.message.deleted'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.skill.message.delete_failed'))");
|
||||
expect(source).not.toContain("'Skill 已保存'");
|
||||
expect(source).not.toContain("'保存 Skill 失败'");
|
||||
expect(source).not.toContain("'Skill 已删除'");
|
||||
expect(source).not.toContain("'删除 Skill 失败'");
|
||||
});
|
||||
|
||||
it('localizes MCP HTTP control and copy fallbacks', () => {
|
||||
expect(source).toContain("throw new Error(t('ai_settings.clipboard.error.unsupported'))");
|
||||
expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.control_unsupported_runtime'))");
|
||||
expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.start_unsupported_version'))");
|
||||
expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.stop_unsupported_version'))");
|
||||
expect(source).toContain("messageApi.success(checked ? t('ai_settings.mcp_http.message.started') : t('ai_settings.mcp_http.message.stopped'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_http.message.toggle_failed'))");
|
||||
expect(source).toContain("messageApi.error(t('ai_settings.mcp_http.message.url_unavailable'))");
|
||||
expect(source).toContain("copyTextToClipboard(url, t('ai_settings.mcp_http.message.url_copied'))");
|
||||
expect(source).toContain("messageApi.error(t('ai_settings.mcp_http.message.authorization_header_required'))");
|
||||
expect(source).toContain("copyTextToClipboard(`Authorization: ${authorizationHeader}`, t('ai_settings.mcp_http.message.authorization_header_copied'))");
|
||||
expect(source).not.toContain("'当前环境不支持复制到剪贴板'");
|
||||
expect(source).not.toContain("'当前运行时暂不支持 MCP HTTP 服务控制'");
|
||||
expect(source).not.toContain("'当前版本暂不支持启动 MCP HTTP 服务'");
|
||||
expect(source).not.toContain("'当前版本暂不支持停止 MCP HTTP 服务'");
|
||||
expect(source).not.toContain("'GoNavi MCP HTTP 服务已启动'");
|
||||
expect(source).not.toContain("'GoNavi MCP HTTP 服务已停止'");
|
||||
expect(source).not.toContain("'切换 GoNavi MCP HTTP 服务失败'");
|
||||
expect(source).not.toContain("'当前没有可复制的 MCP HTTP URL'");
|
||||
expect(source).not.toContain("'MCP HTTP URL 已复制'");
|
||||
expect(source).not.toContain("'请先启动 MCP HTTP 服务生成 Authorization Header'");
|
||||
expect(source).not.toContain("'Authorization Header 已复制'");
|
||||
});
|
||||
|
||||
it('localizes MCP HTTP default status fallback', () => {
|
||||
expect(source).toContain("const defaultMCPHTTPServerStatus = useMemo<AIMCPHTTPServerStatus>(() => ({");
|
||||
expect(source).toContain("message: t('ai_settings.mcp_http.status.not_running')");
|
||||
expect(source).toContain("useState<AIMCPHTTPServerStatus>(() => defaultMCPHTTPServerStatus)");
|
||||
expect(source).not.toContain("'GoNavi MCP HTTP 服务未启动'");
|
||||
});
|
||||
|
||||
it('localizes Skill required built-in tool option labels', () => {
|
||||
expect(source).toContain("label: `${tool.name} · ${t('ai_settings.tools.builtin_tool_label')}`");
|
||||
expect(source).toContain("]), [mcpTools, t]);");
|
||||
expect(source).not.toContain("label: `${tool.name} · 内置工具`");
|
||||
expect(source).not.toContain("· 内置工具");
|
||||
});
|
||||
|
||||
it('loads MCP servers and skills through the AI service', () => {
|
||||
expect(source).toContain('Service.AIGetMCPClientInstallStatuses?.()');
|
||||
expect(source).toContain('Service.AIGetMCPServers?.()');
|
||||
@@ -78,7 +151,8 @@ describe('AISettingsModal edit password behavior', () => {
|
||||
|
||||
it('renders in-modal test errors through the local message host', () => {
|
||||
expect(source).toContain('antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body })');
|
||||
expect(source).toContain("void messageApi.error(`测试失败: ${res?.message || '未知错误'}`);");
|
||||
expect(source).toContain("void messageApi.error(res?.message || t('ai_settings.message.test_failed'))");
|
||||
expect(source).not.toContain("`测试失败: ${res?.message || '未知错误'}`");
|
||||
});
|
||||
|
||||
it('keeps long ai settings toast errors wrapped within the modal body', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Modal, Form, message as antdMessage } from 'antd';
|
||||
import { Form, message as antdMessage } from 'antd';
|
||||
import { RobotOutlined } from '@ant-design/icons';
|
||||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AISkillConfig } from '../types';
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
|
||||
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { useI18n } from '../i18n/provider';
|
||||
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
|
||||
import { EMPTY_MCP_CLIENT_STATUSES } from '../utils/mcpClientInstallStatus';
|
||||
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
|
||||
@@ -28,8 +30,9 @@ import {
|
||||
EMPTY_SKILL,
|
||||
PROVIDER_PRESETS,
|
||||
findPreset,
|
||||
localizeProviderPreset,
|
||||
localizeProviderPresets,
|
||||
matchProviderPreset,
|
||||
type ProviderPreset,
|
||||
waitForAIService,
|
||||
} from './ai/aiSettingsModalConfig';
|
||||
interface AISettingsModalProps {
|
||||
@@ -38,6 +41,7 @@ interface AISettingsModalProps {
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
focusProviderId?: string;
|
||||
onBeforeExternalMCPUse?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
|
||||
@@ -46,7 +50,7 @@ const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
|
||||
path: '/mcp',
|
||||
url: 'http://127.0.0.1:8765/mcp',
|
||||
schemaOnly: true,
|
||||
message: 'GoNavi MCP HTTP 服务未启动',
|
||||
message: '',
|
||||
};
|
||||
|
||||
const DEFAULT_MCP_HTTP_SERVER_DRAFT: AIMCPHTTPServerDraft = {
|
||||
@@ -76,14 +80,19 @@ const normalizeMCPHTTPAuthorizationToken = (value: string): string => {
|
||||
return withoutHeaderName.replace(/^Bearer\s+/i, '').trim();
|
||||
};
|
||||
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId, onBeforeExternalMCPUse }) => {
|
||||
const { t } = useI18n();
|
||||
const defaultMCPHTTPServerStatus = useMemo<AIMCPHTTPServerStatus>(() => ({
|
||||
...DEFAULT_MCP_HTTP_SERVER_STATUS,
|
||||
message: t('ai_settings.mcp_http.status.not_running'),
|
||||
}), [t]);
|
||||
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||||
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||||
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
|
||||
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
|
||||
const [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
|
||||
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
|
||||
const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState<AIMCPHTTPServerStatus>(DEFAULT_MCP_HTTP_SERVER_STATUS);
|
||||
const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState<AIMCPHTTPServerStatus>(() => defaultMCPHTTPServerStatus);
|
||||
const [mcpHTTPServerDraft, setMCPHTTPServerDraft] = useState<AIMCPHTTPServerDraft>(DEFAULT_MCP_HTTP_SERVER_DRAFT);
|
||||
const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false);
|
||||
const [skills, setSkills] = useState<AISkillConfig[]>([]);
|
||||
@@ -111,16 +120,29 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const watchedType = Form.useWatch('type', form);
|
||||
const watchedPresetKey = Form.useWatch('presetKey', form);
|
||||
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
|
||||
const localizedProviderPresets = useMemo(
|
||||
() => localizeProviderPresets(PROVIDER_PRESETS, t),
|
||||
[t],
|
||||
);
|
||||
const findLocalizedPreset = useCallback(
|
||||
(key: string) => localizeProviderPreset(findPreset(key), t),
|
||||
[t],
|
||||
);
|
||||
const matchLocalizedProviderPreset = useCallback(
|
||||
(provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>) =>
|
||||
localizeProviderPreset(matchProviderPreset(provider), t),
|
||||
[t],
|
||||
);
|
||||
const skillRequiredToolOptions = useMemo(() => ([
|
||||
...BUILTIN_AI_TOOL_INFO.map((tool) => ({
|
||||
label: `${tool.name} · 内置工具`,
|
||||
label: `${tool.name} · ${t('ai_settings.tools.builtin_tool_label')}`,
|
||||
value: tool.name,
|
||||
})),
|
||||
...mcpTools.map((tool) => ({
|
||||
label: `${tool.alias} · ${tool.serverName}`,
|
||||
value: tool.alias,
|
||||
})),
|
||||
]), [mcpTools]);
|
||||
]), [mcpTools, t]);
|
||||
|
||||
const resolveAIService = useCallback(async () => {
|
||||
const service = await waitForAIService();
|
||||
@@ -137,11 +159,11 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
|
||||
const copyTextToClipboard = useCallback(async (text: string, successMessage: string) => {
|
||||
if (typeof navigator?.clipboard?.writeText !== 'function') {
|
||||
throw new Error('当前环境不支持复制到剪贴板');
|
||||
throw new Error(t('ai_settings.clipboard.error.unsupported'));
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
void messageApi.success(successMessage);
|
||||
}, [messageApi]);
|
||||
}, [messageApi, t]);
|
||||
|
||||
const {
|
||||
handleCopySelectedMCPConfigPath,
|
||||
@@ -160,9 +182,13 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
resolveAIService,
|
||||
messageApi,
|
||||
copyTextToClipboard,
|
||||
onBeforeInstall: () => setLoading(true),
|
||||
onBeforeInstall: async () => {
|
||||
setLoading(true);
|
||||
await onBeforeExternalMCPUse?.();
|
||||
},
|
||||
onAfterInstall: () => setLoading(false),
|
||||
onConfigChanged: () => window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')),
|
||||
translate: t,
|
||||
});
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
@@ -190,7 +216,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS),
|
||||
callOrFallback(() => Service.AIGetMCPServers?.(), []),
|
||||
callOrFallback(() => Service.AIListMCPTools?.(), []),
|
||||
callOrFallback<AIMCPHTTPServerStatus>(() => Service.AIGetMCPHTTPServerStatus?.(), DEFAULT_MCP_HTTP_SERVER_STATUS),
|
||||
callOrFallback<AIMCPHTTPServerStatus>(() => Service.AIGetMCPHTTPServerStatus?.(), defaultMCPHTTPServerStatus),
|
||||
callOrFallback(() => Service.AIGetSkills?.(), []),
|
||||
callOrFallback<AIMCPClientInstallStatus[]>(() => Service.AIGetMCPClientInstallStatuses?.(), EMPTY_MCP_CLIENT_STATUSES),
|
||||
]);
|
||||
@@ -212,7 +238,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes);
|
||||
if (mcpHTTPServerStatusRes) {
|
||||
const nextStatus = {
|
||||
...DEFAULT_MCP_HTTP_SERVER_STATUS,
|
||||
...defaultMCPHTTPServerStatus,
|
||||
...mcpHTTPServerStatusRes,
|
||||
};
|
||||
setMCPHTTPServerStatus(nextStatus);
|
||||
@@ -223,7 +249,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
syncMCPClientStatuses(mcpClientStatusesRes);
|
||||
}
|
||||
} catch (e) { console.warn('Failed to load AI config', e); }
|
||||
}, [resolveAIService, syncMCPClientStatuses]);
|
||||
}, [defaultMCPHTTPServerStatus, resolveAIService, syncMCPClientStatuses]);
|
||||
|
||||
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
|
||||
|
||||
@@ -305,7 +331,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
},
|
||||
}));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '读取供应商配置失败');
|
||||
void messageApi.error(e?.message || t('ai_settings.message.load_provider_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,16 +345,16 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
if (wasActive) {
|
||||
const newProviders: any[] = await Service?.AIGetProviders?.() || [];
|
||||
if (newProviders.length > 0) {
|
||||
const newActiveName = newProviders[0]?.name || '下一个供应商';
|
||||
void messageApi.success(`已删除,自动切换到「${newActiveName}」`);
|
||||
const newActiveName = newProviders[0]?.name || t('ai_settings.provider.next_provider');
|
||||
void messageApi.success(t('ai_settings.message.deleted_and_switched', { name: newActiveName }));
|
||||
} else {
|
||||
void messageApi.success('已删除');
|
||||
void messageApi.success(t('ai_settings.message.deleted'));
|
||||
}
|
||||
} else {
|
||||
void messageApi.success('已删除');
|
||||
void messageApi.success(t('ai_settings.message.deleted'));
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) { void messageApi.error(e?.message || '删除失败'); }
|
||||
} catch (e: any) { void messageApi.error(e?.message || t('ai_settings.message.delete_failed')); }
|
||||
};
|
||||
|
||||
const handleSaveProvider = async () => {
|
||||
@@ -339,7 +365,8 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
|
||||
// 构建 payload,处理 model/models 逻辑
|
||||
const preset = findPreset(values.presetKey);
|
||||
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
|
||||
const localizedPreset = localizeProviderPreset(preset, t);
|
||||
const isCustomLike = ['custom', 'ollama', 'codebuddy', 'cursor'].includes(values.presetKey);
|
||||
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||||
presetKey: values.presetKey,
|
||||
presetDefaultModel: preset.defaultModel,
|
||||
@@ -347,8 +374,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
valuesModel: values.model,
|
||||
customModels: values.models,
|
||||
});
|
||||
const inlineCompletionModel = String(values.inlineCompletionModel || '').trim();
|
||||
// 内置供应商自动使用 preset label 作为名称
|
||||
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
|
||||
const finalName = isCustomLike ? (values.name || localizedPreset.label) : localizedPreset.label;
|
||||
|
||||
const finalBaseUrl = resolvePresetBaseURL({
|
||||
presetKey: values.presetKey,
|
||||
@@ -371,17 +399,18 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
apiKey: secretDraft.apiKey,
|
||||
hasSecret: secretDraft.hasSecret,
|
||||
model: finalModel,
|
||||
inlineCompletionModel,
|
||||
models: resolvedModels,
|
||||
baseUrl: finalBaseUrl,
|
||||
apiFormat: resolvedTransport.apiFormat,
|
||||
};
|
||||
// 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常
|
||||
await Service?.AISaveProvider?.(payload);
|
||||
void messageApi.success('已保存'); resetProviderEditorSession(); void loadConfig();
|
||||
void messageApi.success(t('ai_settings.message.saved')); resetProviderEditorSession(); void loadConfig();
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) { /* antd form validation error, ignore */ }
|
||||
else void messageApi.error(e?.message || '保存失败');
|
||||
else void messageApi.error(e?.message || t('ai_settings.message.save_failed'));
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -389,9 +418,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
await Service?.AISetActiveProvider?.(id);
|
||||
setActiveProviderId(id); void messageApi.success('已切换');
|
||||
setActiveProviderId(id); void messageApi.success(t('ai_settings.message.switched'));
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) { void messageApi.error(e?.message || '切换失败'); }
|
||||
} catch (e: any) { void messageApi.error(e?.message || t('ai_settings.message.switch_failed')); }
|
||||
};
|
||||
|
||||
const handleSafetyChange = async (level: AISafetyLevel) => {
|
||||
@@ -422,10 +451,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
};
|
||||
await Service?.AISaveUserPromptSettings?.(payload);
|
||||
setUserPromptSettings(payload);
|
||||
void messageApi.success('自定义提示词已保存');
|
||||
void messageApi.success(t('ai_settings.prompts.message.saved'));
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '保存自定义提示词失败');
|
||||
void messageApi.error(e?.message || t('ai_settings.prompts.message.save_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -445,10 +474,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
await Service?.AISaveMCPServer?.(server);
|
||||
await loadConfig();
|
||||
void messageApi.success('MCP 服务已保存');
|
||||
void messageApi.success(t('ai_settings.mcp_server.message.saved'));
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '保存 MCP 服务失败');
|
||||
void messageApi.error(e?.message || t('ai_settings.mcp_server.message.save_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -465,9 +494,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
} else {
|
||||
setMCPServers((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
void messageApi.success('MCP 服务已删除');
|
||||
void messageApi.success(t('ai_settings.mcp_server.message.deleted'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '删除 MCP 服务失败');
|
||||
void messageApi.error(e?.message || t('ai_settings.mcp_server.message.delete_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -479,7 +508,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const res = await Service?.AITestMCPServer?.(server);
|
||||
if (res?.success) {
|
||||
void messageApi.success(res?.message || 'MCP 服务连接成功');
|
||||
void messageApi.success(res?.message || t('ai_settings.mcp_server.message.test_success'));
|
||||
if (typeof Service?.AIListMCPTools === 'function') {
|
||||
const nextTools = await Service.AIListMCPTools();
|
||||
if (Array.isArray(nextTools)) setMCPTools(nextTools);
|
||||
@@ -487,10 +516,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
setMCPTools(res.tools);
|
||||
}
|
||||
} else {
|
||||
void messageApi.error(res?.message || 'MCP 服务测试失败');
|
||||
void messageApi.error(res?.message || t('ai_settings.mcp_server.message.test_failed'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '测试 MCP 服务失败');
|
||||
void messageApi.error(e?.message || t('ai_settings.mcp_server.message.test_request_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -501,13 +530,16 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
setMCPHTTPServerLoading(true);
|
||||
const Service = await resolveAIService();
|
||||
if (!Service) {
|
||||
throw new Error('当前运行时暂不支持 MCP HTTP 服务控制');
|
||||
throw new Error(t('ai_settings.mcp_http.error.control_unsupported_runtime'));
|
||||
}
|
||||
if (checked && typeof Service.AIStartMCPHTTPServer !== 'function') {
|
||||
throw new Error('当前版本暂不支持启动 MCP HTTP 服务');
|
||||
throw new Error(t('ai_settings.mcp_http.error.start_unsupported_version'));
|
||||
}
|
||||
if (!checked && typeof Service.AIStopMCPHTTPServer !== 'function') {
|
||||
throw new Error('当前版本暂不支持停止 MCP HTTP 服务');
|
||||
throw new Error(t('ai_settings.mcp_http.error.stop_unsupported_version'));
|
||||
}
|
||||
if (checked) {
|
||||
await onBeforeExternalMCPUse?.();
|
||||
}
|
||||
const nextStatus = checked
|
||||
? await Service.AIStartMCPHTTPServer({
|
||||
@@ -519,15 +551,15 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
: await Service.AIStopMCPHTTPServer();
|
||||
if (nextStatus) {
|
||||
const normalizedStatus = {
|
||||
...DEFAULT_MCP_HTTP_SERVER_STATUS,
|
||||
...defaultMCPHTTPServerStatus,
|
||||
...nextStatus,
|
||||
};
|
||||
setMCPHTTPServerStatus(normalizedStatus);
|
||||
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(normalizedStatus, prev));
|
||||
}
|
||||
void messageApi.success(checked ? 'GoNavi MCP HTTP 服务已启动' : 'GoNavi MCP HTTP 服务已停止');
|
||||
void messageApi.success(checked ? t('ai_settings.mcp_http.message.started') : t('ai_settings.mcp_http.message.stopped'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '切换 GoNavi MCP HTTP 服务失败');
|
||||
void messageApi.error(e?.message || t('ai_settings.mcp_http.message.toggle_failed'));
|
||||
} finally {
|
||||
setMCPHTTPServerLoading(false);
|
||||
}
|
||||
@@ -543,19 +575,19 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const handleCopyMCPHTTPServerURL = async () => {
|
||||
const url = String(mcpHTTPServerStatus.url || '').trim();
|
||||
if (!url) {
|
||||
void messageApi.error('当前没有可复制的 MCP HTTP URL');
|
||||
void messageApi.error(t('ai_settings.mcp_http.message.url_unavailable'));
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(url, 'MCP HTTP URL 已复制');
|
||||
await copyTextToClipboard(url, t('ai_settings.mcp_http.message.url_copied'));
|
||||
};
|
||||
|
||||
const handleCopyMCPHTTPServerAuthorization = async () => {
|
||||
const authorizationHeader = String(mcpHTTPServerStatus.authorizationHeader || '').trim();
|
||||
if (!authorizationHeader) {
|
||||
void messageApi.error('请先启动 MCP HTTP 服务生成 Authorization Header');
|
||||
void messageApi.error(t('ai_settings.mcp_http.message.authorization_header_required'));
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(`Authorization: ${authorizationHeader}`, 'Authorization Header 已复制');
|
||||
await copyTextToClipboard(`Authorization: ${authorizationHeader}`, t('ai_settings.mcp_http.message.authorization_header_copied'));
|
||||
};
|
||||
|
||||
const updateSkillDraft = (id: string, patch: Partial<AISkillConfig>) => {
|
||||
@@ -572,10 +604,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
await Service?.AISaveSkill?.(skill);
|
||||
await loadConfig();
|
||||
void messageApi.success('Skill 已保存');
|
||||
void messageApi.success(t('ai_settings.skill.message.saved'));
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '保存 Skill 失败');
|
||||
void messageApi.error(e?.message || t('ai_settings.skill.message.save_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -592,9 +624,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
} else {
|
||||
setSkills((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
void messageApi.success('Skill 已删除');
|
||||
void messageApi.success(t('ai_settings.skill.message.deleted'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '删除 Skill 失败');
|
||||
void messageApi.error(e?.message || t('ai_settings.skill.message.delete_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -624,11 +656,12 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
presetFixedApiFormat: preset.fixedApiFormat,
|
||||
valuesApiFormat: values.apiFormat,
|
||||
});
|
||||
const allowEmptySecret = values.presetKey === 'codebuddy';
|
||||
const secretDraft = resolveProviderSecretDraft({
|
||||
apiKeyInput: values.apiKey,
|
||||
});
|
||||
if (secretDraft.mode === 'clear') {
|
||||
throw new Error('测试连接前请填写 API Key');
|
||||
if (secretDraft.mode === 'clear' && !allowEmptySecret) {
|
||||
throw new Error(t('ai_settings.message.test_requires_new_api_key'));
|
||||
}
|
||||
const res = await Service?.AITestProvider?.({
|
||||
...editingProvider,
|
||||
@@ -638,14 +671,15 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
hasSecret: secretDraft.hasSecret,
|
||||
baseUrl: finalBaseUrl,
|
||||
model: finalModel,
|
||||
inlineCompletionModel: String(values.inlineCompletionModel || '').trim(),
|
||||
models: resolvedModels,
|
||||
maxTokens: Number(values.maxTokens) || 4096,
|
||||
temperature: Number(values.temperature) ?? 0.7,
|
||||
apiFormat: resolvedTransport.apiFormat,
|
||||
});
|
||||
if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); }
|
||||
else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); }
|
||||
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); }
|
||||
if (res?.success) { setTestStatus('success'); void messageApi.success(t('ai_settings.message.test_success')); }
|
||||
else { setTestStatus('error'); void messageApi.error(res?.message || t('ai_settings.message.test_failed')); }
|
||||
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || t('ai_settings.message.test_failed')); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -656,12 +690,20 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
presetFixedApiFormat: preset.fixedApiFormat,
|
||||
valuesApiFormat: form.getFieldValue('apiFormat'),
|
||||
});
|
||||
const { model: presetModel, models: presetModels } = resolvePresetModelSelection({
|
||||
presetKey,
|
||||
presetDefaultModel: preset.defaultModel,
|
||||
presetModels: preset.models,
|
||||
customModels: preset.models,
|
||||
});
|
||||
form.setFieldsValue({
|
||||
presetKey,
|
||||
type: resolvedTransport.type,
|
||||
apiFormat: resolvedTransport.apiFormat || 'openai',
|
||||
baseUrl: preset.defaultBaseUrl,
|
||||
model: preset.defaultModel,
|
||||
model: presetModel,
|
||||
models: presetModels,
|
||||
inlineCompletionModel: '',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -681,9 +723,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>AI 设置</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>{t('ai_settings.title')}</div>
|
||||
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||
配置 AI 模型、安全级别和上下文选项
|
||||
{t('ai_settings.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -714,7 +756,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
editingProvider={editingProvider}
|
||||
isEditing={isEditing}
|
||||
form={form}
|
||||
providerPresets={PROVIDER_PRESETS}
|
||||
providerPresets={localizedProviderPresets}
|
||||
watchedPresetKey={watchedPresetKey}
|
||||
watchedApiFormat={watchedApiFormat}
|
||||
loading={loading}
|
||||
@@ -726,8 +768,8 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
cardBorder={cardBorder}
|
||||
inputBg={inputBg}
|
||||
onPrimaryPasswordVisibleChange={setPrimaryPasswordVisible}
|
||||
resolveProviderPreset={matchProviderPreset}
|
||||
resolvePresetByKey={findPreset}
|
||||
resolveProviderPreset={matchLocalizedProviderPreset}
|
||||
resolvePresetByKey={findLocalizedPreset}
|
||||
onAddProvider={handleAddProvider}
|
||||
onEditProvider={handleEditProvider}
|
||||
onDeleteProvider={handleDeleteProvider}
|
||||
|
||||
@@ -2,11 +2,15 @@ import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const connectionModalSource = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8');
|
||||
const connectionModalConfigSource = readFileSync(new URL('./connectionModal/connectionModalConfig.ts', import.meta.url), 'utf8');
|
||||
const connectionModalStep2Source = readFileSync(new URL('./connectionModal/ConnectionModalStep2.tsx', import.meta.url), 'utf8');
|
||||
const connectionModalNetworkSecuritySource = readFileSync(new URL('./connectionModal/ConnectionModalNetworkSecuritySection.tsx', import.meta.url), 'utf8');
|
||||
const connectionModalUriSource = readFileSync(new URL('./connectionModal/connectionModalUri.ts', import.meta.url), 'utf8');
|
||||
const redisSectionsSource = readFileSync(new URL('./ConnectionModalRedisSections.tsx', import.meta.url), 'utf8');
|
||||
const mongoSectionsSource = readFileSync(new URL('./ConnectionModalMongoSections.tsx', import.meta.url), 'utf8');
|
||||
const connectionTypeCatalogSource = readFileSync(new URL('../utils/connectionTypeCatalog.ts', import.meta.url), 'utf8');
|
||||
const connectionTypeCapabilitiesSource = readFileSync(new URL('../utils/connectionTypeCapabilities.ts', import.meta.url), 'utf8');
|
||||
const source = `${connectionModalSource}\n${redisSectionsSource}\n${mongoSectionsSource}\n${connectionTypeCatalogSource}\n${connectionTypeCapabilitiesSource}`;
|
||||
const source = `${connectionModalSource}\n${connectionModalConfigSource}\n${connectionModalStep2Source}\n${connectionModalNetworkSecuritySource}\n${connectionModalUriSource}\n${redisSectionsSource}\n${mongoSectionsSource}\n${connectionTypeCatalogSource}\n${connectionTypeCapabilitiesSource}`;
|
||||
|
||||
describe('ConnectionModal edit password behavior', () => {
|
||||
it('keeps the prefilled primary password masked by default', () => {
|
||||
@@ -20,6 +24,26 @@ describe('ConnectionModal edit password behavior', () => {
|
||||
expect(source).not.toContain('description:\n "当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。"');
|
||||
expect(source).toContain('String(config.password || "") === ""');
|
||||
});
|
||||
|
||||
it('reuses the shared backend-cancel helper for file and certificate pickers', () => {
|
||||
expect(source).not.toContain('res?.message !== "已取消"');
|
||||
expect(source.match(/isBackendCancelledResult\(res\)/g) ?? []).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('uses localized SSL mode labels instead of hardcoded English strings', () => {
|
||||
expect(source).not.toContain('label: "Preferred"');
|
||||
expect(source).not.toContain('label: "Required"');
|
||||
expect(source).not.toContain('label: "Skip Verify"');
|
||||
expect(source).toMatch(
|
||||
/label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.preferred",\s*\)/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.required",\s*\)/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.skip_verify",\s*\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionModal data source registry', () => {
|
||||
@@ -31,21 +55,24 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain("name: 'Elasticsearch'");
|
||||
expect(source).toContain('icon: getDbIcon(item.key, undefined, 36)');
|
||||
expect(source).toContain('type === "elasticsearch"');
|
||||
expect(source).toContain("return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';");
|
||||
expect(source).toContain("'connection_modal.step1.hint.elasticsearch'");
|
||||
expect(source).toContain(
|
||||
"'Index browsing, Mapping inspection, JSON DSL, and query_string queries'",
|
||||
);
|
||||
expect(source).toContain('const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([');
|
||||
expect(source).toContain('"mqtt",');
|
||||
expect(source).toContain(
|
||||
'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "rocketmq" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";',
|
||||
);
|
||||
expect(source).toContain('PRIMARY_USERNAME_OPTIONAL_TYPES.has(dbType)');
|
||||
expect(source).toContain('label="显示数据库 (留空显示全部)"');
|
||||
expect(source).toContain('connection.modal.field.displayDatabases.label');
|
||||
});
|
||||
|
||||
it('keeps MQTT username optional during test-connection validation', () => {
|
||||
expect(source).toContain('"mqtt",');
|
||||
expect(source).toContain('PRIMARY_USERNAME_OPTIONAL_TYPES.has(dbType)');
|
||||
expect(source).toContain(': [createUriAwareRequiredRule("请输入用户名")]');
|
||||
expect(source).toContain('? "未开启认证可留空"');
|
||||
expect(source).toContain('connection.modal.field.username.required');
|
||||
expect(source).toContain('connection.modal.field.username.optional_placeholder');
|
||||
});
|
||||
|
||||
it('exposes Chroma in the create-connection picker with vector defaults', () => {
|
||||
@@ -55,7 +82,10 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain("key: 'chroma'");
|
||||
expect(source).toContain("name: 'Chroma'");
|
||||
expect(source).toContain('type === "chroma"');
|
||||
expect(source).toContain("return 'Collection 浏览、向量检索和元数据过滤';");
|
||||
expect(source).toContain("'connection_modal.step1.hint.chroma'");
|
||||
expect(source).toContain(
|
||||
"'Collection browsing, vector retrieval, and metadata filtering'",
|
||||
);
|
||||
expect(source).toContain('return "http://127.0.0.1:8000/default_database?tenant=default_tenant";');
|
||||
expect(source).toContain('return "tenant=default_tenant&apiKey=...";');
|
||||
});
|
||||
@@ -67,7 +97,10 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain("key: 'qdrant'");
|
||||
expect(source).toContain("name: 'Qdrant'");
|
||||
expect(source).toContain('type === "qdrant"');
|
||||
expect(source).toContain("return 'Collection 浏览、向量搜索和 Payload 过滤';");
|
||||
expect(source).toContain("'connection_modal.step1.hint.qdrant'");
|
||||
expect(source).toContain(
|
||||
"'Collection browsing, vector search, and Payload filtering'",
|
||||
);
|
||||
expect(source).toContain('return "http://127.0.0.1:6333";');
|
||||
expect(source).toContain('return "apiKey=...";');
|
||||
});
|
||||
@@ -94,11 +127,11 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain("return 'NameServer / Topic / Consumer Group';");
|
||||
expect(source).toContain('return "rocketmq://accessKey:secretKey@127.0.0.1:9876,127.0.0.2:9876/orders.events?topology=cluster&groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";');
|
||||
expect(source).toContain('return "groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";');
|
||||
expect(source).toContain('label="默认 Topic(可选)"');
|
||||
expect(source).toContain('label={dbType === "rocketmq" ? "Access Key" : "用户名"}');
|
||||
expect(source).toContain('label={dbType === "rocketmq" ? "Secret Key" : "密码"}');
|
||||
expect(source).toContain('emptyPlaceholder: dbType === "rocketmq" ? "未开启认证可留空" : "密码"');
|
||||
expect(source).toContain('retainedLabel: dbType === "rocketmq" ? "已保存 Secret Key" : "已保存密码"');
|
||||
expect(source).toContain('t("connection.modal.messageQueue.rocketmq.defaultTopic.label")');
|
||||
expect(source).toContain('connection.modal.field.username.label');
|
||||
expect(source).toContain('connection.modal.field.password.label');
|
||||
expect(source).toContain('connection.modal.field.username.optional_placeholder');
|
||||
expect(source).toContain('connection.modal.field.password.retained');
|
||||
});
|
||||
|
||||
it('exposes MQTT in the create-connection picker with broker and topic-filter defaults', () => {
|
||||
@@ -111,7 +144,7 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain("return 'Broker / Topic Filter / QoS';");
|
||||
expect(source).toContain('return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1";');
|
||||
expect(source).toContain('return "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&clientId=gonavi-desktop&qos=1&cleanSession=true&fetchWaitMs=4000";');
|
||||
expect(source).toContain('label="默认 Topic / Filter(可选)"');
|
||||
expect(source).toContain('t("connection.modal.messageQueue.mqtt.defaultTopicFilter.label")');
|
||||
});
|
||||
|
||||
it('exposes Kafka in the create-connection picker with broker and topic defaults', () => {
|
||||
@@ -123,7 +156,7 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain("return 'Broker / Topic / Consumer Group';");
|
||||
expect(source).toContain('return "kafka://user:pass@127.0.0.1:9092,127.0.0.2:9092/orders.events?topology=cluster&groupId=analytics&mechanism=scram-sha-256";');
|
||||
expect(source).toContain('return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest";');
|
||||
expect(source).toContain('label="默认 Topic(可选)"');
|
||||
expect(source).toContain('t("connection.modal.messageQueue.kafka.defaultTopic.label")');
|
||||
});
|
||||
|
||||
it('exposes RabbitMQ in the create-connection picker with management-api and vhost defaults', () => {
|
||||
@@ -136,7 +169,7 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain("return 'Management API / Virtual Host / Queue';");
|
||||
expect(source).toContain('return "rabbitmq://guest:guest@127.0.0.1:15672/%2F?defaultQueue=orders.queue&exchange=events.topic&timeout=30";');
|
||||
expect(source).toContain('return "defaultQueue=orders.queue&exchange=events.topic&managementPathPrefix=/rabbitmq";');
|
||||
expect(source).toContain('label="默认 Virtual Host(可选)"');
|
||||
expect(source).toContain('t("connection.modal.messageQueue.rabbitmq.defaultVirtualHost.label")');
|
||||
});
|
||||
|
||||
it('exposes GaussDB in the create-connection picker with PostgreSQL-family defaults', () => {
|
||||
@@ -158,18 +191,62 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain("key: 'goldendb'");
|
||||
expect(source).toContain("name: 'GoldenDB'");
|
||||
expect(source).toContain('type === "goldendb"');
|
||||
expect(source).toContain("return 'MySQL 兼容 / 分布式事务';");
|
||||
expect(source).toContain("'connection_modal.step1.hint.goldendb'");
|
||||
expect(source).toContain("'MySQL compatible / distributed transactions'");
|
||||
expect(source).toContain('dbType === "goldendb" ? "goldendb" : "mysql"');
|
||||
expect(source).toContain('type === "goldendb" ? "goldendb" : "mysql"');
|
||||
expect(source).toContain('? "goldendb"');
|
||||
});
|
||||
|
||||
it('keeps OceanBase Oracle service name optional for OBClient/MySQL-wire connections', () => {
|
||||
expect(source).toContain('connection.modal.field.oceanBaseServiceName.label');
|
||||
expect(source).toMatch(
|
||||
/isOceanBaseOracle\s*\?\s*\[\]\s*:\s*\[\s*createUriAwareRequiredRule\(\s*t\("connection\.modal\.field\.serviceName\.required"/,
|
||||
);
|
||||
expect(source).toContain('connection.modal.field.oceanBaseServiceName.help');
|
||||
expect(source).toContain('connection.modal.field.serviceName.help');
|
||||
expect(source).toContain('connection.modal.field.serviceName.required');
|
||||
expect(source).not.toContain('请输入 OceanBase Oracle 服务名');
|
||||
expect(source).not.toContain('Oracle 租户必须填写监听器注册的 SERVICE_NAME');
|
||||
});
|
||||
|
||||
it('uses localized message queue service, topology, and extra host copy', () => {
|
||||
[
|
||||
'label="默认 Topic(可选)"',
|
||||
'label="默认 Topic / Filter(可选)"',
|
||||
'label="默认 Virtual Host(可选)"',
|
||||
'label: "单 Broker"',
|
||||
'label: "单 NameServer"',
|
||||
'label="额外 Broker 地址"',
|
||||
'label="额外 NameServer 地址"',
|
||||
'help="可输入多个 broker 地址,格式:host:port(回车确认)"',
|
||||
'help="可输入多个 NameServer 地址,格式:host:port(回车确认)"',
|
||||
].forEach((snippet) => {
|
||||
expect(source).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
'connection.modal.messageQueue.kafka.defaultTopic.help',
|
||||
'connection.modal.messageQueue.rocketmq.defaultTopic.help',
|
||||
'connection.modal.messageQueue.mqtt.defaultTopicFilter.help',
|
||||
'connection.modal.messageQueue.rabbitmq.defaultVirtualHost.help',
|
||||
'connection.modal.messageQueue.kafka.topology.single.label',
|
||||
'connection.modal.messageQueue.rocketmq.topology.single.label',
|
||||
'connection.modal.messageQueue.mqtt.topology.cluster.description',
|
||||
'connection.modal.messageQueue.kafka.extraBrokers.placeholder',
|
||||
'connection.modal.messageQueue.rocketmq.extraNameServers.placeholder',
|
||||
'connection.modal.messageQueue.mqtt.extraBrokers.placeholder',
|
||||
].forEach((key) => {
|
||||
expect(source).toContain(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionModal Redis Sentinel configuration', () => {
|
||||
it('exposes Sentinel topology fields and safe defaults', () => {
|
||||
expect(source).toContain('label: "哨兵模式"');
|
||||
expect(source).toContain('connection.modal.redis.topology.sentinel.label');
|
||||
expect(source).toContain('name="redisSentinelMaster"');
|
||||
expect(source).toContain('Sentinel master 名称');
|
||||
expect(source).toContain('connection.modal.redis.sentinel.master.label');
|
||||
expect(source).toContain('name="redisSentinelPassword"');
|
||||
expect(source).toContain('hasRedisSentinelPassword');
|
||||
expect(source).toContain('clearKey: "redisSentinelPassword"');
|
||||
@@ -177,6 +254,73 @@ describe('ConnectionModal Redis Sentinel configuration', () => {
|
||||
expect(source).toContain('form.setFieldValue("port", 6379)');
|
||||
});
|
||||
|
||||
it('uses localized Redis topology, sentinel, credential, and database-scope copy', () => {
|
||||
[
|
||||
'label: "单机模式"',
|
||||
'description: "只连接一个 Redis 节点。"',
|
||||
'label: "集群模式"',
|
||||
'description: "Redis Cluster,配置多个种子节点。"',
|
||||
'label: "哨兵模式"',
|
||||
'description: "通过 Sentinel 发现主节点,适合主从高可用。"',
|
||||
'? "Sentinel 附加节点地址"',
|
||||
': "集群附加节点地址"',
|
||||
'? "上方主机地址作为第一个 Sentinel;这里填写其他 Sentinel 节点,格式:host:port"',
|
||||
': "主节点使用上方主机地址;这里填写其他种子节点,格式:host:port"',
|
||||
'label="Sentinel master 名称"',
|
||||
'help="填写 Sentinel 配置中的 monitor 名称,例如 mymaster。"',
|
||||
'label="密码 (可选)"',
|
||||
'emptyPlaceholder: "Redis 密码(如果设置了 requirepass)"',
|
||||
'retainedLabel: "已保存 Redis 密码"',
|
||||
'label="Sentinel 用户名(可选)"',
|
||||
'placeholder="留空表示 Sentinel 不使用 ACL 用户名"',
|
||||
'label="Sentinel 密码(可选)"',
|
||||
'emptyPlaceholder: "Sentinel 自身认证密码,留空则不发送"',
|
||||
'retainedLabel: "已保存 Sentinel 密码"',
|
||||
'clearLabel: "清除已保存 Sentinel 密码"',
|
||||
'label="显示数据库 (留空显示全部)"',
|
||||
'help="连接测试成功后可选择"',
|
||||
'placeholder="选择显示的数据库"',
|
||||
].forEach((snippet) => {
|
||||
expect(redisSectionsSource).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
'connection.modal.redis.topology.single.label',
|
||||
'connection.modal.redis.topology.cluster.description',
|
||||
'connection.modal.redis.topology.sentinel.label',
|
||||
'connection.modal.redis.hosts.sentinel.label',
|
||||
'connection.modal.redis.hosts.cluster.help',
|
||||
'connection.modal.redis.sentinel.master.required',
|
||||
'connection.modal.redis.credentials.primary.placeholder.empty',
|
||||
'connection.modal.redis.credentials.sentinelPassword.clear',
|
||||
'connection.modal.redis.databaseScope.placeholder',
|
||||
].forEach((key) => {
|
||||
expect(redisSectionsSource).toContain(key);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses localized Redis test feedback and optional-auth placeholders', () => {
|
||||
[
|
||||
'测试连接前请填写新的 Sentinel 密码,或取消清除已保存 Sentinel 密码',
|
||||
'连接成功但拉取 Redis 数据库列表超时',
|
||||
'连接成功,但获取 Redis 数据库列表失败',
|
||||
'未知错误',
|
||||
'? "未开启认证可留空"',
|
||||
].forEach((snippet) => {
|
||||
expect(connectionModalSource).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
'connection.modal.secret.blocking.redis_sentinel',
|
||||
'connection.modal.test.redis_database_list_timeout',
|
||||
'connection.modal.test.redis_database_list_failure',
|
||||
'connection.modal.error.unknown',
|
||||
'connection.modal.field.username.optional_placeholder',
|
||||
].forEach((key) => {
|
||||
expect(source).toContain(key);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the saved host as the primary Redis node when editing multi-node configs', () => {
|
||||
expect(source).toContain('const savedPrimaryAddress = isFileDbConfigType');
|
||||
expect(source).toContain('savedPrimaryAddress,');
|
||||
@@ -190,10 +334,64 @@ describe('ConnectionModal MongoDB configuration', () => {
|
||||
it('keeps replica, SRV, and read preference fields in the split Mongo sections', () => {
|
||||
expect(source).toContain('ConnectionModalMongoSections');
|
||||
expect(source).toContain('name="mongoSrv"');
|
||||
expect(source).toContain('SRV 与 SSH 隧道同时启用');
|
||||
expect(source).toContain('connection.modal.mongodb.discovery.srv_ssh_warning');
|
||||
expect(source).toContain('name="mongoReplicaPassword"');
|
||||
expect(source).toContain('clearKey: "mongoReplicaPassword"');
|
||||
expect(source).toContain('自动发现成员');
|
||||
expect(source).toContain('connection.modal.action.discover_members');
|
||||
expect(source).toContain('fieldName: "mongoReadPreference"');
|
||||
});
|
||||
|
||||
it('uses localized MongoDB topology, discovery, replica, and policy copy', () => {
|
||||
[
|
||||
'label: "单机模式"',
|
||||
'description: "只连接一个 MongoDB 节点。"',
|
||||
'label: "副本集 / 多节点"',
|
||||
'description: "配置副本集名称和多个候选节点。"',
|
||||
'label: "标准地址"',
|
||||
'description: "使用 host:port 直连或副本集节点列表。"',
|
||||
'label: "SRV 地址"',
|
||||
'description: "使用 mongodb+srv,由 DNS 发现目标节点。"',
|
||||
'<Tag color="blue">当前</Tag>',
|
||||
'message="SRV 与 SSH 隧道同时启用时,可能依赖本地 DNS 解析能力"',
|
||||
'label={mongoSrv ? "附加 SRV 主机(可选)" : "附加节点地址"}',
|
||||
'? "可输入多个候选主机名,格式:host;若留空则仅使用上方主机。"',
|
||||
': "可输入多个节点地址,格式:host:port(回车确认)"',
|
||||
'label="副本集名称(可选)"',
|
||||
'label="副本集用户名(可选)"',
|
||||
'placeholder="留空沿用主用户名"',
|
||||
'label="副本集密码(可选)"',
|
||||
'emptyPlaceholder: "留空沿用主密码"',
|
||||
'retainedLabel: "已保存副本集密码"',
|
||||
'clearLabel: "清除已保存副本集密码"',
|
||||
'当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。',
|
||||
'自动发现成员',
|
||||
'title: "角色"',
|
||||
'title: "健康"',
|
||||
'? "正常" : "异常"',
|
||||
'label="认证库 (authSource)"',
|
||||
'placeholder="默认使用 database 或 admin"',
|
||||
'<Text strong>读偏好 (readPreference)</Text>',
|
||||
'description: "只读主节点。"',
|
||||
'description: "主节点优先。"',
|
||||
'description: "只读从节点。"',
|
||||
'description: "从节点优先。"',
|
||||
'description: "选择最近节点。"',
|
||||
].forEach((snippet) => {
|
||||
expect(mongoSectionsSource).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
'connection.modal.mongodb.topology.single.label',
|
||||
'connection.modal.mongodb.discovery.standard.label',
|
||||
'connection.modal.mongodb.discovery.srv_ssh_warning',
|
||||
'connection.modal.mongodb.replica.hosts.srv.label',
|
||||
'connection.modal.mongodb.replica.password.description',
|
||||
'connection.modal.action.discover_members',
|
||||
'connection.modal.mongodb.members.role',
|
||||
'connection.modal.mongodb.policy.auth_source.label',
|
||||
'connection.modal.mongodb.read_preference.primary',
|
||||
].forEach((key) => {
|
||||
expect(mongoSectionsSource).toContain(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1232
frontend/src/components/ConnectionModal.i18n.test.tsx
Normal file
1232
frontend/src/components/ConnectionModal.i18n.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import {
|
||||
getStoredSecretPlaceholder,
|
||||
type ConnectionConfigSectionKey,
|
||||
} from "../utils/connectionModalPresentation";
|
||||
import { useI18n } from "../i18n/provider";
|
||||
import { noAutoCapInputProps } from "../utils/inputAutoCap";
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -87,278 +88,310 @@ const ConnectionModalMongoSections: React.FC<ConnectionModalMongoSectionsProps>
|
||||
renderStoredSecretControls,
|
||||
setChoiceFieldValue,
|
||||
handleDiscoverMongoMembers,
|
||||
}) => (
|
||||
<>
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "connectionMode",
|
||||
icon: <ClusterOutlined />,
|
||||
children: renderChoiceCards({
|
||||
fieldName: "mongoTopology",
|
||||
value: String(mongoTopology),
|
||||
options: [
|
||||
{
|
||||
value: "single",
|
||||
label: "单机模式",
|
||||
description: "只连接一个 MongoDB 节点。",
|
||||
},
|
||||
{
|
||||
value: "replica",
|
||||
label: "副本集 / 多节点",
|
||||
description: "配置副本集名称和多个候选节点。",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})}
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "mongoDiscovery",
|
||||
icon: <ApiOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="mongoSrv" hidden valuePropName="checked">
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: false,
|
||||
label: "标准地址",
|
||||
description: "使用 host:port 直连或副本集节点列表。",
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
label: "SRV 地址",
|
||||
description: "使用 mongodb+srv,由 DNS 发现目标节点。",
|
||||
},
|
||||
].map((option) => {
|
||||
const active = mongoSrv === option.value;
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setChoiceFieldValue("mongoSrv", option.value)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 14,
|
||||
border: active
|
||||
? darkMode
|
||||
? "1px solid rgba(255,214,102,0.42)"
|
||||
: "1px solid rgba(22,119,255,0.36)"
|
||||
: darkMode
|
||||
? "1px solid rgba(255,255,255,0.08)"
|
||||
: "1px solid rgba(16,24,40,0.08)",
|
||||
background: active
|
||||
? darkMode
|
||||
? "rgba(255,214,102,0.10)"
|
||||
: "rgba(22,119,255,0.07)"
|
||||
: darkMode
|
||||
? "rgba(255,255,255,0.03)"
|
||||
: "rgba(16,24,40,0.03)",
|
||||
color: darkMode ? "#f5f7ff" : "#162033",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Space size={8} wrap>
|
||||
<Text strong>{option.label}</Text>
|
||||
{active ? <Tag color="blue">当前</Tag> : null}
|
||||
</Space>
|
||||
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{mongoSrv && useSSH && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="SRV 与 SSH 隧道同时启用时,可能依赖本地 DNS 解析能力"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{mongoTopology === "replica" &&
|
||||
renderConfigSectionCard({
|
||||
sectionKey: "replica",
|
||||
return (
|
||||
<>
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "connectionMode",
|
||||
icon: <ClusterOutlined />,
|
||||
children: renderChoiceCards({
|
||||
fieldName: "mongoTopology",
|
||||
value: String(mongoTopology),
|
||||
options: [
|
||||
{
|
||||
value: "single",
|
||||
label: t("connection.modal.mongodb.topology.single.label"),
|
||||
description: t("connection.modal.topology.mongodb_single_description"),
|
||||
},
|
||||
{
|
||||
value: "replica",
|
||||
label: t("connection.modal.mongodb.topology.replica.label"),
|
||||
description: t("connection.modal.topology.mongodb_replica_description"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "mongoDiscovery",
|
||||
icon: <ApiOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="mongoHosts"
|
||||
label={mongoSrv ? "附加 SRV 主机(可选)" : "附加节点地址"}
|
||||
help={
|
||||
mongoSrv
|
||||
? "可输入多个候选主机名,格式:host;若留空则仅使用上方主机。"
|
||||
: "可输入多个节点地址,格式:host:port(回车确认)"
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={
|
||||
mongoSrv
|
||||
? "例如:cluster-a.example.com、cluster-b.example.com"
|
||||
: "例如:10.10.0.12:27017、10.10.0.13:27017"
|
||||
}
|
||||
tokenSeparators={[",", ";", " "]}
|
||||
/>
|
||||
<Form.Item name="mongoSrv" hidden valuePropName="checked">
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="mongoReplicaSet"
|
||||
label="副本集名称(可选)"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input {...noAutoCapInputProps} placeholder="例如:rs0" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="mongoReplicaUser"
|
||||
label="副本集用户名(可选)"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input {...noAutoCapInputProps} placeholder="留空沿用主用户名" />
|
||||
</Form.Item>
|
||||
{[
|
||||
{
|
||||
value: false,
|
||||
label: t("connection.modal.mongodb.discovery.standard.label"),
|
||||
description: t("connection.modal.mongodb.discovery.standard.description"),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
label: t("connection.modal.mongodb.discovery.srv.label"),
|
||||
description: t("connection.modal.mongodb.discovery.srv.description"),
|
||||
},
|
||||
].map((option) => {
|
||||
const active = mongoSrv === option.value;
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setChoiceFieldValue("mongoSrv", option.value)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 14,
|
||||
border: active
|
||||
? darkMode
|
||||
? "1px solid rgba(255,214,102,0.42)"
|
||||
: "1px solid rgba(22,119,255,0.36)"
|
||||
: darkMode
|
||||
? "1px solid rgba(255,255,255,0.08)"
|
||||
: "1px solid rgba(16,24,40,0.08)",
|
||||
background: active
|
||||
? darkMode
|
||||
? "rgba(255,214,102,0.10)"
|
||||
: "rgba(22,119,255,0.07)"
|
||||
: darkMode
|
||||
? "rgba(255,255,255,0.03)"
|
||||
: "rgba(16,24,40,0.03)",
|
||||
color: darkMode ? "#f5f7ff" : "#162033",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Space size={8} wrap>
|
||||
<Text strong>{option.label}</Text>
|
||||
{active ? (
|
||||
<Tag color="blue">
|
||||
{t("connection.modal.mongodb.discovery.current")}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Form.Item
|
||||
name="mongoReplicaPassword"
|
||||
label="副本集密码(可选)"
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
emptyPlaceholder: "留空沿用主密码",
|
||||
retainedLabel: "已保存副本集密码",
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: "mongoReplicaPassword",
|
||||
clearKey: "mongoReplicaPassword",
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
clearLabel: "清除已保存副本集密码",
|
||||
description:
|
||||
"当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。",
|
||||
})}
|
||||
<Space size={8} style={{ marginTop: 12, marginBottom: 12 }}>
|
||||
<Button
|
||||
onClick={handleDiscoverMongoMembers}
|
||||
loading={discoveringMembers}
|
||||
>
|
||||
自动发现成员
|
||||
</Button>
|
||||
</Space>
|
||||
{mongoMembers.length > 0 && (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(record) => record.host}
|
||||
pagination={false}
|
||||
dataSource={mongoMembers}
|
||||
style={{ marginBottom: 12 }}
|
||||
columns={[
|
||||
{ title: "Host", dataIndex: "host", width: "48%" },
|
||||
{
|
||||
title: "角色",
|
||||
dataIndex: "role",
|
||||
width: "32%",
|
||||
render: (value: string, record: MongoMemberInfo) => (
|
||||
<Tag color={record.isSelf ? "blue" : "default"}>
|
||||
{value || "UNKNOWN"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "健康",
|
||||
dataIndex: "healthy",
|
||||
width: "20%",
|
||||
render: (value: boolean) => (
|
||||
<Tag color={value ? "success" : "error"}>
|
||||
{value ? "正常" : "异常"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
{mongoSrv && useSSH && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message={t("connection.modal.mongodb.discovery.srv_ssh_warning")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "mongoPolicy",
|
||||
icon: <ThunderboltOutlined />,
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="mongoAuthSource"
|
||||
label="认证库 (authSource)"
|
||||
style={{ marginBottom: 0 }}
|
||||
{mongoTopology === "replica" &&
|
||||
renderConfigSectionCard({
|
||||
sectionKey: "replica",
|
||||
icon: <ClusterOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="mongoHosts"
|
||||
label={t(
|
||||
mongoSrv
|
||||
? "connection.modal.mongodb.replica.hosts.srv.label"
|
||||
: "connection.modal.mongodb.replica.hosts.standard.label",
|
||||
)}
|
||||
help={t(
|
||||
mongoSrv
|
||||
? "connection.modal.mongodb.replica.hosts.srv.help"
|
||||
: "connection.modal.mongodb.replica.hosts.standard.help",
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={t(
|
||||
mongoSrv
|
||||
? "connection.modal.mongodb.replica.hosts.srv.placeholder"
|
||||
: "connection.modal.mongodb.replica.hosts.standard.placeholder",
|
||||
)}
|
||||
tokenSeparators={[",", ";", " "]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="mongoReplicaSet"
|
||||
label={t("connection.modal.mongodb.replica.set.label")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={t("connection.modal.mongodb.replica.set.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="mongoReplicaUser"
|
||||
label={t("connection.modal.mongodb.replica.user.label")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={t("connection.modal.mongodb.replica.user.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="mongoReplicaPassword"
|
||||
label={t("connection.modal.mongodb.replica.password.label")}
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
emptyPlaceholder: t(
|
||||
"connection.modal.mongodb.replica.password.placeholder.empty",
|
||||
),
|
||||
retainedLabel: t(
|
||||
"connection.modal.mongodb.replica.password.placeholder.retained",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: "mongoReplicaPassword",
|
||||
clearKey: "mongoReplicaPassword",
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
clearLabel: t("connection.modal.mongodb.replica.password.clear"),
|
||||
description: t("connection.modal.mongodb.replica.password.description"),
|
||||
})}
|
||||
<Space size={8} style={{ marginTop: 12, marginBottom: 12 }}>
|
||||
<Button
|
||||
onClick={handleDiscoverMongoMembers}
|
||||
loading={discoveringMembers}
|
||||
>
|
||||
{t("connection.modal.action.discover_members")}
|
||||
</Button>
|
||||
</Space>
|
||||
{mongoMembers.length > 0 && (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(record) => record.host}
|
||||
pagination={false}
|
||||
dataSource={mongoMembers}
|
||||
style={{ marginBottom: 12 }}
|
||||
columns={[
|
||||
{ title: "Host", dataIndex: "host", width: "48%" },
|
||||
{
|
||||
title: t("connection.modal.mongodb.members.role"),
|
||||
dataIndex: "role",
|
||||
width: "32%",
|
||||
render: (value: string, record: MongoMemberInfo) => (
|
||||
<Tag color={record.isSelf ? "blue" : "default"}>
|
||||
{value || "UNKNOWN"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("connection.modal.mongodb.members.health"),
|
||||
dataIndex: "healthy",
|
||||
width: "20%",
|
||||
render: (value: boolean) => (
|
||||
<Tag color={value ? "success" : "error"}>
|
||||
{t(
|
||||
value
|
||||
? "connection.modal.mongodb.members.health.ok"
|
||||
: "connection.modal.mongodb.members.health.error",
|
||||
)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "mongoPolicy",
|
||||
icon: <ThunderboltOutlined />,
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Input {...noAutoCapInputProps} placeholder="默认使用 database 或 admin" />
|
||||
</Form.Item>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<Text strong>读偏好 (readPreference)</Text>
|
||||
{renderChoiceCards({
|
||||
fieldName: "mongoReadPreference",
|
||||
value: String(mongoReadPreference),
|
||||
minWidth: 130,
|
||||
options: [
|
||||
{
|
||||
value: "primary",
|
||||
label: "primary",
|
||||
description: "只读主节点。",
|
||||
},
|
||||
{
|
||||
value: "primaryPreferred",
|
||||
label: "primaryPreferred",
|
||||
description: "主节点优先。",
|
||||
},
|
||||
{
|
||||
value: "secondary",
|
||||
label: "secondary",
|
||||
description: "只读从节点。",
|
||||
},
|
||||
{
|
||||
value: "secondaryPreferred",
|
||||
label: "secondaryPreferred",
|
||||
description: "从节点优先。",
|
||||
},
|
||||
{
|
||||
value: "nearest",
|
||||
label: "nearest",
|
||||
description: "选择最近节点。",
|
||||
},
|
||||
],
|
||||
})}
|
||||
<Form.Item
|
||||
name="mongoAuthSource"
|
||||
label={t("connection.modal.mongodb.policy.auth_source.label")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={t("connection.modal.mongodb.policy.auth_source.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<Text strong>{t("connection.modal.mongodb.read_preference")}</Text>
|
||||
{renderChoiceCards({
|
||||
fieldName: "mongoReadPreference",
|
||||
value: String(mongoReadPreference),
|
||||
minWidth: 130,
|
||||
options: [
|
||||
{
|
||||
value: "primary",
|
||||
label: "primary",
|
||||
description: t("connection.modal.mongodb.read_preference.primary"),
|
||||
},
|
||||
{
|
||||
value: "primaryPreferred",
|
||||
label: "primaryPreferred",
|
||||
description: t(
|
||||
"connection.modal.mongodb.read_preference.primary_preferred",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "secondary",
|
||||
label: "secondary",
|
||||
description: t("connection.modal.mongodb.read_preference.secondary"),
|
||||
},
|
||||
{
|
||||
value: "secondaryPreferred",
|
||||
label: "secondaryPreferred",
|
||||
description: t(
|
||||
"connection.modal.mongodb.read_preference.secondary_preferred",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "nearest",
|
||||
label: "nearest",
|
||||
description: t("connection.modal.mongodb.read_preference.nearest"),
|
||||
},
|
||||
],
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionModalMongoSections;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getStoredSecretPlaceholder,
|
||||
type ConnectionConfigSectionKey,
|
||||
} from "../utils/connectionModalPresentation";
|
||||
import { useI18n } from "../i18n/provider";
|
||||
import { noAutoCapInputProps } from "../utils/inputAutoCap";
|
||||
|
||||
type ChoiceCardOption = {
|
||||
@@ -67,176 +68,185 @@ const ConnectionModalRedisSections: React.FC<ConnectionModalRedisSectionsProps>
|
||||
renderConfigSectionCard,
|
||||
renderStoredSecretControls,
|
||||
createUriAwareRequiredRule,
|
||||
}) => (
|
||||
<>
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "connectionMode",
|
||||
icon: <ClusterOutlined />,
|
||||
children: (
|
||||
<>
|
||||
{renderChoiceCards({
|
||||
fieldName: "redisTopology",
|
||||
value: String(redisTopology),
|
||||
options: [
|
||||
{
|
||||
value: "single",
|
||||
label: "单机模式",
|
||||
description: "只连接一个 Redis 节点。",
|
||||
},
|
||||
{
|
||||
value: "cluster",
|
||||
label: "集群模式",
|
||||
description: "Redis Cluster,配置多个种子节点。",
|
||||
},
|
||||
{
|
||||
value: "sentinel",
|
||||
label: "哨兵模式",
|
||||
description: "通过 Sentinel 发现主节点,适合主从高可用。",
|
||||
},
|
||||
],
|
||||
})}
|
||||
{(redisTopology === "cluster" || redisTopology === "sentinel") && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="redisHosts"
|
||||
label={
|
||||
redisTopology === "sentinel"
|
||||
? "Sentinel 附加节点地址"
|
||||
: "集群附加节点地址"
|
||||
}
|
||||
help={
|
||||
redisTopology === "sentinel"
|
||||
? "上方主机地址作为第一个 Sentinel;这里填写其他 Sentinel 节点,格式:host:port"
|
||||
: "主节点使用上方主机地址;这里填写其他种子节点,格式:host:port"
|
||||
}
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={
|
||||
redisTopology === "sentinel"
|
||||
? "例如:10.10.0.12:26379、10.10.0.13:26379"
|
||||
: "例如:10.10.0.12:6379、10.10.0.13:6379"
|
||||
}
|
||||
tokenSeparators={[",", ";", " "]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{redisTopology === "sentinel" && (
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "connectionMode",
|
||||
icon: <ClusterOutlined />,
|
||||
children: (
|
||||
<>
|
||||
{renderChoiceCards({
|
||||
fieldName: "redisTopology",
|
||||
value: String(redisTopology),
|
||||
options: [
|
||||
{
|
||||
value: "single",
|
||||
label: t("connection.modal.redis.topology.single.label"),
|
||||
description: t("connection.modal.redis.topology.single.description"),
|
||||
},
|
||||
{
|
||||
value: "cluster",
|
||||
label: t("connection.modal.redis.topology.cluster.label"),
|
||||
description: t("connection.modal.redis.topology.cluster.description"),
|
||||
},
|
||||
{
|
||||
value: "sentinel",
|
||||
label: t("connection.modal.redis.topology.sentinel.label"),
|
||||
description: t("connection.modal.redis.topology.sentinel.description"),
|
||||
},
|
||||
],
|
||||
})}
|
||||
{(redisTopology === "cluster" || redisTopology === "sentinel") && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="redisSentinelMaster"
|
||||
label="Sentinel master 名称"
|
||||
help="填写 Sentinel 配置中的 monitor 名称,例如 mymaster。"
|
||||
rules={[
|
||||
createUriAwareRequiredRule(
|
||||
"请输入 Sentinel master 名称",
|
||||
),
|
||||
]}
|
||||
name="redisHosts"
|
||||
label={t(
|
||||
redisTopology === "sentinel"
|
||||
? "connection.modal.redis.hosts.sentinel.label"
|
||||
: "connection.modal.redis.hosts.cluster.label",
|
||||
)}
|
||||
help={t(
|
||||
redisTopology === "sentinel"
|
||||
? "connection.modal.redis.hosts.sentinel.help"
|
||||
: "connection.modal.redis.hosts.cluster.help",
|
||||
)}
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="例如:mymaster"
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={t(
|
||||
redisTopology === "sentinel"
|
||||
? "connection.modal.redis.hosts.sentinel.placeholder"
|
||||
: "connection.modal.redis.hosts.cluster.placeholder",
|
||||
)}
|
||||
tokenSeparators={[",", ";", " "]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
{redisTopology === "sentinel" && (
|
||||
<Form.Item
|
||||
name="redisSentinelMaster"
|
||||
label={t("connection.modal.redis.sentinel.master.label")}
|
||||
help={t("connection.modal.redis.sentinel.master.help")}
|
||||
rules={[
|
||||
createUriAwareRequiredRule(
|
||||
t("connection.modal.redis.sentinel.master.required"),
|
||||
),
|
||||
]}
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={t("connection.modal.redis.sentinel.master.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "credentials",
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="password" label="密码 (可选)">
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
visibilityToggle={{
|
||||
visible: primaryPasswordVisible,
|
||||
onVisibleChange: setPrimaryPasswordVisible,
|
||||
}}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder: "Redis 密码(如果设置了 requirepass)",
|
||||
retainedLabel: "已保存 Redis 密码",
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{redisTopology === "sentinel" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "credentials",
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="password" label={t("connection.modal.redis.credentials.primary.label")}>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
visibilityToggle={{
|
||||
visible: primaryPasswordVisible,
|
||||
onVisibleChange: setPrimaryPasswordVisible,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="redisSentinelUser"
|
||||
label="Sentinel 用户名(可选)"
|
||||
style={{ marginBottom: 0 }}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder: t("connection.modal.redis.credentials.primary.placeholder.empty"),
|
||||
retainedLabel: t("connection.modal.redis.credentials.primary.placeholder.retained"),
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{redisTopology === "sentinel" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="留空表示 Sentinel 不使用 ACL 用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="redisSentinelPassword"
|
||||
label="Sentinel 密码(可选)"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
|
||||
emptyPlaceholder: "Sentinel 自身认证密码,留空则不发送",
|
||||
retainedLabel: "已保存 Sentinel 密码",
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: "redisSentinelPassword",
|
||||
clearKey: "redisSentinelPassword",
|
||||
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
|
||||
clearLabel: "清除已保存 Sentinel 密码",
|
||||
description:
|
||||
"当前已保存 Sentinel 密码。留空表示继续沿用,输入新值表示替换。",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
<Form.Item
|
||||
name="redisSentinelUser"
|
||||
label={t("connection.modal.redis.credentials.sentinelUser.label")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={t("connection.modal.redis.credentials.sentinelUser.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="redisSentinelPassword"
|
||||
label={t("connection.modal.redis.credentials.sentinelPassword.label")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
|
||||
emptyPlaceholder: t(
|
||||
"connection.modal.redis.credentials.sentinelPassword.placeholder.empty",
|
||||
),
|
||||
retainedLabel: t(
|
||||
"connection.modal.redis.credentials.sentinelPassword.placeholder.retained",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: "redisSentinelPassword",
|
||||
clearKey: "redisSentinelPassword",
|
||||
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
|
||||
clearLabel: t("connection.modal.redis.credentials.sentinelPassword.clear"),
|
||||
description: t(
|
||||
"connection.modal.redis.credentials.sentinelPassword.description",
|
||||
),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "databaseScope",
|
||||
icon: <DatabaseOutlined />,
|
||||
children: (
|
||||
<Form.Item
|
||||
name="includeRedisDatabases"
|
||||
label="显示数据库 (留空显示全部)"
|
||||
help="连接测试成功后可选择"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择显示的数据库"
|
||||
allowClear
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "databaseScope",
|
||||
icon: <DatabaseOutlined />,
|
||||
children: (
|
||||
<Form.Item
|
||||
name="includeRedisDatabases"
|
||||
label={t("connection.modal.redis.databaseScope.label")}
|
||||
help={t("connection.modal.redis.databaseScope.help")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{redisDbList.map((db) => (
|
||||
<Select.Option key={db} value={db}>
|
||||
db{db}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={t("connection.modal.redis.databaseScope.placeholder")}
|
||||
allowClear
|
||||
>
|
||||
{redisDbList.map((db) => (
|
||||
<Select.Option key={db} value={db}>
|
||||
db{db}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionModalRedisSections;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React from 'react';
|
||||
import { Checkbox, Input, Modal, Typography } from 'antd';
|
||||
import { Button, Checkbox, Input, Typography } from 'antd';
|
||||
import { useI18n } from '../i18n/provider';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -16,6 +18,8 @@ export interface ConnectionPackagePasswordModalProps {
|
||||
confirmLoading?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onBack?: () => void;
|
||||
embedded?: boolean;
|
||||
onIncludeSecretsChange?: (value: boolean) => void;
|
||||
onUseFilePasswordChange?: (value: boolean) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
@@ -32,34 +36,54 @@ export default function ConnectionPackagePasswordModal({
|
||||
password,
|
||||
error,
|
||||
confirmLoading,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
confirmText,
|
||||
cancelText,
|
||||
onBack,
|
||||
embedded = false,
|
||||
onIncludeSecretsChange,
|
||||
onUseFilePasswordChange,
|
||||
onPasswordChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConnectionPackagePasswordModalProps) {
|
||||
const { t } = useI18n();
|
||||
const isExportMode = mode === 'export';
|
||||
const showFilePasswordInput = isExportMode ? useFilePassword : true;
|
||||
const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码';
|
||||
const resolvedConfirmText = confirmText ?? t('common.confirm');
|
||||
const resolvedCancelText = cancelText ?? t('common.cancel');
|
||||
const placeholder = isExportMode
|
||||
? t('app.connection_package.dialog.file_password_placeholder')
|
||||
: t('app.connection_package.dialog.restore_password_placeholder');
|
||||
const helperText = !includeSecrets
|
||||
? '将仅导出连接配置,不包含密码。'
|
||||
? t('app.connection_package.dialog.help.exclude_passwords')
|
||||
: (useFilePassword
|
||||
? '请通过单独渠道将密码告知接收方,不要和文件一起发送。'
|
||||
: '密码已加密保护。如需通过公网传输,建议设置文件保护密码。');
|
||||
? t('app.connection_package.dialog.help.share_file_password_separately')
|
||||
: t('app.connection_package.dialog.help.encrypted_passwords_recommend_file_password'));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={confirmText}
|
||||
cancelText={cancelText}
|
||||
confirmLoading={confirmLoading}
|
||||
onOk={onConfirm}
|
||||
embedded={embedded}
|
||||
title={embedded ? null : (
|
||||
<span style={{ minWidth: 0 }}>{title}</span>
|
||||
)}
|
||||
closable={embedded ? false : undefined}
|
||||
onCancel={onCancel}
|
||||
destroyOnHidden={false}
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel}>
|
||||
{resolvedCancelText}
|
||||
</Button>,
|
||||
<Button key="confirm" type="primary" loading={confirmLoading} onClick={onConfirm}>
|
||||
{resolvedConfirmText}
|
||||
</Button>,
|
||||
onBack ? (
|
||||
<Button key="back" onClick={onBack}>
|
||||
{t('common.back_to_previous')}
|
||||
</Button>
|
||||
) : null,
|
||||
]}
|
||||
>
|
||||
{isExportMode ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -67,14 +91,14 @@ export default function ConnectionPackagePasswordModal({
|
||||
checked={includeSecrets}
|
||||
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
|
||||
>
|
||||
导出连接密码
|
||||
{t('app.connection_package.dialog.option.include_passwords')}
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={useFilePassword}
|
||||
disabled={!includeSecrets}
|
||||
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
|
||||
>
|
||||
设置文件保护密码
|
||||
{t('app.connection_package.dialog.option.use_file_password')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
215
frontend/src/components/DataExportDialog.tsx
Normal file
215
frontend/src/components/DataExportDialog.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Form, InputNumber, Select, message } from 'antd';
|
||||
import { ExportOutlined } from '@ant-design/icons';
|
||||
import { t } from '../i18n';
|
||||
|
||||
export type DataExportFormat = 'csv' | 'xlsx' | 'json' | 'md' | 'html';
|
||||
export type DataExportScope = 'selected' | 'page' | 'all' | 'filteredAll';
|
||||
|
||||
export type DataExportFileOptions = {
|
||||
format: DataExportFormat;
|
||||
xlsxMaxRowsPerSheet?: number;
|
||||
};
|
||||
|
||||
export type DataExportDialogValues = DataExportFileOptions & {
|
||||
scope: DataExportScope | string;
|
||||
};
|
||||
|
||||
export type DataExportScopeOption = {
|
||||
value: DataExportScope | string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type ShowDataExportDialogOptions = {
|
||||
title: string;
|
||||
scopeOptions: DataExportScopeOption[];
|
||||
initialValues?: Partial<DataExportDialogValues>;
|
||||
okText?: string;
|
||||
};
|
||||
|
||||
export const MAX_XLSX_ROWS_PER_SHEET = 1048575;
|
||||
export const DEFAULT_XLSX_ROWS_PER_SHEET = MAX_XLSX_ROWS_PER_SHEET;
|
||||
export const DEFAULT_DATA_EXPORT_FORMAT: DataExportFormat = 'xlsx';
|
||||
|
||||
export const DATA_EXPORT_FORMAT_OPTIONS: Array<{ value: DataExportFormat; label: string }> = [
|
||||
{ value: 'xlsx', label: 'Excel (XLSX)' },
|
||||
{ value: 'csv', label: 'CSV' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'md', label: 'Markdown' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
];
|
||||
|
||||
const resolveDefaultScope = (scopeOptions: DataExportScopeOption[], initialScope?: string): string => {
|
||||
const matchedInitial = scopeOptions.find((item) => item.value === initialScope && !item.disabled);
|
||||
if (matchedInitial) return String(matchedInitial.value);
|
||||
const firstEnabled = scopeOptions.find((item) => !item.disabled);
|
||||
return String(firstEnabled?.value || scopeOptions[0]?.value || 'all');
|
||||
};
|
||||
|
||||
const normalizeDialogValues = (
|
||||
scopeOptions: DataExportScopeOption[],
|
||||
initialValues?: Partial<DataExportDialogValues>,
|
||||
): DataExportDialogValues => {
|
||||
const format = (initialValues?.format || DEFAULT_DATA_EXPORT_FORMAT) as DataExportFormat;
|
||||
const scope = resolveDefaultScope(scopeOptions, initialValues?.scope ? String(initialValues.scope) : undefined);
|
||||
const xlsxMaxRowsPerSheet = Number(initialValues?.xlsxMaxRowsPerSheet) > 0
|
||||
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(initialValues?.xlsxMaxRowsPerSheet)))
|
||||
: DEFAULT_XLSX_ROWS_PER_SHEET;
|
||||
return {
|
||||
format,
|
||||
scope,
|
||||
xlsxMaxRowsPerSheet,
|
||||
};
|
||||
};
|
||||
|
||||
const validateDialogValues = (
|
||||
values: DataExportDialogValues,
|
||||
scopeOptions: DataExportScopeOption[],
|
||||
): string | null => {
|
||||
if (!DATA_EXPORT_FORMAT_OPTIONS.some((item) => item.value === values.format)) {
|
||||
return t('data_export.dialog.validation.format_required');
|
||||
}
|
||||
if (scopeOptions.length > 0) {
|
||||
const matchedScope = scopeOptions.find((item) => String(item.value) === String(values.scope));
|
||||
if (!matchedScope || matchedScope.disabled) {
|
||||
return t('data_export.dialog.validation.scope_required');
|
||||
}
|
||||
}
|
||||
if (values.format === 'xlsx') {
|
||||
const rows = Math.trunc(Number(values.xlsxMaxRowsPerSheet) || 0);
|
||||
if (!Number.isFinite(rows) || rows <= 0) {
|
||||
return t('data_export.dialog.validation.xlsx_max_rows_required');
|
||||
}
|
||||
if (rows > MAX_XLSX_ROWS_PER_SHEET) {
|
||||
return t('data_export.dialog.validation.xlsx_max_rows_limit', {
|
||||
maxRows: MAX_XLSX_ROWS_PER_SHEET.toLocaleString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const DataExportDialogContent: React.FC<{
|
||||
scopeOptions: DataExportScopeOption[];
|
||||
initialValues?: Partial<DataExportDialogValues>;
|
||||
onChange: (values: DataExportDialogValues) => void;
|
||||
}> = ({ scopeOptions, initialValues, onChange }) => {
|
||||
const [values, setValues] = useState<DataExportDialogValues>(() => normalizeDialogValues(scopeOptions, initialValues));
|
||||
|
||||
useEffect(() => {
|
||||
onChange(values);
|
||||
}, [onChange, values]);
|
||||
|
||||
const selectedScope = useMemo(
|
||||
() => scopeOptions.find((item) => String(item.value) === String(values.scope)),
|
||||
[scopeOptions, values.scope],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-export-config-modal="true">
|
||||
<Form layout="vertical" colon={false}>
|
||||
<Form.Item label={t('data_export.dialog.field.format')} style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
value={values.format}
|
||||
options={DATA_EXPORT_FORMAT_OPTIONS}
|
||||
onChange={(format) => setValues((prev) => ({ ...prev, format: format as DataExportFormat }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('data_export.dialog.field.scope')} style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
value={values.scope}
|
||||
disabled={scopeOptions.length <= 1}
|
||||
options={scopeOptions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
disabled: item.disabled,
|
||||
}))}
|
||||
onChange={(scope) => setValues((prev) => ({ ...prev, scope }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{selectedScope?.description && (
|
||||
<div style={{ marginBottom: 16, color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
|
||||
{selectedScope.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{values.format === 'xlsx' && (
|
||||
<Form.Item
|
||||
label={t('data_export.dialog.field.xlsx_max_rows')}
|
||||
extra={t('data_export.dialog.field.xlsx_max_rows_help', {
|
||||
maxRows: MAX_XLSX_ROWS_PER_SHEET.toLocaleString(),
|
||||
})}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={MAX_XLSX_ROWS_PER_SHEET}
|
||||
step={100000}
|
||||
style={{ width: '100%' }}
|
||||
value={values.xlsxMaxRowsPerSheet}
|
||||
onChange={(nextValue) => setValues((prev) => ({
|
||||
...prev,
|
||||
xlsxMaxRowsPerSheet: Number(nextValue) > 0
|
||||
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(nextValue)))
|
||||
: 0,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export async function showDataExportDialog(
|
||||
modal: ReturnType<typeof Modal.useModal>[0],
|
||||
options: ShowDataExportDialogOptions,
|
||||
): Promise<DataExportDialogValues | null> {
|
||||
const initialValues = normalizeDialogValues(options.scopeOptions, options.initialValues);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let latestValues = initialValues;
|
||||
|
||||
const finish = (nextValue: DataExportDialogValues | null) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolve(nextValue);
|
||||
};
|
||||
|
||||
modal.confirm({
|
||||
title: options.title,
|
||||
icon: <ExportOutlined />,
|
||||
width: 520,
|
||||
centered: true,
|
||||
maskClosable: true,
|
||||
okText: options.okText || t('data_export.dialog.action.start'),
|
||||
cancelText: t('common.cancel'),
|
||||
content: (
|
||||
<DataExportDialogContent
|
||||
scopeOptions={options.scopeOptions}
|
||||
initialValues={initialValues}
|
||||
onChange={(values) => {
|
||||
latestValues = values;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
onOk: async () => {
|
||||
const errorMessage = validateDialogValues(latestValues, options.scopeOptions);
|
||||
if (errorMessage) {
|
||||
void message.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
finish(latestValues);
|
||||
},
|
||||
onCancel: () => {
|
||||
finish(null);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
65
frontend/src/components/DataExportFlow.i18n.test.ts
Normal file
65
frontend/src/components/DataExportFlow.i18n.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const componentFiles = [
|
||||
'./DataExportDialog.tsx',
|
||||
'./ExportProgressModal.tsx',
|
||||
'./TableExportWorkbench.tsx',
|
||||
'./useExportProgressRunner.ts',
|
||||
'../utils/tableExportTab.ts',
|
||||
] as const;
|
||||
|
||||
const localeFiles = [
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'en-US',
|
||||
'ja-JP',
|
||||
'de-DE',
|
||||
'ru-RU',
|
||||
] as const;
|
||||
|
||||
const sources = componentFiles.map((file) => readFileSync(new URL(file, import.meta.url), 'utf8'));
|
||||
const combinedSource = sources.join('\n');
|
||||
const catalogs = Object.fromEntries(localeFiles.map((locale) => [
|
||||
locale,
|
||||
JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record<string, string>,
|
||||
])) as Record<typeof localeFiles[number], Record<string, string>>;
|
||||
|
||||
const extractKeys = (source: string): string[] => (
|
||||
Array.from(new Set(source.match(/data_export(?:\.[a-z0-9_]+)+/g) || [])).sort()
|
||||
);
|
||||
|
||||
const placeholdersOf = (value: string): string[] => (
|
||||
Array.from(value.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g), (match) => match[1]).sort()
|
||||
);
|
||||
|
||||
describe('data export i18n', () => {
|
||||
it('routes dialog, progress modal, and workbench copy through translation keys instead of inline Han literals', () => {
|
||||
expect(sources[0]).toContain("t('data_export.dialog.field.format')");
|
||||
expect(sources[1]).toContain("t('data_export.progress.title.error')");
|
||||
expect(sources[2]).toContain("t('data_export.workbench.title')");
|
||||
expect(sources[3]).toContain("t('data_export.progress.title.done')");
|
||||
expect(sources[3]).toContain("t('data_export.progress.title.error')");
|
||||
expect(sources[4]).toContain("t('data_export.workbench.scope.all.label')");
|
||||
expect(sources[4]).toContain("t('data_export.workbench.scope.all.description')");
|
||||
expect(sources[4]).toContain("t('data_export.progress.value.target_fallback')");
|
||||
expect(sources[4]).toContain("t('data_export.workbench.task.export_target'");
|
||||
expect(combinedSource).not.toMatch(/\p{Script=Han}/u);
|
||||
});
|
||||
|
||||
it('keeps all extracted data_export keys present in every supported locale with matching placeholders', () => {
|
||||
const keys = extractKeys(combinedSource);
|
||||
const baseline = catalogs['zh-CN'];
|
||||
|
||||
expect(keys.length).toBeGreaterThan(0);
|
||||
|
||||
keys.forEach((key) => {
|
||||
expect(baseline, `zh-CN:${key}`).toHaveProperty(key);
|
||||
const expectedPlaceholders = placeholdersOf(baseline[key]);
|
||||
localeFiles.forEach((locale) => {
|
||||
expect(catalogs[locale], `${locale}:${key}`).toHaveProperty(key);
|
||||
expect(placeholdersOf(catalogs[locale][key]), `${locale}:${key}`).toEqual(expectedPlaceholders);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid auto commit delay i18n guards', () => {
|
||||
it('localizes auto commit delay option labels', () => {
|
||||
expect(dataGridSource).toContain("translateDataGrid('data_grid.toolbar.commit_delay.seconds', { seconds: item.seconds })");
|
||||
|
||||
[
|
||||
"label: '3 秒'",
|
||||
"label: '5 秒'",
|
||||
"label: '10 秒'",
|
||||
"label: '30 秒'",
|
||||
].forEach((legacyText) => {
|
||||
expect(dataGridSource).not.toContain(legacyText);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
frontend/src/components/DataGrid.auto-commit.i18n.test.ts
Normal file
14
frontend/src/components/DataGrid.auto-commit.i18n.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid auto commit i18n guards', () => {
|
||||
it('localizes auto commit toast wrappers while preserving raw details', () => {
|
||||
expect(dataGridSource).toContain("translateDataGrid('data_grid.message.auto_commit_success')");
|
||||
expect(dataGridSource).toContain("translateDataGrid('data_grid.message.auto_commit_failed', { detail: res.message })");
|
||||
|
||||
expect(dataGridSource).not.toContain("'自动提交成功'");
|
||||
expect(dataGridSource).not.toContain('`自动提交失败: ${res.message}`');
|
||||
});
|
||||
});
|
||||
17
frontend/src/components/DataGrid.cell-undo-menu.i18n.test.ts
Normal file
17
frontend/src/components/DataGrid.cell-undo-menu.i18n.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const legacyMenuSource = readFileSync(new URL('./DataGridLegacyCellContextMenu.tsx', import.meta.url), 'utf8');
|
||||
const v2MenuSource = readFileSync(new URL('./V2TableContextMenu.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid cell undo menu i18n guards', () => {
|
||||
it('localizes cell undo action labels in legacy and v2 menus', () => {
|
||||
[
|
||||
legacyMenuSource,
|
||||
v2MenuSource,
|
||||
].forEach((source) => {
|
||||
expect(source).toContain("data_grid.context_menu.undo_cell_change");
|
||||
expect(source).not.toContain('撤销此单元格修改');
|
||||
});
|
||||
});
|
||||
});
|
||||
24
frontend/src/components/DataGrid.cell-undo.i18n.test.ts
Normal file
24
frontend/src/components/DataGrid.cell-undo.i18n.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid cell undo i18n guards', () => {
|
||||
it('localizes cell undo toast wrappers', () => {
|
||||
[
|
||||
"translateDataGrid('data_grid.message.undo_added_row_hint')",
|
||||
"translateDataGrid('data_grid.message.undo_cell_original_missing')",
|
||||
"translateDataGrid('data_grid.message.undo_cell_success')",
|
||||
].forEach((expected) => {
|
||||
expect(dataGridSource).toContain(expected);
|
||||
});
|
||||
|
||||
[
|
||||
'新增行请使用删除选中或整表回滚撤销',
|
||||
'未找到该单元格的原始数据,无法撤销',
|
||||
'已撤销单元格修改',
|
||||
].forEach((legacyText) => {
|
||||
expect(dataGridSource).not.toContain(legacyText);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid embedded designer title i18n guards', () => {
|
||||
it('localizes the embedded table designer tab title while preserving the raw table name parameter', () => {
|
||||
expect(dataGridSource).toContain("translateDataGrid('data_grid.embedded_designer.title'");
|
||||
expect(dataGridSource).toContain('tableName: tableName ||');
|
||||
expect(dataGridSource).not.toContain('title: `设计表 (${tableName || \'\'}');
|
||||
});
|
||||
|
||||
it('keeps the embedded designer title key in every locale catalog with the tableName placeholder', () => {
|
||||
(['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const).forEach((locale) => {
|
||||
const catalog = JSON.parse(
|
||||
readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'),
|
||||
) as Record<string, string>;
|
||||
|
||||
expect(catalog['data_grid.embedded_designer.title']).toEqual(expect.any(String));
|
||||
expect(catalog['data_grid.embedded_designer.title']).toContain('{{tableName}}');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
20
frontend/src/components/DataGrid.row-number.i18n.test.ts
Normal file
20
frontend/src/components/DataGrid.row-number.i18n.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
|
||||
const rowNumberAriaKey = 'data_grid.aria.row_number';
|
||||
|
||||
describe('DataGrid row number i18n', () => {
|
||||
it('localizes the row number column aria label', () => {
|
||||
expect(source).toContain(`aria-label={translateDataGrid('${rowNumberAriaKey}')}`);
|
||||
expect(source).not.toContain('aria-label="行号"');
|
||||
});
|
||||
|
||||
it('keeps the row number aria label available in every locale', () => {
|
||||
locales.forEach((locale) => {
|
||||
const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record<string, string>;
|
||||
expect(catalog[rowNumberAriaKey], `${locale}:${rowNumberAriaKey}`).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const secondaryActionsSource = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid secondary actions i18n guards', () => {
|
||||
it('localizes the object design action label', () => {
|
||||
expect(secondaryActionsSource).toContain("translate('data_grid.secondary.object_design')");
|
||||
expect(secondaryActionsSource).not.toContain("'对象设计'");
|
||||
expect(secondaryActionsSource).not.toContain('>对象设计<');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Button, Checkbox, Input } from 'antd';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
export type DataGridColumnInfoTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
export interface DataGridColumnInfoPopoverContentProps {
|
||||
darkMode: boolean;
|
||||
@@ -12,6 +15,7 @@ export interface DataGridColumnInfoPopoverContentProps {
|
||||
enableHiddenColumnMemory: boolean;
|
||||
canResetOrder: boolean;
|
||||
canResetHidden: boolean;
|
||||
translate?: DataGridColumnInfoTranslate;
|
||||
onShowColumnCommentChange: (checked: boolean) => void;
|
||||
onShowColumnTypeChange: (checked: boolean) => void;
|
||||
onToggleAllColumnsVisibility: (visible: boolean) => void;
|
||||
@@ -34,6 +38,7 @@ const DataGridColumnInfoPopoverContent: React.FC<DataGridColumnInfoPopoverConten
|
||||
enableHiddenColumnMemory,
|
||||
canResetOrder,
|
||||
canResetHidden,
|
||||
translate = defaultTranslate,
|
||||
onShowColumnCommentChange,
|
||||
onShowColumnTypeChange,
|
||||
onToggleAllColumnsVisibility,
|
||||
@@ -45,24 +50,30 @@ const DataGridColumnInfoPopoverContent: React.FC<DataGridColumnInfoPopoverConten
|
||||
onResetHidden,
|
||||
}) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 200, maxWidth: 300 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666' }}>显示设置</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666' }}>
|
||||
{translate('data_grid.column_settings.display_settings')}
|
||||
</div>
|
||||
<Checkbox checked={showColumnComment} onChange={(e) => onShowColumnCommentChange(e.target.checked)}>
|
||||
表头显示备注
|
||||
{translate('data_grid.column_settings.show_comments')}
|
||||
</Checkbox>
|
||||
<Checkbox checked={showColumnType} onChange={(e) => onShowColumnTypeChange(e.target.checked)}>
|
||||
表头显示类型
|
||||
{translate('data_grid.column_settings.show_types')}
|
||||
</Checkbox>
|
||||
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>列可见性</span>
|
||||
<span>{translate('data_grid.column_settings.column_visibility')}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(true)}>全显</a>
|
||||
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(false)}>全隐</a>
|
||||
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(true)}>
|
||||
{translate('data_grid.column_settings.show_all')}
|
||||
</a>
|
||||
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(false)}>
|
||||
{translate('data_grid.column_settings.hide_all')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="搜索列名..."
|
||||
placeholder={translate('data_grid.column_settings.search_columns_placeholder')}
|
||||
size="small"
|
||||
value={columnSearchText}
|
||||
onChange={(e) => onColumnSearchTextChange(e.target.value)}
|
||||
@@ -85,17 +96,17 @@ const DataGridColumnInfoPopoverContent: React.FC<DataGridColumnInfoPopoverConten
|
||||
|
||||
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
|
||||
<Checkbox checked={enableColumnOrderMemory} onChange={(e) => onEnableColumnOrderMemoryChange(e.target.checked)}>
|
||||
记忆自定义列序
|
||||
{translate('data_grid.column_settings.remember_column_order')}
|
||||
</Checkbox>
|
||||
<Checkbox checked={enableHiddenColumnMemory} onChange={(e) => onEnableHiddenColumnMemoryChange(e.target.checked)}>
|
||||
记忆隐藏列配置
|
||||
{translate('data_grid.column_settings.remember_hidden_columns')}
|
||||
</Checkbox>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetOrder} onClick={onResetOrder}>
|
||||
重置排序
|
||||
{translate('data_grid.column_settings.reset_order')}
|
||||
</Button>
|
||||
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetHidden} onClick={onResetHidden}>
|
||||
重置隐藏
|
||||
{translate('data_grid.column_settings.reset_hidden')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import { AutoComplete, Input, Tooltip } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
export type DataGridColumnQuickFindTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
export interface DataGridColumnQuickFindProps {
|
||||
isV2Ui: boolean;
|
||||
@@ -9,6 +12,7 @@ export interface DataGridColumnQuickFindProps {
|
||||
value: string;
|
||||
options: Array<{ value: string; label?: React.ReactNode }>;
|
||||
hasTarget: boolean;
|
||||
translate?: DataGridColumnQuickFindTranslate;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value?: string) => void;
|
||||
}
|
||||
@@ -18,13 +22,14 @@ const DataGridColumnQuickFind: React.FC<DataGridColumnQuickFindProps> = ({
|
||||
inputProps,
|
||||
value,
|
||||
options,
|
||||
translate = defaultTranslate,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const legacyDropdownOpen = !isV2Ui && String(value || '').trim().length > 0 && options.length > 0;
|
||||
|
||||
return (
|
||||
<Tooltip title="输入字段名,回车或点定位按钮即可跳到对应列">
|
||||
<Tooltip title={translate('data_grid.column_quick_find.tooltip')}>
|
||||
<div
|
||||
data-grid-column-quick-find="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-column-quick-find' : undefined}
|
||||
@@ -54,7 +59,7 @@ const DataGridColumnQuickFind: React.FC<DataGridColumnQuickFindProps> = ({
|
||||
size="small"
|
||||
variant="borderless"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="跳到字段列..."
|
||||
placeholder={translate('data_grid.column_quick_find.placeholder')}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onPressEnter={() => onSubmit(value)}
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
import React from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGridColumnTitle from './DataGridColumnTitle';
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Button: ({ children, type: buttonType, htmlType, ...props }: { children?: React.ReactNode; type?: string; htmlType?: 'button' | 'submit' | 'reset' }) => (
|
||||
<button type={htmlType || 'button'} data-button-type={buttonType} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Form: ({ children, component: Component = 'form', onFinish: _onFinish, ...props }: { children?: React.ReactNode; component?: React.ElementType; onFinish?: () => void }) => (
|
||||
<Component {...props}>{children}</Component>
|
||||
),
|
||||
Input: Object.assign(
|
||||
({ onPressEnter: _onPressEnter, ...props }: { onPressEnter?: () => void }) => <input {...props} />,
|
||||
{
|
||||
TextArea: ({ autoSize: _autoSize, ...props }: { autoSize?: unknown }) => <textarea {...props} />,
|
||||
},
|
||||
),
|
||||
Popover: ({ children, content, open }: { children: React.ReactNode; content?: React.ReactNode; open?: boolean }) => (
|
||||
<span data-popover-open={open ? 'true' : 'false'}>
|
||||
{content}
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Select: ({ options = [], value, onChange }: { options?: Array<{ value: string; label: string }>; value?: string; onChange?: (value: string) => void }) => (
|
||||
<select value={value} onChange={(event) => onChange?.(event.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
Tooltip: ({ children, title, rootClassName }: { children: React.ReactNode; title?: React.ReactNode; rootClassName?: string }) => (
|
||||
<>
|
||||
<div data-testid="tooltip-title">{title}</div>
|
||||
<div data-tooltip-root-class={rootClassName}>{title}</div>
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/icons', () => ({
|
||||
FilterOutlined: () => <span data-icon="filter" />,
|
||||
LinkOutlined: () => <span data-icon="link" />,
|
||||
}));
|
||||
|
||||
describe('DataGridColumnTitle', () => {
|
||||
it('marks v2 table headers as single-line when column type and comment rows are hidden', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
@@ -109,4 +145,207 @@ describe('DataGridColumnTitle', () => {
|
||||
expect(markup).toContain('data-grid-fk-jump="true"');
|
||||
expect(markup).toContain('data-ref-table-name="customers"');
|
||||
});
|
||||
|
||||
it('renders a compact column filter trigger with active state', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGridColumnTitle
|
||||
columnName="status"
|
||||
showColumnType={false}
|
||||
showColumnComment={false}
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#999"
|
||||
columnMetaTooltipColor="#fff"
|
||||
darkMode={false}
|
||||
columnFilter={{
|
||||
active: true,
|
||||
operatorOptions: [
|
||||
{ value: '=', label: '=' },
|
||||
{ value: 'CONTAINS', label: 'Contains' },
|
||||
],
|
||||
defaultOperator: 'CONTAINS',
|
||||
initialOperator: '=',
|
||||
initialValue: 'active',
|
||||
filterLabel: 'Filter',
|
||||
applyLabel: 'Apply',
|
||||
clearLabel: 'Clear',
|
||||
valuePlaceholder: 'Value',
|
||||
secondValuePlaceholder: 'End value',
|
||||
listValuePlaceholder: 'List values',
|
||||
noValuePlaceholder: 'No value needed',
|
||||
isNoValueOp: () => false,
|
||||
isBetweenOp: () => false,
|
||||
isListOp: () => false,
|
||||
onApply: () => true,
|
||||
onClear: () => true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('class="gn-v2-column-title-shell"');
|
||||
expect(markup).toContain('data-grid-column-filter-trigger="true"');
|
||||
expect(markup).toContain('data-grid-column-filter-active="true"');
|
||||
expect(markup).toContain('data-grid-column-filter-popover="true"');
|
||||
expect(markup).toContain('Filter status');
|
||||
expect(markup).toContain('value="active"');
|
||||
});
|
||||
|
||||
it('applies the column filter from the popover action button', () => {
|
||||
const onApply = vi.fn(() => true);
|
||||
const renderer = create(
|
||||
<DataGridColumnTitle
|
||||
columnName="code"
|
||||
showColumnType={false}
|
||||
showColumnComment={false}
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#999"
|
||||
columnMetaTooltipColor="#fff"
|
||||
darkMode={false}
|
||||
columnFilter={{
|
||||
active: false,
|
||||
operatorOptions: [
|
||||
{ value: 'CONTAINS', label: 'Contains' },
|
||||
],
|
||||
defaultOperator: 'CONTAINS',
|
||||
initialOperator: 'CONTAINS',
|
||||
initialValue: '3551',
|
||||
filterLabel: 'Filter',
|
||||
applyLabel: 'Apply',
|
||||
clearLabel: 'Clear',
|
||||
valuePlaceholder: 'Value',
|
||||
secondValuePlaceholder: 'End value',
|
||||
listValuePlaceholder: 'List values',
|
||||
noValuePlaceholder: 'No value needed',
|
||||
isNoValueOp: () => false,
|
||||
isBetweenOp: () => false,
|
||||
isListOp: () => false,
|
||||
onApply,
|
||||
onClear: () => true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const applyButton = renderer.root
|
||||
.findAllByType('button')
|
||||
.find((button) => button.children.includes('Apply'));
|
||||
|
||||
expect(applyButton).toBeTruthy();
|
||||
act(() => {
|
||||
applyButton!.props.onClick({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
op: 'CONTAINS',
|
||||
value: '3551',
|
||||
value2: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps column filter operator switching and clearing interactive', () => {
|
||||
const onApply = vi.fn(() => true);
|
||||
const onClear = vi.fn(() => true);
|
||||
const renderer = create(
|
||||
<DataGridColumnTitle
|
||||
columnName="title"
|
||||
showColumnType={false}
|
||||
showColumnComment={false}
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#999"
|
||||
columnMetaTooltipColor="#fff"
|
||||
darkMode={false}
|
||||
columnFilter={{
|
||||
active: true,
|
||||
operatorOptions: [
|
||||
{ value: '=', label: '=' },
|
||||
{ value: 'CONTAINS', label: 'Contains' },
|
||||
],
|
||||
defaultOperator: 'CONTAINS',
|
||||
initialOperator: 'CONTAINS',
|
||||
initialValue: '3551',
|
||||
filterLabel: 'Filter',
|
||||
applyLabel: 'Apply',
|
||||
clearLabel: 'Clear',
|
||||
valuePlaceholder: 'Value',
|
||||
secondValuePlaceholder: 'End value',
|
||||
listValuePlaceholder: 'List values',
|
||||
noValuePlaceholder: 'No value needed',
|
||||
isNoValueOp: () => false,
|
||||
isBetweenOp: () => false,
|
||||
isListOp: () => false,
|
||||
onApply,
|
||||
onClear,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const operatorSelect = renderer.root.findByType('select');
|
||||
act(() => {
|
||||
operatorSelect.props.onChange({ target: { value: '=' } });
|
||||
});
|
||||
|
||||
const buttons = renderer.root.findAllByType('button');
|
||||
const clearButton = buttons.find((button) => button.children.includes('Clear'));
|
||||
const applyButton = buttons.find((button) => button.children.includes('Apply'));
|
||||
|
||||
expect(clearButton).toBeTruthy();
|
||||
expect(applyButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
applyButton!.props.onClick({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
});
|
||||
expect(onApply).toHaveBeenLastCalledWith({
|
||||
op: '=',
|
||||
value: '3551',
|
||||
value2: '',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
clearButton!.props.onClick();
|
||||
});
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses translated tooltip wrappers while preserving raw metadata values', () => {
|
||||
const translate = vi.fn((key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'data_grid.column.type_tooltip') return `TYPE ${String(params?.type)}`;
|
||||
if (key === 'data_grid.column.comment_tooltip') return `COMMENT ${String(params?.comment)}`;
|
||||
if (key === 'data_grid.column.foreign_key_tooltip') return `FK ${String(params?.target)}`;
|
||||
if (key === 'data_grid.column.foreign_key_jump_title') return `JUMP ${String(params?.tableName)}`;
|
||||
return key;
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGridColumnTitle
|
||||
columnName="account_id"
|
||||
columnMeta={{ type: 'uuid', comment: '账户编号' }}
|
||||
foreignKeyTarget={{ refTableName: 'public.users', refColumnName: 'id' }}
|
||||
showColumnType
|
||||
showColumnComment
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#999"
|
||||
columnMetaTooltipColor="#fff"
|
||||
darkMode={false}
|
||||
translate={translate}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('TYPE uuid');
|
||||
expect(markup).toContain('COMMENT 账户编号');
|
||||
expect(markup).toContain('FK public.users.id');
|
||||
expect(markup).toContain('title="JUMP public.users"');
|
||||
expect(markup).not.toContain('类型:uuid');
|
||||
expect(markup).not.toContain('备注:账户编号');
|
||||
expect(markup).not.toContain('外键:public.users.id');
|
||||
expect(markup).not.toContain('跳转到外键表:public.users');
|
||||
|
||||
expect(translate).toHaveBeenCalledWith('data_grid.column.type_tooltip', { type: 'uuid' });
|
||||
expect(translate).toHaveBeenCalledWith('data_grid.column.comment_tooltip', { comment: '账户编号' });
|
||||
expect(translate).toHaveBeenCalledWith('data_grid.column.foreign_key_tooltip', { target: 'public.users.id' });
|
||||
expect(translate).toHaveBeenCalledWith('data_grid.column.foreign_key_jump_title', { tableName: 'public.users' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { LinkOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Popover, Select, Tooltip } from 'antd';
|
||||
import { FilterOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
export type DataGridColumnTitleTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
export type DataGridColumnFilterDraft = {
|
||||
op: string;
|
||||
value: string;
|
||||
value2?: string;
|
||||
};
|
||||
|
||||
export interface DataGridColumnFilterConfig {
|
||||
active: boolean;
|
||||
operatorOptions: Array<{ value: string; label: string }>;
|
||||
defaultOperator: string;
|
||||
initialOperator?: string;
|
||||
initialValue?: string;
|
||||
initialValue2?: string;
|
||||
filterLabel: string;
|
||||
applyLabel: string;
|
||||
clearLabel: string;
|
||||
valuePlaceholder: string;
|
||||
secondValuePlaceholder: string;
|
||||
listValuePlaceholder: string;
|
||||
noValuePlaceholder: string;
|
||||
isNoValueOp: (op: string) => boolean;
|
||||
isBetweenOp: (op: string) => boolean;
|
||||
isListOp: (op: string) => boolean;
|
||||
onApply: (draft: DataGridColumnFilterDraft) => boolean | void;
|
||||
onClear: () => boolean | void;
|
||||
}
|
||||
|
||||
export interface DataGridColumnTitleProps {
|
||||
columnName: string;
|
||||
@@ -19,9 +49,15 @@ export interface DataGridColumnTitleProps {
|
||||
columnMetaTooltipColor: string;
|
||||
darkMode: boolean;
|
||||
highlighted?: boolean;
|
||||
translate?: DataGridColumnTitleTranslate;
|
||||
onOpenForeignKey?: () => void;
|
||||
columnFilter?: DataGridColumnFilterConfig | null;
|
||||
}
|
||||
|
||||
const stopColumnHeaderInteraction = (event: React.SyntheticEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
|
||||
columnName,
|
||||
columnMeta,
|
||||
@@ -33,7 +69,9 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
|
||||
columnMetaTooltipColor,
|
||||
darkMode,
|
||||
highlighted = false,
|
||||
translate = defaultTranslate,
|
||||
onOpenForeignKey,
|
||||
columnFilter,
|
||||
}) => {
|
||||
const normalizedName = String(columnName || '');
|
||||
const columnType = String(columnMeta?.type || '').trim();
|
||||
@@ -43,13 +81,31 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
|
||||
const shouldShowColumnType = showColumnType && columnType.length > 0;
|
||||
const shouldShowColumnComment = showColumnComment && columnComment.length > 0;
|
||||
const isSingleLineColumnTitle = !shouldShowColumnType && !shouldShowColumnComment;
|
||||
const [filterPopoverOpen, setFilterPopoverOpen] = React.useState(false);
|
||||
const initialFilterOperator = columnFilter?.initialOperator || columnFilter?.defaultOperator || '=';
|
||||
const [draftFilterOperator, setDraftFilterOperator] = React.useState(initialFilterOperator);
|
||||
const [draftFilterValue, setDraftFilterValue] = React.useState(columnFilter?.initialValue || '');
|
||||
const [draftFilterValue2, setDraftFilterValue2] = React.useState(columnFilter?.initialValue2 || '');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!filterPopoverOpen || !columnFilter) return;
|
||||
setDraftFilterOperator(columnFilter.initialOperator || columnFilter.defaultOperator || '=');
|
||||
setDraftFilterValue(columnFilter.initialValue || '');
|
||||
setDraftFilterValue2(columnFilter.initialValue2 || '');
|
||||
}, [
|
||||
columnFilter?.defaultOperator,
|
||||
columnFilter?.initialOperator,
|
||||
columnFilter?.initialValue,
|
||||
columnFilter?.initialValue2,
|
||||
filterPopoverOpen,
|
||||
]);
|
||||
|
||||
const hoverLines: string[] = [];
|
||||
if (columnType) hoverLines.push(`类型:${columnType}`);
|
||||
if (columnComment) hoverLines.push(`备注:${columnComment}`);
|
||||
if (columnType) hoverLines.push(translate('data_grid.column.type_tooltip', { type: columnType }));
|
||||
if (columnComment) hoverLines.push(translate('data_grid.column.comment_tooltip', { comment: columnComment }));
|
||||
if (refTableName) {
|
||||
const refColumnText = refColumnName ? `.${refColumnName}` : '';
|
||||
hoverLines.push(`外键:${refTableName}${refColumnText}`);
|
||||
hoverLines.push(translate('data_grid.column.foreign_key_tooltip', { target: `${refTableName}${refColumnText}` }));
|
||||
}
|
||||
|
||||
const fieldLabel = refTableName ? (
|
||||
@@ -58,7 +114,7 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
|
||||
data-grid-fk-jump="true"
|
||||
data-column-name={normalizedName}
|
||||
data-ref-table-name={refTableName}
|
||||
title={`跳转到外键表:${refTableName}`}
|
||||
title={translate('data_grid.column.foreign_key_jump_title', { tableName: refTableName })}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -146,35 +202,227 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hoverLines.length === 0) {
|
||||
return titleNode;
|
||||
const titleWithOptionalTooltip = (() => {
|
||||
if (hoverLines.length === 0) {
|
||||
return titleNode;
|
||||
}
|
||||
|
||||
const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={(
|
||||
<pre
|
||||
className="gn-data-grid-column-meta-tooltip-content"
|
||||
style={{
|
||||
maxHeight: 260,
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: tooltipTextColor,
|
||||
}}
|
||||
>
|
||||
{hoverLines.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
rootClassName="gn-data-grid-column-meta-tooltip"
|
||||
styles={{ root: { maxWidth: 640 } }}
|
||||
{...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})();
|
||||
|
||||
if (!columnFilter) {
|
||||
return titleWithOptionalTooltip;
|
||||
}
|
||||
|
||||
const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={(
|
||||
<pre
|
||||
className="gn-data-grid-column-meta-tooltip-content"
|
||||
style={{
|
||||
maxHeight: 260,
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: tooltipTextColor,
|
||||
const noValueOperator = columnFilter.isNoValueOp(draftFilterOperator);
|
||||
const betweenOperator = columnFilter.isBetweenOp(draftFilterOperator);
|
||||
const listOperator = columnFilter.isListOp(draftFilterOperator);
|
||||
const activeColor = darkMode ? '#74d99f' : '#16a34a';
|
||||
const mutedColor = columnFilter.active
|
||||
? activeColor
|
||||
: (darkMode ? 'rgba(255,255,255,0.52)' : 'rgba(15, 23, 42, 0.46)');
|
||||
const filterButtonTitle = `${columnFilter.filterLabel} ${normalizedName}`;
|
||||
const submitColumnFilter = (event?: React.SyntheticEvent<HTMLElement>) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const applied = columnFilter.onApply({
|
||||
op: draftFilterOperator,
|
||||
value: draftFilterValue,
|
||||
value2: draftFilterValue2,
|
||||
});
|
||||
if (applied !== false) setFilterPopoverOpen(false);
|
||||
};
|
||||
const filterPopoverContent = (
|
||||
<div
|
||||
data-grid-column-filter-popover="true"
|
||||
onClick={stopColumnHeaderInteraction}
|
||||
style={{
|
||||
width: 260,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 600,
|
||||
color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(15,23,42,0.88)',
|
||||
}}
|
||||
title={normalizedName}
|
||||
>
|
||||
{filterButtonTitle}
|
||||
</div>
|
||||
<Select
|
||||
size="small"
|
||||
value={draftFilterOperator}
|
||||
options={columnFilter.operatorOptions}
|
||||
popupMatchSelectWidth={false}
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}
|
||||
onChange={(value) => {
|
||||
const nextOperator = String(value || columnFilter.defaultOperator || '=');
|
||||
setDraftFilterOperator(nextOperator);
|
||||
if (columnFilter.isNoValueOp(nextOperator)) {
|
||||
setDraftFilterValue('');
|
||||
setDraftFilterValue2('');
|
||||
} else if (!columnFilter.isBetweenOp(nextOperator)) {
|
||||
setDraftFilterValue2('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{noValueOperator ? (
|
||||
<Input
|
||||
size="small"
|
||||
disabled
|
||||
value={columnFilter.noValuePlaceholder}
|
||||
/>
|
||||
) : listOperator ? (
|
||||
<Input.TextArea
|
||||
value={draftFilterValue}
|
||||
placeholder={columnFilter.listValuePlaceholder}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => setDraftFilterValue(event.target.value)}
|
||||
/>
|
||||
) : betweenOperator ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input
|
||||
size="small"
|
||||
value={draftFilterValue}
|
||||
placeholder={columnFilter.valuePlaceholder}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onPressEnter={submitColumnFilter}
|
||||
onChange={(event) => setDraftFilterValue(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
size="small"
|
||||
value={draftFilterValue2}
|
||||
placeholder={columnFilter.secondValuePlaceholder}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onPressEnter={submitColumnFilter}
|
||||
onChange={(event) => setDraftFilterValue2(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
size="small"
|
||||
value={draftFilterValue}
|
||||
placeholder={columnFilter.valuePlaceholder}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onPressEnter={submitColumnFilter}
|
||||
onChange={(event) => setDraftFilterValue(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const cleared = columnFilter.onClear();
|
||||
if (cleared !== false) setFilterPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{hoverLines.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
rootClassName="gn-data-grid-column-meta-tooltip"
|
||||
styles={{ root: { maxWidth: 640 } }}
|
||||
{...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})}
|
||||
{columnFilter.clearLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={submitColumnFilter}
|
||||
>
|
||||
{columnFilter.applyLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="gn-v2-column-title-shell"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
|
||||
</Tooltip>
|
||||
<span style={{ display: 'inline-flex', minWidth: 0, maxWidth: 'calc(100% - 24px)' }}>
|
||||
{titleWithOptionalTooltip}
|
||||
</span>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
open={filterPopoverOpen}
|
||||
onOpenChange={setFilterPopoverOpen}
|
||||
content={filterPopoverContent}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-grid-column-filter-trigger="true"
|
||||
data-grid-column-filter-active={columnFilter.active ? 'true' : undefined}
|
||||
aria-label={filterButtonTitle}
|
||||
title={filterButtonTitle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={stopColumnHeaderInteraction}
|
||||
onPointerDown={stopColumnHeaderInteraction}
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
flex: '0 0 22px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
border: columnFilter.active ? `1px solid ${activeColor}` : '1px solid transparent',
|
||||
borderRadius: 6,
|
||||
background: columnFilter.active
|
||||
? (darkMode ? 'rgba(34, 197, 94, 0.14)' : 'rgba(34, 197, 94, 0.12)')
|
||||
: 'transparent',
|
||||
color: mutedColor,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<FilterOutlined style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
1713
frontend/src/components/DataGridCore.tsx
Normal file
1713
frontend/src/components/DataGridCore.tsx
Normal file
File diff suppressed because it is too large
Load Diff
283
frontend/src/components/DataGridErDiagram.test.tsx
Normal file
283
frontend/src/components/DataGridErDiagram.test.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import DataGridErDiagram from './DataGridErDiagram';
|
||||
|
||||
const hookState = vi.hoisted(() => ({
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'er:messages',
|
||||
tableName: 'messages',
|
||||
role: 'current',
|
||||
isCurrent: true,
|
||||
incomingCount: 1,
|
||||
outgoingCount: 0,
|
||||
relationCount: 1,
|
||||
columns: Array.from({ length: 12 }, (_, index) => ({
|
||||
name: `field_${index + 1}`,
|
||||
type: 'varchar(32)',
|
||||
comment: '',
|
||||
nullable: index !== 0,
|
||||
isPrimary: index === 0,
|
||||
isForeign: false,
|
||||
isRelationField: index <= 1,
|
||||
})),
|
||||
previewColumnCount: 10,
|
||||
hiddenColumnCount: 2,
|
||||
},
|
||||
{
|
||||
id: 'er:message_tags',
|
||||
tableName: 'message_tags',
|
||||
role: 'incoming',
|
||||
isCurrent: false,
|
||||
incomingCount: 0,
|
||||
outgoingCount: 1,
|
||||
relationCount: 1,
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigint',
|
||||
comment: '',
|
||||
nullable: false,
|
||||
isPrimary: true,
|
||||
isForeign: false,
|
||||
isRelationField: true,
|
||||
},
|
||||
{
|
||||
name: 'message_id',
|
||||
type: 'bigint',
|
||||
comment: '',
|
||||
nullable: false,
|
||||
isPrimary: false,
|
||||
isForeign: true,
|
||||
isRelationField: true,
|
||||
},
|
||||
],
|
||||
previewColumnCount: 2,
|
||||
hiddenColumnCount: 0,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
relationCount: 1,
|
||||
relatedTableCount: 1,
|
||||
incomingTableCount: 1,
|
||||
outgoingTableCount: 0,
|
||||
isEmpty: false,
|
||||
},
|
||||
loading: false,
|
||||
reloading: false,
|
||||
error: '',
|
||||
partial: false,
|
||||
reload: vi.fn(),
|
||||
canExpandRelations: true,
|
||||
}));
|
||||
|
||||
vi.mock('./useDataGridErDiagram', () => ({
|
||||
useDataGridErDiagram: () => hookState,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Alert: ({ message }: { message?: React.ReactNode }) => <div>{message}</div>,
|
||||
Button: ({
|
||||
children,
|
||||
icon,
|
||||
onClick,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
onClick?: (...args: any[]) => void;
|
||||
disabled?: boolean;
|
||||
[key: string]: any;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick} disabled={disabled} {...props}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Spin: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/icons', () => {
|
||||
const Icon = ({ label }: { label: string }) => <span>{label}</span>;
|
||||
return {
|
||||
ApartmentOutlined: () => <Icon label="ApartmentOutlined" />,
|
||||
ArrowRightOutlined: () => <Icon label="ArrowRightOutlined" />,
|
||||
CompressOutlined: () => <Icon label="CompressOutlined" />,
|
||||
DatabaseOutlined: () => <Icon label="DatabaseOutlined" />,
|
||||
ExpandOutlined: () => <Icon label="ExpandOutlined" />,
|
||||
LinkOutlined: () => <Icon label="LinkOutlined" />,
|
||||
PlusOutlined: () => <Icon label="PlusOutlined" />,
|
||||
ReloadOutlined: () => <Icon label="ReloadOutlined" />,
|
||||
UndoOutlined: () => <Icon label="UndoOutlined" />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const ReactModule = await import('react');
|
||||
|
||||
const ReactFlow = ({
|
||||
nodes,
|
||||
nodeTypes,
|
||||
children,
|
||||
}: {
|
||||
nodes: Array<{ id: string; type: string; data: any }>;
|
||||
nodeTypes: Record<string, React.ComponentType<any>>;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<div data-react-flow="true">
|
||||
{nodes.map((node) => {
|
||||
const NodeComponent = nodeTypes[node.type];
|
||||
return (
|
||||
<div key={node.id} data-node-id={node.id}>
|
||||
<NodeComponent data={node.data} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ReactFlow,
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
useNodesState: (initialNodes: any[]) => {
|
||||
const [nodes, setNodes] = ReactModule.useState(initialNodes);
|
||||
return [nodes, setNodes, vi.fn()] as const;
|
||||
},
|
||||
useEdgesState: (initialEdges: any[]) => {
|
||||
const [edges, setEdges] = ReactModule.useState(initialEdges);
|
||||
return [edges, setEdges, vi.fn()] as const;
|
||||
},
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
MarkerType: { ArrowClosed: 'arrowclosed' },
|
||||
Position: { Left: 'left', Right: 'right' },
|
||||
};
|
||||
});
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'data_grid.metadata_view.er_table_badge': '表',
|
||||
'data_grid.metadata_view.er_current_badge': '当前表',
|
||||
'data_grid.metadata_view.er_referenced_by_badge': '被引用',
|
||||
'data_grid.metadata_view.er_reference_badge': '引用',
|
||||
'data_grid.metadata_view.er_open_table': '打开表',
|
||||
'data_grid.metadata_view.er_related_table_count': '{{count}} 张关联表',
|
||||
'data_grid.metadata_view.er_relation_count': '{{count}} 条关系',
|
||||
'data_grid.metadata_view.er_relation_depth': '{{count}} 层关系',
|
||||
'data_grid.metadata_view.er_expand_relations': '展开下一层关系',
|
||||
'data_grid.metadata_view.er_reset_relations': '重置为一层',
|
||||
'data_grid.metadata_view.er_expand_fields': '展开全部字段',
|
||||
'data_grid.metadata_view.er_collapse_fields': '收起字段摘要',
|
||||
'data_grid.metadata_view.er_expand_hidden_columns': '展开剩余 {{count}} 个字段',
|
||||
'data_grid.metadata_view.er_empty': '当前表未发现外键关系',
|
||||
'data_grid.metadata_view.er_partial_warning': '部分关系未能完整加载,图中结果可能不完整',
|
||||
'data_grid.table_fallback.query_result': '查询结果',
|
||||
'common.refresh': '刷新',
|
||||
};
|
||||
|
||||
const translate = (key: string, params?: Record<string, unknown>) => {
|
||||
let template = messages[key] || key;
|
||||
Object.entries(params || {}).forEach(([paramKey, paramValue]) => {
|
||||
template = template.replace(`{{${paramKey}}}`, String(paramValue));
|
||||
});
|
||||
return template;
|
||||
};
|
||||
|
||||
const textContent = (node: any): string => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||
return '';
|
||||
}
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((child) => textContent(child)).join('');
|
||||
}
|
||||
if ('children' in node) {
|
||||
return textContent(node.children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const findButton = (renderer: ReactTestRenderer, matcher: (node: any) => boolean) => (
|
||||
renderer.root.find((node) => node.type === 'button' && matcher(node))
|
||||
);
|
||||
|
||||
describe('DataGridErDiagram', () => {
|
||||
beforeEach(() => {
|
||||
hookState.reload.mockReset();
|
||||
hookState.canExpandRelations = true;
|
||||
});
|
||||
|
||||
it('shows hidden fields on demand and lets the toolbar collapse them again', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGridErDiagram
|
||||
connections={[]}
|
||||
connectionId="conn-1"
|
||||
dbName="main"
|
||||
tableName="messages"
|
||||
translate={translate}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).not.toContain('field_11');
|
||||
expect(
|
||||
findButton(renderer, (node) => node.props['data-er-node-toggle'] === 'er:messages').props.className,
|
||||
).toContain('nodrag');
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, (node) => node.props['data-er-node-toggle'] === 'er:messages').props.onClick({
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('field_11');
|
||||
expect(textContent(renderer.toJSON())).toContain('收起字段摘要');
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, (node) => node.props['data-er-action'] === 'collapse-fields').props.onClick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).not.toContain('field_11');
|
||||
});
|
||||
|
||||
it('tracks relation depth from the toolbar controls', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGridErDiagram
|
||||
connections={[]}
|
||||
connectionId="conn-1"
|
||||
dbName="main"
|
||||
tableName="messages"
|
||||
translate={translate}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('1 层关系');
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, (node) => node.props['data-er-action'] === 'expand-relations').props.onClick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('2 层关系');
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, (node) => node.props['data-er-action'] === 'reset-relations').props.onClick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('1 层关系');
|
||||
});
|
||||
});
|
||||
579
frontend/src/components/DataGridErDiagram.tsx
Normal file
579
frontend/src/components/DataGridErDiagram.tsx
Normal file
@@ -0,0 +1,579 @@
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Spin, Tooltip } from 'antd';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
ArrowRightOutlined,
|
||||
CompressOutlined,
|
||||
DatabaseOutlined,
|
||||
ExpandOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UndoOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dagre from 'dagre';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MarkerType,
|
||||
Position,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeMouseHandler,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
import type { BuildErDiagramGraphResult, ErDiagramEdge, ErDiagramNode } from './dataGridErDiagramModel';
|
||||
import { useDataGridErDiagram } from './useDataGridErDiagram';
|
||||
|
||||
type DataGridMetadataTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
export interface DataGridErDiagramProps {
|
||||
connections: any[];
|
||||
connectionId?: string;
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
translate?: DataGridMetadataTranslate;
|
||||
onOpenTable?: (tableName: string) => void;
|
||||
}
|
||||
|
||||
type ErDiagramNodeData = {
|
||||
node: ErDiagramNode;
|
||||
selected: boolean;
|
||||
expanded: boolean;
|
||||
translate: DataGridMetadataTranslate;
|
||||
onToggleExpanded?: (nodeId: string) => void;
|
||||
onOpenTable?: (tableName: string) => void;
|
||||
};
|
||||
|
||||
const NODE_WIDTH = 320;
|
||||
const NODE_BASE_HEIGHT = 108;
|
||||
const NODE_ROW_HEIGHT = 28;
|
||||
const NODE_FOOTER_HEIGHT = 36;
|
||||
const NODE_EXPANDED_MAX_VISIBLE_ROWS = 14;
|
||||
|
||||
const getRoleBadgeKey = (role: ErDiagramNode['role']): string => {
|
||||
switch (role) {
|
||||
case 'current':
|
||||
return 'data_grid.metadata_view.er_current_badge';
|
||||
case 'incoming':
|
||||
return 'data_grid.metadata_view.er_referenced_by_badge';
|
||||
case 'outgoing':
|
||||
return 'data_grid.metadata_view.er_reference_badge';
|
||||
default:
|
||||
return 'data_grid.metadata_view.er_table_badge';
|
||||
}
|
||||
};
|
||||
|
||||
const getVisibleNodeColumns = (node: ErDiagramNode, expanded: boolean) => (
|
||||
expanded ? node.columns : node.columns.slice(0, node.previewColumnCount)
|
||||
);
|
||||
|
||||
const getVisibleNodeRowCount = (node: ErDiagramNode, expanded: boolean): number => Math.min(
|
||||
getVisibleNodeColumns(node, expanded).length,
|
||||
expanded ? NODE_EXPANDED_MAX_VISIBLE_ROWS : node.previewColumnCount,
|
||||
);
|
||||
|
||||
const getNodeColumnViewportHeight = (node: ErDiagramNode, expanded: boolean): number => (
|
||||
getVisibleNodeRowCount(node, expanded) * NODE_ROW_HEIGHT
|
||||
);
|
||||
|
||||
const estimateNodeHeight = (node: ErDiagramNode, expanded: boolean): number => (
|
||||
NODE_BASE_HEIGHT +
|
||||
getNodeColumnViewportHeight(node, expanded) +
|
||||
(node.hiddenColumnCount > 0 ? NODE_FOOTER_HEIGHT : 0)
|
||||
);
|
||||
|
||||
const edgeColorByDirection: Record<ErDiagramEdge['direction'], string> = {
|
||||
incoming: '#2f9e44',
|
||||
outgoing: '#1971c2',
|
||||
self: '#7c3aed',
|
||||
};
|
||||
|
||||
const layoutGraph = (
|
||||
graph: BuildErDiagramGraphResult,
|
||||
translate: DataGridMetadataTranslate,
|
||||
expandedNodeIds: Set<string>,
|
||||
onToggleExpanded?: (nodeId: string) => void,
|
||||
onOpenTable?: (tableName: string) => void,
|
||||
): { nodes: Node<ErDiagramNodeData>[]; edges: Edge[] } => {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setGraph({
|
||||
rankdir: 'LR',
|
||||
nodesep: 56,
|
||||
ranksep: 96,
|
||||
marginx: 24,
|
||||
marginy: 24,
|
||||
});
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
graph.nodes.forEach((node) => {
|
||||
const expanded = expandedNodeIds.has(node.id);
|
||||
dagreGraph.setNode(node.id, {
|
||||
width: NODE_WIDTH,
|
||||
height: estimateNodeHeight(node, expanded),
|
||||
});
|
||||
});
|
||||
graph.edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
return {
|
||||
nodes: graph.nodes.map((node) => {
|
||||
const expanded = expandedNodeIds.has(node.id);
|
||||
const height = estimateNodeHeight(node, expanded);
|
||||
const position = dagreGraph.node(node.id);
|
||||
return {
|
||||
id: node.id,
|
||||
type: 'erTable',
|
||||
position: {
|
||||
x: (position?.x ?? NODE_WIDTH / 2) - (NODE_WIDTH / 2),
|
||||
y: (position?.y ?? height / 2) - (height / 2),
|
||||
},
|
||||
data: {
|
||||
node,
|
||||
selected: false,
|
||||
expanded,
|
||||
translate,
|
||||
onToggleExpanded,
|
||||
onOpenTable,
|
||||
},
|
||||
draggable: true,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
};
|
||||
}),
|
||||
edges: graph.edges.map((edge) => {
|
||||
const color = edgeColorByDirection[edge.direction];
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edge.label,
|
||||
type: 'smoothstep',
|
||||
animated: edge.direction === 'self',
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 18,
|
||||
height: 18,
|
||||
color,
|
||||
},
|
||||
style: {
|
||||
stroke: color,
|
||||
strokeWidth: edge.direction === 'self' ? 2 : 1.7,
|
||||
},
|
||||
labelStyle: {
|
||||
fill: 'var(--gn-fg-4)',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
},
|
||||
labelBgPadding: [6, 3],
|
||||
labelBgBorderRadius: 4,
|
||||
labelBgStyle: {
|
||||
fill: 'var(--gn-bg-panel-2)',
|
||||
stroke: 'var(--gn-br-1)',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const ErTableNode = memo(function ErTableNode({ data }: { data: ErDiagramNodeData }) {
|
||||
const { node, selected, expanded, translate, onToggleExpanded, onOpenTable } = data;
|
||||
const roleBadgeKey = getRoleBadgeKey(node.role);
|
||||
const canOpen = !node.isCurrent && typeof onOpenTable === 'function';
|
||||
const visibleColumns = getVisibleNodeColumns(node, expanded);
|
||||
const footerLabel = expanded
|
||||
? translate('data_grid.metadata_view.er_collapse_fields')
|
||||
: translate('data_grid.metadata_view.er_expand_hidden_columns', { count: node.hiddenColumnCount });
|
||||
const columnsScrollable = expanded && visibleColumns.length > NODE_EXPANDED_MAX_VISIBLE_ROWS;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`gn-er-node-card${selected ? ' is-selected' : ''}${node.isCurrent ? ' is-current' : ''}`}
|
||||
data-role={node.role}
|
||||
data-er-node-table={node.tableName}
|
||||
>
|
||||
<div className="gn-er-node-header">
|
||||
<div className="gn-er-node-title">
|
||||
<span className="gn-er-node-badge">{translate(roleBadgeKey)}</span>
|
||||
<strong title={node.tableName}>{node.tableName}</strong>
|
||||
</div>
|
||||
{canOpen && (
|
||||
<Tooltip title={translate('data_grid.metadata_view.er_open_table')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="gn-er-node-open nodrag nopan"
|
||||
icon={<ArrowRightOutlined />}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpenTable?.(node.tableName);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="gn-er-node-stats">
|
||||
<Tooltip title={translate('data_grid.metadata_view.er_referenced_by_badge')}>
|
||||
<span>
|
||||
<ApartmentOutlined />
|
||||
{node.incomingCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={translate('data_grid.metadata_view.er_reference_badge')}>
|
||||
<span>
|
||||
<LinkOutlined />
|
||||
{node.outgoingCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`gn-er-node-columns nodrag nopan${columnsScrollable ? ' is-scrollable' : ''}`}
|
||||
style={{ '--er-node-columns-max-height': `${getNodeColumnViewportHeight(node, expanded)}px` } as React.CSSProperties}
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<div
|
||||
key={column.name}
|
||||
className={`gn-er-node-column${column.isRelationField ? ' is-relation' : ''}`}
|
||||
data-er-node-column={column.name}
|
||||
>
|
||||
<div className="gn-er-node-column-name">
|
||||
{column.isPrimary && <em className="is-pk">PK</em>}
|
||||
{!column.isPrimary && column.isForeign && <em className="is-fk">FK</em>}
|
||||
<code title={column.comment || column.name}>{column.name}</code>
|
||||
</div>
|
||||
<span className="gn-er-node-column-type">{column.type || '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{node.hiddenColumnCount > 0 && (
|
||||
<div className="gn-er-node-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="gn-er-node-footer-toggle nodrag nopan"
|
||||
data-er-node-toggle={node.id}
|
||||
title={footerLabel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onToggleExpanded?.(node.id);
|
||||
}}
|
||||
>
|
||||
{footerLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const DataGridErDiagramCanvasInner: React.FC<{
|
||||
graph: BuildErDiagramGraphResult;
|
||||
translate: DataGridMetadataTranslate;
|
||||
expandedNodeIds: Set<string>;
|
||||
onToggleNodeExpanded: (nodeId: string) => void;
|
||||
onOpenTable?: (tableName: string) => void;
|
||||
}> = ({
|
||||
graph,
|
||||
translate,
|
||||
expandedNodeIds,
|
||||
onToggleNodeExpanded,
|
||||
onOpenTable,
|
||||
}) => {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(graph.nodes[0]?.id || null);
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedNodeId(graph.nodes[0]?.id || null);
|
||||
}, [graph.nodes]);
|
||||
|
||||
const layout = useMemo(
|
||||
() => layoutGraph(graph, translate, expandedNodeIds, onToggleNodeExpanded, onOpenTable),
|
||||
[expandedNodeIds, graph, onOpenTable, onToggleNodeExpanded, translate],
|
||||
);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layout.nodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layout.edges);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(
|
||||
layout.nodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
selected: node.id === selectedNodeId,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}, [layout.nodes, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEdges(layout.edges);
|
||||
}, [layout.edges, setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes((currentNodes) => currentNodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
selected: node.id === selectedNodeId,
|
||||
},
|
||||
})));
|
||||
}, [selectedNodeId, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
void fitView({ padding: 0.18, duration: 220 });
|
||||
}, 0);
|
||||
return () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
};
|
||||
}, [fitView, layout.edges, layout.nodes]);
|
||||
|
||||
const handleNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||
setSelectedNodeId(node.id);
|
||||
}, []);
|
||||
|
||||
const handleNodeDoubleClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||
const nodeData = node.data as ErDiagramNodeData | undefined;
|
||||
if (nodeData?.node?.isCurrent) {
|
||||
return;
|
||||
}
|
||||
nodeData?.onOpenTable?.(nodeData.node.tableName);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={{ erTable: ErTableNode }}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onPaneClick={() => setSelectedNodeId(null)}
|
||||
minZoom={0.35}
|
||||
maxZoom={1.8}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.18 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={18} size={1.2} />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
);
|
||||
};
|
||||
|
||||
const DataGridErDiagramCanvas: React.FC<{
|
||||
graph: BuildErDiagramGraphResult;
|
||||
translate: DataGridMetadataTranslate;
|
||||
expandedNodeIds: Set<string>;
|
||||
onToggleNodeExpanded: (nodeId: string) => void;
|
||||
onOpenTable?: (tableName: string) => void;
|
||||
}> = (props) => (
|
||||
<ReactFlowProvider>
|
||||
<DataGridErDiagramCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
|
||||
const DataGridErDiagram: React.FC<DataGridErDiagramProps> = ({
|
||||
connections,
|
||||
connectionId,
|
||||
dbName,
|
||||
tableName,
|
||||
translate = defaultTranslate,
|
||||
onOpenTable,
|
||||
}) => {
|
||||
const [relationDepth, setRelationDepth] = useState(1);
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set());
|
||||
const {
|
||||
graph,
|
||||
loading,
|
||||
reloading,
|
||||
error,
|
||||
partial,
|
||||
reload,
|
||||
canExpandRelations,
|
||||
} = useDataGridErDiagram({
|
||||
connections,
|
||||
connectionId,
|
||||
dbName,
|
||||
tableName,
|
||||
relationDepth,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setRelationDepth(1);
|
||||
setExpandedNodeIds(new Set());
|
||||
}, [connectionId, dbName, tableName]);
|
||||
|
||||
const expandableNodeIds = useMemo(
|
||||
() => graph?.nodes.filter((node) => node.hiddenColumnCount > 0).map((node) => node.id) || [],
|
||||
[graph],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const nextExpandableIds = new Set(expandableNodeIds);
|
||||
setExpandedNodeIds((current) => new Set(Array.from(current).filter((nodeId) => nextExpandableIds.has(nodeId))));
|
||||
}, [expandableNodeIds]);
|
||||
|
||||
const expandedFieldCount = useMemo(
|
||||
() => expandableNodeIds.filter((nodeId) => expandedNodeIds.has(nodeId)).length,
|
||||
[expandableNodeIds, expandedNodeIds],
|
||||
);
|
||||
const allFieldsExpanded = expandableNodeIds.length > 0 && expandedFieldCount === expandableNodeIds.length;
|
||||
|
||||
const handleToggleNodeExpanded = useCallback((nodeId: string) => {
|
||||
setExpandedNodeIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExpandAllFields = useCallback(() => {
|
||||
setExpandedNodeIds(new Set(expandableNodeIds));
|
||||
}, [expandableNodeIds]);
|
||||
|
||||
const handleCollapseAllFields = useCallback(() => {
|
||||
setExpandedNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
const handleExpandRelations = useCallback(() => {
|
||||
setRelationDepth((currentDepth) => currentDepth + 1);
|
||||
}, []);
|
||||
|
||||
const handleResetRelationDepth = useCallback(() => {
|
||||
setRelationDepth(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="gn-v2-data-grid-er-diagram">
|
||||
<div className="gn-v2-data-grid-er-toolbar">
|
||||
<div>
|
||||
<span>{translate('data_grid.metadata_view.er_table_badge')}</span>
|
||||
<strong>{tableName || translate('data_grid.table_fallback.query_result')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<div className="gn-v2-data-grid-er-summary">
|
||||
<span className="gn-v2-data-grid-er-chip">
|
||||
<DatabaseOutlined />
|
||||
{translate('data_grid.metadata_view.er_related_table_count', { count: graph?.relatedTableCount ?? 0 })}
|
||||
</span>
|
||||
<span className="gn-v2-data-grid-er-chip">
|
||||
<ApartmentOutlined />
|
||||
{translate('data_grid.metadata_view.er_relation_count', { count: graph?.relationCount ?? 0 })}
|
||||
</span>
|
||||
<span className="gn-v2-data-grid-er-chip">
|
||||
<LinkOutlined />
|
||||
{translate('data_grid.metadata_view.er_relation_depth', { count: relationDepth })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gn-v2-data-grid-er-actions">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
data-er-action="expand-relations"
|
||||
onClick={handleExpandRelations}
|
||||
disabled={!graph || loading || reloading || !canExpandRelations}
|
||||
>
|
||||
{translate('data_grid.metadata_view.er_expand_relations')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
data-er-action="reset-relations"
|
||||
onClick={handleResetRelationDepth}
|
||||
disabled={relationDepth <= 1}
|
||||
>
|
||||
{translate('data_grid.metadata_view.er_reset_relations')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ExpandOutlined />}
|
||||
data-er-action="expand-fields"
|
||||
onClick={handleExpandAllFields}
|
||||
disabled={expandableNodeIds.length === 0 || allFieldsExpanded}
|
||||
>
|
||||
{translate('data_grid.metadata_view.er_expand_fields')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CompressOutlined />}
|
||||
data-er-action="collapse-fields"
|
||||
onClick={handleCollapseAllFields}
|
||||
disabled={expandedFieldCount === 0}
|
||||
>
|
||||
{translate('data_grid.metadata_view.er_collapse_fields')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
data-er-action="refresh"
|
||||
onClick={reload}
|
||||
loading={reloading}
|
||||
>
|
||||
{translate('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{partial && !error && (
|
||||
<div className="gn-v2-data-grid-er-alert">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={translate('data_grid.metadata_view.er_partial_warning')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="gn-v2-data-grid-er-alert">
|
||||
<Alert type="error" showIcon message={error} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="gn-v2-data-grid-er-canvas">
|
||||
<Spin spinning={loading || reloading}>
|
||||
{graph ? (
|
||||
<>
|
||||
{graph.isEmpty && (
|
||||
<div className="gn-v2-data-grid-er-empty">
|
||||
{translate('data_grid.metadata_view.er_empty')}
|
||||
</div>
|
||||
)}
|
||||
<DataGridErDiagramCanvas
|
||||
graph={graph}
|
||||
translate={translate}
|
||||
expandedNodeIds={expandedNodeIds}
|
||||
onToggleNodeExpanded={handleToggleNodeExpanded}
|
||||
onOpenTable={onOpenTable}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="gn-v2-data-grid-er-placeholder" />
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGridErDiagram;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { CopyOutlined, EditOutlined, UndoOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
|
||||
import { t } from '../i18n';
|
||||
|
||||
interface CellContextMenuState {
|
||||
visible: boolean;
|
||||
@@ -21,6 +22,7 @@ interface DataGridLegacyCellContextMenuProps {
|
||||
copiedCellPatchAvailable: boolean;
|
||||
canUndoCellChange: boolean;
|
||||
supportsCopyInsert: boolean;
|
||||
translate?: (key: string, params?: Record<string, unknown>) => string;
|
||||
onClose: () => void;
|
||||
onCopyFieldName: () => void;
|
||||
onCopyRowData: () => void;
|
||||
@@ -55,6 +57,10 @@ const separatorStyle = (darkMode: boolean): React.CSSProperties => ({
|
||||
margin: '4px 0',
|
||||
});
|
||||
|
||||
const fallbackTranslate = (key: string, params?: Record<string, unknown>) => (
|
||||
t(key, params as Parameters<typeof t>[1])
|
||||
);
|
||||
|
||||
const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps> = ({
|
||||
visible,
|
||||
darkMode,
|
||||
@@ -66,6 +72,7 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
|
||||
copiedCellPatchAvailable,
|
||||
canUndoCellChange,
|
||||
supportsCopyInsert,
|
||||
translate = fallbackTranslate,
|
||||
onClose,
|
||||
onCopyFieldName,
|
||||
onCopyRowData,
|
||||
@@ -130,7 +137,7 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
|
||||
>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onCopyFieldName}>
|
||||
<CopyOutlined style={{ marginRight: 8 }} />
|
||||
复制字段名称
|
||||
{translate('data_grid.context_menu.copy_field_name')}
|
||||
</div>
|
||||
<div style={separatorStyle(darkMode)} />
|
||||
{canModifyData && (
|
||||
@@ -149,18 +156,18 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
|
||||
}}
|
||||
>
|
||||
<UndoOutlined style={{ marginRight: 8 }} />
|
||||
撤销此单元格修改
|
||||
{translate('data_grid.context_menu.undo_cell_change')}
|
||||
</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onSetNull}>
|
||||
设置为 NULL
|
||||
{translate('data_grid.batch_fill.set_null')}
|
||||
</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onEditRow}>
|
||||
<EditOutlined style={{ marginRight: 8 }} />
|
||||
编辑本行
|
||||
{translate('data_grid.context_menu.edit_row')}
|
||||
</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyRowForPaste)}>
|
||||
<CopyOutlined style={{ marginRight: 8 }} />
|
||||
复制本行为新增行
|
||||
{translate('data_grid.context_menu.copy_row_as_new')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -177,7 +184,9 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
|
||||
}}
|
||||
>
|
||||
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
|
||||
{canPasteRows ? `粘贴为新增行 (${copiedRowsForPasteLength})` : '粘贴为新增行'}
|
||||
{canPasteRows
|
||||
? translate('data_grid.context_menu.paste_row_as_new_count', { count: copiedRowsForPasteLength })
|
||||
: translate('data_grid.context_menu.paste_row_as_new')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -191,7 +200,7 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
|
||||
}}
|
||||
>
|
||||
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
|
||||
填充到选中行 ({selectedRowKeysLength})
|
||||
{translate('data_grid.context_menu.fill_to_selected_rows', { count: selectedRowKeysLength })}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -205,30 +214,30 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
|
||||
}}
|
||||
>
|
||||
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
|
||||
粘贴已复制列(同名列)
|
||||
{translate('data_grid.context_menu.paste_copied_columns')}
|
||||
</div>
|
||||
<div style={separatorStyle(darkMode)} />
|
||||
</>
|
||||
)}
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyRowData)}>
|
||||
<CopyOutlined style={{ marginRight: 8 }} />
|
||||
复制行数据
|
||||
{translate('data_grid.context_menu.copy_row_data')}
|
||||
</div>
|
||||
{supportsCopyInsert && (
|
||||
<>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyInsert)}>复制为 INSERT</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyUpdate)}>复制为 UPDATE</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyDelete)}>复制为 DELETE</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyInsert)}>{translate('data_grid.context_menu.copy_as_insert')}</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyUpdate)}>{translate('data_grid.context_menu.copy_as_update')}</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyDelete)}>{translate('data_grid.context_menu.copy_as_delete')}</div>
|
||||
</>
|
||||
)}
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyJson)}>复制为 JSON</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyCsv)}>复制为 CSV</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyMarkdown)}>复制为 Markdown</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyJson)}>{translate('data_grid.context_menu.copy_as_json')}</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyCsv)}>{translate('data_grid.context_menu.copy_as_csv')}</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyMarkdown)}>{translate('data_grid.context_menu.copy_as_markdown')}</div>
|
||||
<div style={separatorStyle(darkMode)} />
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportCsv)}>导出为 CSV</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportXlsx)}>导出为 Excel</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportJson)}>导出为 JSON</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportHtml)}>导出为 HTML</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportCsv)}>{translate('data_grid.context_menu.export_as_csv')}</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportXlsx)}>{translate('data_grid.context_menu.export_as_excel')}</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportJson)}>{translate('data_grid.context_menu.export_as_json')}</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportHtml)}>{translate('data_grid.context_menu.export_as_html')}</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React from 'react';
|
||||
import { Button, Checkbox, DatePicker, Form, Input, Modal, TimePicker } from 'antd';
|
||||
import { Button, Checkbox, DatePicker, Form, Input, TimePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import Editor from './MonacoEditor';
|
||||
@@ -8,6 +9,9 @@ import {
|
||||
getTemporalPickerType,
|
||||
type TemporalPickerType,
|
||||
} from './dataGridTemporal';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
export type DataGridModalsTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
type ColumnMeta = {
|
||||
type: string;
|
||||
@@ -28,6 +32,7 @@ export interface DataGridRowEditorField {
|
||||
export interface DataGridModalsProps {
|
||||
tableName?: string;
|
||||
darkMode: boolean;
|
||||
translate?: DataGridModalsTranslate;
|
||||
displayColumnNames: string[];
|
||||
rowEditorOpen: boolean;
|
||||
rowEditorRowKey: string;
|
||||
@@ -68,6 +73,7 @@ export interface DataGridModalsProps {
|
||||
const DataGridModals: React.FC<DataGridModalsProps> = ({
|
||||
tableName,
|
||||
darkMode,
|
||||
translate = defaultTranslate,
|
||||
rowEditorOpen,
|
||||
rowEditorRowKey,
|
||||
rowEditorForm,
|
||||
@@ -105,15 +111,15 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
|
||||
}) => (
|
||||
<>
|
||||
<Modal
|
||||
title="编辑行"
|
||||
title={translate('data_grid.row_editor.title')}
|
||||
open={rowEditorOpen}
|
||||
onCancel={onCloseRowEditor}
|
||||
width={980}
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCloseRowEditor}>取消</Button>,
|
||||
<Button key="ok" type="primary" onClick={onApplyRowEditor}>应用</Button>,
|
||||
<Button key="cancel" onClick={onCloseRowEditor}>{translate('common.cancel')}</Button>,
|
||||
<Button key="ok" type="primary" onClick={onApplyRowEditor}>{translate('data_grid.action.apply')}</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 8, color: '#888', fontSize: 12, display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
@@ -168,7 +174,7 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => onOpenRowEditorFieldEditor(field.columnName)}
|
||||
title="弹窗编辑"
|
||||
title={translate('data_grid.row_editor.popup_edit')}
|
||||
disabled={!field.isWritable}
|
||||
>
|
||||
...
|
||||
@@ -181,16 +187,20 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={cellEditorMeta ? `编辑单元格:${cellEditorMeta.title}` : '编辑单元格'}
|
||||
title={
|
||||
cellEditorMeta
|
||||
? translate('data_grid.cell_editor.title_with_column', { column: cellEditorMeta.title })
|
||||
: translate('data_grid.cell_editor.title')
|
||||
}
|
||||
open={cellEditorOpen}
|
||||
onCancel={onCloseCellEditor}
|
||||
destroyOnHidden
|
||||
width={960}
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="format" onClick={onFormatJsonInEditor} disabled={!cellEditorIsJson}>格式化 JSON</Button>,
|
||||
<Button key="cancel" onClick={onCloseCellEditor}>取消</Button>,
|
||||
<Button key="ok" type="primary" onClick={onSaveCellEditor}>保存</Button>,
|
||||
<Button key="format" onClick={onFormatJsonInEditor} disabled={!cellEditorIsJson}>{translate('data_grid.json_editor.format')}</Button>,
|
||||
<Button key="cancel" onClick={onCloseCellEditor}>{translate('common.cancel')}</Button>,
|
||||
<Button key="ok" type="primary" onClick={onSaveCellEditor}>{translate('common.save')}</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
|
||||
@@ -216,22 +226,25 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`批量填充 (${selectedCellsSize} 个单元格)`}
|
||||
title={translate('data_grid.batch_fill.title', { count: selectedCellsSize })}
|
||||
open={batchEditModalOpen}
|
||||
onCancel={onCloseBatchEditModal}
|
||||
onOk={onApplyBatchFill}
|
||||
width={500}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCloseBatchEditModal}>{translate('common.cancel')}</Button>,
|
||||
<Button key="ok" type="primary" onClick={onApplyBatchFill}>{translate('data_grid.action.apply')}</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Checkbox checked={batchEditSetNull} onChange={(event) => onBatchEditSetNullChange(event.target.checked)}>
|
||||
设置为 NULL
|
||||
{translate('data_grid.batch_fill.set_null')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
{!batchEditSetNull && (
|
||||
<Input.TextArea
|
||||
value={batchEditValue}
|
||||
onChange={(event) => onBatchEditValueChange(event.target.value)}
|
||||
placeholder="输入要填充的值"
|
||||
placeholder={translate('data_grid.batch_fill.value_placeholder')}
|
||||
autoSize={{ minRows: 3, maxRows: 10 }}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -239,20 +252,20 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑 JSON 结果集"
|
||||
title={translate('data_grid.json_editor.title')}
|
||||
open={jsonEditorOpen}
|
||||
onCancel={onCloseJsonEditor}
|
||||
destroyOnHidden
|
||||
width={980}
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="format" onClick={onFormatJsonEditor}>格式化 JSON</Button>,
|
||||
<Button key="cancel" onClick={onCloseJsonEditor}>取消</Button>,
|
||||
<Button key="ok" type="primary" onClick={onApplyJsonEditor}>应用修改</Button>,
|
||||
<Button key="format" onClick={onFormatJsonEditor}>{translate('data_grid.json_editor.format')}</Button>,
|
||||
<Button key="cancel" onClick={onCloseJsonEditor}>{translate('common.cancel')}</Button>,
|
||||
<Button key="ok" type="primary" onClick={onApplyJsonEditor}>{translate('data_grid.json_editor.apply_changes')}</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
|
||||
说明:此处按当前结果集顺序编辑,不支持在 JSON 模式增删记录(可在表格模式操作)。
|
||||
{translate('data_grid.json_editor.description')}
|
||||
</div>
|
||||
{jsonEditorOpen && (
|
||||
<Editor
|
||||
@@ -282,10 +295,10 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
|
||||
width={960}
|
||||
footer={[
|
||||
<Button key="copy" icon={<CopyOutlined />} onClick={onCopyDdl} disabled={!ddlText.trim()}>
|
||||
复制 DDL
|
||||
{translate('data_grid.ddl.copy')}
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={onCloseDdlModal}>
|
||||
关闭
|
||||
{translate('common.close')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
@@ -294,7 +307,7 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
|
||||
height="56vh"
|
||||
language="sql"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={ddlLoading ? '正在加载 DDL...' : ddlText}
|
||||
value={ddlLoading ? translate('data_grid.ddl.loading') : ddlText}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Button, Input, Tooltip } from 'antd';
|
||||
import { LeftOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
export type DataGridPageFindTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
export interface DataGridPageFindProps {
|
||||
isV2Ui: boolean;
|
||||
@@ -17,6 +20,7 @@ export interface DataGridPageFindProps {
|
||||
onCancel: () => void;
|
||||
onNavigatePrevious: () => void;
|
||||
onNavigateNext: () => void;
|
||||
translate?: DataGridPageFindTranslate;
|
||||
}
|
||||
|
||||
const DataGridPageFind: React.FC<DataGridPageFindProps> = ({
|
||||
@@ -34,67 +38,75 @@ const DataGridPageFind: React.FC<DataGridPageFindProps> = ({
|
||||
onCancel,
|
||||
onNavigatePrevious,
|
||||
onNavigateNext,
|
||||
}) => (
|
||||
<Tooltip title="仅查找当前页已加载数据,不改变 WHERE 条件">
|
||||
<div
|
||||
data-grid-page-find="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find' : undefined}
|
||||
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'nowrap', height: 32 }}
|
||||
>
|
||||
<Input
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find-input' : undefined}
|
||||
{...inputProps}
|
||||
allowClear
|
||||
size="small"
|
||||
variant="borderless"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="当前页查找..."
|
||||
value={pageFindText}
|
||||
onChange={(event) => onPageFindTextChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
style={isV2Ui ? undefined : { width: 168, height: 32 }}
|
||||
/>
|
||||
<Button
|
||||
data-grid-page-find-prev="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find-prev' : undefined}
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={!hasMatches}
|
||||
onClick={onNavigatePrevious}
|
||||
style={isV2Ui ? undefined : { height: 32, minWidth: 32, paddingInline: 8 }}
|
||||
/>
|
||||
<Button
|
||||
data-grid-page-find-next="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find-next' : undefined}
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
disabled={!hasMatches}
|
||||
onClick={onNavigateNext}
|
||||
style={isV2Ui ? undefined : { height: 32, minWidth: 32, paddingInline: 8 }}
|
||||
/>
|
||||
{normalizedPageFindText && (
|
||||
<span
|
||||
aria-live="polite"
|
||||
style={isV2Ui ? undefined : {
|
||||
fontSize: 12,
|
||||
color: darkMode ? '#999' : '#666',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'left',
|
||||
flex: '0 1 auto',
|
||||
translate = defaultTranslate,
|
||||
}) => {
|
||||
const summaryText = translate('data_grid.page_find.summary', {
|
||||
occurrences: occurrenceCount,
|
||||
cells: matchedCellCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip title={translate('data_grid.page_find.tooltip')}>
|
||||
<div
|
||||
data-grid-page-find="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find' : undefined}
|
||||
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'nowrap', height: 32 }}
|
||||
>
|
||||
<Input
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find-input' : undefined}
|
||||
{...inputProps}
|
||||
allowClear
|
||||
size="small"
|
||||
variant="borderless"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={translate('data_grid.page_find.placeholder')}
|
||||
value={pageFindText}
|
||||
onChange={(event) => onPageFindTextChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasMatches ? `${activePageFindPosition} / ${matchCount} · ` : ''}匹配 {occurrenceCount} 处 / {matchedCellCount} 个单元格
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
style={isV2Ui ? undefined : { width: 168, height: 32 }}
|
||||
/>
|
||||
<Button
|
||||
data-grid-page-find-prev="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find-prev' : undefined}
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={!hasMatches}
|
||||
onClick={onNavigatePrevious}
|
||||
style={isV2Ui ? undefined : { height: 32, minWidth: 32, paddingInline: 8 }}
|
||||
/>
|
||||
<Button
|
||||
data-grid-page-find-next="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find-next' : undefined}
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
disabled={!hasMatches}
|
||||
onClick={onNavigateNext}
|
||||
style={isV2Ui ? undefined : { height: 32, minWidth: 32, paddingInline: 8 }}
|
||||
/>
|
||||
{normalizedPageFindText && (
|
||||
<span
|
||||
aria-live="polite"
|
||||
style={isV2Ui ? undefined : {
|
||||
fontSize: 12,
|
||||
color: darkMode ? '#999' : '#666',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'left',
|
||||
flex: '0 1 auto',
|
||||
}}
|
||||
>
|
||||
{hasMatches ? `${activePageFindPosition} / ${matchCount} · ` : ''}{summaryText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGridPageFind;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Button, InputNumber, Pagination, Select } from 'antd';
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
interface DataGridPaginationState {
|
||||
current: number;
|
||||
@@ -13,6 +14,8 @@ interface DataGridPaginationState {
|
||||
totalCountCancelled?: boolean;
|
||||
}
|
||||
|
||||
export type DataGridPaginationTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
export interface DataGridPaginationBarProps {
|
||||
isV2Ui: boolean;
|
||||
pagination?: DataGridPaginationState;
|
||||
@@ -20,10 +23,13 @@ export interface DataGridPaginationBarProps {
|
||||
paginationSummaryText: string;
|
||||
paginationControlTotal: number;
|
||||
paginationTotalPages: number;
|
||||
paginationPageText: string;
|
||||
paginationPageSizeOptions: string[];
|
||||
showKnownPageCount: boolean;
|
||||
onPageChange?: (page: number, size: number) => void;
|
||||
onPageSizeChange: (value: string) => void;
|
||||
onV2PageStep: (direction: 'previous' | 'next') => void;
|
||||
translate?: DataGridPaginationTranslate;
|
||||
}
|
||||
|
||||
const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
||||
@@ -33,12 +39,16 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
||||
paginationSummaryText,
|
||||
paginationControlTotal,
|
||||
paginationTotalPages,
|
||||
paginationPageText,
|
||||
paginationPageSizeOptions,
|
||||
showKnownPageCount,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onV2PageStep,
|
||||
translate = defaultTranslate,
|
||||
}) => {
|
||||
const [jumpPage, setJumpPage] = React.useState<number | null>(pagination?.current ?? null);
|
||||
const showSequentialPagination = !showKnownPageCount;
|
||||
|
||||
React.useEffect(() => {
|
||||
setJumpPage(pagination?.current ?? null);
|
||||
@@ -48,9 +58,11 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxJumpPage = Math.max(1, paginationTotalPages);
|
||||
const maxJumpPage = showKnownPageCount ? Math.max(1, paginationTotalPages) : null;
|
||||
const normalizedJumpPage = Number.isFinite(Number(jumpPage)) && Number(jumpPage) > 0
|
||||
? Math.min(maxJumpPage, Math.max(1, Math.trunc(Number(jumpPage))))
|
||||
? (maxJumpPage !== null
|
||||
? Math.min(maxJumpPage, Math.max(1, Math.trunc(Number(jumpPage))))
|
||||
: Math.max(1, Math.trunc(Number(jumpPage))))
|
||||
: null;
|
||||
const jumpDisabled = !onPageChange || normalizedJumpPage === null || normalizedJumpPage === pagination.current;
|
||||
const submitJumpPage = () => {
|
||||
@@ -60,18 +72,18 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
||||
};
|
||||
const jumpPageControl = (
|
||||
<div className="data-grid-pagination-jump" data-grid-pagination-jump="true">
|
||||
<span className="data-grid-pagination-jump-label">跳页</span>
|
||||
<span className="data-grid-pagination-jump-label">{translate('data_grid.pagination.jump_label')}</span>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={maxJumpPage}
|
||||
max={maxJumpPage ?? undefined}
|
||||
precision={0}
|
||||
controls={false}
|
||||
value={jumpPage}
|
||||
onChange={(value) => setJumpPage(typeof value === 'number' && Number.isFinite(value) ? value : null)}
|
||||
onPressEnter={submitJumpPage}
|
||||
className="data-grid-pagination-jump-input"
|
||||
aria-label="跳转页码"
|
||||
aria-label={translate('data_grid.pagination.jump_aria')}
|
||||
disabled={!onPageChange}
|
||||
/>
|
||||
<Button
|
||||
@@ -80,10 +92,35 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
||||
disabled={jumpDisabled}
|
||||
onClick={submitJumpPage}
|
||||
>
|
||||
跳
|
||||
{translate('data_grid.pagination.jump_action')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
const sequentialPaginationControl = (
|
||||
<div
|
||||
className="data-grid-pagination-sequential"
|
||||
data-grid-pagination-sequential="true"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
|
||||
>
|
||||
<Button
|
||||
data-grid-pagination-prev="true"
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={!onPageChange || pagination.current <= 1}
|
||||
onClick={() => onV2PageStep('previous')}
|
||||
/>
|
||||
<div className="data-grid-pagination-page-chip" data-grid-page-chip="true">
|
||||
<span>{paginationPageText}</span>
|
||||
</div>
|
||||
<Button
|
||||
data-grid-pagination-next="true"
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
disabled={!onPageChange || pagination.current >= paginationTotalPages}
|
||||
onClick={() => onV2PageStep('next')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -103,9 +140,15 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
||||
onClick={() => onV2PageStep('previous')}
|
||||
/>
|
||||
<div className="data-grid-pagination-page-chip" data-grid-v2-page-chip="true">
|
||||
<strong>{pagination.current}</strong>
|
||||
<span>/</span>
|
||||
<span>{paginationTotalPages}</span>
|
||||
{showKnownPageCount ? (
|
||||
<>
|
||||
<strong>{pagination.current}</strong>
|
||||
<span>/</span>
|
||||
<span>{paginationTotalPages}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{paginationPageText}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
data-grid-v2-pagination-next="true"
|
||||
@@ -120,44 +163,46 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
||||
popupMatchSelectWidth={false}
|
||||
value={String(pagination.pageSize)}
|
||||
onChange={onPageSizeChange}
|
||||
options={paginationPageSizeOptions.map((value) => ({ value, label: `${value}/页` }))}
|
||||
options={paginationPageSizeOptions.map((value) => ({ value, label: translate('data_grid.pagination.page_size_option', { count: value }) }))}
|
||||
className="data-grid-pagination-size-select"
|
||||
aria-label="每页条数"
|
||||
aria-label={translate('data_grid.pagination.page_size_aria')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="data-grid-pagination-shell">
|
||||
<div className="data-grid-pagination-summary" aria-live="polite">
|
||||
<span className="data-grid-pagination-kicker">结果集</span>
|
||||
<span className="data-grid-pagination-kicker">{translate('data_grid.pagination.result_set')}</span>
|
||||
<span className="data-grid-pagination-summary-value">{paginationSummaryText}</span>
|
||||
</div>
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={paginationControlTotal}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
showTitle={false}
|
||||
size="small"
|
||||
itemRender={(_page, type, originalElement) => {
|
||||
if (type === 'prev') {
|
||||
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><LeftOutlined /></span>;
|
||||
}
|
||||
if (type === 'next') {
|
||||
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><RightOutlined /></span>;
|
||||
}
|
||||
return originalElement;
|
||||
}}
|
||||
/>
|
||||
{showSequentialPagination ? sequentialPaginationControl : (
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={paginationControlTotal}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
showTitle={false}
|
||||
size="small"
|
||||
itemRender={(_page, type, originalElement) => {
|
||||
if (type === 'prev') {
|
||||
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><LeftOutlined /></span>;
|
||||
}
|
||||
if (type === 'next') {
|
||||
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><RightOutlined /></span>;
|
||||
}
|
||||
return originalElement;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{jumpPageControl}
|
||||
<Select
|
||||
size="small"
|
||||
popupMatchSelectWidth={false}
|
||||
value={String(pagination.pageSize)}
|
||||
onChange={onPageSizeChange}
|
||||
options={paginationPageSizeOptions.map((value) => ({ value, label: `${value} 条 / 页` }))}
|
||||
options={paginationPageSizeOptions.map((value) => ({ value, label: translate('data_grid.pagination.page_size_option', { count: value }) }))}
|
||||
className="data-grid-pagination-size-select"
|
||||
aria-label="每页条数"
|
||||
aria-label={translate('data_grid.pagination.page_size_aria')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import Editor from './MonacoEditor';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
type ColumnMeta = {
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type DataGridPreviewPanelTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
interface DataGridPreviewPanelProps {
|
||||
visible: boolean;
|
||||
isTableSurfaceActive: boolean;
|
||||
@@ -16,6 +19,7 @@ interface DataGridPreviewPanelProps {
|
||||
dataPanelValue: string;
|
||||
columnMetaMap: Record<string, ColumnMeta>;
|
||||
columnMetaMapByLowerName: Record<string, ColumnMeta>;
|
||||
translate?: DataGridPreviewPanelTranslate;
|
||||
onFormatJson: () => void;
|
||||
onSave: () => void;
|
||||
onValueChange: (value: string) => void;
|
||||
@@ -33,6 +37,7 @@ const DataGridPreviewPanel: React.FC<DataGridPreviewPanelProps> = ({
|
||||
dataPanelValue,
|
||||
columnMetaMap,
|
||||
columnMetaMapByLowerName,
|
||||
translate = defaultTranslate,
|
||||
onFormatJson,
|
||||
onSave,
|
||||
onValueChange,
|
||||
@@ -71,15 +76,15 @@ const DataGridPreviewPanel: React.FC<DataGridPreviewPanelProps> = ({
|
||||
}}
|
||||
>
|
||||
<span style={{ color: darkMode ? '#aaa' : '#666', fontWeight: 500 }}>
|
||||
{focusedCellInfo ? focusedCellInfo.dataIndex : '点击单元格查看数据'}
|
||||
{focusedCellInfo ? focusedCellInfo.dataIndex : translate('data_grid.preview_panel.no_cell_title')}
|
||||
</span>
|
||||
{meta?.type ? <span style={{ color: '#888', fontSize: 11 }}>({meta.type})</span> : null}
|
||||
<div style={{ flex: 1 }} />
|
||||
{dataPanelIsJson && (
|
||||
<Button size="small" onClick={onFormatJson}>格式化 JSON</Button>
|
||||
<Button size="small" onClick={onFormatJson}>{translate('data_grid.json_editor.format')}</Button>
|
||||
)}
|
||||
{focusedCellWritable && (
|
||||
<Button size="small" type="primary" onClick={onSave}>保存</Button>
|
||||
<Button size="small" type="primary" onClick={onSave}>{translate('common.save')}</Button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
@@ -120,7 +125,7 @@ const DataGridPreviewPanel: React.FC<DataGridPreviewPanelProps> = ({
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
点击表格中的单元格以预览完整数据
|
||||
{translate('data_grid.preview_panel.no_cell_description')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import Editor from './MonacoEditor';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
export type DataGridRecordViewTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
interface DataGridJsonViewProps {
|
||||
darkMode: boolean;
|
||||
rowCount: number;
|
||||
canModifyData: boolean;
|
||||
jsonViewText: string;
|
||||
translate?: DataGridRecordViewTranslate;
|
||||
onOpenJsonEditor: () => void;
|
||||
}
|
||||
|
||||
@@ -15,16 +19,19 @@ export const DataGridJsonView: React.FC<DataGridJsonViewProps> = ({
|
||||
rowCount,
|
||||
canModifyData,
|
||||
jsonViewText,
|
||||
translate = defaultTranslate,
|
||||
onOpenJsonEditor,
|
||||
}) => (
|
||||
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 10px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
|
||||
{rowCount === 0 ? '当前结果集无数据' : `当前结果集 ${rowCount} 条记录`}
|
||||
{rowCount === 0
|
||||
? translate('data_grid.record_view.empty')
|
||||
: translate('data_grid.record_view.json_record_count', { count: rowCount })}
|
||||
</span>
|
||||
{canModifyData && (
|
||||
<Button size="small" type="primary" onClick={onOpenJsonEditor} disabled={rowCount === 0}>
|
||||
编辑 JSON
|
||||
{translate('data_grid.record_view.edit_json')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -56,6 +63,11 @@ interface DataGridTextViewProps {
|
||||
canModifyData: boolean;
|
||||
currentTextRow: Record<string, any> | null;
|
||||
displayOutputColumnNames: string[];
|
||||
columnMetaMap?: Record<string, { type?: string; comment?: string }>;
|
||||
columnMetaMapByLowerName?: Record<string, { type?: string; comment?: string }>;
|
||||
showColumnType?: boolean;
|
||||
showColumnComment?: boolean;
|
||||
translate?: DataGridRecordViewTranslate;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onEditCurrent: () => void;
|
||||
@@ -69,43 +81,72 @@ export const DataGridTextView: React.FC<DataGridTextViewProps> = ({
|
||||
canModifyData,
|
||||
currentTextRow,
|
||||
displayOutputColumnNames,
|
||||
columnMetaMap = {},
|
||||
columnMetaMapByLowerName = {},
|
||||
showColumnType = true,
|
||||
showColumnComment = true,
|
||||
translate = defaultTranslate,
|
||||
onPrev,
|
||||
onNext,
|
||||
onEditCurrent,
|
||||
formatTextViewValue,
|
||||
}) => (
|
||||
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Button size="small" onClick={onPrev} disabled={rowCount === 0 || textRecordIndex <= 0}>
|
||||
上一条
|
||||
</Button>
|
||||
<Button size="small" onClick={onNext} disabled={rowCount === 0 || textRecordIndex >= rowCount - 1}>
|
||||
下一条
|
||||
</Button>
|
||||
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
|
||||
{rowCount === 0 ? '当前结果集无数据' : `记录 ${textRecordIndex + 1} / ${rowCount}`}
|
||||
</span>
|
||||
{canModifyData && (
|
||||
<Button size="small" type="primary" onClick={onEditCurrent} disabled={rowCount === 0}>
|
||||
编辑当前记录
|
||||
}) => {
|
||||
const metaTextColor = darkMode ? 'rgba(255,255,255,0.52)' : 'rgba(0,0,0,0.48)';
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Button size="small" onClick={onPrev} disabled={rowCount === 0 || textRecordIndex <= 0}>
|
||||
{translate('data_grid.record_view.previous')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
|
||||
{currentTextRow ? displayOutputColumnNames.map((col) => (
|
||||
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
|
||||
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)', wordBreak: 'break-all' }}>
|
||||
{col} :
|
||||
<Button size="small" onClick={onNext} disabled={rowCount === 0 || textRecordIndex >= rowCount - 1}>
|
||||
{translate('data_grid.record_view.next')}
|
||||
</Button>
|
||||
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
|
||||
{rowCount === 0
|
||||
? translate('data_grid.record_view.empty')
|
||||
: translate('data_grid.record_view.record_position', { current: textRecordIndex + 1, total: rowCount })}
|
||||
</span>
|
||||
{canModifyData && (
|
||||
<Button size="small" type="primary" onClick={onEditCurrent} disabled={rowCount === 0}>
|
||||
{translate('data_grid.record_view.edit_current')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
|
||||
{currentTextRow ? displayOutputColumnNames.map((col) => {
|
||||
const columnMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()];
|
||||
const columnType = String(columnMeta?.type || '').trim();
|
||||
const columnComment = String(columnMeta?.comment || '').trim();
|
||||
|
||||
return (
|
||||
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
|
||||
<div style={{ wordBreak: 'break-all' }}>
|
||||
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)' }}>
|
||||
{col} :
|
||||
</div>
|
||||
{showColumnType && columnType && (
|
||||
<div style={{ marginTop: 3, fontSize: 11, lineHeight: 1.35, color: metaTextColor }}>
|
||||
{translate('data_grid.column.type_tooltip', { type: columnType })}
|
||||
</div>
|
||||
)}
|
||||
{showColumnComment && columnComment && (
|
||||
<div style={{ marginTop: 2, fontSize: 11, lineHeight: 1.35, color: metaTextColor }}>
|
||||
{translate('data_grid.column.comment_tooltip', { comment: columnComment })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)' }}>
|
||||
{formatTextViewValue(currentTextRow[col], col)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div style={{ fontSize: 12, color: darkMode ? '#999' : '#666', paddingTop: 4 }}>
|
||||
{translate('data_grid.record_view.empty')}
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)' }}>
|
||||
{formatTextViewValue(currentTextRow[col], col)}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div style={{ fontSize: 12, color: darkMode ? '#999' : '#666', paddingTop: 4 }}>
|
||||
当前结果集无数据
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Segmented } from 'antd';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er';
|
||||
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er' | 'sqlLog';
|
||||
|
||||
export type DataGridResultViewTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
export interface DataGridResultViewSwitcherProps {
|
||||
isV2Ui: boolean;
|
||||
darkMode: boolean;
|
||||
viewMode: GridViewMode;
|
||||
onViewModeChange: (nextMode: GridViewMode) => void;
|
||||
translate?: DataGridResultViewTranslate;
|
||||
}
|
||||
|
||||
const DataGridResultViewSwitcher: React.FC<DataGridResultViewSwitcherProps> = ({
|
||||
@@ -15,20 +19,21 @@ const DataGridResultViewSwitcher: React.FC<DataGridResultViewSwitcherProps> = ({
|
||||
darkMode,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
translate = defaultTranslate,
|
||||
}) => (
|
||||
<div
|
||||
data-grid-view-switcher="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-result-switcher' : undefined}
|
||||
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 8 }}
|
||||
>
|
||||
<span style={isV2Ui ? undefined : { fontSize: 12, color: darkMode ? '#999' : '#666' }}>结果视图</span>
|
||||
<span style={isV2Ui ? undefined : { fontSize: 12, color: darkMode ? '#999' : '#666' }}>{translate('data_grid.view.result_view')}</span>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode === 'json' || viewMode === 'text' ? viewMode : 'table'}
|
||||
options={[
|
||||
{ label: '表格', value: 'table' },
|
||||
{ label: translate('data_grid.view.table'), value: 'table' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: '文本', value: 'text' },
|
||||
{ label: translate('data_grid.view.text'), value: 'text' },
|
||||
]}
|
||||
onChange={(value) => onViewModeChange(String(value) as GridViewMode)}
|
||||
/>
|
||||
|
||||
@@ -2,14 +2,18 @@ import React from 'react';
|
||||
import { Button, Popover } from 'antd';
|
||||
import {
|
||||
AimOutlined,
|
||||
BugOutlined,
|
||||
ConsoleSqlOutlined,
|
||||
EditOutlined,
|
||||
FileTextOutlined,
|
||||
LinkOutlined,
|
||||
TableOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er';
|
||||
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er' | 'sqlLog';
|
||||
|
||||
export type DataGridSecondaryActionsTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
export interface DataGridSecondaryActionsProps {
|
||||
isV2Ui: boolean;
|
||||
@@ -31,6 +35,7 @@ export interface DataGridSecondaryActionsProps {
|
||||
isTableSurfaceActive: boolean;
|
||||
onToggleDataPanel: () => void;
|
||||
onOpenTableDdl: () => void;
|
||||
translate?: DataGridSecondaryActionsTranslate;
|
||||
}
|
||||
|
||||
const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
|
||||
@@ -53,15 +58,19 @@ const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
|
||||
isTableSurfaceActive,
|
||||
onToggleDataPanel,
|
||||
onOpenTableDdl,
|
||||
translate = defaultTranslate,
|
||||
}) => {
|
||||
if (isV2Ui) {
|
||||
const fieldsActionLabel = canOpenObjectDesigner ? '对象设计' : '字段信息';
|
||||
const fieldsActionLabel = canOpenObjectDesigner
|
||||
? translate('data_grid.secondary.object_design')
|
||||
: translate('data_grid.column_settings.field_info');
|
||||
const fieldsActionIcon = canOpenObjectDesigner ? <EditOutlined /> : <FileTextOutlined />;
|
||||
const viewTabItems: Array<{ key: GridViewMode; label: string; icon: React.ReactNode; disabled?: boolean }> = [
|
||||
{ key: 'table', label: '数据预览', icon: <TableOutlined /> },
|
||||
{ key: 'table', label: translate('data_grid.secondary.data_preview'), icon: <TableOutlined /> },
|
||||
{ key: 'fields', label: fieldsActionLabel, icon: fieldsActionIcon },
|
||||
{ key: 'ddl', label: '查看 DDL', icon: <ConsoleSqlOutlined />, disabled: !canViewDdl },
|
||||
{ key: 'er', label: 'ER 图', icon: <LinkOutlined /> },
|
||||
{ key: 'ddl', label: translate('data_grid.secondary.view_ddl'), icon: <ConsoleSqlOutlined />, disabled: !canViewDdl },
|
||||
{ key: 'er', label: translate('data_grid.secondary.er_diagram'), icon: <LinkOutlined /> },
|
||||
{ key: 'sqlLog', label: translate('log_panel.short_title'), icon: <BugOutlined /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -98,7 +107,7 @@ const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
|
||||
type={showColumnComment || showColumnType ? 'primary' : 'text'}
|
||||
icon={<FileTextOutlined />}
|
||||
>
|
||||
字段显示
|
||||
{translate('data_grid.secondary.column_display')}
|
||||
</Button>
|
||||
</Popover>
|
||||
<Popover trigger="click" placement="topRight" content={<div style={{ padding: 4 }}>{columnQuickFindContent}</div>}>
|
||||
@@ -108,14 +117,14 @@ const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
|
||||
type="text"
|
||||
icon={<AimOutlined />}
|
||||
>
|
||||
跳列
|
||||
{translate('data_grid.secondary.jump_column')}
|
||||
</Button>
|
||||
</Popover>
|
||||
{pageFindContent}
|
||||
<div className="gn-v2-data-grid-status-center">
|
||||
<span className="gn-v2-data-grid-live">live</span>
|
||||
<span>{mergedDisplayCount} 行</span>
|
||||
<span>未提交 {pendingChangeCount}</span>
|
||||
<span className="gn-v2-data-grid-live">{translate('data_grid.secondary.live')}</span>
|
||||
<span>{translate('data_grid.secondary.row_count', { count: mergedDisplayCount })}</span>
|
||||
<span>{translate('data_grid.secondary.pending_changes', { count: pendingChangeCount })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gn-v2-data-grid-status-right">
|
||||
@@ -154,10 +163,10 @@ const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
|
||||
disabled={!isTableSurfaceActive}
|
||||
onClick={onToggleDataPanel}
|
||||
>
|
||||
数据预览
|
||||
{translate('data_grid.secondary.data_preview')}
|
||||
</Button>
|
||||
<Popover trigger="click" placement="bottomRight" content={columnInfoSettingContent}>
|
||||
<Button data-grid-column-display-action="true" icon={<FileTextOutlined />}>字段信息</Button>
|
||||
<Button data-grid-column-display-action="true" icon={<FileTextOutlined />}>{translate('data_grid.column_settings.field_info')}</Button>
|
||||
</Popover>
|
||||
{canViewDdl && (
|
||||
<Button
|
||||
@@ -166,7 +175,7 @@ const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
|
||||
loading={ddlLoading}
|
||||
onClick={onOpenTableDdl}
|
||||
>
|
||||
查看 DDL
|
||||
{translate('data_grid.secondary.view_ddl')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
1134
frontend/src/components/DataGridShell.tsx
Normal file
1134
frontend/src/components/DataGridShell.tsx
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/src/components/DataGridToolbarFrame.i18n.test.ts
Normal file
26
frontend/src/components/DataGridToolbarFrame.i18n.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const toolbarSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGridToolbarFrame i18n guards', () => {
|
||||
it('localizes data edit commit mode controls', () => {
|
||||
[
|
||||
'data_grid.toolbar.commit_mode.tooltip',
|
||||
'data_grid.toolbar.commit_mode.manual',
|
||||
'data_grid.toolbar.commit_mode.auto',
|
||||
'data_grid.toolbar.commit_mode.auto_countdown',
|
||||
].forEach((key) => {
|
||||
expect(toolbarSource).toContain(`translate('${key}'`);
|
||||
});
|
||||
|
||||
[
|
||||
'控制表数据编辑后的提交方式',
|
||||
"label: '手动提交'",
|
||||
"label: '自动提交'",
|
||||
's 后提交',
|
||||
].forEach((legacyText) => {
|
||||
expect(toolbarSource).not.toContain(legacyText);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export interface DataGridToolbarFrameProps {
|
||||
isV2Ui: boolean;
|
||||
tableName?: string;
|
||||
dbName?: string;
|
||||
translate?: (key: string, params?: Record<string, string | number>) => string;
|
||||
loading: boolean;
|
||||
darkMode: boolean;
|
||||
bgFilter: string;
|
||||
@@ -92,7 +93,7 @@ export interface DataGridToolbarFrameProps {
|
||||
noAutoCapInputProps: Record<string, unknown>;
|
||||
filterFieldSelectStyle: React.CSSProperties;
|
||||
filterFieldPopupWidth: number;
|
||||
exportMenu: MenuProps['items'];
|
||||
onOpenExportModal: () => void;
|
||||
queryResultCopyMenu: MenuProps['items'];
|
||||
dbType: string;
|
||||
onResetPendingChanges: () => void;
|
||||
@@ -141,6 +142,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
isV2Ui,
|
||||
tableName,
|
||||
dbName,
|
||||
translate: translateProp,
|
||||
loading,
|
||||
darkMode,
|
||||
bgFilter,
|
||||
@@ -192,7 +194,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
noAutoCapInputProps,
|
||||
filterFieldSelectStyle,
|
||||
filterFieldPopupWidth,
|
||||
exportMenu,
|
||||
onOpenExportModal,
|
||||
queryResultCopyMenu,
|
||||
dbType,
|
||||
onResetPendingChanges,
|
||||
@@ -236,6 +238,10 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
onDisableAllFilters,
|
||||
onClearFiltersAndSorts,
|
||||
}) => {
|
||||
const translate = React.useCallback(
|
||||
(key: string, params?: Record<string, string | number>) => translateProp?.(key, params) ?? key,
|
||||
[translateProp],
|
||||
);
|
||||
const renderToolbarDivider = () => (
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-toolbar-divider' : undefined}
|
||||
@@ -245,8 +251,9 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
);
|
||||
|
||||
const quickWherePlaceholder = dbType === 'mongodb'
|
||||
? '输入 MongoDB JSON 查询对象,例如 {"status":"A"}'
|
||||
: '输入 WHERE 后面的条件,例如 status = 1 AND name LIKE \'A%\'';
|
||||
? translate('data_grid.filter.mongodb_query_placeholder')
|
||||
: translate('data_grid.filter.quick_where_placeholder');
|
||||
const toolbarTitle = tableName || translate('data_grid.table_fallback.query_result');
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -284,7 +291,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
<>
|
||||
<div className="gn-v2-data-grid-toolbar-title">
|
||||
<TableOutlined className="gn-v2-data-grid-icon" />
|
||||
<strong title={tableName || '查询结果'}>{tableName || '查询结果'}</strong>
|
||||
<strong title={toolbarTitle}>{toolbarTitle}</strong>
|
||||
{dbName && <small title={dbName}>· {dbName}</small>}
|
||||
</div>
|
||||
{renderToolbarDivider()}
|
||||
@@ -292,7 +299,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
)}
|
||||
{onReload && (
|
||||
<Button icon={<ReloadOutlined />} disabled={loading} onClick={onRefresh}>
|
||||
刷新
|
||||
{translate('data_grid.toolbar.refresh')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -300,7 +307,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
<>
|
||||
{renderToolbarDivider()}
|
||||
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={onToggleFilterClick}>
|
||||
筛选
|
||||
{translate('data_grid.toolbar.filter')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -308,13 +315,13 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
{canModifyData && (
|
||||
<>
|
||||
{renderToolbarDivider()}
|
||||
<Button icon={<PlusOutlined />} onClick={onAddRow}>添加行</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={onAddRow}>{translate('data_grid.toolbar.add_row')}</Button>
|
||||
{allSelectedAreDeleted ? (
|
||||
<Button icon={<UndoOutlined />} disabled={selectedRowKeysLength === 0} onClick={onUndoDeleteSelected}>撤销删除</Button>
|
||||
<Button icon={<UndoOutlined />} disabled={selectedRowKeysLength === 0} onClick={onUndoDeleteSelected}>{translate('data_grid.toolbar.undo_delete')}</Button>
|
||||
) : (
|
||||
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeysLength === 0} onClick={onDeleteSelected}>删除选中</Button>
|
||||
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeysLength === 0} onClick={onDeleteSelected}>{translate('data_grid.toolbar.delete_selected')}</Button>
|
||||
)}
|
||||
{selectedRowKeysLength > 0 && <span style={{ fontSize: '12px', color: '#888' }}>已选 {selectedRowKeysLength}</span>}
|
||||
{selectedRowKeysLength > 0 && <span style={{ fontSize: '12px', color: '#888' }}>{translate('data_grid.toolbar.selected_count', { count: selectedRowKeysLength })}</span>}
|
||||
{renderToolbarDivider()}
|
||||
<Button
|
||||
data-grid-cell-editor-action="true"
|
||||
@@ -322,18 +329,18 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
type={cellEditMode ? 'primary' : 'default'}
|
||||
onClick={onToggleCellEditMode}
|
||||
>
|
||||
单元格编辑器
|
||||
{translate('data_grid.toolbar.cell_editor')}
|
||||
</Button>
|
||||
{cellEditMode && selectedCellsSize > 0 && (
|
||||
<>
|
||||
<Button icon={<CopyOutlined />} onClick={onCopySelectedCellsToClipboard}>
|
||||
复制选区 ({selectedCellsSize})
|
||||
{translate('data_grid.toolbar.copy_selection', { count: selectedCellsSize })}
|
||||
</Button>
|
||||
<Button icon={<CopyOutlined />} onClick={onCopySelectedColumnsFromRow}>
|
||||
复制选区列值 ({selectedCellsSize})
|
||||
{translate('data_grid.toolbar.copy_selection_columns', { count: selectedCellsSize })}
|
||||
</Button>
|
||||
<Button type="primary" onClick={onOpenBatchEditModal}>
|
||||
批量填充 ({selectedCellsSize})
|
||||
{translate('data_grid.toolbar.batch_fill', { count: selectedCellsSize })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -344,10 +351,10 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
disabled={selectedRowKeysLength === 0}
|
||||
onClick={onPasteCopiedColumnsToSelectedRows}
|
||||
>
|
||||
粘贴到选中行 ({selectedRowKeysLength})
|
||||
{translate('data_grid.toolbar.paste_to_selected_rows', { count: selectedRowKeysLength })}
|
||||
</Button>
|
||||
<span style={{ fontSize: '12px', color: '#888' }}>
|
||||
已复制 {copiedCellPatchColumnCount} 列
|
||||
{translate('data_grid.toolbar.copied_columns_count', { count: copiedCellPatchColumnCount })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -361,26 +368,26 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
>
|
||||
{isV2Ui ? (
|
||||
<>
|
||||
<span>提交事务</span>
|
||||
<span>{translate('data_grid.toolbar.commit_label')}</span>
|
||||
<span className="gn-v2-toolbar-kbd">{pendingChangeCount}</span>
|
||||
</>
|
||||
) : `提交事务 (${pendingChangeCount})`}
|
||||
) : translate('data_grid.toolbar.commit', { count: pendingChangeCount })}
|
||||
</Button>
|
||||
{hasChanges && (
|
||||
<Dropdown menu={{ items: [{ key: 'preview-sql', label: '生成预览 SQL', icon: <ConsoleSqlOutlined />, onClick: onPreviewChanges }] }}>
|
||||
<Button icon={<ConsoleSqlOutlined />}>预览SQL <DownOutlined /></Button>
|
||||
<Dropdown menu={{ items: [{ key: 'preview-sql', label: translate('data_grid.toolbar.preview_sql_generate'), icon: <ConsoleSqlOutlined />, onClick: onPreviewChanges }] }}>
|
||||
<Button icon={<ConsoleSqlOutlined />}>{translate('data_grid.toolbar.preview_sql')} <DownOutlined /></Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
{hasChanges && <Button icon={<UndoOutlined />} onClick={onResetPendingChanges}>回滚</Button>}
|
||||
<Tooltip title="控制表数据编辑后的提交方式。手动提交更安全;自动提交会在最后一次修改后按所选时间提交。">
|
||||
{hasChanges && <Button icon={<UndoOutlined />} onClick={onResetPendingChanges}>{translate('data_grid.toolbar.rollback')}</Button>}
|
||||
<Tooltip title={translate('data_grid.toolbar.commit_mode.tooltip')}>
|
||||
<Select
|
||||
size="small"
|
||||
value={dataEditCommitMode}
|
||||
onChange={onDataEditCommitModeChange}
|
||||
style={{ width: 118, flex: '0 0 auto' }}
|
||||
options={[
|
||||
{ value: 'manual', label: '手动提交' },
|
||||
{ value: 'auto', label: '自动提交' },
|
||||
{ value: 'manual', label: translate('data_grid.toolbar.commit_mode.manual') },
|
||||
{ value: 'auto', label: translate('data_grid.toolbar.commit_mode.auto') },
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -395,7 +402,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
)}
|
||||
{dataEditCommitMode === 'auto' && hasChanges && autoCommitRemainingSeconds !== null && (
|
||||
<span style={{ fontSize: 12, color: '#888', whiteSpace: 'nowrap' }}>
|
||||
{autoCommitRemainingSeconds}s 后提交
|
||||
{translate('data_grid.toolbar.commit_mode.auto_countdown', { seconds: autoCommitRemainingSeconds })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -404,8 +411,8 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
{(canImport || canExport) && (
|
||||
<>
|
||||
{renderToolbarDivider()}
|
||||
{canImport && <Button icon={<ImportOutlined />} onClick={onImport}>导入</Button>}
|
||||
{canExport && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}>导出 <DownOutlined /></Button></Dropdown>}
|
||||
{canImport && <Button icon={<ImportOutlined />} onClick={onImport}>{translate('data_grid.toolbar.import')}</Button>}
|
||||
{canExport && <Button icon={<ExportOutlined />} onClick={onOpenExportModal}>{translate('data_grid.toolbar.export')}</Button>}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -419,7 +426,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
disabled={!canCopyQueryResult}
|
||||
onClick={onCopyQueryResultCsv}
|
||||
>
|
||||
复制 <DownOutlined />
|
||||
{translate('data_grid.toolbar.copy')} <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</>
|
||||
@@ -427,7 +434,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
|
||||
<>
|
||||
{renderToolbarDivider()}
|
||||
<Tooltip title="一键借助 AI 智能分析当前查询页数据">
|
||||
<Tooltip title={translate('data_grid.toolbar.ai_insight_tooltip')}>
|
||||
<Button
|
||||
className={isV2Ui ? 'gn-v2-ai-insight-button' : undefined}
|
||||
icon={<RobotOutlined />}
|
||||
@@ -444,7 +451,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
}}
|
||||
onClick={onRequestAiInsight}
|
||||
>
|
||||
<span>{isV2Ui ? 'AI 洞察' : 'AI 数据洞察'}</span>
|
||||
<span>{isV2Ui ? translate('data_grid.toolbar.ai_insight_short') : translate('data_grid.toolbar.ai_insight')}</span>
|
||||
{isV2Ui && aiShortcutLabel !== '-' && <span className="gn-v2-toolbar-kbd">{aiShortcutLabel}</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -460,12 +467,12 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
{prefersManualTotalCount && (
|
||||
<>
|
||||
{renderToolbarDivider()}
|
||||
<Tooltip title={paginationTotalCountLoading ? '取消本次精确总数统计(不会影响当前浏览)' : '按当前筛选统计精确总数'}>
|
||||
<Tooltip title={paginationTotalCountLoading ? translate('data_grid.toolbar.cancel_count_tooltip') : translate('data_grid.toolbar.count_total_tooltip')}>
|
||||
<Button
|
||||
icon={paginationTotalCountLoading ? <CloseOutlined /> : <VerticalAlignBottomOutlined />}
|
||||
onClick={onToggleTotalCount}
|
||||
>
|
||||
{paginationTotalCountLoading ? '取消统计' : '统计总数'}
|
||||
{paginationTotalCountLoading ? translate('data_grid.toolbar.cancel_count') : translate('data_grid.toolbar.count_total')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
@@ -541,10 +548,10 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
/>
|
||||
</AutoComplete>
|
||||
<Button size="small" type="primary" onClick={onApplyQuickWhere}>
|
||||
应用 WHERE
|
||||
{translate('data_grid.filter.apply_where')}
|
||||
</Button>
|
||||
<Button size="small" onClick={onClearQuickWhere} disabled={!quickWhereDraft && !quickWhereCondition}>
|
||||
清空
|
||||
{translate('data_grid.filter.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -556,13 +563,13 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
onChange={(event) => updateFilter(cond.id, 'enabled', event.target.checked)}
|
||||
style={{ marginTop: 6, flex: '0 0 auto', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
启用
|
||||
{translate('data_grid.filter.enabled')}
|
||||
</Checkbox>
|
||||
<Select
|
||||
style={{ width: 96, minWidth: 96, maxWidth: 96, flex: '0 0 96px' }}
|
||||
value={condIndex === 0 ? '__FIRST__' : (cond.logic === 'OR' ? 'OR' : 'AND')}
|
||||
onChange={(value) => updateFilter(cond.id, 'logic', value)}
|
||||
options={condIndex === 0 ? [{ value: '__FIRST__', label: '首条' }] : filterLogicOptions}
|
||||
options={condIndex === 0 ? [{ value: '__FIRST__', label: translate('data_grid.filter.first_condition') }] : filterLogicOptions}
|
||||
disabled={condIndex === 0}
|
||||
/>
|
||||
<Select
|
||||
@@ -579,7 +586,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
.toLowerCase()
|
||||
.includes(String(input || '').trim().toLowerCase())
|
||||
}
|
||||
placeholder="搜索字段名"
|
||||
placeholder={translate('data_grid.filter.search_field_placeholder')}
|
||||
disabled={cond.op === 'CUSTOM'}
|
||||
/>
|
||||
<Select
|
||||
@@ -596,7 +603,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
value={cond.value}
|
||||
onChange={(event) => updateFilter(cond.id, 'value', event.target.value)}
|
||||
placeholder="输入自定义 WHERE 表达式(不需要再写 WHERE),例如:status IN ('A','B')"
|
||||
placeholder={translate('data_grid.filter.custom_where_placeholder')}
|
||||
/>
|
||||
) : isListOp(cond.op) ? (
|
||||
<Input.TextArea
|
||||
@@ -605,7 +612,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
value={cond.value}
|
||||
onChange={(event) => updateFilter(cond.id, 'value', event.target.value)}
|
||||
placeholder="多个值用逗号或换行分隔"
|
||||
placeholder={translate('data_grid.filter.list_values_placeholder')}
|
||||
/>
|
||||
) : isBetweenOp(cond.op) ? (
|
||||
<>
|
||||
@@ -614,18 +621,18 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
style={{ width: 220 }}
|
||||
value={cond.value}
|
||||
onChange={(event) => updateFilter(cond.id, 'value', event.target.value)}
|
||||
placeholder="开始值"
|
||||
placeholder={translate('data_grid.filter.start_value_placeholder')}
|
||||
/>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
style={{ width: 220 }}
|
||||
value={cond.value2 || ''}
|
||||
onChange={(event) => updateFilter(cond.id, 'value2', event.target.value)}
|
||||
placeholder="结束值"
|
||||
placeholder={translate('data_grid.filter.end_value_placeholder')}
|
||||
/>
|
||||
</>
|
||||
) : isNoValueOp(cond.op) ? (
|
||||
<Input {...noAutoCapInputProps} style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
|
||||
<Input {...noAutoCapInputProps} style={{ width: 220 }} value="" disabled placeholder={translate('data_grid.filter.no_value_placeholder')} />
|
||||
) : (
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
@@ -652,7 +659,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
style={{ flex: '0 0 auto' }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'inherit', opacity: 0.7, whiteSpace: 'nowrap', minWidth: 32 }}>
|
||||
{index === 0 ? '排序' : '然后'}
|
||||
{index === 0 ? translate('data_grid.filter.sort_label') : translate('data_grid.filter.then_label')}
|
||||
</span>
|
||||
<Select
|
||||
style={filterFieldSelectStyle}
|
||||
@@ -678,7 +685,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
.toLowerCase()
|
||||
.includes(String(input || '').trim().toLowerCase())
|
||||
}
|
||||
placeholder="选择排序字段"
|
||||
placeholder={translate('data_grid.filter.select_sort_field_placeholder')}
|
||||
allowClear
|
||||
onClear={() => {
|
||||
const next = sortInfo.filter((_, itemIndex) => itemIndex !== index);
|
||||
@@ -694,8 +701,8 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
onApplySortInfo(next);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'ascend', label: '升序 ↑' },
|
||||
{ value: 'descend', label: '降序 ↓' },
|
||||
{ value: 'ascend', label: `${translate('data_grid.filter.sort_asc')} ↑` },
|
||||
{ value: 'descend', label: `${translate('data_grid.filter.sort_desc')} ↓` },
|
||||
]}
|
||||
disabled={!item.columnKey}
|
||||
/>
|
||||
@@ -724,7 +731,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
borderTop: ((enableSortControls && sortInfo.length > 0) || filterConditions.length > 0) ? `1px dashed ${panelFrameColor}` : 'none',
|
||||
}}
|
||||
>
|
||||
<Button type="primary" ghost onClick={addFilter} size="small" icon={<PlusOutlined />}>添加条件</Button>
|
||||
<Button type="primary" ghost onClick={addFilter} size="small" icon={<PlusOutlined />}>{translate('data_grid.filter.add_condition')}</Button>
|
||||
{enableSortControls && (
|
||||
<Button
|
||||
type="dashed"
|
||||
@@ -736,15 +743,15 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
}}
|
||||
disabled={sortInfo.length >= displayColumnNames.length}
|
||||
>
|
||||
添加排序
|
||||
{translate('data_grid.filter.add_sort')}
|
||||
</Button>
|
||||
)}
|
||||
<div style={{ width: 1, height: 16, background: panelFrameColor, margin: '0 2px', flexShrink: 0 }} />
|
||||
<Button size="small" onClick={onEnableAllFilters}>全启用</Button>
|
||||
<Button size="small" onClick={onDisableAllFilters}>全停用</Button>
|
||||
<Button size="small" onClick={onEnableAllFilters}>{translate('data_grid.filter.enable_all')}</Button>
|
||||
<Button size="small" onClick={onDisableAllFilters}>{translate('data_grid.filter.disable_all')}</Button>
|
||||
<div style={{ width: 1, height: 16, background: panelFrameColor, margin: '0 2px', flexShrink: 0 }} />
|
||||
<Button type="primary" onClick={onApplyFilters} size="small">应用</Button>
|
||||
<Button size="small" icon={<ClearOutlined />} onClick={onClearFiltersAndSorts}>清除</Button>
|
||||
<Button type="primary" onClick={onApplyFilters} size="small">{translate('data_grid.filter.apply')}</Button>
|
||||
<Button size="small" icon={<ClearOutlined />} onClick={onClearFiltersAndSorts}>{translate('data_grid.filter.clear')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user