Compare commits
377 Commits
release/0.
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3665639300 | ||
|
|
3b9116e259 | ||
|
|
a06f45da28 | ||
|
|
21222cf9f4 | ||
|
|
30301cd637 | ||
|
|
55829bce86 | ||
|
|
2b340f3136 | ||
|
|
9eb06f6f96 | ||
|
|
01dd62f4e2 | ||
|
|
09ecc841ab | ||
|
|
3a0c5201a0 | ||
|
|
5f6acc25da | ||
|
|
5bbeb7f373 | ||
|
|
df4fcab90b | ||
|
|
f16e2f15c2 | ||
|
|
38e71119a4 | ||
|
|
ff2b86819d | ||
|
|
9d08b185d0 | ||
|
|
a43c84f968 | ||
|
|
14c6510835 | ||
|
|
6f14e827ab | ||
|
|
d9b4c6a21b | ||
|
|
d2c3e3e779 | ||
|
|
3cb2d494cc | ||
|
|
9a61622568 | ||
|
|
21f2b29d1d | ||
|
|
7ddb49a81d | ||
|
|
9bb7ece2dd | ||
|
|
177dafacc9 | ||
|
|
03a1506686 | ||
|
|
15b1ad24d1 | ||
|
|
f584270209 | ||
|
|
fe9d02734f | ||
|
|
65a9f4352e | ||
|
|
f3b78f9763 | ||
|
|
0bccdeed8c | ||
|
|
39f6fbbe1f | ||
|
|
8a1a9a8fb8 | ||
|
|
dca5f629b2 | ||
|
|
8eae39c2c2 | ||
|
|
9613b2a8eb | ||
|
|
4fd679ce42 | ||
|
|
e56a72eb9f | ||
|
|
0fda09a19f | ||
|
|
33b78fb583 | ||
|
|
40416fb4df | ||
|
|
651eec1617 | ||
|
|
9dc58acb39 | ||
|
|
f3193f0933 | ||
|
|
7cb46f9f69 | ||
|
|
04c4613e4d | ||
|
|
8a10519f9b | ||
|
|
d57081ecfb | ||
|
|
035f536e8d | ||
|
|
22e4299d3e | ||
|
|
384aea132c | ||
|
|
890478eb7b | ||
|
|
8c79f2af0c | ||
|
|
a2cad9f7ce | ||
|
|
af90936fcc | ||
|
|
d3a1c017da | ||
|
|
a90423c04c | ||
|
|
6e23053ac6 | ||
|
|
9b50e9c9c8 | ||
|
|
4c76202d2c | ||
|
|
9c5b1a033a | ||
|
|
c631feef91 | ||
|
|
737896627a | ||
|
|
47235e1390 | ||
|
|
b6121fe1f8 | ||
|
|
f78b132c7c | ||
|
|
1adef17366 | ||
|
|
ada9bbf03e | ||
|
|
266f217bfd | ||
|
|
54d46453df | ||
|
|
c7cf9526de | ||
|
|
d849cd49af | ||
|
|
604aaad69d | ||
|
|
605e266eab | ||
|
|
2569a3779a | ||
|
|
bb6271246b | ||
|
|
8e0d1b0a80 | ||
|
|
d150780879 | ||
|
|
52d2ee7592 | ||
|
|
2410aad849 | ||
|
|
33b21cc5ee | ||
|
|
1a0ba9a499 | ||
|
|
7a2563b83b | ||
|
|
632e57ea60 | ||
|
|
ca76440981 | ||
|
|
af5e84213f | ||
|
|
fcade0f860 | ||
|
|
1c2377bc62 | ||
|
|
426ef3bcf6 | ||
|
|
fb500ee33b | ||
|
|
89d79ff10c | ||
|
|
aa1bb5b886 | ||
|
|
5038ae5c9b | ||
|
|
83fe3d4ed9 | ||
|
|
808c773134 | ||
|
|
5d86ee7c76 | ||
|
|
8297829be6 | ||
|
|
f696f52470 | ||
|
|
60b63d7a22 | ||
|
|
1f617f9d53 | ||
|
|
1751e14d20 | ||
|
|
82e06bd94d | ||
|
|
c810d999bd | ||
|
|
0009c98c7e | ||
|
|
070ff72ad8 | ||
|
|
803c33b306 | ||
|
|
1d882d089f | ||
|
|
19da7fc66c | ||
|
|
c1877ea013 | ||
|
|
60dbb8a559 | ||
|
|
67fe3e3017 | ||
|
|
1a042321d2 | ||
|
|
35944d58f8 | ||
|
|
5c2509c37f | ||
|
|
8e1b01b550 | ||
|
|
29fa5eb6df | ||
|
|
7c6391af3d | ||
|
|
5746796bc2 | ||
|
|
3ec7c9be9d | ||
|
|
ac6ef06413 | ||
|
|
ac0b6c05e8 | ||
|
|
37b3c78049 | ||
|
|
255cc14bf6 | ||
|
|
4718755208 | ||
|
|
91b5b85904 | ||
|
|
c842201bf4 | ||
|
|
263db6bf30 | ||
|
|
b5e8f5c022 | ||
|
|
b62d22395b | ||
|
|
f74270d585 | ||
|
|
ef64a24e01 | ||
|
|
c1266c225a | ||
|
|
acee1a06e8 | ||
|
|
eddb9f38c9 | ||
|
|
fbda6917f7 | ||
|
|
b022cd63e5 | ||
|
|
9eb42565f1 | ||
|
|
6d533167da | ||
|
|
f992ad72e6 | ||
|
|
5c0f6f8ff4 | ||
|
|
1eb517f083 | ||
|
|
02fa0aef46 | ||
|
|
f7107a1625 | ||
|
|
08ab06c038 | ||
|
|
3402b56fdb | ||
|
|
2c2baca69f | ||
|
|
e464c2cce1 | ||
|
|
15f72c013d | ||
|
|
c2c8870841 | ||
|
|
4f7ac7149a | ||
|
|
8d8af530a7 | ||
|
|
29b96719d5 | ||
|
|
9c96246320 | ||
|
|
31644dee6b | ||
|
|
aa9d8d243a | ||
|
|
6e55d63877 | ||
|
|
c126c4b731 | ||
|
|
c85de27aac | ||
|
|
eeef0f06ed | ||
|
|
fcd4d4026c | ||
|
|
a7bee7f3b6 | ||
|
|
ed4a7b96d4 | ||
|
|
09d013f27d | ||
|
|
09aa526570 | ||
|
|
5844cd7c01 | ||
|
|
4f74c44147 | ||
|
|
a5fdfefa2d | ||
|
|
37ac13b94e | ||
|
|
d4d685b076 | ||
|
|
9f6d524e3d | ||
|
|
a89289f1cc | ||
|
|
b958ff6481 | ||
|
|
98e9e5686d | ||
|
|
93446e060e | ||
|
|
ecc8ff1197 | ||
|
|
82369b4070 | ||
|
|
1bda751ada | ||
|
|
7bc358d612 | ||
|
|
36a57f9601 | ||
|
|
e85c561f1e | ||
|
|
2677364d0e | ||
|
|
da28207168 | ||
|
|
87cfbee6d3 | ||
|
|
0100b771b0 | ||
|
|
1758d6f918 | ||
|
|
b86cfcacaa | ||
|
|
7d543e06c6 | ||
|
|
17e4e3ad1c | ||
|
|
84579b83c9 | ||
|
|
7ddef7096b | ||
|
|
557178f182 | ||
|
|
a1b546ddd9 | ||
|
|
da5e879409 | ||
|
|
8935ad2905 | ||
|
|
cd5a0e85e8 | ||
|
|
ccb9f09452 | ||
|
|
5afd80c559 | ||
|
|
1b36f60821 | ||
|
|
eaa76d8f04 | ||
|
|
0f717706b0 | ||
|
|
8950081a6c | ||
|
|
3bf8758418 | ||
|
|
561d3810da | ||
|
|
18cb66b893 | ||
|
|
ab61e703b1 | ||
|
|
7933b4c315 | ||
|
|
c99f857d0a | ||
|
|
2c3f4a1032 | ||
|
|
72de16995a | ||
|
|
0adc8411fa | ||
|
|
8efa7e2de6 | ||
|
|
ecee206304 | ||
|
|
299dceb01c | ||
|
|
5cad761bdd | ||
|
|
b8728170ec | ||
|
|
4ce4cdaad8 | ||
|
|
cc7ef12029 | ||
|
|
5b6403f266 | ||
|
|
caceb2868d | ||
|
|
e7b9ff4a10 | ||
|
|
76f65cb96c | ||
|
|
8bdc6e8086 | ||
|
|
1eb2f6dffe | ||
|
|
5c5e1fc68f | ||
|
|
fb70f1420c | ||
|
|
d75596921c | ||
|
|
d251594fd9 | ||
|
|
7598bf372b | ||
|
|
64021ffd2a | ||
|
|
fbd785400f | ||
|
|
b573fd95cc | ||
|
|
a097d96380 | ||
|
|
6ee0fea110 | ||
|
|
e6b822c967 | ||
|
|
0ab10d2e80 | ||
|
|
064cdc34be | ||
|
|
c62f4b7d3c | ||
|
|
304a4926d2 | ||
|
|
cabf84a041 | ||
|
|
9b02720169 | ||
|
|
eb36dcc5a2 | ||
|
|
1a3f137438 | ||
|
|
5f94cd3911 | ||
|
|
bb257c35bc | ||
|
|
1dabac1a65 | ||
|
|
e013288967 | ||
|
|
d467322ebe | ||
|
|
e26a456eae | ||
|
|
501ad9e9a3 | ||
|
|
482a7fce2e | ||
|
|
e6af5f966b | ||
|
|
eef973b7fc | ||
|
|
d8b6b4ef8d | ||
|
|
4d58cc6e26 | ||
|
|
b0bdddad9b | ||
|
|
a73ca36a32 | ||
|
|
92e9381fcc | ||
|
|
c4c7e379d1 | ||
|
|
695713c779 | ||
|
|
ca49b37dc7 | ||
|
|
c8c0c5f20a | ||
|
|
d61d7ec39b | ||
|
|
e964c8ecf8 | ||
|
|
7644462180 | ||
|
|
3bd02e2e09 | ||
|
|
0daf702d25 | ||
|
|
058c74e49a | ||
|
|
b85c7529ec | ||
|
|
e521d2125f | ||
|
|
450fdfa59e | ||
|
|
c87b15b22a | ||
|
|
797ba27d20 | ||
|
|
ed1f40e04a | ||
|
|
2b190e564f | ||
|
|
1c050aefd0 | ||
|
|
75a5a322e0 | ||
|
|
61d6197fe3 | ||
|
|
6157161293 | ||
|
|
0f843a7dcf | ||
|
|
fb65b553e9 | ||
|
|
1a5bf79dd3 | ||
|
|
dea096d4c2 | ||
|
|
04f8b266d3 | ||
|
|
b53227cb15 | ||
|
|
0246d7fae5 | ||
|
|
4aa177ed37 | ||
|
|
4f5a7bd94b | ||
|
|
00c6f9871f | ||
|
|
6a4b397ecc | ||
|
|
3973038aea | ||
|
|
71b41459e7 | ||
|
|
69942bb77e | ||
|
|
f372b20a68 | ||
|
|
e6da986927 | ||
|
|
4570516678 | ||
|
|
8c91d8929b | ||
|
|
786835c9bc | ||
|
|
f2fc7cbd05 | ||
|
|
462ca57907 | ||
|
|
4bfdb2cb6c | ||
|
|
6918b56ed9 | ||
|
|
1afb8850ad | ||
|
|
3284eeba17 | ||
|
|
494484eb92 | ||
|
|
6156884455 | ||
|
|
a54b8906a3 | ||
|
|
f477feab2f | ||
|
|
e76e174bfe | ||
|
|
b904c0b107 | ||
|
|
c02e7c12e8 | ||
|
|
a87c801e66 | ||
|
|
7f00139847 | ||
|
|
78c5351399 | ||
|
|
e2acfa51eb | ||
|
|
9a684cd82c | ||
|
|
e3b142053f | ||
|
|
3ca898a950 | ||
|
|
84688e995a | ||
|
|
4d0940636d | ||
|
|
26b79adc5f | ||
|
|
90aa3561be | ||
|
|
ec59023736 | ||
|
|
4a96cb93d2 | ||
|
|
4c322db9d0 | ||
|
|
ed18c8285f | ||
|
|
5f8cedabd8 | ||
|
|
20923989b9 | ||
|
|
210106cde7 | ||
|
|
87aac277ec | ||
|
|
4de3f408c5 | ||
|
|
439625a49c | ||
|
|
884d72f3d3 | ||
|
|
98c1600e13 | ||
|
|
eb594b7741 | ||
|
|
587ed3444b | ||
|
|
e366a61910 | ||
|
|
5986b71c4d | ||
|
|
cb18bc3067 | ||
|
|
d676ac9084 | ||
|
|
7fcbcb2471 | ||
|
|
c680e50e74 | ||
|
|
9685102229 | ||
|
|
3505b4428a | ||
|
|
9ebdf7f053 | ||
|
|
9ad852c10b | ||
|
|
2a8fff4d93 | ||
|
|
eca560b4e5 | ||
|
|
2f475dddc0 | ||
|
|
ad9d8a12be | ||
|
|
095b22951e | ||
|
|
7350a011e3 | ||
|
|
53b5802add | ||
|
|
54e7077317 | ||
|
|
4cb5071b0b | ||
|
|
96de46cf1e | ||
|
|
7d5592d8d9 | ||
|
|
d0ba8822f3 | ||
|
|
140db73ef4 | ||
|
|
7ae5341c1c | ||
|
|
bec5013a44 | ||
|
|
66a3113fa8 | ||
|
|
a435d62d3b | ||
|
|
50d92d3184 | ||
|
|
91658848c9 | ||
|
|
01940e74b7 | ||
|
|
30210bc40e | ||
|
|
e90a3e2db6 | ||
|
|
5df95730d8 | ||
|
|
67a9c454d0 | ||
|
|
c17493952b | ||
|
|
dd258bd46c | ||
|
|
505c89066b |
26
.github/release.yaml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
changelog:
|
||||||
|
categories:
|
||||||
|
- title: 新功能
|
||||||
|
labels:
|
||||||
|
- feature
|
||||||
|
- enhancement
|
||||||
|
- feat
|
||||||
|
- title: 问题修复
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- fix
|
||||||
|
- title: 文档与流程
|
||||||
|
labels:
|
||||||
|
- docs
|
||||||
|
- documentation
|
||||||
|
- ci
|
||||||
|
- workflow
|
||||||
|
- chore
|
||||||
|
- title: 重构与优化
|
||||||
|
labels:
|
||||||
|
- refactor
|
||||||
|
- perf
|
||||||
|
- optimization
|
||||||
|
- title: 其他更新
|
||||||
|
labels:
|
||||||
|
- '*'
|
||||||
674
.github/workflows/dev-build.yml
vendored
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
name: Dev Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.platform }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: macos-latest
|
||||||
|
platform: darwin/amd64
|
||||||
|
os_name: MacOS
|
||||||
|
arch_name: Amd64
|
||||||
|
build_name: gonavi-build-darwin-amd64
|
||||||
|
wails_tags: ""
|
||||||
|
artifact_suffix: ""
|
||||||
|
build_optional_agents: true
|
||||||
|
linux_webkit: ""
|
||||||
|
- os: macos-latest
|
||||||
|
platform: darwin/arm64
|
||||||
|
os_name: MacOS
|
||||||
|
arch_name: Arm64
|
||||||
|
build_name: gonavi-build-darwin-arm64
|
||||||
|
wails_tags: ""
|
||||||
|
artifact_suffix: ""
|
||||||
|
build_optional_agents: true
|
||||||
|
linux_webkit: ""
|
||||||
|
- os: windows-latest
|
||||||
|
platform: windows/amd64
|
||||||
|
os_name: Windows
|
||||||
|
arch_name: Amd64
|
||||||
|
build_name: gonavi-build-windows-amd64
|
||||||
|
wails_tags: ""
|
||||||
|
artifact_suffix: ""
|
||||||
|
build_optional_agents: true
|
||||||
|
linux_webkit: ""
|
||||||
|
- os: windows-latest
|
||||||
|
platform: windows/arm64
|
||||||
|
os_name: Windows
|
||||||
|
arch_name: Arm64
|
||||||
|
build_name: gonavi-build-windows-arm64
|
||||||
|
wails_tags: ""
|
||||||
|
artifact_suffix: ""
|
||||||
|
build_optional_agents: true
|
||||||
|
linux_webkit: ""
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
platform: linux/amd64
|
||||||
|
os_name: Linux
|
||||||
|
arch_name: Amd64
|
||||||
|
build_name: gonavi-build-linux-amd64
|
||||||
|
wails_tags: ""
|
||||||
|
artifact_suffix: ""
|
||||||
|
build_optional_agents: true
|
||||||
|
linux_webkit: "4.0"
|
||||||
|
- os: ubuntu-24.04
|
||||||
|
platform: linux/amd64
|
||||||
|
os_name: Linux
|
||||||
|
arch_name: Amd64
|
||||||
|
build_name: gonavi-build-linux-amd64-webkit41
|
||||||
|
wails_tags: "webkit2_41"
|
||||||
|
artifact_suffix: "-WebKit41"
|
||||||
|
build_optional_agents: false
|
||||||
|
linux_webkit: "4.1"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.24'
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install UPX (Windows)
|
||||||
|
if: contains(matrix.platform, 'windows')
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$UPX_VERSION = "4.2.4"
|
||||||
|
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
|
||||||
|
$zipPath = "$env:RUNNER_TEMP\upx.zip"
|
||||||
|
$extractPath = "$env:RUNNER_TEMP\upx"
|
||||||
|
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
|
||||||
|
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||||||
|
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
|
||||||
|
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||||
|
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
|
||||||
|
if (!(Test-Path $upxCmd)) {
|
||||||
|
Write-Error "❌ 未检测到 upx,无法保证 Windows 产物经过压缩"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
& $upxCmd --version
|
||||||
|
|
||||||
|
- name: Install Linux Dependencies
|
||||||
|
if: contains(matrix.platform, 'linux')
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev
|
||||||
|
|
||||||
|
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
|
||||||
|
else
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
|
||||||
|
upx --version
|
||||||
|
|
||||||
|
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||||
|
|
||||||
|
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||||
|
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
|
||||||
|
|
||||||
|
echo "📥 下载 linuxdeploy..."
|
||||||
|
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
|
||||||
|
-O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
|
||||||
|
echo "⚠️ linuxdeploy 下载失败,AppImage 打包将跳过"
|
||||||
|
touch /tmp/skip-appimage
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "📥 下载 linuxdeploy-plugin-gtk..."
|
||||||
|
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
|
||||||
|
-O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
|
||||||
|
echo "⚠️ linuxdeploy-plugin-gtk 下载失败,AppImage 打包将跳过"
|
||||||
|
touch /tmp/skip-appimage
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f /tmp/skip-appimage ]; then
|
||||||
|
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
|
||||||
|
echo "✅ AppImage 工具准备完成"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install Wails
|
||||||
|
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
|
|
||||||
|
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
|
||||||
|
id: msys2_duckdb
|
||||||
|
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||||
|
continue-on-error: true
|
||||||
|
uses: msys2/setup-msys2@v2
|
||||||
|
with:
|
||||||
|
msystem: UCRT64
|
||||||
|
update: true
|
||||||
|
install: >-
|
||||||
|
mingw-w64-ucrt-x86_64-gcc
|
||||||
|
|
||||||
|
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
|
||||||
|
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
function Find-MingwBin([string[]]$candidates) {
|
||||||
|
foreach ($bin in $candidates) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($bin)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$gcc = Join-Path $bin 'gcc.exe'
|
||||||
|
$gxx = Join-Path $bin 'g++.exe'
|
||||||
|
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
|
||||||
|
return $bin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$msys2Outcome = "${{ steps.msys2_duckdb.outcome }}"
|
||||||
|
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
|
||||||
|
$candidateBins = @()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
|
||||||
|
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
|
||||||
|
}
|
||||||
|
$candidateBins += @(
|
||||||
|
'C:\msys64\ucrt64\bin',
|
||||||
|
'D:\a\_temp\msys64\ucrt64\bin'
|
||||||
|
)
|
||||||
|
$candidateBins = @($candidateBins | Select-Object -Unique)
|
||||||
|
|
||||||
|
$mingwBin = Find-MingwBin $candidateBins
|
||||||
|
if (-not $mingwBin) {
|
||||||
|
if ($msys2Outcome -ne 'success') {
|
||||||
|
Write-Warning "⚠️ MSYS2 安装步骤结果为 $msys2Outcome,回退到 UCRT64 本机路径探测"
|
||||||
|
} else {
|
||||||
|
Write-Warning "⚠️ MSYS2 已执行,但未找到 UCRT64 gcc/g++,回退到本机路径探测"
|
||||||
|
}
|
||||||
|
$mingwBin = Find-MingwBin $candidateBins
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $mingwBin) {
|
||||||
|
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。已检查:$($candidateBins -join ', ')"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$gcc = (Join-Path $mingwBin 'gcc.exe')
|
||||||
|
$gxx = (Join-Path $mingwBin 'g++.exe')
|
||||||
|
|
||||||
|
if (!(Test-Path $gcc) -or !(Test-Path $gxx)) {
|
||||||
|
Write-Error "❌ DuckDB 编译器缺失:gcc=$gcc g++=$gxx"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||||
|
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx"
|
||||||
|
|
||||||
|
- name: Verify DuckDB CGO Toolchain (Windows AMD64)
|
||||||
|
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
& "$env:CC" --version
|
||||||
|
& "$env:CXX" --version
|
||||||
|
|
||||||
|
# ---- 生成 dev 版本号 ----
|
||||||
|
- name: Generate Dev Version
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
DEV_VERSION="dev-${SHORT_SHA}"
|
||||||
|
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "📌 Dev 版本号: ${DEV_VERSION}"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
DEV_VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||||
|
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
|
||||||
|
else
|
||||||
|
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Optional Driver Agents
|
||||||
|
if: ${{ matrix.build_optional_agents }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||||
|
GOOS="${TARGET_PLATFORM%%/*}"
|
||||||
|
GOARCH="${TARGET_PLATFORM##*/}"
|
||||||
|
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||||
|
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
|
||||||
|
for DRIVER in "${DRIVERS[@]}"; do
|
||||||
|
BUILD_DRIVER="$DRIVER"
|
||||||
|
if [ "$DRIVER" = "doris" ]; then
|
||||||
|
BUILD_DRIVER="diros"
|
||||||
|
fi
|
||||||
|
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
|
||||||
|
echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
TAG="gonavi_${BUILD_DRIVER}_driver"
|
||||||
|
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
||||||
|
if [ "$GOOS" = "windows" ]; then
|
||||||
|
OUTPUT="${OUTPUT}.exe"
|
||||||
|
fi
|
||||||
|
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||||
|
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
|
||||||
|
if [ "$DRIVER" = "duckdb" ]; then
|
||||||
|
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||||
|
-tags "${TAG}" \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags "-s -w" \
|
||||||
|
-o "${OUTPUT_PATH}" \
|
||||||
|
./cmd/optional-driver-agent
|
||||||
|
else
|
||||||
|
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||||
|
-tags "${TAG}" \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags "-s -w" \
|
||||||
|
-o "${OUTPUT_PATH}" \
|
||||||
|
./cmd/optional-driver-agent
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# macOS Packaging
|
||||||
|
- name: Package macOS DMG
|
||||||
|
if: contains(matrix.platform, 'darwin')
|
||||||
|
run: |
|
||||||
|
brew install create-dmg
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
cd build/bin
|
||||||
|
|
||||||
|
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
|
||||||
|
if [ -z "$APP_PATH" ]; then
|
||||||
|
echo "❌ 未找到 .app 应用包!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
APP_NAME=$(basename "$APP_PATH")
|
||||||
|
|
||||||
|
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
|
||||||
|
if [ -z "$APP_BIN" ]; then
|
||||||
|
echo "❌ 未找到 macOS 应用主程序!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。"
|
||||||
|
|
||||||
|
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||||
|
if command -v xattr >/dev/null 2>&1; then
|
||||||
|
xattr -cr "$APP_NAME" || true
|
||||||
|
fi
|
||||||
|
codesign --force --deep --sign - "$APP_NAME"
|
||||||
|
|
||||||
|
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||||
|
FINAL_NAME="GoNavi-${VERSION}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
|
||||||
|
echo "📦 正在生成 DMG: $DMG_NAME..."
|
||||||
|
|
||||||
|
create-dmg \
|
||||||
|
--volname "GoNavi Dev Build" \
|
||||||
|
--window-pos 200 120 \
|
||||||
|
--window-size 800 400 \
|
||||||
|
--icon-size 100 \
|
||||||
|
--icon "$APP_NAME" 200 190 \
|
||||||
|
--hide-extension "$APP_NAME" \
|
||||||
|
--app-drop-link 600 185 \
|
||||||
|
"$DMG_NAME" \
|
||||||
|
"$APP_NAME"
|
||||||
|
|
||||||
|
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dev-verify.XXXXXX")
|
||||||
|
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
|
||||||
|
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
|
||||||
|
if [ -z "$PACKAGED_APP" ]; then
|
||||||
|
echo "❌ DMG 内未找到 .app 应用包!"
|
||||||
|
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
|
||||||
|
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||||
|
|
||||||
|
# Windows Packaging
|
||||||
|
- name: Package Windows EXE
|
||||||
|
if: contains(matrix.platform, 'windows')
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
Set-Location build/bin
|
||||||
|
$version = "${{ steps.version.outputs.version }}"
|
||||||
|
$target = "${{ matrix.build_name }}"
|
||||||
|
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
|
||||||
|
|
||||||
|
if (Test-Path "$target.exe") {
|
||||||
|
$finalExe = "$target.exe"
|
||||||
|
} elseif (Test-Path "$target") {
|
||||||
|
Rename-Item -Path "$target" -NewName "$target.exe"
|
||||||
|
$finalExe = "$target.exe"
|
||||||
|
} else {
|
||||||
|
Write-Error "❌ 未找到构建产物 '$target'!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
|
||||||
|
if ($isArm64Target) {
|
||||||
|
Write-Warning "⚠️ UPX 当前不支持 win64/arm64,跳过压缩并保留原始 EXE。"
|
||||||
|
$LASTEXITCODE = 0
|
||||||
|
} else {
|
||||||
|
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $upxCmd) {
|
||||||
|
Write-Error "❌ 未找到 upx,无法保证 Windows 产物经过压缩"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
|
||||||
|
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
|
||||||
|
& upx --best --lzma --force $finalExe | Out-Host
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "❌ UPX 压缩失败($LASTEXITCODE)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
& upx -t $finalExe | Out-Host
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "❌ UPX 校验失败($LASTEXITCODE)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
|
||||||
|
if ($afterBytes -lt $beforeBytes) {
|
||||||
|
$savedBytes = $beforeBytes - $afterBytes
|
||||||
|
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB,减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
|
||||||
|
} else {
|
||||||
|
Write-Host ("ℹ️ UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
|
||||||
|
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
|
||||||
|
|
||||||
|
# Linux Packaging
|
||||||
|
- name: Package Linux
|
||||||
|
if: contains(matrix.platform, 'linux')
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
cd build/bin
|
||||||
|
TARGET="${{ matrix.build_name }}"
|
||||||
|
TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.tar.gz"
|
||||||
|
APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.AppImage"
|
||||||
|
|
||||||
|
if [ ! -f "$TARGET" ]; then
|
||||||
|
echo "❌ 未找到构建产物 '$TARGET'!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$TARGET"
|
||||||
|
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
|
||||||
|
echo "🗜️ 正在使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
|
||||||
|
upx --best --lzma --force "$TARGET"
|
||||||
|
upx -t "$TARGET"
|
||||||
|
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
|
||||||
|
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
|
||||||
|
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
|
||||||
|
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
|
||||||
|
else
|
||||||
|
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf "ℹ️ Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 正在打包 $TAR_NAME..."
|
||||||
|
tar -czvf "$TAR_NAME" "$TARGET"
|
||||||
|
mv "$TAR_NAME" ../../
|
||||||
|
|
||||||
|
if [ -f /tmp/skip-appimage ]; then
|
||||||
|
echo "⚠️ 跳过 AppImage 打包"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 正在生成 AppImage..."
|
||||||
|
mkdir -p AppDir/usr/bin
|
||||||
|
mkdir -p AppDir/usr/share/applications
|
||||||
|
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
|
||||||
|
|
||||||
|
cp "$TARGET" AppDir/usr/bin/gonavi
|
||||||
|
|
||||||
|
printf '%s\n' \
|
||||||
|
'[Desktop Entry]' \
|
||||||
|
'Name=GoNavi' \
|
||||||
|
'Exec=gonavi' \
|
||||||
|
'Icon=gonavi' \
|
||||||
|
'Type=Application' \
|
||||||
|
'Categories=Development;Database;' \
|
||||||
|
'Comment=Database Management Tool' \
|
||||||
|
> AppDir/usr/share/applications/gonavi.desktop
|
||||||
|
|
||||||
|
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
|
||||||
|
|
||||||
|
if [ -f "../../build/appicon.png" ]; then
|
||||||
|
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||||
|
cp "../../build/appicon.png" AppDir/gonavi.png
|
||||||
|
else
|
||||||
|
convert -size 256x256 xc:#336791 -fill white -gravity center -pointsize 48 -annotate 0 "GoNavi" AppDir/gonavi.png || \
|
||||||
|
wget -q "https://via.placeholder.com/256/336791/FFFFFF?text=GoNavi" -O AppDir/gonavi.png || \
|
||||||
|
touch AppDir/gonavi.png
|
||||||
|
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||||
|
fi
|
||||||
|
|
||||||
|
export DEPLOY_GTK_VERSION=3
|
||||||
|
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || {
|
||||||
|
echo "⚠️ AppImage 生成失败,但 tar.gz 已成功生成"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || {
|
||||||
|
echo "⚠️ AppImage 重命名失败"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -f "$APPIMAGE_NAME" ]; then
|
||||||
|
mv "$APPIMAGE_NAME" ../../
|
||||||
|
echo "✅ AppImage 生成成功"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dev-build-artifacts-${{ strategy.job-index }}
|
||||||
|
path: |
|
||||||
|
GoNavi-*.dmg
|
||||||
|
GoNavi-*.exe
|
||||||
|
GoNavi-*.tar.gz
|
||||||
|
GoNavi-*.AppImage
|
||||||
|
drivers/**
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# 汇总所有产物并发布为 Pre-release
|
||||||
|
release:
|
||||||
|
name: Publish Dev Pre-release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download All Artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: release-assets
|
||||||
|
pattern: dev-build-artifacts-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: List Assets
|
||||||
|
run: ls -R release-assets
|
||||||
|
|
||||||
|
- name: Package Driver Agents Bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd release-assets
|
||||||
|
if [ ! -d drivers ]; then
|
||||||
|
echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then
|
||||||
|
echo "⚠️ drivers 目录为空,跳过驱动总包打包"
|
||||||
|
rm -rf drivers
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 打包驱动总包:GoNavi-DriverAgents.zip"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
out_name = "GoNavi-DriverAgents.zip"
|
||||||
|
index_name = "GoNavi-DriverAgents-Index.json"
|
||||||
|
base = Path("drivers")
|
||||||
|
out_path = Path(out_name)
|
||||||
|
index_path = Path(index_name)
|
||||||
|
if out_path.exists():
|
||||||
|
out_path.unlink()
|
||||||
|
if index_path.exists():
|
||||||
|
index_path.unlink()
|
||||||
|
|
||||||
|
size_index = {}
|
||||||
|
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for p in base.rglob("*"):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
arcname = p.relative_to(base).as_posix()
|
||||||
|
zf.write(p, arcname)
|
||||||
|
size_index[p.name] = p.stat().st_size
|
||||||
|
|
||||||
|
index_path.write_text(
|
||||||
|
json.dumps({"assets": size_index}, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"created {out_name} size={out_path.stat().st_size} bytes")
|
||||||
|
print(f"created {index_name} entries={len(size_index)}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
rm -rf drivers
|
||||||
|
|
||||||
|
- name: Generate SHA256SUMS
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd release-assets
|
||||||
|
FILES=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
if [ -n "$file" ]; then
|
||||||
|
FILES+=("$file")
|
||||||
|
fi
|
||||||
|
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
|
||||||
|
if [ ${#FILES[@]} -eq 0 ]; then
|
||||||
|
echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS"
|
||||||
|
: > SHA256SUMS
|
||||||
|
else
|
||||||
|
sha256sum "${FILES[@]}" > SHA256SUMS
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate Dev Version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
DEV_VERSION="dev-${SHORT_SHA}"
|
||||||
|
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Format Build Time
|
||||||
|
id: build_time
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY' >> "$GITHUB_OUTPUT"
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
raw = "${{ github.event.head_commit.timestamp }}"
|
||||||
|
dt = datetime.fromisoformat(raw)
|
||||||
|
china_tz = timezone(timedelta(hours=8))
|
||||||
|
formatted = dt.astimezone(china_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
print(f"display={formatted}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
# 删除旧的 dev pre-release(保持只有最新一个)
|
||||||
|
- name: Reset Previous Dev Release
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const tag = 'dev-latest';
|
||||||
|
const ref = `tags/${tag}`;
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchedReleases = releases.filter((release) => release.tag_name === tag);
|
||||||
|
if (matchedReleases.length === 0) {
|
||||||
|
core.info(`No existing releases found for tag ${tag}`);
|
||||||
|
} else {
|
||||||
|
for (const release of matchedReleases) {
|
||||||
|
core.info(`Deleting release ${release.id} (${release.name || 'unnamed'}) for tag ${tag}`);
|
||||||
|
await github.rest.repos.deleteRelease({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
release_id: release.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await github.rest.git.deleteRef({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref,
|
||||||
|
});
|
||||||
|
core.info(`Deleted ref ${ref}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
core.info(`No existing ref found for ${ref}`);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Create Dev Pre-release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: dev-latest
|
||||||
|
name: "🧪 Dev Build (${{ steps.version.outputs.version }})"
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
files: release-assets/*
|
||||||
|
prerelease: true
|
||||||
|
draft: false
|
||||||
|
body: |
|
||||||
|
## 🧪 测试版本 (Dev Build)
|
||||||
|
|
||||||
|
**版本**: `${{ steps.version.outputs.version }}`
|
||||||
|
**分支**: `dev`
|
||||||
|
**提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
|
||||||
|
**构建时间**: ${{ steps.build_time.outputs.display }}
|
||||||
|
|
||||||
|
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
|
||||||
|
> 每次 push 到 `dev` 分支会自动覆盖此 release。
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
3
.github/workflows/release-winget.yml
vendored
@@ -10,6 +10,9 @@ on:
|
|||||||
description: 'Tag of release you want to publish'
|
description: 'Tag of release you want to publish'
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|||||||
305
.github/workflows/release.yml
vendored
@@ -8,6 +8,9 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Phase 1: Build in parallel and output artifacts
|
# Phase 1: Build in parallel and output artifacts
|
||||||
build:
|
build:
|
||||||
@@ -88,6 +91,26 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install UPX (Windows)
|
||||||
|
if: contains(matrix.platform, 'windows')
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$UPX_VERSION = "4.2.4"
|
||||||
|
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
|
||||||
|
$zipPath = "$env:RUNNER_TEMP\upx.zip"
|
||||||
|
$extractPath = "$env:RUNNER_TEMP\upx"
|
||||||
|
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
|
||||||
|
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||||||
|
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
|
||||||
|
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||||
|
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
|
||||||
|
if (!(Test-Path $upxCmd)) {
|
||||||
|
Write-Error "❌ 未检测到 upx,无法保证 Windows 产物经过压缩"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
& $upxCmd --version
|
||||||
|
|
||||||
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
|
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
|
||||||
- name: Install Linux Dependencies
|
- name: Install Linux Dependencies
|
||||||
if: contains(matrix.platform, 'linux')
|
if: contains(matrix.platform, 'linux')
|
||||||
@@ -102,6 +125,9 @@ jobs:
|
|||||||
sudo apt-get install -y libwebkit2gtk-4.0-dev
|
sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
|
||||||
|
upx --version
|
||||||
|
|
||||||
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
|
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
|
||||||
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||||
|
|
||||||
@@ -131,15 +157,91 @@ jobs:
|
|||||||
- name: Install Wails
|
- name: Install Wails
|
||||||
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
|
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
|
|
||||||
|
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
|
||||||
|
id: msys2_duckdb
|
||||||
|
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||||
|
continue-on-error: true
|
||||||
|
uses: msys2/setup-msys2@v2
|
||||||
|
with:
|
||||||
|
msystem: UCRT64
|
||||||
|
update: true
|
||||||
|
install: >-
|
||||||
|
mingw-w64-ucrt-x86_64-gcc
|
||||||
|
|
||||||
|
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
|
||||||
|
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
function Find-MingwBin([string[]]$candidates) {
|
||||||
|
foreach ($bin in $candidates) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($bin)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$gcc = Join-Path $bin 'gcc.exe'
|
||||||
|
$gxx = Join-Path $bin 'g++.exe'
|
||||||
|
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
|
||||||
|
return $bin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$msys2Outcome = "${{ steps.msys2_duckdb.outcome }}"
|
||||||
|
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
|
||||||
|
$candidateBins = @()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
|
||||||
|
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
|
||||||
|
}
|
||||||
|
$candidateBins += @(
|
||||||
|
'C:\msys64\ucrt64\bin',
|
||||||
|
'D:\a\_temp\msys64\ucrt64\bin'
|
||||||
|
)
|
||||||
|
$candidateBins = @($candidateBins | Select-Object -Unique)
|
||||||
|
|
||||||
|
$mingwBin = Find-MingwBin $candidateBins
|
||||||
|
if (-not $mingwBin) {
|
||||||
|
if ($msys2Outcome -ne 'success') {
|
||||||
|
Write-Warning "⚠️ MSYS2 安装步骤结果为 $msys2Outcome,回退到 UCRT64 本机路径探测"
|
||||||
|
} else {
|
||||||
|
Write-Warning "⚠️ MSYS2 已执行,但未找到 UCRT64 gcc/g++,回退到本机路径探测"
|
||||||
|
}
|
||||||
|
$mingwBin = Find-MingwBin $candidateBins
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $mingwBin) {
|
||||||
|
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。已检查:$($candidateBins -join ', ')"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$gcc = (Join-Path $mingwBin 'gcc.exe')
|
||||||
|
$gxx = (Join-Path $mingwBin 'g++.exe')
|
||||||
|
|
||||||
|
if (!(Test-Path $gcc) -or !(Test-Path $gxx)) {
|
||||||
|
Write-Error "❌ DuckDB 编译器缺失:gcc=$gcc g++=$gxx"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||||
|
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx"
|
||||||
|
|
||||||
|
- name: Verify DuckDB CGO Toolchain (Windows AMD64)
|
||||||
|
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
& "$env:CC" --version
|
||||||
|
& "$env:CXX" --version
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG_ARGS=()
|
|
||||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||||
TAG_ARGS+=(-tags "${{ matrix.wails_tags }}")
|
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||||
|
else
|
||||||
|
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||||
fi
|
fi
|
||||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} "${TAG_ARGS[@]}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
|
||||||
|
|
||||||
- name: Build Optional Driver Agents
|
- name: Build Optional Driver Agents
|
||||||
if: ${{ matrix.build_optional_agents }}
|
if: ${{ matrix.build_optional_agents }}
|
||||||
@@ -149,12 +251,20 @@ jobs:
|
|||||||
TARGET_PLATFORM="${{ matrix.platform }}"
|
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||||
GOOS="${TARGET_PLATFORM%%/*}"
|
GOOS="${TARGET_PLATFORM%%/*}"
|
||||||
GOARCH="${TARGET_PLATFORM##*/}"
|
GOARCH="${TARGET_PLATFORM##*/}"
|
||||||
DRIVERS=(mariadb diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine)
|
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||||
mkdir -p "$OUTDIR"
|
mkdir -p "$OUTDIR"
|
||||||
|
|
||||||
for DRIVER in "${DRIVERS[@]}"; do
|
for DRIVER in "${DRIVERS[@]}"; do
|
||||||
TAG="gonavi_${DRIVER}_driver"
|
BUILD_DRIVER="$DRIVER"
|
||||||
|
if [ "$DRIVER" = "doris" ]; then
|
||||||
|
BUILD_DRIVER="diros"
|
||||||
|
fi
|
||||||
|
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
|
||||||
|
echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
TAG="gonavi_${BUILD_DRIVER}_driver"
|
||||||
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
||||||
if [ "$GOOS" = "windows" ]; then
|
if [ "$GOOS" = "windows" ]; then
|
||||||
OUTPUT="${OUTPUT}.exe"
|
OUTPUT="${OUTPUT}.exe"
|
||||||
@@ -162,20 +272,12 @@ jobs:
|
|||||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||||
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
|
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
|
||||||
if [ "$DRIVER" = "duckdb" ]; then
|
if [ "$DRIVER" = "duckdb" ]; then
|
||||||
set +e
|
|
||||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||||
-tags "${TAG}" \
|
-tags "${TAG}" \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-ldflags "-s -w" \
|
-ldflags "-s -w" \
|
||||||
-o "${OUTPUT_PATH}" \
|
-o "${OUTPUT_PATH}" \
|
||||||
./cmd/optional-driver-agent
|
./cmd/optional-driver-agent
|
||||||
DUCKDB_RC=$?
|
|
||||||
set -e
|
|
||||||
if [ "${DUCKDB_RC}" -ne 0 ]; then
|
|
||||||
echo "⚠️ DuckDB 代理构建失败(平台 ${GOOS}/${GOARCH}),跳过该资产,不阻断发布"
|
|
||||||
rm -f "${OUTPUT_PATH}"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||||
-tags "${TAG}" \
|
-tags "${TAG}" \
|
||||||
@@ -201,9 +303,21 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
APP_NAME=$(basename "$APP_PATH")
|
APP_NAME=$(basename "$APP_PATH")
|
||||||
|
|
||||||
|
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
|
||||||
|
if [ -z "$APP_BIN" ]; then
|
||||||
|
echo "❌ 未找到 macOS 应用主程序!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。"
|
||||||
|
|
||||||
echo "🔏 正在进行 Ad-hoc 签名..."
|
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||||
codesign --force --options runtime --deep --sign - "$APP_NAME"
|
# 注意:Ad-hoc + hardened runtime(--options runtime)在未配置 entitlements 时,
|
||||||
|
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
|
||||||
|
if command -v xattr >/dev/null 2>&1; then
|
||||||
|
xattr -cr "$APP_NAME" || true
|
||||||
|
fi
|
||||||
|
codesign --force --deep --sign - "$APP_NAME"
|
||||||
|
|
||||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||||
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
|
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
|
||||||
@@ -219,11 +333,22 @@ jobs:
|
|||||||
--app-drop-link 600 185 \
|
--app-drop-link 600 185 \
|
||||||
"$DMG_NAME" \
|
"$DMG_NAME" \
|
||||||
"$APP_NAME"
|
"$APP_NAME"
|
||||||
|
|
||||||
|
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-release-verify.XXXXXX")
|
||||||
|
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
|
||||||
|
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
|
||||||
|
if [ -z "$PACKAGED_APP" ]; then
|
||||||
|
echo "❌ DMG 内未找到 .app 应用包!"
|
||||||
|
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
|
||||||
|
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||||
|
|
||||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||||
|
|
||||||
# Windows Packaging
|
# Windows Packaging
|
||||||
- name: Package Windows Portable Zip
|
- name: Package Windows EXE
|
||||||
if: contains(matrix.platform, 'windows')
|
if: contains(matrix.platform, 'windows')
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -234,7 +359,6 @@ jobs:
|
|||||||
}
|
}
|
||||||
$target = "${{ matrix.build_name }}"
|
$target = "${{ matrix.build_name }}"
|
||||||
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
|
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
|
||||||
$finalZipName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.zip"
|
|
||||||
|
|
||||||
if (Test-Path "$target.exe") {
|
if (Test-Path "$target.exe") {
|
||||||
$finalExe = "$target.exe"
|
$finalExe = "$target.exe"
|
||||||
@@ -246,11 +370,39 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "📦 生成 Windows 可执行文件 $finalExeName..."
|
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
|
||||||
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
|
if ($isArm64Target) {
|
||||||
|
Write-Warning "⚠️ UPX 当前不支持 win64/arm64,跳过压缩并保留原始 EXE。"
|
||||||
|
$LASTEXITCODE = 0
|
||||||
|
} else {
|
||||||
|
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $upxCmd) {
|
||||||
|
Write-Error "❌ 未找到 upx,无法保证 Windows 产物经过压缩"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
|
||||||
|
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
|
||||||
|
& upx --best --lzma --force $finalExe | Out-Host
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "❌ UPX 压缩失败($LASTEXITCODE)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
& upx -t $finalExe | Out-Host
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "❌ UPX 校验失败($LASTEXITCODE)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
|
||||||
|
if ($afterBytes -lt $beforeBytes) {
|
||||||
|
$savedBytes = $beforeBytes - $afterBytes
|
||||||
|
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB,减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
|
||||||
|
} else {
|
||||||
|
Write-Host ("ℹ️ UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "📦 生成 Windows 压缩包 $finalZipName..."
|
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
|
||||||
Compress-Archive -LiteralPath $finalExe -DestinationPath "..\\..\\$finalZipName" -Force
|
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
|
||||||
|
|
||||||
# Linux Packaging (tar.gz and AppImage)
|
# Linux Packaging (tar.gz and AppImage)
|
||||||
- name: Package Linux
|
- name: Package Linux
|
||||||
@@ -269,6 +421,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
chmod +x "$TARGET"
|
chmod +x "$TARGET"
|
||||||
|
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
|
||||||
|
echo "🗜️ 正在使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
|
||||||
|
upx --best --lzma --force "$TARGET"
|
||||||
|
upx -t "$TARGET"
|
||||||
|
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
|
||||||
|
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
|
||||||
|
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
|
||||||
|
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
|
||||||
|
else
|
||||||
|
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf "ℹ️ Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
|
||||||
|
fi
|
||||||
|
|
||||||
# 1. Create tar.gz
|
# 1. Create tar.gz
|
||||||
echo "📦 正在打包 $TAR_NAME..."
|
echo "📦 正在打包 $TAR_NAME..."
|
||||||
@@ -341,7 +504,6 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
GoNavi-*.dmg
|
GoNavi-*.dmg
|
||||||
GoNavi-*.exe
|
GoNavi-*.exe
|
||||||
GoNavi-*.zip
|
|
||||||
GoNavi-*.tar.gz
|
GoNavi-*.tar.gz
|
||||||
GoNavi-*.AppImage
|
GoNavi-*.AppImage
|
||||||
drivers/**
|
drivers/**
|
||||||
@@ -363,6 +525,38 @@ jobs:
|
|||||||
- name: List Assets
|
- name: List Assets
|
||||||
run: ls -R release-assets
|
run: ls -R release-assets
|
||||||
|
|
||||||
|
- name: Verify Optional Driver Assets
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd release-assets
|
||||||
|
|
||||||
|
REQUIRED_FILES=(
|
||||||
|
"drivers/Windows/duckdb-driver-agent-windows-amd64.exe"
|
||||||
|
"drivers/MacOS/duckdb-driver-agent-darwin-amd64"
|
||||||
|
"drivers/MacOS/duckdb-driver-agent-darwin-arm64"
|
||||||
|
"drivers/Linux/duckdb-driver-agent-linux-amd64"
|
||||||
|
"drivers/Windows/clickhouse-driver-agent-windows-amd64.exe"
|
||||||
|
"drivers/MacOS/clickhouse-driver-agent-darwin-amd64"
|
||||||
|
"drivers/MacOS/clickhouse-driver-agent-darwin-arm64"
|
||||||
|
"drivers/Linux/clickhouse-driver-agent-linux-amd64"
|
||||||
|
)
|
||||||
|
|
||||||
|
missing=0
|
||||||
|
for file in "${REQUIRED_FILES[@]}"; do
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
echo "❌ 缺少驱动资产:$file"
|
||||||
|
missing=1
|
||||||
|
else
|
||||||
|
echo "✅ 已找到驱动资产:$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$missing" -ne 0 ]; then
|
||||||
|
echo "❌ 可选驱动资产不完整,终止发布"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Package Driver Agents Bundle
|
- name: Package Driver Agents Bundle
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -433,6 +627,74 @@ jobs:
|
|||||||
sha256sum "${FILES[@]}" > SHA256SUMS
|
sha256sum "${FILES[@]}" > SHA256SUMS
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Checkout code for changelog
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
path: repo-for-changelog
|
||||||
|
|
||||||
|
- name: Generate Changelog
|
||||||
|
id: changelog
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd repo-for-changelog
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
# 获取上一个 tag
|
||||||
|
PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true)
|
||||||
|
if [ -z "$PREV_TAG" ]; then
|
||||||
|
echo "⚠️ 未找到上一个 tag,使用全部 commit"
|
||||||
|
RANGE="$TAG"
|
||||||
|
else
|
||||||
|
RANGE="${PREV_TAG}..${TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📋 生成更新日志:$RANGE"
|
||||||
|
|
||||||
|
# 提取 commit 消息(排除 merge commit)
|
||||||
|
COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true)
|
||||||
|
if [ -z "$COMMITS" ]; then
|
||||||
|
BODY="暂无提交记录。"
|
||||||
|
else
|
||||||
|
CAT_FEAT=""
|
||||||
|
CAT_FIX=""
|
||||||
|
CAT_PERF=""
|
||||||
|
CAT_REFACTOR=""
|
||||||
|
CAT_I18N=""
|
||||||
|
CAT_OTHER=""
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -z "$line" ] && continue
|
||||||
|
case "$line" in
|
||||||
|
✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;;
|
||||||
|
🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;;
|
||||||
|
⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;;
|
||||||
|
♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;;
|
||||||
|
🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;;
|
||||||
|
🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
|
||||||
|
*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
|
||||||
|
esac
|
||||||
|
done <<< "$COMMITS"
|
||||||
|
|
||||||
|
BODY=""
|
||||||
|
[ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n"
|
||||||
|
[ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n"
|
||||||
|
[ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n"
|
||||||
|
[ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n"
|
||||||
|
[ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n"
|
||||||
|
[ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n"
|
||||||
|
|
||||||
|
# 附加 compare 链接
|
||||||
|
if [ -n "$PREV_TAG" ]; then
|
||||||
|
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
|
||||||
|
BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 写入到文件避免多行环境变量问题
|
||||||
|
printf '%b' "$BODY" > /tmp/changelog.md
|
||||||
|
echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
@@ -440,5 +702,6 @@ jobs:
|
|||||||
files: release-assets/*
|
files: release-assets/*
|
||||||
draft: true
|
draft: true
|
||||||
make_latest: true
|
make_latest: true
|
||||||
|
body_path: ${{ steps.changelog.outputs.changelog_file }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
15
.gitignore
vendored
@@ -1,7 +1,7 @@
|
|||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
|
.gitignore
|
||||||
# build / release artifacts
|
# build / release artifacts
|
||||||
frontend/release/
|
frontend/release/
|
||||||
**/release/
|
**/release/
|
||||||
@@ -17,5 +17,16 @@ dist/
|
|||||||
GoNavi-Wails
|
GoNavi-Wails
|
||||||
GoNavi-Wails.exe
|
GoNavi-Wails.exe
|
||||||
.ace-tool/
|
.ace-tool/
|
||||||
|
.superpowers/
|
||||||
.claude/
|
.claude/
|
||||||
tmpclaude-*
|
.gemini/
|
||||||
|
.playwright-mcp/
|
||||||
|
**/tmpclaude-*
|
||||||
|
docs/superpowers/
|
||||||
|
docs/需求追踪/
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
|
**/CLAUDE.md
|
||||||
|
.worktrees
|
||||||
|
docs
|
||||||
|
.tmp_superpowers_edit
|
||||||
|
|||||||
143
CONTRIBUTING.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Contributing Guide
|
||||||
|
|
||||||
|
Thank you for contributing to this project.
|
||||||
|
|
||||||
|
This repository uses `dev` as the default integration branch, while stable releases are published from `main` through `release/*` branches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Model
|
||||||
|
|
||||||
|
- `dev`: default branch and day-to-day integration branch
|
||||||
|
- `main`: stable release branch
|
||||||
|
- `release/*`: release preparation branches for maintainers
|
||||||
|
- Recommended branch names for external contributors:
|
||||||
|
- `fix/*`: bug fixes
|
||||||
|
- `feature/*`: new features or enhancements
|
||||||
|
|
||||||
|
Maintainer release flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How External Contributors Should Open Pull Requests
|
||||||
|
|
||||||
|
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `dev`**.
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- `dev` is the active integration branch, so changes can be reviewed in the same lane as ongoing work
|
||||||
|
- contributors align with the branch that triggers day-to-day validation and dev builds
|
||||||
|
- maintainers can cut `release/*` branches from `dev` without re-syncing external changes first
|
||||||
|
|
||||||
|
Recommended flow:
|
||||||
|
|
||||||
|
1. Fork this repository
|
||||||
|
2. Sync your fork with `dev` and create a branch from `dev` (`fix/*` or `feature/*` is recommended)
|
||||||
|
3. Make your changes and perform basic self-checks
|
||||||
|
4. Push the branch to your fork
|
||||||
|
5. Open a pull request against the `dev` branch of this repository
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull Request Requirements
|
||||||
|
|
||||||
|
Please keep each pull request focused, reviewable, and easy to validate.
|
||||||
|
|
||||||
|
Recommended expectations:
|
||||||
|
|
||||||
|
- one pull request should address one logical change
|
||||||
|
- use a clear title that explains the purpose
|
||||||
|
- include the following in the description:
|
||||||
|
- background and problem statement
|
||||||
|
- key changes
|
||||||
|
- impact scope
|
||||||
|
- validation method
|
||||||
|
- include screenshots or recordings for UI changes when helpful
|
||||||
|
- explicitly mention risk and rollback notes for compatibility, data, or build-chain changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Merge Strategy for Maintainers
|
||||||
|
|
||||||
|
Pull requests merged into `dev` should generally use **Squash and merge**.
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- keeps `dev` history readable and easier to audit during active iteration
|
||||||
|
- maps each PR to a single integration commit on `dev`
|
||||||
|
- reduces cherry-pick and conflict cost before creating `release/*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintainer Sync Rules
|
||||||
|
|
||||||
|
Because external pull requests are merged directly into `dev`, maintainers should treat `dev` as the source branch for daily collaboration and release preparation.
|
||||||
|
|
||||||
|
### 1. Create `release/*` from `dev`
|
||||||
|
|
||||||
|
Before a release, create a release branch from `dev`, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout dev
|
||||||
|
git pull
|
||||||
|
git checkout -b release/v0.6.0
|
||||||
|
git push -u origin release/v0.6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Release from `release/*` back to `main`
|
||||||
|
|
||||||
|
When release preparation is complete, merge the release branch back into `main` and create a tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git merge release/v0.6.0
|
||||||
|
git push
|
||||||
|
git tag v0.6.0
|
||||||
|
git push origin v0.6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Sync `main` back to `dev` after release
|
||||||
|
|
||||||
|
After the release, sync `main` back into `dev` so the next iteration starts from the released code line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout dev
|
||||||
|
git pull
|
||||||
|
git merge main
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Message Recommendation
|
||||||
|
|
||||||
|
Keep commit messages clear and easy to audit.
|
||||||
|
|
||||||
|
Recommended format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
emoji type(scope): concise description
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
🔧 fix(ci): fix DuckDB driver toolchain on Windows AMD64
|
||||||
|
✨ feat(redis): add Stream data browsing support
|
||||||
|
♻️ refactor(datagrid): optimize large-table horizontal scrolling and rendering
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Notes
|
||||||
|
|
||||||
|
- Please include validation results for documentation, build-chain, or driver compatibility changes
|
||||||
|
- For larger changes, opening an issue or draft PR first is recommended
|
||||||
|
- Maintainers may ask contributors to narrow the scope if the change conflicts with the current project direction
|
||||||
|
|
||||||
|
Thank you for contributing.
|
||||||
143
CONTRIBUTING.zh-CN.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# 贡献指南
|
||||||
|
|
||||||
|
感谢你对本项目的贡献。
|
||||||
|
|
||||||
|
本项目当前采用“`dev` 作为默认集成分支,`main` 作为稳定发布分支,`release/*` 负责发版准备”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 分支模型
|
||||||
|
|
||||||
|
- `dev`:默认分支,也是日常开发集成分支
|
||||||
|
- `main`:稳定发布分支
|
||||||
|
- `release/*`:发布准备分支,主要供维护者使用
|
||||||
|
- 外部贡献者建议使用以下分支命名:
|
||||||
|
- `fix/*`:问题修复
|
||||||
|
- `feature/*`:功能新增或增强
|
||||||
|
|
||||||
|
维护者发布流转如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 外部贡献者如何提 Pull Request
|
||||||
|
|
||||||
|
无论是 `fix/*` 还是 `feature/*`,**外部贡献者统一直接向 `dev` 发起 Pull Request**。
|
||||||
|
|
||||||
|
这样做的原因:
|
||||||
|
|
||||||
|
- `dev` 是当前日常集成分支,评审与合入路径和维护者开发流程一致
|
||||||
|
- 外部贡献会直接进入触发日常校验和 dev 构建的分支
|
||||||
|
- 维护者可以直接从 `dev` 切 `release/*`,减少额外同步步骤
|
||||||
|
|
||||||
|
建议流程:
|
||||||
|
|
||||||
|
1. Fork 本仓库
|
||||||
|
2. 先同步你 fork 中的 `dev`,再从 `dev` 创建分支(建议命名为 `fix/*` 或 `feature/*`)
|
||||||
|
3. 完成代码修改,并进行必要自检
|
||||||
|
4. 推送到你的远程分支
|
||||||
|
5. 向本仓库的 `dev` 分支发起 Pull Request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull Request 要求
|
||||||
|
|
||||||
|
请尽量保证 PR 单一、清晰、可审核。
|
||||||
|
|
||||||
|
建议遵循以下要求:
|
||||||
|
|
||||||
|
- 一个 PR 只解决一类问题,避免混入无关改动
|
||||||
|
- 标题清晰说明改动目的
|
||||||
|
- 描述中说明:
|
||||||
|
- 背景与问题
|
||||||
|
- 变更点
|
||||||
|
- 影响范围
|
||||||
|
- 验证方式
|
||||||
|
- 如涉及 UI 调整,建议附截图或录屏
|
||||||
|
- 如涉及兼容性、数据变更或构建链路调整,请明确说明风险和回滚方式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR 合并策略(维护者)
|
||||||
|
|
||||||
|
`dev` 分支上的 PR 建议使用 **Squash and merge**。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 保持 `dev` 集成历史清晰、便于审查
|
||||||
|
- 每个 PR 在 `dev` 上对应一个明确的集成提交
|
||||||
|
- 降低发版前整理与冲突处理成本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 维护者同步规则
|
||||||
|
|
||||||
|
由于外部 PR 会直接合入 `dev`,维护者应将 `dev` 作为日常协作与发版准备的主线分支。
|
||||||
|
|
||||||
|
### 1. 发版前从 dev 切 release/*
|
||||||
|
|
||||||
|
发布前由维护者基于 `dev` 创建发布分支,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout dev
|
||||||
|
git pull
|
||||||
|
git checkout -b release/v0.6.0
|
||||||
|
git push -u origin release/v0.6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. release/* → main 发版
|
||||||
|
|
||||||
|
发布准备完成后,将 `release/*` 合并回 `main`,并打标签发布:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git merge release/v0.6.0
|
||||||
|
git push
|
||||||
|
git tag v0.6.0
|
||||||
|
git push origin v0.6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. main 回流到 dev(发版后必做)
|
||||||
|
|
||||||
|
发布完成后,需要将 `main` 回流到 `dev`,确保下一轮开发从已发布代码线继续推进:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout dev
|
||||||
|
git pull
|
||||||
|
git merge main
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 提交建议
|
||||||
|
|
||||||
|
建议保持提交信息简洁、明确,便于维护者审查与后续追踪。
|
||||||
|
|
||||||
|
推荐格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
emoji type(scope): 中文描述
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链
|
||||||
|
✨ feat(redis): 新增 Stream 类型数据浏览支持
|
||||||
|
♻️ refactor(datagrid): 优化大表横向滚动与渲染结构
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 其他说明
|
||||||
|
|
||||||
|
- 文档、构建链路、驱动兼容性相关改动,请尽量附带验证结果
|
||||||
|
- 若改动较大,建议先提 Issue 或 Draft PR,先对齐方案再实施
|
||||||
|
- 如提交内容与项目当前架构方向冲突,维护者可能要求收敛范围后再合并
|
||||||
|
|
||||||
|
感谢你的贡献。
|
||||||
260
README.md
@@ -1,160 +1,194 @@
|
|||||||
# GoNavi - 现代化的轻量级数据库管理工具
|
# GoNavi - A Modern Lightweight Database Client
|
||||||
|
|
||||||
[](https://go.dev/)
|
[](https://go.dev/)
|
||||||
[](https://wails.io)
|
[](https://wails.io)
|
||||||
[](https://reactjs.org/)
|
[](https://reactjs.org/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/Syngnat/GoNavi/actions)
|
[](https://github.com/Syngnat/GoNavi/actions)
|
||||||
|
[](https://github.com/Syngnat/GoNavi/stargazers)
|
||||||
|
[](https://github.com/Syngnat/GoNavi/releases)
|
||||||
|
|
||||||
**GoNavi** 是一款基于 **Wails (Go)** 和 **React** 构建的现代化、高性能、跨平台数据库管理客户端。它旨在提供如原生应用般流畅的用户体验,同时保持极低的资源占用。
|
**Language**: English | [简体中文](README.zh-CN.md)
|
||||||
|
|
||||||
相比于 Electron 应用,GoNavi 的体积更小(~10MB),启动速度更快,内存占用更低。
|
GoNavi is a modern, high-performance, cross-platform database client built with **Wails (Go)** and **React**.
|
||||||
|
It delivers native-like responsiveness with low resource usage.
|
||||||
|
|
||||||
<h2 align="center">📸 项目截图</h2>
|
Compared with many Electron-based clients, GoNavi is typically smaller in binary size (around 10MB class), starts faster, and uses less memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
GoNavi is designed for developers and DBAs who need a unified desktop experience across multiple databases.
|
||||||
|
|
||||||
|
- **Native-performance architecture**: Wails (Go + WebView) with lightweight runtime overhead.
|
||||||
|
- **Large dataset usability**: virtualized rendering and optimized DataGrid workflows for high-volume tables.
|
||||||
|
- **Unified connectivity**: URI build/parse, SSH tunnel, proxy support, and on-demand driver activation.
|
||||||
|
- **Production-oriented workflow**: SQL editor, object management, batch export/backup, sync tools, execution logs, and update checks.
|
||||||
|
|
||||||
|
## Supported Data Sources
|
||||||
|
|
||||||
|
> `Built-in`: available out of the box.
|
||||||
|
> `Optional driver agent`: install/enable via Driver Manager first.
|
||||||
|
|
||||||
|
| Category | Data Source | Driver Mode | Typical Capabilities |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Relational | MySQL | Built-in | Schema browsing, SQL query, data editing, export/backup |
|
||||||
|
| 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 |
|
||||||
|
| Relational | MariaDB | Optional driver agent | Querying, object management, data editing |
|
||||||
|
| Relational | Doris | Optional driver agent | Querying, object browsing, SQL execution |
|
||||||
|
| Search | Sphinx | Optional driver agent | SphinxQL querying and object browsing |
|
||||||
|
| 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 | 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 |
|
||||||
|
| Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management |
|
||||||
|
| Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying |
|
||||||
|
| Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution |
|
||||||
|
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
|
||||||
|
|
||||||
|
<h2 align="center">📸 Screenshots</h2>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
|
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
|
||||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
|
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
|
||||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
|
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
|
||||||
<br />
|
<br />
|
||||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
|
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
|
||||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
|
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
|
||||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
|
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ 核心特性
|
## Key Features
|
||||||
|
|
||||||
### 🚀 极致性能
|
### AI Assistant (New)
|
||||||
- **零卡顿交互**:采用独创的 "幽灵拖拽" (Ghost Resizing) 技术,在包含数万行数据的表格中调整列宽,依然保持 60fps+ 的丝滑体验。
|
- **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.
|
||||||
|
|
||||||
### 🔌 多数据库支持
|
### Performance
|
||||||
- **MySQL**:完整支持,涵盖数据编辑、结构管理与导入导出。
|
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
|
||||||
- **PostgreSQL**:数据查看与编辑支持,事务提交能力持续完善。
|
- **Virtualized rendering**: keeps large result sets responsive.
|
||||||
- **SQLite**:本地文件数据库支持。
|
|
||||||
- **Oracle**:基础数据访问与编辑支持。
|
|
||||||
- **Dameng(达梦)**:基础数据访问与编辑支持。
|
|
||||||
- **Kingbase(人大金仓)**:基础数据访问与编辑支持。
|
|
||||||
- **TDengine**:时序数据库连接、库表浏览与 SQL 查询支持。
|
|
||||||
- **Redis**:Key/Value 浏览、命令执行、视图与编码切换。
|
|
||||||
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
|
|
||||||
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
|
|
||||||
|
|
||||||
### 📊 强大的数据管理 (DataGrid)
|
### Data Management (DataGrid)
|
||||||
- **所见即所得编辑**:直接在表格中双击单元格修改数据。
|
- In-place cell editing.
|
||||||
- **批量事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
|
- Batch insert/update/delete with transaction-oriented submit/rollback.
|
||||||
- **大字段编辑**:双击大字段自动打开弹窗编辑器,避免卡顿。
|
- Large-field popup editor.
|
||||||
- **右键上下文菜单**:快速设置 NULL、复制/导出等操作。
|
- Context actions (set NULL, copy/export, etc.).
|
||||||
- **智能上下文**:自动识别单表查询,解锁编辑功能;复杂查询自动切换为只读模式。
|
- Smart read/write mode switching based on query context.
|
||||||
- **批量导出/备份**:支持表与数据库的批量导出/备份。
|
- Export formats: CSV, Excel (XLSX), JSON, Markdown.
|
||||||
- **数据导出**:支持 CSV、Excel (XLSX)、JSON、Markdown 等格式。
|
|
||||||
|
|
||||||
### 🧰 批量导出/备份
|
### SQL Editor
|
||||||
- **数据库批量导出**:支持结构导出与结构+数据备份。
|
- Monaco Editor core.
|
||||||
- **表批量导出**:支持多表一键导出/备份。
|
- Context-aware completion for databases/tables/columns.
|
||||||
- **智能上下文检测**:自动判断目标范围,避免误操作。
|
- Multi-tab query workflow.
|
||||||
|
|
||||||
### 🧩 Redis 视图与编码
|
### Batch Export / Backup
|
||||||
- **视图模式切换**:自动/原始文本/UTF-8/十六进制多模式显示。
|
- Database-level and table-level batch export/backup.
|
||||||
- **智能解码**:针对二进制值进行 UTF-8 质量判定与中文字符识别。
|
- Scope-aware operation flow to reduce mistakes.
|
||||||
- **命令执行**:内置命令面板快速操作。
|
|
||||||
|
|
||||||
### 🔄 数据同步与导入导出
|
### Connectivity
|
||||||
- **连接配置导入/导出**:支持配置 JSON 导入导出,便于团队共享。
|
- URI generation/parsing.
|
||||||
- **数据同步**:内置数据同步面板,支持跨库同步任务配置。
|
- SSH tunnel support.
|
||||||
|
- Proxy support.
|
||||||
|
- Config import/export (JSON).
|
||||||
|
- Optional driver management and activation.
|
||||||
|
|
||||||
### 🆙 在线更新
|
### Redis Tools
|
||||||
- **自动更新**:启动/定时/手动检查更新,自动下载并提示重启完成更新。
|
- Multi-view value rendering (auto/raw text/UTF-8/hex).
|
||||||
|
- Built-in command execution panel.
|
||||||
|
|
||||||
### 🧾 可观测性
|
### Observability and Update
|
||||||
- **SQL 执行日志**:实时查看 SQL 与执行耗时,便于排障与优化。
|
- SQL execution logs with timing information.
|
||||||
|
- Startup/scheduled/manual update checks.
|
||||||
|
|
||||||
### 📝 智能 SQL 编辑器
|
### UI/UX
|
||||||
- **Monaco Editor 内核**:集成 VS Code 同款编辑器,体验极佳。
|
- Ant Design 5 based interface.
|
||||||
- **智能补全**:自动感知当前连接上下文,提供数据库、表名、字段名的实时补全。
|
- Light/Dark themes.
|
||||||
- **多标签页**:支持多窗口并行操作,像浏览器一样管理你的查询会话。
|
- Flexible sidebar and layout behavior.
|
||||||
|
|
||||||
### 🎨 现代化 UI
|
|
||||||
- **Ant Design 5**:企业级 UI 设计语言。
|
|
||||||
- **暗黑模式**:内置深色/浅色主题切换,适应不同光照环境。
|
|
||||||
- **响应式布局**:灵活的侧边栏与布局调整。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ 技术栈
|
## Tech Stack
|
||||||
|
|
||||||
* **后端 (Backend)**: Go 1.24 + Wails v2
|
- **Backend**: Go 1.24 + Wails v2
|
||||||
* **前端 (Frontend)**: React 18 + TypeScript + Vite
|
- **Frontend**: React 18 + TypeScript + Vite
|
||||||
* **UI 框架**: Ant Design 5
|
- **UI**: Ant Design 5
|
||||||
* **状态管理**: Zustand
|
- **State Management**: Zustand
|
||||||
* **编辑器**: Monaco Editor
|
- **Editor**: Monaco Editor
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 安装与运行
|
## Installation and Run
|
||||||
|
|
||||||
### 前置要求
|
### Prerequisites
|
||||||
* [Go](https://go.dev/dl/) 1.21+
|
- [Go](https://go.dev/dl/) 1.21+
|
||||||
* [Node.js](https://nodejs.org/) 18+
|
- [Node.js](https://nodejs.org/) 18+
|
||||||
* [Wails CLI](https://wails.io/docs/gettingstarted/installation): `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
|
||||||
|
`go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||||
|
|
||||||
### 开发模式
|
### Development Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆项目
|
# Clone
|
||||||
git clone https://github.com/Syngnat/GoNavi.git
|
git clone https://github.com/Syngnat/GoNavi.git
|
||||||
cd GoNavi
|
cd GoNavi
|
||||||
|
|
||||||
# 启动开发服务器 (支持热重载)
|
# Start development with hot reload
|
||||||
wails dev
|
wails dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### 编译构建
|
### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建当前平台的可执行文件
|
# Build for current platform
|
||||||
wails build
|
wails build
|
||||||
|
|
||||||
# 清理并构建 (推荐发布前使用)
|
# Clean build (recommended before release)
|
||||||
wails build -clean
|
wails build -clean
|
||||||
```
|
```
|
||||||
|
|
||||||
构建产物将位于 `build/bin` 目录下。
|
Artifacts are generated in `build/bin`.
|
||||||
|
|
||||||
### 跨平台编译 (GitHub Actions)
|
### Cross-Platform Release (GitHub Actions)
|
||||||
|
|
||||||
本项目内置了 GitHub Actions 流水线,Push `v*` 格式的 Tag 即可自动触发构建并发布 Release。
|
The repository includes a release workflow.
|
||||||
支持构建:
|
Push a `v*` tag to trigger automated build and release.
|
||||||
* macOS (AMD64 / ARM64)
|
Release notes are generated automatically from merged pull requests and categorized by `.github/release.yaml`.
|
||||||
* Windows (AMD64)
|
|
||||||
* Linux (AMD64,提供 WebKitGTK 4.0 与 4.1 变体产物)
|
Target artifacts include:
|
||||||
|
- macOS (AMD64 / ARM64)
|
||||||
|
- Windows (AMD64)
|
||||||
|
- Linux (AMD64, WebKitGTK 4.0 and 4.1 variants)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ❓ 常见问题 (Troubleshooting)
|
## Troubleshooting
|
||||||
|
|
||||||
### macOS 提示 "应用已损坏,无法打开"
|
### macOS: "App is damaged and can’t be opened"
|
||||||
|
|
||||||
由于本项目尚未购买 Apple 开发者证书进行签名(Notarization),macOS 的 Gatekeeper 安全机制可能会拦截应用的运行。请按照以下步骤解决:
|
Without Apple notarization, Gatekeeper may block startup.
|
||||||
|
|
||||||
1. 将下载的 `GoNavi.app` 拖入 **应用程序** 文件夹。
|
1. Move `GoNavi.app` to **Applications**.
|
||||||
2. 打开 **终端 (Terminal)**。
|
2. Open **Terminal**.
|
||||||
3. 复制并执行以下命令(输入密码时不会显示):
|
3. Run:
|
||||||
```bash
|
|
||||||
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
|
||||||
```
|
|
||||||
4. 或者:在 Finder 中右键点击应用图标,按住 `Control` 键选择 **打开**,然后在弹出的窗口中再次点击 **打开**。
|
|
||||||
|
|
||||||
### Linux 启动报错缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
|
```bash
|
||||||
|
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
||||||
|
```
|
||||||
|
|
||||||
GoNavi 的 Linux 二进制依赖系统 WebKitGTK 运行库。不同发行版默认版本不同:
|
Or right-click the app in Finder and choose **Open** with Control key flow.
|
||||||
|
|
||||||
- Debian 13 / Ubuntu 24.04 及更新版本:通常为 WebKitGTK 4.1
|
### Linux: missing `libwebkit2gtk` / `libjavascriptcoregtk`
|
||||||
- Ubuntu 22.04 / Debian 12 等:通常为 WebKitGTK 4.0
|
|
||||||
|
|
||||||
如果启动时报错(如 `libwebkit2gtk-4.0.so.37: cannot open shared object file`),请按系统安装对应依赖后重试:
|
GoNavi depends on WebKitGTK runtime libraries.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Debian 13 / Ubuntu 24.04+
|
# Debian 13 / Ubuntu 24.04+
|
||||||
@@ -166,20 +200,34 @@ sudo apt-get update
|
|||||||
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
|
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你使用的是 Release 中带 `-WebKit41` 后缀的 Linux 产物,请优先在 Debian 13 / Ubuntu 24.04+ 上使用;普通 Linux 产物更适合 WebKitGTK 4.0 运行环境。
|
If you use Linux artifacts with the `-WebKit41` suffix, prefer Debian 13 / Ubuntu 24.04+.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🤝 贡献指南
|
## Contributing
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request!
|
Issues and pull requests are welcome.
|
||||||
|
|
||||||
1. Fork 本仓库
|
For the full workflow, branch model, and maintainer sync rules, see:
|
||||||
2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. 提交你的改动 (`git commit -m 'feat: Add some AmazingFeature'`)
|
|
||||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
|
||||||
5. 开启一个 Pull Request
|
|
||||||
|
|
||||||
## 📄 开源协议
|
- [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
本项目采用 [Apache-2.0 协议](LICENSE) 开源。
|
External contributors should branch from `dev` and open pull requests against `dev`.
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [linux.do](https://linux.do/)
|
||||||
|
- [AIBook](https://aibook.ren/)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under [Apache-2.0](LICENSE).
|
||||||
|
|||||||
217
README.zh-CN.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# GoNavi - 现代化轻量级数据库客户端
|
||||||
|
|
||||||
|
[](https://go.dev/)
|
||||||
|
[](https://wails.io)
|
||||||
|
[](https://reactjs.org/)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://github.com/Syngnat/GoNavi/actions)
|
||||||
|
[](https://github.com/Syngnat/GoNavi/stargazers)
|
||||||
|
[](https://github.com/Syngnat/GoNavi/releases)
|
||||||
|
|
||||||
|
**语言**: [English](README.md) | 简体中文
|
||||||
|
|
||||||
|
GoNavi 是基于 **Wails (Go)** 与 **React** 构建的跨平台数据库管理工具,强调原生性能、低资源占用与多数据源统一工作流。
|
||||||
|
|
||||||
|
相比常见 Electron 客户端,GoNavi 在体积、启动速度和内存占用上更轻量。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做到“快、稳、统一”。
|
||||||
|
|
||||||
|
- **原生性能架构**:Wails(Go + WebView),降低运行时开销。
|
||||||
|
- **大数据可用性**:虚拟滚动 + DataGrid 交互优化,提升大结果集可操作性。
|
||||||
|
- **统一连接能力**:支持 URI 生成/解析、SSH 隧道、代理、驱动按需安装。
|
||||||
|
- **工程化能力完整**:覆盖 SQL 编辑、对象管理、批量导出/备份、数据同步、执行日志、在线更新。
|
||||||
|
|
||||||
|
## 支持的数据源
|
||||||
|
|
||||||
|
> `内置`:主程序开箱即用。
|
||||||
|
> `可选驱动代理`:需在驱动管理中安装启用后可用。
|
||||||
|
|
||||||
|
| 类别 | 数据源 | 驱动模式 | 典型能力 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 关系型 | MySQL | 内置 | 库表浏览、SQL 查询、数据编辑、导出/备份 |
|
||||||
|
| 关系型 | PostgreSQL | 内置 | 库表浏览、SQL 查询、数据编辑、对象管理 |
|
||||||
|
| 关系型 | Oracle | 内置 | 连接查询、对象浏览、数据编辑 |
|
||||||
|
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
|
||||||
|
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
|
||||||
|
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
|
||||||
|
| 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 |
|
||||||
|
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
|
||||||
|
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
|
||||||
|
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
|
||||||
|
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||||
|
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||||
|
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||||
|
| 国产数据库 | Vastbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||||
|
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
|
||||||
|
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
|
||||||
|
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
|
||||||
|
| 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |
|
||||||
|
|
||||||
|
<h2 align="center">📸 项目截图</h2>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
|
||||||
|
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
|
||||||
|
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
|
||||||
|
<br />
|
||||||
|
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
|
||||||
|
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
|
||||||
|
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心特性
|
||||||
|
|
||||||
|
### AI 智能助手 (New)
|
||||||
|
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude,同时支持任意自定义兼容 OpenAI 格式的 API。
|
||||||
|
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI,让 SQL 生成、分析变得更精准。
|
||||||
|
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等)。
|
||||||
|
|
||||||
|
### 性能与交互
|
||||||
|
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
|
||||||
|
- 虚拟滚动渲染,降低大结果集卡顿风险。
|
||||||
|
|
||||||
|
### 数据管理(DataGrid)
|
||||||
|
- 单元格所见即所得编辑。
|
||||||
|
- 批量新增/修改/删除,支持事务提交与回滚。
|
||||||
|
- 大字段弹窗编辑。
|
||||||
|
- 右键上下文操作(NULL、复制、导出等)。
|
||||||
|
- 根据查询上下文智能切换读写模式。
|
||||||
|
- 支持 CSV / XLSX / JSON / Markdown 导出。
|
||||||
|
|
||||||
|
### SQL 编辑器
|
||||||
|
- 基于 Monaco Editor。
|
||||||
|
- 上下文补全(数据库/表/字段)。
|
||||||
|
- 多标签查询工作流。
|
||||||
|
|
||||||
|
### 连接与驱动
|
||||||
|
- URI 生成与解析。
|
||||||
|
- SSH 隧道、代理支持。
|
||||||
|
- 连接配置 JSON 导入/导出。
|
||||||
|
- 可选驱动安装与启用管理。
|
||||||
|
|
||||||
|
### Redis 工具
|
||||||
|
- 自动/原始文本/UTF-8/十六进制等视图模式。
|
||||||
|
- 内置命令执行面板。
|
||||||
|
|
||||||
|
### 可观测性与更新
|
||||||
|
- SQL 执行日志(含耗时)。
|
||||||
|
- 启动/定时/手动更新检查。
|
||||||
|
|
||||||
|
### UI 体验
|
||||||
|
- Ant Design 5 体系。
|
||||||
|
- 深色/浅色主题切换。
|
||||||
|
- 灵活布局与侧边栏行为。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **后端**: Go 1.24 + Wails v2
|
||||||
|
- **前端**: React 18 + TypeScript + Vite
|
||||||
|
- **UI 框架**: Ant Design 5
|
||||||
|
- **状态管理**: Zustand
|
||||||
|
- **编辑器**: Monaco Editor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装与运行
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
- [Go](https://go.dev/dl/) 1.21+
|
||||||
|
- [Node.js](https://nodejs.org/) 18+
|
||||||
|
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
|
||||||
|
`go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone https://github.com/Syngnat/GoNavi.git
|
||||||
|
cd GoNavi
|
||||||
|
|
||||||
|
# 启动开发(热重载)
|
||||||
|
wails dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 编译构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建当前平台
|
||||||
|
wails build
|
||||||
|
|
||||||
|
# 清理后构建(发布前推荐)
|
||||||
|
wails build -clean
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物位于 `build/bin`。
|
||||||
|
|
||||||
|
### 跨平台发布(GitHub Actions)
|
||||||
|
|
||||||
|
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
|
||||||
|
Release 更新说明会基于已合并 Pull Request 自动生成,并按 `.github/release.yaml` 分类。
|
||||||
|
|
||||||
|
支持目标:
|
||||||
|
- macOS (AMD64 / ARM64)
|
||||||
|
- Windows (AMD64)
|
||||||
|
- Linux (AMD64,含 WebKitGTK 4.0 / 4.1 变体)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### macOS 提示“应用已损坏,无法打开”
|
||||||
|
|
||||||
|
在未进行 Apple Notarization 时,Gatekeeper 可能拦截应用。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux 缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian 13 / Ubuntu 24.04+
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0
|
||||||
|
|
||||||
|
# Ubuntu 22.04 / Debian 12
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
欢迎提交 Issue 与 Pull Request。
|
||||||
|
|
||||||
|
完整流程、分支模型与维护者同步规则请查看:
|
||||||
|
|
||||||
|
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
|
||||||
|
|
||||||
|
外部贡献者应从 `dev` 拉出分支,并统一向 `dev` 发起 Pull Request。
|
||||||
|
|
||||||
|
## Star History (Star 增长趋势)
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## 友情链接
|
||||||
|
|
||||||
|
- [linux.do](https://linux.do/)
|
||||||
|
- [AI全书](https://aibook.ren/)
|
||||||
|
|
||||||
|
## 开源协议
|
||||||
|
|
||||||
|
本项目采用 [Apache-2.0 协议](LICENSE)。
|
||||||
9
assets_dev.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build dev
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// 开发模式下由 Wails DevServer 提供前端资源,这里只提供一个稳定的占位 FS,
|
||||||
|
// 避免编译时依赖 frontend/dist 被并发重建。
|
||||||
|
var assets = os.DirFS(".")
|
||||||
13
assets_prod.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !dev
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
var embeddedAssets embed.FS
|
||||||
|
|
||||||
|
var assets fs.FS = embeddedAssets
|
||||||
228
build-driver-agents.sh
Executable file
@@ -0,0 +1,228 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
DEFAULT_DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法:
|
||||||
|
./build-driver-agents.sh [选项]
|
||||||
|
|
||||||
|
选项:
|
||||||
|
--drivers <列表> 指定驱动列表(逗号分隔),例如:kingbase,mongodb
|
||||||
|
--platform <GOOS/GOARCH>
|
||||||
|
目标平台,默认使用当前 Go 环境(go env GOOS/GOARCH)
|
||||||
|
--out-dir <目录> 输出目录根路径,默认:dist/driver-agents
|
||||||
|
--bundle-name <文件名> 驱动总包 zip 名称,默认:GoNavi-DriverAgents.zip
|
||||||
|
--strict 任一驱动构建失败即中断(默认失败后继续,最后汇总)
|
||||||
|
-h, --help 显示帮助
|
||||||
|
|
||||||
|
示例:
|
||||||
|
./build-driver-agents.sh
|
||||||
|
./build-driver-agents.sh --drivers kingbase
|
||||||
|
./build-driver-agents.sh --platform windows/amd64 --drivers kingbase,mongodb
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_driver() {
|
||||||
|
local name
|
||||||
|
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)"
|
||||||
|
case "$name" in
|
||||||
|
doris|diros) echo "doris" ;;
|
||||||
|
mariadb|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|mongodb|tdengine|clickhouse)
|
||||||
|
echo "$name"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
build_driver_name() {
|
||||||
|
case "$1" in
|
||||||
|
doris) echo "diros" ;;
|
||||||
|
*) echo "$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
platform_dir_name() {
|
||||||
|
case "$1" in
|
||||||
|
windows) echo "Windows" ;;
|
||||||
|
darwin) echo "MacOS" ;;
|
||||||
|
linux) echo "Linux" ;;
|
||||||
|
*) echo "Unknown" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
driver_csv=""
|
||||||
|
target_platform=""
|
||||||
|
out_root="dist/driver-agents"
|
||||||
|
bundle_name="GoNavi-DriverAgents.zip"
|
||||||
|
strict_mode="false"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--drivers)
|
||||||
|
driver_csv="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--platform)
|
||||||
|
target_platform="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--out-dir)
|
||||||
|
out_root="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--bundle-name)
|
||||||
|
bundle_name="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--strict)
|
||||||
|
strict_mode="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ 未知参数:$1"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! command -v go >/dev/null 2>&1; then
|
||||||
|
echo "❌ 未找到 Go,请先安装 Go 并确保 go 在 PATH 中。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$target_platform" ]]; then
|
||||||
|
target_platform="$(go env GOOS)/$(go env GOARCH)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$target_platform" != */* ]]; then
|
||||||
|
echo "❌ --platform 参数格式错误,应为 GOOS/GOARCH,例如 darwin/arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
goos="${target_platform%%/*}"
|
||||||
|
goarch="${target_platform##*/}"
|
||||||
|
platform_key="${goos}-${goarch}"
|
||||||
|
platform_dir="$(platform_dir_name "$goos")"
|
||||||
|
|
||||||
|
declare -a drivers=()
|
||||||
|
if [[ -n "$driver_csv" ]]; then
|
||||||
|
IFS=',' read -r -a raw_drivers <<<"$driver_csv"
|
||||||
|
for item in "${raw_drivers[@]}"; do
|
||||||
|
normalized="$(normalize_driver "$item")" || {
|
||||||
|
echo "❌ 不支持的驱动:$item"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
drivers+=("$normalized")
|
||||||
|
done
|
||||||
|
else
|
||||||
|
drivers=("${DEFAULT_DRIVERS[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
output_dir="${out_root%/}/${platform_key}"
|
||||||
|
bundle_stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-driver-bundle.XXXXXX")"
|
||||||
|
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$bundle_stage_dir"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$output_dir" "$bundle_platform_dir"
|
||||||
|
output_dir_abs="$(cd "$output_dir" && pwd)"
|
||||||
|
bundle_zip_path="$output_dir_abs/$bundle_name"
|
||||||
|
|
||||||
|
declare -a built_assets=()
|
||||||
|
declare -a failed_drivers=()
|
||||||
|
declare -a skipped_drivers=()
|
||||||
|
|
||||||
|
echo "🚀 开始构建 optional-driver-agent"
|
||||||
|
echo " 平台:$goos/$goarch"
|
||||||
|
echo " 输出目录:$output_dir_abs"
|
||||||
|
echo " 驱动列表:${drivers[*]}"
|
||||||
|
|
||||||
|
for driver in "${drivers[@]}"; do
|
||||||
|
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
|
||||||
|
echo "⚠️ 跳过 duckdb(仅支持 windows/amd64)"
|
||||||
|
skipped_drivers+=("$driver")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
build_driver="$(build_driver_name "$driver")"
|
||||||
|
tag="gonavi_${build_driver}_driver"
|
||||||
|
asset_name="${driver}-driver-agent-${goos}-${goarch}"
|
||||||
|
if [[ "$goos" == "windows" ]]; then
|
||||||
|
asset_name="${asset_name}.exe"
|
||||||
|
fi
|
||||||
|
output_path="$output_dir_abs/$asset_name"
|
||||||
|
|
||||||
|
cgo_enabled=0
|
||||||
|
if [[ "$driver" == "duckdb" ]]; then
|
||||||
|
cgo_enabled=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔧 构建 $driver -> $asset_name (tag=$tag, CGO_ENABLED=$cgo_enabled)"
|
||||||
|
set +e
|
||||||
|
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
|
||||||
|
go build -tags "$tag" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
|
||||||
|
build_exit=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ $build_exit -ne 0 ]]; then
|
||||||
|
echo "❌ 构建失败:$driver"
|
||||||
|
failed_drivers+=("$driver")
|
||||||
|
if [[ "$strict_mode" == "true" ]]; then
|
||||||
|
exit $build_exit
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$output_path" "$bundle_platform_dir/$asset_name"
|
||||||
|
built_assets+=("$asset_name")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#built_assets[@]} -eq 0 ]]; then
|
||||||
|
echo "❌ 未成功构建任何驱动代理。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$bundle_zip_path"
|
||||||
|
if command -v zip >/dev/null 2>&1; then
|
||||||
|
(
|
||||||
|
cd "$bundle_stage_dir"
|
||||||
|
zip -qry "$bundle_zip_path" "$platform_dir"
|
||||||
|
)
|
||||||
|
elif command -v ditto >/dev/null 2>&1; then
|
||||||
|
(
|
||||||
|
cd "$bundle_stage_dir"
|
||||||
|
ditto -c -k --sequesterRsrc --keepParent "$platform_dir" "$bundle_zip_path"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "❌ 未找到 zip/ditto,无法生成驱动总包 zip。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ 构建完成"
|
||||||
|
echo " 单文件输出目录:$output_dir_abs"
|
||||||
|
echo " 驱动总包:$bundle_zip_path"
|
||||||
|
echo " 已构建:${built_assets[*]}"
|
||||||
|
if [[ ${#skipped_drivers[@]} -gt 0 ]]; then
|
||||||
|
echo " 已跳过:${skipped_drivers[*]}"
|
||||||
|
fi
|
||||||
|
if [[ ${#failed_drivers[@]} -gt 0 ]]; then
|
||||||
|
echo "⚠️ 构建失败驱动:${failed_drivers[*]}"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
315
build-release.sh
@@ -1,16 +1,42 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
# 配置
|
# 配置
|
||||||
APP_NAME="GoNavi"
|
APP_NAME="GoNavi"
|
||||||
DIST_DIR="dist"
|
DIST_DIR="dist"
|
||||||
BUILD_BIN_DIR="build/bin"
|
BUILD_BIN_DIR="build/bin"
|
||||||
DEFAULT_BINARY_NAME="GoNavi" # 对应 wails.json 中的 outputfilename
|
DEFAULT_BINARY_NAME="GoNavi" # 对应 wails.json 中的 outputfilename
|
||||||
|
DEV_VERSION_FILE="version/dev-version.txt"
|
||||||
|
DEFAULT_DEV_VERSION="0.0.1-test"
|
||||||
|
|
||||||
# 提取版本号
|
resolve_build_version() {
|
||||||
VERSION=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]')
|
if [ -n "${GONAVI_VERSION:-}" ]; then
|
||||||
if [ -z "$VERSION" ]; then
|
printf '%s\n' "${GONAVI_VERSION}"
|
||||||
VERSION="0.0.0"
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -f "$DEV_VERSION_FILE" ]; then
|
||||||
|
local dev_version
|
||||||
|
dev_version=$(head -n 1 "$DEV_VERSION_FILE" | tr -d '\r' | tr -d '[:space:]')
|
||||||
|
if [ -n "$dev_version" ]; then
|
||||||
|
printf '%s\n' "$dev_version"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local package_version
|
||||||
|
package_version=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[:space:]')
|
||||||
|
if [ -n "$package_version" ]; then
|
||||||
|
printf '%s\n' "$package_version"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$DEFAULT_DEV_VERSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
VERSION="$(resolve_build_version)"
|
||||||
echo "ℹ️ 检测到版本号: $VERSION"
|
echo "ℹ️ 检测到版本号: $VERSION"
|
||||||
LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION"
|
LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION"
|
||||||
|
|
||||||
@@ -20,121 +46,162 @@ RED='\033[0;31m'
|
|||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
|
get_file_size_bytes() {
|
||||||
|
local target="$1"
|
||||||
|
if [ ! -f "$target" ]; then
|
||||||
|
echo 0
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if stat -f%z "$target" >/dev/null 2>&1; then
|
||||||
|
stat -f%z "$target"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if stat -c%s "$target" >/dev/null 2>&1; then
|
||||||
|
stat -c%s "$target"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
wc -c <"$target" | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
format_size_mb() {
|
||||||
|
local bytes="${1:-0}"
|
||||||
|
awk -v b="$bytes" 'BEGIN { printf "%.2fMB", b / 1024 / 1024 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
try_compress_binary_with_upx() {
|
||||||
|
local exe_path="$1"
|
||||||
|
local label="$2"
|
||||||
|
if [ ! -f "$exe_path" ]; then
|
||||||
|
echo -e "${RED} ❌ 未找到 ${label} 文件:$exe_path${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v upx >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED} ❌ 未找到 upx,${label} 必须进行压缩后才能继续打包。${NC}"
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin)
|
||||||
|
echo " 安装命令: brew install upx"
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
echo " 安装命令: sudo apt-get install -y upx-ucl (或对应发行版包管理器)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local before_bytes after_bytes
|
||||||
|
before_bytes=$(get_file_size_bytes "$exe_path")
|
||||||
|
echo " 🗜️ 正在使用 UPX 压缩 ${label}..."
|
||||||
|
if upx --best --lzma --force "$exe_path" >/dev/null 2>&1; then
|
||||||
|
if ! upx -t "$exe_path" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED} ❌ UPX 校验失败:${label}${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
after_bytes=$(get_file_size_bytes "$exe_path")
|
||||||
|
if [ "$after_bytes" -lt "$before_bytes" ]; then
|
||||||
|
local saved_bytes=$((before_bytes - after_bytes))
|
||||||
|
echo " ✅ UPX 压缩完成: $(format_size_mb "$before_bytes") -> $(format_size_mb "$after_bytes"),减少 $(format_size_mb "$saved_bytes")"
|
||||||
|
else
|
||||||
|
echo " ℹ️ UPX 压缩完成: $(format_size_mb "$before_bytes") -> $(format_size_mb "$after_bytes")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ UPX 压缩失败:${label}${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_macos_bundle_xattrs() {
|
||||||
|
local bundle_path="$1"
|
||||||
|
if [ -z "$bundle_path" ] || [ ! -e "$bundle_path" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v xattr >/dev/null 2>&1; then
|
||||||
|
xattr -cr "$bundle_path" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
package_macos_bundle_zip() {
|
||||||
|
local app_path="$1"
|
||||||
|
local archive_path="$2"
|
||||||
|
local archive_abs
|
||||||
|
|
||||||
|
if [ ! -d "$app_path" ]; then
|
||||||
|
echo -e "${RED} ❌ 未找到 macOS 应用包:$app_path${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
archive_abs="$(cd "$(dirname "$archive_path")" && pwd)/$(basename "$archive_path")"
|
||||||
|
rm -f "$archive_path"
|
||||||
|
if command -v ditto >/dev/null 2>&1; then
|
||||||
|
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$archive_abs"
|
||||||
|
elif command -v zip >/dev/null 2>&1; then
|
||||||
|
(
|
||||||
|
cd "$(dirname "$app_path")" && \
|
||||||
|
zip -qry "$archive_abs" "$(basename "$app_path")"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ 未找到 ditto/zip,无法打包 macOS 应用。${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$archive_abs" ]; then
|
||||||
|
echo -e "${RED} ❌ macOS 应用归档失败:$archive_abs${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
package_macos_release() {
|
||||||
|
local platform="$1"
|
||||||
|
local archive_suffix="$2"
|
||||||
|
|
||||||
|
echo -e "${GREEN}🍎 正在构建 macOS (${platform})...${NC}"
|
||||||
|
wails build -platform "darwin/${platform}" -clean -ldflags "$LDFLAGS"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED} ❌ macOS ${platform} 构建失败。${NC}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local app_src="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
||||||
|
local app_dest_name="${APP_NAME}-${VERSION}-${archive_suffix}.app"
|
||||||
|
local zip_name="${APP_NAME}-${VERSION}-${archive_suffix}.zip"
|
||||||
|
|
||||||
|
mv "$app_src" "$DIST_DIR/$app_dest_name"
|
||||||
|
|
||||||
|
local app_bin_path
|
||||||
|
app_bin_path=$(find "$DIST_DIR/$app_dest_name/Contents/MacOS" -maxdepth 1 -type f -print -quit)
|
||||||
|
if [ -z "$app_bin_path" ] || [ ! -f "$app_bin_path" ]; then
|
||||||
|
echo -e "${RED} ❌ 未找到 macOS ${platform} 主程序文件。${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW} ⚠️ macOS ${platform} 改为无交互 ZIP 打包,不再生成 DMG。${NC}"
|
||||||
|
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (${platform})..."
|
||||||
|
clear_macos_bundle_xattrs "$DIST_DIR/$app_dest_name"
|
||||||
|
codesign --force --deep --sign - "$DIST_DIR/$app_dest_name"
|
||||||
|
|
||||||
|
echo " 📦 正在打包 macOS 应用归档 (${platform})..."
|
||||||
|
package_macos_bundle_zip "$DIST_DIR/$app_dest_name" "$DIST_DIR/$zip_name"
|
||||||
|
rm -rf "$DIST_DIR/$app_dest_name"
|
||||||
|
echo " ✅ 已生成 $zip_name"
|
||||||
|
}
|
||||||
|
|
||||||
echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}"
|
echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}"
|
||||||
|
|
||||||
# 清理并创建输出目录
|
# 清理并创建输出目录
|
||||||
rm -rf $DIST_DIR
|
rm -rf "$DIST_DIR"
|
||||||
mkdir -p $DIST_DIR
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
# --- macOS ARM64 构建 ---
|
package_macos_release "arm64" "mac-arm64"
|
||||||
echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}"
|
package_macos_release "amd64" "mac-amd64"
|
||||||
wails build -platform darwin/arm64 -clean -ldflags "$LDFLAGS"
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
|
||||||
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app"
|
|
||||||
DMG_NAME="${APP_NAME}-${VERSION}-mac-arm64.dmg"
|
|
||||||
|
|
||||||
# 移动 .app 到 dist
|
|
||||||
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
|
|
||||||
|
|
||||||
# 创建 DMG
|
|
||||||
if command -v create-dmg &> /dev/null; then
|
|
||||||
echo " 📦 正在打包 DMG (arm64)..."
|
|
||||||
# 移除已存在的 DMG (以防万一)
|
|
||||||
rm -f "$DIST_DIR/$DMG_NAME"
|
|
||||||
|
|
||||||
create-dmg \
|
|
||||||
--volname "${APP_NAME} ${VERSION}" \
|
|
||||||
--volicon "build/appicon.icns" \
|
|
||||||
--window-pos 200 120 \
|
|
||||||
--window-size 800 400 \
|
|
||||||
--icon-size 100 \
|
|
||||||
--icon "$APP_DEST_NAME" 200 190 \
|
|
||||||
--hide-extension "$APP_DEST_NAME" \
|
|
||||||
--app-drop-link 600 185 \
|
|
||||||
"$DIST_DIR/$DMG_NAME" \
|
|
||||||
"$DIST_DIR/$APP_DEST_NAME"
|
|
||||||
|
|
||||||
# 检查是否生成了 rw.* 的临时文件并重命名 (create-dmg 有时会有此行为)
|
|
||||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
|
||||||
RW_FILE=$(find "$DIST_DIR" -name "rw.*.dmg" -print -quit)
|
|
||||||
if [ -n "$RW_FILE" ]; then
|
|
||||||
echo -e "${YELLOW} ⚠️ 检测到临时文件名,正在重命名...${NC}"
|
|
||||||
mv "$RW_FILE" "$DIST_DIR/$DMG_NAME"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 删除中间的 .app 文件,保持目录整洁
|
|
||||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
|
||||||
|
|
||||||
if [ -f "$DIST_DIR/$DMG_NAME" ]; then
|
|
||||||
echo " ✅ 已生成 $DMG_NAME"
|
|
||||||
else
|
|
||||||
echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出。${NC}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
|
|
||||||
echo " 安装命令: brew install create-dmg"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${RED} ❌ macOS arm64 构建失败。${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- macOS AMD64 构建 ---
|
|
||||||
echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}"
|
|
||||||
wails build -platform darwin/amd64 -clean -ldflags "$LDFLAGS"
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
|
||||||
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app"
|
|
||||||
DMG_NAME="${APP_NAME}-${VERSION}-mac-amd64.dmg"
|
|
||||||
|
|
||||||
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
|
|
||||||
|
|
||||||
if command -v create-dmg &> /dev/null; then
|
|
||||||
echo " 📦 正在打包 DMG (amd64)..."
|
|
||||||
rm -f "$DIST_DIR/$DMG_NAME"
|
|
||||||
|
|
||||||
create-dmg \
|
|
||||||
--volname "${APP_NAME} ${VERSION}" \
|
|
||||||
--volicon "build/appicon.icns" \
|
|
||||||
--window-pos 200 120 \
|
|
||||||
--window-size 800 400 \
|
|
||||||
--icon-size 100 \
|
|
||||||
--icon "$APP_DEST_NAME" 200 190 \
|
|
||||||
--hide-extension "$APP_DEST_NAME" \
|
|
||||||
--app-drop-link 600 185 \
|
|
||||||
"$DIST_DIR/$DMG_NAME" \
|
|
||||||
"$DIST_DIR/$APP_DEST_NAME"
|
|
||||||
|
|
||||||
# 检查是否生成了 rw.* 的临时文件并重命名
|
|
||||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
|
||||||
RW_FILE=$(find "$DIST_DIR" -name "rw.*.dmg" -print -quit)
|
|
||||||
if [ -n "$RW_FILE" ]; then
|
|
||||||
echo -e "${YELLOW} ⚠️ 检测到临时文件名,正在重命名...${NC}"
|
|
||||||
mv "$RW_FILE" "$DIST_DIR/$DMG_NAME"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
|
||||||
|
|
||||||
if [ -f "$DIST_DIR/$DMG_NAME" ]; then
|
|
||||||
echo " ✅ 已生成 $DMG_NAME"
|
|
||||||
else
|
|
||||||
echo -e "${RED} ❌ DMG 生成失败。${NC}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具。${NC}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${RED} ❌ macOS amd64 构建失败。${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Windows AMD64 构建 ---
|
# --- Windows AMD64 构建 ---
|
||||||
echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
|
echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
|
||||||
if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
||||||
wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS"
|
wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS"
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
|
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||||
|
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$TARGET_EXE"
|
||||||
|
try_compress_binary_with_upx "$TARGET_EXE" "Windows amd64 可执行文件"
|
||||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
|
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||||
else
|
else
|
||||||
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
|
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
|
||||||
@@ -148,7 +215,9 @@ echo -e "${GREEN}🪟 正在构建 Windows (arm64)...${NC}"
|
|||||||
if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
|
if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
|
||||||
wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS"
|
wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS"
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
|
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
|
||||||
|
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$TARGET_EXE"
|
||||||
|
echo -e "${YELLOW} ⚠️ 当前 UPX 不支持 win64/arm64,跳过 Windows arm64 压缩。${NC}"
|
||||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
|
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
|
||||||
else
|
else
|
||||||
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
|
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
|
||||||
@@ -168,8 +237,10 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
|
|||||||
# 本机 Linux amd64,直接构建
|
# 本机 Linux amd64,直接构建
|
||||||
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
|
||||||
|
chmod +x "$TARGET_LINUX_BIN"
|
||||||
|
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux amd64 可执行文件"
|
||||||
# 打包为 tar.gz
|
# 打包为 tar.gz
|
||||||
cd "$DIST_DIR"
|
cd "$DIST_DIR"
|
||||||
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
|
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
|
||||||
@@ -186,8 +257,10 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
|
|||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
|
||||||
|
chmod +x "$TARGET_LINUX_BIN"
|
||||||
|
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux amd64 可执行文件"
|
||||||
cd "$DIST_DIR"
|
cd "$DIST_DIR"
|
||||||
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
|
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
|
||||||
rm "${APP_NAME}-${VERSION}-linux-amd64"
|
rm "${APP_NAME}-${VERSION}-linux-amd64"
|
||||||
@@ -208,8 +281,10 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
|
|||||||
# 本机 Linux arm64,直接构建
|
# 本机 Linux arm64,直接构建
|
||||||
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
|
||||||
|
chmod +x "$TARGET_LINUX_BIN"
|
||||||
|
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux arm64 可执行文件"
|
||||||
cd "$DIST_DIR"
|
cd "$DIST_DIR"
|
||||||
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
|
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
|
||||||
rm "${APP_NAME}-${VERSION}-linux-arm64"
|
rm "${APP_NAME}-${VERSION}-linux-arm64"
|
||||||
@@ -225,8 +300,10 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
|
|||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
|
||||||
|
chmod +x "$TARGET_LINUX_BIN"
|
||||||
|
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux arm64 可执行文件"
|
||||||
cd "$DIST_DIR"
|
cd "$DIST_DIR"
|
||||||
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
|
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
|
||||||
rm "${APP_NAME}-${VERSION}-linux-arm64"
|
rm "${APP_NAME}-${VERSION}-linux-arm64"
|
||||||
|
|||||||
339
cmd/manualtestseed/main.go
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/ai"
|
||||||
|
aiservice "GoNavi-Wails/internal/ai/service"
|
||||||
|
"GoNavi-Wails/internal/app"
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/secretstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
modeSeedSecureStorage = "seed-secure-storage"
|
||||||
|
modeSeedAIUpdate = "seed-ai-update"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testConnectionID = "manualtest-postgres"
|
||||||
|
testSecureProviderID = "manualtest-secure-provider"
|
||||||
|
testPendingProviderID = "manualtest-pending-provider"
|
||||||
|
testBackupDirName = "manual-test-backups"
|
||||||
|
connectionsFileName = "connections.json"
|
||||||
|
globalProxyFileName = "global_proxy.json"
|
||||||
|
aiConfigFileName = "ai_config.json"
|
||||||
|
securityUpdateFileName = "config-security-update.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type backupManifest struct {
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
ConfigDir string `json:"configDir"`
|
||||||
|
Files []backupManifestFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type backupManifestFile struct {
|
||||||
|
RelativePath string `json:"relativePath"`
|
||||||
|
Existed bool `json:"existed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storedAIConfig struct {
|
||||||
|
SchemaVersion int `json:"schemaVersion,omitempty"`
|
||||||
|
Providers []ai.ProviderConfig `json:"providers"`
|
||||||
|
ActiveProvider string `json:"activeProvider"`
|
||||||
|
SafetyLevel string `json:"safetyLevel"`
|
||||||
|
ContextLevel string `json:"contextLevel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := flag.String("mode", modeSeedSecureStorage, "seed mode: seed-secure-storage | seed-ai-update")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
configDir, err := resolveConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("resolve config dir failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := secretstore.NewKeyringStore()
|
||||||
|
if err := store.HealthCheck(); err != nil {
|
||||||
|
fatalf("secret store unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backupDir, err := backupConfigFiles(configDir)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("backup config files failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.TrimSpace(*mode) {
|
||||||
|
case modeSeedSecureStorage:
|
||||||
|
if err := seedSecureStorage(configDir, store); err != nil {
|
||||||
|
fatalf("seed secure storage failed: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("mode=%s\nbackup=%s\nconnectionId=%s\nproviderId=%s\n", modeSeedSecureStorage, backupDir, testConnectionID, testSecureProviderID)
|
||||||
|
case modeSeedAIUpdate:
|
||||||
|
if err := seedAIUpdate(configDir, store); err != nil {
|
||||||
|
fatalf("seed ai update failed: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("mode=%s\nbackup=%s\npendingProviderId=%s\n", modeSeedAIUpdate, backupDir, testPendingProviderID)
|
||||||
|
default:
|
||||||
|
fatalf("unsupported mode: %s", *mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatalf(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConfigDir() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(homeDir, ".gonavi"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupConfigFiles(configDir string) (string, error) {
|
||||||
|
backupDir := filepath.Join(configDir, testBackupDirName, time.Now().Format("20060102-150405"))
|
||||||
|
files := []string{
|
||||||
|
connectionsFileName,
|
||||||
|
globalProxyFileName,
|
||||||
|
aiConfigFileName,
|
||||||
|
filepath.Join("migrations", securityUpdateFileName),
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := backupManifest{
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
ConfigDir: configDir,
|
||||||
|
Files: make([]backupManifestFile, 0, len(files)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, relativePath := range files {
|
||||||
|
srcPath := filepath.Join(configDir, relativePath)
|
||||||
|
info, err := os.Stat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
manifest.Files = append(manifest.Files, backupManifestFile{
|
||||||
|
RelativePath: relativePath,
|
||||||
|
Existed: false,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dstPath := filepath.Join(backupDir, relativePath)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
manifest.Files = append(manifest.Files, backupManifestFile{
|
||||||
|
RelativePath: relativePath,
|
||||||
|
Existed: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
manifestData, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(backupDir, "manifest.json"), manifestData, 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return backupDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedSecureStorage(configDir string, store secretstore.SecretStore) error {
|
||||||
|
if err := cleanupKnownTestSecrets(store); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
appService := app.NewAppWithSecretStore(store)
|
||||||
|
_ = appService.DeleteConnection(testConnectionID)
|
||||||
|
|
||||||
|
if _, err := appService.SaveConnection(connection.SavedConnectionInput{
|
||||||
|
ID: testConnectionID,
|
||||||
|
Name: "手工测试 PostgreSQL",
|
||||||
|
Config: connection.ConnectionConfig{
|
||||||
|
ID: testConnectionID,
|
||||||
|
Type: "postgres",
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 5432,
|
||||||
|
User: "postgres",
|
||||||
|
Password: "manualtest-pg-secret",
|
||||||
|
Database: "postgres",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := appService.SaveGlobalProxy(connection.SaveGlobalProxyInput{
|
||||||
|
Enabled: true,
|
||||||
|
Type: "http",
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 7890,
|
||||||
|
User: "manual-test",
|
||||||
|
Password: "manualtest-proxy-secret",
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
storeConfig := aiservice.NewProviderConfigStore(configDir, store)
|
||||||
|
snapshot, err := storeConfig.LoadRuntime()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
snapshot.Providers = filterProviders(snapshot.Providers, testSecureProviderID, testPendingProviderID)
|
||||||
|
snapshot.Providers = append(snapshot.Providers, ai.ProviderConfig{
|
||||||
|
ID: testSecureProviderID,
|
||||||
|
Type: "custom",
|
||||||
|
Name: "手工测试 Secure Provider",
|
||||||
|
APIKey: "manualtest-ai-secret",
|
||||||
|
BaseURL: "https://api.openai.com/v1",
|
||||||
|
Model: "gpt-4o-mini",
|
||||||
|
APIFormat: "openai",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": "Bearer manualtest-header-secret",
|
||||||
|
"X-Trace-Id": "manualtest-visible",
|
||||||
|
},
|
||||||
|
MaxTokens: 2048,
|
||||||
|
Temperature: 0.2,
|
||||||
|
})
|
||||||
|
if snapshot.SafetyLevel == "" {
|
||||||
|
snapshot.SafetyLevel = ai.PermissionReadOnly
|
||||||
|
}
|
||||||
|
if snapshot.ContextLevel == "" {
|
||||||
|
snapshot.ContextLevel = ai.ContextSchemaOnly
|
||||||
|
}
|
||||||
|
return storeConfig.Save(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedAIUpdate(configDir string, store secretstore.SecretStore) error {
|
||||||
|
if err := cleanupKnownTestSecrets(store); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(configDir, aiConfigFileName)
|
||||||
|
cfg, err := readStoredAIConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Providers = filterProviders(cfg.Providers, testSecureProviderID, testPendingProviderID)
|
||||||
|
cfg.Providers = append(cfg.Providers, ai.ProviderConfig{
|
||||||
|
ID: testPendingProviderID,
|
||||||
|
Type: "custom",
|
||||||
|
Name: "手工测试 待迁移 AI",
|
||||||
|
APIKey: "manualtest-ai-update-secret",
|
||||||
|
BaseURL: "https://api.openai.com/v1",
|
||||||
|
Model: "gpt-4o-mini",
|
||||||
|
APIFormat: "openai",
|
||||||
|
MaxTokens: 1024,
|
||||||
|
})
|
||||||
|
if cfg.SchemaVersion == 0 {
|
||||||
|
cfg.SchemaVersion = 2
|
||||||
|
}
|
||||||
|
if cfg.Providers == nil {
|
||||||
|
cfg.Providers = []ai.ProviderConfig{}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(configPath, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStoredAIConfig(configPath string) (storedAIConfig, error) {
|
||||||
|
cfg := storedAIConfig{
|
||||||
|
Providers: []ai.ProviderConfig{},
|
||||||
|
SafetyLevel: string(ai.PermissionReadOnly),
|
||||||
|
ContextLevel: string(ai.ContextSchemaOnly),
|
||||||
|
SchemaVersion: 2,
|
||||||
|
ActiveProvider: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return storedAIConfig{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return storedAIConfig{}, err
|
||||||
|
}
|
||||||
|
if cfg.Providers == nil {
|
||||||
|
cfg.Providers = []ai.ProviderConfig{}
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterProviders(providers []ai.ProviderConfig, excludedIDs ...string) []ai.ProviderConfig {
|
||||||
|
excluded := make(map[string]struct{}, len(excludedIDs))
|
||||||
|
for _, id := range excludedIDs {
|
||||||
|
excluded[strings.TrimSpace(id)] = struct{}{}
|
||||||
|
}
|
||||||
|
filtered := make([]ai.ProviderConfig, 0, len(providers))
|
||||||
|
for _, provider := range providers {
|
||||||
|
if _, skip := excluded[strings.TrimSpace(provider.ID)]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, provider)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupKnownTestSecrets(store secretstore.SecretStore) error {
|
||||||
|
type secretRef struct {
|
||||||
|
kind string
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
refs := []secretRef{
|
||||||
|
{kind: "connection", id: testConnectionID},
|
||||||
|
{kind: "global-proxy", id: "default"},
|
||||||
|
{kind: "ai-provider", id: testSecureProviderID},
|
||||||
|
{kind: "ai-provider", id: testPendingProviderID},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range refs {
|
||||||
|
ref, err := secretstore.BuildRef(item.kind, item.id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := store.Delete(ref); err != nil && !isIgnorableDeleteError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIgnorableDeleteError(err error) bool {
|
||||||
|
if err == nil || os.IsNotExist(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||||
|
return strings.Contains(message, "could not be found") ||
|
||||||
|
strings.Contains(message, "not be found in the keyring") ||
|
||||||
|
strings.Contains(message, "element not found")
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
"GoNavi-Wails/internal/db"
|
"GoNavi-Wails/internal/db"
|
||||||
@@ -16,6 +19,7 @@ type agentRequest struct {
|
|||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
|
TimeoutMs int64 `json:"timeoutMs,omitempty"`
|
||||||
DBName string `json:"dbName,omitempty"`
|
DBName string `json:"dbName,omitempty"`
|
||||||
TableName string `json:"tableName,omitempty"`
|
TableName string `json:"tableName,omitempty"`
|
||||||
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
||||||
@@ -47,6 +51,8 @@ const (
|
|||||||
agentMethodApplyChanges = "applyChanges"
|
agentMethodApplyChanges = "applyChanges"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const legacyClickHouseDefaultTimeout = 2 * time.Hour
|
||||||
|
|
||||||
var (
|
var (
|
||||||
agentDriverType string
|
agentDriverType string
|
||||||
agentDatabaseFactory func() db.Database
|
agentDatabaseFactory func() db.Database
|
||||||
@@ -137,14 +143,14 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
|||||||
return fail(resp, err.Error())
|
return fail(resp, err.Error())
|
||||||
}
|
}
|
||||||
case agentMethodQuery:
|
case agentMethodQuery:
|
||||||
data, fields, err := (*inst).Query(req.Query)
|
data, fields, err := queryWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(resp, err.Error())
|
return fail(resp, err.Error())
|
||||||
}
|
}
|
||||||
resp.Data = data
|
resp.Data = data
|
||||||
resp.Fields = fields
|
resp.Fields = fields
|
||||||
case agentMethodExec:
|
case agentMethodExec:
|
||||||
affected, err := (*inst).Exec(req.Query)
|
affected, err := execWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(resp, err.Error())
|
return fail(resp, err.Error())
|
||||||
}
|
}
|
||||||
@@ -218,7 +224,11 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeResponse(writer *bufio.Writer, resp agentResponse) error {
|
func writeResponse(writer *bufio.Writer, resp agentResponse) error {
|
||||||
payload, err := json.Marshal(resp)
|
// 对响应数据做统一 JSON 安全归一化:
|
||||||
|
// 将 map[any]any(如 duckdb.Map)递归转换为 map[string]any,避免序列化失败导致代理进程退出。
|
||||||
|
safeResp := resp
|
||||||
|
safeResp.Data = normalizeAgentResponseData(resp.Data)
|
||||||
|
payload, err := json.Marshal(safeResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -234,3 +244,87 @@ func fail(resp agentResponse, errText string) agentResponse {
|
|||||||
resp.Error = strings.TrimSpace(errText)
|
resp.Error = strings.TrimSpace(errText)
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeAgentResponseData(v interface{}) interface{} {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rv := reflect.ValueOf(v)
|
||||||
|
switch rv.Kind() {
|
||||||
|
case reflect.Pointer, reflect.Interface:
|
||||||
|
if rv.IsNil() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalizeAgentResponseData(rv.Elem().Interface())
|
||||||
|
case reflect.Map:
|
||||||
|
if rv.IsNil() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{}, rv.Len())
|
||||||
|
iter := rv.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
out[fmt.Sprint(iter.Key().Interface())] = normalizeAgentResponseData(iter.Value().Interface())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case reflect.Slice:
|
||||||
|
if rv.IsNil() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 保持 []byte 原样,避免改变现有二进制列的 JSON 编码行为(base64)。
|
||||||
|
if rv.Type().Elem().Kind() == reflect.Uint8 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
size := rv.Len()
|
||||||
|
items := make([]interface{}, size)
|
||||||
|
for i := 0; i < size; i++ {
|
||||||
|
items[i] = normalizeAgentResponseData(rv.Index(i).Interface())
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
case reflect.Array:
|
||||||
|
size := rv.Len()
|
||||||
|
items := make([]interface{}, size)
|
||||||
|
for i := 0; i < size; i++ {
|
||||||
|
items[i] = normalizeAgentResponseData(rv.Index(i).Interface())
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]map[string]interface{}, []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.(interface {
|
||||||
|
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
|
||||||
|
}); ok {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
return q.QueryContext(ctx, query)
|
||||||
|
}
|
||||||
|
return inst.Query(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (int64, error) {
|
||||||
|
effectiveTimeoutMs := timeoutMs
|
||||||
|
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||||
|
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||||
|
}
|
||||||
|
if effectiveTimeoutMs <= 0 {
|
||||||
|
return inst.Exec(query)
|
||||||
|
}
|
||||||
|
if e, ok := inst.(interface {
|
||||||
|
ExecContext(context.Context, string) (int64, error)
|
||||||
|
}); ok {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
return e.ExecContext(ctx, query)
|
||||||
|
}
|
||||||
|
return inst.Exec(query)
|
||||||
|
}
|
||||||
|
|||||||
172
cmd/optional-driver-agent/main_test.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
)
|
||||||
|
|
||||||
|
type duckMapLike map[any]any
|
||||||
|
|
||||||
|
func TestWriteResponse_NormalizesMapAnyAny(t *testing.T) {
|
||||||
|
resp := agentResponse{
|
||||||
|
ID: 1,
|
||||||
|
Success: true,
|
||||||
|
Data: []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": int64(7),
|
||||||
|
"meta": duckMapLike{"k": "v", 2: "two"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
writer := bufio.NewWriter(&out)
|
||||||
|
if err := writeResponse(writer, resp); err != nil {
|
||||||
|
t.Fatalf("writeResponse 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded struct {
|
||||||
|
Data []map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &decoded); err != nil {
|
||||||
|
t.Fatalf("解码响应失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(decoded.Data) != 1 {
|
||||||
|
t.Fatalf("期望 1 行数据,实际 %d", len(decoded.Data))
|
||||||
|
}
|
||||||
|
meta, ok := decoded.Data[0]["meta"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("meta 字段类型异常: %T", decoded.Data[0]["meta"])
|
||||||
|
}
|
||||||
|
if meta["k"] != "v" {
|
||||||
|
t.Fatalf("字符串 key 转换异常: %v", meta["k"])
|
||||||
|
}
|
||||||
|
if meta["2"] != "two" {
|
||||||
|
t.Fatalf("数字 key 未字符串化: %v", meta["2"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) {
|
||||||
|
raw := []byte{0x61, 0x62, 0x63}
|
||||||
|
normalized := normalizeAgentResponseData(raw)
|
||||||
|
out, ok := normalized.([]byte)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("期望 []byte,实际 %T", normalized)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(out, raw) {
|
||||||
|
t.Fatalf("[]byte 内容被意外改写: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAgentTimeoutDB struct {
|
||||||
|
queryCalled bool
|
||||||
|
queryContextCalled bool
|
||||||
|
execCalled bool
|
||||||
|
execContextCalled bool
|
||||||
|
deadlineSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAgentTimeoutDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||||||
|
func (f *fakeAgentTimeoutDB) Close() error { return nil }
|
||||||
|
func (f *fakeAgentTimeoutDB) Ping() error { return nil }
|
||||||
|
func (f *fakeAgentTimeoutDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
f.queryCalled = true
|
||||||
|
return nil, nil, errors.New("query should not be called")
|
||||||
|
}
|
||||||
|
func (f *fakeAgentTimeoutDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
f.queryContextCalled = true
|
||||||
|
if _, ok := ctx.Deadline(); ok {
|
||||||
|
f.deadlineSet = true
|
||||||
|
}
|
||||||
|
return []map[string]interface{}{{"ok": 1}}, []string{"ok"}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAgentTimeoutDB) Exec(query string) (int64, error) {
|
||||||
|
f.execCalled = true
|
||||||
|
return 0, errors.New("exec should not be called")
|
||||||
|
}
|
||||||
|
func (f *fakeAgentTimeoutDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
f.execContextCalled = true
|
||||||
|
if _, ok := ctx.Deadline(); ok {
|
||||||
|
f.deadlineSet = true
|
||||||
|
}
|
||||||
|
return 3, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAgentTimeoutDB) GetDatabases() ([]string, error) { return nil, nil }
|
||||||
|
func (f *fakeAgentTimeoutDB) GetTables(dbName string) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAgentTimeoutDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
func (f *fakeAgentTimeoutDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAgentTimeoutDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAgentTimeoutDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAgentTimeoutDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) {
|
||||||
|
fake := &fakeAgentTimeoutDB{}
|
||||||
|
data, fields, err := queryWithOptionalTimeout(fake, "SELECT 1", int64((2 * time.Second).Milliseconds()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("queryWithOptionalTimeout 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
if !fake.queryContextCalled || fake.queryCalled {
|
||||||
|
t.Fatalf("query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled)
|
||||||
|
}
|
||||||
|
if !fake.deadlineSet {
|
||||||
|
t.Fatal("queryWithOptionalTimeout 未设置 deadline")
|
||||||
|
}
|
||||||
|
if len(data) != 1 || len(fields) != 1 || fields[0] != "ok" {
|
||||||
|
t.Fatalf("queryWithOptionalTimeout 返回数据异常: data=%v fields=%v", data, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecWithOptionalTimeout_UsesExecContext(t *testing.T) {
|
||||||
|
fake := &fakeAgentTimeoutDB{}
|
||||||
|
affected, err := execWithOptionalTimeout(fake, "DELETE FROM t", int64((2 * time.Second).Milliseconds()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execWithOptionalTimeout 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
if !fake.execContextCalled || fake.execCalled {
|
||||||
|
t.Fatalf("exec 调用路径异常,ExecContext=%v Exec=%v", fake.execContextCalled, fake.execCalled)
|
||||||
|
}
|
||||||
|
if !fake.deadlineSet {
|
||||||
|
t.Fatal("execWithOptionalTimeout 未设置 deadline")
|
||||||
|
}
|
||||||
|
if affected != 3 {
|
||||||
|
t.Fatalf("受影响行数异常,want=3 got=%d", affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testing.T) {
|
||||||
|
old := agentDriverType
|
||||||
|
agentDriverType = "clickhouse"
|
||||||
|
defer func() { agentDriverType = old }()
|
||||||
|
|
||||||
|
fake := &fakeAgentTimeoutDB{}
|
||||||
|
_, _, err := queryWithOptionalTimeout(fake, "SELECT 1", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("queryWithOptionalTimeout 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
if !fake.queryContextCalled || fake.queryCalled {
|
||||||
|
t.Fatalf("clickhouse legacy query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
cmd/optional-driver-agent/provider_clickhouse.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build gonavi_clickhouse_driver
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "GoNavi-Wails/internal/db"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
agentDriverType = "clickhouse"
|
||||||
|
agentDatabaseFactory = func() db.Database {
|
||||||
|
return &db.ClickHouseDB{}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
cmd/optional-driver-agent/provider_mongodb_v1.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build gonavi_mongodb_driver_v1
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "GoNavi-Wails/internal/db"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
agentDriverType = "mongodb"
|
||||||
|
agentDatabaseFactory = func() db.Database {
|
||||||
|
return &db.MongoDBV1{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,79 +3,85 @@
|
|||||||
"drivers": {
|
"drivers": {
|
||||||
"mariadb": {
|
"mariadb": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "1.9.3",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/mariadb"
|
"downloadUrl": "builtin://activate/mariadb"
|
||||||
},
|
},
|
||||||
"diros": {
|
"doris": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "1.9.3",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/diros"
|
"downloadUrl": "builtin://activate/doris"
|
||||||
},
|
},
|
||||||
"sphinx": {
|
"sphinx": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "1.9.3",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/sphinx"
|
"downloadUrl": "builtin://activate/sphinx"
|
||||||
},
|
},
|
||||||
"sqlserver": {
|
"sqlserver": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "1.9.6",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/sqlserver"
|
"downloadUrl": "builtin://activate/sqlserver"
|
||||||
},
|
},
|
||||||
"sqlite": {
|
"sqlite": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "1.44.3",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/sqlite"
|
"downloadUrl": "builtin://activate/sqlite"
|
||||||
},
|
},
|
||||||
"duckdb": {
|
"duckdb": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "2.5.6",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/duckdb"
|
"downloadUrl": "builtin://activate/duckdb"
|
||||||
},
|
},
|
||||||
"dameng": {
|
"dameng": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "1.8.22",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/dameng"
|
"downloadUrl": "builtin://activate/dameng"
|
||||||
},
|
},
|
||||||
"kingbase": {
|
"kingbase": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "0.0.0-20201021123113-29bd62a876c3",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/kingbase"
|
"downloadUrl": "builtin://activate/kingbase"
|
||||||
},
|
},
|
||||||
"highgo": {
|
"highgo": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "0.0.0-local",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/highgo"
|
"downloadUrl": "builtin://activate/highgo"
|
||||||
},
|
},
|
||||||
"vastbase": {
|
"vastbase": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "1.11.1",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/vastbase"
|
"downloadUrl": "builtin://activate/vastbase"
|
||||||
},
|
},
|
||||||
"mongodb": {
|
"mongodb": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "2.5.0",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/mongodb"
|
"downloadUrl": "builtin://activate/mongodb"
|
||||||
},
|
},
|
||||||
"tdengine": {
|
"tdengine": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "3.7.8",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/tdengine"
|
"downloadUrl": "builtin://activate/tdengine"
|
||||||
},
|
},
|
||||||
|
"clickhouse": {
|
||||||
|
"engine": "go",
|
||||||
|
"version": "2.43.1",
|
||||||
|
"checksumPolicy": "off",
|
||||||
|
"downloadUrl": "builtin://activate/clickhouse"
|
||||||
|
},
|
||||||
"postgres": {
|
"postgres": {
|
||||||
"engine": "go",
|
"engine": "go",
|
||||||
"version": "go-embedded",
|
"version": "1.11.1",
|
||||||
"checksumPolicy": "off",
|
"checksumPolicy": "off",
|
||||||
"downloadUrl": "builtin://activate/postgres"
|
"downloadUrl": "builtin://activate/postgres"
|
||||||
}
|
}
|
||||||
|
|||||||
1432
docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
# JVM 缓存可视化编辑设计
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
当前用户在公司 Java 项目中经常把缓存或运行时状态直接保存在 JVM 内存中。出现数据脏值、缓存穿透、临时纠偏或排障时,通常只有两种方式:
|
||||||
|
|
||||||
|
- 为特定业务临时补管理接口
|
||||||
|
- 重启应用并依赖重新初始化
|
||||||
|
|
||||||
|
这两种方式都存在明显问题:
|
||||||
|
|
||||||
|
- 临时接口会污染业务代码,并带来后续维护和权限风险
|
||||||
|
- 重启应用成本高,且不适合用于精确修复单个缓存项
|
||||||
|
|
||||||
|
GoNavi 现有已具备三类可复用基础:
|
||||||
|
|
||||||
|
- 统一连接与工作台能力:`frontend/src/components/ConnectionModal.tsx`、`frontend/src/components/Sidebar.tsx`、`frontend/src/components/TabManager.tsx`
|
||||||
|
- 独立运行时能力样板:Redis 通过 `internal/app/methods_redis.go` 和专用前端视图实现,不依赖 SQL `Database` 抽象
|
||||||
|
- AI 与日志能力底座:`frontend/src/components/AIChatPanel.tsx`、`frontend/src/components/QueryEditor.tsx`、`frontend/src/components/LogPanel.tsx`
|
||||||
|
|
||||||
|
因此,GoNavi 有条件扩展出 JVM 运行时连接与受控编辑能力,但不能简单把该需求理解为“新数据库驱动”。
|
||||||
|
|
||||||
|
## 2. 目标
|
||||||
|
|
||||||
|
- 为 GoNavi 增加统一的 `JVM Connector` 子系统,用于连接和浏览 Java 服务的运行时缓存/管理对象
|
||||||
|
- 在同一套 UI 下支持多种接入模式,并根据目标 JVM 能力自动协商或手动切换
|
||||||
|
- 提供结构化的缓存浏览、值检查、受控修改、操作预览和审计记录
|
||||||
|
- 允许 AI 参与解释、分析和生成修改计划,但不默认开放 AI 自动执行
|
||||||
|
- 尽量避免强依赖 `-javaagent` 或运行时动态 attach,适配企业内对生产进程注入普遍敏感的环境
|
||||||
|
|
||||||
|
## 3. 非目标
|
||||||
|
|
||||||
|
- 不承诺“任意 JVM 内任意对象均可直接读写”
|
||||||
|
- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测
|
||||||
|
- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象
|
||||||
|
- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入
|
||||||
|
- 不绕过目标服务现有认证、鉴权和网络边界
|
||||||
|
|
||||||
|
## 4. 需求与约束
|
||||||
|
|
||||||
|
### 4.1 需求清单
|
||||||
|
|
||||||
|
- 统一配置 JVM 连接
|
||||||
|
- 探测当前 JVM 支持的接入模式与可用能力
|
||||||
|
- 浏览缓存空间、管理对象和受控操作
|
||||||
|
- 查看值快照与元数据
|
||||||
|
- 执行受控修改,并提供 before/after 预览
|
||||||
|
- 将操作结果写入审计记录
|
||||||
|
- 支持 AI 对资源结构和修改方案进行分析
|
||||||
|
|
||||||
|
### 4.2 已确认约束
|
||||||
|
|
||||||
|
- 用户倾向通用型产品形态,但目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
|
||||||
|
- 企业环境下,稳定性与安全性优先级高于“黑科技式通用能力”
|
||||||
|
- 一期应优先基于标准协议和业务可控接入面,而不是侵入式 runtime 操作
|
||||||
|
|
||||||
|
## 5. 现状分析
|
||||||
|
|
||||||
|
### 5.1 GoNavi 架构启示
|
||||||
|
|
||||||
|
- `internal/db/database.go` 面向标准化数据源 CRUD,适合 SQL 类资源
|
||||||
|
- `internal/app/methods_redis.go` 证明 GoNavi 已支持“独立运行时系统能力线”
|
||||||
|
- `frontend/src/components/RedisViewer.tsx` 与 `frontend/src/components/RedisCommandEditor.tsx` 提供了树形浏览、结构化值编辑和控制台交互样板
|
||||||
|
- `frontend/src/components/AIChatPanel.tsx` 与 `frontend/src/components/ai/AIMessageBubble.tsx` 已具备 AI 交互和危险执行确认能力
|
||||||
|
|
||||||
|
### 5.2 结论
|
||||||
|
|
||||||
|
JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector` 子系统,而不是复用 SQL `Database` 接口。
|
||||||
|
|
||||||
|
## 6. 方案比较
|
||||||
|
|
||||||
|
### 方案 A:单一路径通用 Agent
|
||||||
|
|
||||||
|
- 描述:统一要求目标 JVM 通过 `-javaagent` 或运行时 attach 暴露运行时对象访问能力
|
||||||
|
- 优点:
|
||||||
|
- 理论能力上限最高
|
||||||
|
- 可覆盖更多自研缓存和深层对象
|
||||||
|
- 缺点:
|
||||||
|
- 与已知企业约束直接冲突
|
||||||
|
- 风险最高,部署与安全成本高
|
||||||
|
- 与首期产品化目标不匹配
|
||||||
|
|
||||||
|
### 方案 B:多接入模式 + 能力协商
|
||||||
|
|
||||||
|
- 描述:统一做 `JVM Connector`,底层同时支持 `JMX`、`Management Endpoint`、`Agent`
|
||||||
|
- 优点:
|
||||||
|
- 产品形态统一
|
||||||
|
- 能根据目标 JVM 能力降级
|
||||||
|
- 可先做低风险路径,后续再扩展高级模式
|
||||||
|
- 缺点:
|
||||||
|
- 不同模式能力不一致,UI 与权限模型更复杂
|
||||||
|
|
||||||
|
### 方案 C:只做业务侧管理端点
|
||||||
|
|
||||||
|
- 描述:完全放弃通用接入,只提供官方 Starter/管理端点接入
|
||||||
|
- 优点:
|
||||||
|
- 结构最稳,AI 最容易接入
|
||||||
|
- 权限、审计、预览、回滚最好做
|
||||||
|
- 缺点:
|
||||||
|
- 不满足“尽量通用”的产品定位
|
||||||
|
- 无法覆盖仅开放 JMX 的存量系统
|
||||||
|
|
||||||
|
## 7. 选型
|
||||||
|
|
||||||
|
采用方案 B。当前已落地:
|
||||||
|
|
||||||
|
- `JMX Provider`
|
||||||
|
- `Management Endpoint Provider`
|
||||||
|
- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent)
|
||||||
|
|
||||||
|
## 8. 目标架构
|
||||||
|
|
||||||
|
### 8.1 总体结构
|
||||||
|
|
||||||
|
新增统一的 `JVM Connector` 子系统,分为五层:
|
||||||
|
|
||||||
|
- `Connection Layer`
|
||||||
|
- 新增 `jvm` 连接类型
|
||||||
|
- 保存目标地址、认证、允许模式、首选模式、环境标签等配置
|
||||||
|
- `Capability Layer`
|
||||||
|
- 建立连接后探测当前支持的 provider 与能力矩阵
|
||||||
|
- `Provider Layer`
|
||||||
|
- `JMX Provider`
|
||||||
|
- `Management Endpoint Provider`
|
||||||
|
- `Agent Provider`(预留)
|
||||||
|
- `Resource Layer`
|
||||||
|
- 将不同来源统一映射为结构化资源
|
||||||
|
- `Guard Layer`
|
||||||
|
- 统一负责预览、确认、审计、回读验证、错误归一化
|
||||||
|
|
||||||
|
### 8.2 设计原则
|
||||||
|
|
||||||
|
- UI 统一,协议多态
|
||||||
|
- 读写分离,修改必须经过 Guard Layer
|
||||||
|
- provider 不得自行绕过权限与审计链路
|
||||||
|
- 能力不足时显式降级,不提供“看似可用、实际不可执行”的假入口
|
||||||
|
|
||||||
|
## 9. Provider 设计
|
||||||
|
|
||||||
|
### 9.1 JMX Provider
|
||||||
|
|
||||||
|
- 负责:
|
||||||
|
- 建立 JMX/RMI 连接
|
||||||
|
- 发现 MBean
|
||||||
|
- 读取属性
|
||||||
|
- 调用白名单操作
|
||||||
|
- 写入允许修改的白名单属性
|
||||||
|
- 适用场景:
|
||||||
|
- 目标 JVM 已开放 JMX
|
||||||
|
- 缓存或管理对象已暴露为 MBean
|
||||||
|
- 特点:
|
||||||
|
- 低侵入、标准化、可落地
|
||||||
|
- key/value 级资源能力通常有限
|
||||||
|
|
||||||
|
### 9.2 Management Endpoint Provider
|
||||||
|
|
||||||
|
- 负责:
|
||||||
|
- 调用业务服务暴露的 GoNavi 管理端点或 Starter
|
||||||
|
- 返回结构化缓存资源、元数据和受控动作
|
||||||
|
- 提供修改预览与回滚信息
|
||||||
|
- 适用场景:
|
||||||
|
- 业务方愿意接入轻量 Starter/管理端点
|
||||||
|
- 需要更强的 key/value 级浏览与修改能力
|
||||||
|
- 特点:
|
||||||
|
- 最适合产品化和 AI 协同
|
||||||
|
- 权限、脱敏、审计、回滚最容易做
|
||||||
|
|
||||||
|
### 9.3 Agent Provider
|
||||||
|
|
||||||
|
- 负责:
|
||||||
|
- 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口
|
||||||
|
- 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力
|
||||||
|
- 定位:
|
||||||
|
- 高级模式
|
||||||
|
- 不默认启用
|
||||||
|
- 需要目标 Java 服务以 `-javaagent` 方式显式启动
|
||||||
|
|
||||||
|
## 10. 统一资源模型
|
||||||
|
|
||||||
|
建议统一抽象以下资源:
|
||||||
|
|
||||||
|
- `runtime`
|
||||||
|
- 目标 JVM 实例
|
||||||
|
- `cacheNamespace`
|
||||||
|
- 缓存空间,如某个 CacheManager 下的 cacheName
|
||||||
|
- `cacheEntry`
|
||||||
|
- 具体缓存项 key/value
|
||||||
|
- `managedBean`
|
||||||
|
- 可读写的托管对象或 MBean
|
||||||
|
- `operation`
|
||||||
|
- 受控操作,如 `evict`、`put`、`refresh`、`clear`
|
||||||
|
- `auditRecord`
|
||||||
|
- 每次读写与 AI 建议的审计记录
|
||||||
|
|
||||||
|
统一资源模型要求:
|
||||||
|
|
||||||
|
- 每个资源都有稳定 ID、显示名、provider 来源、能力标签、敏感级别
|
||||||
|
- 值快照必须区分原始值、展示值和可编辑值
|
||||||
|
- 资源定位信息必须可写入审计
|
||||||
|
|
||||||
|
## 11. AI 协同设计
|
||||||
|
|
||||||
|
### 11.1 AI 的角色
|
||||||
|
|
||||||
|
AI 在 JVM 场景中只能作为“受控编排者”,不能作为直接执行者。
|
||||||
|
|
||||||
|
AI 可以:
|
||||||
|
|
||||||
|
- 解释缓存/Bean 的结构和当前状态
|
||||||
|
- 生成筛选条件和定位建议
|
||||||
|
- 生成结构化修改计划
|
||||||
|
- 生成风险说明和回滚建议
|
||||||
|
- 对执行前后结果做对比分析
|
||||||
|
|
||||||
|
AI 不应默认做:
|
||||||
|
|
||||||
|
- 直接执行 JVM 修改
|
||||||
|
- 自由生成任意脚本并直写内存
|
||||||
|
- 绕过人工确认直接调用 provider
|
||||||
|
|
||||||
|
### 11.2 AI 输出形态
|
||||||
|
|
||||||
|
AI 不直接输出脚本,而输出结构化变更计划,例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targetType": "cacheEntry",
|
||||||
|
"selector": {
|
||||||
|
"namespace": "userSessionCache",
|
||||||
|
"key": "user:1001"
|
||||||
|
},
|
||||||
|
"action": "updateValue",
|
||||||
|
"payload": {
|
||||||
|
"format": "json",
|
||||||
|
"value": {
|
||||||
|
"status": "ACTIVE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reason": "修复错误缓存态"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 AI 执行链路
|
||||||
|
|
||||||
|
1. AI 读取结构化上下文
|
||||||
|
2. AI 产出结构化变更计划
|
||||||
|
3. Guard Layer 校验目标资源、能力和权限
|
||||||
|
4. UI 展示修改预览与风险提示
|
||||||
|
5. 用户确认
|
||||||
|
6. provider 执行
|
||||||
|
7. 系统回读验证并写审计
|
||||||
|
|
||||||
|
### 11.4 一期 AI 边界
|
||||||
|
|
||||||
|
- 支持 AI 分析资源
|
||||||
|
- 支持 AI 生成修改计划
|
||||||
|
- 不默认支持 AI 自动执行修改
|
||||||
|
|
||||||
|
## 12. 页面与交互设计
|
||||||
|
|
||||||
|
### 12.1 连接层
|
||||||
|
|
||||||
|
在 `ConnectionModal` 中新增 `JVM` 类型,建议配置:
|
||||||
|
|
||||||
|
- 连接名称
|
||||||
|
- 目标地址/端口
|
||||||
|
- 认证信息
|
||||||
|
- 允许模式列表
|
||||||
|
- 首选模式
|
||||||
|
- 环境标签(DEV/UAT/PROD)
|
||||||
|
- 默认权限级别(只读/读写)
|
||||||
|
|
||||||
|
### 12.2 侧边栏
|
||||||
|
|
||||||
|
展示结构:
|
||||||
|
|
||||||
|
- 连接
|
||||||
|
- 模式能力
|
||||||
|
- 资源类型
|
||||||
|
- `cacheNamespace` / `managedBean` / `operation`
|
||||||
|
|
||||||
|
每个连接或节点显示能力徽标,例如:
|
||||||
|
|
||||||
|
- `JMX`
|
||||||
|
- `Endpoint`
|
||||||
|
- `Agent`
|
||||||
|
- `只读`
|
||||||
|
- `可写`
|
||||||
|
|
||||||
|
### 12.3 主工作区 Tab
|
||||||
|
|
||||||
|
建议新增以下 Tab 类型:
|
||||||
|
|
||||||
|
- `概览`
|
||||||
|
- `资源浏览`
|
||||||
|
- `值检查器`
|
||||||
|
- `修改预览`
|
||||||
|
- `AI 助手`
|
||||||
|
- `审计记录`
|
||||||
|
|
||||||
|
### 12.4 标准操作流
|
||||||
|
|
||||||
|
1. 用户连接 JVM
|
||||||
|
2. 系统探测 provider 能力
|
||||||
|
3. 用户选择资源并读取快照
|
||||||
|
4. 用户手工修改或让 AI 生成计划
|
||||||
|
5. 系统生成 before/after 预览
|
||||||
|
6. 用户二次确认
|
||||||
|
7. provider 执行
|
||||||
|
8. 系统回读验证
|
||||||
|
9. 写入审计与操作日志
|
||||||
|
|
||||||
|
## 13. 权限与审计
|
||||||
|
|
||||||
|
### 13.1 权限模型
|
||||||
|
|
||||||
|
权限建议分四层:
|
||||||
|
|
||||||
|
- `连接级`
|
||||||
|
- 决定默认 `readonly` / `readwrite`
|
||||||
|
- `模式级`
|
||||||
|
- 决定某 provider 支持哪些动作
|
||||||
|
- `资源级`
|
||||||
|
- 某些资源永远只读
|
||||||
|
- `环境级`
|
||||||
|
- `PROD` 默认强制二次确认,禁用 AI 自动执行
|
||||||
|
|
||||||
|
### 13.2 审计要求
|
||||||
|
|
||||||
|
JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogPanel 样式。
|
||||||
|
|
||||||
|
建议记录:
|
||||||
|
|
||||||
|
- 连接 ID / 名称
|
||||||
|
- provider 类型
|
||||||
|
- 资源定位信息
|
||||||
|
- 动作类型
|
||||||
|
- 修改原因
|
||||||
|
- AI 是否参与
|
||||||
|
- 执行前摘要
|
||||||
|
- 执行后摘要
|
||||||
|
- 结果状态
|
||||||
|
- 耗时
|
||||||
|
- 错误信息
|
||||||
|
|
||||||
|
建议本地独立落盘为 `jvm_audit.jsonl` 或等价结构,不混入 `sqlLogs`。
|
||||||
|
|
||||||
|
## 14. 错误处理与兼容性边界
|
||||||
|
|
||||||
|
### 14.1 错误分层
|
||||||
|
|
||||||
|
- `连接层失败`
|
||||||
|
- 认证失败、证书失败、JMX/RMI 不通、端点 401/403
|
||||||
|
- `能力层失败`
|
||||||
|
- 连接成功但不支持列 key、写值或批量操作
|
||||||
|
- `执行层失败`
|
||||||
|
- 资源不存在、值格式非法、provider 拒绝写入
|
||||||
|
- `验证层失败`
|
||||||
|
- 执行返回成功但回读校验不一致
|
||||||
|
|
||||||
|
所有错误都应显式标明是哪个 provider、哪一层失败,避免泛化为“修改失败”。
|
||||||
|
|
||||||
|
### 14.2 首期兼容性承诺
|
||||||
|
|
||||||
|
优先承诺以下边界:
|
||||||
|
|
||||||
|
- Java 8 / 11 / 17 / 21
|
||||||
|
- Spring Boot 服务优先
|
||||||
|
- JMX 标准 MBean
|
||||||
|
- Management Endpoint 模式下优先支持:
|
||||||
|
- Caffeine
|
||||||
|
- Ehcache
|
||||||
|
- Guava Cache
|
||||||
|
- Spring Cache 抽象下可枚举缓存
|
||||||
|
- 接入 GoNavi Starter 的自研缓存
|
||||||
|
- 值类型首期优先:
|
||||||
|
- string
|
||||||
|
- number
|
||||||
|
- boolean
|
||||||
|
- JSON object / JSON array
|
||||||
|
- map / list 的结构化展示
|
||||||
|
|
||||||
|
### 14.3 首期不承诺
|
||||||
|
|
||||||
|
- 任意 Java 对象深度反射编辑
|
||||||
|
- 无类型信息的二进制对象直接改写
|
||||||
|
- 跨 classloader 任意对象定位
|
||||||
|
- 生产环境默认开放批量危险写入
|
||||||
|
|
||||||
|
## 15. MVP 分期
|
||||||
|
|
||||||
|
### Phase 1:连接与只读探测
|
||||||
|
|
||||||
|
- JVM 连接类型
|
||||||
|
- JMX / Endpoint 能力探测
|
||||||
|
- 资源树浏览
|
||||||
|
- 值查看
|
||||||
|
- 概览页与能力徽标
|
||||||
|
- 不开放写入
|
||||||
|
|
||||||
|
### Phase 2:受控修改与审计
|
||||||
|
|
||||||
|
- 白名单资源写入
|
||||||
|
- before/after 预览
|
||||||
|
- 二次确认
|
||||||
|
- 审计日志
|
||||||
|
- 回读验证
|
||||||
|
- 环境级保护策略
|
||||||
|
|
||||||
|
### Phase 3:AI 协同
|
||||||
|
|
||||||
|
- AI 解释资源
|
||||||
|
- AI 生成修改计划
|
||||||
|
- AI 风险分析
|
||||||
|
- AI 回滚建议
|
||||||
|
- 仍默认不允许 AI 自动执行
|
||||||
|
|
||||||
|
### Phase 4:高级模式
|
||||||
|
|
||||||
|
- Agent Provider
|
||||||
|
- 预埋 Java Agent 的 runtime 资源治理能力
|
||||||
|
- 仅在特殊环境启用
|
||||||
|
|
||||||
|
## 16. 验证策略
|
||||||
|
|
||||||
|
### 16.1 功能验证
|
||||||
|
|
||||||
|
- 能连接 JMX 目标
|
||||||
|
- 能连接 Endpoint 目标
|
||||||
|
- 能列出缓存空间
|
||||||
|
- 能查看 key/value
|
||||||
|
- 能完成受控修改并回读成功
|
||||||
|
|
||||||
|
### 16.2 兼容性验证
|
||||||
|
|
||||||
|
- Java 8 / 11 / 17 / 21
|
||||||
|
- 本地、容器、K8s 内网场景
|
||||||
|
- 开启认证 / 不开启认证
|
||||||
|
- 仅 JMX、仅 Endpoint、双模式并存
|
||||||
|
|
||||||
|
### 16.3 安全验证
|
||||||
|
|
||||||
|
- 只读连接无法写入
|
||||||
|
- `PROD` 环境必须二次确认
|
||||||
|
- AI 无法绕过人工确认直接执行
|
||||||
|
- 审计日志完整记录修改链路
|
||||||
|
|
||||||
|
### 16.4 稳定性验证
|
||||||
|
|
||||||
|
- 目标 JVM 不可达时 UI 不假死
|
||||||
|
- 资源树大数量时支持分页或懒加载
|
||||||
|
- 回读失败时标识“不确定状态”
|
||||||
|
- provider 超时、部分失败、降级路径清晰
|
||||||
|
|
||||||
|
## 17. 风险与缓解
|
||||||
|
|
||||||
|
### 17.1 风险
|
||||||
|
|
||||||
|
- 多 provider 模式会带来能力不一致,用户可能误解“所有 JVM 都能随便改”
|
||||||
|
- JMX 模式的 key/value 级能力可能明显不足
|
||||||
|
- 管理端点模式需要业务接入,推广成本高于纯客户端方案
|
||||||
|
- 若未来引入 Agent 模式,可能引入新的安全审核和兼容性成本
|
||||||
|
|
||||||
|
### 17.2 缓解
|
||||||
|
|
||||||
|
- 在 UI 中显式展示能力矩阵和当前 provider 来源
|
||||||
|
- 所有修改都强制经过预览、确认与审计
|
||||||
|
- 首期将“通用”定义为“统一入口 + 多模式协商”,而不是“单通道万能能力”
|
||||||
|
- Agent 仅作为高级扩展位,避免污染 MVP 边界
|
||||||
|
|
||||||
|
## 18. 最终结论
|
||||||
|
|
||||||
|
JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采用“统一入口、多 provider、能力协商、强 Guard Layer”的产品化方案。
|
||||||
|
|
||||||
|
推荐结论如下:
|
||||||
|
|
||||||
|
- 新增独立的 `JVM Connector` 子系统
|
||||||
|
- 首期支持 `JMX + Management Endpoint`
|
||||||
|
- `Agent` 作为高级可选模式交付
|
||||||
|
- AI 首期支持分析与生成修改计划,不默认开放自动执行
|
||||||
|
- 所有修改必须经过预览、确认、审计和回读验证
|
||||||
|
|
||||||
|
这一路径能够在兼顾企业安全约束的前提下,为用户提供可持续演进的 JVM 运行时缓存治理能力。
|
||||||
246
docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# 需求进度追踪 - JVM缓存可视化编辑
|
||||||
|
|
||||||
|
## 1. 需求摘要
|
||||||
|
- 需求名称:JVM缓存可视化编辑
|
||||||
|
- 提出日期:2026-04-22
|
||||||
|
- 负责人:Codex
|
||||||
|
- 目标:完成 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的通用能力交付,降低“改缓存只能写接口或重启应用”的运维与排障成本
|
||||||
|
- 非目标:不承诺覆盖所有 Java 框架/所有对象类型,不绕过目标应用现有安全控制,不在首期开放脚本式任意表达式执行
|
||||||
|
|
||||||
|
## 2. 范围与验收
|
||||||
|
- 范围:
|
||||||
|
- 交付 JVM 共享契约、连接配置、provider 注册、连接测试与能力探测
|
||||||
|
- 交付 Endpoint / JMX / Agent 三种接入模式及其资源浏览、读值、预览、执行链路
|
||||||
|
- 交付 JVM 资源页、预览弹窗、审计查看、AI 草稿生成与回填能力
|
||||||
|
- 交付 Guard、审计、来源标记、真实集成测试与构建验证
|
||||||
|
- 验收标准:
|
||||||
|
- 可以在 GoNavi 中新增 JVM 连接并完成连接测试
|
||||||
|
- 可以按资源树浏览 JVM 对象并查看结构化快照
|
||||||
|
- 可以对支持写入的资源执行预览和确认写入,且带 Guard 与审计
|
||||||
|
- 可以通过 AI 生成结构化修改草稿,但不会跳过人工确认直接执行
|
||||||
|
- 可以通过真实 JMX 与真实 HTTP contract 完成端到端验证,并通过前后端构建回归
|
||||||
|
- 依赖与约束:
|
||||||
|
- 需复用 GoNavi 当前 Wails + React + driver-agent 架构
|
||||||
|
- 新能力不得破坏现有数据库/Redis 工作流
|
||||||
|
- 高风险写操作必须具备明确鉴权、审计与回滚思路
|
||||||
|
- JMX 模式要求 GoNavi 运行机器本地可用 `java` 可执行文件
|
||||||
|
|
||||||
|
## 3. 里程碑与进度
|
||||||
|
- [x] 阶段 1(需求澄清):完成
|
||||||
|
- [x] 阶段 2(影响分析):完成
|
||||||
|
- [x] 阶段 3(方案设计):完成(已形成正式设计文档)
|
||||||
|
- [x] 阶段 4(实施计划):完成(已形成正式实施计划)
|
||||||
|
- [x] 阶段 5(实现与自检):完成(Task 1 至 Task 7 已完成,代码与构建回归通过)
|
||||||
|
- [x] 阶段 6(评审与交付):完成(已完成契约复核、上下文隔离修正、文档回填与交付检查)
|
||||||
|
- [ ] 阶段 7(发布与观察):未开始
|
||||||
|
|
||||||
|
## 4. 变更清单
|
||||||
|
- 已完成:
|
||||||
|
- 确认 GoNavi 当前存在统一驱动接口与可选 driver-agent 机制
|
||||||
|
- 确认前端已有 Redis 结构化浏览、命令编辑器、Monaco 编辑器、DataGrid 编辑能力可复用
|
||||||
|
- 初步判断 JVM 运行时对象编辑不适合直接复用 SQL/Database 抽象,需新增非数据库协议层
|
||||||
|
- 用户已确认目标方向为“通用型 JVM 接入”
|
||||||
|
- 用户已确认升级到完整模式,开始高风险架构评估
|
||||||
|
- 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
|
||||||
|
- 已形成 JVM 缓存可视化编辑正式设计文档
|
||||||
|
- 已形成 JVM Connector MVP 正式实施计划文档
|
||||||
|
- 已完成 Task 1:JVM 共享契约与配置归一化
|
||||||
|
- 已完成 Task 2:Provider 注册、连接测试与能力探测 API
|
||||||
|
- 已完成 Task 3:JVM 连接表单、图标与展示文案接入
|
||||||
|
- 已完成 Task 4:只读资源浏览与 JVM Tab
|
||||||
|
- 已完成 Task 5:写入预览、Guard 和审计记录
|
||||||
|
- 已完成 Task 6:AI 结构化变更计划
|
||||||
|
- 已完成 Task 7:全量回归、文档回填与交付检查
|
||||||
|
- 已完成 JVM AI 计划解析、资源定位解析、AI 计划到当前 JVM 变更草稿的显式映射,避免把 `payload.format/value` 包装层直接透传到现有 JVM 写入契约
|
||||||
|
- 已完成 AI 聊天面板 JVM 上下文注入、AI 气泡“应用到 JVM 预览”入口以及 JVM 资源页草稿回填闭环
|
||||||
|
- 已完成 JVM AI 计划来源上下文绑定:消息现在绑定生成时的 `tabId + connectionId + providerMode + resourcePath`,避免切换 JVM 页签后误投递到当前激活页
|
||||||
|
- 已完成 Endpoint provider 真实 HTTP contract 与补测,支持资源浏览、读值、预览和执行
|
||||||
|
- 已完成可手工启动的 Java Endpoint fixture 与真实集成补测,可直接验证 Endpoint 模式端到端行为
|
||||||
|
- 已完成 JMX provider 真实 helper 接入与补测,支持 `domain -> mbean -> attribute/operation` 浏览、attribute `set`、operation `invoke`
|
||||||
|
- 已完成 JMX helper 预编译 runtime jar 内嵌分发,运行时不再依赖仓库源码目录,也不再要求本地 `javac`
|
||||||
|
- 已完成 JVM 快照动作提示与 payload 模板回填,前端可直接根据 `supportedActions` 生成草稿
|
||||||
|
- 已完成 AI 参与来源写入 JVM 审计记录,审计页可区分“手工”与“AI 辅助”
|
||||||
|
- 已完成 Agent provider、Agent 连接表单与概览展示,支持通过独立 Agent Base URL 接入 GoNavi Java Agent
|
||||||
|
- 已完成真实 Java Agent fixture 与集成验证,可通过 `-javaagent` 方式真实验证 Agent 模式资源浏览、预览与执行
|
||||||
|
- 已完成 JVM 收口优化:Endpoint 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义
|
||||||
|
- 待处理:
|
||||||
|
- 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项
|
||||||
|
|
||||||
|
## 5. 风险与阻塞
|
||||||
|
- 风险:
|
||||||
|
- 直接修改 JVM 内对象属于高风险运行时操作,误改可能造成业务状态污染
|
||||||
|
- 不同缓存框架(Caffeine/Ehcache/Guava/自研 Map)缺少统一标准协议
|
||||||
|
- 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性
|
||||||
|
- 若目标 JVM 不允许预埋或动态注入 Agent,则“通用型”能力边界会明显收缩
|
||||||
|
- 多接入模式会带来能力不一致问题,UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
|
||||||
|
- 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力
|
||||||
|
- 当前 AI 计划若只提供 `namespace + key`,仍更适合 endpoint/cache 风格资源;JMX 复杂 target 仍建议优先使用 `resourcePath`
|
||||||
|
- JMX helper 已改为内嵌 jar 分发,但操作者机器仍需本地存在可用 `java`
|
||||||
|
- Agent 模式要求目标 Java 服务显式以 `-javaagent` 方式启动 GoNavi Java Agent,并额外暴露管理端口
|
||||||
|
- JMX operation preview 仅做参数/签名校验和预览快照,不预测真实副作用
|
||||||
|
- JMX 参数转换当前覆盖基础类型、`ObjectName` 和部分数组;复杂对象写入仍是后续扩展项
|
||||||
|
- 历史旧 AI 消息不包含 JVM 来源上下文,若需要应用到预览,需在目标 JVM 资源页重新生成计划
|
||||||
|
- 阻塞:
|
||||||
|
- 当前开发收口阶段无新增阻塞
|
||||||
|
- 缓解措施:
|
||||||
|
- 优先收敛到标准接入面(JMX / Spring Actuator / Java Agent 三选一)
|
||||||
|
- 首期只支持白名单对象类型与受控写操作
|
||||||
|
- 要求变更审计、预览、确认与失败回滚路径
|
||||||
|
- 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入”
|
||||||
|
- JMX helper 改为内嵌 runtime jar,默认写入用户缓存目录;必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath
|
||||||
|
- 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行
|
||||||
|
|
||||||
|
## 6. 决策记录
|
||||||
|
- 决策 1:先做可行性评估与方案设计,不直接进入实现
|
||||||
|
- 决策 2:默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程
|
||||||
|
- 决策 3:已按完整模式推进,后续方案将优先评估通用 Agent 路径是否成立
|
||||||
|
- 决策 4:由于目标服务大概率不允许 agent/attach,后续推荐方向转为“多接入模式 + 能力协商”
|
||||||
|
- 决策 5:AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入
|
||||||
|
- 决策 6:AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
|
||||||
|
- 决策 7:当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
|
||||||
|
- 决策 8:JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
|
||||||
|
- 决策 9:JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java`
|
||||||
|
- 决策 10:Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach
|
||||||
|
|
||||||
|
## 7. 验证记录
|
||||||
|
- 验证项:
|
||||||
|
- GoNavi 驱动代理机制核查
|
||||||
|
- GoNavi 现有 Redis/编辑器/UI 复用能力核查
|
||||||
|
- JVM Connector 正式设计文档自检
|
||||||
|
- JVM Connector 实施计划文档自检
|
||||||
|
- Task 1:JVM 共享契约与配置归一化
|
||||||
|
- Task 2:Provider 注册、连接测试与能力探测 API
|
||||||
|
- Task 6:AI 计划解析、资源定位解析、契约映射与页签上下文隔离
|
||||||
|
- Task 7:Java Endpoint fixture 真实集成验证
|
||||||
|
- Task 7:JMX helper 内嵌分发与运行时缓存验证
|
||||||
|
- Task 7:Agent provider 与真实 Java Agent 集成验证
|
||||||
|
- Task 7:后端全量测试
|
||||||
|
- Task 7:前端全量测试
|
||||||
|
- Task 7:前端生产构建
|
||||||
|
- Task 7:Wails 生产构建
|
||||||
|
- 结果:
|
||||||
|
- 已确认存在可复用的连接桥接与编辑器基础设施
|
||||||
|
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
|
||||||
|
- 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节
|
||||||
|
- 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测
|
||||||
|
- Task 1 已完成规格审查与代码质量审查,结论均通过
|
||||||
|
- 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API
|
||||||
|
- Task 2 已完成规格审查与代码质量审查,结论均通过
|
||||||
|
- 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入
|
||||||
|
- Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题
|
||||||
|
- 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点
|
||||||
|
- Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题
|
||||||
|
- 已完成 Task 5:后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力
|
||||||
|
- Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底
|
||||||
|
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
|
||||||
|
- 已完成 Task 6:AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
|
||||||
|
- 已完成 Task 6:AI 聊天消息与 JVM 来源页签绑定,AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
|
||||||
|
- 已完成 Task 7:Java Endpoint fixture,可真实验证 `resources / value / preview / apply` 四个 endpoint contract
|
||||||
|
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过
|
||||||
|
- 已完成 Task 7:JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测
|
||||||
|
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过
|
||||||
|
- 已完成 Task 7:Agent provider、Java agent fixture 与真实 `-javaagent` 集成测试
|
||||||
|
- `go test ./internal/jvm -run 'TestAgentProvider' -count=1` 通过
|
||||||
|
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过(11 tests)
|
||||||
|
- `go test ./... -count=1` 通过
|
||||||
|
- `cd frontend && npm test -- --run` 通过(61 files,259 tests)
|
||||||
|
- `cd frontend && npm run build` 通过;构建中存在既有 chunk size / dynamic import 警告,但未阻塞产物生成
|
||||||
|
- `wails build -clean` 通过,成功生成 macOS 应用包
|
||||||
|
- 已完成 JVM 收口优化:模式能力探测现在按当前 mode 做业务化错误翻译,避免概览页继续回显 `non-JRMP server`、`baseURL is required` 这类原始报错
|
||||||
|
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 再次通过(Endpoint 能力探测只读语义回归)
|
||||||
|
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` 再次通过(能力探测模式透传与中文错误翻译回归)
|
||||||
|
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` 通过(JVM 资源页布局回归)
|
||||||
|
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` 通过(风险等级、审计结果等本地化展示回归)
|
||||||
|
- `cd frontend && npm run build` 再次通过
|
||||||
|
- `wails build -clean` 再次通过,成功生成最新可验收桌面包
|
||||||
|
- 证据(日志/截图/链接):
|
||||||
|
- `cmd/optional-driver-agent/main.go`
|
||||||
|
- `internal/db/database.go`
|
||||||
|
- `frontend/src/components/RedisViewer.tsx`
|
||||||
|
- `frontend/src/components/RedisCommandEditor.tsx`
|
||||||
|
- `frontend/src/components/QueryEditor.tsx`
|
||||||
|
- `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md`
|
||||||
|
- `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`
|
||||||
|
- `internal/connection/types.go`
|
||||||
|
- `internal/jvm/types.go`
|
||||||
|
- `internal/jvm/config.go`
|
||||||
|
- `internal/jvm/config_test.go`
|
||||||
|
- `frontend/src/types.ts`
|
||||||
|
- `frontend/src/utils/jvmConnectionConfig.ts`
|
||||||
|
- `frontend/src/utils/jvmConnectionConfig.test.ts`
|
||||||
|
- `go test ./internal/jvm -count=1`
|
||||||
|
- `go test ./...`
|
||||||
|
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
|
||||||
|
- `cd frontend && npm test -- --run`
|
||||||
|
- `cd frontend && npm run build`
|
||||||
|
- `internal/jvm/provider.go`
|
||||||
|
- `internal/jvm/jmx_provider.go`
|
||||||
|
- `internal/jvm/http_provider.go`
|
||||||
|
- `internal/jvm/http_provider_test.go`
|
||||||
|
- `internal/jvm/jmx_helper.go`
|
||||||
|
- `internal/jvm/jmx_helper_test.go`
|
||||||
|
- `internal/jvm/provider_contract_test.go`
|
||||||
|
- `internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar`
|
||||||
|
- `internal/jvm/jmxhelper_assets/README.md`
|
||||||
|
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java`
|
||||||
|
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java`
|
||||||
|
- `tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java`
|
||||||
|
- `internal/app/methods_jvm.go`
|
||||||
|
- `internal/app/methods_jvm_test.go`
|
||||||
|
- `frontend/wailsjs/go/app/App.d.ts`
|
||||||
|
- `frontend/wailsjs/go/app/App.js`
|
||||||
|
- `frontend/wailsjs/go/models.ts`
|
||||||
|
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1`
|
||||||
|
- `go test ./internal/jvm ./internal/app -count=1`
|
||||||
|
- `wails build -clean`
|
||||||
|
- `frontend/src/components/DatabaseIcons.tsx`
|
||||||
|
- `frontend/src/components/ConnectionModal.tsx`
|
||||||
|
- `frontend/src/utils/jvmRuntimePresentation.ts`
|
||||||
|
- `frontend/src/utils/jvmRuntimePresentation.test.ts`
|
||||||
|
- `frontend/src/utils/jvmConnectionConfig.ts`
|
||||||
|
- `frontend/src/utils/jvmConnectionConfig.test.ts`
|
||||||
|
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
|
||||||
|
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
|
||||||
|
- `cd frontend && npm run build`
|
||||||
|
- `internal/app/methods_jvm.go`
|
||||||
|
- `internal/app/methods_jvm_test.go`
|
||||||
|
- `frontend/src/components/Sidebar.tsx`
|
||||||
|
- `frontend/src/components/TabManager.tsx`
|
||||||
|
- `frontend/src/components/JVMOverview.tsx`
|
||||||
|
- `frontend/src/components/JVMResourceBrowser.tsx`
|
||||||
|
- `frontend/src/components/jvm/JVMModeBadge.tsx`
|
||||||
|
- `frontend/src/store.ts`
|
||||||
|
- `frontend/src/types.ts`
|
||||||
|
- `go test ./internal/app -run 'TestJVM(ListResources|GetValue)' -count=1`
|
||||||
|
- `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1`
|
||||||
|
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
|
||||||
|
- `cd frontend && npm run build`
|
||||||
|
- `internal/jvm/guard.go`
|
||||||
|
- `internal/jvm/guard_test.go`
|
||||||
|
- `internal/jvm/audit_store.go`
|
||||||
|
- `internal/jvm/audit_store_test.go`
|
||||||
|
- `internal/app/methods_jvm.go`
|
||||||
|
- `internal/app/methods_jvm_test.go`
|
||||||
|
- `frontend/src/components/JVMAuditViewer.tsx`
|
||||||
|
- `frontend/src/components/jvm/JVMChangePreviewModal.tsx`
|
||||||
|
- `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1`
|
||||||
|
- `go test ./internal/jvm ./internal/app -count=1`
|
||||||
|
- `cd frontend && npm run build`
|
||||||
|
- `frontend/src/utils/jvmAiPlan.ts`
|
||||||
|
- `frontend/src/utils/jvmAiPlan.test.ts`
|
||||||
|
- `frontend/src/components/AIChatPanel.tsx`
|
||||||
|
- `frontend/src/components/ai/AIMessageBubble.tsx`
|
||||||
|
- `frontend/src/components/JVMResourceBrowser.tsx`
|
||||||
|
- `frontend/src/types.ts`
|
||||||
|
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts`
|
||||||
|
- `go test ./... -count=1`
|
||||||
|
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1`
|
||||||
|
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1`
|
||||||
|
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx`
|
||||||
|
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts`
|
||||||
|
- `cd frontend && npm test -- --run`
|
||||||
|
- `wails build -clean`
|
||||||
|
|
||||||
|
## 8. 下一步
|
||||||
|
- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入
|
||||||
|
- 负责人:Codex
|
||||||
24
docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# SQL 方言适配需求进度追踪
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
|
||||||
|
- GitHub 相关问题:Refs #402(金仓字段类型/DDL 方言)、Refs #409(Oracle 删除数据 DATE 字面量)。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 表设计 ALTER TABLE 预览:按 MySQL-family、PostgreSQL-family、Oracle/Dameng、SQL Server、SQLite、DuckDB、ClickHouse、TDengine 分支生成。
|
||||||
|
- 新建表 DDL 预览:避免 Oracle/Dameng/SQL Server/SQLite/DuckDB/ClickHouse/TDengine 输出 MySQL 表选项。
|
||||||
|
- SQL 自动补全:按当前连接方言解析关键字和函数,避免 Oracle/SQL Server 出现 MySQL-only 提示。
|
||||||
|
- 表设计字段类型:按数据源给出候选类型,不再大量回退到 MySQL 通用类型。
|
||||||
|
- Oracle/Dameng 数据复制/删除 SQL:DATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
## 风险与后续
|
||||||
|
|
||||||
|
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER,并用中文注释阻止 MySQL 专属子句外溢。
|
||||||
|
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。
|
||||||
71
docs/需求追踪/需求进度追踪-发布脚本测试版号与Mac打包无交互-20260424.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# 需求进度追踪 - 发布脚本测试版号与 Mac 打包无交互
|
||||||
|
|
||||||
|
## 1. 需求摘要
|
||||||
|
- 需求名称:发布脚本测试版号与 Mac 打包无交互
|
||||||
|
- 提出日期:2026-04-24
|
||||||
|
- 负责人:Codex
|
||||||
|
- 目标:
|
||||||
|
- `build-release.sh` 不再触发 macOS DMG/Finder 排版交互。
|
||||||
|
- `build-release.sh` 与开发态应用内版本号统一使用测试版号来源。
|
||||||
|
- 非目标:
|
||||||
|
- 不调整 GitHub Release 工作流。
|
||||||
|
- 不修改正式发布 tag 版本策略。
|
||||||
|
|
||||||
|
## 2. 范围与验收
|
||||||
|
- 范围:
|
||||||
|
- 发布脚本 `build-release.sh`
|
||||||
|
- 版本解析逻辑 `internal/app/version.go`
|
||||||
|
- 共享测试版号文件
|
||||||
|
- 验收标准:
|
||||||
|
- `bash build-release.sh` 的 macOS 打包不再调用 `create-dmg` 或触发 Finder 排版。
|
||||||
|
- 本地开发态版本显示与发布脚本默认版本号一致。
|
||||||
|
- 保留环境变量覆盖版本号能力。
|
||||||
|
- 依赖与约束:
|
||||||
|
- 维持现有 Windows/Linux 构建逻辑不变。
|
||||||
|
|
||||||
|
## 3. 里程碑与进度
|
||||||
|
- [x] 阶段 1(需求澄清):确认去掉 DMG 排版,统一测试版号来源
|
||||||
|
- [x] 阶段 2(影响分析):锁定 `build-release.sh` 与 `internal/app/version.go`
|
||||||
|
- [x] 阶段 3(方案设计):共享 `version/dev-version.txt`,macOS 改 ZIP 打包
|
||||||
|
- [x] 阶段 4(实施计划):先补版本回归测试,再改实现
|
||||||
|
- [ ] 阶段 5(实现与自检):
|
||||||
|
- [ ] 阶段 6(评审与交付):
|
||||||
|
- [ ] 阶段 7(发布与观察):
|
||||||
|
|
||||||
|
## 4. 变更清单
|
||||||
|
- 已完成:
|
||||||
|
- 新增共享测试版号文件。
|
||||||
|
- 新增版本回归测试。
|
||||||
|
- 改造发布脚本 macOS 打包为无交互 ZIP。
|
||||||
|
- 进行中:
|
||||||
|
- 自检验证。
|
||||||
|
- 待处理:
|
||||||
|
- 无。
|
||||||
|
|
||||||
|
## 5. 风险与阻塞
|
||||||
|
- 风险:
|
||||||
|
- 正式发版若未覆盖 `GONAVI_VERSION`,默认会使用测试版号。
|
||||||
|
- 阻塞:
|
||||||
|
- 无。
|
||||||
|
- 缓解措施:
|
||||||
|
- 允许通过 `GONAVI_VERSION` 环境变量显式覆盖。
|
||||||
|
|
||||||
|
## 6. 决策记录
|
||||||
|
- 决策 1:以 `version/dev-version.txt` 作为本地开发/测试共享版本号来源。
|
||||||
|
- 决策 2:发布脚本的 macOS 产物改为 ZIP,避免 `create-dmg` 的 Finder 交互。
|
||||||
|
|
||||||
|
## 7. 验证记录
|
||||||
|
- 验证项:
|
||||||
|
- 版本回归测试
|
||||||
|
- 发布脚本语法检查
|
||||||
|
- 发布脚本运行输出
|
||||||
|
- 结果:
|
||||||
|
- 进行中
|
||||||
|
- 证据(日志/截图/链接):
|
||||||
|
- 待补充
|
||||||
|
|
||||||
|
## 8. 下一步
|
||||||
|
- 下一步行动:
|
||||||
|
- 跑通回归测试和脚本验证,确认输出产物与版本号
|
||||||
|
- 负责人:
|
||||||
|
- Codex
|
||||||
182
frontend/ai_ui_mockups_wip.html
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>AI UI Brainstorming Prototypes</title>
|
||||||
|
<!-- React & ReactDOM -->
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||||
|
<!-- Babel -->
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<!-- Ant Design -->
|
||||||
|
<script src="https://unpkg.com/dayjs/dayjs.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/antd/dist/antd.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/antd/dist/reset.css" />
|
||||||
|
<!-- Icons -->
|
||||||
|
<script src="https://unpkg.com/@ant-design/icons/dist/index.umd.js"></script>
|
||||||
|
<style>
|
||||||
|
body { padding: 40px; background: #f0f2f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }
|
||||||
|
.prototype-container { display: flex; gap: 40px; }
|
||||||
|
.prototype-column { flex: 1; max-width: 600px; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); overflow: hidden; }
|
||||||
|
.prototype-header { padding: 16px 24px; border-bottom: 1px solid #f0f0f0; background: #fafafa; font-weight: bold; }
|
||||||
|
.prototype-body { padding: 24px; }
|
||||||
|
|
||||||
|
/* Default App Theme Colors (Light Mode) */
|
||||||
|
:root {
|
||||||
|
--gn-border: rgba(16,24,40,0.08);
|
||||||
|
--gn-bg: rgba(255,255,255,0.84);
|
||||||
|
--gn-text: #162033;
|
||||||
|
--gn-muted: rgba(16,24,40,0.55);
|
||||||
|
--gn-primary: #1677ff;
|
||||||
|
--gn-primary-bg: rgba(24,144,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* V1 Styles: Professional List */
|
||||||
|
.v1-list-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 16px; margin-bottom: 8px; border-radius: 8px;
|
||||||
|
border: 1px solid transparent; cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.v1-list-item:hover { background: #f5f5f5; }
|
||||||
|
.v1-list-item.selected {
|
||||||
|
background: var(--gn-primary-bg); border-color: var(--gn-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* V2 Styles: Refined Cards (ConnectionModal Style) */
|
||||||
|
.v2-card-grid {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
||||||
|
}
|
||||||
|
.v2-card {
|
||||||
|
padding: 16px; border-radius: 12px; border: 1px solid var(--gn-border);
|
||||||
|
cursor: pointer; transition: all 0.2s; background: white;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(16,24,40,0.01);
|
||||||
|
}
|
||||||
|
.v2-card:hover { border-color: #d9d9d9; background: #fafafa; }
|
||||||
|
.v2-card.selected {
|
||||||
|
border-color: var(--gn-primary); box-shadow: 0 0 0 1px var(--gn-primary) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title { font-size: 13px; font-weight: 600; color: var(--gn-muted); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState } = React;
|
||||||
|
const { Input, Slider, Select, Button, Form, ConfigProvider } = antd;
|
||||||
|
const { ThunderboltOutlined, CloudOutlined, ExperimentOutlined, AppstoreOutlined, SettingOutlined, LinkOutlined, KeyOutlined } = icons;
|
||||||
|
|
||||||
|
const PROVIDERS = [
|
||||||
|
{ key: 'openai', label: 'OpenAI', icon: <ThunderboltOutlined />, desc: 'GPT-4o / o1' },
|
||||||
|
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'V3 / R1' },
|
||||||
|
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Sonnet 3.5' },
|
||||||
|
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '通用 API' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const V1ListDesign = () => {
|
||||||
|
const [selected, setSelected] = useState('openai');
|
||||||
|
return (
|
||||||
|
<div className="prototype-column">
|
||||||
|
<div className="prototype-header">方案一:IDE 专业列表风格 (更克制、无彩色渐变)</div>
|
||||||
|
<div className="prototype-body">
|
||||||
|
<div className="section-title">提供商选择</div>
|
||||||
|
<div style={{ marginBottom: 24, padding: 8, background: '#fafafa', borderRadius: 10, border: '1px solid #f0f0f0' }}>
|
||||||
|
{PROVIDERS.map(p => (
|
||||||
|
<div key={p.key} className={`v1-list-item ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 32, height: 32, borderRadius: 6, display: 'grid', placeItems: 'center',
|
||||||
|
background: selected === p.key ? '#1677ff' : '#e6f4ff',
|
||||||
|
color: selected === p.key ? '#fff' : '#1677ff', fontSize: 16
|
||||||
|
}}>
|
||||||
|
{p.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--gn-muted)' }}>{p.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selected === p.key ? 'var(--gn-primary)' : '#d9d9d9'}`, padding: 2 }}>
|
||||||
|
{selected === p.key && <div style={{ width: '100%', height: '100%', background: 'var(--gn-primary)', borderRadius: '50%' }} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-title">连接配置 (紧凑表单)</div>
|
||||||
|
<Form layout="vertical" size="middle">
|
||||||
|
<Form.Item label="API Endpoint">
|
||||||
|
<Input placeholder="https://api.openai.com/v1" prefix={<LinkOutlined style={{color: 'var(--gn-muted)'}}/>} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="API Key">
|
||||||
|
<Input.Password placeholder="sk-..." prefix={<KeyOutlined style={{color: 'var(--gn-muted)'}}/>} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Model Name">
|
||||||
|
<Input placeholder="gpt-4o" prefix={<AppstoreOutlined style={{color: 'var(--gn-muted)'}}/>} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const V2CardDesign = () => {
|
||||||
|
const [selected, setSelected] = useState('openai');
|
||||||
|
return (
|
||||||
|
<div className="prototype-column">
|
||||||
|
<div className="prototype-header">方案二:GoNavi 统一卡片风格 (类似 ConnectionModal)</div>
|
||||||
|
<div className="prototype-body">
|
||||||
|
<div className="section-title">选择服务提供商</div>
|
||||||
|
<div className="v2-card-grid" style={{ marginBottom: 24 }}>
|
||||||
|
{PROVIDERS.map(p => (
|
||||||
|
<div key={p.key} className={`v2-card ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
|
<div style={{ color: selected === p.key ? 'var(--gn-primary)' : 'var(--gn-muted)', fontSize: 20, marginTop: 2 }}>
|
||||||
|
{p.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--gn-muted)', marginTop: 4 }}>{p.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: 20, borderRadius: 12, border: '1px solid var(--gn-border)', background: '#fafafa' }}>
|
||||||
|
<div className="section-title" style={{ marginTop: 0 }}>认证与设置</div>
|
||||||
|
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} size="middle">
|
||||||
|
<Form.Item label="Endpoint" style={{ marginBottom: 16 }}>
|
||||||
|
<Input placeholder="https://api..." />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="API Key" style={{ marginBottom: 16 }}>
|
||||||
|
<Input.Password placeholder="sk-..." />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="模型名称" style={{ marginBottom: 0 }}>
|
||||||
|
<Input placeholder="例如 gpt-4o" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<h1 style={{ fontSize: 24, margin: 0 }}>AI 设置 UI 重构探讨</h1>
|
||||||
|
<p style={{ color: 'var(--gn-muted)' }}>当前设计带有太多渐变和鲜艳色彩("AI 味")。以下是遵循 GoNavi 本身设计规范(克制、专业)的两个方案:</p>
|
||||||
|
</div>
|
||||||
|
<div className="prototype-container">
|
||||||
|
<V1ListDesign />
|
||||||
|
<V2CardDesign />
|
||||||
|
</div>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(<App />);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,6 +5,23 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>GoNavi</title>
|
<title>GoNavi</title>
|
||||||
|
<script>
|
||||||
|
if (typeof window !== 'undefined' && !window.go) {
|
||||||
|
window.go = {
|
||||||
|
app: {
|
||||||
|
App: new Proxy({}, { get: () => async () => ({ success: false }) })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && !window.runtime) {
|
||||||
|
window.runtime = new Proxy({}, {
|
||||||
|
get: (target, prop) => {
|
||||||
|
if (prop === 'Environment') return async () => ({ platform: 'darwin' });
|
||||||
|
return typeof prop === 'string' && prop.startsWith('WindowIs') ? () => false : () => {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
3538
frontend/package-lock.json
generated
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "gonavi-client",
|
"name": "gonavi-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.6.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
@@ -15,11 +16,17 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"antd": "^5.12.0",
|
"antd": "^5.12.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
"mermaid": "^11.13.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable": "^3.1.3",
|
"react-resizable": "^3.1.3",
|
||||||
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sql-formatter": "^15.7.0",
|
"sql-formatter": "^15.7.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
@@ -31,6 +38,7 @@
|
|||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.0.8"
|
"vite": "^5.0.8",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
5b8157374dae5f9340e31b2d0bd2c00e
|
571d014306268cf67665967059cda912
|
||||||
1
frontend/public/db-icons/clickhouse.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>ClickHouse</title><path d="M21.333 10H24v4h-2.667ZM16 1.335h2.667v21.33H16Zm-5.333 0h2.666v21.33h-2.666ZM0 22.665V1.335h2.667v21.33zm5.333-21.33H8v21.33H5.333Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 246 B |
1
frontend/public/db-icons/diros.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apache Doris</title><path d="M8.666.0001c-.5355-.004-1.068.1072-1.5241.3384-.207.1048-.5749.3802-.8177.6118-1.0278.9803-1.2876 2.5138-.6553 3.8679.205.439.5068.7694 2.8476 3.1166 2.4527 2.4594 2.6352 2.6255 2.8852 2.6258.2446.0003.3647-.099 1.4408-1.19.9367-.9496 1.2306-1.2992 1.4536-1.7286.5966-1.149.6487-2.0513.174-3.014-.2264-.459-.4816-.7514-1.9012-2.176-.9018-.9052-1.7907-1.7496-1.9751-1.8765C10.0488.2005 9.3548.0052 8.666 0ZM3.5518 5.5737c-.2176.0031-.6097.085-.6097.3285v12.0904l.1642.175c.1123.1194.2498.1748.4342.1748.2545 0 .4436-.1738 3.349-3.0786 2.6868-2.6862 3.079-2.909 3.0791-3.305.0002-.3961-.3924-.6194-3.0784-3.306-2.8612-2.8619-3.0968-3.079-3.3384-3.079Zm13.0967.861c-.0481.0184-.112.1636-.1418.3225-.0756.403-.3719 1.109-.6572 1.5663-.1407.2253-2.2392 2.3955-5.049 5.2212-2.7513 2.7667-4.9104 4.9985-5.0468 5.2165-.4552.7275-.5967 1.3905-.4684 2.1964.222 1.3947 1.3263 2.6812 2.5486 2.9693.4667.11 1.618.0927 2.0329-.0305.2084-.062.526-.2112.7055-.3318.5023-.3373 9.341-9.0562 9.6463-9.5154.449-.6753.8356-1.0716.8395-1.9762-.0056-.5935-.1305-1.1138-1.0715-2.306-.5094-.6523-3.2341-3.3723-3.338-3.3324Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
frontend/public/db-icons/duckdb.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>DuckDB</title><path d="M12 0C5.363 0 0 5.363 0 12s5.363 12 12 12 12-5.363 12-12S18.637 0 12 0zM9.502 7.03a4.974 4.974 0 0 1 4.97 4.97 4.974 4.974 0 0 1-4.97 4.97A4.974 4.974 0 0 1 4.532 12a4.974 4.974 0 0 1 4.97-4.97zm6.563 3.183h2.351c.98 0 1.787.782 1.787 1.762s-.807 1.789-1.787 1.789h-2.351v-3.551z"/></svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
1
frontend/public/db-icons/mariadb.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MariaDB</title><path d="M23.157 4.412c-.676.284-.79.31-1.673.372-.65.045-.757.057-1.212.209-.75.246-1.395.75-2.02 1.59-.296.398-1.249 1.913-1.249 1.988 0 .057-.65.998-.915 1.32-.574.713-1.08 1.079-2.14 1.59-.77.36-1.224.524-4.102 1.477-1.073.353-2.133.738-2.367.864-.852.449-1.515 1.036-2.203 1.938-1.003 1.32-.972 1.313-3.042.947a12.264 12.264 0 00-.675-.063c-.644-.05-1.023.044-1.332.334L0 17.193l.177.088c.094.05.353.234.561.398.215.17.461.347.55.391.088.044.17.088.183.101.012.013-.089.17-.228.353-.435.581-.593.871-.574 1.048.019.164.032.17.43.17.517-.006.826-.056 1.261-.208.65-.233 2.058-.94 2.784-1.4.776-.5 1.717-.998 1.956-1.042.082-.02.354-.07.594-.114.58-.107 1.464-.095 2.587.05.108.013.373.045.6.064.227.025.43.057.454.076.026.012.474.037.998.056.934.026 1.104.007 1.3-.189.126-.133.385-.631.498-.985.209-.643.417-.921.366-.492-.113.966-.322 1.692-.713 2.411-.259.499-.663 1.092-.934 1.395-.322.347-.315.36.088.315.619-.063 1.471-.397 2.096-.82.827-.562 1.647-1.691 2.19-3.03.107-.27.22-.22.183.083-.013.094-.038.315-.057.498l-.031.328.353-.202c.833-.48 1.414-1.262 2.127-2.884.227-.518.877-2.922 1.073-3.976a9.64 9.64 0 01.271-1.042c.127-.429.196-.555.48-.858.183-.19.625-.555.978-.808.72-.505.953-.75 1.187-1.205.208-.417.284-1.13.132-1.357-.132-.202-.284-.196-.763.006Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/db-icons/mongodb.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MongoDB</title><path d="M17.193 9.555c-1.264-5.58-4.252-7.414-4.573-8.115-.28-.394-.53-.954-.735-1.44-.036.495-.055.685-.523 1.184-.723.566-4.438 3.682-4.74 10.02-.282 5.912 4.27 9.435 4.888 9.884l.07.05A73.49 73.49 0 0111.91 24h.481c.114-1.032.284-2.056.51-3.07.417-.296.604-.463.85-.693a11.342 11.342 0 003.639-8.464c.01-.814-.103-1.662-.197-2.218zm-5.336 8.195s0-8.291.275-8.29c.213 0 .49 10.695.49 10.695-.381-.045-.765-1.76-.765-2.405z"/></svg>
|
||||||
|
After Width: | Height: | Size: 527 B |
1
frontend/public/db-icons/mysql.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MySQL</title><path d="M16.405 5.501c-.115 0-.193.014-.274.033v.013h.014c.054.104.146.18.214.273.054.107.1.214.154.32l.014-.015c.094-.066.14-.172.14-.333-.04-.047-.046-.094-.08-.14-.04-.067-.126-.1-.18-.153zM5.77 18.695h-.927a50.854 50.854 0 00-.27-4.41h-.008l-1.41 4.41H2.45l-1.4-4.41h-.01a72.892 72.892 0 00-.195 4.41H0c.055-1.966.192-3.81.41-5.53h1.15l1.335 4.064h.008l1.347-4.064h1.095c.242 2.015.384 3.86.428 5.53zm4.017-4.08c-.378 2.045-.876 3.533-1.492 4.46-.482.716-1.01 1.073-1.583 1.073-.153 0-.34-.046-.566-.138v-.494c.11.017.24.026.386.026.268 0 .483-.075.647-.222.197-.18.295-.382.295-.605 0-.155-.077-.47-.23-.944L6.23 14.615h.91l.727 2.36c.164.536.233.91.205 1.123.4-1.064.678-2.227.835-3.483zm12.325 4.08h-2.63v-5.53h.885v4.85h1.745zm-3.32.135l-1.016-.5c.09-.076.177-.158.255-.25.433-.506.648-1.258.648-2.253 0-1.83-.718-2.746-2.155-2.746-.704 0-1.254.232-1.65.697-.43.508-.646 1.256-.646 2.245 0 .972.19 1.686.574 2.14.35.41.877.615 1.583.615.264 0 .506-.033.725-.098l1.325.772.36-.622zM15.5 17.588c-.225-.36-.337-.94-.337-1.736 0-1.393.424-2.09 1.27-2.09.443 0 .77.167.977.5.224.362.336.936.336 1.723 0 1.404-.424 2.108-1.27 2.108-.445 0-.77-.167-.978-.5zm-1.658-.425c0 .47-.172.856-.516 1.156-.344.3-.803.45-1.384.45-.543 0-1.064-.172-1.573-.515l.237-.476c.438.22.833.328 1.19.328.332 0 .593-.073.783-.22a.754.754 0 00.3-.615c0-.33-.23-.61-.648-.845-.388-.213-1.163-.657-1.163-.657-.422-.307-.632-.636-.632-1.177 0-.45.157-.81.47-1.085.315-.278.72-.415 1.22-.415.512 0 .98.136 1.4.41l-.213.476a2.726 2.726 0 00-1.064-.23c-.283 0-.502.068-.654.206a.685.685 0 00-.248.524c0 .328.234.61.666.85.393.215 1.187.67 1.187.67.433.305.648.63.648 1.168zm9.382-5.852c-.535-.014-.95.04-1.297.188-.1.04-.26.04-.274.167.055.053.063.14.11.214.08.134.218.313.346.407.14.11.28.216.427.31.26.16.555.255.81.416.145.094.293.213.44.313.073.05.12.14.214.172v-.02c-.046-.06-.06-.147-.105-.214-.067-.067-.134-.127-.2-.193a3.223 3.223 0 00-.695-.675c-.214-.146-.682-.35-.77-.595l-.013-.014c.146-.013.32-.066.46-.106.227-.06.435-.047.67-.106.106-.027.213-.06.32-.094v-.06c-.12-.12-.21-.283-.334-.395a8.867 8.867 0 00-1.104-.823c-.21-.134-.476-.22-.697-.334-.08-.04-.214-.06-.26-.127-.12-.146-.19-.34-.275-.514a17.69 17.69 0 01-.547-1.163c-.12-.262-.193-.523-.34-.763-.69-1.137-1.437-1.826-2.586-2.5-.247-.14-.543-.2-.856-.274-.167-.008-.334-.02-.5-.027-.11-.047-.216-.174-.31-.235-.38-.24-1.364-.76-1.644-.072-.18.434.267.862.422 1.082.115.153.26.328.34.5.047.116.06.235.107.356.106.294.207.622.347.897.073.14.153.287.247.413.054.073.146.107.167.227-.094.136-.1.334-.154.5-.24.757-.146 1.693.194 2.25.107.166.362.534.703.393.3-.12.234-.5.32-.835.02-.08.007-.133.048-.187v.015c.094.188.188.367.274.555.206.328.566.668.867.895.16.12.287.328.487.402v-.02h-.015c-.043-.058-.1-.086-.154-.133a3.445 3.445 0 01-.35-.4 8.76 8.76 0 01-.747-1.218c-.11-.21-.202-.436-.29-.643-.04-.08-.04-.2-.107-.24-.1.146-.247.273-.32.453-.127.288-.14.642-.188 1.01-.027.007-.014 0-.027.014-.214-.052-.287-.274-.367-.46-.2-.475-.233-1.238-.06-1.785.047-.14.247-.582.167-.716-.042-.127-.174-.2-.247-.303a2.478 2.478 0 01-.24-.427c-.16-.374-.24-.788-.414-1.162-.08-.173-.22-.354-.334-.513-.127-.18-.267-.307-.368-.52-.033-.073-.08-.194-.027-.274.014-.054.042-.075.094-.09.088-.072.335.022.422.062.247.1.455.194.662.334.094.066.195.193.315.226h.14c.214.047.455.014.655.073.355.114.675.28.962.46a5.953 5.953 0 012.085 2.286c.08.154.115.295.188.455.14.33.313.663.455.982.14.315.275.636.476.897.1.14.502.213.682.286.133.06.34.115.46.188.23.14.454.3.67.454.11.076.443.243.463.378z"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
1
frontend/public/db-icons/postgres.svg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
1
frontend/public/db-icons/redis.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Redis</title><path d="M22.71 13.145c-1.66 2.092-3.452 4.483-7.038 4.483-3.203 0-4.397-2.825-4.48-5.12.701 1.484 2.073 2.685 4.214 2.63 4.117-.133 6.94-3.852 6.94-7.239 0-4.05-3.022-6.972-8.268-6.972-3.752 0-8.4 1.428-11.455 3.685C2.59 6.937 3.885 9.958 4.35 9.626c2.648-1.904 4.748-3.13 6.784-3.744C8.12 9.244.886 17.05 0 18.425c.1 1.261 1.66 4.648 2.424 4.648.232 0 .431-.133.664-.365a100.49 100.49 0 0 0 5.54-6.765c.222 3.104 1.748 6.898 6.014 6.898 3.819 0 7.604-2.756 9.33-8.965.2-.764-.73-1.361-1.261-.73zm-4.349-5.013c0 1.959-1.926 2.922-3.685 2.922-.941 0-1.664-.247-2.235-.568 1.051-1.592 2.092-3.225 3.21-4.973 1.972.334 2.71 1.43 2.71 2.619z"/></svg>
|
||||||
|
After Width: | Height: | Size: 738 B |
1
frontend/public/db-icons/sphinx.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Sphinx</title><path d="M16.284 19.861c0-.654.177-1.834.393-2.623.499-1.822.774-4.079.497-4.079-.116 0-.959.762-1.873 1.694-3.472 3.54-7.197 5.543-10.312 5.543-1.778 0-2.987-.45-4.154-1.545C.128 18.186 0 17.858 0 16.703c0-1.188.117-1.468.909-2.175.718-.642 1.171-.813 2.157-.813.76.171 1.21.16 1.457.461.251.296.338 1.265.035 1.832-.162.303-.585.491-1.105.491-.49 0-.77-.116-.669-.278.315-.511-.135-.857-.713-.548-.699.374-.711 1.698-.021 2.322.969.878 3.65 1.208 5.262.648 1.743-.605 4.022-2.061 5.841-3.732l1.6-1.469-2.088-.013c-2.186-.012-3.608-.273-8.211-1.506-1.531-.41-3.003-.765-3.271-.789-.304-.026-.503-.274-.487-.656.027-.646.378-1.127.793-1.308.249-.109 1.977-.274 3.809-.761 7.136-1.898 7.569-1.629 12.323-.426 1.553.393 3.351.821 4.147.835 1.227.022 1.493.124 1.74.666.16.351.291.686.291.745 0 .058-.695.424-1.545.813-3.12 1.428-4.104 2.185-3.088 3.635.421.602.412.666-.14 1.052-.323.227-.59.687-.593 1.022-.009.908-.583 2.856-1.417 3.624l-.732.675v-1.189Zm1.594-8.328c1.242-.346 1.994-.738 3.539-1.562-1.272-.372-4.462-.895-4.462-.895-2.354-.472-2.108-.448-2.214.071a3.475 3.475 0 0 1-.45 1.105c-.541.848-2.521 1.026-3.656.483-.356-.171-.714-.821-.709-1.283.007-.65-.362-.801-.598-.714-.191.07-.813.079-2.179.448-4.514 1.217-5.132 1.078-2.189 1.495.353.05 2.223.572 3.136.815 2.239.597 2.658.641 5.556.581 2.015-.042 2.858-.163 4.226-.544ZM.732 6.258c.056-.577.088-.702 1.692-1.025.919-.185 3.185-.785 5.036-1.333 4.254-1.26 5.462-1.263 9.873-.026 1.904.535 4.037.973 4.74.975 1.097.002 1.668.487 1.668.487.505 1.16.412 1.24-1.558 1.24-1.374 0-2.558-.232-4.385-.857-1.389-.476-3.369-.923-4.451-1.004-1.974-.149-1.971-.15-8.072 1.529-1.072.295-2.553.624-3.29.732l-1.342.196.089-.914Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/public/db-icons/sqlite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>SQLite</title><path d="M21.678.521c-1.032-.92-2.28-.55-3.513.544a8.71 8.71 0 0 0-.547.535c-2.109 2.237-4.066 6.38-4.674 9.544.237.48.422 1.093.544 1.561a13.044 13.044 0 0 1 .164.703s-.019-.071-.096-.296l-.05-.146a1.689 1.689 0 0 0-.033-.08c-.138-.32-.518-.995-.686-1.289-.143.423-.27.818-.376 1.176.484.884.778 2.4.778 2.4s-.025-.099-.147-.442c-.107-.303-.644-1.244-.772-1.464-.217.804-.304 1.346-.226 1.478.152.256.296.698.422 1.186.286 1.1.485 2.44.485 2.44l.017.224a22.41 22.41 0 0 0 .056 2.748c.095 1.146.273 2.13.5 2.657l.155-.084c-.334-1.038-.47-2.399-.41-3.967.09-2.398.642-5.29 1.661-8.304 1.723-4.55 4.113-8.201 6.3-9.945-1.993 1.8-4.692 7.63-5.5 9.788-.904 2.416-1.545 4.684-1.931 6.857.666-2.037 2.821-2.912 2.821-2.912s1.057-1.304 2.292-3.166c-.74.169-1.955.458-2.362.629-.6.251-.762.337-.762.337s1.945-1.184 3.613-1.72C21.695 7.9 24.195 2.767 21.678.521m-18.573.543A1.842 1.842 0 0 0 1.27 2.9v16.608a1.84 1.84 0 0 0 1.835 1.834h9.418a22.953 22.953 0 0 1-.052-2.707c-.006-.062-.011-.141-.016-.2a27.01 27.01 0 0 0-.473-2.378c-.121-.47-.275-.898-.369-1.057-.116-.197-.098-.31-.097-.432 0-.12.015-.245.037-.386a9.98 9.98 0 0 1 .234-1.045l.217-.028c-.017-.035-.014-.065-.031-.097l-.041-.381a32.8 32.8 0 0 1 .382-1.194l.2-.019c-.008-.016-.01-.038-.018-.053l-.043-.316c.63-3.28 2.587-7.443 4.8-9.791.066-.069.133-.128.198-.194Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
6
frontend/public/db-icons/sqlserver.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>SQL Server</title>
|
||||||
|
<path fill="#A91D22" d="M4.2 7.25c1.05-1.56 4.53-2.69 8.24-2.69 3.34 0 6.13.91 7.25 2.15.57.64.63 1.29.16 1.87-1 1.27-3.81 2.09-7.18 2.09-3.85 0-7.1-1.03-8.29-2.52-.32-.4-.38-.61-.18-.9Z"/>
|
||||||
|
<path fill="#D63539" d="M5.07 11.11c1.27-1.2 4.24-2.04 7.42-2.04 3.59 0 6.58 1.04 7.34 2.54.27.54.16 1.07-.34 1.55-1.18 1.12-3.89 1.81-7.12 1.81-3.56 0-6.56-.91-7.6-2.25-.4-.52-.31-1.02.3-1.61Z"/>
|
||||||
|
<path fill="#F15F5C" d="M7.2 16.12c1.12-.75 3.11-1.18 5.38-1.18 2.43 0 4.59.52 5.71 1.39.84.65 1 1.42.42 2.05-.92 1-3.09 1.63-5.74 1.63-2.87 0-5.34-.75-6.22-1.88-.53-.68-.36-1.37.45-2.01Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 691 B |
@@ -7,7 +7,7 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body, #root {
|
body, #root {
|
||||||
border-radius: 14px; /* Slightly rounded app window corners */
|
border-radius: var(--gonavi-border-radius); /* Slightly rounded app window corners */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 侧边栏 Tree 样式优化 */
|
/* 侧边栏 Tree 样式优化 */
|
||||||
@@ -37,6 +37,126 @@ body, #root {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-scroll-shell {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-scroll-content {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-scroll-shell .ant-tree {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-scroll-shell .ant-tree .ant-tree-list-holder,
|
||||||
|
.sidebar-tree-scroll-shell .ant-tree .ant-tree-list-holder-inner {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-scroll-shell .ant-tree .ant-tree-treenode {
|
||||||
|
width: auto;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-scroll-shell .ant-tree .ant-tree-node-content-wrapper {
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-scroll-shell .ant-tree .ant-tree-title {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-tree {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner,
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner .ant-tree-treenode {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper {
|
||||||
|
min-height: 36px;
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:hover,
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:active,
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus,
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus-visible,
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-treenode {
|
||||||
|
padding: 2px 0;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
display: flex !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-switcher {
|
||||||
|
width: 0 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
margin-inline-end: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-switcher:hover,
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-switcher:active,
|
||||||
|
.redis-viewer-workbench .ant-tree .ant-tree-switcher:focus {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .redis-tree-expander-button:hover,
|
||||||
|
.redis-viewer-workbench .redis-tree-expander-button:focus-visible {
|
||||||
|
background: transparent !important;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper {
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-inline-end: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper:last-child {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-table {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redis-viewer-workbench .ant-table-wrapper .ant-table-thead > tr > th {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar styling for dark mode */
|
/* Scrollbar styling for dark mode */
|
||||||
body[data-theme='dark'] ::-webkit-scrollbar {
|
body[data-theme='dark'] ::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
@@ -57,6 +177,29 @@ body[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
|
|||||||
background: #666;
|
background: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for light mode (transparent-friendly) */
|
||||||
|
body[data-theme='light'] ::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
body[data-theme='light'] ::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
body[data-theme='light'] ::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
body[data-theme='light'] ::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
body[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.30);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
|
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
|
||||||
body {
|
body {
|
||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
@@ -67,6 +210,96 @@ body[data-theme='dark'] {
|
|||||||
在透明窗口环境下会显著加剧 GPU 负载 */
|
在透明窗口环境下会显著加剧 GPU 负载 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 暗色 + 透明:提升选中/焦点可读性,避免默认蓝色在半透明背景下发灰 */
|
||||||
|
body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
|
||||||
|
body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
|
||||||
|
background: rgba(246, 196, 83, 0.24) !important;
|
||||||
|
color: rgba(255, 236, 179, 0.98) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
|
||||||
|
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
|
||||||
|
background: linear-gradient(90deg, rgba(246, 196, 83, 0.22), rgba(246, 196, 83, 0.08)) !important;
|
||||||
|
border: 1px solid rgba(246, 196, 83, 0.24) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .ant-checkbox-checked .ant-checkbox-inner {
|
||||||
|
background-color: #f6c453 !important;
|
||||||
|
border-color: #f6c453 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .ant-checkbox-indeterminate .ant-checkbox-inner::after {
|
||||||
|
background-color: #f6c453 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .ant-checkbox:hover .ant-checkbox-inner,
|
||||||
|
body[data-theme='dark'] .ant-checkbox-wrapper:hover .ant-checkbox-inner {
|
||||||
|
border-color: #f6c453 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .ant-radio-checked .ant-radio-inner {
|
||||||
|
border-color: #f6c453 !important;
|
||||||
|
background-color: #f6c453 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .ant-radio-wrapper:hover .ant-radio-inner,
|
||||||
|
body[data-theme='dark'] .ant-radio:hover .ant-radio-inner {
|
||||||
|
border-color: #f6c453 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .ant-switch.ant-switch-checked {
|
||||||
|
background: #d8a93b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected > td,
|
||||||
|
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell {
|
||||||
|
background: rgba(246, 196, 83, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected:hover > td,
|
||||||
|
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell {
|
||||||
|
background: rgba(246, 196, 83, 0.26) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(230, 234, 242, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
|
||||||
|
background: rgba(246, 196, 83, 0.16);
|
||||||
|
border-color: rgba(246, 196, 83, 0.3);
|
||||||
|
color: #f6c453;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
|
||||||
|
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
|
||||||
|
color: rgba(15, 23, 42, 0.92) !important;
|
||||||
|
background: linear-gradient(90deg, rgba(22, 119, 255, 0.12), rgba(22, 119, 255, 0.04)) !important;
|
||||||
|
border: 1px solid rgba(22, 119, 255, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border-color: rgba(15, 23, 42, 0.08);
|
||||||
|
color: rgba(51, 65, 85, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
|
||||||
|
background: rgba(22, 119, 255, 0.1);
|
||||||
|
border-color: rgba(22, 119, 255, 0.22);
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
|
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
|
||||||
.connection-modal-wrap {
|
.connection-modal-wrap {
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
@@ -92,3 +325,97 @@ body[data-theme='dark'] {
|
|||||||
background-color: #ff4d4f !important;
|
background-color: #ff4d4f !important;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */
|
||||||
|
.driver-manager-table .ant-table-sticky-scroll {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */
|
||||||
|
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content,
|
||||||
|
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body {
|
||||||
|
overflow-x: auto !important;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal,
|
||||||
|
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal {
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-manager-table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-manager-footer {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-manager-footer-actions {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-manager-hscroll {
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-manager-hscroll-inner {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-update-action-btn.ant-btn,
|
||||||
|
.security-update-action-btn.ant-btn-default,
|
||||||
|
.security-update-action-btn.ant-btn-primary,
|
||||||
|
.security-update-action-btn.ant-btn-text {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-update-action-btn.ant-btn:focus,
|
||||||
|
.security-update-action-btn.ant-btn:focus-visible,
|
||||||
|
.security-update-action-btn.ant-btn-default:focus,
|
||||||
|
.security-update-action-btn.ant-btn-default:focus-visible,
|
||||||
|
.security-update-action-btn.ant-btn-primary:focus,
|
||||||
|
.security-update-action-btn.ant-btn-primary:focus-visible,
|
||||||
|
.security-update-action-btn.ant-btn-text:focus,
|
||||||
|
.security-update-action-btn.ant-btn-text:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-update-banner {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-update-result-card {
|
||||||
|
transition: background 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-update-result-card-active {
|
||||||
|
animation: security-update-result-pulse 1.8s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes security-update-result-pulse {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
3376
frontend/src/App.tsx
497
frontend/src/components/AIChatPanel.css
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
.ai-chat-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 1px solid rgba(128, 128, 128, 0.12);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize Handle */
|
||||||
|
.ai-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 10;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-resize-handle:hover,
|
||||||
|
.ai-resize-handle.active {
|
||||||
|
background: rgba(22, 119, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.ai-chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-header-left .ai-logo {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-header-left .ai-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages Area */
|
||||||
|
.ai-chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-messages::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-messages::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-messages::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(128, 128, 128, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welcome */
|
||||||
|
.ai-chat-welcome {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-welcome .welcome-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-welcome .welcome-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-welcome .quick-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-welcome .quick-action-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-welcome .quick-action-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.12) !important;
|
||||||
|
border-color: rgba(99, 102, 241, 0.3) !important;
|
||||||
|
color: #818cf8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IDE Style Messages */
|
||||||
|
.ai-ide-message {
|
||||||
|
padding: 12px 16px;
|
||||||
|
animation: ai-msg-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-msg-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-ide-message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-ide-message-content {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
/* Remove pre-wrap here, as it conflicts with ReactMarkdown's block rendering */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown Styles Override */
|
||||||
|
.ai-markdown-content {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.ai-markdown-content p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.ai-markdown-content p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.ai-markdown-content h1,
|
||||||
|
.ai-markdown-content h2,
|
||||||
|
.ai-markdown-content h3,
|
||||||
|
.ai-markdown-content h4,
|
||||||
|
.ai-markdown-content h5,
|
||||||
|
.ai-markdown-content h6 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ai-markdown-content h1:first-child,
|
||||||
|
.ai-markdown-content h2:first-child,
|
||||||
|
.ai-markdown-content h3:first-child,
|
||||||
|
.ai-markdown-content h4:first-child,
|
||||||
|
.ai-markdown-content h5:first-child,
|
||||||
|
.ai-markdown-content h6:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.ai-markdown-content pre {
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.ai-markdown-content code {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
background: rgba(128, 128, 128, 0.15);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
.ai-markdown-content ul, .ai-markdown-content ol {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.ai-markdown-content li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Advanced Typing/Blinker indicator */
|
||||||
|
.ai-blinking-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 14px;
|
||||||
|
background-color: currentColor;
|
||||||
|
border-radius: 1px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 4px;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-dot-bounce {
|
||||||
|
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||||
|
40% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History Drawer Styles */
|
||||||
|
.ai-history-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
.ai-history-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(128, 128, 128, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.ai-history-list:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(128, 128, 128, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-history-item:hover {
|
||||||
|
background: rgba(128, 128, 128, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-history-item .ai-history-delete-btn {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-history-item:hover .ai-history-delete-btn,
|
||||||
|
.ai-history-item.active .ai-history-delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Area */
|
||||||
|
.ai-chat-input-area {
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea scrollbar */
|
||||||
|
.ai-chat-input-wrapper textarea {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input-wrapper textarea::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input-wrapper textarea::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input-wrapper textarea::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(128, 128, 128, 0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom-color: rgba(128, 128, 128, 0.4);
|
||||||
|
padding: 6px 10px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input-wrapper:focus-within {
|
||||||
|
border-color: var(--ant-primary-color, #1677ff) !important;
|
||||||
|
background: rgba(128, 128, 128, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input-wrapper textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
resize: none;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 28px;
|
||||||
|
max-height: 200px;
|
||||||
|
padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input-wrapper textarea::placeholder {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-send-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-send-btn:hover {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-send-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-send-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-ide-message:hover .ai-message-actions {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown 额外样式增强: Table & Blockquote */
|
||||||
|
.ai-markdown-content table {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 让消息内容区域成为表格的滚动约束容器 */
|
||||||
|
.ai-ide-message-content {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格滚动容器 - 不限定直接子元素 */
|
||||||
|
.ai-markdown-content table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-markdown-content table::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-markdown-content table::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(128, 128, 128, 0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-markdown-content th,
|
||||||
|
.ai-markdown-content td {
|
||||||
|
border: 1px solid rgba(125, 125, 125, 0.2);
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-markdown-content th {
|
||||||
|
background: rgba(125, 125, 125, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-markdown-content blockquote {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-left: 4px solid rgba(125, 125, 125, 0.4);
|
||||||
|
background: rgba(125, 125, 125, 0.05);
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.85;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖 code 块容器样式避免和 syntax highlighter 冲突 */
|
||||||
|
.ai-markdown-content > pre {
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 新版 AI 状态流转动画 ===== */
|
||||||
|
|
||||||
|
/* 1. 连接脉冲动画 (connecting) */
|
||||||
|
.ai-wave-pulse {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.ai-wave-pulse span {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: currentColor;
|
||||||
|
animation: wave-pulse-anim 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.ai-wave-pulse span:nth-child(1) { animation-delay: 0s; }
|
||||||
|
.ai-wave-pulse span:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
.ai-wave-pulse span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
|
||||||
|
@keyframes wave-pulse-anim {
|
||||||
|
0%, 100% { transform: translateY(0) scale(0.8); opacity: 0.4; }
|
||||||
|
50% { transform: translateY(-4px) scale(1.1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 平滑高度与透明度过渡 (针对 ThinkingBlock 和 面板折叠) */
|
||||||
|
.ai-expand-transition {
|
||||||
|
display: grid;
|
||||||
|
transition: grid-template-rows 0.3s ease-out, opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
.ai-expand-transition.expanded {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.ai-expand-transition.collapsed {
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.ai-expand-transition > div {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Agent风格旋转Loading环 */
|
||||||
|
.ai-spinning-ring {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(22, 119, 255, 0.2);
|
||||||
|
border-top-color: #1677ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: ai-spin-anim 0.8s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-spin-anim {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 面板/弹窗内部 toast 定位覆盖:从 fixed(视口顶部)改为 absolute(容器内部顶部) */
|
||||||
|
.ai-chat-panel .ant-message,
|
||||||
|
.ai-settings-body .ant-message {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 16px !important;
|
||||||
|
left: 50% !important;
|
||||||
|
transform: translateX(-50%) !important;
|
||||||
|
right: auto !important;
|
||||||
|
width: max-content;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
1736
frontend/src/components/AIChatPanel.tsx
Normal file
841
frontend/src/components/AISettingsModal.tsx
Normal file
@@ -0,0 +1,841 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Modal, Button, Input, Select, Form, Checkbox, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
|
||||||
|
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
|
||||||
|
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
|
||||||
|
import {
|
||||||
|
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
|
||||||
|
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||||
|
QWEN_CODING_PLAN_MODELS,
|
||||||
|
resolveProviderPresetKey,
|
||||||
|
resolvePresetBaseURL,
|
||||||
|
resolvePresetModelSelection,
|
||||||
|
resolvePresetTransport,
|
||||||
|
} from '../utils/aiProviderPresets';
|
||||||
|
import {
|
||||||
|
PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||||
|
PROVIDER_PRESET_CARD_CONTENT_STYLE,
|
||||||
|
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
|
||||||
|
PROVIDER_PRESET_GRID_STYLE,
|
||||||
|
PROVIDER_PRESET_CARD_TITLE_STYLE,
|
||||||
|
} from '../utils/aiSettingsPresetLayout';
|
||||||
|
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
|
||||||
|
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
|
||||||
|
|
||||||
|
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||||
|
|
||||||
|
interface AISettingsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
darkMode: boolean;
|
||||||
|
overlayTheme: OverlayWorkbenchTheme;
|
||||||
|
focusProviderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预设配置:每个预设映射到后端 type(openai/anthropic/gemini/custom)并附带默认 URL 和 Model
|
||||||
|
interface ProviderPreset {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
desc: string;
|
||||||
|
color: string;
|
||||||
|
backendType: AIProviderType;
|
||||||
|
fixedApiFormat?: string;
|
||||||
|
defaultBaseUrl: string;
|
||||||
|
defaultModel: string;
|
||||||
|
models: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||||
|
{ key: 'openai', label: 'OpenAI', icon: <ApiOutlined />, desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] },
|
||||||
|
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
|
||||||
|
{ key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: <CloudOutlined />, desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },
|
||||||
|
{ key: 'qwen-coding-plan', label: '通义千问(Coding Plan)', icon: <CloudOutlined />, desc: 'Claude Code CLI 代理链路 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS },
|
||||||
|
{ key: 'zhipu', label: '智谱 GLM', icon: <ExperimentOutlined />, desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] },
|
||||||
|
{ key: 'moonshot', label: 'Kimi', icon: <ExperimentOutlined />, desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] },
|
||||||
|
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] },
|
||||||
|
{ key: 'gemini', label: 'Gemini', icon: <CloudOutlined />, desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] },
|
||||||
|
{ key: 'volcengine-ark', label: '火山方舟', icon: <CloudOutlined />, desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
|
||||||
|
{ key: 'volcengine-coding', label: '火山 Coding Plan', icon: <CloudOutlined />, desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] },
|
||||||
|
{ key: 'minimax', label: 'MiniMax', icon: <ExperimentOutlined />, desc: 'M2.7 / M2.5 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] },
|
||||||
|
{ key: 'ollama', label: 'Ollama', icon: <AppstoreOutlined />, desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
|
||||||
|
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
|
||||||
|
|
||||||
|
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): ProviderPreset => {
|
||||||
|
const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom');
|
||||||
|
return findPreset(presetKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [
|
||||||
|
{ label: '只读模式', value: 'readonly', desc: 'AI 仅可执行 SELECT 等查询操作,最安全', color: '#22c55e', icon: '🔒' },
|
||||||
|
{ label: '读写模式', value: 'readwrite', desc: 'AI 可执行 INSERT/UPDATE/DELETE,危险操作需二次确认', color: '#f59e0b', icon: '⚠️' },
|
||||||
|
{ label: '完全模式', value: 'full', desc: 'AI 可执行所有操作(含 DDL),高危操作自动告警', color: '#ef4444', icon: '🔓' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; icon: string }[] = [
|
||||||
|
{ label: '仅 Schema', value: 'schema_only', desc: '只传递表/列结构信息给 AI', icon: '📋' },
|
||||||
|
{ label: '含采样数据', value: 'with_samples', desc: '包含少量采样数据帮助 AI 理解数据特征', icon: '📊' },
|
||||||
|
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
|
||||||
|
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||||||
|
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||||||
|
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
|
||||||
|
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
|
||||||
|
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
|
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
|
||||||
|
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
|
||||||
|
const [clearProviderSecret, setClearProviderSecret] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const modalBodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Modal 内部 toast 通知
|
||||||
|
const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body });
|
||||||
|
|
||||||
|
// 主题色
|
||||||
|
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
|
||||||
|
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
|
||||||
|
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
|
||||||
|
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
|
||||||
|
const inputBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
|
||||||
|
|
||||||
|
// Hook 必须在组件顶层调用,不能在条件分支内
|
||||||
|
const watchedType = Form.useWatch('type', form);
|
||||||
|
const watchedPresetKey = Form.useWatch('presetKey', form);
|
||||||
|
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
|
||||||
|
const watchedApiKeyInput = Form.useWatch('apiKey', form);
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const Service = (window as any).go?.aiservice?.Service;
|
||||||
|
if (!Service) { console.warn('[AI] Service not found on window.go'); return; }
|
||||||
|
const [provRes, safeRes, ctxRes, promptsRes] = await Promise.all([
|
||||||
|
Service.AIGetProviders?.() || [],
|
||||||
|
Service.AIGetSafetyLevel?.() || 'readonly',
|
||||||
|
Service.AIGetContextLevel?.() || 'schema_only',
|
||||||
|
Service.AIGetBuiltinPrompts?.() || {},
|
||||||
|
]);
|
||||||
|
console.log('[AI] AIGetProviders result:', JSON.stringify(provRes), 'isArray:', Array.isArray(provRes));
|
||||||
|
if (Array.isArray(provRes)) {
|
||||||
|
setProviders(provRes);
|
||||||
|
const activeRes = await Service.AIGetActiveProvider?.();
|
||||||
|
console.log('[AI] AIGetActiveProvider result:', activeRes);
|
||||||
|
if (activeRes) setActiveProviderId(activeRes);
|
||||||
|
}
|
||||||
|
if (safeRes) setSafetyLevel(safeRes);
|
||||||
|
if (ctxRes) setContextLevel(ctxRes);
|
||||||
|
if (promptsRes) setBuiltinPrompts(promptsRes);
|
||||||
|
} catch (e) { console.warn('Failed to load AI config', e); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !focusProviderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!providers.some((provider) => provider.id === focusProviderId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveSection('providers');
|
||||||
|
setActiveProviderId(focusProviderId);
|
||||||
|
}, [focusProviderId, open, providers]);
|
||||||
|
|
||||||
|
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
|
||||||
|
setEditingProvider(session.editingProvider as AIProviderConfig | null);
|
||||||
|
setIsEditing(session.isEditing);
|
||||||
|
setTestStatus(session.testStatus);
|
||||||
|
setClearProviderSecret(session.clearProviderSecret);
|
||||||
|
form.resetFields();
|
||||||
|
if (session.formValues) {
|
||||||
|
form.setFieldsValue(session.formValues);
|
||||||
|
}
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const resetProviderEditorSession = useCallback(() => {
|
||||||
|
applyProviderEditorSession(buildClosedProviderEditorSession());
|
||||||
|
}, [applyProviderEditorSession]);
|
||||||
|
|
||||||
|
const handleModalClose = useCallback(() => {
|
||||||
|
resetProviderEditorSession();
|
||||||
|
onClose();
|
||||||
|
}, [onClose, resetProviderEditorSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
resetProviderEditorSession();
|
||||||
|
}
|
||||||
|
}, [open, resetProviderEditorSession]);
|
||||||
|
const handleAddProvider = () => {
|
||||||
|
const preset = findPreset('openai');
|
||||||
|
applyProviderEditorSession(buildAddProviderEditorSession({
|
||||||
|
presetKey: 'openai',
|
||||||
|
presetBackendType: preset.backendType,
|
||||||
|
presetBaseUrl: preset.defaultBaseUrl,
|
||||||
|
presetModel: preset.defaultModel,
|
||||||
|
presetModels: preset.models,
|
||||||
|
apiFormat: 'openai',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditProvider = (p: AIProviderConfig) => {
|
||||||
|
// 尝试根据 baseUrl 和 type 推断 preset
|
||||||
|
const matchedPreset = matchProviderPreset(p);
|
||||||
|
const resolvedTransport = resolvePresetTransport({
|
||||||
|
presetBackendType: matchedPreset.backendType,
|
||||||
|
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||||||
|
valuesApiFormat: p.apiFormat,
|
||||||
|
});
|
||||||
|
applyProviderEditorSession(buildEditProviderEditorSession({
|
||||||
|
provider: { ...p, presetKey: matchedPreset.key } as any,
|
||||||
|
formValues: {
|
||||||
|
...p,
|
||||||
|
type: resolvedTransport.type,
|
||||||
|
models: p.models || [],
|
||||||
|
presetKey: matchedPreset.key,
|
||||||
|
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProvider = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const Service = (window as any).go?.aiservice?.Service;
|
||||||
|
const wasActive = id === activeProviderId;
|
||||||
|
await Service?.AIDeleteProvider?.(id);
|
||||||
|
await loadConfig();
|
||||||
|
// 合并提示:删除的是当前激活的供应商时,附带自动切换信息
|
||||||
|
if (wasActive) {
|
||||||
|
const newProviders: any[] = await Service?.AIGetProviders?.() || [];
|
||||||
|
if (newProviders.length > 0) {
|
||||||
|
const newActiveName = newProviders[0]?.name || '下一个供应商';
|
||||||
|
void messageApi.success(`已删除,自动切换到「${newActiveName}」`);
|
||||||
|
} else {
|
||||||
|
void messageApi.success('已删除');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
void messageApi.success('已删除');
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||||
|
} catch (e: any) { void messageApi.error(e?.message || '删除失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProvider = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
const Service = (window as any).go?.aiservice?.Service;
|
||||||
|
|
||||||
|
// 构建 payload,处理 model/models 逻辑
|
||||||
|
const preset = findPreset(values.presetKey);
|
||||||
|
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
|
||||||
|
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||||||
|
presetKey: values.presetKey,
|
||||||
|
presetDefaultModel: preset.defaultModel,
|
||||||
|
presetModels: preset.models,
|
||||||
|
valuesModel: values.model,
|
||||||
|
customModels: values.models,
|
||||||
|
});
|
||||||
|
// 内置供应商自动使用 preset label 作为名称
|
||||||
|
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
|
||||||
|
|
||||||
|
const finalBaseUrl = resolvePresetBaseURL({
|
||||||
|
presetKey: values.presetKey,
|
||||||
|
presetDefaultBaseUrl: preset.defaultBaseUrl,
|
||||||
|
valuesBaseUrl: values.baseUrl,
|
||||||
|
});
|
||||||
|
const resolvedTransport = resolvePresetTransport({
|
||||||
|
presetBackendType: preset.backendType,
|
||||||
|
presetFixedApiFormat: preset.fixedApiFormat,
|
||||||
|
valuesApiFormat: values.apiFormat,
|
||||||
|
});
|
||||||
|
const secretDraft = resolveProviderSecretDraft({
|
||||||
|
hasSecret: editingProvider?.hasSecret,
|
||||||
|
apiKeyInput: values.apiKey,
|
||||||
|
clearSecret: clearProviderSecret,
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
...editingProvider,
|
||||||
|
...values,
|
||||||
|
...resolvedTransport,
|
||||||
|
name: finalName,
|
||||||
|
apiKey: secretDraft.apiKey,
|
||||||
|
hasSecret: secretDraft.hasSecret,
|
||||||
|
model: finalModel,
|
||||||
|
models: resolvedModels,
|
||||||
|
baseUrl: finalBaseUrl,
|
||||||
|
apiFormat: resolvedTransport.apiFormat,
|
||||||
|
};
|
||||||
|
// 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常
|
||||||
|
await Service?.AISaveProvider?.(payload);
|
||||||
|
void messageApi.success('已保存'); 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 || '保存失败');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetActive = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const Service = (window as any).go?.aiservice?.Service;
|
||||||
|
await Service?.AISetActiveProvider?.(id);
|
||||||
|
setActiveProviderId(id); void messageApi.success('已切换');
|
||||||
|
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||||
|
} catch (e: any) { void messageApi.error(e?.message || '切换失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSafetyChange = async (level: AISafetyLevel) => {
|
||||||
|
try {
|
||||||
|
const Service = (window as any).go?.aiservice?.Service;
|
||||||
|
await Service?.AISetSafetyLevel?.(level);
|
||||||
|
setSafetyLevel(level);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextChange = async (level: AIContextLevel) => {
|
||||||
|
try {
|
||||||
|
const Service = (window as any).go?.aiservice?.Service;
|
||||||
|
await Service?.AISetContextLevel?.(level);
|
||||||
|
setContextLevel(level);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestProvider = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
setTestStatus('idle');
|
||||||
|
const Service = (window as any).go?.aiservice?.Service;
|
||||||
|
const preset = findPreset(values.presetKey || 'openai');
|
||||||
|
const finalBaseUrl = resolvePresetBaseURL({
|
||||||
|
presetKey: values.presetKey || 'openai',
|
||||||
|
presetDefaultBaseUrl: preset.defaultBaseUrl,
|
||||||
|
valuesBaseUrl: values.baseUrl,
|
||||||
|
});
|
||||||
|
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||||||
|
presetKey: values.presetKey || 'openai',
|
||||||
|
presetDefaultModel: preset.defaultModel,
|
||||||
|
presetModels: preset.models,
|
||||||
|
valuesModel: values.model,
|
||||||
|
customModels: values.models,
|
||||||
|
});
|
||||||
|
const resolvedTransport = resolvePresetTransport({
|
||||||
|
presetBackendType: preset.backendType,
|
||||||
|
presetFixedApiFormat: preset.fixedApiFormat,
|
||||||
|
valuesApiFormat: values.apiFormat,
|
||||||
|
});
|
||||||
|
const secretDraft = resolveProviderSecretDraft({
|
||||||
|
hasSecret: editingProvider?.hasSecret,
|
||||||
|
apiKeyInput: values.apiKey,
|
||||||
|
clearSecret: clearProviderSecret,
|
||||||
|
});
|
||||||
|
if (secretDraft.mode === 'clear') {
|
||||||
|
throw new Error('测试连接前请填写新的 API Key,或取消清除已保存密钥');
|
||||||
|
}
|
||||||
|
const res = await Service?.AITestProvider?.({
|
||||||
|
...editingProvider,
|
||||||
|
...values,
|
||||||
|
...resolvedTransport,
|
||||||
|
apiKey: secretDraft.apiKey,
|
||||||
|
hasSecret: secretDraft.hasSecret,
|
||||||
|
baseUrl: finalBaseUrl,
|
||||||
|
model: finalModel,
|
||||||
|
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 || '测试失败'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePresetChange = (presetKey: string) => {
|
||||||
|
const preset = findPreset(presetKey);
|
||||||
|
const resolvedTransport = resolvePresetTransport({
|
||||||
|
presetBackendType: preset.backendType,
|
||||||
|
presetFixedApiFormat: preset.fixedApiFormat,
|
||||||
|
valuesApiFormat: form.getFieldValue('apiFormat'),
|
||||||
|
});
|
||||||
|
form.setFieldsValue({
|
||||||
|
presetKey,
|
||||||
|
type: resolvedTransport.type,
|
||||||
|
apiFormat: resolvedTransport.apiFormat || 'openai',
|
||||||
|
baseUrl: preset.defaultBaseUrl,
|
||||||
|
model: preset.defaultModel,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 字段装饰器样式 ----
|
||||||
|
const fieldGroupStyle: React.CSSProperties = {
|
||||||
|
padding: '14px 16px', borderRadius: 12, border: `1px solid ${cardBorder}`,
|
||||||
|
background: cardBg, marginBottom: 12,
|
||||||
|
};
|
||||||
|
const fieldLabelStyle: React.CSSProperties = {
|
||||||
|
fontSize: 13, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.08em',
|
||||||
|
color: sectionLabelColor, marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Provider 列表 =====
|
||||||
|
const renderProviderList = () => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{providers.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center', padding: '36px 20px', color: overlayTheme.mutedText, fontSize: 14,
|
||||||
|
border: `1px dashed ${cardBorder}`, borderRadius: 14, background: cardBg,
|
||||||
|
}}>
|
||||||
|
<RobotOutlined style={{ fontSize: 32, marginBottom: 12, opacity: 0.3, display: 'block' }} />
|
||||||
|
暂未配置模型供应商<br />
|
||||||
|
<span style={{ fontSize: 13, opacity: 0.6 }}>添加一个以开始使用 AI 助手</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{providers.map(p => {
|
||||||
|
const matchedPreset = matchProviderPreset(p);
|
||||||
|
const isActive = p.id === activeProviderId;
|
||||||
|
return (
|
||||||
|
<div key={p.id} onClick={() => handleSetActive(p.id)} style={{
|
||||||
|
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
|
||||||
|
border: `1.5px solid ${isActive ? overlayTheme.selectedText : cardBorder}`,
|
||||||
|
background: isActive ? overlayTheme.selectedBg : cardBg,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center',
|
||||||
|
background: isActive ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
|
||||||
|
color: isActive ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||||
|
fontSize: 18, flexShrink: 0, transition: 'all 0.2s ease',
|
||||||
|
}}>
|
||||||
|
{matchedPreset.icon || <ApiOutlined />}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{p.name || p.type}
|
||||||
|
{isActive && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 13 }} />}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>{matchedPreset.label}</span>
|
||||||
|
<span style={{ opacity: 0.4 }}>·</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.model || '未选择模型'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Space size={2}>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<Button type="text" size="small" icon={<EditOutlined />}
|
||||||
|
onClick={e => { e.stopPropagation(); handleEditProvider(p); }}
|
||||||
|
style={{ color: overlayTheme.mutedText }} />
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDeleteProvider(p.id)}
|
||||||
|
okButtonProps={{ danger: true }} okText="删除" cancelText="取消">
|
||||||
|
<Button type="text" size="small" icon={<DeleteOutlined />} danger
|
||||||
|
onClick={e => e.stopPropagation()} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddProvider}
|
||||||
|
style={{ borderRadius: 12, height: 42, borderColor: darkMode ? 'rgba(255,255,255,0.12)' : undefined }}>
|
||||||
|
添加模型供应商
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== Provider 编辑表单 =====
|
||||||
|
const renderProviderForm = () => {
|
||||||
|
const presetKeyFromForm = watchedPresetKey || (editingProvider as any)?.presetKey || 'openai';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 顶部返回 */}
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Button size="small" onClick={resetProviderEditorSession}
|
||||||
|
style={{ borderRadius: 8 }}>← 返回</Button>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
|
||||||
|
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical" size="small">
|
||||||
|
{/* Provider 类型选择 - 卡片式 */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<div style={fieldLabelStyle}>
|
||||||
|
<AppstoreOutlined style={{ fontSize: 14 }} /> 服务类型
|
||||||
|
</div>
|
||||||
|
<Form.Item name="presetKey" noStyle>
|
||||||
|
<div style={PROVIDER_PRESET_GRID_STYLE}>
|
||||||
|
{PROVIDER_PRESETS.map(pt => (
|
||||||
|
<div key={pt.key} onClick={() => { form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }}
|
||||||
|
style={{
|
||||||
|
...PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||||
|
border: `1.5px solid ${presetKeyFromForm === pt.key ? overlayTheme.selectedText : 'transparent'}`,
|
||||||
|
background: presetKeyFromForm === pt.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
|
||||||
|
boxShadow: presetKeyFromForm === pt.key ? 'none' : (darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)'),
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: presetKeyFromForm === pt.key ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||||
|
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{pt.icon}
|
||||||
|
</div>
|
||||||
|
<div style={PROVIDER_PRESET_CARD_CONTENT_STYLE}>
|
||||||
|
<div style={{ ...PROVIDER_PRESET_CARD_TITLE_STYLE, fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
|
||||||
|
<div style={{ ...PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.4 }}>{pt.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="type" hidden><Input /></Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 基本信息 - 仅自定义/Ollama 显示 */}
|
||||||
|
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
|
||||||
|
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
|
||||||
|
<div style={fieldLabelStyle}>
|
||||||
|
<RobotOutlined style={{ fontSize: 14 }} /> 基本信息
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>供应商名称</span>} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}>
|
||||||
|
<Input placeholder="例如:我的自建 OpenAI / 专属大模型"
|
||||||
|
size="middle"
|
||||||
|
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{presetKeyFromForm === 'custom' && (
|
||||||
|
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API 格式</span>} name="apiFormat" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', padding: 4, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.04)',
|
||||||
|
borderRadius: 8, gap: 4
|
||||||
|
}}>
|
||||||
|
{[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map(fmt => (
|
||||||
|
<div
|
||||||
|
key={fmt.value}
|
||||||
|
onClick={() => form.setFieldsValue({ apiFormat: fmt.value })}
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px', borderRadius: 6, fontSize: 13, fontWeight: watchedApiFormat === fmt.value ? 600 : 500, cursor: 'pointer',
|
||||||
|
background: watchedApiFormat === fmt.value ? (darkMode ? '#374151' : '#ffffff') : 'transparent',
|
||||||
|
color: watchedApiFormat === fmt.value ? overlayTheme.titleText : overlayTheme.mutedText,
|
||||||
|
boxShadow: watchedApiFormat === fmt.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmt.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>可用模型列表(可选配置)</span>} name="models" style={{ marginBottom: 0 }}>
|
||||||
|
<Select mode="tags" size="middle" placeholder="配置指定的模型ID,留空则默认去服务端拉取" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Form.Item name="model" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name="name" hidden><Input /></Form.Item>
|
||||||
|
|
||||||
|
{/* 认证信息 */}
|
||||||
|
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
|
||||||
|
<div style={fieldLabelStyle}>
|
||||||
|
<KeyOutlined style={{ fontSize: 14 }} /> 认证 & 连接
|
||||||
|
</div>
|
||||||
|
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || clearProviderSecret || editingProvider?.hasSecret) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: editingProvider?.hasSecret ? 8 : 16 }}>
|
||||||
|
<Input.Password placeholder={editingProvider?.hasSecret ? '留空表示继续沿用已保存密钥' : 'sk-... / 你的 API Key'}
|
||||||
|
size="middle"
|
||||||
|
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
|
||||||
|
</Form.Item>
|
||||||
|
{editingProvider?.hasSecret && (
|
||||||
|
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: cardBg }}>
|
||||||
|
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
|
||||||
|
当前已保存 API Key。留空表示继续沿用,输入新值表示替换。
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
checked={clearProviderSecret}
|
||||||
|
disabled={String(watchedApiKeyInput || '').trim() !== ''}
|
||||||
|
onChange={(event) => setClearProviderSecret(event.target.checked)}
|
||||||
|
>
|
||||||
|
清除已保存 API Key
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
|
||||||
|
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
|
||||||
|
<Input placeholder={findPreset(presetKeyFromForm).defaultBaseUrl || 'https://...'}
|
||||||
|
size="middle"
|
||||||
|
suffix={<LinkOutlined style={{ color: overlayTheme.mutedText }} />}
|
||||||
|
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12, paddingTop: 16,
|
||||||
|
borderTop: `1px solid ${cardBorder}`, paddingBottom: 24,
|
||||||
|
}}>
|
||||||
|
<Button onClick={handleTestProvider} loading={loading} style={{ borderRadius: 10 }}
|
||||||
|
icon={testStatus === 'success' ? <CheckOutlined style={{ color: '#22c55e' }} /> : undefined}>
|
||||||
|
{testStatus === 'success' ? '连接正常' : testStatus === 'error' ? '重新测试' : '测试连接'}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={handleSaveProvider} loading={loading}
|
||||||
|
style={{ borderRadius: 10, fontWeight: 600 }}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 安全控制 =====
|
||||||
|
const renderSafetySettings = () => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
|
||||||
|
控制 AI 可执行的 SQL 操作类型,保护数据安全
|
||||||
|
</div>
|
||||||
|
{SAFETY_OPTIONS.map(opt => {
|
||||||
|
const active = safetyLevel === opt.value;
|
||||||
|
return (
|
||||||
|
<div key={opt.value} onClick={() => handleSafetyChange(opt.value)} style={{
|
||||||
|
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
|
||||||
|
border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`,
|
||||||
|
background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : overlayTheme.selectedBg) : cardBg,
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 14,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
|
||||||
|
background: active ? (opt.color === '#ef4444' ? `${opt.color}25` : overlayTheme.iconBg) : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
|
||||||
|
color: active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor) : overlayTheme.mutedText,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}>
|
||||||
|
{opt.icon}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{opt.label}
|
||||||
|
{active && <CheckOutlined style={{ color: opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor, fontSize: 14 }} />}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 上下文级别 =====
|
||||||
|
const renderContextSettings = () => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
|
||||||
|
控制发送给 AI 的数据库上下文信息量
|
||||||
|
</div>
|
||||||
|
{CONTEXT_OPTIONS.map(opt => {
|
||||||
|
const active = contextLevel === opt.value;
|
||||||
|
return (
|
||||||
|
<div key={opt.value} onClick={() => handleContextChange(opt.value)} style={{
|
||||||
|
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
|
||||||
|
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
|
||||||
|
background: active ? overlayTheme.selectedBg : cardBg,
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 14,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
|
||||||
|
background: active ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
|
||||||
|
color: active ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}>
|
||||||
|
{opt.icon}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{opt.label}
|
||||||
|
{active && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 14 }} />}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBuiltinPrompts = () => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
|
||||||
|
以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会被动态注入到对应场景的请求上下文中。
|
||||||
|
</div>
|
||||||
|
{Object.entries(builtinPrompts).map(([title, promptText]) => (
|
||||||
|
<div key={title} style={{
|
||||||
|
padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: cardBg,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
|
||||||
|
padding: '10px 12px', borderRadius: 8, fontSize: 13, color: overlayTheme.mutedText,
|
||||||
|
whiteSpace: 'pre-wrap', fontFamily: 'monospace', lineHeight: 1.5,
|
||||||
|
userSelect: 'text', border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)'
|
||||||
|
}}>
|
||||||
|
{promptText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BUILTIN_TOOLS_INFO = [
|
||||||
|
{ name: 'get_connections', icon: '🔗', desc: '获取所有可用的数据库连接', detail: '返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。', params: '无参数' },
|
||||||
|
{ name: 'get_databases', icon: '🗄️', desc: '获取指定连接下的所有数据库', detail: '传入 connectionId,返回该连接下的数据库/Schema 名称列表。', params: 'connectionId: 连接 ID' },
|
||||||
|
{ name: 'get_tables', icon: '📋', desc: '获取指定数据库下的所有表名', detail: '传入 connectionId 和 dbName,返回表名列表。AI 用它来定位用户提到的目标表。', params: 'connectionId, dbName' },
|
||||||
|
{ name: 'get_columns', icon: '🔍', desc: '获取指定表的字段结构', detail: '传入 connectionId、dbName 和 tableName,返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。', params: 'connectionId, dbName, tableName' },
|
||||||
|
{ name: 'get_table_ddl', icon: '📝', desc: '获取表的建表语句 (DDL)', detail: '传入 connectionId、dbName 和 tableName,返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。', params: 'connectionId, dbName, tableName' },
|
||||||
|
{ name: 'execute_sql', icon: '▶️', desc: '执行 SQL 查询并返回结果', detail: '传入 connectionId、dbName 和 sql,在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。', params: 'connectionId, dbName, sql' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderBuiltinTools = () => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
|
||||||
|
AI 助手在处理数据库相关问题时,可以自动调用以下内置工具获取真实数据,全程无需人工干预。
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, padding: '8px 12px', borderRadius: 8, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||||
|
💡 工作流程:get_connections → get_databases → get_tables → get_columns → 生成 SQL
|
||||||
|
</div>
|
||||||
|
{BUILTIN_TOOLS_INFO.map(tool => (
|
||||||
|
<div key={tool.name} style={{
|
||||||
|
padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: 20 }}>{tool.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, fontFamily: 'monospace' }}>
|
||||||
|
{tool.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 2 }}>{tool.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, padding: '8px 12px',
|
||||||
|
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.02)', borderRadius: 8,
|
||||||
|
}}>
|
||||||
|
{tool.detail}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<ToolOutlined style={{ fontSize: 12 }} />
|
||||||
|
<span>参数:</span>
|
||||||
|
<code style={{ fontFamily: 'monospace', fontSize: 12, padding: '1px 6px', borderRadius: 4, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
|
||||||
|
{tool.params}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalShellStyle = {
|
||||||
|
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
|
||||||
|
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: 12, display: 'grid', placeItems: 'center',
|
||||||
|
background: overlayTheme.iconBg, color: overlayTheme.iconColor, fontSize: 18, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<RobotOutlined />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>AI 设置</div>
|
||||||
|
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||||
|
配置 AI 模型、安全级别和上下文选项
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onCancel={handleModalClose}
|
||||||
|
footer={null}
|
||||||
|
width={820}
|
||||||
|
styles={{
|
||||||
|
content: modalShellStyle,
|
||||||
|
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||||
|
body: { paddingTop: 8, height: 620, overflow: 'hidden' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={modalBodyRef} className="ai-settings-body" style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch', position: 'relative' }}>
|
||||||
|
{messageContextHolder}
|
||||||
|
<div style={{ padding: '0 12px', height: 'fit-content' }}>
|
||||||
|
<div style={{ marginBottom: 12, fontWeight: 600, color: overlayTheme.titleText }}>设置导航</div>
|
||||||
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
{[
|
||||||
|
{ key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: <ApiOutlined /> },
|
||||||
|
{ key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: <SafetyCertificateOutlined /> },
|
||||||
|
{ key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: <RobotOutlined /> },
|
||||||
|
{ key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: <ToolOutlined /> },
|
||||||
|
{ key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: <ExperimentOutlined /> },
|
||||||
|
].map((item) => {
|
||||||
|
const active = activeSection === item.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveSection(item.key as typeof activeSection)}
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${active
|
||||||
|
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
|
||||||
|
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
|
||||||
|
background: active
|
||||||
|
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
|
||||||
|
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
|
||||||
|
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span style={{ fontSize: 16 }}>{item.icon}</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : 'rgba(128,128,128,0.7)' }}>
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
|
||||||
|
{activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())}
|
||||||
|
{activeSection === 'safety' && renderSafetySettings()}
|
||||||
|
{activeSection === 'context' && renderContextSettings()}
|
||||||
|
{activeSection === 'tools' && renderBuiltinTools()}
|
||||||
|
{activeSection === 'prompts' && renderBuiltinPrompts()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AISettingsModal;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
102
frontend/src/components/ConnectionPackagePasswordModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Checkbox, Input, Modal, Typography } from 'antd';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
type ConnectionPackagePasswordModalMode = 'import' | 'export';
|
||||||
|
|
||||||
|
export interface ConnectionPackagePasswordModalProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
mode?: ConnectionPackagePasswordModalMode;
|
||||||
|
includeSecrets?: boolean;
|
||||||
|
useFilePassword?: boolean;
|
||||||
|
password: string;
|
||||||
|
error?: string;
|
||||||
|
confirmLoading?: boolean;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onIncludeSecretsChange?: (value: boolean) => void;
|
||||||
|
onUseFilePasswordChange?: (value: boolean) => void;
|
||||||
|
onPasswordChange: (value: string) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConnectionPackagePasswordModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
mode = 'import',
|
||||||
|
includeSecrets = true,
|
||||||
|
useFilePassword = false,
|
||||||
|
password,
|
||||||
|
error,
|
||||||
|
confirmLoading,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
onIncludeSecretsChange,
|
||||||
|
onUseFilePasswordChange,
|
||||||
|
onPasswordChange,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConnectionPackagePasswordModalProps) {
|
||||||
|
const isExportMode = mode === 'export';
|
||||||
|
const showFilePasswordInput = isExportMode ? useFilePassword : true;
|
||||||
|
const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码';
|
||||||
|
const helperText = !includeSecrets
|
||||||
|
? '将仅导出连接配置,不包含密码。'
|
||||||
|
: (useFilePassword
|
||||||
|
? '请通过单独渠道将密码告知接收方,不要和文件一起发送。'
|
||||||
|
: '密码已加密保护。如需通过公网传输,建议设置文件保护密码。');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={title}
|
||||||
|
okText={confirmText}
|
||||||
|
cancelText={cancelText}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
onOk={onConfirm}
|
||||||
|
onCancel={onCancel}
|
||||||
|
destroyOnClose={false}
|
||||||
|
maskClosable={false}
|
||||||
|
>
|
||||||
|
{isExportMode ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={includeSecrets}
|
||||||
|
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
|
||||||
|
>
|
||||||
|
导出连接密码
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
checked={useFilePassword}
|
||||||
|
disabled={!includeSecrets}
|
||||||
|
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
|
||||||
|
>
|
||||||
|
设置文件保护密码
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showFilePasswordInput ? (
|
||||||
|
<Input.Password
|
||||||
|
autoFocus
|
||||||
|
value={password}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={isExportMode && !useFilePassword}
|
||||||
|
onChange={(event) => onPasswordChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isExportMode ? (
|
||||||
|
<Text type={useFilePassword ? 'warning' : 'secondary'} style={{ display: 'block', marginTop: 8 }}>
|
||||||
|
{helperText}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<Text type="danger" style={{ display: 'block', marginTop: 8 }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
frontend/src/components/DataGrid.layout.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import DataGrid from './DataGrid';
|
||||||
|
|
||||||
|
vi.mock('../store', () => ({
|
||||||
|
useStore: (selector: (state: any) => any) => selector({
|
||||||
|
connections: [],
|
||||||
|
addSqlLog: vi.fn(),
|
||||||
|
theme: 'light',
|
||||||
|
appearance: {
|
||||||
|
enabled: true,
|
||||||
|
opacity: 1,
|
||||||
|
blur: 0,
|
||||||
|
showDataTableVerticalBorders: false,
|
||||||
|
dataTableColumnWidthMode: 'standard',
|
||||||
|
},
|
||||||
|
queryOptions: {
|
||||||
|
showColumnComment: false,
|
||||||
|
showColumnType: false,
|
||||||
|
},
|
||||||
|
setQueryOptions: vi.fn(),
|
||||||
|
tableColumnOrders: {},
|
||||||
|
enableColumnOrderMemory: false,
|
||||||
|
setTableColumnOrder: vi.fn(),
|
||||||
|
setEnableColumnOrderMemory: vi.fn(),
|
||||||
|
clearTableColumnOrder: vi.fn(),
|
||||||
|
tableHiddenColumns: {},
|
||||||
|
enableHiddenColumnMemory: false,
|
||||||
|
setTableHiddenColumns: vi.fn(),
|
||||||
|
setEnableHiddenColumnMemory: vi.fn(),
|
||||||
|
clearTableHiddenColumns: vi.fn(),
|
||||||
|
aiPanelVisible: false,
|
||||||
|
setAIPanelVisible: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||||
|
ImportData: vi.fn(),
|
||||||
|
ExportTable: vi.fn(),
|
||||||
|
ExportData: vi.fn(),
|
||||||
|
ExportQuery: vi.fn(),
|
||||||
|
ApplyChanges: vi.fn(),
|
||||||
|
DBGetColumns: vi.fn(),
|
||||||
|
DBGetIndexes: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@monaco-editor/react', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('DataGrid layout', () => {
|
||||||
|
it('renders a secondary action strip for view switching and auxiliary actions', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
__gonavi_row_key__: 'row-1',
|
||||||
|
id: 1,
|
||||||
|
name: 'alpha',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columnNames={['id', 'name']}
|
||||||
|
loading={false}
|
||||||
|
tableName="users"
|
||||||
|
readOnly
|
||||||
|
pagination={{
|
||||||
|
current: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
total: 1,
|
||||||
|
}}
|
||||||
|
onPageChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('data-grid-secondary-actions="true"');
|
||||||
|
expect(markup).toContain('data-grid-view-switcher="true"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders row copy and paste actions in editable table toolbar', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
__gonavi_row_key__: 'row-1',
|
||||||
|
id: 1,
|
||||||
|
name: 'alpha',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columnNames={['id', 'name']}
|
||||||
|
loading={false}
|
||||||
|
tableName="users"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('data-grid-copy-row-action="true"');
|
||||||
|
expect(markup).toContain('data-grid-paste-row-action="true"');
|
||||||
|
expect(markup).toContain('复制行');
|
||||||
|
expect(markup).toContain('粘贴行');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a quick WHERE condition editor when table filters are visible', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
__gonavi_row_key__: 'row-1',
|
||||||
|
id: 1,
|
||||||
|
name: 'alpha',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columnNames={['id', 'name']}
|
||||||
|
loading={false}
|
||||||
|
tableName="users"
|
||||||
|
showFilter
|
||||||
|
quickWhereCondition="name like 'a%'"
|
||||||
|
onApplyQuickWhereCondition={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('data-grid-quick-where="true"');
|
||||||
|
expect(markup).toContain('WHERE');
|
||||||
|
expect(markup).toContain('输入 WHERE 后面的条件');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,195 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import { TabData, ColumnDefinition } from '../types';
|
import { TabData, ColumnDefinition } from '../types';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||||
import { buildOrderBySQL, buildWhereSQL, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||||
|
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
|
||||||
|
import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveApproximateTableCountStrategy } from '../utils/approximateTableCount';
|
||||||
|
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||||
|
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
|
||||||
|
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||||
|
import {
|
||||||
|
buildEffectiveFilterConditions,
|
||||||
|
normalizeQuickWhereCondition,
|
||||||
|
validateQuickWhereCondition,
|
||||||
|
} from '../utils/dataGridWhereFilter';
|
||||||
|
|
||||||
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
type ViewerPaginationState = {
|
||||||
|
current: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalKnown: boolean;
|
||||||
|
totalApprox: boolean;
|
||||||
|
approximateTotal?: number;
|
||||||
|
totalCountLoading: boolean;
|
||||||
|
totalCountCancelled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
const isIntegerText = (text: string): boolean => /^[+-]?\d+$/.test(text);
|
||||||
|
|
||||||
|
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value >= 0n && value <= JS_MAX_SAFE_INTEGER_BIGINT ? Number(value) : null;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) return null;
|
||||||
|
if (isIntegerText(text)) {
|
||||||
|
try {
|
||||||
|
const parsedBigInt = BigInt(text);
|
||||||
|
if (parsedBigInt < 0n || parsedBigInt > JS_MAX_SAFE_INTEGER_BIGINT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Number(parsedBigInt);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parsed = Number(text);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseTotalFromCountRow = (row: any): number | null => {
|
||||||
|
if (!row || typeof row !== 'object') return null;
|
||||||
|
const entries = Object.entries(row as Record<string, unknown>);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
for (const [key, raw] of entries) {
|
||||||
|
const normalized = String(key || '').trim().toLowerCase();
|
||||||
|
if (normalized === 'total' || normalized === 'count' || normalized.includes('count')) {
|
||||||
|
const parsed = toNonNegativeFiniteNumber(raw);
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, raw] of entries) {
|
||||||
|
const parsed = toNonNegativeFiniteNumber(raw);
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDuckDBIdentifier = (raw: string): string => {
|
||||||
|
const text = String(raw || '').trim();
|
||||||
|
if (text.length >= 2) {
|
||||||
|
const first = text[0];
|
||||||
|
const last = text[text.length - 1];
|
||||||
|
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
|
||||||
|
return text.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDuckDBSchemaAndTable = (dbName: string, tableName: string) => {
|
||||||
|
const rawTable = String(tableName || '').trim();
|
||||||
|
if (!rawTable) return { schemaName: 'main', pureTableName: '' };
|
||||||
|
|
||||||
|
const parts = rawTable.split('.');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const pureTableName = normalizeDuckDBIdentifier(parts[parts.length - 1]);
|
||||||
|
const schemaName = normalizeDuckDBIdentifier(parts[parts.length - 2]);
|
||||||
|
if (schemaName && pureTableName) {
|
||||||
|
return { schemaName, pureTableName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackSchema = normalizeDuckDBIdentifier(String(dbName || '').trim()) || 'main';
|
||||||
|
return { schemaName: fallbackSchema, pureTableName: normalizeDuckDBIdentifier(rawTable) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
|
||||||
|
|
||||||
|
const isDuckDBUnsupportedTypeError = (msg: string): boolean => /unsupported\s*type:\s*duckdb\./i.test(String(msg || ''));
|
||||||
|
|
||||||
|
const isDuckDBComplexColumnType = (columnType?: string): boolean => {
|
||||||
|
const raw = String(columnType || '').trim().toLowerCase();
|
||||||
|
if (!raw) return false;
|
||||||
|
return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list');
|
||||||
|
};
|
||||||
|
|
||||||
|
const reverseOrderBySQL = (orderBySQL: string): string => {
|
||||||
|
const raw = String(orderBySQL || '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
const body = raw.replace(/^order\s+by\s+/i, '').trim();
|
||||||
|
if (!body) return '';
|
||||||
|
|
||||||
|
const parts = body
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => {
|
||||||
|
if (/\s+asc$/i.test(part)) return part.replace(/\s+asc$/i, ' DESC');
|
||||||
|
if (/\s+desc$/i.test(part)) return part.replace(/\s+desc$/i, ' ASC');
|
||||||
|
return `${part} DESC`;
|
||||||
|
});
|
||||||
|
if (parts.length === 0) return '';
|
||||||
|
return ` ORDER BY ${parts.join(', ')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewerFilterSnapshot = {
|
||||||
|
showFilter: boolean;
|
||||||
|
conditions: FilterCondition[];
|
||||||
|
quickWhereCondition: string;
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>;
|
||||||
|
scrollTop: number;
|
||||||
|
scrollLeft: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewerScrollSnapshot = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewerFilterSnapshotsByTab = new Map<string, ViewerFilterSnapshot>();
|
||||||
|
|
||||||
|
const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefined): FilterCondition[] => {
|
||||||
|
if (!Array.isArray(conditions)) return [];
|
||||||
|
return conditions.map((cond) => ({
|
||||||
|
id: Number.isFinite(Number(cond?.id)) ? Number(cond?.id) : undefined,
|
||||||
|
enabled: cond?.enabled !== false,
|
||||||
|
logic: String(cond?.logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND',
|
||||||
|
column: String(cond?.column || ''),
|
||||||
|
op: String(cond?.op || '='),
|
||||||
|
value: String(cond?.value ?? ''),
|
||||||
|
value2: String(cond?.value2 ?? ''),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
|
||||||
|
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
|
||||||
|
if (!cached) {
|
||||||
|
return { showFilter: false, conditions: [], quickWhereCondition: '', currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
showFilter: cached.showFilter === true,
|
||||||
|
conditions: normalizeViewerFilterConditions(cached.conditions),
|
||||||
|
quickWhereCondition: normalizeQuickWhereCondition(cached.quickWhereCondition),
|
||||||
|
currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1,
|
||||||
|
pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100,
|
||||||
|
sortInfo: Array.isArray(cached.sortInfo)
|
||||||
|
? cached.sortInfo.filter(s => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend'))
|
||||||
|
.map(s => ({ columnKey: String(s.columnKey), order: s.order }))
|
||||||
|
: (cached.sortInfo && (cached.sortInfo as any).columnKey ? [{ columnKey: String((cached.sortInfo as any).columnKey), order: (cached.sortInfo as any).order }] : []),
|
||||||
|
scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0,
|
||||||
|
scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
|
||||||
|
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||||
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||||
@@ -16,29 +199,193 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const fetchSeqRef = useRef(0);
|
const fetchSeqRef = useRef(0);
|
||||||
const countSeqRef = useRef(0);
|
const countSeqRef = useRef(0);
|
||||||
const countKeyRef = useRef<string>('');
|
const countKeyRef = useRef<string>('');
|
||||||
|
const duckdbApproxSeqRef = useRef(0);
|
||||||
|
const duckdbApproxKeyRef = useRef<string>('');
|
||||||
|
const oracleApproxSeqRef = useRef(0);
|
||||||
|
const oracleApproxKeyRef = useRef<string>('');
|
||||||
|
const manualCountSeqRef = useRef(0);
|
||||||
|
const manualCountKeyRef = useRef<string>('');
|
||||||
const pkSeqRef = useRef(0);
|
const pkSeqRef = useRef(0);
|
||||||
const pkKeyRef = useRef<string>('');
|
const pkKeyRef = useRef<string>('');
|
||||||
|
const latestConfigRef = useRef<any>(null);
|
||||||
|
const latestDbTypeRef = useRef<string>('');
|
||||||
|
const latestDbNameRef = useRef<string>('');
|
||||||
|
const latestCountSqlRef = useRef<string>('');
|
||||||
|
const latestCountKeyRef = useRef<string>('');
|
||||||
|
const scrollSnapshotRef = useRef<ViewerScrollSnapshot>({
|
||||||
|
top: initialViewerSnapshot.scrollTop,
|
||||||
|
left: initialViewerSnapshot.scrollLeft,
|
||||||
|
});
|
||||||
|
const initialLoadRef = useRef(false);
|
||||||
|
const skipNextAutoFetchRef = useRef(false);
|
||||||
|
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState<ViewerPaginationState>({
|
||||||
current: 1,
|
current: initialViewerSnapshot.currentPage,
|
||||||
pageSize: 100,
|
pageSize: initialViewerSnapshot.pageSize,
|
||||||
total: 0,
|
total: 0,
|
||||||
totalKnown: false
|
totalKnown: false,
|
||||||
|
totalApprox: false,
|
||||||
|
totalCountLoading: false,
|
||||||
|
totalCountCancelled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
const [sortInfo, setSortInfo] = useState<Array<{ columnKey: string, order: string, enabled?: boolean }>>(initialViewerSnapshot.sortInfo);
|
||||||
|
|
||||||
const [showFilter, setShowFilter] = useState(false);
|
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
|
||||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
|
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
|
||||||
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
|
const [quickWhereCondition, setQuickWhereCondition] = useState<string>(initialViewerSnapshot.quickWhereCondition);
|
||||||
const forceReadOnly = currentConnType === 'tdengine';
|
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
||||||
|
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
|
||||||
|
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
|
||||||
|
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
|
||||||
|
const preferManualTotalCount = currentConnCaps.preferManualTotalCount;
|
||||||
|
const supportsApproximateTableCount = currentConnCaps.supportsApproximateTableCount;
|
||||||
|
const supportsApproximateTotalPages = currentConnCaps.supportsApproximateTotalPages;
|
||||||
|
const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
|
||||||
|
const normalizedTabId = String(tabId || '').trim();
|
||||||
|
if (!normalizedTabId) return;
|
||||||
|
viewerFilterSnapshotsByTab.set(normalizedTabId, {
|
||||||
|
showFilter,
|
||||||
|
conditions: normalizeViewerFilterConditions(filterConditions),
|
||||||
|
quickWhereCondition: normalizeQuickWhereCondition(quickWhereCondition),
|
||||||
|
currentPage: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
sortInfo,
|
||||||
|
scrollTop: scrollSnapshotRef.current.top,
|
||||||
|
scrollLeft: scrollSnapshotRef.current.left,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}, [showFilter, filterConditions, quickWhereCondition, pagination.current, pagination.pageSize, sortInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||||
|
setShowFilter(snapshot.showFilter);
|
||||||
|
setFilterConditions(snapshot.conditions);
|
||||||
|
setQuickWhereCondition(snapshot.quickWhereCondition);
|
||||||
|
setSortInfo(snapshot.sortInfo);
|
||||||
|
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||||
|
initialLoadRef.current = false;
|
||||||
|
}, [tab.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
persistViewerSnapshot(tab.id);
|
||||||
|
}, [persistViewerSnapshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
persistViewerSnapshot(tab.id);
|
||||||
|
};
|
||||||
|
}, [tab.id, persistViewerSnapshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||||
setPkColumns([]);
|
setPkColumns([]);
|
||||||
pkKeyRef.current = '';
|
pkKeyRef.current = '';
|
||||||
countKeyRef.current = '';
|
countKeyRef.current = '';
|
||||||
setPagination(prev => ({ ...prev, current: 1, total: 0, totalKnown: false }));
|
duckdbApproxKeyRef.current = '';
|
||||||
}, [tab.connectionId, tab.dbName, tab.tableName]);
|
oracleApproxKeyRef.current = '';
|
||||||
|
manualCountKeyRef.current = '';
|
||||||
|
duckdbSafeSelectCacheRef.current = {};
|
||||||
|
latestConfigRef.current = null;
|
||||||
|
latestDbTypeRef.current = '';
|
||||||
|
latestDbNameRef.current = '';
|
||||||
|
latestCountSqlRef.current = '';
|
||||||
|
latestCountKeyRef.current = '';
|
||||||
|
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||||
|
initialLoadRef.current = false;
|
||||||
|
skipNextAutoFetchRef.current = true;
|
||||||
|
setPagination(prev => ({
|
||||||
|
...prev,
|
||||||
|
current: snapshot.currentPage,
|
||||||
|
pageSize: snapshot.pageSize,
|
||||||
|
total: 0,
|
||||||
|
totalKnown: false,
|
||||||
|
totalApprox: false,
|
||||||
|
approximateTotal: undefined,
|
||||||
|
totalCountLoading: false,
|
||||||
|
totalCountCancelled: false,
|
||||||
|
}));
|
||||||
|
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName]);
|
||||||
|
|
||||||
|
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
|
||||||
|
scrollSnapshotRef.current = snapshot;
|
||||||
|
persistViewerSnapshot(tab.id, {
|
||||||
|
scrollTop: snapshot.top,
|
||||||
|
scrollLeft: snapshot.left,
|
||||||
|
});
|
||||||
|
}, [tab.id, persistViewerSnapshot]);
|
||||||
|
|
||||||
|
const handleManualTotalCount = useCallback(async () => {
|
||||||
|
const config = latestConfigRef.current;
|
||||||
|
const dbName = latestDbNameRef.current;
|
||||||
|
const countSql = latestCountSqlRef.current;
|
||||||
|
const countKey = latestCountKeyRef.current;
|
||||||
|
|
||||||
|
if (!config || !countSql || !countKey) {
|
||||||
|
message.warning('当前结果集尚未就绪,请先执行一次加载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
manualCountKeyRef.current = countKey;
|
||||||
|
const countSeq = ++manualCountSeqRef.current;
|
||||||
|
const countStart = Date.now();
|
||||||
|
setPagination(prev => ({ ...prev, totalCountLoading: true, totalCountCancelled: false }));
|
||||||
|
const countConfig = buildRpcConnectionConfig(config, { timeout: 120 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resCount = await DBQuery(countConfig as any, dbName, countSql);
|
||||||
|
const countDuration = Date.now() - countStart;
|
||||||
|
addSqlLog({
|
||||||
|
id: `log-${Date.now()}-manual-count`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
sql: countSql,
|
||||||
|
status: resCount?.success ? 'success' : 'error',
|
||||||
|
duration: countDuration,
|
||||||
|
message: resCount?.success ? '' : String(resCount?.message || '统计失败'),
|
||||||
|
dbName
|
||||||
|
});
|
||||||
|
|
||||||
|
if (manualCountSeqRef.current !== countSeq) return;
|
||||||
|
if (manualCountKeyRef.current !== countKey) return;
|
||||||
|
|
||||||
|
if (!resCount?.success) {
|
||||||
|
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||||
|
message.error(String(resCount?.message || '统计总数失败'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(resCount.data) || resCount.data.length === 0) {
|
||||||
|
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = parseTotalFromCountRow(resCount.data[0]);
|
||||||
|
if (total === null) {
|
||||||
|
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||||
|
message.error('统计结果解析失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPagination(prev => ({
|
||||||
|
...prev,
|
||||||
|
total,
|
||||||
|
totalKnown: true,
|
||||||
|
totalApprox: false,
|
||||||
|
approximateTotal: undefined,
|
||||||
|
totalCountLoading: false,
|
||||||
|
totalCountCancelled: false,
|
||||||
|
}));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (manualCountSeqRef.current !== countSeq) return;
|
||||||
|
if (manualCountKeyRef.current !== countKey) return;
|
||||||
|
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||||
|
message.error(`统计总数失败: ${String(e?.message || e)}`);
|
||||||
|
}
|
||||||
|
}, [addSqlLog]);
|
||||||
|
|
||||||
|
const handleCancelManualTotalCount = useCallback(() => {
|
||||||
|
manualCountSeqRef.current++;
|
||||||
|
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
|
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
|
||||||
const seq = ++fetchSeqRef.current;
|
const seq = ++fetchSeqRef.current;
|
||||||
@@ -59,46 +406,163 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||||
};
|
};
|
||||||
|
|
||||||
const dbType = config.type || '';
|
const dbType = resolveDataSourceType(config);
|
||||||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||||||
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
|
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
|
||||||
|
const normalizedQuickWhereCondition = normalizeQuickWhereCondition(quickWhereCondition);
|
||||||
|
const quickWhereValidation = validateQuickWhereCondition(normalizedQuickWhereCondition);
|
||||||
|
if (!quickWhereValidation.ok) {
|
||||||
|
message.error(quickWhereValidation.message);
|
||||||
|
if (fetchSeqRef.current === seq) setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, normalizedQuickWhereCondition);
|
||||||
|
|
||||||
const dbName = tab.dbName || '';
|
const dbName = tab.dbName || '';
|
||||||
const tableName = tab.tableName || '';
|
const tableName = tab.tableName || '';
|
||||||
|
const isMongoDB = dbTypeLower === 'mongodb';
|
||||||
|
let mongoFilter: Record<string, unknown> | undefined;
|
||||||
|
if (isMongoDB) {
|
||||||
|
try {
|
||||||
|
mongoFilter = buildMongoFilter(effectiveFilterConditions);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`);
|
||||||
|
if (fetchSeqRef.current === seq) setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const whereSQL = buildWhereSQL(dbType, filterConditions);
|
const whereSQL = isMongoDB
|
||||||
|
? JSON.stringify(mongoFilter || {})
|
||||||
const countSql = `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
: buildWhereSQL(dbType, effectiveFilterConditions);
|
||||||
|
const countSql = isMongoDB
|
||||||
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
? buildMongoCountCommand(tableName, mongoFilter || {})
|
||||||
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||||
const offset = (page - 1) * size;
|
const orderBySQL = isMongoDB ? '' : buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||||
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
|
const totalRows = Number(pagination.total);
|
||||||
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
|
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
|
||||||
|
const totalKnown = pagination.totalKnown && hasFiniteTotal;
|
||||||
|
const approximateTotalRows = Number(pagination.approximateTotal);
|
||||||
|
const hasApproximateTotalPages =
|
||||||
|
!totalKnown &&
|
||||||
|
supportsApproximateTotalPages &&
|
||||||
|
pagination.totalApprox &&
|
||||||
|
Number.isFinite(approximateTotalRows) &&
|
||||||
|
approximateTotalRows > 0;
|
||||||
|
const effectiveTotalRows = hasApproximateTotalPages ? approximateTotalRows : totalRows;
|
||||||
|
const totalPages = Number.isFinite(effectiveTotalRows) && effectiveTotalRows > 0 ? Math.max(1, Math.ceil(effectiveTotalRows / size)) : 0;
|
||||||
|
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
|
||||||
|
const offset = (currentPage - 1) * size;
|
||||||
|
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
|
||||||
|
const reverseOrderSQL = isClickHouse ? reverseOrderBySQL(orderBySQL) : '';
|
||||||
|
let useClickHouseReversePagination = false;
|
||||||
|
let clickHouseReverseLimit = 0;
|
||||||
|
let clickHouseReverseHasMore = false;
|
||||||
|
let sql = '';
|
||||||
|
if (isMongoDB) {
|
||||||
|
const mongoSort = buildMongoSort(sortInfo, pkColumns);
|
||||||
|
sql = buildMongoFindCommand({
|
||||||
|
collection: tableName,
|
||||||
|
filter: mongoFilter || {},
|
||||||
|
sort: mongoSort,
|
||||||
|
limit: size + 1,
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||||
|
sql = `${baseSql}${orderBySQL}`;
|
||||||
|
// ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景,
|
||||||
|
// 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET,并在前端翻转结果。
|
||||||
|
if (isClickHouse && totalKnown && offset > 0 && reverseOrderSQL) {
|
||||||
|
const pageRowCount = Math.max(0, Math.min(size, totalRows - offset));
|
||||||
|
if (pageRowCount > 0) {
|
||||||
|
const tailOffset = Math.max(0, totalRows - (offset + pageRowCount));
|
||||||
|
if (tailOffset < offset) {
|
||||||
|
sql = buildPaginatedSelectSQL(dbType, baseSql, reverseOrderSQL, pageRowCount, tailOffset);
|
||||||
|
useClickHouseReversePagination = true;
|
||||||
|
clickHouseReverseLimit = pageRowCount;
|
||||||
|
clickHouseReverseHasMore = currentPage < totalPages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!useClickHouseReversePagination) {
|
||||||
|
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
|
||||||
|
sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, size + 1, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const requestStartTime = Date.now();
|
const requestStartTime = Date.now();
|
||||||
let executedSql = sql;
|
let executedSql = sql;
|
||||||
try {
|
try {
|
||||||
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
|
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const result = await DBQuery(config as any, dbName, querySql);
|
try {
|
||||||
addSqlLog({
|
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, querySql);
|
||||||
id: `log-${Date.now()}-data`,
|
addSqlLog({
|
||||||
timestamp: Date.now(),
|
id: `log-${Date.now()}-data`,
|
||||||
sql: querySql,
|
timestamp: Date.now(),
|
||||||
status: result.success ? 'success' : 'error',
|
sql: querySql,
|
||||||
duration: Date.now() - startTime,
|
status: result.success ? 'success' : 'error',
|
||||||
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
|
duration: Date.now() - startTime,
|
||||||
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
|
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
|
||||||
dbName
|
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
|
||||||
});
|
dbName
|
||||||
return result;
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e: any) {
|
||||||
|
const errMessage = String(e?.message || e || 'query failed');
|
||||||
|
addSqlLog({
|
||||||
|
id: `log-${Date.now()}-data`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
sql: querySql,
|
||||||
|
status: 'error',
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
message: `${attemptLabel}: ${errMessage}`,
|
||||||
|
dbName
|
||||||
|
});
|
||||||
|
return { success: false, message: errMessage, data: [], fields: [] };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
|
const hasSort = hasExplicitSort(sortInfo);
|
||||||
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
|
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
|
||||||
let resData = await executeDataQuery(sql, '主查询');
|
let resData = await executeDataQuery(sql, '主查询');
|
||||||
|
|
||||||
|
if (!resData.success && dbTypeLower === 'duckdb' && isDuckDBUnsupportedTypeError(String(resData.message || ''))) {
|
||||||
|
const cacheKey = `${tab.connectionId}|${dbName}|${tableName}`;
|
||||||
|
let safeSelect = duckdbSafeSelectCacheRef.current[cacheKey] || '';
|
||||||
|
if (!safeSelect) {
|
||||||
|
try {
|
||||||
|
const resCols = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName);
|
||||||
|
if (resCols?.success && Array.isArray(resCols.data)) {
|
||||||
|
const columnDefs = resCols.data as ColumnDefinition[];
|
||||||
|
const selectParts = columnDefs.map((col) => {
|
||||||
|
const colName = String(col?.name || '').trim();
|
||||||
|
if (!colName) return '';
|
||||||
|
const quotedCol = quoteIdentPart(dbType, colName);
|
||||||
|
if (isDuckDBComplexColumnType(col?.type)) {
|
||||||
|
return `CAST(${quotedCol} AS VARCHAR) AS ${quotedCol}`;
|
||||||
|
}
|
||||||
|
return quotedCol;
|
||||||
|
}).filter(Boolean);
|
||||||
|
if (selectParts.length > 0) {
|
||||||
|
safeSelect = selectParts.join(', ');
|
||||||
|
duckdbSafeSelectCacheRef.current[cacheKey] = safeSelect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and keep original error path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (safeSelect) {
|
||||||
|
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||||
|
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, pkColumns), size + 1, offset);
|
||||||
|
executedSql = fallbackSql;
|
||||||
|
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!resData.success && isMySQLFamily && hasSort && isSortMemoryErr(resData.message)) {
|
if (!resData.success && isMySQLFamily && hasSort && isSortMemoryErr(resData.message)) {
|
||||||
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
|
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
|
||||||
if (retrySql32MB !== sql) {
|
if (retrySql32MB !== sql) {
|
||||||
@@ -122,7 +586,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
if (pkKeyRef.current !== pkKey) {
|
if (pkKeyRef.current !== pkKey) {
|
||||||
pkKeyRef.current = pkKey;
|
pkKeyRef.current = pkKey;
|
||||||
const pkSeq = ++pkSeqRef.current;
|
const pkSeq = ++pkSeqRef.current;
|
||||||
DBGetColumns(config as any, dbName, tableName)
|
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName)
|
||||||
.then((resCols: any) => {
|
.then((resCols: any) => {
|
||||||
if (pkSeqRef.current !== pkSeq) return;
|
if (pkSeqRef.current !== pkSeq) return;
|
||||||
if (pkKeyRef.current !== pkKey) return;
|
if (pkKeyRef.current !== pkKey) return;
|
||||||
@@ -141,7 +605,12 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
let resultData = resData.data as any[];
|
let resultData = resData.data as any[];
|
||||||
if (!Array.isArray(resultData)) resultData = [];
|
if (!Array.isArray(resultData)) resultData = [];
|
||||||
|
|
||||||
const hasMore = resultData.length > size;
|
if (useClickHouseReversePagination) {
|
||||||
|
// 反向查询后恢复为原排序方向,保证用户看到的仍是“最后一页正序数据”。
|
||||||
|
resultData = resultData.slice(0, clickHouseReverseLimit).reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = useClickHouseReversePagination ? clickHouseReverseHasMore : resultData.length > size;
|
||||||
if (hasMore) resultData = resultData.slice(0, size);
|
if (hasMore) resultData = resultData.slice(0, size);
|
||||||
|
|
||||||
let fieldNames = resData.fields || [];
|
let fieldNames = resData.fields || [];
|
||||||
@@ -156,27 +625,81 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
setData(resultData);
|
setData(resultData);
|
||||||
const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`;
|
const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`;
|
||||||
const derivedTotalKnown = !hasMore;
|
const derivedTotalKnown = !hasMore;
|
||||||
const derivedTotal = derivedTotalKnown ? offset + resultData.length : page * size + 1;
|
const derivedTotal = derivedTotalKnown ? offset + resultData.length : currentPage * size + 1;
|
||||||
|
const isDuckDB = dbTypeLower === 'duckdb';
|
||||||
|
const minExpectedTotal = hasMore ? offset + resultData.length + 1 : offset + resultData.length;
|
||||||
if (derivedTotalKnown) countKeyRef.current = countKey;
|
if (derivedTotalKnown) countKeyRef.current = countKey;
|
||||||
|
latestConfigRef.current = config;
|
||||||
|
latestDbTypeRef.current = dbTypeLower;
|
||||||
|
latestDbNameRef.current = dbName;
|
||||||
|
latestCountSqlRef.current = countSql;
|
||||||
|
latestCountKeyRef.current = countKey;
|
||||||
|
|
||||||
setPagination(prev => {
|
setPagination(prev => {
|
||||||
if (derivedTotalKnown) {
|
if (derivedTotalKnown) {
|
||||||
return { ...prev, current: page, pageSize: size, total: derivedTotal, totalKnown: true };
|
return {
|
||||||
|
...prev,
|
||||||
|
current: currentPage,
|
||||||
|
pageSize: size,
|
||||||
|
total: derivedTotal,
|
||||||
|
totalKnown: true,
|
||||||
|
totalApprox: false,
|
||||||
|
approximateTotal: undefined,
|
||||||
|
totalCountLoading: false,
|
||||||
|
totalCountCancelled: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (prev.totalKnown && countKeyRef.current === countKey) {
|
if (prev.totalKnown && countKeyRef.current === countKey) {
|
||||||
return { ...prev, current: page, pageSize: size };
|
if (!isDuckDB) {
|
||||||
|
return { ...prev, current: currentPage, pageSize: size };
|
||||||
|
}
|
||||||
|
// 当当前页存在“下一页”信号时,已知总数至少应大于当前页末尾。
|
||||||
|
// 若旧总数不满足该条件(例如历史统计值为 0),降级为未知总数并回退到 derivedTotal。
|
||||||
|
if (Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
|
||||||
|
return { ...prev, current: currentPage, pageSize: size };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ...prev, current: page, pageSize: size, total: derivedTotal, totalKnown: false };
|
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
|
||||||
|
const hasApproximateTotalForCurrentKey =
|
||||||
|
prev.totalApprox &&
|
||||||
|
(duckdbApproxKeyRef.current === countKey || oracleApproxKeyRef.current === countKey) &&
|
||||||
|
Number.isFinite(prev.approximateTotal) &&
|
||||||
|
Number(prev.approximateTotal) >= minExpectedTotal;
|
||||||
|
if (hasApproximateTotalForCurrentKey) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
current: currentPage,
|
||||||
|
pageSize: size,
|
||||||
|
total: derivedTotal,
|
||||||
|
totalKnown: false,
|
||||||
|
totalApprox: true,
|
||||||
|
approximateTotal: prev.approximateTotal,
|
||||||
|
totalCountLoading: keepManualCounting,
|
||||||
|
totalCountCancelled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
current: currentPage,
|
||||||
|
pageSize: size,
|
||||||
|
total: derivedTotal,
|
||||||
|
totalKnown: false,
|
||||||
|
totalApprox: false,
|
||||||
|
approximateTotal: undefined,
|
||||||
|
totalCountLoading: keepManualCounting,
|
||||||
|
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!derivedTotalKnown) {
|
const shouldRunAsyncCount = !derivedTotalKnown && !preferManualTotalCount;
|
||||||
|
if (shouldRunAsyncCount) {
|
||||||
if (countKeyRef.current !== countKey) {
|
if (countKeyRef.current !== countKey) {
|
||||||
countKeyRef.current = countKey;
|
countKeyRef.current = countKey;
|
||||||
const countSeq = ++countSeqRef.current;
|
const countSeq = ++countSeqRef.current;
|
||||||
const countStart = Date.now();
|
const countStart = Date.now();
|
||||||
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
|
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
|
||||||
// 这里为统计请求设置更短的超时,避免“后台统计”长期占用资源。
|
// DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT。
|
||||||
const countConfig: any = { ...(config as any), timeout: 5 };
|
const countConfig = buildRpcConnectionConfig(config, { timeout: 5 });
|
||||||
|
|
||||||
DBQuery(countConfig, dbName, countSql)
|
DBQuery(countConfig, dbName, countSql)
|
||||||
.then((resCount: any) => {
|
.then((resCount: any) => {
|
||||||
@@ -193,15 +716,23 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (countSeqRef.current !== countSeq) return;
|
if (countSeqRef.current !== countSeq) return;
|
||||||
if (countKeyRef.current !== countKey) return;
|
if (latestCountKeyRef.current !== countKey) return;
|
||||||
|
|
||||||
if (!resCount.success) return;
|
if (!resCount.success) return;
|
||||||
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
|
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
|
||||||
|
|
||||||
const total = Number(resCount.data[0]?.['total']);
|
const total = parseTotalFromCountRow(resCount.data[0]);
|
||||||
if (!Number.isFinite(total) || total < 0) return;
|
if (total === null) return;
|
||||||
|
|
||||||
setPagination(prev => ({ ...prev, total, totalKnown: true }));
|
setPagination(prev => ({
|
||||||
|
...prev,
|
||||||
|
total,
|
||||||
|
totalKnown: true,
|
||||||
|
totalApprox: false,
|
||||||
|
approximateTotal: undefined,
|
||||||
|
totalCountLoading: false,
|
||||||
|
totalCountCancelled: false,
|
||||||
|
}));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (countSeqRef.current !== countSeq) return;
|
if (countSeqRef.current !== countSeq) return;
|
||||||
@@ -210,6 +741,90 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!derivedTotalKnown) {
|
||||||
|
const approximateCountStrategy = supportsApproximateTableCount
|
||||||
|
? resolveApproximateTableCountStrategy({ dbType: dbTypeLower, whereSQL })
|
||||||
|
: 'none';
|
||||||
|
|
||||||
|
if (approximateCountStrategy === 'duckdb-estimated-size' && duckdbApproxKeyRef.current !== countKey) {
|
||||||
|
duckdbApproxKeyRef.current = countKey;
|
||||||
|
const approxSeq = ++duckdbApproxSeqRef.current;
|
||||||
|
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
|
||||||
|
const escapedSchema = escapeSQLLiteral(schemaName);
|
||||||
|
const escapedTable = escapeSQLLiteral(pureTableName);
|
||||||
|
const approxConfig = buildRpcConnectionConfig(config, { timeout: 3 });
|
||||||
|
const approxSqlCandidates = [
|
||||||
|
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
|
||||||
|
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const approxSql of approxSqlCandidates) {
|
||||||
|
try {
|
||||||
|
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
|
||||||
|
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||||
|
if (latestCountKeyRef.current !== countKey) return;
|
||||||
|
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
|
||||||
|
|
||||||
|
const approxTotal = parseApproximateTableCountRow(approxRes.data[0]);
|
||||||
|
if (approxTotal === null) continue;
|
||||||
|
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
|
||||||
|
|
||||||
|
setPagination(prev => {
|
||||||
|
if (latestCountKeyRef.current !== countKey) return prev;
|
||||||
|
if (prev.totalKnown) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
totalKnown: false,
|
||||||
|
totalApprox: true,
|
||||||
|
approximateTotal: approxTotal,
|
||||||
|
totalCountCancelled: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||||
|
if (latestCountKeyRef.current !== countKey) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approximateCountStrategy === 'oracle-num-rows' && oracleApproxKeyRef.current !== countKey) {
|
||||||
|
oracleApproxKeyRef.current = countKey;
|
||||||
|
const approxSeq = ++oracleApproxSeqRef.current;
|
||||||
|
const approxConfig = buildRpcConnectionConfig(config, { timeout: 3 });
|
||||||
|
const approxSql = buildOracleApproximateTotalSql({ dbName, tableName });
|
||||||
|
|
||||||
|
DBQuery(approxConfig as any, dbName, approxSql)
|
||||||
|
.then((approxRes: any) => {
|
||||||
|
if (oracleApproxSeqRef.current !== approxSeq) return;
|
||||||
|
if (latestCountKeyRef.current !== countKey) return;
|
||||||
|
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) return;
|
||||||
|
|
||||||
|
const approxTotal = parseApproximateTableCountRow(approxRes.data[0], ['approx_total', 'num_rows', 'estimated_rows', 'row_count', 'count', 'total']);
|
||||||
|
if (approxTotal === null) return;
|
||||||
|
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) return;
|
||||||
|
|
||||||
|
setPagination(prev => {
|
||||||
|
if (latestCountKeyRef.current !== countKey) return prev;
|
||||||
|
if (prev.totalKnown) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
totalKnown: false,
|
||||||
|
totalApprox: true,
|
||||||
|
approximateTotal: approxTotal,
|
||||||
|
totalCountCancelled: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (oracleApproxSeqRef.current !== approxSeq) return;
|
||||||
|
if (latestCountKeyRef.current !== countKey) return;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
message.error(String(resData.message || '查询失败'));
|
message.error(String(resData.message || '查询失败'));
|
||||||
}
|
}
|
||||||
@@ -227,7 +842,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (fetchSeqRef.current === seq) setLoading(false);
|
if (fetchSeqRef.current === seq) setLoading(false);
|
||||||
}, [connections, tab, sortInfo, filterConditions, pkColumns]);
|
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
|
||||||
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
|
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
|
||||||
// 主键信息只会在首次加载后更新一次,避免循环查询。
|
// 主键信息只会在首次加载后更新一次,避免循环查询。
|
||||||
|
|
||||||
@@ -236,21 +851,70 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
fetchData(pagination.current, pagination.pageSize);
|
fetchData(pagination.current, pagination.pageSize);
|
||||||
}, [fetchData, pagination.current, pagination.pageSize]);
|
}, [fetchData, pagination.current, pagination.pageSize]);
|
||||||
const handleSort = useCallback((field: string, order: string) => {
|
const handleSort = useCallback((field: string, order: string) => {
|
||||||
|
// 支持多字段排序:field 为 JSON 数组字符串时解析为多字段
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(field);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
setSortInfo(parsed.filter((s: any) => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* 单字段模式 */ }
|
||||||
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
|
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
|
||||||
const normalizedField = String(field || '').trim();
|
const normalizedField = String(field || '').trim();
|
||||||
if (!normalizedField || !normalizedOrder) {
|
if (!normalizedField || !normalizedOrder) {
|
||||||
setSortInfo(null);
|
setSortInfo([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSortInfo({ columnKey: normalizedField, order: normalizedOrder });
|
setSortInfo([{ columnKey: normalizedField, order: normalizedOrder, enabled: true }]);
|
||||||
}, []);
|
}, []);
|
||||||
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
|
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
|
||||||
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
|
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
|
||||||
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
|
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
|
||||||
|
const handleApplyQuickWhereCondition = useCallback((condition: string) => {
|
||||||
|
const normalized = normalizeQuickWhereCondition(condition);
|
||||||
|
const validation = validateQuickWhereCondition(normalized);
|
||||||
|
if (!validation.ok) {
|
||||||
|
message.error(validation.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuickWhereCondition(normalized);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const exportSqlWithFilter = useMemo(() => {
|
||||||
|
const tableName = String(tab.tableName || '').trim();
|
||||||
|
const dbType = resolveDataSourceType(currentConnConfig);
|
||||||
|
if (!tableName || !dbType) return '';
|
||||||
|
|
||||||
|
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
|
||||||
|
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
|
||||||
|
if (!whereSQL) return '';
|
||||||
|
|
||||||
|
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||||
|
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||||
|
const normalizedType = dbType.toLowerCase();
|
||||||
|
const hasSortForBuffer = hasExplicitSort(sortInfo);
|
||||||
|
if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
|
||||||
|
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
return sql;
|
||||||
|
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(1, pagination.pageSize);
|
const action = resolveDataViewerAutoFetchAction({
|
||||||
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
skipNextAutoFetch: skipNextAutoFetchRef.current,
|
||||||
|
hasInitialLoad: initialLoadRef.current,
|
||||||
|
});
|
||||||
|
if (action === 'skip') {
|
||||||
|
skipNextAutoFetchRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'load-current-page') {
|
||||||
|
initialLoadRef.current = true;
|
||||||
|
fetchData(pagination.current, pagination.pageSize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchData(1, pagination.pageSize);
|
||||||
|
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
@@ -259,6 +923,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
columnNames={columnNames}
|
columnNames={columnNames}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
tableName={tab.tableName}
|
tableName={tab.tableName}
|
||||||
|
exportScope="table"
|
||||||
dbName={tab.dbName}
|
dbName={tab.dbName}
|
||||||
connectionId={tab.connectionId}
|
connectionId={tab.connectionId}
|
||||||
pkColumns={pkColumns}
|
pkColumns={pkColumns}
|
||||||
@@ -266,11 +931,19 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
|
onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined}
|
||||||
|
onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined}
|
||||||
showFilter={showFilter}
|
showFilter={showFilter}
|
||||||
onToggleFilter={handleToggleFilter}
|
onToggleFilter={handleToggleFilter}
|
||||||
onApplyFilter={handleApplyFilter}
|
onApplyFilter={handleApplyFilter}
|
||||||
|
appliedFilterConditions={filterConditions}
|
||||||
|
quickWhereCondition={quickWhereCondition}
|
||||||
|
onApplyQuickWhereCondition={handleApplyQuickWhereCondition}
|
||||||
readOnly={forceReadOnly}
|
readOnly={forceReadOnly}
|
||||||
sortInfoExternal={sortInfo}
|
sortInfoExternal={sortInfo}
|
||||||
|
exportSqlWithFilter={exportSqlWithFilter || undefined}
|
||||||
|
scrollSnapshot={scrollSnapshotRef.current}
|
||||||
|
onScrollSnapshotChange={handleTableScrollSnapshotChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
223
frontend/src/components/DatabaseIcons.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// ─── 公共接口 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DbIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 默认色表 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DB_DEFAULT_COLORS: Record<string, string> = {
|
||||||
|
mysql: '#00758F',
|
||||||
|
mariadb: '#003545',
|
||||||
|
postgres: '#336791',
|
||||||
|
redis: '#DC382D',
|
||||||
|
mongodb: '#47A248',
|
||||||
|
jvm: '#1677FF',
|
||||||
|
kingbase: '#1890FF',
|
||||||
|
dameng: '#E6002D',
|
||||||
|
oracle: '#F80000',
|
||||||
|
sqlserver: '#CC2927',
|
||||||
|
clickhouse: '#FFBF00',
|
||||||
|
sqlite: '#003B57',
|
||||||
|
duckdb: '#FFC107',
|
||||||
|
vastbase: '#0066CC',
|
||||||
|
highgo: '#00A86B',
|
||||||
|
tdengine: '#2962FF',
|
||||||
|
diros: '#0050B3',
|
||||||
|
sphinx: '#2F5D62',
|
||||||
|
custom: '#888888',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDbDefaultColor = (type: string): string =>
|
||||||
|
DB_DEFAULT_COLORS[type?.toLowerCase()] || DB_DEFAULT_COLORS.custom;
|
||||||
|
|
||||||
|
// ─── 有品牌 SVG 文件的数据库类型(文件在 /db-icons/ 下) ────
|
||||||
|
|
||||||
|
const BRAND_SVG_TYPES = new Set([
|
||||||
|
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite',
|
||||||
|
'diros', 'sphinx', 'duckdb', 'sqlserver',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 品牌 SVG 图标:用 <img> 加载 /db-icons/*.svg */
|
||||||
|
const BrandSvgIcon: React.FC<{ type: string; size: number; color?: string }> = ({ type, size, color }) => {
|
||||||
|
const bgColor = color || getDbDefaultColor(type);
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: size, height: size, borderRadius: size * 0.22,
|
||||||
|
background: '#fff', border: `1.5px solid ${bgColor}`,
|
||||||
|
flexShrink: 0, overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src={`/db-icons/${type}.svg`}
|
||||||
|
alt={type}
|
||||||
|
width={size * 0.7}
|
||||||
|
height={size * 0.7}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 彩色标签图标(fallback) ──────────────────────────────
|
||||||
|
|
||||||
|
/** 通用彩色标签:填充背景 + 白色粗体缩写 */
|
||||||
|
const ColorBadge: React.FC<{ size: number; color: string; label: string }> = ({ size, color, label }) => {
|
||||||
|
const textSize = label.length <= 2 ? size * 0.48 : size * 0.38;
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="1" y="1" width="22" height="22" rx="5" fill={color}/>
|
||||||
|
<text
|
||||||
|
x="12" y="12" dominantBaseline="central" textAnchor="middle"
|
||||||
|
fontSize={textSize} fontWeight="800" fontFamily="system-ui,-apple-system,sans-serif"
|
||||||
|
fill="#fff" letterSpacing={label.length > 2 ? -0.5 : 0}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 各数据库图标 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
// 有品牌 SVG 的数据库
|
||||||
|
const MySQLIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="mysql" size={size} color={color} />
|
||||||
|
);
|
||||||
|
const MariaDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="mariadb" size={size} color={color} />
|
||||||
|
);
|
||||||
|
const PostgresIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="postgres" size={size} color={color} />
|
||||||
|
);
|
||||||
|
const RedisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="redis" size={size} color={color} />
|
||||||
|
);
|
||||||
|
const MongoDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="mongodb" size={size} color={color} />
|
||||||
|
);
|
||||||
|
const ClickHouseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="clickhouse" size={size} color={color} />
|
||||||
|
);
|
||||||
|
const SQLiteIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="sqlite" size={size} color={color} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// 无品牌 SVG → 彩色文字标签
|
||||||
|
const OracleIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oracle} label="Or" />
|
||||||
|
);
|
||||||
|
const SQLServerIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="sqlserver" size={size} color={color} />
|
||||||
|
);
|
||||||
|
const DorisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="diros" size={size} color={color} />
|
||||||
|
);
|
||||||
|
const SphinxIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="sphinx" size={size} color={color} />
|
||||||
|
);
|
||||||
|
const DuckDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<BrandSvgIcon type="duckdb" size={size} color={color} />
|
||||||
|
);
|
||||||
|
const KingBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.kingbase} label="KB" />
|
||||||
|
);
|
||||||
|
const DamengIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.dameng} label="DM" />
|
||||||
|
);
|
||||||
|
const VastBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.vastbase} label="VB" />
|
||||||
|
);
|
||||||
|
const HighGoIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.highgo} label="HG" />
|
||||||
|
);
|
||||||
|
const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.tdengine} label="TD" />
|
||||||
|
);
|
||||||
|
const JVMIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.jvm} label="JVM" />
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Custom — 齿轮图标 */
|
||||||
|
const CustomIcon: React.FC<DbIconProps> = ({ size = 16, color }) => {
|
||||||
|
const c = color || DB_DEFAULT_COLORS.custom;
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="1" y="1" width="22" height="22" rx="5" fill={c}/>
|
||||||
|
<circle cx="12" cy="12" r="3.5" stroke="#fff" strokeWidth="1.5" fill="none"/>
|
||||||
|
<path d="M12 4v2.5M12 17.5V20M4 12h2.5M17.5 12H20M6.34 6.34l1.77 1.77M15.89 15.89l1.77 1.77M6.34 17.66l1.77-1.77M15.89 8.11l1.77-1.77" stroke="#fff" strokeWidth="1.3" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 图标注册表 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DorisIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.diros} label="Do" />
|
||||||
|
);
|
||||||
|
const SphinxIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||||
|
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sphinx} label="Sp" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
||||||
|
mysql: MySQLIcon,
|
||||||
|
mariadb: MariaDBIcon,
|
||||||
|
diros: DorisIcon,
|
||||||
|
sphinx: SphinxIcon,
|
||||||
|
postgres: PostgresIcon,
|
||||||
|
redis: RedisIcon,
|
||||||
|
mongodb: MongoDBIcon,
|
||||||
|
jvm: JVMIcon,
|
||||||
|
kingbase: KingBaseIcon,
|
||||||
|
dameng: DamengIcon,
|
||||||
|
oracle: OracleIcon,
|
||||||
|
sqlserver: SQLServerIcon,
|
||||||
|
clickhouse: ClickHouseIcon,
|
||||||
|
sqlite: SQLiteIcon,
|
||||||
|
duckdb: DuckDBIcon,
|
||||||
|
vastbase: VastBaseIcon,
|
||||||
|
highgo: HighGoIcon,
|
||||||
|
tdengine: TDengineIcon,
|
||||||
|
custom: CustomIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 可选图标类型列表(用于图标选择器 UI) */
|
||||||
|
export const DB_ICON_TYPES: string[] = [
|
||||||
|
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'jvm',
|
||||||
|
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse',
|
||||||
|
'kingbase', 'dameng', 'vastbase', 'highgo', 'tdengine', 'custom',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 该类型是否有品牌 SVG 文件 */
|
||||||
|
export const hasBrandSvg = (type: string): boolean => BRAND_SVG_TYPES.has(type?.toLowerCase());
|
||||||
|
|
||||||
|
/** 获取数据库图标 React 节点 */
|
||||||
|
export const getDbIcon = (type: string, color?: string, size?: number): React.ReactNode => {
|
||||||
|
const key = (type || 'custom').toLowerCase();
|
||||||
|
const Component = DB_ICON_MAP[key] || CustomIcon;
|
||||||
|
return <Component size={size} color={color} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 获取数据库图标显示名称(中文) */
|
||||||
|
export const getDbIconLabel = (type: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
mysql: 'MySQL', mariadb: 'MariaDB', postgres: 'PostgreSQL',
|
||||||
|
redis: 'Redis', mongodb: 'MongoDB', jvm: 'JVM',
|
||||||
|
oracle: 'Oracle',
|
||||||
|
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
|
||||||
|
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
|
||||||
|
vastbase: 'VastBase', highgo: '瀚高', tdengine: 'TDengine',
|
||||||
|
custom: '自定义',
|
||||||
|
};
|
||||||
|
return labels[type?.toLowerCase()] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 预设颜色列表 */
|
||||||
|
export const PRESET_ICON_COLORS: string[] = [
|
||||||
|
'#336791', '#00758F', '#DC382D', '#47A248', '#F80000',
|
||||||
|
'#CC2927', '#1890FF', '#E6002D', '#FFBF00', '#2962FF',
|
||||||
|
'#00A86B', '#0066CC', '#FF6B35', '#7C3AED',
|
||||||
|
];
|
||||||
@@ -4,11 +4,29 @@ import { Spin, Alert } from 'antd';
|
|||||||
import { TabData } from '../types';
|
import { TabData } from '../types';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { DBQuery } from '../../wailsjs/go/app/App';
|
import { DBQuery } from '../../wailsjs/go/app/App';
|
||||||
|
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||||
|
|
||||||
interface DefinitionViewerProps {
|
interface DefinitionViewerProps {
|
||||||
tab: TabData;
|
tab: TabData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeMySQLViewDDL = (rawDefinition: unknown): string => {
|
||||||
|
const text = String(rawDefinition || '').trim();
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, '');
|
||||||
|
const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i;
|
||||||
|
if (createViewPrefixPattern.test(normalized)) {
|
||||||
|
return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\s*(select|with)\b/i.test(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalized};`;
|
||||||
|
};
|
||||||
|
|
||||||
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -201,7 +219,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
|||||||
const sql = String(query || '').trim();
|
const sql = String(query || '').trim();
|
||||||
if (!sql) continue;
|
if (!sql) continue;
|
||||||
try {
|
try {
|
||||||
const result = await DBQuery(config as any, dbName, sql);
|
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
|
||||||
if (!result.success || !Array.isArray(result.data)) {
|
if (!result.success || !Array.isArray(result.data)) {
|
||||||
lastMessage = result.message || lastMessage;
|
lastMessage = result.message || lastMessage;
|
||||||
continue;
|
continue;
|
||||||
@@ -227,7 +245,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
|||||||
];
|
];
|
||||||
for (const query of candidates) {
|
for (const query of candidates) {
|
||||||
try {
|
try {
|
||||||
const result = await DBQuery(config as any, dbName, query);
|
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
|
||||||
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
|
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -256,15 +274,15 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
|||||||
case 'mysql': {
|
case 'mysql': {
|
||||||
const keys = Object.keys(row);
|
const keys = Object.keys(row);
|
||||||
const textDefinition = row.view_definition || row.VIEW_DEFINITION;
|
const textDefinition = row.view_definition || row.VIEW_DEFINITION;
|
||||||
if (textDefinition) return String(textDefinition);
|
if (textDefinition) return normalizeMySQLViewDDL(textDefinition);
|
||||||
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
|
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
|
||||||
if (sqlKey) return row[sqlKey];
|
if (sqlKey) return normalizeMySQLViewDDL(row[sqlKey]);
|
||||||
const tableSqlKey = keys.find(k => k.toLowerCase().includes('create table'));
|
const tableSqlKey = keys.find(k => k.toLowerCase().includes('create table'));
|
||||||
if (tableSqlKey) return row[tableSqlKey];
|
if (tableSqlKey) return normalizeMySQLViewDDL(row[tableSqlKey]);
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const val = String(row[key] || '');
|
const val = String(row[key] || '');
|
||||||
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('VIEW') || val.toUpperCase().includes('TABLE'))) {
|
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('VIEW') || val.toUpperCase().includes('TABLE'))) {
|
||||||
return val;
|
return normalizeMySQLViewDDL(val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return JSON.stringify(row, null, 2);
|
return JSON.stringify(row, null, 2);
|
||||||
|
|||||||
465
frontend/src/components/FindInDatabaseModal.tsx
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { Modal, Input, Button, Table, Progress, Space, Tag, message, Tooltip, Select, Empty } from 'antd';
|
||||||
|
import { SearchOutlined, StopOutlined, EyeOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||||
|
import { DBQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/app/App';
|
||||||
|
import { quoteIdentPart, escapeLiteral } from '../utils/sql';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||||
|
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||||
|
import { isMacLikePlatform } from '../utils/appearance';
|
||||||
|
|
||||||
|
interface FindInDatabaseModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
connectionId: string;
|
||||||
|
dbName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResultItem {
|
||||||
|
tableName: string;
|
||||||
|
matchedColumns: string[];
|
||||||
|
matchCount: number;
|
||||||
|
rows: Record<string, any>[];
|
||||||
|
columns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断数据库列类型是否为文本类型(只搜索文本字段) */
|
||||||
|
const isTextColumnType = (colType: string): boolean => {
|
||||||
|
const t = (colType || '').toLowerCase().trim();
|
||||||
|
// 显式排除非文本类型
|
||||||
|
if (/^(int|bigint|smallint|tinyint|mediumint|float|double|decimal|numeric|real|money|smallmoney|bit|boolean|bool)/.test(t)) return false;
|
||||||
|
if (/^(date|time|datetime|timestamp|year|interval)/.test(t)) return false;
|
||||||
|
if (/^(blob|binary|varbinary|image|bytea|raw|long raw)/.test(t)) return false;
|
||||||
|
if (/^(geometry|geography|point|line|polygon|spatial)/.test(t)) return false;
|
||||||
|
if (/^(json|jsonb|xml|uuid|uniqueidentifier)/.test(t)) return false;
|
||||||
|
if (/^(serial|bigserial|smallserial|autoincrement|identity)/.test(t)) return false;
|
||||||
|
// 文本类型正匹配
|
||||||
|
if (/^(varchar|char|nvarchar|nchar|text|ntext|tinytext|mediumtext|longtext|string|clob|nclob|character)/.test(t)) return true;
|
||||||
|
if (t === 'sysname' || t === 'sql_variant') return true;
|
||||||
|
// 未知类型默认尝试搜索
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 根据 dbType 构建限制返回行数的 SELECT SQL */
|
||||||
|
const buildLimitedSelectSQL = (dbType: string, baseSql: string, limit: number): string => {
|
||||||
|
const normalizedType = (dbType || '').toLowerCase();
|
||||||
|
switch (normalizedType) {
|
||||||
|
case 'sqlserver':
|
||||||
|
case 'mssql':
|
||||||
|
return baseSql.replace(/^SELECT\b/i, `SELECT TOP ${limit}`);
|
||||||
|
case 'oracle':
|
||||||
|
case 'dameng':
|
||||||
|
return `${baseSql} FETCH FIRST ${limit} ROWS ONLY`;
|
||||||
|
default:
|
||||||
|
return `${baseSql} LIMIT ${limit}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_MATCH_ROWS_PER_TABLE = 100;
|
||||||
|
|
||||||
|
const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose, connectionId, dbName }) => {
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [matchMode, setMatchMode] = useState<'contains' | 'exact'>('contains');
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||||
|
const [progress, setProgress] = useState({ current: 0, total: 0, tableName: '' });
|
||||||
|
const [expandedTable, setExpandedTable] = useState<string | null>(null);
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
|
||||||
|
const connections = useStore(state => state.connections);
|
||||||
|
const theme = useStore(state => state.theme);
|
||||||
|
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||||
|
|
||||||
|
const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]);
|
||||||
|
const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]);
|
||||||
|
|
||||||
|
const wt = useMemo(() => {
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
return buildOverlayWorkbenchTheme(isDark, { disableBackdropFilter: disableLocalBackdropFilter });
|
||||||
|
}, [disableLocalBackdropFilter, theme]);
|
||||||
|
|
||||||
|
const buildConfig = useCallback(() => {
|
||||||
|
if (!conn) return null;
|
||||||
|
return {
|
||||||
|
...conn.config,
|
||||||
|
port: Number(conn.config.port),
|
||||||
|
password: conn.config.password || "",
|
||||||
|
database: conn.config.database || "",
|
||||||
|
useSSH: conn.config.useSSH || false,
|
||||||
|
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||||
|
};
|
||||||
|
}, [conn]);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async () => {
|
||||||
|
const searchKeyword = keyword.trim();
|
||||||
|
if (!searchKeyword) {
|
||||||
|
message.warning('请输入搜索关键字');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const config = buildConfig();
|
||||||
|
if (!config) {
|
||||||
|
message.error('未找到连接配置');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
setResults([]);
|
||||||
|
setExpandedTable(null);
|
||||||
|
cancelledRef.current = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取所有表
|
||||||
|
const tablesRes = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
|
||||||
|
if (!tablesRes.success) {
|
||||||
|
message.error('获取表列表失败: ' + tablesRes.message);
|
||||||
|
setSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tableRows: any[] = Array.isArray(tablesRes.data) ? tablesRes.data : [];
|
||||||
|
const tableNames = tableRows.map((row: any) => Object.values(row)[0] as string).filter(Boolean);
|
||||||
|
|
||||||
|
if (tableNames.length === 0) {
|
||||||
|
message.info('当前数据库没有表');
|
||||||
|
setSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress({ current: 0, total: tableNames.length, tableName: '' });
|
||||||
|
|
||||||
|
// 2. 获取所有列信息(返回 any[],含 tableName/name/type 字段)
|
||||||
|
const allColsRes = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName);
|
||||||
|
const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : [];
|
||||||
|
|
||||||
|
// 按表名分组
|
||||||
|
const columnsByTable: Record<string, Array<{ name: string; type: string }>> = {};
|
||||||
|
allColumns.forEach((col: any) => {
|
||||||
|
const tbl = col.tableName || '';
|
||||||
|
if (!columnsByTable[tbl]) columnsByTable[tbl] = [];
|
||||||
|
columnsByTable[tbl].push({ name: col.name, type: col.type || '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchResults: SearchResultItem[] = [];
|
||||||
|
const escapedKeyword = escapeLiteral(searchKeyword);
|
||||||
|
|
||||||
|
// 3. 逐表搜索
|
||||||
|
for (let i = 0; i < tableNames.length; i++) {
|
||||||
|
if (cancelledRef.current) break;
|
||||||
|
|
||||||
|
const tableName = tableNames[i];
|
||||||
|
setProgress({ current: i + 1, total: tableNames.length, tableName });
|
||||||
|
|
||||||
|
// 获取该表的文本列
|
||||||
|
const tableCols = columnsByTable[tableName] || [];
|
||||||
|
const textCols = tableCols.filter(c => isTextColumnType(c.type));
|
||||||
|
|
||||||
|
if (textCols.length === 0) continue;
|
||||||
|
|
||||||
|
// 构建 WHERE 子句
|
||||||
|
const castType = (dbType === 'sqlserver' || dbType === 'mssql') ? 'NVARCHAR(MAX)' : 'CHAR';
|
||||||
|
const whereConditions = textCols.map(c => {
|
||||||
|
const quotedCol = quoteIdentPart(dbType, c.name);
|
||||||
|
if (matchMode === 'exact') {
|
||||||
|
return `CAST(${quotedCol} AS ${castType}) = '${escapedKeyword}'`;
|
||||||
|
}
|
||||||
|
return `CAST(${quotedCol} AS ${castType}) LIKE '%${escapedKeyword}%'`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const quotedTable = quoteIdentPart(dbType, tableName);
|
||||||
|
const baseSql = `SELECT * FROM ${quotedTable} WHERE ${whereConditions.join(' OR ')}`;
|
||||||
|
const sql = buildLimitedSelectSQL(dbType, baseSql, MAX_MATCH_ROWS_PER_TABLE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
|
||||||
|
if (res.success && Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
// 检查哪些列实际匹配了
|
||||||
|
const matchedCols = new Set<string>();
|
||||||
|
const lowerKeyword = searchKeyword.toLowerCase();
|
||||||
|
res.data.forEach((row: any) => {
|
||||||
|
textCols.forEach(c => {
|
||||||
|
const val = row[c.name];
|
||||||
|
if (val != null) {
|
||||||
|
const strVal = String(val).toLowerCase();
|
||||||
|
if (matchMode === 'exact' ? strVal === lowerKeyword : strVal.includes(lowerKeyword)) {
|
||||||
|
matchedCols.add(c.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedCols.size > 0) {
|
||||||
|
const columns = Object.keys(res.data[0]);
|
||||||
|
searchResults.push({
|
||||||
|
tableName,
|
||||||
|
matchedColumns: Array.from(matchedCols),
|
||||||
|
matchCount: res.data.length,
|
||||||
|
rows: res.data,
|
||||||
|
columns,
|
||||||
|
});
|
||||||
|
setResults([...searchResults]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 单表查询失败不中断整体搜索
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelledRef.current) {
|
||||||
|
setResults([...searchResults]);
|
||||||
|
if (searchResults.length === 0) {
|
||||||
|
message.info('未找到匹配的数据');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('搜索出错: ' + (e?.message || String(e)));
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, [keyword, matchMode, dbName, dbType, buildConfig]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
setResults([]);
|
||||||
|
setExpandedTable(null);
|
||||||
|
setProgress({ current: 0, total: 0, tableName: '' });
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// 汇总表的列定义
|
||||||
|
const summaryColumns = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: '表名',
|
||||||
|
dataIndex: 'tableName',
|
||||||
|
key: 'tableName',
|
||||||
|
width: 220,
|
||||||
|
render: (text: string) => (
|
||||||
|
<span style={{ fontWeight: 500, color: wt.titleText }}>
|
||||||
|
<DatabaseOutlined style={{ marginRight: 6, color: wt.iconColor }} />
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '匹配列',
|
||||||
|
dataIndex: 'matchedColumns',
|
||||||
|
key: 'matchedColumns',
|
||||||
|
render: (cols: string[]) => (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{cols.map(col => (
|
||||||
|
<Tag key={col} color="blue" style={{ margin: 0, fontSize: 12 }}>{col}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '命中行数',
|
||||||
|
dataIndex: 'matchCount',
|
||||||
|
key: 'matchCount',
|
||||||
|
width: 100,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (count: number) => (
|
||||||
|
<Tag color={count >= MAX_MATCH_ROWS_PER_TABLE ? 'orange' : 'green'}>
|
||||||
|
{count >= MAX_MATCH_ROWS_PER_TABLE ? `≥${count}` : count}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 80,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: any, record: SearchResultItem) => (
|
||||||
|
<Tooltip title={expandedTable === record.tableName ? '收起详情' : '查看详情'}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setExpandedTable(prev => prev === record.tableName ? null : record.tableName); }}
|
||||||
|
style={{ color: wt.iconColor }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [wt, expandedTable]);
|
||||||
|
|
||||||
|
// 展开的详情行 - 动态列
|
||||||
|
const expandedResult = useMemo(() => {
|
||||||
|
if (!expandedTable) return null;
|
||||||
|
return results.find(r => r.tableName === expandedTable);
|
||||||
|
}, [expandedTable, results]);
|
||||||
|
|
||||||
|
const detailColumns = useMemo(() => {
|
||||||
|
if (!expandedResult) return [];
|
||||||
|
const lowerKeyword = keyword.trim().toLowerCase();
|
||||||
|
return expandedResult.columns.map(col => ({
|
||||||
|
title: col,
|
||||||
|
dataIndex: col,
|
||||||
|
key: col,
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: any) => {
|
||||||
|
const strVal = value != null ? String(value) : '';
|
||||||
|
const isMatch = expandedResult.matchedColumns.includes(col) &&
|
||||||
|
strVal.toLowerCase().includes(lowerKeyword);
|
||||||
|
return (
|
||||||
|
<Tooltip title={strVal} placement="topLeft">
|
||||||
|
<span style={isMatch ? { background: 'rgba(255, 193, 7, 0.3)', padding: '1px 3px', borderRadius: 3 } : undefined}>
|
||||||
|
{strVal || <span style={{ color: wt.mutedText }}>NULL</span>}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [expandedResult, keyword, wt]);
|
||||||
|
|
||||||
|
const percent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<span style={{ color: wt.titleText, fontWeight: 600 }}>
|
||||||
|
<SearchOutlined style={{ marginRight: 8, color: wt.iconColor }} />
|
||||||
|
在数据库中搜索 — {dbName}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}
|
||||||
|
width={960}
|
||||||
|
styles={{
|
||||||
|
content: {
|
||||||
|
background: wt.shellBg,
|
||||||
|
borderRadius: 16,
|
||||||
|
border: wt.shellBorder,
|
||||||
|
boxShadow: wt.shellShadow,
|
||||||
|
backdropFilter: wt.shellBackdropFilter,
|
||||||
|
WebkitBackdropFilter: wt.shellBackdropFilter,
|
||||||
|
},
|
||||||
|
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||||
|
body: { paddingTop: 8 },
|
||||||
|
}}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="输入要搜索的字符串..."
|
||||||
|
value={keyword}
|
||||||
|
onChange={e => setKeyword(e.target.value)}
|
||||||
|
onPressEnter={!searching ? handleSearch : undefined}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
disabled={searching}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={matchMode}
|
||||||
|
onChange={v => setMatchMode(v)}
|
||||||
|
disabled={searching}
|
||||||
|
style={{ width: 110 }}
|
||||||
|
options={[
|
||||||
|
{ label: '包含', value: 'contains' },
|
||||||
|
{ label: '精确匹配', value: 'exact' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{searching ? (
|
||||||
|
<Button icon={<StopOutlined />} danger onClick={handleCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch} disabled={!keyword.trim()}>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
{searching && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<Progress
|
||||||
|
percent={percent}
|
||||||
|
size="small"
|
||||||
|
status="active"
|
||||||
|
strokeColor={wt.iconColor}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: wt.mutedText }}>
|
||||||
|
正在搜索 {progress.tableName}... ({progress.current}/{progress.total})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 结果汇总表 */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ fontSize: 13, color: wt.mutedText, fontWeight: 500 }}>
|
||||||
|
找到 {results.length} 个表包含匹配数据
|
||||||
|
{searching && '(搜索进行中...)'}
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
dataSource={results}
|
||||||
|
columns={summaryColumns}
|
||||||
|
rowKey="tableName"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
style={{ borderRadius: 8, overflow: 'hidden' }}
|
||||||
|
scroll={{ y: expandedTable ? 200 : 400 }}
|
||||||
|
onRow={(record) => ({
|
||||||
|
style: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: expandedTable === record.tableName ? wt.hoverBg : undefined,
|
||||||
|
},
|
||||||
|
onClick: () => setExpandedTable(prev => prev === record.tableName ? null : record.tableName),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 详情展开 */}
|
||||||
|
{expandedResult && (
|
||||||
|
<div style={{
|
||||||
|
border: wt.sectionBorder,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: wt.sectionBg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: wt.sectionBorder,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: wt.titleText,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<span>
|
||||||
|
<DatabaseOutlined style={{ marginRight: 6 }} />
|
||||||
|
{expandedResult.tableName} — 匹配行详情
|
||||||
|
</span>
|
||||||
|
<Tag color="blue">{expandedResult.rows.length} 行</Tag>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
dataSource={expandedResult.rows.map((row, i) => ({ ...row, __rowIdx: i }))}
|
||||||
|
columns={detailColumns}
|
||||||
|
rowKey="__rowIdx"
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false }}
|
||||||
|
scroll={{ x: Math.max(800, expandedResult.columns.length * 180) }}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 无结果且搜索完成 */}
|
||||||
|
{!searching && results.length === 0 && progress.total > 0 && (
|
||||||
|
<Empty description="未找到匹配的数据" style={{ margin: '24px 0' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FindInDatabaseModal;
|
||||||
@@ -4,6 +4,7 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
|||||||
import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App';
|
import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App';
|
||||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
|
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
|
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||||
|
|
||||||
interface ImportPreviewModalProps {
|
interface ImportPreviewModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -107,7 +108,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
|||||||
ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
|
ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await ImportDataWithProgress(config as any, dbName, tableName, filePath);
|
const res = await ImportDataWithProgress(buildRpcConnectionConfig(config) as any, dbName, tableName, filePath);
|
||||||
|
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setImportResult(res.data);
|
setImportResult(res.data);
|
||||||
|
|||||||
48
frontend/src/components/JVMAuditViewer.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import JVMAuditViewer from "./JVMAuditViewer";
|
||||||
|
|
||||||
|
vi.mock("../store", () => ({
|
||||||
|
useStore: (selector: (state: any) => any) =>
|
||||||
|
selector({
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: "conn-jvm-1",
|
||||||
|
name: "orders-jvm",
|
||||||
|
config: {
|
||||||
|
host: "localhost",
|
||||||
|
port: 10990,
|
||||||
|
jvm: {
|
||||||
|
preferredMode: "endpoint",
|
||||||
|
readOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: "light",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("JVMAuditViewer", () => {
|
||||||
|
it("renders a unified JVM workspace audit shell", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMAuditViewer
|
||||||
|
tab={{
|
||||||
|
id: "tab-jvm-audit",
|
||||||
|
type: "jvm-audit",
|
||||||
|
title: "[orders-jvm] JVM 审计",
|
||||||
|
connectionId: "conn-jvm-1",
|
||||||
|
providerMode: "endpoint",
|
||||||
|
} as any}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('data-jvm-workspace-shell="true"');
|
||||||
|
expect(markup).toContain('data-jvm-workspace-hero="true"');
|
||||||
|
expect(markup).toContain("JVM 变更审计");
|
||||||
|
expect(markup).toContain("审计记录");
|
||||||
|
expect(markup).toContain("最近 50 条");
|
||||||
|
});
|
||||||
|
});
|
||||||
271
frontend/src/components/JVMAuditViewer.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from "antd";
|
||||||
|
import type { ColumnsType } from "antd/es/table";
|
||||||
|
import { ReloadOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import type { JVMAuditRecord, TabData } from "../types";
|
||||||
|
import {
|
||||||
|
formatJVMAuditResultLabel,
|
||||||
|
formatJVMActionDisplayText,
|
||||||
|
resolveJVMAuditResultColor,
|
||||||
|
} from "../utils/jvmResourcePresentation";
|
||||||
|
import JVMModeBadge from "./jvm/JVMModeBadge";
|
||||||
|
import {
|
||||||
|
getJVMWorkspaceCardStyle,
|
||||||
|
JVMWorkspaceHero,
|
||||||
|
JVMWorkspaceShell,
|
||||||
|
} from "./jvm/JVMWorkspaceLayout";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
type JVMAuditViewerProps = {
|
||||||
|
tab: TabData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIMIT_OPTIONS = [20, 50, 100, 200];
|
||||||
|
|
||||||
|
const normalizeAuditRecords = (value: any): JVMAuditRecord[] => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value as JVMAuditRecord[];
|
||||||
|
}
|
||||||
|
if (Array.isArray(value?.data)) {
|
||||||
|
return value.data as JVMAuditRecord[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterAuditRecordsByMode = (
|
||||||
|
records: JVMAuditRecord[],
|
||||||
|
providerMode?: string,
|
||||||
|
): JVMAuditRecord[] => {
|
||||||
|
const normalizedMode = String(providerMode || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalizedMode) {
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
return records.filter(
|
||||||
|
(record) =>
|
||||||
|
String(record.providerMode || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() === normalizedMode,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: number): string => {
|
||||||
|
if (!timestamp) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const normalized = timestamp > 1e12 ? timestamp : timestamp * 1000;
|
||||||
|
const date = new Date(normalized);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return String(timestamp);
|
||||||
|
}
|
||||||
|
return date.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||||
|
const connection = useStore((state) =>
|
||||||
|
state.connections.find((item) => item.id === tab.connectionId),
|
||||||
|
);
|
||||||
|
const theme = useStore((state) => state.theme);
|
||||||
|
const darkMode = theme === "dark";
|
||||||
|
const [limit, setLimit] = useState(50);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [records, setRecords] = useState<JVMAuditRecord[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnsType<JVMAuditRecord>>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: "时间",
|
||||||
|
dataIndex: "timestamp",
|
||||||
|
key: "timestamp",
|
||||||
|
width: 180,
|
||||||
|
render: (value: number) => formatTimestamp(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "模式",
|
||||||
|
dataIndex: "providerMode",
|
||||||
|
key: "providerMode",
|
||||||
|
width: 120,
|
||||||
|
render: (value: string) => (
|
||||||
|
<JVMModeBadge mode={value || tab.providerMode || "jmx"} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "动作",
|
||||||
|
dataIndex: "action",
|
||||||
|
key: "action",
|
||||||
|
width: 160,
|
||||||
|
render: (value: string) => formatJVMActionDisplayText(value) || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "资源",
|
||||||
|
dataIndex: "resourceId",
|
||||||
|
key: "resourceId",
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string) => value || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "原因",
|
||||||
|
dataIndex: "reason",
|
||||||
|
key: "reason",
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string) => value || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "来源",
|
||||||
|
dataIndex: "source",
|
||||||
|
key: "source",
|
||||||
|
width: 120,
|
||||||
|
render: (value?: string) => {
|
||||||
|
const normalized = String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (normalized === "ai-plan") {
|
||||||
|
return <Tag color="purple">AI 辅助</Tag>;
|
||||||
|
}
|
||||||
|
return <Tag>手工</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "结果",
|
||||||
|
dataIndex: "result",
|
||||||
|
key: "result",
|
||||||
|
width: 140,
|
||||||
|
render: (value: string) => (
|
||||||
|
<Tag color={resolveJVMAuditResultColor(value)}>
|
||||||
|
{formatJVMAuditResultLabel(value)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[tab.providerMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadRecords = async () => {
|
||||||
|
if (!connection) {
|
||||||
|
setLoading(false);
|
||||||
|
setRecords([]);
|
||||||
|
setError("连接不存在或已被删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendApp = (window as any).go?.app?.App;
|
||||||
|
if (typeof backendApp?.JVMListAuditRecords !== "function") {
|
||||||
|
setLoading(false);
|
||||||
|
setRecords([]);
|
||||||
|
setError("JVMListAuditRecords 后端方法不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await backendApp.JVMListAuditRecords(connection.id, limit);
|
||||||
|
if (result?.success === false) {
|
||||||
|
setRecords([]);
|
||||||
|
setError(String(result?.message || "读取 JVM 审计记录失败"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRecords(
|
||||||
|
filterAuditRecordsByMode(
|
||||||
|
normalizeAuditRecords(result),
|
||||||
|
tab.providerMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
setRecords([]);
|
||||||
|
setError(err?.message || "读取 JVM 审计记录失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRecords();
|
||||||
|
}, [connection, limit, tab.connectionId]);
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return (
|
||||||
|
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMode =
|
||||||
|
tab.providerMode || connection.config.jvm?.preferredMode || "jmx";
|
||||||
|
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JVMWorkspaceShell darkMode={darkMode}>
|
||||||
|
<JVMWorkspaceHero
|
||||||
|
darkMode={darkMode}
|
||||||
|
eyebrow="JVM Audit"
|
||||||
|
title="JVM 变更审计"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Text strong>{connection.name}</Text>
|
||||||
|
<Text type="secondary"> · {connection.id}</Text>
|
||||||
|
<Text type="secondary"> · 当前范围:最近 {limit} 条</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
badges={<JVMModeBadge mode={activeMode} />}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => void loadRecords()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={limit}
|
||||||
|
onChange={setLimit}
|
||||||
|
options={LIMIT_OPTIONS.map((item) => ({
|
||||||
|
value: item,
|
||||||
|
label: `最近 ${item} 条`,
|
||||||
|
}))}
|
||||||
|
style={{ width: 132 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card title="审计记录" variant="borderless" style={cardStyle}>
|
||||||
|
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||||
|
{error ? <Alert type="error" showIcon message={error} /> : null}
|
||||||
|
<Table<JVMAuditRecord>
|
||||||
|
rowKey={(record) =>
|
||||||
|
`${record.timestamp}-${record.resourceId}-${record.action}`
|
||||||
|
}
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={records}
|
||||||
|
pagination={false}
|
||||||
|
locale={{
|
||||||
|
emptyText: error ? "当前无法加载审计记录" : "暂无审计记录",
|
||||||
|
}}
|
||||||
|
scroll={{ x: 960 }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</JVMWorkspaceShell>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JVMAuditViewer;
|
||||||
272
frontend/src/components/JVMDiagnosticConsole.test.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import JVMDiagnosticConsole, {
|
||||||
|
createJVMDiagnosticLocalPendingChunk,
|
||||||
|
createJVMDiagnosticRunningRecord,
|
||||||
|
isJVMDiagnosticTerminalPhase,
|
||||||
|
} from "./JVMDiagnosticConsole";
|
||||||
|
|
||||||
|
const baseState = {
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: "conn-1",
|
||||||
|
name: "orders-jvm",
|
||||||
|
config: {
|
||||||
|
host: "orders.internal",
|
||||||
|
jvm: {
|
||||||
|
diagnostic: {
|
||||||
|
enabled: true,
|
||||||
|
transport: "agent-bridge",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
jvmDiagnosticDrafts: {},
|
||||||
|
jvmDiagnosticOutputs: {},
|
||||||
|
setJVMDiagnosticDraft: vi.fn(),
|
||||||
|
appendJVMDiagnosticOutput: vi.fn(),
|
||||||
|
clearJVMDiagnosticOutput: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mockState: any = baseState;
|
||||||
|
let registeredCompletionProvider: any = null;
|
||||||
|
const mockMonaco = {
|
||||||
|
Range: class {
|
||||||
|
startLineNumber: number;
|
||||||
|
startColumn: number;
|
||||||
|
endLineNumber: number;
|
||||||
|
endColumn: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
startLineNumber: number,
|
||||||
|
startColumn: number,
|
||||||
|
endLineNumber: number,
|
||||||
|
endColumn: number,
|
||||||
|
) {
|
||||||
|
this.startLineNumber = startLineNumber;
|
||||||
|
this.startColumn = startColumn;
|
||||||
|
this.endLineNumber = endLineNumber;
|
||||||
|
this.endColumn = endColumn;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyMod: { CtrlCmd: 2048 },
|
||||||
|
KeyCode: { Enter: 3 },
|
||||||
|
editor: {
|
||||||
|
setTheme: vi.fn(),
|
||||||
|
},
|
||||||
|
languages: {
|
||||||
|
CompletionItemKind: {
|
||||||
|
Keyword: 1,
|
||||||
|
Snippet: 2,
|
||||||
|
Value: 3,
|
||||||
|
},
|
||||||
|
CompletionItemInsertTextRule: {
|
||||||
|
InsertAsSnippet: 4,
|
||||||
|
},
|
||||||
|
register: vi.fn(),
|
||||||
|
registerCompletionItemProvider: vi.fn((language: string, provider: any) => {
|
||||||
|
if (language === "jvm-diagnostic") {
|
||||||
|
registeredCompletionProvider = provider;
|
||||||
|
}
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const mockEditor = {
|
||||||
|
addCommand: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@monaco-editor/react", () => ({
|
||||||
|
default: ({
|
||||||
|
beforeMount,
|
||||||
|
language,
|
||||||
|
onMount,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
beforeMount?: (monaco: any) => void;
|
||||||
|
language?: string;
|
||||||
|
onMount?: (editor: any, monaco: any) => void;
|
||||||
|
value?: string;
|
||||||
|
}) => {
|
||||||
|
beforeMount?.(mockMonaco);
|
||||||
|
onMount?.(mockEditor, mockMonaco);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-before-mount={beforeMount ? "true" : "false"}
|
||||||
|
data-monaco-editor-mock="true"
|
||||||
|
data-language={language}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../store", () => ({
|
||||||
|
useStore: (selector: (state: any) => any) => selector(mockState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("JVMDiagnosticConsole", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
registeredCompletionProvider = null;
|
||||||
|
mockMonaco.editor.setTheme.mockClear();
|
||||||
|
mockMonaco.languages.register.mockClear();
|
||||||
|
mockMonaco.languages.registerCompletionItemProvider.mockClear();
|
||||||
|
mockEditor.addCommand.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds local pending output and history while a command is waiting for backend events", () => {
|
||||||
|
const chunk = createJVMDiagnosticLocalPendingChunk({
|
||||||
|
sessionId: "session-1",
|
||||||
|
commandId: "cmd-1",
|
||||||
|
command: "thread -n 5",
|
||||||
|
});
|
||||||
|
const record = createJVMDiagnosticRunningRecord({
|
||||||
|
connectionId: "conn-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
commandId: "cmd-1",
|
||||||
|
transport: "arthas-tunnel",
|
||||||
|
command: "thread -n 5",
|
||||||
|
source: "manual",
|
||||||
|
reason: "排查线程",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chunk).toMatchObject({
|
||||||
|
sessionId: "session-1",
|
||||||
|
commandId: "cmd-1",
|
||||||
|
event: "diagnostic",
|
||||||
|
phase: "running",
|
||||||
|
});
|
||||||
|
expect(chunk.content).toContain("thread -n 5");
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
connectionId: "conn-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
commandId: "cmd-1",
|
||||||
|
transport: "arthas-tunnel",
|
||||||
|
command: "thread -n 5",
|
||||||
|
status: "running",
|
||||||
|
reason: "排查线程",
|
||||||
|
});
|
||||||
|
expect(isJVMDiagnosticTerminalPhase("completed")).toBe(true);
|
||||||
|
expect(isJVMDiagnosticTerminalPhase("failed")).toBe(true);
|
||||||
|
expect(isJVMDiagnosticTerminalPhase("running")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a stable workbench shell and hides command inputs before session creation", () => {
|
||||||
|
mockState = {
|
||||||
|
...baseState,
|
||||||
|
jvmDiagnosticDrafts: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMDiagnosticConsole
|
||||||
|
tab={{
|
||||||
|
id: "tab-1",
|
||||||
|
title: "诊断增强",
|
||||||
|
type: "jvm-diagnostic",
|
||||||
|
connectionId: "conn-1",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("开始一次诊断");
|
||||||
|
expect(markup).toContain("命令输入将在会话建立后显示");
|
||||||
|
expect(markup).toContain("先建立会话,再显示命令编辑器和模板");
|
||||||
|
expect(markup).toContain("会话与能力");
|
||||||
|
expect(markup).toContain("审计历史");
|
||||||
|
expect(markup).not.toContain("命令模板");
|
||||||
|
expect(markup).not.toContain("实时输出");
|
||||||
|
expect(markup).not.toContain('data-monaco-editor-mock="true"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows command input, reason field, and presets after a session exists", () => {
|
||||||
|
mockState = {
|
||||||
|
...baseState,
|
||||||
|
jvmDiagnosticDrafts: {
|
||||||
|
"tab-1": {
|
||||||
|
sessionId: "session-1",
|
||||||
|
command: "thread -n 5",
|
||||||
|
reason: "排查 CPU 线程",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMDiagnosticConsole
|
||||||
|
tab={{
|
||||||
|
id: "tab-1",
|
||||||
|
title: "诊断增强",
|
||||||
|
type: "jvm-diagnostic",
|
||||||
|
connectionId: "conn-1",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("overflow:auto");
|
||||||
|
expect(markup).toContain("JVM 诊断工作台");
|
||||||
|
expect(markup).toContain("会话与能力");
|
||||||
|
expect(markup).toContain("实时输出");
|
||||||
|
expect(markup).toContain("审计历史");
|
||||||
|
expect(markup.indexOf("命令输入")).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(markup).toContain("诊断命令");
|
||||||
|
expect(markup).toContain("诊断原因(可选)");
|
||||||
|
expect(markup).toContain("用于审计记录");
|
||||||
|
expect(markup.indexOf("命令输入")).toBeLessThan(markup.indexOf("实时输出"));
|
||||||
|
expect(markup).toContain("观察类命令");
|
||||||
|
expect(markup).toContain("thread");
|
||||||
|
expect(markup).toContain("执行命令");
|
||||||
|
expect(markup).toContain('data-monaco-editor-mock="true"');
|
||||||
|
expect(markup).toContain('data-language="jvm-diagnostic"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the same styled editor shell and registers command completion before mount", () => {
|
||||||
|
mockState = {
|
||||||
|
...baseState,
|
||||||
|
jvmDiagnosticDrafts: {
|
||||||
|
"tab-1": {
|
||||||
|
sessionId: "session-1",
|
||||||
|
command: "thr",
|
||||||
|
reason: "排查 CPU 线程",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMDiagnosticConsole
|
||||||
|
tab={{
|
||||||
|
id: "tab-1",
|
||||||
|
title: "诊断增强",
|
||||||
|
type: "jvm-diagnostic",
|
||||||
|
connectionId: "conn-1",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain(
|
||||||
|
'data-jvm-diagnostic-command-editor-shell="true"',
|
||||||
|
);
|
||||||
|
expect(markup).toContain('data-before-mount="true"');
|
||||||
|
expect(markup).toContain("border-radius:14px");
|
||||||
|
expect(registeredCompletionProvider).toBeTruthy();
|
||||||
|
|
||||||
|
const result = registeredCompletionProvider.provideCompletionItems(
|
||||||
|
{
|
||||||
|
getValueInRange: () => "thr",
|
||||||
|
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 4 }),
|
||||||
|
},
|
||||||
|
{ lineNumber: 1, column: 4 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.suggestions).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
label: "thread",
|
||||||
|
insertText: "thread ",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
1094
frontend/src/components/JVMDiagnosticConsole.tsx
Normal file
85
frontend/src/components/JVMMonitoringDashboard.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import JVMMonitoringDashboard from "./JVMMonitoringDashboard";
|
||||||
|
|
||||||
|
vi.mock("../store", () => ({
|
||||||
|
useStore: (selector: (state: any) => any) =>
|
||||||
|
selector({
|
||||||
|
theme: "light",
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: "conn-1",
|
||||||
|
name: "orders-jvm",
|
||||||
|
config: {
|
||||||
|
host: "orders.internal",
|
||||||
|
port: 9010,
|
||||||
|
jvm: {
|
||||||
|
preferredMode: "jmx",
|
||||||
|
allowedModes: ["jmx"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("JVMMonitoringDashboard", () => {
|
||||||
|
it("shows start action and empty-state guidance before monitoring starts", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMMonitoringDashboard
|
||||||
|
tab={{
|
||||||
|
id: "tab-monitor-1",
|
||||||
|
title: "持续监控",
|
||||||
|
type: "jvm-monitoring",
|
||||||
|
connectionId: "conn-1",
|
||||||
|
providerMode: "jmx",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("开始监控");
|
||||||
|
expect(markup).toContain("当前尚未开始持续监控");
|
||||||
|
expect(markup).toContain("堆内存");
|
||||||
|
expect(markup).toContain("暂无堆内存采样数据");
|
||||||
|
expect(markup).not.toContain("暂无 Heap 采样数据");
|
||||||
|
expect(markup).not.toContain("当前 provider 未提供 Heap 指标");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a dedicated vertical scroll shell for tall monitoring content", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMMonitoringDashboard
|
||||||
|
tab={{
|
||||||
|
id: "tab-monitor-scroll",
|
||||||
|
title: "持续监控",
|
||||||
|
type: "jvm-monitoring",
|
||||||
|
connectionId: "conn-1",
|
||||||
|
providerMode: "jmx",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('data-jvm-monitoring-dashboard-scroll-shell="true"');
|
||||||
|
expect(markup).toContain("height:100%");
|
||||||
|
expect(markup).toContain("overflow-y:auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks monitoring charts before detail panels so charts keep full content width", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMMonitoringDashboard
|
||||||
|
tab={{
|
||||||
|
id: "tab-monitor-layout",
|
||||||
|
title: "持续监控",
|
||||||
|
type: "jvm-monitoring",
|
||||||
|
connectionId: "conn-1",
|
||||||
|
providerMode: "jmx",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('data-jvm-monitoring-content-stack="true"');
|
||||||
|
expect(markup).toContain("gap:24px");
|
||||||
|
expect(markup).not.toContain("minmax(min(100%, 320px), 1fr)");
|
||||||
|
});
|
||||||
|
});
|
||||||
392
frontend/src/components/JVMMonitoringDashboard.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Alert, Button, Card, Empty, Space, Spin, Tag, Typography } from "antd";
|
||||||
|
import { DashboardOutlined, PauseCircleOutlined, PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import type { JVMMonitoringSessionState, TabData } from "../types";
|
||||||
|
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||||
|
import {
|
||||||
|
buildMonitoringAvailabilityText,
|
||||||
|
normalizeMonitoringProviderMode,
|
||||||
|
type JVMMonitoringProviderMode,
|
||||||
|
} from "../utils/jvmMonitoringPresentation";
|
||||||
|
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
|
||||||
|
import JVMMonitoringCharts from "./jvm/JVMMonitoringCharts";
|
||||||
|
import JVMMonitoringDetailPanel from "./jvm/JVMMonitoringDetailPanel";
|
||||||
|
import JVMMonitoringStatusCards from "./jvm/JVMMonitoringStatusCards";
|
||||||
|
|
||||||
|
const { Paragraph, Text, Title } = Typography;
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
type JVMMonitoringDashboardProps = {
|
||||||
|
tab: TabData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMonitoringSessionMissing = (message: string): boolean =>
|
||||||
|
/monitoring session not found/i.test(String(message || ""));
|
||||||
|
|
||||||
|
const createEmptySession = (
|
||||||
|
connectionId: string,
|
||||||
|
providerMode: JVMMonitoringProviderMode,
|
||||||
|
): JVMMonitoringSessionState => ({
|
||||||
|
connectionId,
|
||||||
|
providerMode,
|
||||||
|
running: false,
|
||||||
|
points: [],
|
||||||
|
recentGcEvents: [],
|
||||||
|
availableMetrics: [],
|
||||||
|
missingMetrics: [],
|
||||||
|
providerWarnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeMonitoringSession = (
|
||||||
|
payload: any,
|
||||||
|
connectionId: string,
|
||||||
|
providerMode: JVMMonitoringProviderMode,
|
||||||
|
): JVMMonitoringSessionState => ({
|
||||||
|
connectionId: String(payload?.connectionId || connectionId),
|
||||||
|
providerMode: normalizeMonitoringProviderMode(payload?.providerMode, providerMode),
|
||||||
|
running: payload?.running === true,
|
||||||
|
points: Array.isArray(payload?.points) ? payload.points : [],
|
||||||
|
recentGcEvents: Array.isArray(payload?.recentGcEvents) ? payload.recentGcEvents : [],
|
||||||
|
availableMetrics: Array.isArray(payload?.availableMetrics)
|
||||||
|
? payload.availableMetrics
|
||||||
|
: [],
|
||||||
|
missingMetrics: Array.isArray(payload?.missingMetrics) ? payload.missingMetrics : [],
|
||||||
|
providerWarnings: Array.isArray(payload?.providerWarnings)
|
||||||
|
? payload.providerWarnings
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveBackendApp = () =>
|
||||||
|
typeof window === "undefined" ? undefined : (window as any).go?.app?.App;
|
||||||
|
|
||||||
|
const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab }) => {
|
||||||
|
const theme = useStore((state) => state.theme);
|
||||||
|
const connection = useStore((state) =>
|
||||||
|
state.connections.find((item) => item.id === tab.connectionId),
|
||||||
|
);
|
||||||
|
const darkMode = theme === "dark";
|
||||||
|
const providerMode = normalizeMonitoringProviderMode(
|
||||||
|
tab.providerMode,
|
||||||
|
normalizeMonitoringProviderMode(connection?.config.jvm?.preferredMode, "jmx"),
|
||||||
|
);
|
||||||
|
const [session, setSession] = useState<JVMMonitoringSessionState>(() =>
|
||||||
|
createEmptySession(tab.connectionId, providerMode),
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
const [pollSeed, setPollSeed] = useState(0);
|
||||||
|
|
||||||
|
const rpcConnectionConfig = useMemo(() => {
|
||||||
|
if (!connection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildRpcConnectionConfig(connection.config, {
|
||||||
|
database: "",
|
||||||
|
jvm: {
|
||||||
|
...(connection.config.jvm || {}),
|
||||||
|
preferredMode: providerMode,
|
||||||
|
allowedModes: [providerMode],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [connection, providerMode]);
|
||||||
|
|
||||||
|
const latestPoint = useMemo(() => {
|
||||||
|
const points = session.points || [];
|
||||||
|
return points.length > 0 ? points[points.length - 1] : undefined;
|
||||||
|
}, [session.points]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSession(createEmptySession(tab.connectionId, providerMode));
|
||||||
|
}, [tab.connectionId, providerMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connection || !rpcConnectionConfig) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const backendApp = resolveBackendApp();
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (typeof backendApp?.JVMGetMonitoringHistory !== "function") {
|
||||||
|
setError("JVMGetMonitoringHistory 后端方法不可用");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await backendApp.JVMGetMonitoringHistory(
|
||||||
|
rpcConnectionConfig,
|
||||||
|
providerMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.success === false) {
|
||||||
|
const message = String(result?.message || "读取监控历史失败");
|
||||||
|
if (isMonitoringSessionMissing(message)) {
|
||||||
|
setSession(createEmptySession(tab.connectionId, providerMode));
|
||||||
|
setError("");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSession = normalizeMonitoringSession(
|
||||||
|
result?.data,
|
||||||
|
tab.connectionId,
|
||||||
|
providerMode,
|
||||||
|
);
|
||||||
|
setSession(nextSession);
|
||||||
|
setError("");
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (nextSession.running) {
|
||||||
|
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
} catch (fetchError: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(fetchError?.message || "读取监控历史失败");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connection, providerMode, rpcConnectionConfig, tab.connectionId, pollSeed]);
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return <Empty description="连接不存在或已被删除" style={{ marginTop: 80 }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendApp = resolveBackendApp();
|
||||||
|
const availabilityText = buildMonitoringAvailabilityText(session);
|
||||||
|
const modeMeta = resolveJVMModeMeta(providerMode);
|
||||||
|
const emptyState = !session.running && (session.points || []).length === 0;
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
if (!rpcConnectionConfig || typeof backendApp?.JVMStartMonitoring !== "function") {
|
||||||
|
setError("JVMStartMonitoring 后端方法不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await backendApp.JVMStartMonitoring(rpcConnectionConfig);
|
||||||
|
if (result?.success === false) {
|
||||||
|
throw new Error(String(result?.message || "开始监控失败"));
|
||||||
|
}
|
||||||
|
setSession(
|
||||||
|
normalizeMonitoringSession(result?.data, tab.connectionId, providerMode),
|
||||||
|
);
|
||||||
|
setPollSeed((current) => current + 1);
|
||||||
|
} catch (startError: any) {
|
||||||
|
setError(startError?.message || "开始监控失败");
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
if (!rpcConnectionConfig || typeof backendApp?.JVMStopMonitoring !== "function") {
|
||||||
|
setError("JVMStopMonitoring 后端方法不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await backendApp.JVMStopMonitoring(
|
||||||
|
rpcConnectionConfig,
|
||||||
|
providerMode,
|
||||||
|
);
|
||||||
|
if (result?.success === false) {
|
||||||
|
throw new Error(String(result?.message || "停止监控失败"));
|
||||||
|
}
|
||||||
|
setSession((current) => ({ ...current, running: false }));
|
||||||
|
setPollSeed((current) => current + 1);
|
||||||
|
} catch (stopError: any) {
|
||||||
|
setError(stopError?.message || "停止监控失败");
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="jvm-monitoring-dashboard-scroll-shell"
|
||||||
|
data-jvm-monitoring-dashboard-scroll-shell="true"
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
overflowX: "hidden",
|
||||||
|
padding: 20,
|
||||||
|
display: "grid",
|
||||||
|
gap: 16,
|
||||||
|
alignContent: "start",
|
||||||
|
background: darkMode ? "#141414" : "#f5f7fb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card variant="borderless" style={{ borderRadius: 12 }}>
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
size={12}
|
||||||
|
style={{ width: "100%", alignItems: "stretch" }}
|
||||||
|
>
|
||||||
|
<Space size={12} wrap style={{ justifyContent: "space-between" }}>
|
||||||
|
<div>
|
||||||
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
|
<DashboardOutlined style={{ color: "#1677ff", marginRight: 8 }} />
|
||||||
|
JVM 持续监控
|
||||||
|
</Title>
|
||||||
|
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||||
|
<Text strong>{connection.name}</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
{" "}
|
||||||
|
· {connection.config.host}:{connection.config.port}
|
||||||
|
</Text>
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color={modeMeta.color} style={{ marginInlineEnd: 0 }}>
|
||||||
|
{modeMeta.label}
|
||||||
|
</Tag>
|
||||||
|
{session.running ? (
|
||||||
|
<Tag color="green">采样中</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>未运行</Tag>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => setPollSeed((current) => current + 1)}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
{session.running ? (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
type="primary"
|
||||||
|
icon={<PauseCircleOutlined />}
|
||||||
|
loading={actionLoading}
|
||||||
|
onClick={() => void handleStop()}
|
||||||
|
>
|
||||||
|
停止监控
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
loading={actionLoading}
|
||||||
|
onClick={() => void handleStart()}
|
||||||
|
>
|
||||||
|
开始监控
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{(session.missingMetrics?.length || session.providerWarnings?.length) ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="监控能力存在降级"
|
||||||
|
description={availabilityText}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{error ? <Alert type="error" showIcon message={error} /> : null}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{loading && emptyState ? (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{emptyState ? (
|
||||||
|
<div
|
||||||
|
data-jvm-monitoring-content-stack="true"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: 24,
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card variant="borderless" style={{ borderRadius: 12 }}>
|
||||||
|
<Empty
|
||||||
|
description="当前尚未开始持续监控"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
>
|
||||||
|
<Paragraph type="secondary" style={{ maxWidth: 520, margin: "0 auto 16px" }}>
|
||||||
|
点击“开始监控”后,GoNavi 会在当前会话内持续保留该连接的采样结果;切换页签不会停止采样。
|
||||||
|
</Paragraph>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
loading={actionLoading}
|
||||||
|
onClick={() => void handleStart()}
|
||||||
|
>
|
||||||
|
开始监控
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
</Card>
|
||||||
|
<JVMMonitoringCharts
|
||||||
|
points={session.points || []}
|
||||||
|
session={session}
|
||||||
|
darkMode={darkMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
data-jvm-monitoring-content-stack="true"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: 24,
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<JVMMonitoringStatusCards
|
||||||
|
latestPoint={latestPoint}
|
||||||
|
session={session}
|
||||||
|
darkMode={darkMode}
|
||||||
|
/>
|
||||||
|
<JVMMonitoringCharts
|
||||||
|
points={session.points || []}
|
||||||
|
session={session}
|
||||||
|
darkMode={darkMode}
|
||||||
|
/>
|
||||||
|
<JVMMonitoringDetailPanel
|
||||||
|
session={session}
|
||||||
|
latestPoint={latestPoint}
|
||||||
|
darkMode={darkMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JVMMonitoringDashboard;
|
||||||
65
frontend/src/components/JVMOverview.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import JVMOverview from "./JVMOverview";
|
||||||
|
|
||||||
|
vi.mock("../../wailsjs/go/app/App", () => ({
|
||||||
|
JVMProbeCapabilities: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../store", () => ({
|
||||||
|
useStore: (selector: (state: any) => any) =>
|
||||||
|
selector({
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: "conn-jvm-1",
|
||||||
|
name: "orders-jvm",
|
||||||
|
config: {
|
||||||
|
host: "localhost",
|
||||||
|
port: 10990,
|
||||||
|
jvm: {
|
||||||
|
preferredMode: "jmx",
|
||||||
|
allowedModes: ["jmx", "endpoint", "agent"],
|
||||||
|
readOnly: true,
|
||||||
|
environment: "dev",
|
||||||
|
endpoint: {
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: "http://localhost:8080/actuator",
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: "http://localhost:8563",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: "light",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("JVMOverview", () => {
|
||||||
|
it("renders a unified JVM workspace overview shell", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMOverview
|
||||||
|
tab={{
|
||||||
|
id: "tab-jvm-overview",
|
||||||
|
type: "jvm-overview",
|
||||||
|
title: "[orders-jvm] JVM 概览",
|
||||||
|
connectionId: "conn-jvm-1",
|
||||||
|
providerMode: "jmx",
|
||||||
|
} as any}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('data-jvm-workspace-shell="true"');
|
||||||
|
expect(markup).toContain('data-jvm-workspace-hero="true"');
|
||||||
|
expect(markup).toContain("JVM 运行时概览");
|
||||||
|
expect(markup).toContain("连接摘要");
|
||||||
|
expect(markup).toContain("模式能力");
|
||||||
|
expect(markup).toContain("JMX 地址");
|
||||||
|
expect(markup).toContain("Endpoint");
|
||||||
|
expect(markup).toContain("Agent");
|
||||||
|
});
|
||||||
|
});
|
||||||
239
frontend/src/components/JVMOverview.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Empty,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from "antd";
|
||||||
|
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import { JVMProbeCapabilities } from "../../wailsjs/go/app/App";
|
||||||
|
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||||
|
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
|
||||||
|
import type { JVMCapability, TabData } from "../types";
|
||||||
|
import JVMModeBadge from "./jvm/JVMModeBadge";
|
||||||
|
import {
|
||||||
|
getJVMWorkspaceCardStyle,
|
||||||
|
JVMWorkspaceHero,
|
||||||
|
JVMWorkspaceShell,
|
||||||
|
} from "./jvm/JVMWorkspaceLayout";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
|
||||||
|
|
||||||
|
type JVMOverviewProps = {
|
||||||
|
tab: TabData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||||
|
const connection = useStore((state) =>
|
||||||
|
state.connections.find((item) => item.id === tab.connectionId),
|
||||||
|
);
|
||||||
|
const theme = useStore((state) => state.theme);
|
||||||
|
const darkMode = theme === "dark";
|
||||||
|
const providerMode =
|
||||||
|
tab.providerMode || connection?.config.jvm?.preferredMode || "jmx";
|
||||||
|
const readOnly = connection?.config.jvm?.readOnly !== false;
|
||||||
|
const allowedModes = connection?.config.jvm?.allowedModes || [];
|
||||||
|
const [capabilities, setCapabilities] = useState<JVMCapability[]>([]);
|
||||||
|
const [capabilityLoading, setCapabilityLoading] = useState(true);
|
||||||
|
const [capabilityError, setCapabilityError] = useState("");
|
||||||
|
|
||||||
|
const endpointSummary = useMemo(() => {
|
||||||
|
if (!connection?.config.jvm?.endpoint) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const endpoint = connection.config.jvm.endpoint;
|
||||||
|
if (!endpoint.enabled && !endpoint.baseUrl) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return endpoint.baseUrl || "已启用";
|
||||||
|
}, [connection]);
|
||||||
|
|
||||||
|
const agentSummary = useMemo(() => {
|
||||||
|
if (!connection?.config.jvm?.agent) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const agent = connection.config.jvm.agent;
|
||||||
|
if (!agent.enabled && !agent.baseUrl) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return agent.baseUrl || "已启用";
|
||||||
|
}, [connection]);
|
||||||
|
|
||||||
|
const allowedModeSummary = useMemo(() => {
|
||||||
|
const items = allowedModes.length > 0 ? allowedModes : ["jmx"];
|
||||||
|
return items.map((item) => resolveJVMModeMeta(item).label).join("、");
|
||||||
|
}, [allowedModes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connection) {
|
||||||
|
setCapabilities([]);
|
||||||
|
setCapabilityError("连接不存在或已被删除");
|
||||||
|
setCapabilityLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const loadCapabilities = async () => {
|
||||||
|
setCapabilityLoading(true);
|
||||||
|
setCapabilityError("");
|
||||||
|
try {
|
||||||
|
const result = await JVMProbeCapabilities(
|
||||||
|
buildRpcConnectionConfig(connection.config, { database: "" }) as any,
|
||||||
|
);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result?.success === false) {
|
||||||
|
setCapabilities([]);
|
||||||
|
setCapabilityError(
|
||||||
|
String(result?.message || "读取 JVM 模式能力失败"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCapabilities(
|
||||||
|
Array.isArray(result?.data) ? (result.data as JVMCapability[]) : [],
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCapabilities([]);
|
||||||
|
setCapabilityError(error?.message || "读取 JVM 模式能力失败");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCapabilityLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadCapabilities();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [connection]);
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return (
|
||||||
|
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jmxHost = connection.config.jvm?.jmx?.host || connection.config.host;
|
||||||
|
const jmxPort = connection.config.jvm?.jmx?.port || connection.config.port;
|
||||||
|
|
||||||
|
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JVMWorkspaceShell darkMode={darkMode}>
|
||||||
|
<JVMWorkspaceHero
|
||||||
|
darkMode={darkMode}
|
||||||
|
eyebrow="JVM Runtime"
|
||||||
|
title="JVM 运行时概览"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Text strong>{connection.name}</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
{" "}
|
||||||
|
· {connection.config.host}:{connection.config.port}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
<JVMModeBadge mode={providerMode} />
|
||||||
|
<Tag color={readOnly ? "blue" : "red"}>
|
||||||
|
{readOnly ? "只读连接" : "可写连接"}
|
||||||
|
</Tag>
|
||||||
|
<Tag>{connection.config.jvm?.environment || "dev"}</Tag>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card title="连接摘要" variant="borderless" style={cardStyle}>
|
||||||
|
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
|
||||||
|
<Descriptions.Item label="当前模式">
|
||||||
|
{resolveJVMModeMeta(providerMode).label}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="允许模式">
|
||||||
|
{allowedModeSummary}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="JMX 地址">{`${jmxHost}:${jmxPort}`}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Endpoint">
|
||||||
|
{endpointSummary || "未配置"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Agent">
|
||||||
|
{agentSummary || "未配置"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="资源浏览">
|
||||||
|
{"通过侧边栏展开模式节点后懒加载"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="模式能力" variant="borderless" style={cardStyle}>
|
||||||
|
{capabilityLoading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 3 }} />
|
||||||
|
) : capabilityError ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="读取 JVM 模式能力失败"
|
||||||
|
description={
|
||||||
|
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||||
|
{capabilityError}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : capabilities.length === 0 ? (
|
||||||
|
<Empty description="暂无模式能力数据" />
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||||
|
{capabilities.map((capability) => (
|
||||||
|
<div
|
||||||
|
key={capability.mode}
|
||||||
|
style={{
|
||||||
|
border: "1px solid rgba(5, 5, 5, 0.08)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<JVMModeBadge mode={capability.mode} />
|
||||||
|
<Tag color={capability.canBrowse ? "green" : "default"}>
|
||||||
|
{capability.canBrowse ? "可浏览" : "不可浏览"}
|
||||||
|
</Tag>
|
||||||
|
<Tag color={capability.canWrite ? "red" : "blue"}>
|
||||||
|
{capability.canWrite ? "可写" : "只读"}
|
||||||
|
</Tag>
|
||||||
|
<Tag color={capability.canPreview ? "gold" : "default"}>
|
||||||
|
{capability.canPreview ? "支持预览" : "不支持预览"}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
{capability.reason ? (
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginTop: 8,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{capability.reason}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</JVMWorkspaceShell>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JVMOverview;
|
||||||
118
frontend/src/components/JVMResourceBrowser.layout.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import JVMResourceBrowser from './JVMResourceBrowser';
|
||||||
|
|
||||||
|
vi.mock('@monaco-editor/react', () => ({
|
||||||
|
default: ({ language, value }: { language?: string; value?: string }) => (
|
||||||
|
<div data-monaco-editor-mock="true" data-language={language}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../store', () => ({
|
||||||
|
useStore: (selector: (state: any) => any) => selector({
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'conn-jvm-1',
|
||||||
|
name: 'localhost',
|
||||||
|
config: {
|
||||||
|
host: 'localhost',
|
||||||
|
jvm: {
|
||||||
|
preferredMode: 'jmx',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-jvm-2',
|
||||||
|
name: 'writable-jvm',
|
||||||
|
config: {
|
||||||
|
host: 'localhost',
|
||||||
|
jvm: {
|
||||||
|
preferredMode: 'jmx',
|
||||||
|
readOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addTab: vi.fn(),
|
||||||
|
aiPanelVisible: false,
|
||||||
|
setAIPanelVisible: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./jvm/JVMModeBadge', () => ({
|
||||||
|
default: ({ mode }: { mode: string }) => <span>{mode}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./jvm/JVMChangePreviewModal', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('JVMResourceBrowser layout', () => {
|
||||||
|
it('renders a dedicated vertical scroll shell for tall snapshot content', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMResourceBrowser
|
||||||
|
tab={{
|
||||||
|
id: 'tab-jvm-resource-1',
|
||||||
|
type: 'jvm-resource',
|
||||||
|
title: '[localhost] JVM 资源',
|
||||||
|
connectionId: 'conn-jvm-1',
|
||||||
|
providerMode: 'jmx',
|
||||||
|
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
|
||||||
|
resourceKind: 'mbean',
|
||||||
|
} as any}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('data-jvm-resource-browser-scroll-shell="true"');
|
||||||
|
expect(markup).toContain('data-jvm-workspace-shell="true"');
|
||||||
|
expect(markup).toContain('data-jvm-workspace-hero="true"');
|
||||||
|
expect(markup).toContain('data-jvm-resource-workbench="true"');
|
||||||
|
expect(markup).toContain('height:100%');
|
||||||
|
expect(markup).toContain('overflow-y:auto');
|
||||||
|
expect(markup).toContain('grid-template-columns:minmax(0, 1fr) minmax(360px, 440px)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the draft action field with a Chinese label', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMResourceBrowser
|
||||||
|
tab={{
|
||||||
|
id: 'tab-jvm-resource-2',
|
||||||
|
type: 'jvm-resource',
|
||||||
|
title: '[localhost] JVM 资源',
|
||||||
|
connectionId: 'conn-jvm-2',
|
||||||
|
providerMode: 'jmx',
|
||||||
|
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
|
||||||
|
resourceKind: 'mbean',
|
||||||
|
} as any}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('动作');
|
||||||
|
expect(markup).not.toContain('>Action<');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the change draft form entirely for read-only JVM connections', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<JVMResourceBrowser
|
||||||
|
tab={{
|
||||||
|
id: 'tab-jvm-resource-3',
|
||||||
|
type: 'jvm-resource',
|
||||||
|
title: '[localhost] JVM 资源',
|
||||||
|
connectionId: 'conn-jvm-1',
|
||||||
|
providerMode: 'jmx',
|
||||||
|
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
|
||||||
|
resourceKind: 'mbean',
|
||||||
|
} as any}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).not.toContain('变更草稿');
|
||||||
|
expect(markup).not.toContain('预览变更');
|
||||||
|
expect(markup).not.toContain('Payload(JSON)');
|
||||||
|
});
|
||||||
|
});
|
||||||
946
frontend/src/components/JVMResourceBrowser.tsx
Normal file
@@ -0,0 +1,946 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import Editor from "@monaco-editor/react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
FileSearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import type {
|
||||||
|
JVMActionDefinition,
|
||||||
|
JVMApplyResult,
|
||||||
|
JVMChangePreview,
|
||||||
|
JVMChangeRequest,
|
||||||
|
JVMAIPlanContext,
|
||||||
|
JVMValueSnapshot,
|
||||||
|
SavedConnection,
|
||||||
|
TabData,
|
||||||
|
} from "../types";
|
||||||
|
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||||
|
import {
|
||||||
|
buildJVMChangeDraftFromAIPlan,
|
||||||
|
buildJVMAIPlanPrompt,
|
||||||
|
matchesJVMAIPlanTargetTab,
|
||||||
|
type JVMAIChangeDraft,
|
||||||
|
type JVMAIChangePlan,
|
||||||
|
} from "../utils/jvmAiPlan";
|
||||||
|
import {
|
||||||
|
estimateJVMResourceEditorHeight,
|
||||||
|
formatJVMActionDisplayText,
|
||||||
|
formatJVMActionSummary,
|
||||||
|
resolveJVMActionDisplay,
|
||||||
|
resolveJVMValueEditorLanguage,
|
||||||
|
} from "../utils/jvmResourcePresentation";
|
||||||
|
import { buildJVMTabTitle } from "../utils/jvmRuntimePresentation";
|
||||||
|
import JVMModeBadge from "./jvm/JVMModeBadge";
|
||||||
|
import JVMChangePreviewModal from "./jvm/JVMChangePreviewModal";
|
||||||
|
import {
|
||||||
|
getJVMWorkspaceCardStyle,
|
||||||
|
JVMWorkspaceHero,
|
||||||
|
JVMWorkspaceShell,
|
||||||
|
} from "./jvm/JVMWorkspaceLayout";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const DEFAULT_PAYLOAD_TEXT = "{\n \n}";
|
||||||
|
|
||||||
|
type JVMResourceBrowserProps = {
|
||||||
|
tab: TabData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildJVMRuntimeConfig = (
|
||||||
|
connection: SavedConnection,
|
||||||
|
providerMode: string,
|
||||||
|
) => {
|
||||||
|
const sourceJVM = connection.config.jvm || {};
|
||||||
|
return buildRpcConnectionConfig(connection.config, {
|
||||||
|
jvm: {
|
||||||
|
...sourceJVM,
|
||||||
|
preferredMode: providerMode,
|
||||||
|
allowedModes: [providerMode],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshotBlockStyle = (background: string): React.CSSProperties => ({
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: 8,
|
||||||
|
background,
|
||||||
|
overflow: "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatValue = (value: unknown): string => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDraftPayload = (draft: JVMAIChangeDraft): string => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(draft.payload ?? {}, null, 2);
|
||||||
|
} catch {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildActionPayloadTemplate = (
|
||||||
|
definition?: JVMActionDefinition | null,
|
||||||
|
): string => {
|
||||||
|
if (definition?.payloadExample) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(definition.payloadExample, null, 2);
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_PAYLOAD_TEXT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_PAYLOAD_TEXT;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDefaultAction = (
|
||||||
|
actions: JVMActionDefinition[] | undefined,
|
||||||
|
providerMode: "jmx" | "endpoint" | "agent",
|
||||||
|
): string => {
|
||||||
|
if (actions && actions.length > 0) {
|
||||||
|
return String(actions[0].action || "").trim() || "put";
|
||||||
|
}
|
||||||
|
if (providerMode === "jmx") {
|
||||||
|
return "set";
|
||||||
|
}
|
||||||
|
return "put";
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePreviewResult = (value: any): JVMChangePreview | null => {
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
typeof value.allowed === "boolean"
|
||||||
|
) {
|
||||||
|
return value as JVMChangePreview;
|
||||||
|
}
|
||||||
|
if (value?.data && typeof value.data.allowed === "boolean") {
|
||||||
|
return value.data as JVMChangePreview;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeApplyResult = (value: any): JVMApplyResult | null => {
|
||||||
|
if (value && typeof value === "object" && typeof value.status === "string") {
|
||||||
|
return value as JVMApplyResult;
|
||||||
|
}
|
||||||
|
if (value?.data && typeof value.data.status === "string") {
|
||||||
|
return value.data as JVMApplyResult;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||||
|
const connection = useStore((state) =>
|
||||||
|
state.connections.find((item) => item.id === tab.connectionId),
|
||||||
|
);
|
||||||
|
const addTab = useStore((state) => state.addTab);
|
||||||
|
const theme = useStore((state) => state.theme);
|
||||||
|
const darkMode = theme === "dark";
|
||||||
|
const providerMode = (tab.providerMode ||
|
||||||
|
connection?.config.jvm?.preferredMode ||
|
||||||
|
"jmx") as "jmx" | "endpoint" | "agent";
|
||||||
|
const resourcePath = String(tab.resourcePath || "").trim();
|
||||||
|
const readOnly = connection?.config.jvm?.readOnly !== false;
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [snapshot, setSnapshot] = useState<JVMValueSnapshot | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [action, setAction] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [payloadText, setPayloadText] = useState(DEFAULT_PAYLOAD_TEXT);
|
||||||
|
const [draftSource, setDraftSource] = useState<"manual" | "ai-plan">(
|
||||||
|
"manual",
|
||||||
|
);
|
||||||
|
const [draftResourceId, setDraftResourceId] = useState("");
|
||||||
|
const [draftError, setDraftError] = useState("");
|
||||||
|
const [applyMessage, setApplyMessage] = useState("");
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const [previewResult, setPreviewResult] = useState<JVMChangePreview | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [applyLoading, setApplyLoading] = useState(false);
|
||||||
|
|
||||||
|
const displayValue = useMemo(() => formatValue(snapshot?.value), [snapshot]);
|
||||||
|
const displayLanguage = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveJVMValueEditorLanguage(snapshot?.format || "", snapshot?.value),
|
||||||
|
[snapshot?.format, snapshot?.value],
|
||||||
|
);
|
||||||
|
const metadataText = useMemo(
|
||||||
|
() =>
|
||||||
|
snapshot?.metadata && Object.keys(snapshot.metadata).length > 0
|
||||||
|
? JSON.stringify(snapshot.metadata, null, 2)
|
||||||
|
: "",
|
||||||
|
[snapshot?.metadata],
|
||||||
|
);
|
||||||
|
const metadataLanguage = useMemo(
|
||||||
|
() => resolveJVMValueEditorLanguage("json", snapshot?.metadata),
|
||||||
|
[snapshot?.metadata],
|
||||||
|
);
|
||||||
|
const supportedActions = useMemo(() => {
|
||||||
|
if (!Array.isArray(snapshot?.supportedActions)) {
|
||||||
|
return [] as JVMActionDefinition[];
|
||||||
|
}
|
||||||
|
return snapshot.supportedActions.filter(
|
||||||
|
(item) => !!String(item?.action || "").trim(),
|
||||||
|
);
|
||||||
|
}, [snapshot]);
|
||||||
|
const selectedActionDefinition = useMemo(
|
||||||
|
() => supportedActions.find((item) => item.action === action) || null,
|
||||||
|
[action, supportedActions],
|
||||||
|
);
|
||||||
|
const selectedActionDisplay = useMemo(
|
||||||
|
() => resolveJVMActionDisplay(selectedActionDefinition || action),
|
||||||
|
[action, selectedActionDefinition],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadSnapshot = async () => {
|
||||||
|
if (!connection) {
|
||||||
|
setLoading(false);
|
||||||
|
setSnapshot(null);
|
||||||
|
setError("连接不存在或已被删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resourcePath) {
|
||||||
|
setLoading(false);
|
||||||
|
setSnapshot(null);
|
||||||
|
setError("资源路径为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendApp = (window as any).go?.app?.App;
|
||||||
|
if (typeof backendApp?.JVMGetValue !== "function") {
|
||||||
|
setLoading(false);
|
||||||
|
setSnapshot(null);
|
||||||
|
setError("JVMGetValue 后端方法不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await backendApp.JVMGetValue(
|
||||||
|
buildJVMRuntimeConfig(connection, providerMode),
|
||||||
|
resourcePath,
|
||||||
|
);
|
||||||
|
if (!result?.success) {
|
||||||
|
setSnapshot(null);
|
||||||
|
setError(String(result?.message || "读取 JVM 资源失败"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSnapshot((result.data || null) as JVMValueSnapshot | null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setSnapshot(null);
|
||||||
|
setError(err?.message || "读取 JVM 资源失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSnapshot();
|
||||||
|
}, [connection, providerMode, resourcePath, tab.connectionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAction("");
|
||||||
|
setReason("");
|
||||||
|
setPayloadText(DEFAULT_PAYLOAD_TEXT);
|
||||||
|
setDraftSource("manual");
|
||||||
|
setDraftResourceId("");
|
||||||
|
setDraftError("");
|
||||||
|
setApplyMessage("");
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setPreviewResult(null);
|
||||||
|
}, [providerMode, resourcePath, tab.connectionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (action.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextAction = resolveDefaultAction(supportedActions, providerMode);
|
||||||
|
setAction(nextAction);
|
||||||
|
const nextDefinition = supportedActions.find(
|
||||||
|
(item) => item.action === nextAction,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
String(payloadText || "").trim() === "" ||
|
||||||
|
payloadText === DEFAULT_PAYLOAD_TEXT
|
||||||
|
) {
|
||||||
|
setPayloadText(buildActionPayloadTemplate(nextDefinition));
|
||||||
|
}
|
||||||
|
}, [action, payloadText, providerMode, supportedActions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent).detail as
|
||||||
|
| {
|
||||||
|
plan?: JVMAIChangePlan;
|
||||||
|
targetTabId?: string;
|
||||||
|
connectionId?: string;
|
||||||
|
providerMode?: JVMAIPlanContext["providerMode"];
|
||||||
|
resourcePath?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const plan = detail?.plan;
|
||||||
|
if (!plan || (detail?.targetTabId && detail.targetTabId !== tab.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const planContext =
|
||||||
|
detail?.targetTabId &&
|
||||||
|
detail?.connectionId &&
|
||||||
|
detail?.providerMode &&
|
||||||
|
detail?.resourcePath
|
||||||
|
? {
|
||||||
|
tabId: detail.targetTabId,
|
||||||
|
connectionId: detail.connectionId,
|
||||||
|
providerMode: detail.providerMode,
|
||||||
|
resourcePath: detail.resourcePath,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!planContext) {
|
||||||
|
setDraftError(
|
||||||
|
"AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。",
|
||||||
|
);
|
||||||
|
setApplyMessage("");
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setPreviewResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchesJVMAIPlanTargetTab(tab, planContext)) {
|
||||||
|
setDraftError(
|
||||||
|
"当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。",
|
||||||
|
);
|
||||||
|
setApplyMessage("");
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setPreviewResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let draftFromPlan: JVMAIChangeDraft;
|
||||||
|
try {
|
||||||
|
draftFromPlan = buildJVMChangeDraftFromAIPlan(plan);
|
||||||
|
} catch (err: any) {
|
||||||
|
setDraftError(err?.message || "AI 计划暂时无法转换为 JVM 预览草稿");
|
||||||
|
setApplyMessage("");
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setPreviewResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraftResourceId(draftFromPlan.resourceId);
|
||||||
|
setAction(draftFromPlan.action);
|
||||||
|
setReason(draftFromPlan.reason);
|
||||||
|
setPayloadText(formatDraftPayload(draftFromPlan));
|
||||||
|
setDraftSource(draftFromPlan.source || "ai-plan");
|
||||||
|
setDraftError("");
|
||||||
|
setApplyMessage(
|
||||||
|
`已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`,
|
||||||
|
);
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setPreviewResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
"gonavi:jvm-apply-ai-plan",
|
||||||
|
handler as EventListener,
|
||||||
|
);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(
|
||||||
|
"gonavi:jvm-apply-ai-plan",
|
||||||
|
handler as EventListener,
|
||||||
|
);
|
||||||
|
}, [resourcePath, tab.id]);
|
||||||
|
|
||||||
|
const handleSelectAction = (
|
||||||
|
nextAction: string,
|
||||||
|
definition?: JVMActionDefinition | null,
|
||||||
|
) => {
|
||||||
|
const normalized = String(nextAction || "").trim();
|
||||||
|
setAction(normalized);
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentPayload = String(payloadText || "").trim();
|
||||||
|
if (
|
||||||
|
!currentPayload ||
|
||||||
|
currentPayload === "{}" ||
|
||||||
|
payloadText === DEFAULT_PAYLOAD_TEXT
|
||||||
|
) {
|
||||||
|
setPayloadText(buildActionPayloadTemplate(definition));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDraftPlan = (): JVMChangeRequest => {
|
||||||
|
const trimmedAction = String(action || "").trim() || "put";
|
||||||
|
const trimmedReason = String(reason || "").trim();
|
||||||
|
if (!trimmedReason) {
|
||||||
|
throw new Error("请填写变更原因");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPayload = String(payloadText || "").trim();
|
||||||
|
let payload: Record<string, any> = {};
|
||||||
|
if (rawPayload) {
|
||||||
|
const parsed = JSON.parse(rawPayload);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error("Payload 必须是 JSON 对象");
|
||||||
|
}
|
||||||
|
payload = parsed as Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceId = String(
|
||||||
|
draftResourceId || snapshot?.resourceId || resourcePath,
|
||||||
|
).trim();
|
||||||
|
if (!resourceId) {
|
||||||
|
throw new Error("资源 ID 为空,无法生成变更草稿");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerMode,
|
||||||
|
resourceId,
|
||||||
|
action: trimmedAction,
|
||||||
|
reason: trimmedReason,
|
||||||
|
source: draftSource,
|
||||||
|
expectedVersion: snapshot?.version || undefined,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAudit = () => {
|
||||||
|
if (!connection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addTab({
|
||||||
|
id: `jvm-audit-${connection.id}-${providerMode}`,
|
||||||
|
title: buildJVMTabTitle(connection.name, "audit", providerMode),
|
||||||
|
type: "jvm-audit",
|
||||||
|
connectionId: connection.id,
|
||||||
|
providerMode,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAskAIForPlan = () => {
|
||||||
|
if (!connection) {
|
||||||
|
setDraftError("连接不存在或已被删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = buildJVMAIPlanPrompt({
|
||||||
|
connectionName: connection.name,
|
||||||
|
host: connection.config.host,
|
||||||
|
providerMode,
|
||||||
|
resourcePath,
|
||||||
|
readOnly,
|
||||||
|
environment: connection.config.jvm?.environment,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useStore.getState();
|
||||||
|
const wasClosed = !store.aiPanelVisible;
|
||||||
|
if (wasClosed) {
|
||||||
|
store.setAIPanelVisible(true);
|
||||||
|
}
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("gonavi:ai:inject-prompt", { detail: { prompt } }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
wasClosed ? 350 : 0,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!connection) {
|
||||||
|
setDraftError("连接不存在或已被删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendApp = (window as any).go?.app?.App;
|
||||||
|
if (typeof backendApp?.JVMPreviewChange !== "function") {
|
||||||
|
setDraftError("JVMPreviewChange 后端方法不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let draftPlan: JVMChangeRequest;
|
||||||
|
try {
|
||||||
|
draftPlan = buildDraftPlan();
|
||||||
|
} catch (err: any) {
|
||||||
|
setDraftError(err?.message || "变更草稿不合法");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewLoading(true);
|
||||||
|
setDraftError("");
|
||||||
|
setApplyMessage("");
|
||||||
|
try {
|
||||||
|
const result = await backendApp.JVMPreviewChange(
|
||||||
|
buildJVMRuntimeConfig(connection, providerMode),
|
||||||
|
draftPlan,
|
||||||
|
);
|
||||||
|
if (result?.success === false) {
|
||||||
|
setPreviewResult(null);
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setDraftError(String(result?.message || "预览 JVM 变更失败"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = normalizePreviewResult(result);
|
||||||
|
if (!preview) {
|
||||||
|
setPreviewResult(null);
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setDraftError("预览结果格式不正确");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewResult(preview);
|
||||||
|
setPreviewOpen(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setPreviewResult(null);
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setDraftError(err?.message || "预览 JVM 变更失败");
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (!connection) {
|
||||||
|
setDraftError("连接不存在或已被删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendApp = (window as any).go?.app?.App;
|
||||||
|
if (typeof backendApp?.JVMApplyChange !== "function") {
|
||||||
|
setDraftError("JVMApplyChange 后端方法不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let draftPlan: JVMChangeRequest;
|
||||||
|
try {
|
||||||
|
draftPlan = buildDraftPlan();
|
||||||
|
} catch (err: any) {
|
||||||
|
setDraftError(err?.message || "变更草稿不合法");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setApplyLoading(true);
|
||||||
|
setDraftError("");
|
||||||
|
setApplyMessage("");
|
||||||
|
try {
|
||||||
|
const result = await backendApp.JVMApplyChange(
|
||||||
|
buildJVMRuntimeConfig(connection, providerMode),
|
||||||
|
draftPlan,
|
||||||
|
);
|
||||||
|
if (result?.success === false) {
|
||||||
|
setDraftError(String(result?.message || "执行 JVM 变更失败"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyResult = normalizeApplyResult(result);
|
||||||
|
if (applyResult?.updatedValue) {
|
||||||
|
setSnapshot(applyResult.updatedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setPreviewResult(null);
|
||||||
|
setApplyMessage(
|
||||||
|
applyResult?.message || result?.message || "JVM 变更已执行",
|
||||||
|
);
|
||||||
|
await loadSnapshot();
|
||||||
|
} catch (err: any) {
|
||||||
|
setDraftError(err?.message || "执行 JVM 变更失败");
|
||||||
|
} finally {
|
||||||
|
setApplyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return (
|
||||||
|
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
.jvm-resource-browser-scroll-shell {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.jvm-resource-browser-scroll-shell::-webkit-scrollbar,
|
||||||
|
.jvm-resource-browser-code-block::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
.jvm-resource-browser-scroll-shell::-webkit-scrollbar-thumb,
|
||||||
|
.jvm-resource-browser-code-block::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.jvm-resource-browser-scroll-shell::-webkit-scrollbar-track,
|
||||||
|
.jvm-resource-browser-code-block::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.jvm-resource-workbench {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<JVMWorkspaceShell
|
||||||
|
darkMode={darkMode}
|
||||||
|
className="jvm-resource-browser-scroll-shell"
|
||||||
|
data-jvm-resource-browser-scroll-shell="true"
|
||||||
|
>
|
||||||
|
<JVMWorkspaceHero
|
||||||
|
darkMode={darkMode}
|
||||||
|
eyebrow="JVM Resource"
|
||||||
|
title="JVM 资源工作台"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Text strong>{connection.name}</Text>
|
||||||
|
<Text type="secondary"> · {resourcePath || "-"}</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
<JVMModeBadge mode={providerMode} />
|
||||||
|
<Tag color={readOnly ? "blue" : "red"}>
|
||||||
|
{readOnly ? "只读连接" : "可写连接"}
|
||||||
|
</Tag>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => void loadSnapshot()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FileSearchOutlined />}
|
||||||
|
onClick={handleOpenAudit}
|
||||||
|
>
|
||||||
|
审计记录
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<RobotOutlined />}
|
||||||
|
onClick={handleAskAIForPlan}
|
||||||
|
>
|
||||||
|
AI 生成计划
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="jvm-resource-workbench"
|
||||||
|
data-jvm-resource-workbench="true"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "minmax(0, 1fr) minmax(360px, 440px)",
|
||||||
|
gap: 18,
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
title="资源快照"
|
||||||
|
variant="borderless"
|
||||||
|
style={{
|
||||||
|
...cardStyle,
|
||||||
|
gridColumn: readOnly ? "1 / -1" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 6 }} />
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||||
|
{error ? <Alert type="error" showIcon message={error} /> : null}
|
||||||
|
{snapshot ? (
|
||||||
|
<>
|
||||||
|
<Descriptions
|
||||||
|
column={1}
|
||||||
|
size="small"
|
||||||
|
styles={DESCRIPTION_STYLES}
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="资源 ID">
|
||||||
|
{snapshot.resourceId || "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="资源类型">
|
||||||
|
{snapshot.kind || tab.resourceKind || "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="格式">
|
||||||
|
{snapshot.format || "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="版本">
|
||||||
|
{snapshot.version || "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="可用动作">
|
||||||
|
{formatJVMActionSummary(supportedActions)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
{snapshot.description ? (
|
||||||
|
<Text type="secondary">{snapshot.description}</Text>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ display: "block", marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
资源值
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
className="jvm-resource-browser-code-block"
|
||||||
|
style={{
|
||||||
|
...snapshotBlockStyle("rgba(0, 0, 0, 0.04)"),
|
||||||
|
height: estimateJVMResourceEditorHeight(displayValue),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language={displayLanguage}
|
||||||
|
theme={
|
||||||
|
darkMode ? "transparent-dark" : "transparent-light"
|
||||||
|
}
|
||||||
|
value={displayValue}
|
||||||
|
options={{
|
||||||
|
readOnly: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
lineNumbers: "on",
|
||||||
|
wordWrap: "on",
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
folding: true,
|
||||||
|
renderValidationDecorations: "off",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{metadataText ? (
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ display: "block", marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
元数据
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
className="jvm-resource-browser-code-block"
|
||||||
|
style={{
|
||||||
|
...snapshotBlockStyle("rgba(0, 0, 0, 0.03)"),
|
||||||
|
height:
|
||||||
|
estimateJVMResourceEditorHeight(metadataText),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language={metadataLanguage}
|
||||||
|
theme={
|
||||||
|
darkMode
|
||||||
|
? "transparent-dark"
|
||||||
|
: "transparent-light"
|
||||||
|
}
|
||||||
|
value={metadataText}
|
||||||
|
options={{
|
||||||
|
readOnly: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
lineNumbers: "on",
|
||||||
|
wordWrap: "on",
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
folding: true,
|
||||||
|
renderValidationDecorations: "off",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : error ? null : (
|
||||||
|
<Empty description="暂无资源数据" />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!readOnly ? (
|
||||||
|
<Card title="变更草稿" variant="borderless" style={cardStyle}>
|
||||||
|
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||||
|
{draftError ? (
|
||||||
|
<Alert type="error" showIcon message={draftError} />
|
||||||
|
) : null}
|
||||||
|
{applyMessage ? (
|
||||||
|
<Alert type="success" showIcon message={applyMessage} />
|
||||||
|
) : null}
|
||||||
|
<Descriptions
|
||||||
|
column={1}
|
||||||
|
size="small"
|
||||||
|
styles={DESCRIPTION_STYLES}
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="资源路径">
|
||||||
|
{resourcePath || "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="目标资源">
|
||||||
|
{draftResourceId || resourcePath || "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="资源版本">
|
||||||
|
{snapshot?.version || "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="草稿来源">
|
||||||
|
{draftSource === "ai-plan" ? "AI 辅助草稿" : "手工编辑"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
{supportedActions.length > 0 ? (
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
size={8}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Text strong>资源支持动作</Text>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
{supportedActions.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.action}
|
||||||
|
size="small"
|
||||||
|
type={action === item.action ? "primary" : "default"}
|
||||||
|
danger={item.dangerous}
|
||||||
|
onClick={() => handleSelectAction(item.action, item)}
|
||||||
|
>
|
||||||
|
{resolveJVMActionDisplay(item).label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
{selectedActionDisplay.description ? (
|
||||||
|
<Text type="secondary">
|
||||||
|
{selectedActionDisplay.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{selectedActionDefinition?.payloadFields?.length ? (
|
||||||
|
<Text type="secondary">
|
||||||
|
Payload 字段:
|
||||||
|
{selectedActionDefinition.payloadFields
|
||||||
|
.map(
|
||||||
|
(field) =>
|
||||||
|
`${field.name}${field.required ? "(必填)" : ""}`,
|
||||||
|
)
|
||||||
|
.join("、")}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||||
|
<Text strong>动作</Text>
|
||||||
|
<Input
|
||||||
|
value={action}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleSelectAction(
|
||||||
|
event.target.value,
|
||||||
|
selectedActionDefinition,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
providerMode === "jmx"
|
||||||
|
? "例如 set 或 invoke"
|
||||||
|
: "例如 put / clear / evict"
|
||||||
|
}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
{action ? (
|
||||||
|
<Text type="secondary">
|
||||||
|
当前动作:
|
||||||
|
{formatJVMActionDisplayText(selectedActionDisplay)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||||
|
<Text strong>变更原因</Text>
|
||||||
|
<Input
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
placeholder="填写本次 JVM 资源变更原因"
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||||
|
<Text strong>Payload(JSON)</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
需要输入 JSON 对象,预览和执行都会直接使用这份 payload。
|
||||||
|
{selectedActionDefinition?.payloadExample
|
||||||
|
? " 已按当前动作填充推荐模板。"
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
<TextArea
|
||||||
|
value={payloadText}
|
||||||
|
onChange={(event) => setPayloadText(event.target.value)}
|
||||||
|
autoSize={{ minRows: 8, maxRows: 18 }}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space size={12} wrap>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={previewLoading}
|
||||||
|
onClick={() => void handlePreview()}
|
||||||
|
>
|
||||||
|
预览变更
|
||||||
|
</Button>
|
||||||
|
<Button icon={<RobotOutlined />} onClick={handleAskAIForPlan}>
|
||||||
|
让 AI 生成计划
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</JVMWorkspaceShell>
|
||||||
|
|
||||||
|
<JVMChangePreviewModal
|
||||||
|
open={previewOpen}
|
||||||
|
preview={previewResult}
|
||||||
|
applying={applyLoading}
|
||||||
|
onCancel={() => {
|
||||||
|
if (applyLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewOpen(false);
|
||||||
|
}}
|
||||||
|
onConfirm={() => void handleApply()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JVMResourceBrowser;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
import { Table, Tag, Button, Tooltip } from 'antd';
|
import { Table, Tag, Button, Tooltip, Empty } from 'antd';
|
||||||
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
|
import { ClearOutlined, CloseOutlined, BugOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||||
|
|
||||||
interface LogPanelProps {
|
interface LogPanelProps {
|
||||||
height: number;
|
height: number;
|
||||||
@@ -16,7 +16,8 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
|||||||
const theme = useStore(state => state.theme);
|
const theme = useStore(state => state.theme);
|
||||||
const appearance = useStore(state => state.appearance);
|
const appearance = useStore(state => state.appearance);
|
||||||
const darkMode = theme === 'dark';
|
const darkMode = theme === 'dark';
|
||||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||||
|
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||||
|
|
||||||
// Background Helper
|
// Background Helper
|
||||||
const getBg = (darkHex: string) => {
|
const getBg = (darkHex: string) => {
|
||||||
@@ -27,24 +28,40 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
|||||||
const b = parseInt(hex.substring(4, 6), 16);
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||||
};
|
};
|
||||||
const bgMain = getBg('#1f1f1f');
|
const bgMain = getBg('#1d1d1d');
|
||||||
const bgToolbar = getBg('#2a2a2a');
|
const shellOpacity = darkMode ? Math.max(0.18, opacity * 0.82) : Math.max(0.28, opacity * 0.92);
|
||||||
const logScrollbarThumb = darkMode ? 'rgba(255, 255, 255, 0.34)' : 'rgba(0, 0, 0, 0.26)';
|
const shellOpacityStrong = darkMode ? Math.max(0.22, opacity * 0.9) : Math.max(0.34, opacity * 0.96);
|
||||||
const logScrollbarThumbHover = darkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.36)';
|
const panelDividerColor = darkMode
|
||||||
|
? `rgba(255,255,255,${Math.max(0.04, opacity * 0.10)})`
|
||||||
|
: `rgba(0,0,0,${Math.max(0.04, opacity * 0.08)})`;
|
||||||
|
const panelMutedTextColor = darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(0,0,0,0.58)';
|
||||||
|
const panelShellBg = darkMode
|
||||||
|
? `linear-gradient(180deg, rgba(15,20,30,${shellOpacity}) 0%, rgba(9,13,22,${shellOpacityStrong}) 100%)`
|
||||||
|
: `linear-gradient(180deg, rgba(255,255,255,${shellOpacityStrong}) 0%, rgba(246,248,252,${shellOpacity}) 100%)`;
|
||||||
|
const panelAccentColor = darkMode ? '#ffd666' : '#1677ff';
|
||||||
|
const panelShadow = darkMode
|
||||||
|
? `0 12px 28px rgba(0,0,0,${Math.max(0.05, opacity * 0.18)})`
|
||||||
|
: `0 12px 24px rgba(15,23,42,${Math.max(0.02, opacity * 0.08)})`;
|
||||||
|
const logScrollbarThumb = darkMode
|
||||||
|
? `rgba(255, 255, 255, ${Math.max(0.18, opacity * 0.34)})`
|
||||||
|
: `rgba(0, 0, 0, ${Math.max(0.12, opacity * 0.26)})`;
|
||||||
|
const logScrollbarThumbHover = darkMode
|
||||||
|
? `rgba(255, 255, 255, ${Math.max(0.28, opacity * 0.48)})`
|
||||||
|
: `rgba(0, 0, 0, ${Math.max(0.18, opacity * 0.36)})`;
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: 'Time',
|
title: 'Time',
|
||||||
dataIndex: 'timestamp',
|
dataIndex: 'timestamp',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (ts: number) => <span style={{ color: '#888', fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
|
render: (ts: number) => <span style={{ color: panelMutedTextColor, fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 70,
|
width: 70,
|
||||||
render: (status: string) => (
|
render: (status: string) => (
|
||||||
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0 }}>
|
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0, borderRadius: 999, paddingInline: 8, fontSize: 11, fontWeight: 700 }}>
|
||||||
{status === 'success' ? 'OK' : 'ERR'}
|
{status === 'success' ? 'OK' : 'ERR'}
|
||||||
</Tag>
|
</Tag>
|
||||||
)
|
)
|
||||||
@@ -59,10 +76,10 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
|||||||
title: 'SQL / Message',
|
title: 'SQL / Message',
|
||||||
dataIndex: 'sql',
|
dataIndex: 'sql',
|
||||||
render: (text: string, record: any) => (
|
render: (text: string, record: any) => (
|
||||||
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.2' }}>
|
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.45' }}>
|
||||||
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
|
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
|
||||||
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
|
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
|
||||||
{record.affectedRows !== undefined && <div style={{ color: '#888', marginTop: 1 }}>Affected: {record.affectedRows}</div>}
|
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>Affected: {record.affectedRows}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -71,12 +88,18 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
|||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
height,
|
height,
|
||||||
borderTop: 'none',
|
margin: 0,
|
||||||
background: bgMain,
|
border: `1px solid ${panelDividerColor}`,
|
||||||
|
borderRadius: 14,
|
||||||
|
background: panelShellBg,
|
||||||
|
WebkitBackdropFilter: opacity < 0.999 ? 'blur(14px)' : 'none',
|
||||||
|
boxShadow: panelShadow,
|
||||||
|
backdropFilter: darkMode && opacity < 0.999 ? 'blur(18px)' : 'none',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 100 // Ensure above other content
|
overflow: 'hidden',
|
||||||
|
zIndex: 100
|
||||||
}}>
|
}}>
|
||||||
{/* Resize Handle */}
|
{/* Resize Handle */}
|
||||||
<div
|
<div
|
||||||
@@ -94,38 +117,53 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
|||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '4px 8px',
|
padding: '10px 14px',
|
||||||
borderBottom: 'none',
|
borderBottom: `1px solid ${panelDividerColor}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: 32
|
gap: 12,
|
||||||
|
minHeight: 48
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 'bold', fontSize: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||||
<BugOutlined /> SQL 执行日志
|
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? `rgba(255,214,102,${Math.max(0.10, Math.min(0.18, opacity * 0.18))})` : `rgba(24,144,255,${Math.max(0.08, Math.min(0.16, opacity * 0.16))})`, color: panelAccentColor, flexShrink: 0 }}>
|
||||||
|
<BugOutlined />
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 13, color: darkMode ? '#f5f7ff' : '#162033' }}>SQL 执行日志</div>
|
||||||
|
<div style={{ fontSize: 12, color: panelMutedTextColor }}>记录执行状态、耗时与错误信息,便于快速回溯。</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<Tooltip title="清空日志">
|
<Tooltip title="清空日志">
|
||||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} />
|
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} style={{ color: panelMutedTextColor }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="关闭面板">
|
<Tooltip title="关闭面板">
|
||||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} />
|
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: panelMutedTextColor }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="log-panel-scroll" style={{ flex: 1, overflow: 'auto' }}>
|
<div className="log-panel-scroll" style={{ flex: 1, overflow: 'auto', padding: '8px 10px 10px' }}>
|
||||||
<Table
|
{sqlLogs.length === 0 ? (
|
||||||
className="log-panel-table"
|
<div style={{ height: '100%', minHeight: 160, display: 'grid', placeItems: 'center' }}>
|
||||||
dataSource={sqlLogs}
|
<Empty
|
||||||
columns={columns}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
size="small"
|
description={<span style={{ color: panelMutedTextColor }}>暂无 SQL 执行日志</span>}
|
||||||
pagination={false}
|
/>
|
||||||
rowKey="id"
|
</div>
|
||||||
showHeader={false}
|
) : (
|
||||||
// scroll={{ y: height - 32 }} // Let flex handle it
|
<Table
|
||||||
/>
|
className="log-panel-table"
|
||||||
|
dataSource={sqlLogs}
|
||||||
|
columns={columns}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
rowKey="id"
|
||||||
|
showHeader={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<style>{`
|
<style>{`
|
||||||
.log-panel-scroll {
|
.log-panel-scroll {
|
||||||
@@ -155,6 +193,16 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
|||||||
.log-panel-table .ant-table-tbody > tr > td {
|
.log-panel-table .ant-table-tbody > tr > td {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
.log-panel-table .ant-table-tbody > tr > td {
|
||||||
|
padding: 8px 10px !important;
|
||||||
|
border-bottom: 1px solid ${panelDividerColor} !important;
|
||||||
|
}
|
||||||
|
.log-panel-table .ant-table-tbody > tr:last-child > td {
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
.log-panel-table .ant-table-row:hover > td {
|
||||||
|
background: ${darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)'} !important;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react';
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { Button, Space, message } from 'antd';
|
import { Button, Space, message } from 'antd';
|
||||||
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
|
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
|
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||||
import Editor, { OnMount } from '@monaco-editor/react';
|
import Editor, { OnMount } from '@monaco-editor/react';
|
||||||
|
|
||||||
interface RedisCommandEditorProps {
|
interface RedisCommandEditorProps {
|
||||||
@@ -14,6 +15,67 @@ interface CommandResult {
|
|||||||
result: any;
|
result: any;
|
||||||
error?: string;
|
error?: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 智能解析 Redis 脚本块,保护多行引号内的换行符
|
||||||
|
function parseRedisScriptBlocks(script: string): string[] {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let currentBlock = "";
|
||||||
|
let inQuote: string | null = null;
|
||||||
|
let isEscaping = false;
|
||||||
|
|
||||||
|
const lines = script.split('\n');
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!inQuote && (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('#'))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let j = 0; j < line.length; j++) {
|
||||||
|
const char = line[j];
|
||||||
|
|
||||||
|
if (isEscaping) {
|
||||||
|
isEscaping = false;
|
||||||
|
currentBlock += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\\') {
|
||||||
|
isEscaping = true;
|
||||||
|
currentBlock += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"' || char === "'") {
|
||||||
|
if (inQuote === char) {
|
||||||
|
inQuote = null;
|
||||||
|
} else if (!inQuote) {
|
||||||
|
inQuote = char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBlock += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inQuote || (i < lines.length - 1 && currentBlock.trim() !== '')) {
|
||||||
|
if (!inQuote) {
|
||||||
|
blocks.push(currentBlock.trim());
|
||||||
|
currentBlock = "";
|
||||||
|
} else {
|
||||||
|
currentBlock += '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBlock.trim() !== '') {
|
||||||
|
blocks.push(currentBlock.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks.filter(b => b.trim() !== '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
|
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
|
||||||
@@ -23,6 +85,13 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
|||||||
const [command, setCommand] = useState('');
|
const [command, setCommand] = useState('');
|
||||||
const [results, setResults] = useState<CommandResult[]>([]);
|
const [results, setResults] = useState<CommandResult[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// UI Layout state
|
||||||
|
const [editorHeight, setEditorHeight] = useState(250);
|
||||||
|
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const resultsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
|
|
||||||
const getConfig = useCallback(() => {
|
const getConfig = useCallback(() => {
|
||||||
@@ -37,77 +106,173 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
|||||||
};
|
};
|
||||||
}, [connection, redisDB]);
|
}, [connection, redisDB]);
|
||||||
|
|
||||||
const handleEditorMount: OnMount = (editor) => {
|
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
// Add keyboard shortcut for execute
|
|
||||||
editor.addCommand(
|
editor.addCommand(
|
||||||
// Ctrl/Cmd + Enter
|
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
|
||||||
2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter
|
|
||||||
() => handleExecute()
|
() => handleExecute()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!(window as any).__redisCompletionRegistered) {
|
||||||
|
(window as any).__redisCompletionRegistered = true;
|
||||||
|
|
||||||
|
const redisCommands = [
|
||||||
|
"APPEND", "AUTH", "BGREWRITEAOF", "BGSAVE", "BITCOUNT", "BITFIELD", "BITOP",
|
||||||
|
"BITPOS", "BLPOP", "BRPOP", "BRPOPLPUSH", "BZMPOP", "BZPOPMIN", "BZPOPMAX",
|
||||||
|
"CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DBSIZE", "DEBUG", "DECR", "DECRBY",
|
||||||
|
"DEL", "DISCARD", "DUMP", "ECHO", "EVAL", "EVALSHA", "EXEC", "EXISTS", "EXPIRE",
|
||||||
|
"EXPIREAT", "EXPIRETIME", "FLUSHALL", "FLUSHDB", "GEOADD", "GEODIST", "GEOHASH",
|
||||||
|
"GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH", "GEOSEARCHSTORE",
|
||||||
|
"GET", "GETBIT", "GETDEL", "GETEX", "GETRANGE", "GETSET", "HDEL", "HELLO",
|
||||||
|
"HEXISTS", "HGET", "HGETALL", "HINCRBY", "HINCRBYFLOAT", "HKEYS", "HLEN",
|
||||||
|
"HMGET", "HMSET", "HSCAN", "HSET", "HSETNX", "HSTRLEN", "HVALS", "INCR",
|
||||||
|
"INCRBY", "INCRBYFLOAT", "INFO", "KEYS", "LASTSAVE", "LCS", "LINDEX", "LINSERT",
|
||||||
|
"LLEN", "LMOVE", "LMPOP", "LPOP", "LPOS", "LPUSH", "LPUSHX", "LRANGE", "LREM",
|
||||||
|
"LSET", "LTRIM", "MEMORY", "MGET", "MIGRATE", "MODULE", "MONITOR", "MOVE", "MSET",
|
||||||
|
"MSETNX", "MULTI", "OBJECT", "PERSIST", "PEXPIRE", "PEXPIREAT", "PEXPIRETIME",
|
||||||
|
"PFADD", "PFCOUNT", "PFMERGE", "PING", "PSETEX", "PSUBSCRIBE", "PTTL", "PUBLISH",
|
||||||
|
"PUBSUB", "PUNSUBSCRIBE", "QUIT", "RANDOMKEY", "READONLY", "READWRITE", "RENAME",
|
||||||
|
"RENAMENX", "RESET", "RESTORE", "ROLE", "RPOP", "RPOPLPUSH", "RPUSH", "RPUSHX",
|
||||||
|
"SADD", "SAVE", "SCAN", "SCARD", "SCRIPT", "SDIFF", "SDIFFSTORE", "SELECT",
|
||||||
|
"SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SHUTDOWN", "SINTER", "SINTERCARD",
|
||||||
|
"SINTERSTORE", "SISMEMBER", "SLAVEOF", "SLOWLOG", "SMEMBERS", "SMISMEMBER",
|
||||||
|
"SMOVE", "SORT", "SORT_RO", "SPOP", "SRANDMEMBER", "SREM", "SSCAN", "STRLEN",
|
||||||
|
"SUBSCRIBE", "SUNION", "SUNIONSTORE", "SWAPDB", "SYNC", "TIME", "TOUCH", "TTL",
|
||||||
|
"TYPE", "UNLINK", "UNSUBSCRIBE", "UNWATCH", "WAIT", "WATCH", "XACK", "XADD",
|
||||||
|
"XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XINFO", "XLEN", "XPENDING", "XRANGE",
|
||||||
|
"XREAD", "XREADGROUP", "XREVRANGE", "XTRIM", "ZADD", "ZCARD", "ZCOUNT", "ZDIFF",
|
||||||
|
"ZDIFFSTORE", "ZINCRBY", "ZINTER", "ZINTERCARD", "ZINTERSTORE", "ZLEXCOUNT",
|
||||||
|
"ZMPOP", "ZMSCORE", "ZPOPMAX", "ZPOPMIN", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX",
|
||||||
|
"ZRANGEBYSCORE", "ZRANK", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK",
|
||||||
|
"ZREMRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK",
|
||||||
|
"ZSCAN", "ZSCORE", "ZUNION", "ZUNIONSTORE"
|
||||||
|
];
|
||||||
|
|
||||||
|
monaco.languages.registerCompletionItemProvider('redis', {
|
||||||
|
provideCompletionItems: (model: any, position: any) => {
|
||||||
|
const word = model.getWordUntilPosition(position);
|
||||||
|
const range = {
|
||||||
|
startLineNumber: position.lineNumber,
|
||||||
|
endLineNumber: position.lineNumber,
|
||||||
|
startColumn: word.startColumn,
|
||||||
|
endColumn: word.endColumn
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
suggestions: redisCommands.map(cmd => ({
|
||||||
|
label: cmd,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||||
|
insertText: cmd,
|
||||||
|
range: range,
|
||||||
|
detail: "Redis Command"
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExecute = async () => {
|
const handleExecute = async () => {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
|
|
||||||
const cmdToExecute = command.trim();
|
let cmdToExecute = '';
|
||||||
|
|
||||||
|
// 1. 获取用户是否有高亮选中的文本
|
||||||
|
const selection = editorRef.current?.getSelection();
|
||||||
|
if (selection && !selection.isEmpty()) {
|
||||||
|
cmdToExecute = editorRef.current?.getModel()?.getValueInRange(selection) || '';
|
||||||
|
} else {
|
||||||
|
// 没有选中则取全部文本
|
||||||
|
cmdToExecute = editorRef.current?.getValue() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdToExecute = cmdToExecute.trim();
|
||||||
if (!cmdToExecute) {
|
if (!cmdToExecute) {
|
||||||
message.warning('请输入命令');
|
message.warning('请输入要执行的命令');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support multiple commands separated by newlines
|
// 2. 智能解析多行命令
|
||||||
const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#'));
|
const commands = parseRedisScriptBlocks(cmdToExecute);
|
||||||
|
if (commands.length === 0) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const newResults: CommandResult[] = [];
|
const newResults: CommandResult[] = [];
|
||||||
|
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
const trimmedCmd = cmd.trim();
|
const start = Date.now();
|
||||||
if (!trimmedCmd) continue;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd);
|
const res = await (window as any).go.app.App.RedisExecuteCommand(buildRpcConnectionConfig(config), cmd);
|
||||||
newResults.push({
|
newResults.push({
|
||||||
command: trimmedCmd,
|
command: cmd,
|
||||||
result: res.success ? res.data : null,
|
result: res.success ? res.data : null,
|
||||||
error: res.success ? undefined : res.message,
|
error: res.success ? undefined : res.message,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
|
durationMs: Date.now() - start
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
newResults.push({
|
newResults.push({
|
||||||
command: trimmedCmd,
|
command: cmd,
|
||||||
result: null,
|
result: null,
|
||||||
error: e?.message || String(e),
|
error: e?.message || String(e),
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
|
durationMs: Date.now() - start
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setResults(prev => [...newResults, ...prev]);
|
setResults(prev => [...prev, ...newResults]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto scroll to bottom when new results arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (resultsEndRef.current) {
|
||||||
|
resultsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatResult = (result: any): string => {
|
const formatResult = (result: any): React.ReactNode => {
|
||||||
if (result === null || result === undefined) {
|
if (result === null || result === undefined) {
|
||||||
return '(nil)';
|
return <span style={{ color: '#569cd6' }}>(nil)</span>;
|
||||||
}
|
}
|
||||||
if (typeof result === 'string') {
|
if (typeof result === 'string') {
|
||||||
return `"${result}"`;
|
// 尝试美化 JSON 字符串
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 4, padding: 8, background: 'rgba(0,0,0,0.2)', borderRadius: 4 }}>
|
||||||
|
{JSON.stringify(parsed, null, 2)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// not a valid json, just return string
|
||||||
|
}
|
||||||
|
return <span style={{ color: '#ce9178' }}>"{result}"</span>;
|
||||||
}
|
}
|
||||||
if (typeof result === 'number') {
|
if (typeof result === 'number') {
|
||||||
return `(integer) ${result}`;
|
return <span style={{ color: '#b5cea8' }}>(integer) {result}</span>;
|
||||||
}
|
}
|
||||||
if (Array.isArray(result)) {
|
if (Array.isArray(result)) {
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
return '(empty array)';
|
return '(empty array)';
|
||||||
}
|
}
|
||||||
return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n');
|
return (
|
||||||
|
<div style={{ marginLeft: 8 }}>
|
||||||
|
{result.map((item, index) => (
|
||||||
|
<div key={index} style={{ display: 'flex' }}>
|
||||||
|
<span style={{ color: '#608b4e', marginRight: 8, userSelect: 'none' }}>{index + 1})</span>
|
||||||
|
<div>{formatResult(item)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (typeof result === 'object') {
|
if (typeof result === 'object') {
|
||||||
return JSON.stringify(result, null, 2);
|
return JSON.stringify(result, null, 2);
|
||||||
@@ -115,18 +280,56 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
|||||||
return String(result);
|
return String(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resizing logic
|
||||||
|
const handleDragStart = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
|
||||||
|
document.addEventListener('mousemove', handleDragMove);
|
||||||
|
document.addEventListener('mouseup', handleDragEnd);
|
||||||
|
document.body.style.cursor = 'row-resize';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragMove = useCallback((e: MouseEvent) => {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
const delta = e.clientY - dragRef.current.startY;
|
||||||
|
let newHeight = dragRef.current.startHeight + delta;
|
||||||
|
|
||||||
|
// 限制高度
|
||||||
|
const minHeight = 100;
|
||||||
|
const maxHeight = containerRef.current ? containerRef.current.clientHeight - 100 : 800;
|
||||||
|
if (newHeight < minHeight) newHeight = minHeight;
|
||||||
|
if (newHeight > maxHeight) newHeight = maxHeight;
|
||||||
|
|
||||||
|
setEditorHeight(newHeight);
|
||||||
|
|
||||||
|
// 更新编辑器布局
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.layout();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
dragRef.current = null;
|
||||||
|
document.removeEventListener('mousemove', handleDragMove);
|
||||||
|
document.removeEventListener('mouseup', handleDragEnd);
|
||||||
|
document.body.style.cursor = 'default';
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.layout();
|
||||||
|
}
|
||||||
|
}, [handleDragMove]);
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return <div style={{ padding: 20 }}>连接不存在</div>;
|
return <div style={{ padding: 20 }}>连接不存在</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', background: '#fff' }}>
|
||||||
{/* Command Input */}
|
{/* Editor Top Pane */}
|
||||||
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
|
<div style={{ height: editorHeight, minHeight: 100, display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fdfdfd' }}>
|
||||||
<Space>
|
<Space>
|
||||||
<span style={{ fontWeight: 500 }}>Redis 命令</span>
|
<span style={{ fontWeight: 600 }}>Redis Console</span>
|
||||||
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
|
<span style={{ color: '#888', fontSize: 13, background: '#f0f0f0', padding: '2px 8px', borderRadius: 12 }}>db{redisDB}</span>
|
||||||
</Space>
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
@@ -135,68 +338,89 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
|||||||
onClick={handleExecute}
|
onClick={handleExecute}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
执行 (Ctrl+Enter)
|
执行 (Cmd+Enter)
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<ClearOutlined />} onClick={handleClear}>清空结果</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Editor
|
<div style={{ flex: 1, position: 'relative' }}>
|
||||||
height="150px"
|
<Editor
|
||||||
defaultLanguage="plaintext"
|
defaultLanguage="redis"
|
||||||
value={command}
|
language="redis"
|
||||||
onChange={(value) => setCommand(value || '')}
|
value={command}
|
||||||
onMount={handleEditorMount}
|
onChange={(value) => setCommand(value || '')}
|
||||||
options={{
|
onMount={handleEditorMount}
|
||||||
minimap: { enabled: false },
|
options={{
|
||||||
lineNumbers: 'on',
|
minimap: { enabled: false },
|
||||||
fontSize: 14,
|
lineNumbers: 'on',
|
||||||
wordWrap: 'on',
|
fontSize: 14,
|
||||||
scrollBeyondLastLine: false,
|
wordWrap: 'on',
|
||||||
automaticLayout: true,
|
scrollBeyondLastLine: false,
|
||||||
tabSize: 2
|
automaticLayout: true,
|
||||||
}}
|
tabSize: 4,
|
||||||
/>
|
padding: { top: 10, bottom: 10 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Resizer Handle */}
|
||||||
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
|
<div
|
||||||
{results.length === 0 ? (
|
className="horizontal-resizer"
|
||||||
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
|
onMouseDown={handleDragStart}
|
||||||
输入 Redis 命令并按 Ctrl+Enter 执行
|
style={{
|
||||||
<br />
|
height: 8,
|
||||||
<span style={{ fontSize: 12 }}>支持多行命令,每行一个命令</span>
|
cursor: 'row-resize',
|
||||||
</div>
|
background: '#f0f0f0',
|
||||||
) : (
|
borderTop: '1px solid #e0e0e0',
|
||||||
results.map((item, index) => (
|
borderBottom: '1px solid #e0e0e0',
|
||||||
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
|
display: 'flex',
|
||||||
<div style={{ color: '#569cd6', marginBottom: 4 }}>
|
justifyContent: 'center',
|
||||||
> {item.command}
|
alignItems: 'center',
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 40, height: 4, background: '#ccc', borderRadius: 2 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Terminal Bottom Pane */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '4px 12px', background: '#252526', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #333' }}>
|
||||||
|
<span style={{ color: '#ccc', fontSize: 12 }}>Execution Output</span>
|
||||||
|
<Button type="text" size="small" icon={<ClearOutlined />} onClick={handleClear} style={{ color: '#aaa' }}>清空控制台</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: '"Consolas", "Courier New", monospace', fontSize: 13, padding: 12 }}>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<div style={{ color: '#666', textAlign: 'center', marginTop: 40 }}>
|
||||||
|
<div>在此终端执行命令,结果会以原样输出</div>
|
||||||
|
<div style={{ fontSize: 12, marginTop: 12 }}>
|
||||||
|
Tips: <code>选中任意行</code> 按 <code style={{ color: '#999' }}>Ctrl + Enter</code> 仅执行选中段落
|
||||||
</div>
|
</div>
|
||||||
{item.error ? (
|
|
||||||
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
|
|
||||||
(error) {item.error}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ color: '#ce9178', whiteSpace: 'pre-wrap' }}>
|
|
||||||
{formatResult(item.result)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))
|
) : (
|
||||||
)}
|
results.map((item, index) => (
|
||||||
</div>
|
<div key={item.timestamp + index} style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ color: '#569cd6', marginBottom: 6, fontWeight: 'bold' }}>
|
||||||
{/* Common Commands Help */}
|
<span style={{ color: '#4CAF50', marginRight: 8 }}>➜</span>
|
||||||
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
|
{item.command}
|
||||||
常用命令:
|
<span style={{ color: '#666', fontSize: 11, marginLeft: 12, fontWeight: 'normal' }}>[{item.durationMs}ms]</span>
|
||||||
<span style={{ marginLeft: 8 }}>
|
</div>
|
||||||
<code>KEYS *</code> |
|
|
||||||
<code style={{ marginLeft: 8 }}>GET key</code> |
|
<div style={{ paddingLeft: 20 }}>
|
||||||
<code style={{ marginLeft: 8 }}>SET key value</code> |
|
{item.error ? (
|
||||||
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
|
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
|
||||||
<code style={{ marginLeft: 8 }}>INFO</code> |
|
(error) {item.error}
|
||||||
<code style={{ marginLeft: 8 }}>DBSIZE</code>
|
</div>
|
||||||
</span>
|
) : (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{formatResult(item.result)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={resultsEndRef} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
379
frontend/src/components/RedisMonitor.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { Card, Row, Col, Statistic, Select, Button, message, Tag, Typography, Tooltip, Spin } from 'antd';
|
||||||
|
import { AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid, Legend, LineChart, Line } from 'recharts';
|
||||||
|
import {
|
||||||
|
DesktopOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
HddOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PauseCircleOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { SavedConnection } from '../types';
|
||||||
|
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||||
|
import { RedisGetServerInfo } from '../../wailsjs/go/app/App';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface RedisMonitorProps {
|
||||||
|
connectionId: string;
|
||||||
|
redisDB: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data point for charts
|
||||||
|
interface MetricPoint {
|
||||||
|
time: string;
|
||||||
|
qps: number;
|
||||||
|
memory: number; // in MB
|
||||||
|
memory_rss: number; // in MB
|
||||||
|
clients: number;
|
||||||
|
cpuSys: number;
|
||||||
|
cpuUser: number;
|
||||||
|
hitRate: number;
|
||||||
|
keys: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_HISTORY_POINTS = 60; // Keep up to 60 data points
|
||||||
|
|
||||||
|
const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) => {
|
||||||
|
const connections = useStore(state => state.connections);
|
||||||
|
const theme = useStore(state => state.theme);
|
||||||
|
const darkMode = theme === 'dark';
|
||||||
|
|
||||||
|
const [isRunning, setIsRunning] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [history, setHistory] = useState<MetricPoint[]>([]);
|
||||||
|
const [currentInfo, setCurrentInfo] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Ref to track if component is mounted to prevent state updates after unmount
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
// Interval ref
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
// Previous ops counter to calculate QPS if instantaneous_ops_per_sec is not enough
|
||||||
|
const prevMetricsRef = useRef({ prevOps: 0, prevTime: 0 });
|
||||||
|
|
||||||
|
const connection = connections.find((c: SavedConnection) => c.id === connectionId);
|
||||||
|
|
||||||
|
const fetchMetrics = async () => {
|
||||||
|
if (!connection) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = buildRpcConnectionConfig(connection.config, { redisDB });
|
||||||
|
const res = await RedisGetServerInfo(config);
|
||||||
|
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
setError(res.message || 'Failed to fetch Redis info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
const infoMap = res.data as Record<string, string>;
|
||||||
|
setCurrentInfo(infoMap);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timeStr = now.toLocaleTimeString([], { hour12: false, second: '2-digit' });
|
||||||
|
|
||||||
|
// Parse values
|
||||||
|
const qps = parseInt(infoMap['instantaneous_ops_per_sec'] || '0', 10);
|
||||||
|
const memBytes = parseInt(infoMap['used_memory'] || '0', 10);
|
||||||
|
const memRssBytes = parseInt(infoMap['used_memory_rss'] || '0', 10);
|
||||||
|
const clients = parseInt(infoMap['connected_clients'] || '0', 10);
|
||||||
|
const cpuSys = parseFloat(infoMap['used_cpu_sys'] || '0');
|
||||||
|
const cpuUser = parseFloat(infoMap['used_cpu_user'] || '0');
|
||||||
|
|
||||||
|
const hits = parseInt(infoMap['keyspace_hits'] || '0', 10);
|
||||||
|
const misses = parseInt(infoMap['keyspace_misses'] || '0', 10);
|
||||||
|
const hitRate = (hits + misses) > 0 ? (hits / (hits + misses)) * 100 : 0;
|
||||||
|
|
||||||
|
let keys = 0;
|
||||||
|
Object.keys(infoMap).forEach(k => {
|
||||||
|
if (k.startsWith('db')) {
|
||||||
|
const m = infoMap[k].match(/keys=(\d+)/);
|
||||||
|
if (m) keys += parseInt(m[1], 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const point: MetricPoint = {
|
||||||
|
time: timeStr,
|
||||||
|
qps,
|
||||||
|
memory: parseFloat((memBytes / 1024 / 1024).toFixed(2)),
|
||||||
|
memory_rss: parseFloat((memRssBytes / 1024 / 1024).toFixed(2)),
|
||||||
|
clients,
|
||||||
|
cpuSys: parseFloat(cpuSys.toFixed(2)),
|
||||||
|
cpuUser: parseFloat(cpuUser.toFixed(2)),
|
||||||
|
hitRate: parseFloat(hitRate.toFixed(2)),
|
||||||
|
keys
|
||||||
|
};
|
||||||
|
|
||||||
|
setHistory(prev => {
|
||||||
|
const next = [...prev, point];
|
||||||
|
if (next.length > MAX_HISTORY_POINTS) {
|
||||||
|
return next.slice(next.length - MAX_HISTORY_POINTS);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) setLoading(false);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setError(err.message || 'Unknown error');
|
||||||
|
if (loading) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
fetchMetrics(); // initial fetch
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
intervalRef.current = setInterval(fetchMetrics, 2000); // 2 second interval
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, [isRunning, connectionId, redisDB, connection]);
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return <div style={{ padding: 20 }}>Connection not found.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine styles for charts based on theme
|
||||||
|
const chartTextColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)';
|
||||||
|
const chartGridColor = darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
|
||||||
|
const cardBgColor = darkMode ? '#1f1f1f' : '#ffffff';
|
||||||
|
|
||||||
|
const getFormatMemoryString = (bytes: string) => {
|
||||||
|
const val = parseInt(bytes || '0', 10);
|
||||||
|
if (val > 1024*1024*1024) return (val/1024/1024/1024).toFixed(2) + ' GB';
|
||||||
|
if (val > 1024*1024) return (val/1024/1024).toFixed(2) + ' MB';
|
||||||
|
if (val > 1024) return (val/1024).toFixed(2) + ' KB';
|
||||||
|
return val + ' B';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUptimeString = (seconds: string) => {
|
||||||
|
const d = parseInt(seconds || '0', 10);
|
||||||
|
if (d < 60) return `${d}s`;
|
||||||
|
if (d < 3600) return `${Math.floor(d/60)}m ${d%60}s`;
|
||||||
|
if (d < 86400) return `${Math.floor(d/3600)}h ${Math.floor((d%3600)/60)}m`;
|
||||||
|
return `${Math.floor(d/86400)}d ${Math.floor((d%86400)/3600)}h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', overflow: 'auto', padding: '16px 24px', backgroundColor: darkMode ? '#141414' : '#f0f2f5' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
|
<div>
|
||||||
|
<Title level={3} style={{ margin: 0, fontWeight: 600 }}>
|
||||||
|
<DashboardOutlined style={{ marginRight: 8, color: '#1677ff' }} />
|
||||||
|
Redis 实例监控
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
{connection.name}
|
||||||
|
{currentInfo.redis_version && ` • Redis ${currentInfo.redis_version}`}
|
||||||
|
{currentInfo.os && ` • ${currentInfo.os}`}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
{error && <Tag color="error" style={{ height: 32, lineHeight: '30px', fontSize: 13 }}>{error}</Tag>}
|
||||||
|
{loading && !error && <Spin style={{ alignSelf: 'center', marginRight: 16 }} />}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type={isRunning ? "default" : "primary"}
|
||||||
|
icon={isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||||
|
onClick={() => setIsRunning(!isRunning)}
|
||||||
|
>
|
||||||
|
{isRunning ? '暂停刷新' : '恢复刷新'}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={fetchMetrics}>
|
||||||
|
立即刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||||
|
<Statistic
|
||||||
|
title={<span style={{ fontWeight: 500 }}><DesktopOutlined /> 已用内存 (Used)</span>}
|
||||||
|
value={getFormatMemoryString(currentInfo.used_memory || '0')}
|
||||||
|
valueStyle={{ color: '#eb2f96', fontWeight: 600 }}
|
||||||
|
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Peak: {getFormatMemoryString(currentInfo.used_memory_peak || '0')}</Text>}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||||
|
<Statistic
|
||||||
|
title={<span style={{ fontWeight: 500 }}><ApiOutlined /> 客户端数量 (Clients)</span>}
|
||||||
|
value={currentInfo.connected_clients || '0'}
|
||||||
|
valueStyle={{ color: '#1677ff', fontWeight: 600 }}
|
||||||
|
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Blocked: {currentInfo.blocked_clients || '0'}</Text>}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||||
|
<Statistic
|
||||||
|
title={<span style={{ fontWeight: 500 }}><HddOutlined /> 吞吐量 (OPS)</span>}
|
||||||
|
value={currentInfo.instantaneous_ops_per_sec || '0'}
|
||||||
|
valueStyle={{ color: '#52c41a', fontWeight: 600 }}
|
||||||
|
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>cmds/s</Text>}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||||
|
<Statistic
|
||||||
|
title={<span style={{ fontWeight: 500 }}>启动时长 (Uptime)</span>}
|
||||||
|
value={getUptimeString(currentInfo.uptime_in_seconds || '0')}
|
||||||
|
valueStyle={{ color: '#fa8c16', fontWeight: 600 }}
|
||||||
|
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Days: {currentInfo.uptime_in_days || '0'}</Text>}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
title="请求吞吐量 (QPS)"
|
||||||
|
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||||
|
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorQps" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#52c41a" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="#52c41a" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||||
|
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||||
|
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
|
<RechartsTooltip
|
||||||
|
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||||
|
itemStyle={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="qps" name="QPS" stroke="#52c41a" strokeWidth={2} fillOpacity={1} fill="url(#colorQps)" isAnimationActive={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
title="内存开销 (Memory)"
|
||||||
|
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||||
|
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||||
|
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||||
|
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} domain={['auto', 'auto']} />
|
||||||
|
<RechartsTooltip
|
||||||
|
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||||
|
itemStyle={{ fontWeight: 600 }}
|
||||||
|
formatter={(value: any) => [`${value} MB`]}
|
||||||
|
/>
|
||||||
|
<Legend verticalAlign="top" height={36}/>
|
||||||
|
<Line type="monotone" dataKey="memory" name="Used Memory" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
<Line type="monotone" dataKey="memory_rss" name="RSS Memory" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
title="CPU 使用率 (CPU Usage)"
|
||||||
|
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||||
|
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||||
|
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||||
|
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
|
<RechartsTooltip
|
||||||
|
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||||
|
itemStyle={{ fontWeight: 600 }}
|
||||||
|
formatter={(value: any) => [`${value} s`]}
|
||||||
|
/>
|
||||||
|
<Legend verticalAlign="top" height={36}/>
|
||||||
|
<Line type="monotone" dataKey="cpuSys" name="System" stroke="#cf1322" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
<Line type="monotone" dataKey="cpuUser" name="User" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
title="连接信息 (Clients & Keys)"
|
||||||
|
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||||
|
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||||
|
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||||
|
<YAxis yAxisId="left" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis yAxisId="right" orientation="right" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
|
<RechartsTooltip
|
||||||
|
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||||
|
itemStyle={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Legend verticalAlign="top" height={36}/>
|
||||||
|
<Line yAxisId="left" type="stepAfter" dataKey="clients" name="Clients" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
<Line yAxisId="right" type="stepAfter" dataKey="keys" name="Total Keys" stroke="#fa8c16" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<Card bordered={false} title="详细服务器参数" style={{ background: cardBgColor, borderRadius: 8 }}>
|
||||||
|
<div style={{ columnCount: 3, columnGap: 40 }}>
|
||||||
|
{['redis_version', 'os', 'arch_bits', 'multiplexing_api', 'gcc_version', 'run_id', 'tcp_port', 'uptime_in_days', 'hz', 'lru_clock', 'role', 'maxmemory_human', 'maxmemory_policy', 'mem_fragmentation_ratio', 'keyspace_hits', 'keyspace_misses', 'total_connections_received'].map(key => (
|
||||||
|
currentInfo[key] ? (
|
||||||
|
<div key={key} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8, borderBottom: `1px dashed ${chartGridColor}` }}>
|
||||||
|
<Text type="secondary">{key}</Text>
|
||||||
|
<Text strong>{currentInfo[key]}</Text>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RedisMonitor;
|
||||||
154
frontend/src/components/SecurityUpdateBanner.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Button } from 'antd';
|
||||||
|
import { CloseOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import type { SecurityUpdateStatus } from '../types';
|
||||||
|
import { getSecurityUpdateStatusMeta } from '../utils/securityUpdatePresentation';
|
||||||
|
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||||
|
import {
|
||||||
|
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||||
|
SECURITY_UPDATE_BANNER_CLASS,
|
||||||
|
getSecurityUpdateActionButtonStyle,
|
||||||
|
getSecurityUpdateBannerSurfaceStyle,
|
||||||
|
} from '../utils/securityUpdateVisuals';
|
||||||
|
|
||||||
|
interface SecurityUpdateBannerProps {
|
||||||
|
status: SecurityUpdateStatus;
|
||||||
|
darkMode: boolean;
|
||||||
|
overlayTheme: OverlayWorkbenchTheme;
|
||||||
|
surfaceOpacity?: number;
|
||||||
|
onStart: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onRestart: () => void;
|
||||||
|
onOpenDetails: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePrimaryAction = (
|
||||||
|
status: SecurityUpdateStatus,
|
||||||
|
actions: Pick<SecurityUpdateBannerProps, 'onStart' | 'onRetry' | 'onRestart' | 'onOpenDetails'>,
|
||||||
|
) => {
|
||||||
|
switch (status.overallStatus) {
|
||||||
|
case 'postponed':
|
||||||
|
return {
|
||||||
|
label: '立即更新',
|
||||||
|
onClick: actions.onStart,
|
||||||
|
};
|
||||||
|
case 'needs_attention':
|
||||||
|
return {
|
||||||
|
label: '查看详情',
|
||||||
|
onClick: actions.onOpenDetails,
|
||||||
|
};
|
||||||
|
case 'rolled_back':
|
||||||
|
return {
|
||||||
|
label: '重新开始更新',
|
||||||
|
onClick: actions.onRestart,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: '查看详情',
|
||||||
|
onClick: actions.onOpenDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSecondaryAction = (
|
||||||
|
status: SecurityUpdateStatus,
|
||||||
|
actions: Pick<SecurityUpdateBannerProps, 'onRetry' | 'onOpenDetails'>,
|
||||||
|
) => {
|
||||||
|
switch (status.overallStatus) {
|
||||||
|
case 'needs_attention':
|
||||||
|
return {
|
||||||
|
label: '重新检查',
|
||||||
|
onClick: actions.onRetry,
|
||||||
|
};
|
||||||
|
case 'rolled_back':
|
||||||
|
return {
|
||||||
|
label: '查看详情',
|
||||||
|
onClick: actions.onOpenDetails,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SecurityUpdateBanner = ({
|
||||||
|
status,
|
||||||
|
darkMode,
|
||||||
|
overlayTheme,
|
||||||
|
surfaceOpacity = 1,
|
||||||
|
onStart,
|
||||||
|
onRetry,
|
||||||
|
onRestart,
|
||||||
|
onOpenDetails,
|
||||||
|
onDismiss,
|
||||||
|
}: SecurityUpdateBannerProps) => {
|
||||||
|
const statusMeta = getSecurityUpdateStatusMeta(status);
|
||||||
|
const primaryAction = resolvePrimaryAction(status, { onStart, onRetry, onRestart, onOpenDetails });
|
||||||
|
const secondaryAction = resolveSecondaryAction(status, { onRetry, onOpenDetails });
|
||||||
|
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={SECURITY_UPDATE_BANNER_CLASS}
|
||||||
|
style={{
|
||||||
|
margin: '12px 12px 0',
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderRadius: 16,
|
||||||
|
...getSecurityUpdateBannerSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 14,
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
background: overlayTheme.iconBg,
|
||||||
|
color: overlayTheme.iconColor,
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SafetyCertificateOutlined />
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||||
|
已保存配置可进行安全更新
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 4, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||||
|
{statusMeta.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
|
{secondaryAction ? (
|
||||||
|
<Button className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={secondaryAction.onClick}>
|
||||||
|
{secondaryAction.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||||
|
style={actionButtonStyle}
|
||||||
|
type="primary"
|
||||||
|
onClick={primaryAction.onClick}
|
||||||
|
>
|
||||||
|
{primaryAction.label}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||||
|
style={{ ...actionButtonStyle, width: 36, minWidth: 36, paddingInline: 0 }}
|
||||||
|
type="text"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={onDismiss}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { SecurityUpdateBannerProps };
|
||||||
|
export default SecurityUpdateBanner;
|
||||||
133
frontend/src/components/SecurityUpdateIntroModal.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { Button, Modal } from 'antd';
|
||||||
|
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||||
|
import {
|
||||||
|
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||||
|
SECURITY_UPDATE_MODAL_CLASS,
|
||||||
|
getSecurityUpdateActionButtonStyle,
|
||||||
|
getSecurityUpdateShellSurfaceStyle,
|
||||||
|
} from '../utils/securityUpdateVisuals';
|
||||||
|
|
||||||
|
interface SecurityUpdateIntroModalProps {
|
||||||
|
open: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
darkMode: boolean;
|
||||||
|
overlayTheme: OverlayWorkbenchTheme;
|
||||||
|
surfaceOpacity?: number;
|
||||||
|
onStart: () => void;
|
||||||
|
onPostpone: () => void;
|
||||||
|
onViewDetails: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionButtonStyle: CSSProperties = {
|
||||||
|
...getSecurityUpdateActionButtonStyle(),
|
||||||
|
height: 38,
|
||||||
|
paddingInline: 18,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SecurityUpdateIntroModal = ({
|
||||||
|
open,
|
||||||
|
loading = false,
|
||||||
|
darkMode,
|
||||||
|
overlayTheme,
|
||||||
|
surfaceOpacity = 1,
|
||||||
|
onStart,
|
||||||
|
onPostpone,
|
||||||
|
onViewDetails,
|
||||||
|
}: SecurityUpdateIntroModalProps) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||||
|
title={(
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 12,
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
background: overlayTheme.iconBg,
|
||||||
|
color: overlayTheme.iconColor,
|
||||||
|
fontSize: 18,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SafetyCertificateOutlined />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
|
||||||
|
已保存配置安全更新
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||||
|
使用新的安全存储方式前,需要先完成一次本地配置更新。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
open={open}
|
||||||
|
closable={!loading}
|
||||||
|
maskClosable={!loading}
|
||||||
|
keyboard={!loading}
|
||||||
|
onCancel={onPostpone}
|
||||||
|
width={560}
|
||||||
|
styles={{
|
||||||
|
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||||
|
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||||
|
body: { paddingTop: 8 },
|
||||||
|
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||||
|
}}
|
||||||
|
footer={[
|
||||||
|
<Button
|
||||||
|
key="details"
|
||||||
|
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
style={actionButtonStyle}
|
||||||
|
onClick={onViewDetails}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="later"
|
||||||
|
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
style={actionButtonStyle}
|
||||||
|
onClick={onPostpone}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
稍后提醒我
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="start"
|
||||||
|
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||||
|
type="primary"
|
||||||
|
style={actionButtonStyle}
|
||||||
|
loading={loading}
|
||||||
|
onClick={onStart}
|
||||||
|
>
|
||||||
|
立即更新
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 0 6px',
|
||||||
|
color: darkMode ? 'rgba(255,255,255,0.82)' : '#2f3b52',
|
||||||
|
lineHeight: 1.8,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
为了让已保存的连接、代理和相关服务配置使用新的安全存储方式,本次更新需要进行一次本地配置更新。
|
||||||
|
更新前会自动创建本地备份;如果本次未完成,系统会保留当前可用配置,你也可以稍后继续。
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { SecurityUpdateIntroModalProps };
|
||||||
|
export default SecurityUpdateIntroModal;
|
||||||
69
frontend/src/components/SecurityUpdateProgressModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Modal, Spin } from 'antd';
|
||||||
|
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||||
|
import {
|
||||||
|
SECURITY_UPDATE_MODAL_CLASS,
|
||||||
|
getSecurityUpdateShellSurfaceStyle,
|
||||||
|
} from '../utils/securityUpdateVisuals';
|
||||||
|
|
||||||
|
interface SecurityUpdateProgressModalProps {
|
||||||
|
open: boolean;
|
||||||
|
stageText: string;
|
||||||
|
detailText?: string;
|
||||||
|
overlayTheme: OverlayWorkbenchTheme;
|
||||||
|
surfaceOpacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SecurityUpdateProgressModal = ({
|
||||||
|
open,
|
||||||
|
stageText,
|
||||||
|
detailText,
|
||||||
|
overlayTheme,
|
||||||
|
surfaceOpacity = 1,
|
||||||
|
}: SecurityUpdateProgressModalProps) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||||
|
open={open}
|
||||||
|
closable={false}
|
||||||
|
maskClosable={false}
|
||||||
|
keyboard={false}
|
||||||
|
footer={null}
|
||||||
|
width={420}
|
||||||
|
centered
|
||||||
|
styles={{
|
||||||
|
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||||
|
header: { display: 'none' },
|
||||||
|
body: { padding: 28 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 16 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 18,
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
background: overlayTheme.iconBg,
|
||||||
|
color: overlayTheme.iconColor,
|
||||||
|
fontSize: 22,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SafetyCertificateOutlined />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||||
|
{stageText}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||||
|
{detailText ?? '更新过程中会保留当前可用配置,请稍候。'}
|
||||||
|
</div>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { SecurityUpdateProgressModalProps };
|
||||||
|
export default SecurityUpdateProgressModal;
|
||||||
337
frontend/src/components/SecurityUpdateSettingsModal.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button, Empty, Modal, Tag } from 'antd';
|
||||||
|
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
|
||||||
|
import {
|
||||||
|
getSecurityUpdateIssueActionMeta,
|
||||||
|
getSecurityUpdateIssueSeverityMeta,
|
||||||
|
getSecurityUpdateItemStatusMeta,
|
||||||
|
getSecurityUpdateStatusMeta,
|
||||||
|
sortSecurityUpdateIssues,
|
||||||
|
} from '../utils/securityUpdatePresentation';
|
||||||
|
import {
|
||||||
|
hasSecurityUpdateRecentResult,
|
||||||
|
resolveSecurityUpdateFocusState,
|
||||||
|
type SecurityUpdateFocusState,
|
||||||
|
type SecurityUpdateSettingsFocusTarget,
|
||||||
|
} from '../utils/securityUpdateRepairFlow';
|
||||||
|
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||||
|
import {
|
||||||
|
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||||
|
SECURITY_UPDATE_MODAL_CLASS,
|
||||||
|
SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS,
|
||||||
|
SECURITY_UPDATE_RESULT_CARD_CLASS,
|
||||||
|
getSecurityUpdateActionButtonStyle,
|
||||||
|
getSecurityUpdateSectionSurfaceStyle,
|
||||||
|
getSecurityUpdateShellSurfaceStyle,
|
||||||
|
} from '../utils/securityUpdateVisuals';
|
||||||
|
|
||||||
|
interface SecurityUpdateSettingsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
darkMode: boolean;
|
||||||
|
overlayTheme: OverlayWorkbenchTheme;
|
||||||
|
surfaceOpacity?: number;
|
||||||
|
status: SecurityUpdateStatus;
|
||||||
|
focusTarget?: SecurityUpdateSettingsFocusTarget | null;
|
||||||
|
focusRequest?: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onStart: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onRestart: () => void;
|
||||||
|
onIssueAction: (issue: SecurityUpdateIssue) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionStyle = (
|
||||||
|
overlayTheme: OverlayWorkbenchTheme,
|
||||||
|
surfaceOpacity: number,
|
||||||
|
options?: { emphasized?: boolean },
|
||||||
|
) => ({
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, {
|
||||||
|
...options,
|
||||||
|
surfaceOpacity,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const EMPTY_FOCUS_STATE: SecurityUpdateFocusState = {
|
||||||
|
target: null,
|
||||||
|
pulseKey: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SecurityUpdateSettingsModal = ({
|
||||||
|
open,
|
||||||
|
darkMode,
|
||||||
|
overlayTheme,
|
||||||
|
surfaceOpacity = 1,
|
||||||
|
status,
|
||||||
|
focusTarget = null,
|
||||||
|
focusRequest = 0,
|
||||||
|
onClose,
|
||||||
|
onStart,
|
||||||
|
onRetry,
|
||||||
|
onRestart,
|
||||||
|
onIssueAction,
|
||||||
|
}: SecurityUpdateSettingsModalProps) => {
|
||||||
|
const statusMeta = getSecurityUpdateStatusMeta(status);
|
||||||
|
const sortedIssues = sortSecurityUpdateIssues(status.issues);
|
||||||
|
const showRecentResult = hasSecurityUpdateRecentResult(status);
|
||||||
|
const showStart = status.overallStatus === 'pending' || status.overallStatus === 'postponed';
|
||||||
|
const showRetry = status.overallStatus === 'needs_attention';
|
||||||
|
const showRestart = status.overallStatus === 'needs_attention' || status.overallStatus === 'rolled_back';
|
||||||
|
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
|
||||||
|
const [activeFocus, setActiveFocus] = useState<SecurityUpdateFocusState>(EMPTY_FOCUS_STATE);
|
||||||
|
const statusSectionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const recentResultRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextFocus = resolveSecurityUpdateFocusState(open, focusTarget, focusRequest);
|
||||||
|
if (!nextFocus.target || !nextFocus.pulseKey) {
|
||||||
|
setActiveFocus(EMPTY_FOCUS_STATE);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNode = nextFocus.target === 'recent_result'
|
||||||
|
? recentResultRef.current
|
||||||
|
: statusSectionRef.current;
|
||||||
|
if (!targetNode) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveFocus(EMPTY_FOCUS_STATE);
|
||||||
|
const animationFrame = window.requestAnimationFrame(() => {
|
||||||
|
targetNode.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
targetNode.focus({ preventScroll: true });
|
||||||
|
setActiveFocus(nextFocus);
|
||||||
|
});
|
||||||
|
const highlightTimer = window.setTimeout(() => {
|
||||||
|
setActiveFocus((current) => (
|
||||||
|
current.pulseKey === nextFocus.pulseKey ? EMPTY_FOCUS_STATE : current
|
||||||
|
));
|
||||||
|
}, 1800);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
window.clearTimeout(highlightTimer);
|
||||||
|
};
|
||||||
|
}, [focusRequest, focusTarget, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||||
|
title={(
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 12,
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
background: overlayTheme.iconBg,
|
||||||
|
color: overlayTheme.iconColor,
|
||||||
|
fontSize: 18,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SafetyCertificateOutlined />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
|
||||||
|
安全更新
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||||
|
管理已保存配置的安全更新状态与待处理项。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={[
|
||||||
|
showRetry ? (
|
||||||
|
<Button key="retry" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRetry}>
|
||||||
|
重新检查
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
showRestart ? (
|
||||||
|
<Button key="restart" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRestart}>
|
||||||
|
重新开始更新
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
showStart ? (
|
||||||
|
<Button
|
||||||
|
key="start"
|
||||||
|
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||||
|
style={actionButtonStyle}
|
||||||
|
type="primary"
|
||||||
|
onClick={onStart}
|
||||||
|
>
|
||||||
|
开始更新
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
<Button key="close" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={760}
|
||||||
|
styles={{
|
||||||
|
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||||
|
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||||
|
body: { paddingTop: 8, maxHeight: 640, overflowY: 'auto' },
|
||||||
|
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gap: 14, padding: '12px 0' }}>
|
||||||
|
<div
|
||||||
|
ref={statusSectionRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'status' })}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||||
|
当前状态:{statusMeta.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||||
|
{statusMeta.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tag color={
|
||||||
|
statusMeta.tone === 'success'
|
||||||
|
? 'success'
|
||||||
|
: statusMeta.tone === 'error'
|
||||||
|
? 'error'
|
||||||
|
: statusMeta.tone === 'processing'
|
||||||
|
? 'processing'
|
||||||
|
: statusMeta.tone === 'warning'
|
||||||
|
? 'warning'
|
||||||
|
: 'default'
|
||||||
|
}>
|
||||||
|
{statusMeta.label}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
|
||||||
|
影响范围
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
|
||||||
|
{[
|
||||||
|
{ label: '总计', value: status.summary.total },
|
||||||
|
{ label: '已更新', value: status.summary.updated },
|
||||||
|
{ label: '待处理', value: status.summary.pending },
|
||||||
|
{ label: '已跳过', value: status.summary.skipped },
|
||||||
|
{ label: '失败', value: status.summary.failed },
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
style={{
|
||||||
|
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '12px 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}>{item.label}</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 20, fontWeight: 700, color: overlayTheme.titleText }}>{item.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
|
||||||
|
待处理清单
|
||||||
|
</div>
|
||||||
|
{sortedIssues.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="当前没有待处理项"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
{sortedIssues.map((issue) => {
|
||||||
|
const actionMeta = getSecurityUpdateIssueActionMeta(issue);
|
||||||
|
const itemStatusMeta = getSecurityUpdateItemStatusMeta(issue.status);
|
||||||
|
const issueSeverityMeta = getSecurityUpdateIssueSeverityMeta(issue.severity);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={issue.id}
|
||||||
|
style={{
|
||||||
|
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 14,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||||
|
{issue.title || issue.message || issue.id}
|
||||||
|
</div>
|
||||||
|
<Tag color={itemStatusMeta.color}>
|
||||||
|
状态:{itemStatusMeta.label}
|
||||||
|
</Tag>
|
||||||
|
<Tag color={issueSeverityMeta.color}>
|
||||||
|
级别:{issueSeverityMeta.label}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||||
|
{issue.message || '当前项需要进一步处理后才能完成安全更新。'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||||
|
style={actionButtonStyle}
|
||||||
|
type={actionMeta.emphasis === 'primary' ? 'primary' : 'default'}
|
||||||
|
onClick={() => onIssueAction(issue)}
|
||||||
|
>
|
||||||
|
{actionMeta.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showRecentResult ? (
|
||||||
|
<div
|
||||||
|
ref={recentResultRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={[
|
||||||
|
SECURITY_UPDATE_RESULT_CARD_CLASS,
|
||||||
|
activeFocus.target === 'recent_result' ? SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'recent_result' })}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 8 }}>
|
||||||
|
最近一次结果
|
||||||
|
</div>
|
||||||
|
{status.backupPath ? (
|
||||||
|
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||||
|
备份位置:<span style={{ color: overlayTheme.titleText }}>{status.backupPath}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{status.lastError ? (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 13, color: '#ff7875', lineHeight: 1.7 }}>
|
||||||
|
最近错误:{status.lastError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { SecurityUpdateSettingsModalProps };
|
||||||
|
export default SecurityUpdateSettingsModal;
|
||||||
@@ -1,44 +1,107 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useRef, useState } from 'react';
|
||||||
import { Tabs, Dropdown } from 'antd';
|
import { Tabs, Dropdown } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps, TabsProps } from 'antd';
|
||||||
|
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
|
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
|
||||||
|
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import DataViewer from './DataViewer';
|
import DataViewer from './DataViewer';
|
||||||
import QueryEditor from './QueryEditor';
|
import QueryEditor from './QueryEditor';
|
||||||
import TableDesigner from './TableDesigner';
|
import TableDesigner from './TableDesigner';
|
||||||
import RedisViewer from './RedisViewer';
|
import RedisViewer from './RedisViewer';
|
||||||
import RedisCommandEditor from './RedisCommandEditor';
|
import RedisCommandEditor from './RedisCommandEditor';
|
||||||
|
import RedisMonitor from './RedisMonitor';
|
||||||
import TriggerViewer from './TriggerViewer';
|
import TriggerViewer from './TriggerViewer';
|
||||||
import DefinitionViewer from './DefinitionViewer';
|
import DefinitionViewer from './DefinitionViewer';
|
||||||
|
import TableOverview from './TableOverview';
|
||||||
|
import JVMOverview from './JVMOverview';
|
||||||
|
import JVMResourceBrowser from './JVMResourceBrowser';
|
||||||
|
import JVMAuditViewer from './JVMAuditViewer';
|
||||||
|
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
|
||||||
|
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
|
||||||
import type { TabData } from '../types';
|
import type { TabData } from '../types';
|
||||||
|
import { buildTabDisplayTitle } from '../utils/tabDisplay';
|
||||||
|
import { resolveConnectionAccentColor } from '../utils/connectionVisual';
|
||||||
|
|
||||||
const detectConnectionEnvLabel = (connectionName: string): string | null => {
|
type SortableTabLabelProps = {
|
||||||
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
displayTitle: string;
|
||||||
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
|
menuItems: MenuProps['items'];
|
||||||
if (tokens.includes('uat')) return 'UAT';
|
accentColor?: string;
|
||||||
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
|
|
||||||
if (tokens.includes('sit')) return 'SIT';
|
|
||||||
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
|
|
||||||
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
|
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
|
||||||
if (tab.type !== 'table' && tab.type !== 'design') return tab.title;
|
displayTitle,
|
||||||
if (!connectionName) return tab.title;
|
menuItems,
|
||||||
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
|
accentColor,
|
||||||
return `[${prefix}] ${tab.title}`;
|
}) => {
|
||||||
|
const labelStyle = accentColor
|
||||||
|
? ({ '--connection-accent': accentColor } as React.CSSProperties)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||||
|
<span
|
||||||
|
className={`tab-dnd-label${accentColor ? ' has-connection-accent' : ''}`}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
title={displayTitle}
|
||||||
|
style={labelStyle}
|
||||||
|
>
|
||||||
|
{accentColor ? <span className="tab-connection-accent" aria-hidden="true" /> : null}
|
||||||
|
<span className="tab-title-text">{displayTitle}</span>
|
||||||
|
</span>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraggableTabNodeProps = {
|
||||||
|
node: React.ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
|
||||||
|
const tabId = String(node.key || '').trim();
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
...(node.props.style || {}),
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
|
opacity: isDragging ? 0.88 : 1,
|
||||||
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
|
touchAction: 'none',
|
||||||
|
zIndex: isDragging ? 2 : node.props.style?.zIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
return React.cloneElement(node, {
|
||||||
|
ref: setNodeRef,
|
||||||
|
style,
|
||||||
|
...attributes,
|
||||||
|
...listeners,
|
||||||
|
className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const TabManager: React.FC = () => {
|
const TabManager: React.FC = () => {
|
||||||
const tabs = useStore(state => state.tabs);
|
const tabs = useStore(state => state.tabs);
|
||||||
const connections = useStore(state => state.connections);
|
const connections = useStore(state => state.connections);
|
||||||
|
const theme = useStore(state => state.theme);
|
||||||
const activeTabId = useStore(state => state.activeTabId);
|
const activeTabId = useStore(state => state.activeTabId);
|
||||||
const setActiveTab = useStore(state => state.setActiveTab);
|
const setActiveTab = useStore(state => state.setActiveTab);
|
||||||
|
const addTab = useStore(state => state.addTab);
|
||||||
const closeTab = useStore(state => state.closeTab);
|
const closeTab = useStore(state => state.closeTab);
|
||||||
const closeOtherTabs = useStore(state => state.closeOtherTabs);
|
const closeOtherTabs = useStore(state => state.closeOtherTabs);
|
||||||
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
|
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
|
||||||
const closeTabsToRight = useStore(state => state.closeTabsToRight);
|
const closeTabsToRight = useStore(state => state.closeTabsToRight);
|
||||||
const closeAllTabs = useStore(state => state.closeAllTabs);
|
const closeAllTabs = useStore(state => state.closeAllTabs);
|
||||||
|
const moveTab = useStore(state => state.moveTab);
|
||||||
|
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
|
||||||
|
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||||
|
const suppressClickUntilRef = useRef<number>(0);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: { distance: 8 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const onChange = (newActiveKey: string) => {
|
const onChange = (newActiveKey: string) => {
|
||||||
setActiveTab(newActiveKey);
|
setActiveTab(newActiveKey);
|
||||||
@@ -50,24 +113,121 @@ const TabManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
const sourceId = String(event.active.id || '').trim();
|
||||||
|
setDraggingTabId(sourceId || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const sourceId = String(event.active.id || '').trim();
|
||||||
|
const targetId = String(event.over?.id || '').trim();
|
||||||
|
setDraggingTabId(null);
|
||||||
|
if (!sourceId || !targetId || sourceId === targetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suppressClickUntilRef.current = Date.now() + 120;
|
||||||
|
moveTab(sourceId, targetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragCancel = () => {
|
||||||
|
setDraggingTabId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleGlobalInsertSql = (e: any) => {
|
||||||
|
const { sql, runImmediately, connectionId: eventConnId, dbName: eventDbName } = e.detail;
|
||||||
|
if (!sql) return;
|
||||||
|
|
||||||
|
const activeTab = tabs.find(t => t.id === activeTabId);
|
||||||
|
|
||||||
|
// 🔧 runImmediately(点击"执行")始终新建独立 tab,避免追加到已有 tab 导致 SQL 重复
|
||||||
|
if (runImmediately) {
|
||||||
|
const newTabId = 'tab-' + Date.now();
|
||||||
|
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
|
||||||
|
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
|
||||||
|
addTab({
|
||||||
|
id: newTabId,
|
||||||
|
type: 'query',
|
||||||
|
title: '新建查询',
|
||||||
|
query: sql,
|
||||||
|
connectionId: resolvedConnId,
|
||||||
|
dbName: resolvedDbName
|
||||||
|
});
|
||||||
|
setActiveTab(newTabId);
|
||||||
|
setTimeout(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
|
||||||
|
detail: { tabId: newTabId, sql, runImmediately: true, connectionId: resolvedConnId, dbName: resolvedDbName }
|
||||||
|
}));
|
||||||
|
}, 300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入模式:追加到已有 tab 或新建 tab
|
||||||
|
if (activeTab && activeTab.type === 'query') {
|
||||||
|
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
|
||||||
|
detail: { tabId: activeTab.id, sql, runImmediately: false, connectionId: eventConnId, dbName: eventDbName }
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const newTabId = 'tab-' + Date.now();
|
||||||
|
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
|
||||||
|
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
|
||||||
|
addTab({
|
||||||
|
id: newTabId,
|
||||||
|
type: 'query',
|
||||||
|
title: '新建查询',
|
||||||
|
query: sql,
|
||||||
|
connectionId: resolvedConnId,
|
||||||
|
dbName: resolvedDbName
|
||||||
|
});
|
||||||
|
setActiveTab(newTabId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('gonavi:insert-sql', handleGlobalInsertSql);
|
||||||
|
return () => window.removeEventListener('gonavi:insert-sql', handleGlobalInsertSql);
|
||||||
|
}, [tabs, activeTabId, addTab, setActiveTab, connections]);
|
||||||
|
|
||||||
|
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
|
||||||
|
|
||||||
|
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
|
||||||
|
<DefaultTabBar {...tabBarProps}>
|
||||||
|
{(node) => <DraggableTabNode key={node.key} node={node} />}
|
||||||
|
</DefaultTabBar>
|
||||||
|
);
|
||||||
|
|
||||||
const items = useMemo(() => tabs.map((tab, index) => {
|
const items = useMemo(() => tabs.map((tab, index) => {
|
||||||
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
|
const connection = connections.find((conn) => conn.id === tab.connectionId);
|
||||||
const displayTitle = buildTabDisplayTitle(tab, connectionName);
|
const displayTitle = buildTabDisplayTitle(tab, connection);
|
||||||
|
const accentColor = connection ? resolveConnectionAccentColor(connection) : undefined;
|
||||||
|
const tabIsActive = tab.id === activeTabId;
|
||||||
let content;
|
let content;
|
||||||
if (tab.type === 'query') {
|
if (tab.type === 'query') {
|
||||||
content = <QueryEditor tab={tab} />;
|
content = <QueryEditor tab={tab} isActive={tabIsActive} />;
|
||||||
} else if (tab.type === 'table') {
|
} else if (tab.type === 'table') {
|
||||||
content = <DataViewer tab={tab} />;
|
content = <DataViewer tab={tab} isActive={tabIsActive} />;
|
||||||
} else if (tab.type === 'design') {
|
} else if (tab.type === 'design') {
|
||||||
content = <TableDesigner tab={tab} />;
|
content = <TableDesigner tab={tab} />;
|
||||||
} else if (tab.type === 'redis-keys') {
|
} else if (tab.type === 'redis-keys') {
|
||||||
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||||
} else if (tab.type === 'redis-command') {
|
} else if (tab.type === 'redis-command') {
|
||||||
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||||
|
} else if (tab.type === 'redis-monitor') {
|
||||||
|
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||||
} else if (tab.type === 'trigger') {
|
} else if (tab.type === 'trigger') {
|
||||||
content = <TriggerViewer tab={tab} />;
|
content = <TriggerViewer tab={tab} />;
|
||||||
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
|
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
|
||||||
content = <DefinitionViewer tab={tab} />;
|
content = <DefinitionViewer tab={tab} />;
|
||||||
|
} else if (tab.type === 'table-overview') {
|
||||||
|
content = <TableOverview tab={tab} />;
|
||||||
|
} else if (tab.type === 'jvm-overview') {
|
||||||
|
content = <JVMOverview tab={tab} />;
|
||||||
|
} else if (tab.type === 'jvm-resource') {
|
||||||
|
content = <JVMResourceBrowser tab={tab} />;
|
||||||
|
} else if (tab.type === 'jvm-audit') {
|
||||||
|
content = <JVMAuditViewer tab={tab} />;
|
||||||
|
} else if (tab.type === 'jvm-diagnostic') {
|
||||||
|
content = <JVMDiagnosticConsole tab={tab} />;
|
||||||
|
} else if (tab.type === 'jvm-monitoring') {
|
||||||
|
content = <JVMMonitoringDashboard tab={tab} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems: MenuProps['items'] = [
|
const menuItems: MenuProps['items'] = [
|
||||||
@@ -100,14 +260,16 @@ const TabManager: React.FC = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
label: (
|
label: (
|
||||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
<SortableTabLabel
|
||||||
<span onContextMenu={(e) => e.preventDefault()}>{displayTitle}</span>
|
displayTitle={displayTitle}
|
||||||
</Dropdown>
|
menuItems={menuItems}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
key: tab.id,
|
key: tab.id,
|
||||||
children: content,
|
children: content,
|
||||||
};
|
};
|
||||||
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -156,18 +318,86 @@ const TabManager: React.FC = () => {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
.main-tabs .ant-tabs-nav::before {
|
.main-tabs .ant-tabs-nav::before {
|
||||||
border-bottom: none !important;
|
border-bottom: 1px solid ${tabsNavBorderColor} !important;
|
||||||
|
}
|
||||||
|
.main-tabs .ant-tabs-tab {
|
||||||
|
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease;
|
||||||
|
}
|
||||||
|
.main-tabs .tab-dnd-label {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.main-tabs .tab-dnd-label.has-connection-accent {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.main-tabs .tab-connection-accent {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--connection-accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--connection-accent) 22%, transparent);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.main-tabs .tab-title-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.main-tabs .tab-dnd-node.is-dragging,
|
||||||
|
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.72);
|
||||||
|
background: rgba(255, 214, 102, 0.16);
|
||||||
|
}
|
||||||
|
body[data-theme='light'] .main-tabs .ant-tabs-tab-btn:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
|
||||||
|
background: rgba(9, 109, 217, 0.08);
|
||||||
|
}
|
||||||
|
body[data-theme='light'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||||
|
background: rgba(24, 144, 255, 0.10) !important;
|
||||||
|
border-color: rgba(24, 144, 255, 0.28) !important;
|
||||||
|
}
|
||||||
|
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||||
|
background: rgba(255, 214, 102, 0.12) !important;
|
||||||
|
border-color: rgba(255, 214, 102, 0.4) !important;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<Tabs
|
<DndContext
|
||||||
className="main-tabs"
|
sensors={sensors}
|
||||||
type="editable-card"
|
collisionDetection={closestCenter}
|
||||||
onChange={onChange}
|
modifiers={[restrictToHorizontalAxis]}
|
||||||
activeKey={activeTabId || undefined}
|
onDragStart={handleDragStart}
|
||||||
onEdit={onEdit}
|
onDragEnd={handleDragEnd}
|
||||||
items={items}
|
onDragCancel={handleDragCancel}
|
||||||
hideAdd
|
>
|
||||||
/>
|
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||||
|
<Tabs
|
||||||
|
className="main-tabs"
|
||||||
|
type="editable-card"
|
||||||
|
destroyInactiveTabPane={false}
|
||||||
|
onChange={(newActiveKey) => {
|
||||||
|
if (Date.now() < suppressClickUntilRef.current) return;
|
||||||
|
onChange(newActiveKey);
|
||||||
|
}}
|
||||||
|
activeKey={activeTabId || undefined}
|
||||||
|
onEdit={onEdit}
|
||||||
|
items={items}
|
||||||
|
hideAdd
|
||||||
|
renderTabBar={renderTabBar}
|
||||||
|
/>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
187
frontend/src/components/TableDesignerSqlPreview.test.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import TableDesignerSqlPreview, { resolveSqlChangeHighlights } from './TableDesignerSqlPreview';
|
||||||
|
|
||||||
|
const mockMonaco = {
|
||||||
|
Range: class {
|
||||||
|
startLineNumber: number;
|
||||||
|
startColumn: number;
|
||||||
|
endLineNumber: number;
|
||||||
|
endColumn: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
startLineNumber: number,
|
||||||
|
startColumn: number,
|
||||||
|
endLineNumber: number,
|
||||||
|
endColumn: number,
|
||||||
|
) {
|
||||||
|
this.startLineNumber = startLineNumber;
|
||||||
|
this.startColumn = startColumn;
|
||||||
|
this.endLineNumber = endLineNumber;
|
||||||
|
this.endColumn = endColumn;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
defineTheme: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEditor = {
|
||||||
|
deltaDecorations: vi.fn(() => ['decoration-1']),
|
||||||
|
getModel: vi.fn(() => ({
|
||||||
|
getLineCount: () => 5,
|
||||||
|
getLineMaxColumn: (lineNumber: number) => (lineNumber === 1 ? 22 : 80),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@monaco-editor/react', () => ({
|
||||||
|
default: ({
|
||||||
|
beforeMount,
|
||||||
|
defaultLanguage,
|
||||||
|
language,
|
||||||
|
onMount,
|
||||||
|
options,
|
||||||
|
theme,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
beforeMount?: (monaco: any) => void;
|
||||||
|
defaultLanguage?: string;
|
||||||
|
language?: string;
|
||||||
|
onMount?: (editor: any, monaco: any) => void;
|
||||||
|
options?: Record<string, any>;
|
||||||
|
theme?: string;
|
||||||
|
value?: string;
|
||||||
|
}) => {
|
||||||
|
beforeMount?.(mockMonaco);
|
||||||
|
onMount?.(mockEditor, mockMonaco);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-default-language={defaultLanguage}
|
||||||
|
data-language={language}
|
||||||
|
data-monaco-editor-mock="true"
|
||||||
|
data-options={JSON.stringify(options)}
|
||||||
|
data-theme={theme}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TableDesignerSqlPreview', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockEditor.deltaDecorations.mockClear();
|
||||||
|
mockMonaco.editor.defineTheme.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders SQL changes in a read-only Monaco SQL editor with explicit syntax highlight theme', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<TableDesignerSqlPreview
|
||||||
|
sql={'ALTER TABLE "users"\nRENAME COLUMN "name" TO "display_name";'}
|
||||||
|
darkMode={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('data-table-designer-sql-preview="true"');
|
||||||
|
expect(markup).toContain('data-monaco-editor-mock="true"');
|
||||||
|
expect(markup).toContain('data-default-language="sql"');
|
||||||
|
expect(markup).toContain('data-language="sql"');
|
||||||
|
expect(markup).toContain('data-theme="gonavi-sql-preview-light"');
|
||||||
|
expect(markup).toContain('"readOnly":true');
|
||||||
|
expect(markup).toContain('"lineNumbers":"on"');
|
||||||
|
expect(markup).not.toContain('"glyphMargin":true');
|
||||||
|
expect(markup).toContain('ALTER TABLE');
|
||||||
|
expect(markup).toContain('RENAME COLUMN');
|
||||||
|
|
||||||
|
expect(mockMonaco.editor.defineTheme).toHaveBeenCalledWith(
|
||||||
|
'gonavi-sql-preview-light',
|
||||||
|
expect.objectContaining({
|
||||||
|
base: 'vs',
|
||||||
|
inherit: true,
|
||||||
|
rules: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ token: 'keyword', foreground: expect.any(String) }),
|
||||||
|
expect.objectContaining({ token: 'string', foreground: expect.any(String) }),
|
||||||
|
expect.objectContaining({ token: 'comment', foreground: expect.any(String) }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects only SQL change operation lines instead of highlighting the whole SQL block', () => {
|
||||||
|
const highlights = resolveSqlChangeHighlights([
|
||||||
|
'ALTER TABLE "users"',
|
||||||
|
'ADD COLUMN "age" int NULL;',
|
||||||
|
'ALTER TABLE "users"',
|
||||||
|
'RENAME COLUMN "name" TO "display_name";',
|
||||||
|
'-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
expect(highlights).toEqual([
|
||||||
|
expect.objectContaining({ kind: 'add', lineNumber: 2 }),
|
||||||
|
expect.objectContaining({ kind: 'rename', lineNumber: 4 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds Monaco decorations to changed SQL lines only', () => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<TableDesignerSqlPreview
|
||||||
|
sql={[
|
||||||
|
'ALTER TABLE "users"',
|
||||||
|
'ADD COLUMN "age" int NULL;',
|
||||||
|
'ALTER TABLE "users"',
|
||||||
|
'DROP COLUMN "legacy_name";',
|
||||||
|
].join('\n')}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockEditor.deltaDecorations).toHaveBeenCalledWith(
|
||||||
|
[],
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
range: expect.objectContaining({ startLineNumber: 2, endLineNumber: 2 }),
|
||||||
|
options: expect.objectContaining({
|
||||||
|
className: expect.stringContaining('gonavi-sql-preview-change-line-add'),
|
||||||
|
isWholeLine: true,
|
||||||
|
linesDecorationsClassName: expect.stringContaining('gonavi-sql-preview-change-marker-add'),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
range: expect.objectContaining({ startLineNumber: 4, endLineNumber: 4 }),
|
||||||
|
options: expect.objectContaining({
|
||||||
|
className: expect.stringContaining('gonavi-sql-preview-change-line-drop'),
|
||||||
|
isWholeLine: true,
|
||||||
|
linesDecorationsClassName: expect.stringContaining('gonavi-sql-preview-change-marker-drop'),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const firstDecorationCall = mockEditor.deltaDecorations.mock.calls[0] as unknown as [unknown, unknown[]];
|
||||||
|
expect(firstDecorationCall[1]).toHaveLength(2);
|
||||||
|
expect(firstDecorationCall[1]).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
options: expect.not.objectContaining({
|
||||||
|
glyphMarginClassName: expect.any(String),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the dark SQL preview theme when dark mode is enabled', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<TableDesignerSqlPreview sql="CREATE TABLE users (id int);" darkMode />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain('data-theme="gonavi-sql-preview-dark"');
|
||||||
|
expect(mockMonaco.editor.defineTheme).toHaveBeenCalledWith(
|
||||||
|
'gonavi-sql-preview-dark',
|
||||||
|
expect.objectContaining({
|
||||||
|
base: 'vs-dark',
|
||||||
|
inherit: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
250
frontend/src/components/TableDesignerSqlPreview.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react';
|
||||||
|
|
||||||
|
interface TableDesignerSqlPreviewProps {
|
||||||
|
sql: string;
|
||||||
|
darkMode?: boolean;
|
||||||
|
height?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SqlChangeHighlightKind =
|
||||||
|
| 'add'
|
||||||
|
| 'comment'
|
||||||
|
| 'constraint'
|
||||||
|
| 'create'
|
||||||
|
| 'drop'
|
||||||
|
| 'modify'
|
||||||
|
| 'rename';
|
||||||
|
|
||||||
|
export interface SqlChangeHighlight {
|
||||||
|
line: string;
|
||||||
|
lineNumber: number;
|
||||||
|
kind: SqlChangeHighlightKind;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQL_PREVIEW_LIGHT_THEME = 'gonavi-sql-preview-light';
|
||||||
|
const SQL_PREVIEW_DARK_THEME = 'gonavi-sql-preview-dark';
|
||||||
|
|
||||||
|
const CHANGE_LINE_RULES: Array<{
|
||||||
|
kind: SqlChangeHighlightKind;
|
||||||
|
label: string;
|
||||||
|
pattern: RegExp;
|
||||||
|
}> = [
|
||||||
|
{ kind: 'rename', label: '重命名变更', pattern: /\b(RENAME\s+COLUMN|CHANGE\s+COLUMN|RENAME\s+TO|SP_RENAME)\b/i },
|
||||||
|
{ kind: 'add', label: '新增变更', pattern: /\b(ADD\s+COLUMN|ADD\s+PRIMARY\s+KEY)\b/i },
|
||||||
|
{ kind: 'drop', label: '删除变更', pattern: /\b(DROP\s+COLUMN|DROP\s+PRIMARY\s+KEY)\b/i },
|
||||||
|
{ kind: 'modify', label: '字段属性变更', pattern: /\b(MODIFY\s+COLUMN|ALTER\s+COLUMN|SET\s+DATA\s+TYPE|SET\s+DEFAULT|DROP\s+DEFAULT|SET\s+NOT\s+NULL|DROP\s+NOT\s+NULL)\b/i },
|
||||||
|
{ kind: 'constraint', label: '约束变更', pattern: /\b(ADD\s+CONSTRAINT|DROP\s+CONSTRAINT)\b/i },
|
||||||
|
{ kind: 'comment', label: '备注变更', pattern: /\b(COMMENT\s+ON\s+COLUMN|COMMENT\s+ON\s+TABLE)\b/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CREATE_TABLE_PATTERN = /^\s*CREATE\s+TABLE\b/i;
|
||||||
|
|
||||||
|
const getCreateTableLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('--')) return null;
|
||||||
|
return {
|
||||||
|
line,
|
||||||
|
lineNumber,
|
||||||
|
kind: 'create',
|
||||||
|
label: '新建表结构',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlterLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('--')) return null;
|
||||||
|
|
||||||
|
const matchedRule = CHANGE_LINE_RULES.find((rule) => rule.pattern.test(trimmed));
|
||||||
|
if (!matchedRule) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
line,
|
||||||
|
lineNumber,
|
||||||
|
kind: matchedRule.kind,
|
||||||
|
label: matchedRule.label,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveSqlChangeHighlights = (sql: string): SqlChangeHighlight[] => {
|
||||||
|
const lines = sql.split(/\r?\n/);
|
||||||
|
const isCreateTableSql = lines.some((line) => CREATE_TABLE_PATTERN.test(line));
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line, index) => (
|
||||||
|
isCreateTableSql
|
||||||
|
? getCreateTableLineHighlight(line, index + 1)
|
||||||
|
: getAlterLineHighlight(line, index + 1)
|
||||||
|
))
|
||||||
|
.filter((highlight): highlight is SqlChangeHighlight => Boolean(highlight));
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerSqlPreviewThemes: BeforeMount = (monaco) => {
|
||||||
|
monaco.editor.defineTheme(SQL_PREVIEW_LIGHT_THEME, {
|
||||||
|
base: 'vs',
|
||||||
|
inherit: true,
|
||||||
|
rules: [
|
||||||
|
{ token: 'keyword', foreground: '006C9C', fontStyle: 'bold' },
|
||||||
|
{ token: 'operator', foreground: '8250DF' },
|
||||||
|
{ token: 'number', foreground: 'B45309' },
|
||||||
|
{ token: 'string', foreground: '15803D' },
|
||||||
|
{ token: 'comment', foreground: '64748B', fontStyle: 'italic' },
|
||||||
|
{ token: 'predefined', foreground: '0F766E' },
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#00000000',
|
||||||
|
'editor.lineHighlightBackground': '#0F172A0A',
|
||||||
|
'editorGutter.background': '#00000000',
|
||||||
|
'editorLineNumber.foreground': '#94A3B8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
monaco.editor.defineTheme(SQL_PREVIEW_DARK_THEME, {
|
||||||
|
base: 'vs-dark',
|
||||||
|
inherit: true,
|
||||||
|
rules: [
|
||||||
|
{ token: 'keyword', foreground: '7DD3FC', fontStyle: 'bold' },
|
||||||
|
{ token: 'operator', foreground: 'C4B5FD' },
|
||||||
|
{ token: 'number', foreground: 'FDBA74' },
|
||||||
|
{ token: 'string', foreground: '86EFAC' },
|
||||||
|
{ token: 'comment', foreground: '94A3B8', fontStyle: 'italic' },
|
||||||
|
{ token: 'predefined', foreground: '5EEAD4' },
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#00000000',
|
||||||
|
'editor.lineHighlightBackground': '#FFFFFF12',
|
||||||
|
'editorGutter.background': '#00000000',
|
||||||
|
'editorLineNumber.foreground': '#64748B',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLineDecorationClassName = (kind: SqlChangeHighlightKind): string =>
|
||||||
|
`gonavi-sql-preview-change-line gonavi-sql-preview-change-line-${kind}`;
|
||||||
|
|
||||||
|
const getLineDecorationMarkerClassName = (kind: SqlChangeHighlightKind): string =>
|
||||||
|
`gonavi-sql-preview-change-marker gonavi-sql-preview-change-marker-${kind}`;
|
||||||
|
|
||||||
|
const TableDesignerSqlPreview: React.FC<TableDesignerSqlPreviewProps> = ({
|
||||||
|
sql,
|
||||||
|
darkMode = false,
|
||||||
|
height = '360px',
|
||||||
|
}) => {
|
||||||
|
const decorationIdsRef = useRef<string[]>([]);
|
||||||
|
const editorRef = useRef<any>(null);
|
||||||
|
const monacoRef = useRef<any>(null);
|
||||||
|
const changeHighlights = useMemo(() => resolveSqlChangeHighlights(sql), [sql]);
|
||||||
|
|
||||||
|
const applyChangeDecorations = useCallback(() => {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
const monaco = monacoRef.current;
|
||||||
|
const model = editor?.getModel?.();
|
||||||
|
if (!editor || !monaco || !model) return;
|
||||||
|
|
||||||
|
const lineCount = model.getLineCount();
|
||||||
|
const decorations = changeHighlights
|
||||||
|
.filter((highlight) => highlight.lineNumber <= lineCount)
|
||||||
|
.map((highlight) => {
|
||||||
|
const endColumn = Math.max(1, model.getLineMaxColumn(highlight.lineNumber));
|
||||||
|
return {
|
||||||
|
range: new monaco.Range(highlight.lineNumber, 1, highlight.lineNumber, endColumn),
|
||||||
|
options: {
|
||||||
|
className: getLineDecorationClassName(highlight.kind),
|
||||||
|
hoverMessage: { value: highlight.label },
|
||||||
|
isWholeLine: true,
|
||||||
|
linesDecorationsClassName: getLineDecorationMarkerClassName(highlight.kind),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, decorations);
|
||||||
|
}, [changeHighlights]);
|
||||||
|
|
||||||
|
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
monacoRef.current = monaco;
|
||||||
|
applyChangeDecorations();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyChangeDecorations();
|
||||||
|
}, [applyChangeDecorations, sql]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-table-designer-sql-preview="true"
|
||||||
|
style={{
|
||||||
|
maxHeight: 400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: darkMode ? '1px solid #333' : '1px solid #eee',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.gonavi-sql-preview-change-line {
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.gonavi-sql-preview-change-line-add,
|
||||||
|
.gonavi-sql-preview-change-line-create {
|
||||||
|
background: rgba(22, 163, 74, 0.14);
|
||||||
|
border-left-color: #16a34a;
|
||||||
|
}
|
||||||
|
.gonavi-sql-preview-change-line-drop {
|
||||||
|
background: rgba(220, 38, 38, 0.14);
|
||||||
|
border-left-color: #dc2626;
|
||||||
|
}
|
||||||
|
.gonavi-sql-preview-change-line-modify,
|
||||||
|
.gonavi-sql-preview-change-line-rename,
|
||||||
|
.gonavi-sql-preview-change-line-constraint,
|
||||||
|
.gonavi-sql-preview-change-line-comment {
|
||||||
|
background: rgba(217, 119, 6, 0.16);
|
||||||
|
border-left-color: #d97706;
|
||||||
|
}
|
||||||
|
.gonavi-sql-preview-change-marker {
|
||||||
|
width: 4px !important;
|
||||||
|
margin-left: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.gonavi-sql-preview-change-marker-add,
|
||||||
|
.gonavi-sql-preview-change-marker-create {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
.gonavi-sql-preview-change-marker-drop {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
.gonavi-sql-preview-change-marker-modify,
|
||||||
|
.gonavi-sql-preview-change-marker-rename,
|
||||||
|
.gonavi-sql-preview-change-marker-constraint,
|
||||||
|
.gonavi-sql-preview-change-marker-comment {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<Editor
|
||||||
|
beforeMount={registerSqlPreviewThemes}
|
||||||
|
defaultLanguage="sql"
|
||||||
|
height={height}
|
||||||
|
language="sql"
|
||||||
|
onMount={handleEditorMount}
|
||||||
|
options={{
|
||||||
|
automaticLayout: true,
|
||||||
|
fontFamily: '"JetBrains Mono", "Cascadia Code", Consolas, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
lineDecorationsWidth: 14,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
padding: { top: 8, bottom: 8 },
|
||||||
|
readOnly: true,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: 'on',
|
||||||
|
}}
|
||||||
|
theme={darkMode ? SQL_PREVIEW_DARK_THEME : SQL_PREVIEW_LIGHT_THEME}
|
||||||
|
value={sql}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TableDesignerSqlPreview;
|
||||||
749
frontend/src/components/TableOverview.tsx
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } from 'react';
|
||||||
|
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd';
|
||||||
|
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
|
||||||
|
import type { TabData } from '../types';
|
||||||
|
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||||
|
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||||
|
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||||
|
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||||
|
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
|
||||||
|
import {
|
||||||
|
TABLE_OVERVIEW_RENDER_BATCH_SIZE,
|
||||||
|
buildTableOverviewSearchIndex,
|
||||||
|
filterAndSortTableOverviewRows,
|
||||||
|
resolveTableOverviewVisibleRows,
|
||||||
|
type TableOverviewSortField,
|
||||||
|
type TableOverviewSortOrder,
|
||||||
|
} from '../utils/tableOverviewFilter';
|
||||||
|
|
||||||
|
interface TableOverviewProps {
|
||||||
|
tab: TabData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableStatRow {
|
||||||
|
name: string;
|
||||||
|
comment: string;
|
||||||
|
rows: number;
|
||||||
|
dataSize: number;
|
||||||
|
indexSize: number;
|
||||||
|
engine: string;
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortField = TableOverviewSortField;
|
||||||
|
type SortOrder = TableOverviewSortOrder;
|
||||||
|
type ViewMode = 'card' | 'list';
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (!bytes || bytes <= 0) return '—';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRows = (count: number): string => {
|
||||||
|
if (count === undefined || count === null || count < 0) return '—';
|
||||||
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
|
||||||
|
return String(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMetadataDialect = (connType: string, driver?: string): string => {
|
||||||
|
const type = (connType || '').trim().toLowerCase();
|
||||||
|
if (type === 'custom') {
|
||||||
|
const d = (driver || '').trim().toLowerCase();
|
||||||
|
if (d === 'diros' || d === 'doris') return 'mysql';
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||||
|
if (type === 'dameng') return 'dm';
|
||||||
|
return type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
|
||||||
|
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
|
||||||
|
switch (dialect) {
|
||||||
|
case 'mysql':
|
||||||
|
return `
|
||||||
|
SELECT
|
||||||
|
TABLE_NAME AS table_name,
|
||||||
|
TABLE_COMMENT AS table_comment,
|
||||||
|
TABLE_ROWS AS table_rows,
|
||||||
|
DATA_LENGTH AS data_length,
|
||||||
|
INDEX_LENGTH AS index_length,
|
||||||
|
ENGINE AS engine,
|
||||||
|
CREATE_TIME AS create_time,
|
||||||
|
UPDATE_TIME AS update_time
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = '${escapeLiteral(dbName)}'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name`;
|
||||||
|
case 'postgres':
|
||||||
|
case 'kingbase':
|
||||||
|
case 'vastbase':
|
||||||
|
case 'highgo': {
|
||||||
|
const schema = schemaName || 'public';
|
||||||
|
return `
|
||||||
|
SELECT
|
||||||
|
n.nspname || '.' || c.relname AS table_name,
|
||||||
|
obj_description(c.oid, 'pg_class') AS table_comment,
|
||||||
|
c.reltuples::bigint AS table_rows,
|
||||||
|
pg_total_relation_size(c.oid) AS data_length,
|
||||||
|
pg_indexes_size(c.oid) AS index_length
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relkind = 'r'
|
||||||
|
AND n.nspname = '${escapeLiteral(schema)}'
|
||||||
|
ORDER BY c.relname`;
|
||||||
|
}
|
||||||
|
case 'sqlserver': {
|
||||||
|
const safeDB = `[${dbName.replace(/]/g, ']]')}]`;
|
||||||
|
return `
|
||||||
|
SELECT
|
||||||
|
s.name + '.' + t.name AS table_name,
|
||||||
|
ep.value AS table_comment,
|
||||||
|
SUM(p.rows) AS table_rows,
|
||||||
|
SUM(a.total_pages) * 8 * 1024 AS data_length,
|
||||||
|
SUM(a.used_pages) * 8 * 1024 AS index_length
|
||||||
|
FROM ${safeDB}.sys.tables t
|
||||||
|
JOIN ${safeDB}.sys.schemas s ON t.schema_id = s.schema_id
|
||||||
|
LEFT JOIN ${safeDB}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description'
|
||||||
|
LEFT JOIN ${safeDB}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)
|
||||||
|
LEFT JOIN ${safeDB}.sys.allocation_units a ON p.partition_id = a.container_id
|
||||||
|
WHERE t.type = 'U'
|
||||||
|
GROUP BY s.name, t.name, ep.value
|
||||||
|
ORDER BY s.name, t.name`;
|
||||||
|
}
|
||||||
|
case 'clickhouse':
|
||||||
|
return `SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length FROM system.tables WHERE database = '${escapeLiteral(dbName)}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`;
|
||||||
|
case 'dm':
|
||||||
|
case 'oracle': {
|
||||||
|
const owner = (schemaName || dbName).toUpperCase();
|
||||||
|
return `SELECT table_name, comments AS table_comment, num_rows AS table_rows, 0 AS data_length, 0 AS index_length FROM all_tab_comments JOIN all_tables USING (table_name, owner) WHERE owner = '${escapeLiteral(owner)}' ORDER BY table_name`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `SELECT table_name, '' AS table_comment, 0 AS table_rows, 0 AS data_length, 0 AS index_length FROM information_schema.tables WHERE table_schema = '${escapeLiteral(dbName)}' AND table_type = 'BASE TABLE' ORDER BY table_name`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableStatRow[] => {
|
||||||
|
return rows.map((row) => {
|
||||||
|
const get = (keys: string[]): any => {
|
||||||
|
for (const k of keys) {
|
||||||
|
for (const rk of Object.keys(row)) {
|
||||||
|
if (rk.toLowerCase() === k.toLowerCase() && row[rk] !== null && row[rk] !== undefined) return row[rk];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const strVal = (keys: string[]) => String(get(keys) ?? '').trim();
|
||||||
|
const numVal = (keys: string[]) => {
|
||||||
|
const v = get(keys);
|
||||||
|
if (v === null || v === undefined || v === '') return 0;
|
||||||
|
const n = Number(v);
|
||||||
|
return isNaN(n) ? 0 : Math.max(0, Math.round(n));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: strVal(['Name', 'table_name', 'tablename', 'TABLE_NAME']),
|
||||||
|
comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']),
|
||||||
|
rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']),
|
||||||
|
dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']),
|
||||||
|
indexSize: numVal(['Index_length', 'index_length', 'INDEX_LENGTH']),
|
||||||
|
engine: strVal(['Engine', 'engine']),
|
||||||
|
createTime: strVal(['Create_time', 'create_time']),
|
||||||
|
updateTime: strVal(['Update_time', 'update_time']),
|
||||||
|
};
|
||||||
|
}).filter(t => t.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||||
|
const connections = useStore(state => state.connections);
|
||||||
|
const theme = useStore(state => state.theme);
|
||||||
|
const addTab = useStore(state => state.addTab);
|
||||||
|
const setActiveContext = useStore(state => state.setActiveContext);
|
||||||
|
const darkMode = theme === 'dark';
|
||||||
|
|
||||||
|
const [tables, setTables] = useState<TableStatRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [sortField, setSortField] = useState<SortField>('name');
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
|
const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
|
||||||
|
const deferredSearchText = useDeferredValue(searchText);
|
||||||
|
const isSearchPending = searchText !== deferredSearchText;
|
||||||
|
|
||||||
|
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
|
||||||
|
const metadataDialect = useMemo(
|
||||||
|
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver),
|
||||||
|
[connection?.config?.driver, connection?.config?.type]
|
||||||
|
);
|
||||||
|
const autoFetchVisible = useAutoFetchVisibility();
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!connection) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const config = {
|
||||||
|
...connection.config,
|
||||||
|
port: Number(connection.config.port),
|
||||||
|
password: connection.config.password || '',
|
||||||
|
database: connection.config.database || '',
|
||||||
|
useSSH: connection.config.useSSH || false,
|
||||||
|
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
|
||||||
|
};
|
||||||
|
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', (tab as any).schemaName);
|
||||||
|
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
|
||||||
|
if (res.success && Array.isArray(res.data)) {
|
||||||
|
setTables(parseTableStats(metadataDialect, res.data));
|
||||||
|
} else {
|
||||||
|
message.error('获取表信息失败: ' + (res.message || '未知错误'));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('获取表信息失败: ' + (e?.message || String(e)));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [connection, metadataDialect, tab.dbName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoFetchVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadData();
|
||||||
|
}, [autoFetchVisible, loadData]);
|
||||||
|
|
||||||
|
const tableSearchIndex = useMemo(() => buildTableOverviewSearchIndex(tables), [tables]);
|
||||||
|
|
||||||
|
const sortedFiltered = useMemo(() => (
|
||||||
|
filterAndSortTableOverviewRows(tableSearchIndex, deferredSearchText, sortField, sortOrder)
|
||||||
|
), [deferredSearchText, sortField, sortOrder, tableSearchIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleTableLimit(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
|
||||||
|
}, [deferredSearchText, sortField, sortOrder, viewMode, tables]);
|
||||||
|
|
||||||
|
const visibleOverview = useMemo(() => (
|
||||||
|
resolveTableOverviewVisibleRows(sortedFiltered, visibleTableLimit)
|
||||||
|
), [sortedFiltered, visibleTableLimit]);
|
||||||
|
|
||||||
|
const visibleTables = visibleOverview.visibleRows;
|
||||||
|
|
||||||
|
const openTable = useCallback((tableName: string) => {
|
||||||
|
if (!connection) return;
|
||||||
|
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
|
||||||
|
addTab({
|
||||||
|
id: `${connection.id}-${tab.dbName}-${tableName}`,
|
||||||
|
title: tableName,
|
||||||
|
type: 'table',
|
||||||
|
connectionId: connection.id,
|
||||||
|
dbName: tab.dbName,
|
||||||
|
tableName,
|
||||||
|
});
|
||||||
|
}, [connection, tab.dbName, addTab, setActiveContext]);
|
||||||
|
|
||||||
|
const openDesign = useCallback((tableName: string) => {
|
||||||
|
if (!connection) return;
|
||||||
|
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
|
||||||
|
addTab({
|
||||||
|
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
|
||||||
|
title: `设计表 (${tableName})`,
|
||||||
|
type: 'design',
|
||||||
|
connectionId: connection.id,
|
||||||
|
dbName: tab.dbName,
|
||||||
|
tableName,
|
||||||
|
initialTab: 'columns',
|
||||||
|
readOnly: false,
|
||||||
|
});
|
||||||
|
}, [connection, tab.dbName, addTab, setActiveContext]);
|
||||||
|
|
||||||
|
const buildConfig = useCallback(() => {
|
||||||
|
if (!connection) return null;
|
||||||
|
return {
|
||||||
|
...connection.config,
|
||||||
|
port: Number(connection.config.port),
|
||||||
|
password: connection.config.password || '',
|
||||||
|
database: connection.config.database || '',
|
||||||
|
useSSH: connection.config.useSSH || false,
|
||||||
|
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
|
||||||
|
};
|
||||||
|
}, [connection]);
|
||||||
|
|
||||||
|
const handleCopyStructure = useCallback(async (tableName: string) => {
|
||||||
|
const config = buildConfig();
|
||||||
|
if (!config) return;
|
||||||
|
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
|
||||||
|
if (res.success) {
|
||||||
|
navigator.clipboard.writeText(res.data as string);
|
||||||
|
message.success('表结构已复制到剪贴板');
|
||||||
|
} else {
|
||||||
|
message.error(res.message);
|
||||||
|
}
|
||||||
|
}, [buildConfig, tab.dbName]);
|
||||||
|
|
||||||
|
const handleExport = useCallback(async (tableName: string, format: string) => {
|
||||||
|
const config = buildConfig();
|
||||||
|
if (!config) return;
|
||||||
|
const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0);
|
||||||
|
const res = await ExportTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, format);
|
||||||
|
hide();
|
||||||
|
if (res.success) {
|
||||||
|
message.success('导出成功');
|
||||||
|
} else if (res.message !== '已取消') {
|
||||||
|
message.error('导出失败: ' + res.message);
|
||||||
|
}
|
||||||
|
}, [buildConfig, tab.dbName]);
|
||||||
|
|
||||||
|
const handleDeleteTable = useCallback((tableName: string) => {
|
||||||
|
const config = buildConfig();
|
||||||
|
if (!config) return;
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除表',
|
||||||
|
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
const res = await DropTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
|
||||||
|
if (res.success) {
|
||||||
|
message.success('表删除成功');
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
message.error('删除失败: ' + res.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [buildConfig, tab.dbName, loadData]);
|
||||||
|
|
||||||
|
const handleTableDataDangerAction = useCallback((tableName: string, action: TableDataDangerActionKind) => {
|
||||||
|
const config = buildConfig();
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const { label, progressLabel } = getTableDataDangerActionMeta(action);
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认${label}`,
|
||||||
|
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
|
||||||
|
okText: '继续',
|
||||||
|
cancelText: '取消',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
const app = (window as any).go.app.App;
|
||||||
|
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
|
||||||
|
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
|
||||||
|
try {
|
||||||
|
const res = await app[methodName](buildRpcConnectionConfig(config) as any, tab.dbName || '', [tableName]);
|
||||||
|
hide();
|
||||||
|
if (res.success) {
|
||||||
|
message.success(`${progressLabel}成功`);
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
message.error(`${progressLabel}失败: ${res.message}`);
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
hide();
|
||||||
|
message.error(`${progressLabel}失败: ${e?.message || String(e)}`);
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [buildConfig, tab.dbName, loadData]);
|
||||||
|
|
||||||
|
const handleRenameTable = useCallback((tableName: string) => {
|
||||||
|
const config = buildConfig();
|
||||||
|
if (!config) return;
|
||||||
|
let newName = tableName;
|
||||||
|
Modal.confirm({
|
||||||
|
title: '重命名表',
|
||||||
|
content: (
|
||||||
|
<Input
|
||||||
|
{...noAutoCapInputProps}
|
||||||
|
defaultValue={tableName}
|
||||||
|
onChange={e => { newName = e.target.value; }}
|
||||||
|
placeholder="输入新表名"
|
||||||
|
autoFocus
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
onOk: async () => {
|
||||||
|
const trimmed = newName.trim();
|
||||||
|
if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); }
|
||||||
|
if (trimmed === tableName) { message.warning('新旧表名相同'); return; }
|
||||||
|
const res = await RenameTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, trimmed);
|
||||||
|
if (res.success) {
|
||||||
|
message.success('表重命名成功');
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
message.error('重命名失败: ' + res.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [buildConfig, tab.dbName, loadData]);
|
||||||
|
|
||||||
|
// --- Theme ---
|
||||||
|
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
|
||||||
|
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
|
||||||
|
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
|
||||||
|
const textPrimary = darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)';
|
||||||
|
const textSecondary = darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)';
|
||||||
|
const textMuted = darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)';
|
||||||
|
const accentColor = '#1677ff';
|
||||||
|
const containerBg = darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.01)';
|
||||||
|
|
||||||
|
const toggleSort = (field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder(field === 'name' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortMenuItems = [
|
||||||
|
{ key: 'name', label: `按名称${sortField === 'name' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('name') },
|
||||||
|
{ key: 'rows', label: `按行数${sortField === 'rows' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('rows') },
|
||||||
|
{ key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const totalRows = useMemo(() => tables.reduce((s, t) => s + t.rows, 0), [tables]);
|
||||||
|
const totalSize = useMemo(() => tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0), [tables]);
|
||||||
|
const maxCombinedSize = useMemo(() => sortedFiltered.reduce((max, table) => {
|
||||||
|
return Math.max(max, table.dataSize + table.indexSize);
|
||||||
|
}, 0), [sortedFiltered]);
|
||||||
|
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
|
||||||
|
<Spin size="large" tip="加载表信息..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
|
||||||
|
<DatabaseOutlined style={{ fontSize: 16, color: accentColor }} />
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
|
||||||
|
<span style={{ fontSize: 12, color: textMuted }}>
|
||||||
|
{tables.length} 张表 · {formatRows(totalRows)} 行 · {formatSize(totalSize)}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<Input
|
||||||
|
{...noAutoCapInputProps}
|
||||||
|
placeholder="搜索表名或注释..."
|
||||||
|
prefix={<SearchOutlined style={{ color: textMuted }} />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 240 }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
|
||||||
|
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||||
|
</Dropdown>
|
||||||
|
<div style={{ display: 'flex', gap: 2, padding: 2, borderRadius: 6, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
|
||||||
|
<Tooltip title="卡片视图">
|
||||||
|
<div
|
||||||
|
onClick={() => setViewMode('card')}
|
||||||
|
style={{
|
||||||
|
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
|
||||||
|
background: viewMode === 'card' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
|
||||||
|
boxShadow: viewMode === 'card' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||||
|
color: viewMode === 'card' ? accentColor : textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppstoreOutlined style={{ fontSize: 14 }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="列表视图">
|
||||||
|
<div
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
style={{
|
||||||
|
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
|
||||||
|
background: viewMode === 'list' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
|
||||||
|
boxShadow: viewMode === 'list' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||||
|
color: viewMode === 'list' ? accentColor : textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UnorderedListOutlined style={{ fontSize: 14 }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
|
||||||
|
{sortedFiltered.length > 0 && (isSearchPending || visibleOverview.hiddenCount > 0 || deferredSearchText.trim()) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 10,
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.025)',
|
||||||
|
color: textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{isSearchPending
|
||||||
|
? '正在更新筛选结果...'
|
||||||
|
: `匹配 ${sortedFiltered.length} 张表,当前渲染 ${visibleTables.length} 张`}
|
||||||
|
</span>
|
||||||
|
{visibleOverview.hiddenCount > 0 && (
|
||||||
|
<span>还有 {visibleOverview.hiddenCount} 张未渲染,可继续加载或缩小搜索范围</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sortedFiltered.length === 0 ? (
|
||||||
|
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
|
||||||
|
) : viewMode === 'card' ? (
|
||||||
|
/* ========== 卡片视图 ========== */
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
{visibleTables.map(t => (
|
||||||
|
<Dropdown
|
||||||
|
key={t.name}
|
||||||
|
trigger={['contextMenu']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
|
||||||
|
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
|
||||||
|
addTab({
|
||||||
|
id: `query-${Date.now()}`,
|
||||||
|
title: '新建查询',
|
||||||
|
type: 'query',
|
||||||
|
connectionId: tab.connectionId,
|
||||||
|
dbName: tab.dbName,
|
||||||
|
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||||
|
});
|
||||||
|
}},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
|
||||||
|
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||||
|
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||||
|
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||||
|
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||||
|
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
|
||||||
|
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
|
||||||
|
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
|
||||||
|
]},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
|
||||||
|
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
|
||||||
|
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
|
||||||
|
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
|
||||||
|
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
|
||||||
|
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onDoubleClick={() => openTable(t.name)}
|
||||||
|
style={{
|
||||||
|
background: cardBg,
|
||||||
|
border: `1px solid ${cardBorder}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '14px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
|
||||||
|
<Tooltip title={t.name} mouseEnterDelay={0.4}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{t.comment && (
|
||||||
|
<Tooltip title={t.comment} mouseEnterDelay={0.4}>
|
||||||
|
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{t.comment}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
|
||||||
|
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
|
||||||
|
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
|
||||||
|
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ========== 行视图 ========== */
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{visibleTables.map(t => {
|
||||||
|
const combinedSize = t.dataSize + t.indexSize;
|
||||||
|
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
|
||||||
|
const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%';
|
||||||
|
const fillColor = darkMode ? 'rgba(22,119,255,0.18)' : 'rgba(22,119,255,0.12)';
|
||||||
|
const rowSecondary = t.comment || (t.engine ? `${t.engine} 表` : '双击打开数据,右键查看更多操作');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
key={t.name}
|
||||||
|
trigger={['contextMenu']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
|
||||||
|
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
|
||||||
|
addTab({
|
||||||
|
id: `query-${Date.now()}`,
|
||||||
|
title: '新建查询',
|
||||||
|
type: 'query',
|
||||||
|
connectionId: tab.connectionId,
|
||||||
|
dbName: tab.dbName,
|
||||||
|
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||||
|
});
|
||||||
|
}},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
|
||||||
|
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||||
|
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||||
|
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||||
|
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||||
|
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
|
||||||
|
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
|
||||||
|
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
|
||||||
|
]},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
|
||||||
|
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
|
||||||
|
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
|
||||||
|
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
|
||||||
|
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
|
||||||
|
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onDoubleClick={() => openTable(t.name)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${cardBorder}`,
|
||||||
|
background: cardBg,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: fillWidth,
|
||||||
|
background: fillColor,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transition: 'width 0.2s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 16,
|
||||||
|
padding: '14px 16px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0, flex: '1 1 320px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
|
||||||
|
<Tooltip title={t.name} mouseEnterDelay={0.4}>
|
||||||
|
<span style={{ color: textPrimary, fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
{t.engine && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 11,
|
||||||
|
color: textMuted,
|
||||||
|
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.engine}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Tooltip title={rowSecondary} mouseEnterDelay={0.4}>
|
||||||
|
<div style={{ marginTop: 6, color: textSecondary, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{rowSecondary}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap', fontSize: 12 }}>
|
||||||
|
<div style={{ minWidth: 96, textAlign: 'right' }}>
|
||||||
|
<div style={{ color: textMuted }}>行数</div>
|
||||||
|
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 110, textAlign: 'right' }}>
|
||||||
|
<div style={{ color: textMuted }}>数据大小</div>
|
||||||
|
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 110, textAlign: 'right' }}>
|
||||||
|
<div style={{ color: textMuted }}>索引大小</div>
|
||||||
|
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 96, textAlign: 'right' }}>
|
||||||
|
<div style={{ color: textMuted }}>相对大小</div>
|
||||||
|
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sortedFiltered.length > 0 && visibleOverview.hiddenCount > 0 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0 4px' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => setVisibleTableLimit(limit => limit + TABLE_OVERVIEW_RENDER_BATCH_SIZE)}
|
||||||
|
>
|
||||||
|
显示更多表(剩余 {visibleOverview.hiddenCount})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TableOverview;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Editor, { loader } from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import { Spin, Alert } from 'antd';
|
import { Spin, Alert } from 'antd';
|
||||||
import { TabData } from '../types';
|
import { TabData } from '../types';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { DBQuery } from '../../wailsjs/go/app/App';
|
import { DBQuery } from '../../wailsjs/go/app/App';
|
||||||
|
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||||
|
|
||||||
interface TriggerViewerProps {
|
interface TriggerViewerProps {
|
||||||
tab: TabData;
|
tab: TabData;
|
||||||
@@ -18,31 +19,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
|
|||||||
const theme = useStore(state => state.theme);
|
const theme = useStore(state => state.theme);
|
||||||
const darkMode = theme === 'dark';
|
const darkMode = theme === 'dark';
|
||||||
|
|
||||||
// 初始化透明 Monaco Editor 主题
|
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
|
||||||
useEffect(() => {
|
|
||||||
loader.init().then(monaco => {
|
|
||||||
monaco.editor.defineTheme('transparent-dark', {
|
|
||||||
base: 'vs-dark',
|
|
||||||
inherit: true,
|
|
||||||
rules: [],
|
|
||||||
colors: {
|
|
||||||
'editor.background': '#00000000',
|
|
||||||
'editor.lineHighlightBackground': '#ffffff10',
|
|
||||||
'editorGutter.background': '#00000000',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
monaco.editor.defineTheme('transparent-light', {
|
|
||||||
base: 'vs',
|
|
||||||
inherit: true,
|
|
||||||
rules: [],
|
|
||||||
colors: {
|
|
||||||
'editor.background': '#00000000',
|
|
||||||
'editor.lineHighlightBackground': '#00000010',
|
|
||||||
'editorGutter.background': '#00000000',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
||||||
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
|
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
|
||||||
@@ -124,7 +101,7 @@ LIMIT 1`];
|
|||||||
const sql = String(query || '').trim();
|
const sql = String(query || '').trim();
|
||||||
if (!sql) continue;
|
if (!sql) continue;
|
||||||
try {
|
try {
|
||||||
const result = await DBQuery(config as any, dbName, sql);
|
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
|
||||||
if (!result.success || !Array.isArray(result.data)) {
|
if (!result.success || !Array.isArray(result.data)) {
|
||||||
lastMessage = result.message || lastMessage;
|
lastMessage = result.message || lastMessage;
|
||||||
continue;
|
continue;
|
||||||
@@ -150,7 +127,7 @@ LIMIT 1`];
|
|||||||
];
|
];
|
||||||
for (const query of candidates) {
|
for (const query of candidates) {
|
||||||
try {
|
try {
|
||||||
const result = await DBQuery(config as any, dbName, query);
|
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
|
||||||
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
|
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
76
frontend/src/components/ai/AIChatHeader.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Tooltip } from 'antd';
|
||||||
|
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined } from '@ant-design/icons';
|
||||||
|
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||||
|
import type { AIChatMessage } from '../../types';
|
||||||
|
|
||||||
|
interface AIChatHeaderProps {
|
||||||
|
darkMode: boolean;
|
||||||
|
mutedColor: string;
|
||||||
|
textColor: string;
|
||||||
|
overlayTheme: OverlayWorkbenchTheme;
|
||||||
|
onHistoryClick: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onSettingsClick: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
messages?: AIChatMessage[];
|
||||||
|
sessionTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToMarkdown = (messages: AIChatMessage[], title: string) => {
|
||||||
|
const lines: string[] = [`# ${title}`, '', `> 导出时间:${new Date().toLocaleString()}`, ''];
|
||||||
|
messages.forEach(msg => {
|
||||||
|
const role = msg.role === 'user' ? '👤 You' : '🤖 GoNavi AI';
|
||||||
|
lines.push(`## ${role}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(msg.content);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('---');
|
||||||
|
lines.push('');
|
||||||
|
});
|
||||||
|
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '-')}.md`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
|
||||||
|
darkMode, mutedColor, textColor, overlayTheme,
|
||||||
|
onHistoryClick, onClear, onSettingsClick, onClose,
|
||||||
|
messages = [], sessionTitle = '新对话'
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
|
||||||
|
<div className="ai-chat-header-left" style={{ gap: 8 }}>
|
||||||
|
<Tooltip title="历史会话">
|
||||||
|
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
|
||||||
|
</Tooltip>
|
||||||
|
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
|
||||||
|
<RobotOutlined />
|
||||||
|
</div>
|
||||||
|
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
|
||||||
|
</div>
|
||||||
|
<div className="ai-chat-header-right">
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<Tooltip title="导出为 Markdown">
|
||||||
|
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="新对话 (清空当前)">
|
||||||
|
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="AI 设置">
|
||||||
|
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="关闭面板">
|
||||||
|
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
frontend/src/components/ai/AIChatInput.notice.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AIChatInput } from './AIChatInput';
|
||||||
|
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||||
|
|
||||||
|
vi.mock('../../store', () => ({
|
||||||
|
useStore: (selector: (state: any) => any) => selector({
|
||||||
|
aiContexts: {},
|
||||||
|
addAIContext: vi.fn(),
|
||||||
|
removeAIContext: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../wailsjs/go/app/App', () => ({
|
||||||
|
DBGetTables: vi.fn(),
|
||||||
|
DBShowCreateTable: vi.fn(),
|
||||||
|
DBGetDatabases: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AIChatInput notice layout', () => {
|
||||||
|
it('renders the composer notice above the input editor', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AIChatInput
|
||||||
|
input=""
|
||||||
|
setInput={() => {}}
|
||||||
|
draftImages={[]}
|
||||||
|
setDraftImages={() => {}}
|
||||||
|
sending={false}
|
||||||
|
onSend={() => {}}
|
||||||
|
onStop={() => {}}
|
||||||
|
handleKeyDown={() => {}}
|
||||||
|
activeConnName=""
|
||||||
|
activeContext={null}
|
||||||
|
activeProvider={{ model: '', models: [] }}
|
||||||
|
dynamicModels={[]}
|
||||||
|
loadingModels={false}
|
||||||
|
composerNotice={{
|
||||||
|
tone: 'error',
|
||||||
|
title: '模型列表加载失败',
|
||||||
|
description: '请检查供应商入口和 API Key。',
|
||||||
|
}}
|
||||||
|
onModelChange={() => {}}
|
||||||
|
onFetchModels={() => {}}
|
||||||
|
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||||
|
darkMode={false}
|
||||||
|
textColor="#162033"
|
||||||
|
mutedColor="rgba(16,24,40,0.55)"
|
||||||
|
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const noticeIndex = markup.indexOf('data-ai-chat-composer-notice="true"');
|
||||||
|
const inputIndex = markup.indexOf('data-ai-chat-composer-input="true"');
|
||||||
|
|
||||||
|
expect(noticeIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(inputIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(noticeIndex).toBeLessThan(inputIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
633
frontend/src/components/ai/AIChatInput.tsx
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
|
||||||
|
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||||
|
import { useStore } from '../../store';
|
||||||
|
import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App';
|
||||||
|
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||||
|
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
|
||||||
|
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
|
||||||
|
|
||||||
|
interface AIChatInputProps {
|
||||||
|
input: string;
|
||||||
|
setInput: (val: string) => void;
|
||||||
|
draftImages: string[];
|
||||||
|
setDraftImages: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
sending: boolean;
|
||||||
|
onSend: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
handleKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
|
activeConnName: string;
|
||||||
|
activeContext: any;
|
||||||
|
activeProvider: any;
|
||||||
|
dynamicModels: string[];
|
||||||
|
loadingModels: boolean;
|
||||||
|
composerNotice?: AIComposerNotice | null;
|
||||||
|
onModelChange: (val: string) => void;
|
||||||
|
onFetchModels: () => void;
|
||||||
|
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
|
darkMode: boolean;
|
||||||
|
textColor: string;
|
||||||
|
mutedColor: string;
|
||||||
|
overlayTheme: OverlayWorkbenchTheme;
|
||||||
|
contextUsageChars?: number;
|
||||||
|
maxContextChars?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||||
|
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
|
||||||
|
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
|
||||||
|
composerNotice,
|
||||||
|
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
|
||||||
|
contextUsageChars, maxContextChars
|
||||||
|
}) => {
|
||||||
|
const [contextOpen, setContextOpen] = React.useState(false);
|
||||||
|
const [contextLoading, setContextLoading] = React.useState(false);
|
||||||
|
const [contextTables, setContextTables] = React.useState<{name: string}[]>([]);
|
||||||
|
const [selectedTableKeys, setSelectedTableKeys] = React.useState<string[]>([]);
|
||||||
|
const [searchText, setSearchText] = React.useState('');
|
||||||
|
const [appendingContext, setAppendingContext] = React.useState(false);
|
||||||
|
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
files.forEach(file => {
|
||||||
|
if (file.type.indexOf('image') !== -1) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
if (event.target?.result) {
|
||||||
|
setDraftImages(prev => [...prev, event.target!.result as string]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [dbList, setDbList] = React.useState<string[]>([]);
|
||||||
|
const [selectedDbName, setSelectedDbName] = React.useState<string>('');
|
||||||
|
|
||||||
|
const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase()));
|
||||||
|
const [contextExpanded, setContextExpanded] = React.useState(false);
|
||||||
|
const composerNoticePalette = React.useMemo(() => {
|
||||||
|
if (composerNotice?.tone === 'error') {
|
||||||
|
return darkMode
|
||||||
|
? {
|
||||||
|
background: 'rgba(255,120,117,0.12)',
|
||||||
|
borderColor: 'rgba(255,120,117,0.24)',
|
||||||
|
iconColor: '#ff7875',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
background: 'rgba(255,77,79,0.08)',
|
||||||
|
borderColor: 'rgba(255,77,79,0.16)',
|
||||||
|
iconColor: '#ff4d4f',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return darkMode
|
||||||
|
? {
|
||||||
|
background: 'rgba(250,173,20,0.12)',
|
||||||
|
borderColor: 'rgba(250,173,20,0.22)',
|
||||||
|
iconColor: '#ffd666',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
background: 'rgba(250,173,20,0.08)',
|
||||||
|
borderColor: 'rgba(250,173,20,0.18)',
|
||||||
|
iconColor: '#d48806',
|
||||||
|
};
|
||||||
|
}, [composerNotice, darkMode]);
|
||||||
|
|
||||||
|
// Slash commands
|
||||||
|
const [showSlashMenu, setShowSlashMenu] = React.useState(false);
|
||||||
|
const [slashFilter, setSlashFilter] = React.useState('');
|
||||||
|
const slashCommands = React.useMemo(() => [
|
||||||
|
{ cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' },
|
||||||
|
{ cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:' },
|
||||||
|
{ cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```' },
|
||||||
|
{ cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n\n```' },
|
||||||
|
{ cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' },
|
||||||
|
{ cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' },
|
||||||
|
{ cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' },
|
||||||
|
{ cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' },
|
||||||
|
], []);
|
||||||
|
const filteredSlashCmds = slashCommands.filter(c => c.cmd.startsWith(slashFilter.toLowerCase()));
|
||||||
|
|
||||||
|
const aiContexts = useStore(state => state.aiContexts);
|
||||||
|
const addAIContext = useStore(state => state.addAIContext);
|
||||||
|
const removeAIContext = useStore(state => state.removeAIContext);
|
||||||
|
|
||||||
|
const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
|
||||||
|
const activeContextItems = aiContexts[connectionKey] || [];
|
||||||
|
|
||||||
|
const fetchTablesForDb = async (dbName: string, connConfig: any) => {
|
||||||
|
setContextLoading(true);
|
||||||
|
setSelectedDbName(dbName);
|
||||||
|
try {
|
||||||
|
const res = await DBGetTables(buildRpcConnectionConfig(connConfig), dbName);
|
||||||
|
if (res.success && Array.isArray(res.data)) {
|
||||||
|
setContextTables(res.data.map(r => ({ name: Object.values(r)[0] as string })));
|
||||||
|
} else {
|
||||||
|
message.error('获取表格失败: ' + res.message);
|
||||||
|
setContextTables([]);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
setContextTables([]);
|
||||||
|
} finally {
|
||||||
|
setContextLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenContext = async () => {
|
||||||
|
if (!activeContext?.connectionId) {
|
||||||
|
message.warning('请先在左侧选择一个数据库作为所聊上下文');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId);
|
||||||
|
if (!conn) return;
|
||||||
|
|
||||||
|
setContextOpen(true);
|
||||||
|
setContextLoading(true);
|
||||||
|
setSearchText('');
|
||||||
|
// Store dbName::tableName composite keys
|
||||||
|
setSelectedTableKeys(activeContextItems.map(c => `${c.dbName}::${c.tableName}`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch databases
|
||||||
|
const dbRes = await DBGetDatabases(buildRpcConnectionConfig(conn.config) as any);
|
||||||
|
if (dbRes.success && Array.isArray(dbRes.data)) {
|
||||||
|
const databases = dbRes.data.map((r: any) => Object.values(r)[0] as string);
|
||||||
|
setDbList(databases);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch tables for the active contextual database
|
||||||
|
const initDbName = activeContext.dbName || '';
|
||||||
|
setSelectedDbName(initDbName);
|
||||||
|
const tablesRes = await DBGetTables(buildRpcConnectionConfig(conn.config) as any, initDbName);
|
||||||
|
if (tablesRes.success && Array.isArray(tablesRes.data)) {
|
||||||
|
setContextTables(tablesRes.data.map((r: any) => ({ name: Object.values(r)[0] as string })));
|
||||||
|
} else {
|
||||||
|
setContextTables([]);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setContextLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppendContext = async () => {
|
||||||
|
const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId);
|
||||||
|
if (!conn) return;
|
||||||
|
|
||||||
|
setAppendingContext(true);
|
||||||
|
try {
|
||||||
|
let addedCount = 0;
|
||||||
|
let removedCount = 0;
|
||||||
|
|
||||||
|
for (const cx of activeContextItems) {
|
||||||
|
const key = `${cx.dbName}::${cx.tableName}`;
|
||||||
|
if (!selectedTableKeys.includes(key)) {
|
||||||
|
removeAIContext(connectionKey, cx.dbName, cx.tableName);
|
||||||
|
removedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of selectedTableKeys) {
|
||||||
|
const [dbName, tableName] = key.split('::');
|
||||||
|
if (!dbName || !tableName) continue;
|
||||||
|
|
||||||
|
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName);
|
||||||
|
let createSql = '';
|
||||||
|
if (res.success && res.data) {
|
||||||
|
if (typeof res.data === 'string') {
|
||||||
|
createSql = res.data;
|
||||||
|
} else if (Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
const row = res.data[0];
|
||||||
|
createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error(`获取表 ${dbName}.${tableName} 结构失败: ` + (res.message || '未知错误'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createSql) {
|
||||||
|
addAIContext(connectionKey, {
|
||||||
|
dbName: dbName,
|
||||||
|
tableName: tableName,
|
||||||
|
ddl: createSql
|
||||||
|
});
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (addedCount > 0 || removedCount > 0) {
|
||||||
|
if (addedCount > 0 && removedCount === 0) {
|
||||||
|
message.success(`已添加 ${addedCount} 张表的结构到上下文`);
|
||||||
|
} else if (removedCount > 0 && addedCount === 0) {
|
||||||
|
message.success(`已从上下文移除 ${removedCount} 张表的结构`);
|
||||||
|
} else {
|
||||||
|
message.success(`上下文已同步更新:新增 ${addedCount},移除 ${removedCount}`);
|
||||||
|
}
|
||||||
|
if (addedCount > 0) setContextExpanded(true);
|
||||||
|
} else {
|
||||||
|
message.info('选中的表未发生变化');
|
||||||
|
}
|
||||||
|
setContextOpen(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setAppendingContext(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
|
||||||
|
<div className="ai-chat-input-wrapper" style={{
|
||||||
|
borderColor: 'transparent',
|
||||||
|
background: 'transparent',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
gap: 8,
|
||||||
|
padding: '8px 4px 8px'
|
||||||
|
}}>
|
||||||
|
<div className="ai-chat-input-preview-area" style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{activeContextItems.length > 0 && (
|
||||||
|
<Tag
|
||||||
|
onClick={() => setContextExpanded(!contextExpanded)}
|
||||||
|
style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<DatabaseOutlined /> 关联上下文 ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
|
||||||
|
</span>
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contextExpanded && activeContextItems.map((ctx, idx) => (
|
||||||
|
<Tag
|
||||||
|
key={`ctx-${idx}`}
|
||||||
|
closable
|
||||||
|
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
|
||||||
|
style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13 }}>🗄️ {ctx.tableName}</span>
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{draftImages.map((b64, i) => (
|
||||||
|
<div key={i} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
|
||||||
|
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${i}`} />
|
||||||
|
<div
|
||||||
|
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
|
||||||
|
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{composerNotice && (
|
||||||
|
<div
|
||||||
|
data-ai-chat-composer-notice="true"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 8,
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: composerNoticePalette.background,
|
||||||
|
border: `1px solid ${composerNoticePalette.borderColor}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
|
||||||
|
{composerNotice.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
|
||||||
|
{composerNotice.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||||
|
{showSlashMenu && filteredSlashCmds.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
|
||||||
|
background: darkMode ? '#2a2a2a' : '#fff',
|
||||||
|
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
|
||||||
|
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 100,
|
||||||
|
maxHeight: 220, overflowY: 'auto', padding: 4
|
||||||
|
}}>
|
||||||
|
{filteredSlashCmds.map(cmd => (
|
||||||
|
<div
|
||||||
|
key={cmd.cmd}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px', borderRadius: 6, cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
transition: 'background 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
setInput(cmd.prompt);
|
||||||
|
setShowSlashMenu(false);
|
||||||
|
setSlashFilter('');
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: textColor, minWidth: 80 }}>{cmd.cmd}</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{cmd.label}</span>
|
||||||
|
<span style={{ fontSize: 11, color: mutedColor, marginLeft: 'auto' }}>{cmd.desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input.TextArea
|
||||||
|
onPaste={(e) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.indexOf('image') !== -1) {
|
||||||
|
e.preventDefault();
|
||||||
|
const blob = items[i].getAsFile();
|
||||||
|
if (blob) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
if (event.target?.result) {
|
||||||
|
setDraftImages(prev => [...prev, event.target!.result as string]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={textareaRef as any}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setInput(val);
|
||||||
|
// Slash command detection
|
||||||
|
if (val.startsWith('/')) {
|
||||||
|
setSlashFilter(val.split(/\s/)[0]);
|
||||||
|
setShowSlashMenu(true);
|
||||||
|
} else {
|
||||||
|
setShowSlashMenu(false);
|
||||||
|
setSlashFilter('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown as any}
|
||||||
|
placeholder="输入消息... (Enter 发送,Shift+Enter 换行,/ 快捷命令)"
|
||||||
|
variant="borderless"
|
||||||
|
autoSize={{ minRows: 1, maxRows: 8 }}
|
||||||
|
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{activeConnName && (
|
||||||
|
<Tooltip title="当前数据查询上下文">
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
fontSize: 11, padding: '2px 8px', borderRadius: 12,
|
||||||
|
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||||
|
color: overlayTheme.mutedText, cursor: 'default'
|
||||||
|
}}>
|
||||||
|
<DatabaseOutlined style={{ fontSize: 10 }} />
|
||||||
|
<span style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeProvider && (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
variant="filled"
|
||||||
|
value={activeProvider.model || undefined}
|
||||||
|
onChange={onModelChange}
|
||||||
|
onDropdownVisibleChange={(open) => {
|
||||||
|
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
|
||||||
|
onFetchModels();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={loadingModels}
|
||||||
|
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
|
||||||
|
style={{ width: 130, fontSize: 11, background: 'transparent' }}
|
||||||
|
dropdownStyle={{ minWidth: 200 }}
|
||||||
|
showSearch
|
||||||
|
placeholder="选择模型"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contextUsageChars !== undefined && maxContextChars !== undefined && (
|
||||||
|
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k)时将触发自动压缩。`}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
fontSize: 10, padding: '2px 6px', borderRadius: 12, border: '1px solid transparent',
|
||||||
|
background: contextUsageChars > maxContextChars * 0.8 ? (darkMode ? 'rgba(250, 173, 20, 0.1)' : 'rgba(250, 173, 20, 0.08)') : (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'),
|
||||||
|
borderColor: contextUsageChars > maxContextChars * 0.8 ? 'rgba(250, 173, 20, 0.3)' : 'transparent',
|
||||||
|
color: contextUsageChars > maxContextChars * 0.8 ? '#faad14' : overlayTheme.mutedText, cursor: 'default',
|
||||||
|
transition: 'all 0.3s'
|
||||||
|
}}>
|
||||||
|
<span>🧠 {(contextUsageChars / 1000).toFixed(1)}k / {(maxContextChars / 1000).toFixed(0)}k</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
<Tooltip title="上传图片/截图">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PictureOutlined style={{ fontSize: 16 }} />}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = textColor}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="关联附带数据库表上下文">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<TableOutlined style={{ fontSize: 16 }} />}
|
||||||
|
onClick={handleOpenContext}
|
||||||
|
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = textColor}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
{sending ? (
|
||||||
|
<button
|
||||||
|
className="ai-chat-send-btn ai-chat-stop-btn"
|
||||||
|
onClick={onStop}
|
||||||
|
title="停止生成"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,77,79,0.1)',
|
||||||
|
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
|
||||||
|
width: 26, height: 26, borderRadius: 6, padding: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ai-chat-send-btn"
|
||||||
|
onClick={() => onSend()}
|
||||||
|
disabled={!input.trim() && draftImages.length === 0}
|
||||||
|
title="发送"
|
||||||
|
style={{
|
||||||
|
background: (input.trim() || draftImages.length > 0) ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
|
||||||
|
color: (input.trim() || draftImages.length > 0) ? overlayTheme.iconColor : mutedColor,
|
||||||
|
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: (input.trim() || draftImages.length > 0) ? 'pointer' : 'not-allowed', flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SendOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={<span style={{ color: textColor }}>关联数据库表结构上下文</span>}
|
||||||
|
open={contextOpen}
|
||||||
|
onCancel={() => setContextOpen(false)}
|
||||||
|
onOk={handleAppendContext}
|
||||||
|
confirmLoading={appendingContext}
|
||||||
|
okText="同步所选表至上下文"
|
||||||
|
cancelText="取消"
|
||||||
|
centered
|
||||||
|
styles={{
|
||||||
|
content: { background: darkMode ? '#1e1e1e' : '#ffffff', border: overlayTheme.shellBorder },
|
||||||
|
header: { background: darkMode ? '#1e1e1e' : '#ffffff', borderBottom: overlayTheme.shellBorder },
|
||||||
|
body: { padding: '20px 24px' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin spinning={contextLoading}>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
|
||||||
|
{dbList.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value={selectedDbName}
|
||||||
|
onChange={val => {
|
||||||
|
const c = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
|
||||||
|
if (c) fetchTablesForDb(val, c.config);
|
||||||
|
}}
|
||||||
|
options={dbList.map(d => ({ label: d, value: d }))}
|
||||||
|
style={{ width: 160, flexShrink: 0 }}
|
||||||
|
placeholder="切换数据库"
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
placeholder="在当前库搜索表名..."
|
||||||
|
prefix={<SearchOutlined style={{ color: overlayTheme.mutedText }} />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
style={{ background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', border: 'none', flexGrow: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filteredTables.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`, paddingBottom: 12, marginBottom: 8 }}>
|
||||||
|
<Checkbox
|
||||||
|
indeterminate={
|
||||||
|
filteredTables.length > 0 &&
|
||||||
|
filteredTables.some(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`)) &&
|
||||||
|
!filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))
|
||||||
|
}
|
||||||
|
checked={filteredTables.length > 0 && filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
const newSelected = new Set([...selectedTableKeys, ...filteredTables.map(t => `${selectedDbName}::${t.name}`)]);
|
||||||
|
setSelectedTableKeys(Array.from(newSelected));
|
||||||
|
} else {
|
||||||
|
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
|
||||||
|
setSelectedTableKeys(selectedTableKeys.filter(key => !filteredKeys.includes(key)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ color: textColor, fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
全选匹配的表 ({filteredTables.length})
|
||||||
|
</Checkbox>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
style={{ padding: 0, height: 'auto', fontSize: 13 }}
|
||||||
|
onClick={() => {
|
||||||
|
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
|
||||||
|
const remainingSelected = selectedTableKeys.filter(key => !filteredKeys.includes(key));
|
||||||
|
const toAdd = filteredKeys.filter(key => !selectedTableKeys.includes(key));
|
||||||
|
setSelectedTableKeys([...remainingSelected, ...toAdd]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
反选匹配结果
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: 300, overflowY: 'auto', margin: '0 -24px', padding: '0 24px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{filteredTables.map(t => {
|
||||||
|
const key = `${selectedDbName}::${t.name}`;
|
||||||
|
const isSelected = selectedTableKeys.includes(key);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
onClick={(e) => {
|
||||||
|
// If click originated from the checkbox input itself, let its onChange handle it to avoid duplicate toggle
|
||||||
|
if ((e.target as HTMLElement).tagName.toLowerCase() === 'input') return;
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
|
||||||
|
} else {
|
||||||
|
setSelectedTableKeys([...selectedTableKeys, key]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]);
|
||||||
|
else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
|
||||||
|
}}
|
||||||
|
style={{ color: textColor, width: '100%' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13, userSelect: 'none' }}>{t.name}</span>
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '40px 0', textAlign: 'center', color: overlayTheme.mutedText }}>
|
||||||
|
没有找到匹配 '{searchText}' 的表
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
frontend/src/components/ai/AIChatWelcome.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RobotOutlined } from '@ant-design/icons';
|
||||||
|
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||||
|
|
||||||
|
interface AIChatWelcomeProps {
|
||||||
|
overlayTheme: OverlayWorkbenchTheme;
|
||||||
|
quickActionBg: string;
|
||||||
|
quickActionBorder: string;
|
||||||
|
textColor: string;
|
||||||
|
mutedColor: string;
|
||||||
|
onQuickAction: (prompt: string, autoSend?: boolean) => void;
|
||||||
|
contextTableNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AIChatWelcome: React.FC<AIChatWelcomeProps> = ({
|
||||||
|
overlayTheme, quickActionBg, quickActionBorder, textColor, mutedColor, onQuickAction, contextTableNames = []
|
||||||
|
}) => {
|
||||||
|
const hasContext = contextTableNames.length > 0;
|
||||||
|
const tableList = contextTableNames.join('、');
|
||||||
|
|
||||||
|
const quickActions = hasContext
|
||||||
|
? [
|
||||||
|
{ label: '📝 生成 SQL', prompt: `请根据以下表结构生成一条常用查询语句:${tableList}` },
|
||||||
|
{ label: '🔍 解释表结构', prompt: `请详细解释以下表的设计意图和字段含义:${tableList}` },
|
||||||
|
{ label: '⚡ 优化建议', prompt: `请分析以下表的结构设计,给出索引优化和查询性能优化建议:${tableList}` },
|
||||||
|
{ label: '🏗️ Schema 分析', prompt: `请对以下表进行全面的 Schema 分析,包括数据类型选择、范式评估和改进建议:${tableList}` },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ label: '📝 生成 SQL', prompt: '请根据当前数据库表结构生成一条查询语句:' },
|
||||||
|
{ label: '🔍 解释 SQL', prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n\n```' },
|
||||||
|
{ label: '⚡ 优化建议', prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n\n```' },
|
||||||
|
{ label: '🏗️ Schema 分析', prompt: '请分析当前数据库的表结构并给出优化建议。' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
|
||||||
|
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
|
||||||
|
你好,我是 GoNavi AI
|
||||||
|
</div>
|
||||||
|
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
|
||||||
|
{hasContext
|
||||||
|
? `已自动关联 ${contextTableNames.length} 张表结构,点击下方按钮快速开始分析。`
|
||||||
|
: '我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。'}
|
||||||
|
</div>
|
||||||
|
<div className="quick-actions">
|
||||||
|
{quickActions.map(action => (
|
||||||
|
<div
|
||||||
|
key={action.label}
|
||||||
|
className="quick-action-btn"
|
||||||
|
style={{
|
||||||
|
background: quickActionBg,
|
||||||
|
borderColor: quickActionBorder,
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
|
onClick={() => onQuickAction(action.prompt)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
127
frontend/src/components/ai/AIHistoryDrawer.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Drawer, Button, Tooltip, Input } from 'antd';
|
||||||
|
import { MenuFoldOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { useStore } from '../../store';
|
||||||
|
|
||||||
|
interface AIHistoryDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
bgColor?: string;
|
||||||
|
darkMode: boolean;
|
||||||
|
textColor: string;
|
||||||
|
mutedColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
onCreateNew: () => void;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AIHistoryDrawer: React.FC<AIHistoryDrawerProps> = ({
|
||||||
|
open, onClose, bgColor, darkMode, textColor, mutedColor, borderColor, onCreateNew, sessionId
|
||||||
|
}) => {
|
||||||
|
const aiChatSessions = useStore(state => state.aiChatSessions);
|
||||||
|
const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId);
|
||||||
|
const deleteAISession = useStore(state => state.deleteAISession);
|
||||||
|
|
||||||
|
// 阶段4: 历史记录搜索
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
|
||||||
|
const filteredSessions = aiChatSessions.filter(s =>
|
||||||
|
!searchText || (s.title && s.title.toLowerCase().includes(searchText.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
placement="left"
|
||||||
|
closable={false}
|
||||||
|
onClose={onClose}
|
||||||
|
open={open}
|
||||||
|
getContainer={false}
|
||||||
|
style={{ position: 'absolute', background: bgColor || (darkMode ? '#1e1e1e' : '#f8f9fa') }}
|
||||||
|
width={260}
|
||||||
|
bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
{/* 侧拉面板头部 */}
|
||||||
|
<div style={{ padding: '16px 16px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: textColor }}>对话历史</span>
|
||||||
|
<Tooltip title="收起">
|
||||||
|
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={onClose} style={{ color: mutedColor }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新建对话按钮 */}
|
||||||
|
<div style={{ padding: '0 12px 12px' }}>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
block
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => { onCreateNew(); onClose(); }}
|
||||||
|
style={{ borderColor: borderColor, color: textColor, background: 'transparent' }}
|
||||||
|
>
|
||||||
|
开启新对话
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 列表搜索 */}
|
||||||
|
<div style={{ padding: '0 12px 12px' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索历史记录..."
|
||||||
|
prefix={<SearchOutlined style={{ color: mutedColor }} />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
variant="filled"
|
||||||
|
size="small"
|
||||||
|
style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'transparent', color: textColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 列表容器 */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0 10px 16px' }} className="ai-history-list">
|
||||||
|
{filteredSessions.length === 0 ? (
|
||||||
|
<div style={{ padding: '30px 0', textAlign: 'center', color: mutedColor, fontSize: 12 }}>暂无匹配的对话记录</div>
|
||||||
|
) : (
|
||||||
|
filteredSessions.map(session => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`ai-history-item ${sessionId === session.id ? 'active' : ''}`}
|
||||||
|
onClick={() => { setAIActiveSessionId(session.id); onClose(); }}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: sessionId === session.id ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : 'transparent',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ overflow: 'hidden', flex: 1, paddingRight: 8 }}>
|
||||||
|
<div style={{ fontSize: 13, color: textColor, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: sessionId === session.id ? 600 : 'normal' }}>
|
||||||
|
{session.title || '新对话'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: mutedColor, marginTop: 4 }}>
|
||||||
|
{new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<Button
|
||||||
|
className="ai-history-delete-btn"
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteAISession(session.id);
|
||||||
|
}}
|
||||||
|
style={{ display: sessionId === session.id ? 'inline-flex' : undefined }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
826
frontend/src/components/ai/AIMessageBubble.tsx
Normal file
@@ -0,0 +1,826 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Button, Tooltip, message } from 'antd';
|
||||||
|
import { UserOutlined, RobotOutlined, EditOutlined, ReloadOutlined, DeleteOutlined, CheckOutlined, CopyOutlined, PlayCircleOutlined, ApiOutlined, LoadingOutlined, CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import mermaid from 'mermaid';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import type { AIChatMessage, AIToolCall } from '../../types';
|
||||||
|
import { useStore } from '../../store';
|
||||||
|
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||||
|
import { normalizeAiMarkdown } from '../../utils/aiMarkdown';
|
||||||
|
import { extractJVMChangePlan, resolveJVMAIPlanTargetTabId } from '../../utils/jvmAiPlan';
|
||||||
|
import {
|
||||||
|
parseJVMDiagnosticPlan,
|
||||||
|
resolveJVMDiagnosticPlanTargetTabId,
|
||||||
|
} from '../../utils/jvmDiagnosticPlan';
|
||||||
|
|
||||||
|
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
|
||||||
|
const remarkPlugins = [remarkGfm];
|
||||||
|
|
||||||
|
const MemoizedMarkdown = React.memo(({
|
||||||
|
content,
|
||||||
|
darkMode,
|
||||||
|
overlayTheme,
|
||||||
|
activeConnectionConfig,
|
||||||
|
activeConnectionId,
|
||||||
|
activeDbName
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
darkMode: boolean;
|
||||||
|
overlayTheme: OverlayWorkbenchTheme;
|
||||||
|
activeConnectionConfig?: any;
|
||||||
|
activeConnectionId?: string;
|
||||||
|
activeDbName?: string;
|
||||||
|
}) => {
|
||||||
|
const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]);
|
||||||
|
// 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo
|
||||||
|
const components = React.useMemo(() => ({
|
||||||
|
code({ node, inline, className, children, ...props }: any) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
|
if (!inline && match && match[1] === 'mermaid') {
|
||||||
|
return <MermaidRenderer chart={String(children).replace(/\n$/, '')} darkMode={darkMode} />;
|
||||||
|
}
|
||||||
|
return !inline && match ? (
|
||||||
|
<AIBlockHashRender match={match} darkMode={darkMode} overlayTheme={overlayTheme} children={children} activeConnectionConfig={activeConnectionConfig} activeConnectionId={activeConnectionId} activeDbName={activeDbName} />
|
||||||
|
) : (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
||||||
|
{normalizedContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AIMessageBubbleProps {
|
||||||
|
msg: AIChatMessage;
|
||||||
|
darkMode: boolean;
|
||||||
|
overlayTheme: OverlayWorkbenchTheme;
|
||||||
|
textColor: string;
|
||||||
|
onEdit: (msg: AIChatMessage) => void;
|
||||||
|
onRetry: (msg: AIChatMessage) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
activeConnectionId?: string;
|
||||||
|
activeConnectionConfig?: any;
|
||||||
|
activeDbName?: string;
|
||||||
|
allMessages?: AIChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AIToolResultItem: React.FC<{ resultMsg: AIChatMessage, darkMode: boolean, overlayTheme: OverlayWorkbenchTheme }> = ({ resultMsg, darkMode, overlayTheme }) => {
|
||||||
|
const [toolExpanded, setToolExpanded] = useState(false);
|
||||||
|
const charCount = resultMsg.content ? resultMsg.content.length : 0;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: darkMode ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.02)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '6px 10px',
|
||||||
|
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`,
|
||||||
|
marginTop: 8,
|
||||||
|
width: '100%'
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', gap: 6, fontSize: 12, color: overlayTheme.mutedText }}
|
||||||
|
onClick={() => setToolExpanded(!toolExpanded)}
|
||||||
|
>
|
||||||
|
{toolExpanded ? <CaretDownOutlined /> : <CaretRightOutlined />}
|
||||||
|
<ApiOutlined style={{ color: '#1677ff' }} />
|
||||||
|
<span>探针执行结果 (<span style={{ fontFamily: 'monospace', color: overlayTheme.iconColor }}>{resultMsg.tool_name || 'unknown'}</span>)</span>
|
||||||
|
<span style={{ fontSize: 11, marginLeft: 8, opacity: 0.6 }}>{charCount > 0 ? `${charCount} 个字符` : '无数据'}</span>
|
||||||
|
</div>
|
||||||
|
{toolExpanded && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 300, overflowY: 'auto', background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.03)', padding: 8, borderRadius: 6 }}>
|
||||||
|
{resultMsg.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MermaidRenderer = ({ chart, darkMode }: { chart: string, darkMode: boolean }) => {
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
try {
|
||||||
|
mermaid.initialize({ startOnLoad: false, theme: darkMode ? 'dark' : 'default' });
|
||||||
|
const id = `mermaid-${Math.random().toString(36).substring(2)}`;
|
||||||
|
(async () => {
|
||||||
|
const result: any = await mermaid.render(id, chart);
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = result.svg || result;
|
||||||
|
}
|
||||||
|
})().catch((e: any) => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 解析失败: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 渲染异常: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [chart, darkMode]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="ai-mermaid-container" style={{ margin: '16px 0', display: 'flex', justifyContent: 'flex-start', overflowX: 'auto' }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CodeCopyBtn = ({ text }: { text: string }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="ai-code-copy-btn"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: copied ? 1 : 0.6,
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.opacity = copied ? '1' : '0.6'; }}
|
||||||
|
>
|
||||||
|
{copied ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
|
||||||
|
<span style={{ marginLeft: 4 }}>{copied ? '已复制' : '复制代码'}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CodeRunBtn = ({ text, connectionId, dbName }: { text: string; connectionId?: string; dbName?: string }) => {
|
||||||
|
// 解析 SQL 顶部的 @context 注释,格式:-- @context connectionId=xxx dbName=yyy
|
||||||
|
const contextMatch = text.match(/^--\s*@context\s+connectionId=(\S+)\s+dbName=(\S+)/m);
|
||||||
|
const resolvedConnId = contextMatch?.[1] || connectionId;
|
||||||
|
const resolvedDbName = contextMatch?.[2] || dbName;
|
||||||
|
// 发送给查询编辑器时去掉 @context 注释行
|
||||||
|
const cleanSql = text.replace(/^--\s*@context\s+.*\n?/gm, '').trim();
|
||||||
|
const sqlDetail = (runImmediately: boolean) => ({ sql: cleanSql, runImmediately, connectionId: resolvedConnId, dbName: resolvedDbName });
|
||||||
|
const handleExecute = async () => {
|
||||||
|
try {
|
||||||
|
const Service = (window as any).go?.aiservice?.Service;
|
||||||
|
if (Service?.AICheckSQL) {
|
||||||
|
const result = await Service.AICheckSQL(text);
|
||||||
|
if (!result.allowed) {
|
||||||
|
message.error(`🔒 安全策略拦截:当前安全级别不允许执行 ${result.operationType} 类型的 SQL。请在 AI 设置中调整安全级别。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.requiresConfirm) {
|
||||||
|
const { Modal } = await import('antd');
|
||||||
|
Modal.confirm({
|
||||||
|
title: '⚠️ 安全确认',
|
||||||
|
content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`,
|
||||||
|
okText: '确认执行',
|
||||||
|
cancelText: '取消',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: () => {
|
||||||
|
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Safety check passed or not available, execute directly
|
||||||
|
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
|
||||||
|
} catch (e) {
|
||||||
|
// If safety check fails, still allow manual execution
|
||||||
|
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<Tooltip title="将该段 SQL 注入查询工作区(可快捷修改或执行)">
|
||||||
|
<span
|
||||||
|
className="ai-code-run-btn"
|
||||||
|
onClick={() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(false) }));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer', display: 'flex', alignItems: 'center',
|
||||||
|
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#10b981'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
|
||||||
|
>
|
||||||
|
<PlayCircleOutlined />
|
||||||
|
<span style={{ marginLeft: 4 }}>插入</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="立即执行(受 AI 安全策略管控)">
|
||||||
|
<span
|
||||||
|
className="ai-code-run-btn"
|
||||||
|
onClick={handleExecute}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer', display: 'flex', alignItems: 'center',
|
||||||
|
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#1677ff'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
|
||||||
|
>
|
||||||
|
<PlayCircleOutlined />
|
||||||
|
<span style={{ marginLeft: 4 }}>执行</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 阶段2: 代码块体验升级 (折叠展开、行号显示、内联SQL预览)
|
||||||
|
const AIBlockHashRender = ({ match, darkMode, overlayTheme, children, activeConnectionConfig, activeConnectionId, activeDbName }: any) => {
|
||||||
|
const codeText = String(children).replace(/\n$/, '');
|
||||||
|
// 将 @context 注释行从显示文本中剔除,用户无需看到内部元数据
|
||||||
|
const displayText = codeText.replace(/^--\s*@context\s+.*\n?/gm, '').trim();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [previewData, setPreviewData] = useState<any[] | null>(null);
|
||||||
|
const [previewCols, setPreviewCols] = useState<string[]>([]);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [previewError, setPreviewError] = useState('');
|
||||||
|
const [previewExpanded, setPreviewExpanded] = useState(false);
|
||||||
|
|
||||||
|
const MAX_HEIGHT = 300;
|
||||||
|
const isLongCode = displayText.split('\n').length > 15;
|
||||||
|
const isSql = match[1] === 'sql';
|
||||||
|
const isSelectQuery = isSql && /^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i.test(displayText.trim());
|
||||||
|
|
||||||
|
const handleInlineExecute = async () => {
|
||||||
|
if (!activeConnectionConfig || previewLoading) return;
|
||||||
|
setPreviewLoading(true);
|
||||||
|
setPreviewError('');
|
||||||
|
setPreviewData(null);
|
||||||
|
try {
|
||||||
|
const { DBQuery } = await import('../../../wailsjs/go/app/App');
|
||||||
|
const res = await DBQuery(activeConnectionConfig, activeDbName || '', displayText + ' LIMIT 50');
|
||||||
|
if (res.success && Array.isArray(res.data)) {
|
||||||
|
const rows = res.data as any[];
|
||||||
|
const cols = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||||
|
setPreviewCols(cols);
|
||||||
|
setPreviewData(rows.slice(0, 20));
|
||||||
|
setPreviewExpanded(true);
|
||||||
|
} else {
|
||||||
|
setPreviewError(res.message || '查询无结果');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setPreviewError(err?.message || '执行失败');
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ai-code-block-container" style={{ margin: '12px 0', border: overlayTheme.sectionBorder, borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
<div className="ai-code-header" style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '6px 12px', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||||
|
fontSize: 12, color: overlayTheme.mutedText
|
||||||
|
}}>
|
||||||
|
<span style={{ fontFamily: 'monospace' }}>{match[1]}</span>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
|
{isSql && <CodeRunBtn text={codeText} connectionId={activeConnectionId} dbName={activeDbName} />}
|
||||||
|
{isSelectQuery && activeConnectionConfig && (
|
||||||
|
<Tooltip title="在聊天内预览查询结果(最多20行)">
|
||||||
|
<span
|
||||||
|
onClick={handleInlineExecute}
|
||||||
|
style={{
|
||||||
|
cursor: previewLoading ? 'wait' : 'pointer', display: 'flex', alignItems: 'center',
|
||||||
|
opacity: previewLoading ? 1 : 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#faad14'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '1'; }}
|
||||||
|
onMouseLeave={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '0.6'; }}
|
||||||
|
>
|
||||||
|
{previewLoading ? '⏳' : '👁'}
|
||||||
|
<span style={{ marginLeft: 4 }}>{previewLoading ? '执行中...' : '预览'}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<CodeCopyBtn text={displayText} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={darkMode ? vscDarkPlus as any : vs as any}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag="div"
|
||||||
|
showLineNumbers={true}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: 0,
|
||||||
|
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.02)',
|
||||||
|
maxHeight: expanded ? 'none' : (isLongCode ? MAX_HEIGHT : 'none'),
|
||||||
|
overflowY: expanded ? 'auto' : 'hidden',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: 1.6
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Menlo, Monaco, Consolas, "Courier New", monospace'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
|
||||||
|
{!expanded && isLongCode && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0, left: 0, right: 0,
|
||||||
|
height: 60,
|
||||||
|
background: `linear-gradient(to bottom, transparent, ${darkMode ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)'})`,
|
||||||
|
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
|
||||||
|
paddingBottom: 8, cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: overlayTheme.iconColor, background: darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', padding: '2px 8px', borderRadius: 12 }}>
|
||||||
|
展开全部代码
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{expanded && isLongCode && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex', justifyContent: 'center', padding: '6px 0',
|
||||||
|
background: darkMode ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.02)', cursor: 'pointer',
|
||||||
|
borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`
|
||||||
|
}}
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: overlayTheme.iconColor }}>收起代码</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline SQL Preview Results */}
|
||||||
|
{previewError && (
|
||||||
|
<div style={{ padding: '8px 12px', fontSize: 12, color: '#ef4444', background: darkMode ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.05)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}` }}>
|
||||||
|
❌ {previewError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{previewExpanded && previewData && previewData.length > 0 && (
|
||||||
|
<div style={{ borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}` }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 12px', background: darkMode ? 'rgba(250,173,20,0.08)' : 'rgba(250,173,20,0.05)' }}>
|
||||||
|
<span style={{ fontSize: 11, color: overlayTheme.mutedText }}>📊 预览结果({previewData.length} 行 × {previewCols.length} 列)</span>
|
||||||
|
<span style={{ fontSize: 11, color: overlayTheme.mutedText, cursor: 'pointer' }} onClick={() => setPreviewExpanded(false)}>收起 ▴</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: 'auto', maxHeight: 200, overflowY: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11, fontFamily: 'monospace' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{previewCols.map(col => (
|
||||||
|
<th key={col} style={{ padding: '4px 8px', textAlign: 'left', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', color: overlayTheme.titleText, fontWeight: 600, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}` }}>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{previewData.map((row, ri) => (
|
||||||
|
<tr key={ri}>
|
||||||
|
{previewCols.map(col => (
|
||||||
|
<td key={col} style={{ padding: '3px 8px', color: overlayTheme.mutedText, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'}`, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{row[col] === null ? <span style={{ color: '#999', fontStyle: 'italic' }}>NULL</span> : String(row[col])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!previewExpanded && previewData && previewData.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{ padding: '4px 12px', cursor: 'pointer', fontSize: 11, color: overlayTheme.mutedText, background: darkMode ? 'rgba(250,173,20,0.05)' : 'rgba(250,173,20,0.03)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}` }}
|
||||||
|
onClick={() => setPreviewExpanded(true)}
|
||||||
|
>
|
||||||
|
📊 查看结果({previewData.length} 行)▾
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 可折叠思考过程组件
|
||||||
|
const ThinkingBlock: React.FC<{ displayThinking: string; totalLen: number; isTyping: boolean; isGlobalLoading: boolean; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ displayThinking, totalLen, isTyping, isGlobalLoading, darkMode, overlayTheme, hasContent }) => {
|
||||||
|
// 如果整体在loading,且尚未吐出content,我们认为真正的思考还在进行;如果吐出content了,思考框就算告一段落
|
||||||
|
const isActivelyThinking = isGlobalLoading && !hasContent;
|
||||||
|
const [expanded, setExpanded] = useState(isActivelyThinking);
|
||||||
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => { if (isActivelyThinking) setExpanded(true); }, [isActivelyThinking]);
|
||||||
|
|
||||||
|
// 断开连接或思考结束时,若已有内容且不再产生新内容则默认收起
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isGlobalLoading) setExpanded(false);
|
||||||
|
}, [isGlobalLoading]);
|
||||||
|
|
||||||
|
// 自动滚动到思考内容底部
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (expanded && isTyping && contentRef.current) {
|
||||||
|
contentRef.current.scrollTop = contentRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [displayThinking, expanded, isTyping]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: hasContent ? 8 : 0,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
onClick={() => setExpanded(e => !e)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '6px 10px', cursor: 'pointer',
|
||||||
|
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
|
||||||
|
fontSize: 12, color: overlayTheme.mutedText, userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10 }}>▶</span>
|
||||||
|
<span>💭 思考过程</span>
|
||||||
|
{isActivelyThinking && <span style={{ fontSize: 10, color: '#8b5cf6', animation: 'pulse 1.5s ease-in-out infinite' }}>思考中...</span>}
|
||||||
|
{!isActivelyThinking && <span style={{ fontSize: 10, opacity: 0.5 }}>({displayThinking.length} 字)</span>}
|
||||||
|
</div>
|
||||||
|
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
|
||||||
|
<div ref={contentRef} style={{
|
||||||
|
padding: expanded ? '8px 12px' : '0 12px',
|
||||||
|
borderLeft: '3px solid #8b5cf6',
|
||||||
|
margin: '0 8px 8px',
|
||||||
|
fontSize: 12, lineHeight: 1.7,
|
||||||
|
color: overlayTheme.mutedText,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
maxHeight: 400, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{displayThinking}
|
||||||
|
{isTyping && <span className="ai-blinking-cursor" style={{ background: '#8b5cf6', marginLeft: 4, width: 6, height: 12, display: 'inline-block', verticalAlign: 'middle', opacity: 0.8 }} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 工具调用进度面板聚合展示组件
|
||||||
|
const AIToolCallingBlock: React.FC<{ tool_calls: AIToolCall[]; loading: boolean; allMessages: AIChatMessage[]; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ tool_calls, loading, allMessages, darkMode, overlayTheme, hasContent }) => {
|
||||||
|
const totalCalls = tool_calls.length;
|
||||||
|
const allDone = tool_calls.every(tc => allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id));
|
||||||
|
const [expanded, setExpanded] = useState(!allDone && loading);
|
||||||
|
|
||||||
|
// 断开连接或执行完毕时,若已完成则默认收起
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (allDone || !loading) setExpanded(false);
|
||||||
|
}, [allDone, loading]);
|
||||||
|
|
||||||
|
// 显示友好的人类可读动作名
|
||||||
|
const getHumanActionName = (fname: string) => {
|
||||||
|
if (fname === 'get_connections') return '获取可用连接信息';
|
||||||
|
if (fname === 'get_databases') return '扫描数据库列表';
|
||||||
|
if (fname === 'get_tables') return '分析表结构信息';
|
||||||
|
return fname;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.025)',
|
||||||
|
borderRadius: 8, fontSize: 12, overflow: 'hidden',
|
||||||
|
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
|
||||||
|
marginTop: hasContent ? 12 : 0,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '8px 12px', cursor: 'pointer', userSelect: 'none',
|
||||||
|
background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.titleText, fontWeight: 500 }}>
|
||||||
|
{!allDone && loading ? (
|
||||||
|
<div className="ai-spinning-ring" />
|
||||||
|
) : (
|
||||||
|
<CheckOutlined style={{ color: '#10b981' }} />
|
||||||
|
)}
|
||||||
|
<span>{!allDone && loading ? '正在执行数据探针...' : `数据探针执行完毕 (${totalCalls} 项)`}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10, color: overlayTheme.mutedText }}>▶</span>
|
||||||
|
</div>
|
||||||
|
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
|
||||||
|
<div style={{ padding: expanded ? '4px 12px 12px' : '0 12px' }}>
|
||||||
|
{tool_calls.map((tc, idx) => {
|
||||||
|
const resultMsg = allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id);
|
||||||
|
const isDone = !!resultMsg;
|
||||||
|
const actionName = getHumanActionName(tc.function.name);
|
||||||
|
return (
|
||||||
|
<div key={tc.id} style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 4,
|
||||||
|
marginTop: 6, paddingLeft: 8,
|
||||||
|
borderLeft: `2px solid ${isDone ? '#10b981' : (loading ? '#1677ff' : overlayTheme.shellBorder)}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{isDone
|
||||||
|
? <CheckOutlined style={{ color: '#10b981', fontSize: 11 }} />
|
||||||
|
: (loading ? <div className="ai-spinning-ring" style={{ width: 10, height: 10, borderWidth: 1.5 }} /> : <ApiOutlined style={{ color: overlayTheme.mutedText, fontSize: 11 }} />)
|
||||||
|
}
|
||||||
|
<span style={{ color: isDone ? overlayTheme.mutedText : overlayTheme.titleText }}>{actionName}</span>
|
||||||
|
</div>
|
||||||
|
{resultMsg && <AIToolResultItem resultMsg={resultMsg} darkMode={darkMode} overlayTheme={overlayTheme} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg, darkMode, overlayTheme, textColor, onEdit, onRetry, onDelete, activeConnectionId, activeConnectionConfig, activeDbName, allMessages }) => {
|
||||||
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
const isUser = msg.role === 'user';
|
||||||
|
|
||||||
|
// 从 content 中提取 <think>...</think> 标签内容(部分模型如 MiniMax、DeepSeek 会以文本形式返回思考过程)
|
||||||
|
const { displayContent, parsedThinking } = React.useMemo(() => {
|
||||||
|
const content = msg.content || '';
|
||||||
|
// 优先使用后端已结构化的 thinking 字段(如 Claude API 原生 thinking)
|
||||||
|
if (msg.thinking) {
|
||||||
|
return { displayContent: content, parsedThinking: msg.thinking };
|
||||||
|
}
|
||||||
|
// 尝试从 content 中提取 <think>...</think> 标签
|
||||||
|
const thinkRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g;
|
||||||
|
let thinkParts: string[] = [];
|
||||||
|
let cleanContent = content;
|
||||||
|
let match;
|
||||||
|
while ((match = thinkRegex.exec(content)) !== null) {
|
||||||
|
thinkParts.push(match[1].trim());
|
||||||
|
}
|
||||||
|
if (thinkParts.length > 0) {
|
||||||
|
// 移除所有 <think>...</think> 标签(含未闭合的)
|
||||||
|
cleanContent = content.replace(/<think>[\s\S]*?(?:<\/think>|$)/g, '').trim();
|
||||||
|
return { displayContent: cleanContent, parsedThinking: thinkParts.join('\n\n') };
|
||||||
|
}
|
||||||
|
return { displayContent: content, parsedThinking: '' };
|
||||||
|
}, [msg.content, msg.thinking]);
|
||||||
|
const jvmPlan = React.useMemo(() => {
|
||||||
|
if (isUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return extractJVMChangePlan(displayContent);
|
||||||
|
}, [displayContent, isUser]);
|
||||||
|
const jvmDiagnosticPlan = React.useMemo(() => {
|
||||||
|
if (isUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseJVMDiagnosticPlan(displayContent);
|
||||||
|
}, [displayContent, isUser]);
|
||||||
|
const isTypingThinking = !!(msg.loading && msg.phase === 'thinking');
|
||||||
|
|
||||||
|
if (msg.role === 'tool') return null;
|
||||||
|
|
||||||
|
// 如果是纯空壳的加载状态(connecting,或还在思考/工具阶段但还没吐出一个字的 content)
|
||||||
|
const isWaitState = msg.phase === 'connecting' ||
|
||||||
|
(msg.loading && !msg.content && (msg.phase === 'thinking' || msg.phase === 'tool_calling'));
|
||||||
|
|
||||||
|
if (isWaitState) {
|
||||||
|
return (
|
||||||
|
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
|
||||||
|
<div style={{
|
||||||
|
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
|
||||||
|
borderRadius: 12, padding: '14px 16px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.mutedText }}>
|
||||||
|
<div className="ai-wave-pulse">
|
||||||
|
<span /> <span /> <span />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 13, opacity: 0.8 }}>{msg.content || '正在建立连接'}...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 即使在波纹过渡态,如果有 thinking / tool_calls 也要显示出来,只是把它们压在波纹下面 */}
|
||||||
|
<div style={{ marginTop: parsedThinking || (msg.tool_calls && msg.tool_calls.length > 0) ? 12 : 0 }}>
|
||||||
|
{!isUser && parsedThinking && (
|
||||||
|
<ThinkingBlock
|
||||||
|
displayThinking={parsedThinking}
|
||||||
|
totalLen={parsedThinking.length}
|
||||||
|
isTyping={isTypingThinking}
|
||||||
|
isGlobalLoading={!!msg.loading}
|
||||||
|
darkMode={darkMode}
|
||||||
|
overlayTheme={overlayTheme}
|
||||||
|
hasContent={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isUser && msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||||
|
<AIToolCallingBlock
|
||||||
|
tool_calls={msg.tool_calls}
|
||||||
|
loading={!!msg.loading}
|
||||||
|
allMessages={allMessages || []}
|
||||||
|
darkMode={darkMode}
|
||||||
|
overlayTheme={overlayTheme}
|
||||||
|
hasContent={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
|
||||||
|
<div style={{
|
||||||
|
background: isUser ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '14px 16px',
|
||||||
|
}}>
|
||||||
|
<div className="ai-ide-message-header" style={{
|
||||||
|
color: isUser ? overlayTheme.mutedText : overlayTheme.titleText,
|
||||||
|
marginBottom: isUser ? 6 : 10,
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
{isUser
|
||||||
|
? <><UserOutlined /> <span>You</span></>
|
||||||
|
: <><RobotOutlined style={{ color: overlayTheme.iconColor }} /> <span>GoNavi AI</span></>}
|
||||||
|
</div>
|
||||||
|
{/* 气泡操作栏 */}
|
||||||
|
<div className="ai-message-actions" style={{ display: 'flex', gap: 8, opacity: 0, transition: 'opacity 0.2s', padding: '0 4px' }}>
|
||||||
|
<Tooltip title={isCopied ? "已复制" : "复制全文"}>
|
||||||
|
{isCopied ? (
|
||||||
|
<CheckOutlined className="ai-action-icon" style={{ color: '#10b981' }} />
|
||||||
|
) : (
|
||||||
|
<CopyOutlined className="ai-action-icon" onClick={() => {
|
||||||
|
navigator.clipboard.writeText(msg.content);
|
||||||
|
setIsCopied(true);
|
||||||
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
|
}} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
{isUser ? (
|
||||||
|
<Tooltip title="编辑此条消息(移除其后所有记录并重新发送)">
|
||||||
|
<EditOutlined className="ai-action-icon" onClick={() => onEdit(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip title="重新生成(移除此条并触发上次用户输入重发)">
|
||||||
|
<ReloadOutlined className="ai-action-icon" onClick={() => onRetry(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="删除单条消息">
|
||||||
|
<DeleteOutlined className="ai-action-icon" onClick={() => onDelete(msg.id)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ai-ide-message-content ai-markdown-content" style={{ color: textColor }}>
|
||||||
|
{msg.images && msg.images.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
|
{msg.images.map((img, i) => (
|
||||||
|
<img key={i} src={img} alt={`Attached ${i}`} style={{ maxWidth: 200, maxHeight: 200, borderRadius: 8, objectFit: 'contain', border: overlayTheme.shellBorder }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 可折叠思考过程 */}
|
||||||
|
{!isUser && parsedThinking && (
|
||||||
|
<ThinkingBlock
|
||||||
|
displayThinking={parsedThinking}
|
||||||
|
totalLen={parsedThinking.length}
|
||||||
|
isTyping={isTypingThinking}
|
||||||
|
isGlobalLoading={!!msg.loading}
|
||||||
|
darkMode={darkMode}
|
||||||
|
overlayTheme={overlayTheme}
|
||||||
|
hasContent={!!msg.content}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isUser ? (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 13 }}>{msg.content}</div>
|
||||||
|
) : (
|
||||||
|
<MemoizedMarkdown
|
||||||
|
content={displayContent}
|
||||||
|
darkMode={darkMode}
|
||||||
|
overlayTheme={overlayTheme}
|
||||||
|
activeConnectionConfig={activeConnectionConfig}
|
||||||
|
activeConnectionId={activeConnectionId}
|
||||||
|
activeDbName={activeDbName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isUser && jvmPlan && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
const targetContext = msg.jvmPlanContext;
|
||||||
|
if (!targetContext) {
|
||||||
|
message.warning('这条 JVM 计划缺少来源页签上下文,请在目标 JVM 资源页重新生成。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useStore.getState();
|
||||||
|
const targetTabId = resolveJVMAIPlanTargetTabId(store.tabs, targetContext);
|
||||||
|
if (!targetTabId) {
|
||||||
|
message.warning('未找到与该 JVM 计划匹配的资源页签,请先打开原目标资源后再应用。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('gonavi:jvm-apply-ai-plan', {
|
||||||
|
detail: {
|
||||||
|
plan: jvmPlan,
|
||||||
|
targetTabId,
|
||||||
|
connectionId: targetContext.connectionId,
|
||||||
|
providerMode: targetContext.providerMode,
|
||||||
|
resourcePath: targetContext.resourcePath,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
应用到 JVM 预览
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUser && jvmDiagnosticPlan && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
const targetContext = msg.jvmDiagnosticPlanContext;
|
||||||
|
if (!targetContext) {
|
||||||
|
message.warning('这条诊断计划缺少来源页签上下文,请在目标诊断控制台重新生成。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useStore.getState();
|
||||||
|
const targetTabId = resolveJVMDiagnosticPlanTargetTabId(
|
||||||
|
store.tabs,
|
||||||
|
store.connections,
|
||||||
|
targetContext,
|
||||||
|
);
|
||||||
|
if (!targetTabId) {
|
||||||
|
message.warning('未找到与该诊断计划匹配的诊断控制台页签,请先打开原目标控制台后再应用。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('gonavi:jvm-apply-diagnostic-plan', {
|
||||||
|
detail: {
|
||||||
|
plan: jvmDiagnosticPlan,
|
||||||
|
targetTabId,
|
||||||
|
connectionId: targetContext.connectionId,
|
||||||
|
transport: targetContext.transport,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
应用到诊断控制台
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 错误原文复制按钮 */}
|
||||||
|
{!isUser && msg.rawError && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(msg.rawError || '');
|
||||||
|
const btn = document.getElementById(`raw-err-btn-${msg.id}`);
|
||||||
|
if (btn) { btn.textContent = '✅ 已复制'; setTimeout(() => { btn.textContent = '📋 复制报错原文'; }, 1500); }
|
||||||
|
}}
|
||||||
|
id={`raw-err-btn-${msg.id}`}
|
||||||
|
style={{
|
||||||
|
fontSize: 12, padding: '3px 10px', borderRadius: 6, cursor: 'pointer',
|
||||||
|
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'}`,
|
||||||
|
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
|
||||||
|
color: overlayTheme.mutedText, transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📋 复制报错原文
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 工具调用进度展示 */}
|
||||||
|
{!isUser && msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||||
|
<AIToolCallingBlock
|
||||||
|
tool_calls={msg.tool_calls}
|
||||||
|
loading={!!msg.loading}
|
||||||
|
allMessages={allMessages || []}
|
||||||
|
darkMode={darkMode}
|
||||||
|
overlayTheme={overlayTheme}
|
||||||
|
hasContent={!!msg.content}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{msg.loading && msg.phase !== 'tool_calling' && msg.content && (
|
||||||
|
<span className="ai-blinking-cursor" style={{ background: overlayTheme.iconColor }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
46
frontend/src/components/dataGridAutoWidth.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateAutoFitColumnWidth,
|
||||||
|
normalizeAutoFitCellText,
|
||||||
|
} from './dataGridAutoWidth';
|
||||||
|
|
||||||
|
const measure = (text: string) => text.length * 8;
|
||||||
|
|
||||||
|
describe('dataGridAutoWidth helpers', () => {
|
||||||
|
it('prefers the widest header or sampled value and adds padding', () => {
|
||||||
|
const width = calculateAutoFitColumnWidth({
|
||||||
|
headerTexts: ['user_name'],
|
||||||
|
valueTexts: ['alice', 'very_long_username_value'],
|
||||||
|
measureHeaderText: measure,
|
||||||
|
measureCellText: measure,
|
||||||
|
padding: 32,
|
||||||
|
minWidth: 80,
|
||||||
|
maxWidth: 720,
|
||||||
|
defaultWidth: 140,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(width).toBe('very_long_username_value'.length * 8 + 32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures multiline content by the longest visible line and clamps to max width', () => {
|
||||||
|
const width = calculateAutoFitColumnWidth({
|
||||||
|
headerTexts: ['notes'],
|
||||||
|
valueTexts: ['short\nmuch much longer line here'],
|
||||||
|
measureHeaderText: measure,
|
||||||
|
measureCellText: measure,
|
||||||
|
padding: 24,
|
||||||
|
minWidth: 80,
|
||||||
|
maxWidth: 160,
|
||||||
|
defaultWidth: 140,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(width).toBe(160);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes null and oversized object values into stable preview text', () => {
|
||||||
|
expect(normalizeAutoFitCellText(null)).toBe('NULL');
|
||||||
|
expect(normalizeAutoFitCellText({ a: 1, b: 2 })).toBe('{"a":1,"b":2}');
|
||||||
|
expect(normalizeAutoFitCellText(Array.from({ length: 81 }, (_, index) => index))).toBe('[Array(81)]');
|
||||||
|
});
|
||||||
|
});
|
||||||
108
frontend/src/components/dataGridAutoWidth.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const AUTO_FIT_DEFAULT_MIN_WIDTH = 80;
|
||||||
|
const AUTO_FIT_DEFAULT_MAX_WIDTH = 720;
|
||||||
|
const AUTO_FIT_DEFAULT_PADDING = 40;
|
||||||
|
const AUTO_FIT_DEFAULT_SAMPLE_LIMIT = 200;
|
||||||
|
const AUTO_FIT_MAX_PREVIEW_CHARS = 120;
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
return Object.prototype.toString.call(value) === '[object Object]';
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampWidth = (value: number, minWidth: number, maxWidth: number) => {
|
||||||
|
const safeMin = Math.max(1, Math.floor(minWidth));
|
||||||
|
const safeMax = Math.max(safeMin, Math.floor(maxWidth));
|
||||||
|
return Math.min(safeMax, Math.max(safeMin, Math.ceil(value)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePreviewLine = (value: string): string => {
|
||||||
|
const normalized = String(value ?? '').replace(/\r\n/g, '\n');
|
||||||
|
if (normalized.length <= AUTO_FIT_MAX_PREVIEW_CHARS) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return `${normalized.slice(0, AUTO_FIT_MAX_PREVIEW_CHARS)}…`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitPreviewLines = (value: string): string[] => {
|
||||||
|
return normalizePreviewLine(value)
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trimEnd())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeAutoFitCellText = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return 'NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return normalizePreviewLine(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > 80) {
|
||||||
|
return `[Array(${value.length})]`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return normalizePreviewLine(JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
return '[Array]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
const topLevelSize = Object.keys(value).length;
|
||||||
|
if (topLevelSize > 80) {
|
||||||
|
return `{Object(${topLevelSize})}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return normalizePreviewLine(JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
return '[Object]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizePreviewLine(String(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateAutoFitColumnWidth = ({
|
||||||
|
headerTexts,
|
||||||
|
valueTexts,
|
||||||
|
measureHeaderText,
|
||||||
|
measureCellText,
|
||||||
|
minWidth = AUTO_FIT_DEFAULT_MIN_WIDTH,
|
||||||
|
maxWidth = AUTO_FIT_DEFAULT_MAX_WIDTH,
|
||||||
|
padding = AUTO_FIT_DEFAULT_PADDING,
|
||||||
|
sampleLimit = AUTO_FIT_DEFAULT_SAMPLE_LIMIT,
|
||||||
|
defaultWidth,
|
||||||
|
}: {
|
||||||
|
headerTexts: Array<string | null | undefined>;
|
||||||
|
valueTexts: unknown[];
|
||||||
|
measureHeaderText: (text: string) => number;
|
||||||
|
measureCellText: (text: string) => number;
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
padding?: number;
|
||||||
|
sampleLimit?: number;
|
||||||
|
defaultWidth: number;
|
||||||
|
}): number => {
|
||||||
|
const safePadding = Math.max(0, Math.ceil(padding));
|
||||||
|
let widestTextWidth = Math.max(0, Number(defaultWidth) - safePadding);
|
||||||
|
|
||||||
|
headerTexts.forEach((text) => {
|
||||||
|
splitPreviewLines(normalizeAutoFitCellText(text ?? '')).forEach((line) => {
|
||||||
|
widestTextWidth = Math.max(widestTextWidth, measureHeaderText(line));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
valueTexts.slice(0, Math.max(1, sampleLimit)).forEach((value) => {
|
||||||
|
splitPreviewLines(normalizeAutoFitCellText(value)).forEach((line) => {
|
||||||
|
widestTextWidth = Math.max(widestTextWidth, measureCellText(line));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return clampWidth(widestTextWidth + safePadding, minWidth, maxWidth);
|
||||||
|
};
|
||||||
189
frontend/src/components/dataGridCopyInsert.test.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildCopyDeleteSQL,
|
||||||
|
buildCopyInsertSQL,
|
||||||
|
buildCopyUpdateSQL,
|
||||||
|
resolveUniqueKeyGroupsFromIndexes,
|
||||||
|
} from './dataGridCopyInsert';
|
||||||
|
|
||||||
|
describe('buildCopyInsertSQL', () => {
|
||||||
|
it('normalizes PostgreSQL timestamp values for copy-as-insert and uses PostgreSQL identifier quoting', () => {
|
||||||
|
const sql = buildCopyInsertSQL({
|
||||||
|
dbType: 'postgres',
|
||||||
|
tableName: 'public.OrderLog',
|
||||||
|
orderedCols: ['CreatedAt', 'note'],
|
||||||
|
record: {
|
||||||
|
CreatedAt: '2026-01-21T18:32:26+08:00',
|
||||||
|
note: "O'Brien",
|
||||||
|
},
|
||||||
|
columnTypesByLowerName: {
|
||||||
|
createdat: 'timestamp without time zone',
|
||||||
|
note: 'text',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sql).toBe(
|
||||||
|
`INSERT INTO public."OrderLog" ("CreatedAt", note) VALUES ('2026-01-21 18:32:26', 'O''Brien');`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps timezone offsets for timezone-aware PostgreSQL columns while still removing the T separator', () => {
|
||||||
|
const sql = buildCopyInsertSQL({
|
||||||
|
dbType: 'postgres',
|
||||||
|
tableName: 'public.audit_log',
|
||||||
|
orderedCols: ['created_at'],
|
||||||
|
record: {
|
||||||
|
created_at: '2026-01-21T18:32:26+08:00',
|
||||||
|
},
|
||||||
|
columnTypesByLowerName: {
|
||||||
|
created_at: 'timestamp with time zone',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sql).toBe(
|
||||||
|
`INSERT INTO public.audit_log (created_at) VALUES ('2026-01-21 18:32:26+08:00');`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps RFC3339-looking text unchanged for non-temporal columns', () => {
|
||||||
|
const sql = buildCopyInsertSQL({
|
||||||
|
dbType: 'postgres',
|
||||||
|
tableName: 'public.audit_log',
|
||||||
|
orderedCols: ['payload'],
|
||||||
|
record: {
|
||||||
|
payload: '2026-01-21T18:32:26+08:00',
|
||||||
|
},
|
||||||
|
columnTypesByLowerName: {
|
||||||
|
payload: 'text',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sql).toBe(
|
||||||
|
`INSERT INTO public.audit_log (payload) VALUES ('2026-01-21T18:32:26+08:00');`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups composite unique indexes by name and sequence order', () => {
|
||||||
|
expect(resolveUniqueKeyGroupsFromIndexes([
|
||||||
|
{ name: 'PRIMARY', columnName: 'id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
|
||||||
|
{ name: 'uk_order_code', columnName: 'code', nonUnique: 0, seqInIndex: 2, indexType: 'BTREE' },
|
||||||
|
{ name: 'uk_order_code', columnName: 'tenant_id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
|
||||||
|
{ name: 'idx_note', columnName: 'note', nonUnique: 1, seqInIndex: 1, indexType: 'BTREE' },
|
||||||
|
])).toEqual([
|
||||||
|
['id'],
|
||||||
|
['tenant_id', 'code'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds UPDATE SQL with a primary-key WHERE clause and keeps literal formatting aligned with INSERT', () => {
|
||||||
|
const result = buildCopyUpdateSQL({
|
||||||
|
dbType: 'mysql',
|
||||||
|
tableName: 'orders',
|
||||||
|
orderedCols: ['id', 'note', 'deleted_at'],
|
||||||
|
record: {
|
||||||
|
id: 7,
|
||||||
|
note: "O'Brien",
|
||||||
|
deleted_at: null,
|
||||||
|
},
|
||||||
|
pkColumns: ['id'],
|
||||||
|
columnTypesByLowerName: {
|
||||||
|
deleted_at: 'datetime',
|
||||||
|
},
|
||||||
|
allTableColumns: ['id', 'note', 'deleted_at'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
whereStrategy: 'primary-key',
|
||||||
|
sql: `UPDATE \`orders\` SET \`id\` = '7', \`note\` = 'O''Brien', \`deleted_at\` = NULL WHERE (\`id\` = '7');`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds DELETE SQL with a composite unique-key WHERE clause when no primary key is available', () => {
|
||||||
|
const result = buildCopyDeleteSQL({
|
||||||
|
dbType: 'postgres',
|
||||||
|
tableName: 'public.audit_log',
|
||||||
|
orderedCols: ['tenant_id', 'code', 'payload'],
|
||||||
|
record: {
|
||||||
|
tenant_id: 'acme',
|
||||||
|
code: 'evt-7',
|
||||||
|
payload: '{"ok":true}',
|
||||||
|
},
|
||||||
|
uniqueKeyGroups: [['tenant_id', 'code']],
|
||||||
|
allTableColumns: ['tenant_id', 'code', 'payload'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
whereStrategy: 'unique-key',
|
||||||
|
sql: `DELETE FROM public.audit_log WHERE (tenant_id = 'acme' AND code = 'evt-7');`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to all-column matching and uses IS NULL for null values', () => {
|
||||||
|
const result = buildCopyDeleteSQL({
|
||||||
|
dbType: 'sqlserver',
|
||||||
|
tableName: 'dbo.OrderLog',
|
||||||
|
orderedCols: ['id', 'deleted_at', 'flag'],
|
||||||
|
allTableColumns: ['id', 'deleted_at', 'flag'],
|
||||||
|
record: {
|
||||||
|
id: 5,
|
||||||
|
deleted_at: null,
|
||||||
|
flag: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
whereStrategy: 'all-columns',
|
||||||
|
sql: `DELETE FROM [dbo].[OrderLog] WHERE ([id] = '5' AND [deleted_at] IS NULL AND [flag] = 'true');`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Oracle date constructors when all-column DELETE matching includes DATE values', () => {
|
||||||
|
const result = buildCopyDeleteSQL({
|
||||||
|
dbType: 'oracle',
|
||||||
|
tableName: 'LZJ.RIJIE_TABLE',
|
||||||
|
orderedCols: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'],
|
||||||
|
allTableColumns: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'],
|
||||||
|
record: {
|
||||||
|
NAME: '张三',
|
||||||
|
CREATED_AT: '2026-04-26T08:30:00+08:00',
|
||||||
|
STATUS: 'DONE',
|
||||||
|
MEMO: null,
|
||||||
|
},
|
||||||
|
columnTypesByLowerName: {
|
||||||
|
name: 'NVARCHAR2',
|
||||||
|
created_at: 'DATE',
|
||||||
|
status: 'VARCHAR2',
|
||||||
|
memo: 'VARCHAR2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
whereStrategy: 'all-columns',
|
||||||
|
sql: `DELETE FROM "LZJ"."RIJIE_TABLE" WHERE ("NAME" = '张三' AND "CREATED_AT" = TO_DATE('2026-04-26 08:30:00', 'YYYY-MM-DD HH24:MI:SS') AND "STATUS" = 'DONE' AND "MEMO" IS NULL);`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to build UPDATE/DELETE SQL when the result set lacks keys and does not cover all table columns', () => {
|
||||||
|
const result = buildCopyDeleteSQL({
|
||||||
|
dbType: 'mysql',
|
||||||
|
tableName: 'orders',
|
||||||
|
orderedCols: ['note'],
|
||||||
|
allTableColumns: ['id', 'note', 'created_at'],
|
||||||
|
record: {
|
||||||
|
note: 'partial row',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) {
|
||||||
|
throw new Error('expected buildCopyDeleteSQL to fail');
|
||||||
|
}
|
||||||
|
expect(result.error).toContain('主键');
|
||||||
|
expect(result.error).toContain('全部字段');
|
||||||
|
});
|
||||||
|
});
|
||||||
444
frontend/src/components/dataGridCopyInsert.ts
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import type { IndexDefinition } from '../types';
|
||||||
|
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||||
|
import { isOracleLikeDialect } from '../utils/sqlDialect';
|
||||||
|
|
||||||
|
type BuildCopyInsertSQLParams = {
|
||||||
|
dbType: string;
|
||||||
|
tableName?: string;
|
||||||
|
orderedCols: string[];
|
||||||
|
record: Record<string, any>;
|
||||||
|
columnTypesByLowerName?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildCopyMutationSQLParams = BuildCopyInsertSQLParams & {
|
||||||
|
pkColumns?: string[];
|
||||||
|
uniqueKeyGroups?: string[][];
|
||||||
|
allTableColumns?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CopySqlWhereStrategy = 'primary-key' | 'unique-key' | 'all-columns';
|
||||||
|
|
||||||
|
export type CopyMutationSQLResult =
|
||||||
|
| { ok: true; sql: string; whereStrategy: CopySqlWhereStrategy }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
type CopyMutationWhereClauseResult =
|
||||||
|
| { ok: true; clause: string; whereStrategy: CopySqlWhereStrategy }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
const looksLikeDateTimeText = (val: string): boolean => {
|
||||||
|
if (!val) return false;
|
||||||
|
const len = val.length;
|
||||||
|
if (len < 19 || len > 64) return false;
|
||||||
|
const charCode0 = val.charCodeAt(0);
|
||||||
|
if (charCode0 < 48 || charCode0 > 57) return false;
|
||||||
|
return (
|
||||||
|
val[4] === '-' &&
|
||||||
|
val[7] === '-' &&
|
||||||
|
(val[10] === ' ' || val[10] === 'T') &&
|
||||||
|
val[13] === ':' &&
|
||||||
|
val[16] === ':'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDateTimeString = (val: string): string => {
|
||||||
|
if (!looksLikeDateTimeText(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^0{4}-0{2}-0{2}/.test(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = val.match(
|
||||||
|
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||||
|
);
|
||||||
|
return match ? `${match[1]} ${match[2]}` : val;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTimezoneAwareDateTimeString = (val: string): string => {
|
||||||
|
if (!looksLikeDateTimeText(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^0{4}-0{2}-0{2}/.test(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = val.match(
|
||||||
|
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
const suffix = match[3] || '';
|
||||||
|
return `${match[1]} ${match[2]}${suffix}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTemporalColumnType = (columnType?: string): boolean => {
|
||||||
|
const raw = String(columnType || '').trim().toLowerCase();
|
||||||
|
if (!raw) return false;
|
||||||
|
if (raw.includes('datetime') || raw.includes('timestamp') || raw.includes('timestamptz')) return true;
|
||||||
|
const base = raw.split(/[ (]/)[0];
|
||||||
|
return base === 'date' || base === 'time' || base === 'timetz' || base === 'year';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTimezoneAwareColumnType = (columnType?: string): boolean => {
|
||||||
|
const raw = String(columnType || '').trim().toLowerCase();
|
||||||
|
if (!raw) return false;
|
||||||
|
return (
|
||||||
|
raw.includes('with time zone') ||
|
||||||
|
raw.includes('with timezone') ||
|
||||||
|
raw.includes('datetimeoffset') ||
|
||||||
|
raw.includes('timestamptz') ||
|
||||||
|
raw.includes('timetz')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeTemporalLiteralText = (
|
||||||
|
value: string,
|
||||||
|
columnType?: string,
|
||||||
|
normalizeWhenTypeMissing = false,
|
||||||
|
): string => {
|
||||||
|
const rawType = String(columnType || '').trim();
|
||||||
|
if (!rawType) {
|
||||||
|
return normalizeWhenTypeMissing ? normalizeDateTimeString(value) : value;
|
||||||
|
}
|
||||||
|
if (!isTemporalColumnType(rawType)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return isTimezoneAwareColumnType(rawType)
|
||||||
|
? normalizeTimezoneAwareDateTimeString(value)
|
||||||
|
: normalizeDateTimeString(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatLocalDateTimeLiteral = (value: Date): string => {
|
||||||
|
const year = value.getFullYear();
|
||||||
|
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(value.getDate()).padStart(2, '0');
|
||||||
|
const hour = String(value.getHours()).padStart(2, '0');
|
||||||
|
const minute = String(value.getMinutes()).padStart(2, '0');
|
||||||
|
const second = String(value.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColumnType = (columnTypesByLowerName: Record<string, string>, columnName: string): string | undefined => (
|
||||||
|
columnTypesByLowerName[String(columnName || '').toLowerCase()]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRecordValue = (
|
||||||
|
record: Record<string, any>,
|
||||||
|
columnName: string,
|
||||||
|
): { exists: boolean; value: any } => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(record || {}, columnName)) {
|
||||||
|
return { exists: true, value: record?.[columnName] };
|
||||||
|
}
|
||||||
|
const loweredColumnName = String(columnName || '').toLowerCase();
|
||||||
|
const matchedKey = Object.keys(record || {}).find((key) => key.toLowerCase() === loweredColumnName);
|
||||||
|
if (!matchedKey) {
|
||||||
|
return { exists: false, value: undefined };
|
||||||
|
}
|
||||||
|
return { exists: true, value: record?.[matchedKey] };
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeColumnList = (columns: string[] | undefined): string[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
(columns || []).forEach((column) => {
|
||||||
|
const normalized = String(column || '').trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
const lowered = normalized.toLowerCase();
|
||||||
|
if (seen.has(lowered)) return;
|
||||||
|
seen.add(lowered);
|
||||||
|
result.push(normalized);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNormalizedLiteralText = (value: any, columnType?: string): string => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return normalizeTemporalLiteralText(value, columnType, true);
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return formatLocalDateTimeLiteral(value);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => {
|
||||||
|
if (!isTemporalColumnType(columnType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = toNormalizedLiteralText(value, columnType);
|
||||||
|
const escaped = escapeLiteral(normalized);
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||||
|
return `TO_DATE('${escaped}', 'YYYY-MM-DD')`;
|
||||||
|
}
|
||||||
|
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) {
|
||||||
|
const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
|
||||||
|
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`;
|
||||||
|
}
|
||||||
|
const rawType = String(columnType || '').toLowerCase();
|
||||||
|
if (rawType.includes('timestamp')) {
|
||||||
|
return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
|
||||||
|
}
|
||||||
|
return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCopySqlLiteral = (value: any, columnType?: string, dbType = ''): string => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return 'NULL';
|
||||||
|
}
|
||||||
|
if (isOracleLikeDialect(dbType)) {
|
||||||
|
const oracleTemporalLiteral = formatOracleTemporalLiteral(value, columnType);
|
||||||
|
if (oracleTemporalLiteral) {
|
||||||
|
return oracleTemporalLiteral;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const doesResultCoverAllTableColumns = (orderedCols: string[], allTableColumns: string[]): boolean => {
|
||||||
|
const normalizedOrderedCols = normalizeColumnList(orderedCols);
|
||||||
|
const normalizedAllTableColumns = normalizeColumnList(allTableColumns);
|
||||||
|
if (normalizedOrderedCols.length === 0 || normalizedOrderedCols.length !== normalizedAllTableColumns.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const orderedSet = new Set(normalizedOrderedCols.map((column) => column.toLowerCase()));
|
||||||
|
return normalizedAllTableColumns.every((column) => orderedSet.has(column.toLowerCase()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWhereClauseForColumns = ({
|
||||||
|
dbType,
|
||||||
|
columns,
|
||||||
|
record,
|
||||||
|
columnTypesByLowerName,
|
||||||
|
requireNonNullValues,
|
||||||
|
}: {
|
||||||
|
dbType: string;
|
||||||
|
columns: string[];
|
||||||
|
record: Record<string, any>;
|
||||||
|
columnTypesByLowerName: Record<string, string>;
|
||||||
|
requireNonNullValues: boolean;
|
||||||
|
}): string | null => {
|
||||||
|
const predicates: string[] = [];
|
||||||
|
for (const columnName of columns) {
|
||||||
|
const { exists, value } = getRecordValue(record, columnName);
|
||||||
|
if (!exists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const quotedColumn = quoteIdentPart(dbType, columnName);
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
if (requireNonNullValues) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
predicates.push(`${quotedColumn} IS NULL`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`);
|
||||||
|
}
|
||||||
|
if (predicates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `(${predicates.join(' AND ')})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveMutationWhereClause = ({
|
||||||
|
dbType,
|
||||||
|
orderedCols,
|
||||||
|
record,
|
||||||
|
pkColumns = [],
|
||||||
|
uniqueKeyGroups = [],
|
||||||
|
allTableColumns = [],
|
||||||
|
columnTypesByLowerName = {},
|
||||||
|
}: BuildCopyMutationSQLParams): CopyMutationWhereClauseResult => {
|
||||||
|
const normalizedPkColumns = normalizeColumnList(pkColumns);
|
||||||
|
const pkWhereClause = buildWhereClauseForColumns({
|
||||||
|
dbType,
|
||||||
|
columns: normalizedPkColumns,
|
||||||
|
record,
|
||||||
|
columnTypesByLowerName,
|
||||||
|
requireNonNullValues: true,
|
||||||
|
});
|
||||||
|
if (pkWhereClause) {
|
||||||
|
return { ok: true, clause: pkWhereClause, whereStrategy: 'primary-key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUniqueKeyGroups = (uniqueKeyGroups || [])
|
||||||
|
.map((group) => normalizeColumnList(group))
|
||||||
|
.filter((group) => group.length > 0);
|
||||||
|
for (const group of normalizedUniqueKeyGroups) {
|
||||||
|
const uniqueWhereClause = buildWhereClauseForColumns({
|
||||||
|
dbType,
|
||||||
|
columns: group,
|
||||||
|
record,
|
||||||
|
columnTypesByLowerName,
|
||||||
|
requireNonNullValues: true,
|
||||||
|
});
|
||||||
|
if (uniqueWhereClause) {
|
||||||
|
return { ok: true, clause: uniqueWhereClause, whereStrategy: 'unique-key' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doesResultCoverAllTableColumns(orderedCols, allTableColumns)) {
|
||||||
|
const fullRowWhereClause = buildWhereClauseForColumns({
|
||||||
|
dbType,
|
||||||
|
columns: orderedCols,
|
||||||
|
record,
|
||||||
|
columnTypesByLowerName,
|
||||||
|
requireNonNullValues: false,
|
||||||
|
});
|
||||||
|
if (fullRowWhereClause) {
|
||||||
|
return { ok: true, clause: fullRowWhereClause, whereStrategy: 'all-columns' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: '当前结果集缺少可安全定位行数据的主键/唯一键,且未覆盖表的全部字段,无法生成 WHERE 条件。',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildCopyInsertSQL = ({
|
||||||
|
dbType,
|
||||||
|
tableName,
|
||||||
|
orderedCols,
|
||||||
|
record,
|
||||||
|
columnTypesByLowerName = {},
|
||||||
|
}: BuildCopyInsertSQLParams): string => {
|
||||||
|
const targetTable = quoteQualifiedIdent(dbType, tableName || 'table');
|
||||||
|
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
|
||||||
|
const values = orderedCols.map((col) => {
|
||||||
|
const { value } = getRecordValue(record, col);
|
||||||
|
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col), dbType);
|
||||||
|
});
|
||||||
|
|
||||||
|
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCopyMutationSQL = (
|
||||||
|
mode: 'update' | 'delete',
|
||||||
|
{
|
||||||
|
dbType,
|
||||||
|
tableName,
|
||||||
|
orderedCols,
|
||||||
|
record,
|
||||||
|
pkColumns = [],
|
||||||
|
uniqueKeyGroups = [],
|
||||||
|
allTableColumns = [],
|
||||||
|
columnTypesByLowerName = {},
|
||||||
|
}: BuildCopyMutationSQLParams,
|
||||||
|
): CopyMutationSQLResult => {
|
||||||
|
const normalizedTableName = String(tableName || '').trim();
|
||||||
|
const normalizedOrderedCols = normalizeColumnList(orderedCols);
|
||||||
|
if (!normalizedTableName) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `当前结果集未关联明确表名,无法生成 ${mode.toUpperCase()} SQL。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (normalizedOrderedCols.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: '当前结果集没有可复制的字段,无法生成 SQL。',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = resolveMutationWhereClause({
|
||||||
|
dbType,
|
||||||
|
orderedCols: normalizedOrderedCols,
|
||||||
|
record,
|
||||||
|
pkColumns,
|
||||||
|
uniqueKeyGroups,
|
||||||
|
allTableColumns,
|
||||||
|
columnTypesByLowerName,
|
||||||
|
});
|
||||||
|
if (whereClause.ok === false) {
|
||||||
|
return { ok: false, error: whereClause.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTable = quoteQualifiedIdent(dbType, normalizedTableName);
|
||||||
|
if (mode === 'delete') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
sql: `DELETE FROM ${targetTable} WHERE ${whereClause.clause};`,
|
||||||
|
whereStrategy: whereClause.whereStrategy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = normalizedOrderedCols.map((columnName) => {
|
||||||
|
const { value } = getRecordValue(record, columnName);
|
||||||
|
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
sql: `UPDATE ${targetTable} SET ${assignments.join(', ')} WHERE ${whereClause.clause};`,
|
||||||
|
whereStrategy: whereClause.whereStrategy,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildCopyUpdateSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
|
||||||
|
buildCopyMutationSQL('update', params)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const buildCopyDeleteSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
|
||||||
|
buildCopyMutationSQL('delete', params)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const resolveUniqueKeyGroupsFromIndexes = (indexes: IndexDefinition[] | undefined): string[][] => {
|
||||||
|
type IndexBucket = {
|
||||||
|
order: number;
|
||||||
|
columns: Array<{ columnName: string; seqInIndex: number; order: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map<string, IndexBucket>();
|
||||||
|
(indexes || []).forEach((index, order) => {
|
||||||
|
if (index?.nonUnique !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = String(index?.name || '').trim();
|
||||||
|
const columnName = String(index?.columnName || '').trim();
|
||||||
|
if (!name || !columnName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!buckets.has(name)) {
|
||||||
|
buckets.set(name, { order, columns: [] });
|
||||||
|
}
|
||||||
|
const bucket = buckets.get(name);
|
||||||
|
if (!bucket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bucket.columns.push({
|
||||||
|
columnName,
|
||||||
|
seqInIndex: Number.isFinite(Number(index?.seqInIndex)) ? Number(index.seqInIndex) : 0,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(buckets.values())
|
||||||
|
.sort((left, right) => left.order - right.order)
|
||||||
|
.map((bucket) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return bucket.columns
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftSeq = left.seqInIndex > 0 ? left.seqInIndex : Number.MAX_SAFE_INTEGER;
|
||||||
|
const rightSeq = right.seqInIndex > 0 ? right.seqInIndex : Number.MAX_SAFE_INTEGER;
|
||||||
|
if (leftSeq !== rightSeq) {
|
||||||
|
return leftSeq - rightSeq;
|
||||||
|
}
|
||||||
|
return left.order - right.order;
|
||||||
|
})
|
||||||
|
.map((item) => item.columnName)
|
||||||
|
.filter((columnName) => {
|
||||||
|
const lowered = columnName.toLowerCase();
|
||||||
|
if (seen.has(lowered)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(lowered);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.filter((group) => group.length > 0);
|
||||||
|
};
|
||||||
32
frontend/src/components/dataGridLayout.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
|
||||||
|
|
||||||
|
describe('dataGridLayout helpers', () => {
|
||||||
|
it('returns zero bottom padding without horizontal overflow', () => {
|
||||||
|
expect(calculateTableBodyBottomPadding({
|
||||||
|
hasHorizontalOverflow: false,
|
||||||
|
floatingScrollbarHeight: 10,
|
||||||
|
floatingScrollbarGap: 6,
|
||||||
|
})).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds safe area when horizontal overflow exists', () => {
|
||||||
|
expect(calculateTableBodyBottomPadding({
|
||||||
|
hasHorizontalOverflow: true,
|
||||||
|
floatingScrollbarHeight: 10,
|
||||||
|
floatingScrollbarGap: 6,
|
||||||
|
})).toBe(28);
|
||||||
|
expect(calculateTableBodyBottomPadding({
|
||||||
|
hasHorizontalOverflow: true,
|
||||||
|
floatingScrollbarHeight: 14,
|
||||||
|
floatingScrollbarGap: 4,
|
||||||
|
})).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps scroll width aligned with viewport or content width', () => {
|
||||||
|
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 1200, isMacLike: false })).toBe(1200);
|
||||||
|
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 0, isMacLike: false })).toBe(646);
|
||||||
|
expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202);
|
||||||
|
});
|
||||||
|
});
|
||||||
48
frontend/src/components/dataGridLayout.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export interface TableBodyBottomPaddingOptions {
|
||||||
|
hasHorizontalOverflow: boolean;
|
||||||
|
floatingScrollbarHeight: number;
|
||||||
|
floatingScrollbarGap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualTableScrollXOptions {
|
||||||
|
totalWidth: number;
|
||||||
|
tableViewportWidth: number;
|
||||||
|
isMacLike: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_SCROLLBAR_CLEARANCE = 8;
|
||||||
|
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
|
||||||
|
|
||||||
|
export const calculateTableBodyBottomPadding = ({
|
||||||
|
hasHorizontalOverflow,
|
||||||
|
floatingScrollbarHeight,
|
||||||
|
floatingScrollbarGap,
|
||||||
|
}: TableBodyBottomPaddingOptions): number => {
|
||||||
|
if (!hasHorizontalOverflow) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeScrollbarHeight = Math.max(0, Math.ceil(floatingScrollbarHeight));
|
||||||
|
const safeScrollbarGap = Math.max(0, Math.ceil(floatingScrollbarGap));
|
||||||
|
|
||||||
|
return safeScrollbarHeight + FLOATING_SCROLLBAR_VISUAL_EXTRA + safeScrollbarGap + MIN_SCROLLBAR_CLEARANCE;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateVirtualTableScrollX = ({
|
||||||
|
totalWidth,
|
||||||
|
tableViewportWidth,
|
||||||
|
isMacLike,
|
||||||
|
}: VirtualTableScrollXOptions): number => {
|
||||||
|
const safeTotalWidth = Math.max(0, Math.ceil(totalWidth));
|
||||||
|
const safeViewportWidth = Math.max(0, Math.floor(tableViewportWidth));
|
||||||
|
|
||||||
|
if (safeViewportWidth > 0 && safeTotalWidth < safeViewportWidth) {
|
||||||
|
return safeViewportWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMacLike && safeViewportWidth > 0 && safeTotalWidth > safeViewportWidth) {
|
||||||
|
return safeTotalWidth + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeTotalWidth;
|
||||||
|
};
|
||||||
41
frontend/src/components/dataGridRowClipboard.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard';
|
||||||
|
|
||||||
|
const rowKeyField = '__gonavi_row_key__';
|
||||||
|
|
||||||
|
describe('dataGridRowClipboard', () => {
|
||||||
|
it('copies selected rows in selection order without the internal row key', () => {
|
||||||
|
const copiedRows = buildCopiedRowsForPaste({
|
||||||
|
rows: [
|
||||||
|
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
|
||||||
|
{ [rowKeyField]: 'row-2', id: 2, name: 'beta', hidden_note: 'B' },
|
||||||
|
],
|
||||||
|
selectedRowKeys: ['row-2', 'row-1'],
|
||||||
|
columnNames: ['id', 'name', 'hidden_note'],
|
||||||
|
rowKeyField,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(copiedRows).toEqual([
|
||||||
|
{ id: 2, name: 'beta', hidden_note: 'B' },
|
||||||
|
{ id: 1, name: 'alpha', hidden_note: 'A' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds pasted rows as new rows with fresh internal keys', () => {
|
||||||
|
const pastedRows = buildPastedRowsFromCopiedRows({
|
||||||
|
rows: [
|
||||||
|
{ id: 2, name: 'beta' },
|
||||||
|
{ id: 1, name: 'alpha' },
|
||||||
|
],
|
||||||
|
columnNames: ['id', 'name'],
|
||||||
|
rowKeyField,
|
||||||
|
createRowKey: (index) => `paste-${index}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pastedRows).toEqual([
|
||||||
|
{ [rowKeyField]: 'paste-0', id: 2, name: 'beta' },
|
||||||
|
{ [rowKeyField]: 'paste-1', id: 1, name: 'alpha' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
frontend/src/components/dataGridRowClipboard.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
export interface BuildCopiedRowsForPasteInput {
|
||||||
|
rows: Array<Record<string, any>>;
|
||||||
|
selectedRowKeys: any[];
|
||||||
|
columnNames: string[];
|
||||||
|
rowKeyField: string;
|
||||||
|
rowKeyToString?: (key: any) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildPastedRowsFromCopiedRowsInput {
|
||||||
|
rows: Array<Record<string, any>>;
|
||||||
|
columnNames: string[];
|
||||||
|
rowKeyField: string;
|
||||||
|
createRowKey: (index: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRowKeyToString = (key: any): string => String(key);
|
||||||
|
|
||||||
|
const getCopyableColumnNames = (columnNames: string[], rowKeyField: string): string[] =>
|
||||||
|
columnNames.filter((columnName) => columnName !== rowKeyField);
|
||||||
|
|
||||||
|
const pickCopyableRowValues = (
|
||||||
|
row: Record<string, any>,
|
||||||
|
columnNames: string[],
|
||||||
|
rowKeyField: string,
|
||||||
|
): Record<string, any> => {
|
||||||
|
const next: Record<string, any> = {};
|
||||||
|
getCopyableColumnNames(columnNames, rowKeyField).forEach((columnName) => {
|
||||||
|
next[columnName] = row?.[columnName];
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildCopiedRowsForPaste = ({
|
||||||
|
rows,
|
||||||
|
selectedRowKeys,
|
||||||
|
columnNames,
|
||||||
|
rowKeyField,
|
||||||
|
rowKeyToString = defaultRowKeyToString,
|
||||||
|
}: BuildCopiedRowsForPasteInput): Array<Record<string, any>> => {
|
||||||
|
if (!Array.isArray(rows) || !Array.isArray(selectedRowKeys) || selectedRowKeys.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsByKey = new Map<string, Record<string, any>>();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const rowKey = row?.[rowKeyField];
|
||||||
|
if (rowKey === undefined || rowKey === null) return;
|
||||||
|
rowsByKey.set(rowKeyToString(rowKey), row);
|
||||||
|
});
|
||||||
|
|
||||||
|
return selectedRowKeys
|
||||||
|
.map((selectedKey) => rowsByKey.get(rowKeyToString(selectedKey)))
|
||||||
|
.filter((row): row is Record<string, any> => Boolean(row))
|
||||||
|
.map((row) => pickCopyableRowValues(row, columnNames, rowKeyField));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPastedRowsFromCopiedRows = ({
|
||||||
|
rows,
|
||||||
|
columnNames,
|
||||||
|
rowKeyField,
|
||||||
|
createRowKey,
|
||||||
|
}: BuildPastedRowsFromCopiedRowsInput): Array<Record<string, any>> =>
|
||||||
|
rows.map((row, index) => ({
|
||||||
|
[rowKeyField]: createRowKey(index),
|
||||||
|
...pickCopyableRowValues(row, columnNames, rowKeyField),
|
||||||
|
}));
|
||||||
43
frontend/src/components/dataGridSelectionCopy.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
|
||||||
|
|
||||||
|
describe('dataGridSelectionCopy helpers', () => {
|
||||||
|
it('builds clipboard text in visible row and column order', () => {
|
||||||
|
const text = buildSelectedCellClipboardText({
|
||||||
|
selectedCells: [
|
||||||
|
{ rowKey: 'row-2', colName: 'name' },
|
||||||
|
{ rowKey: 'row-1', colName: 'id' },
|
||||||
|
{ rowKey: 'row-1', colName: 'name' },
|
||||||
|
{ rowKey: 'row-2', colName: 'id' },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ __rowKey: 'row-1', id: 1, name: 'Alice' },
|
||||||
|
{ __rowKey: 'row-2', id: 2, name: 'Bob' },
|
||||||
|
],
|
||||||
|
columnOrder: ['id', 'name', 'email'],
|
||||||
|
rowKeyField: '__rowKey',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toBe('1\tAlice\n2\tBob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes null, objects and multiline text for clipboard safety', () => {
|
||||||
|
const text = buildSelectedCellClipboardText({
|
||||||
|
selectedCells: [
|
||||||
|
{ rowKey: 'row-1', colName: 'notes' },
|
||||||
|
{ rowKey: 'row-1', colName: 'meta' },
|
||||||
|
{ rowKey: 'row-2', colName: 'notes' },
|
||||||
|
{ rowKey: 'row-2', colName: 'meta' },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ __rowKey: 'row-1', notes: null, meta: { a: 1 } },
|
||||||
|
{ __rowKey: 'row-2', notes: 'line1\nline2\tvalue', meta: [1, 2] },
|
||||||
|
],
|
||||||
|
columnOrder: ['notes', 'meta'],
|
||||||
|
rowKeyField: '__rowKey',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toBe('NULL\t{"a":1}\nline1 line2 value\t[1,2]');
|
||||||
|
});
|
||||||
|
});
|
||||||
65
frontend/src/components/dataGridSelectionCopy.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export interface SelectedGridCell {
|
||||||
|
rowKey: string;
|
||||||
|
colName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeClipboardCellValue = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return 'NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.replace(/\r\n/g, '\n').replace(/[\t\n\r]+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value).replace(/[\t\n\r]+/g, ' ').trim();
|
||||||
|
} catch {
|
||||||
|
return String(value).replace(/[\t\n\r]+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSelectedCellClipboardText = ({
|
||||||
|
selectedCells,
|
||||||
|
rows,
|
||||||
|
columnOrder,
|
||||||
|
rowKeyField,
|
||||||
|
}: {
|
||||||
|
selectedCells: SelectedGridCell[];
|
||||||
|
rows: Array<Record<string, any>>;
|
||||||
|
columnOrder: string[];
|
||||||
|
rowKeyField: string;
|
||||||
|
}): string => {
|
||||||
|
if (!selectedCells.length || !rows.length || !columnOrder.length || !rowKeyField) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRowKeys = new Set(selectedCells.map((cell) => cell.rowKey));
|
||||||
|
const selectedColumnKeys = new Set(selectedCells.map((cell) => cell.colName));
|
||||||
|
const orderedRows = rows.filter((row) => selectedRowKeys.has(String(row?.[rowKeyField] ?? '')));
|
||||||
|
const orderedColumns = columnOrder.filter((columnName) => selectedColumnKeys.has(columnName));
|
||||||
|
|
||||||
|
if (!orderedRows.length || !orderedColumns.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCellKeySet = new Set(selectedCells.map((cell) => `${cell.rowKey}::${cell.colName}`));
|
||||||
|
|
||||||
|
return orderedRows
|
||||||
|
.map((row) => {
|
||||||
|
const rowKey = String(row?.[rowKeyField] ?? '');
|
||||||
|
return orderedColumns
|
||||||
|
.map((columnName) => {
|
||||||
|
if (!selectedCellKeySet.has(`${rowKey}::${columnName}`)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return normalizeClipboardCellValue(row?.[columnName]);
|
||||||
|
})
|
||||||
|
.join('\t');
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
};
|
||||||
10
frontend/src/components/dataGridTemporal.test.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveTemporalEditorSaveValue } from './dataGridTemporal';
|
||||||
|
|
||||||
|
describe('dataGridTemporal helpers', () => {
|
||||||
|
it('prefers the picker selected date when form store has not caught up yet', () => {
|
||||||
|
expect(resolveTemporalEditorSaveValue(undefined, dayjs('2026-04-12'), 'date')).toBe('2026-04-12');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
frontend/src/components/dataGridTemporal.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
|
||||||
|
|
||||||
|
export const TEMPORAL_FORMATS: Record<string, string> = {
|
||||||
|
datetime: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
date: 'YYYY-MM-DD',
|
||||||
|
time: 'HH:mm:ss',
|
||||||
|
year: 'YYYY',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTemporalColumnType = (columnType?: string): boolean => {
|
||||||
|
const raw = String(columnType || '').trim().toLowerCase();
|
||||||
|
if (!raw) return false;
|
||||||
|
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
|
||||||
|
const base = raw.split(/[ (]/)[0];
|
||||||
|
return base === 'date' || base === 'time' || base === 'year';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTemporalPickerType = (columnType?: string): TemporalPickerType => {
|
||||||
|
const raw = String(columnType || '').trim().toLowerCase();
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime';
|
||||||
|
const base = raw.split(/[ (]/)[0];
|
||||||
|
if (base === 'date') return 'date';
|
||||||
|
if (base === 'time') return 'time';
|
||||||
|
if (base === 'year') return 'year';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => {
|
||||||
|
if (val === null || val === undefined || val === '') return null;
|
||||||
|
const str = String(val).trim();
|
||||||
|
if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null;
|
||||||
|
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||||
|
const d = dayjs(str, fmt);
|
||||||
|
return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
|
||||||
|
if (!val || !val.isValid()) return '';
|
||||||
|
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||||
|
return val.format(fmt);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveTemporalEditorSaveValue = (
|
||||||
|
formValue: any,
|
||||||
|
pickerValue: dayjs.Dayjs | null | undefined,
|
||||||
|
pickerType: TemporalPickerType,
|
||||||
|
): string | null | any => {
|
||||||
|
const value = pickerValue !== undefined ? pickerValue : formValue;
|
||||||
|
if (value && dayjs.isDayjs(value)) {
|
||||||
|
return formatFromDayjs(value as dayjs.Dayjs, pickerType);
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||