Compare commits
924 Commits
release/0.
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3836da9cb | ||
|
|
ae2b27c4b4 | ||
|
|
8519748512 | ||
|
|
70134cd77f | ||
|
|
356baa1e38 | ||
|
|
5fcc04a200 | ||
|
|
77a306beb2 | ||
|
|
f5ee61f589 | ||
|
|
c99cdb5d0d | ||
|
|
c3a3387ee3 | ||
|
|
c189125aa4 | ||
|
|
4cc8ab6482 | ||
|
|
d1aa06d537 | ||
|
|
d5688d31f6 | ||
|
|
ff8bf20680 | ||
|
|
5061ec081a | ||
|
|
e83c9f5553 | ||
|
|
8eb4bf3954 | ||
|
|
3d91079020 | ||
|
|
ab74270cd4 | ||
|
|
b815e7b296 | ||
|
|
fce50b513c | ||
|
|
781a80e03f | ||
|
|
1058da653d | ||
|
|
0573155285 | ||
|
|
3427a8844a | ||
|
|
e353fda7a2 | ||
|
|
f956991bda | ||
|
|
0bc6941df6 | ||
|
|
c64b1fbb72 | ||
|
|
72143f6a8f | ||
|
|
d5d4d4fabc | ||
|
|
03e08bec32 | ||
|
|
233894f027 | ||
|
|
8f0bd61c14 | ||
|
|
8a0dc3a7d3 | ||
|
|
453e13c88d | ||
|
|
4daaa22cba | ||
|
|
97f062773b | ||
|
|
fb73769063 | ||
|
|
4cac8ef3c9 | ||
|
|
156fce531c | ||
|
|
480edbe501 | ||
|
|
81fab81d1b | ||
|
|
cba8ff394c | ||
|
|
6f4e80c749 | ||
|
|
a9eed57cf7 | ||
|
|
890d693102 | ||
|
|
9038fe1bdf | ||
|
|
ca1c8559cf | ||
|
|
bd2bd49e6d | ||
|
|
c1d27448bc | ||
|
|
e6d2685521 | ||
|
|
8006844b9f | ||
|
|
5d4989f68f | ||
|
|
b7e50118f0 | ||
|
|
5fc0a6504a | ||
|
|
c9053bccc5 | ||
|
|
e4672062f8 | ||
|
|
06583abad9 | ||
|
|
ce568362c6 | ||
|
|
ed67a72b68 | ||
|
|
7850f49429 | ||
|
|
440172aae8 | ||
|
|
19989e4c26 | ||
|
|
4265d7cfa9 | ||
|
|
2d562ccfd6 | ||
|
|
74a422a5e2 | ||
|
|
450d1d66b4 | ||
|
|
4a944ad23f | ||
|
|
26fb650e04 | ||
|
|
d3278bb4c4 | ||
|
|
b438881a50 | ||
|
|
b11b662071 | ||
|
|
11156c941c | ||
|
|
0394667680 | ||
|
|
856a5158e4 | ||
|
|
fb00f47031 | ||
|
|
55a52bb0f3 | ||
|
|
630044b740 | ||
|
|
69f51f8ec8 | ||
|
|
ab053ef7d1 | ||
|
|
89639e36bc | ||
|
|
cf8f9be8dc | ||
|
|
156631c263 | ||
|
|
d8da8d6abf | ||
|
|
61d71cf1d0 | ||
|
|
7eb086cade | ||
|
|
c9d0bce153 | ||
|
|
1d1d8d21cd | ||
|
|
c4153202ba | ||
|
|
8ddd8a726d | ||
|
|
e16082af9a | ||
|
|
2c7962f5d3 | ||
|
|
d6f552d539 | ||
|
|
8f86c4419b | ||
|
|
b4affbc1d5 | ||
|
|
0834d8cb3d | ||
|
|
d0b9a7f50c | ||
|
|
7df524e9ef | ||
|
|
cb90a4ad01 | ||
|
|
01d8fe44ce | ||
|
|
73e93e955c | ||
|
|
c742b4d61e | ||
|
|
ce06bea744 | ||
|
|
48de0b83c4 | ||
|
|
f0afff68c4 | ||
|
|
67e0cc752b | ||
|
|
d78c4481f0 | ||
|
|
75b60f94d2 | ||
|
|
c17d867aa6 | ||
|
|
327a78f1cb | ||
|
|
af51ead948 | ||
|
|
9fab48e64f | ||
|
|
acfa112415 | ||
|
|
79094d4f3b | ||
|
|
da7559426c | ||
|
|
c8f11d7258 | ||
|
|
c45961f027 | ||
|
|
a6105f4807 | ||
|
|
77b58baff7 | ||
|
|
8543f1dc65 | ||
|
|
17a3d72852 | ||
|
|
b5ba49ff8f | ||
|
|
83972d29b7 | ||
|
|
58a9adad34 | ||
|
|
7d1e066997 | ||
|
|
9be10beadc | ||
|
|
8529fbd9e2 | ||
|
|
86095b5bf1 | ||
|
|
c081d23cc4 | ||
|
|
6841e69008 | ||
|
|
ee5623d290 | ||
|
|
ae3e08d5f6 | ||
|
|
15e0766bbb | ||
|
|
25fb3502e1 | ||
|
|
6e7b8ceb39 | ||
|
|
b4f46aeecd | ||
|
|
4162a6491b | ||
|
|
f7648413ed | ||
|
|
9dde59a6c7 | ||
|
|
f7c20f6d79 | ||
|
|
747cabe447 | ||
|
|
b723b16671 | ||
|
|
bffad0c3a3 | ||
|
|
0a48f70643 | ||
|
|
0a229e8156 | ||
|
|
f7ed6f8e61 | ||
|
|
20e817b203 | ||
|
|
1284c8c8ef | ||
|
|
7ac9b89b7d | ||
|
|
c0e29a08ba | ||
|
|
cc788d1b25 | ||
|
|
7fa23e72c0 | ||
|
|
5ce5d03d69 | ||
|
|
4ac6a9e798 | ||
|
|
472686e8ff | ||
|
|
dc38602d32 | ||
|
|
5c867fd121 | ||
|
|
d7879d9ef0 | ||
|
|
8aa2932878 | ||
|
|
f19ff5fdd5 | ||
|
|
7cce1ce30a | ||
|
|
92e9b0ef75 | ||
|
|
a54a357e4b | ||
|
|
2e5c3473e1 | ||
|
|
5f3d1d9880 | ||
|
|
a575fb49db | ||
|
|
53b4fcb842 | ||
|
|
2c95009d1f | ||
|
|
5b8bbd672e | ||
|
|
54f1f6970c | ||
|
|
8a1e65640e | ||
|
|
0312dfbb16 | ||
|
|
02afeba564 | ||
|
|
1a2462ef17 | ||
|
|
c76b634739 | ||
|
|
fc88e21811 | ||
|
|
e1cebb1c9a | ||
|
|
7d7b775fe0 | ||
|
|
6c53fb14a6 | ||
|
|
aad0f447c0 | ||
|
|
9d3c77755d | ||
|
|
67dd178166 | ||
|
|
802385464d | ||
|
|
eff2f7f63a | ||
|
|
7039eae9c7 | ||
|
|
fbed6580fa | ||
|
|
7cdd2bd6c0 | ||
|
|
651c452fe1 | ||
|
|
5b843ee25b | ||
|
|
f7d71c6c5c | ||
|
|
dda8bbb6e3 | ||
|
|
6932abe674 | ||
|
|
79c5bfb3d4 | ||
|
|
ace6e18da8 | ||
|
|
a5b27820cb | ||
|
|
d2189e1442 | ||
|
|
36a80951a0 | ||
|
|
2ea88c03d3 | ||
|
|
5d9499e4dd | ||
|
|
fe479778d1 | ||
|
|
81eaeb5d5f | ||
|
|
ea53430d70 | ||
|
|
be26970761 | ||
|
|
f7dd90a5d1 | ||
|
|
2254b76232 | ||
|
|
ea1737ab8d | ||
|
|
01bee3d8e4 | ||
|
|
805ab8b3d8 | ||
|
|
6742495c6f | ||
|
|
3964ecbe88 | ||
|
|
ebdf7b5a6c | ||
|
|
26ce75a547 | ||
|
|
53811969c5 | ||
|
|
2438899ff5 | ||
|
|
a718c41d5d | ||
|
|
274c32ebdd | ||
|
|
a664f1a869 | ||
|
|
455813e53c | ||
|
|
30d1c080a0 | ||
|
|
f7217583a3 | ||
|
|
8f7c790700 | ||
|
|
4cef232271 | ||
|
|
5b602bff75 | ||
|
|
4ad1d15781 | ||
|
|
f25a449e20 | ||
|
|
9d39440438 | ||
|
|
02faa4586b | ||
|
|
9acb1c69f7 | ||
|
|
a9d515f160 | ||
|
|
37a094c351 | ||
|
|
f5166ac3fc | ||
|
|
23ac30086f | ||
|
|
c6f6f76489 | ||
|
|
cbe4645bc6 | ||
|
|
82cac0b12e | ||
|
|
1ae44941dd | ||
|
|
4b23c013d9 | ||
|
|
cf9b7d9d10 | ||
|
|
eeaf3c658b | ||
|
|
8fba42adbf | ||
|
|
dd8af73887 | ||
|
|
3eb9fd0acb | ||
|
|
c315ea9c96 | ||
|
|
864ad8a371 | ||
|
|
05d1bc22c6 | ||
|
|
e4a8c53079 | ||
|
|
c72542c92c | ||
|
|
7f83954714 | ||
|
|
8c88017703 | ||
|
|
3a2db112f3 | ||
|
|
f6877ecfef | ||
|
|
17ebc50b83 | ||
|
|
d800f1ce84 | ||
|
|
c000bc3c69 | ||
|
|
c277791ef3 | ||
|
|
7612657ded | ||
|
|
e421662576 | ||
|
|
2afddf497b | ||
|
|
c405eb08b5 | ||
|
|
e6dd986115 | ||
|
|
bf3e21f15c | ||
|
|
09139c2553 | ||
|
|
b85e7491a9 | ||
|
|
2fee3d1389 | ||
|
|
999efa5947 | ||
|
|
35b7fdf96b | ||
|
|
5ffaa4361e | ||
|
|
63db9fecb3 | ||
|
|
e687ae2819 | ||
|
|
4cfa4bc63f | ||
|
|
73f3e2cf73 | ||
|
|
6f132db328 | ||
|
|
b8053ff368 | ||
|
|
9ba457c91f | ||
|
|
255e484dcf | ||
|
|
e5fb03bbcd | ||
|
|
bea16b72df | ||
|
|
bea938bc34 | ||
|
|
b516acb173 | ||
|
|
ee96125385 | ||
|
|
6934285d83 | ||
|
|
5a52b141ed | ||
|
|
fdcbadf918 | ||
|
|
ebda018e13 | ||
|
|
f653a6eb79 | ||
|
|
307bcc95d1 | ||
|
|
a7f8ce36df | ||
|
|
f5f5bbf5eb | ||
|
|
8131ea8fc8 | ||
|
|
fac826b335 | ||
|
|
e069ddf8fa | ||
|
|
ccd12742d3 | ||
|
|
17695c361d | ||
|
|
0c8c9a9f12 | ||
|
|
aa1e8d8a40 | ||
|
|
0d9344ff19 | ||
|
|
98418ec5c3 | ||
|
|
5ab50db51c | ||
|
|
aa2177d35a | ||
|
|
9118406de3 | ||
|
|
ef47b27886 | ||
|
|
654178c8cd | ||
|
|
f73415827c | ||
|
|
d414a38877 | ||
|
|
85a0f9d007 | ||
|
|
358d799af8 | ||
|
|
cf0a216329 | ||
|
|
8615265ee1 | ||
|
|
ec23d72332 | ||
|
|
b3f6c45bc1 | ||
|
|
56b3112a07 | ||
|
|
b9c743d67e | ||
|
|
09af56b1c2 | ||
|
|
872b089b15 | ||
|
|
fd33c31b72 | ||
|
|
8b8a00b666 | ||
|
|
24d9db4c51 | ||
|
|
1d90aed187 | ||
|
|
7fe72c42b2 | ||
|
|
b880b5416f | ||
|
|
7b895474ef | ||
|
|
e3515b9eb2 | ||
|
|
c66e8e7b49 | ||
|
|
992d2dee45 | ||
|
|
0cde96844d | ||
|
|
6c36bd0a08 | ||
|
|
d791303967 | ||
|
|
0ff3f99c18 | ||
|
|
cfbfda4de3 | ||
|
|
a5be4cc3ae | ||
|
|
959f32327d | ||
|
|
1dd1cb9e44 | ||
|
|
16836375c4 | ||
|
|
71fca7fb86 | ||
|
|
b707c74203 | ||
|
|
acb119d80e | ||
|
|
b9f9a8fca2 | ||
|
|
f2c8122c46 | ||
|
|
9b1351db23 | ||
|
|
569edbb11a | ||
|
|
2580e4d6f3 | ||
|
|
32d51f3c25 | ||
|
|
8b90c0b3f0 | ||
|
|
067cbd5ab2 | ||
|
|
c7b8663c06 | ||
|
|
0c1a800f16 | ||
|
|
6c1f56d50e | ||
|
|
235bc99846 | ||
|
|
f94a0429d5 | ||
|
|
17331ddbaa | ||
|
|
527ecd37e1 | ||
|
|
6456658576 | ||
|
|
f8abe60dc2 | ||
|
|
01eb2c25e0 | ||
|
|
2ad2f26b2b | ||
|
|
75185f5e66 | ||
|
|
0bcb8ce6c3 | ||
|
|
b2b1e6b944 | ||
|
|
e6a1333f83 | ||
|
|
bf7b9092df | ||
|
|
1f3cc2c686 | ||
|
|
e09391a286 | ||
|
|
6d5d49ef50 | ||
|
|
9848b8b295 | ||
|
|
0fea730908 | ||
|
|
10a695ba0f | ||
|
|
65567221ac | ||
|
|
0f891be026 | ||
|
|
b22d28b79c | ||
|
|
2d9d5f0e98 | ||
|
|
7dc9da0fd0 | ||
|
|
a11d39f981 | ||
|
|
4ce920cc86 | ||
|
|
1965564386 | ||
|
|
f3d325ddab | ||
|
|
c0ae40c638 | ||
|
|
947bdbbe0c | ||
|
|
c99287dc10 | ||
|
|
49c20bef89 | ||
|
|
d26d7d2ff0 | ||
|
|
30f3ac86aa | ||
|
|
741fba4c27 | ||
|
|
baed7a2721 | ||
|
|
4ad074a90c | ||
|
|
6a0f3f3a73 | ||
|
|
ecdbe09c6c | ||
|
|
8d8366c190 | ||
|
|
faef619413 | ||
|
|
0c2b112234 | ||
|
|
ff0661d285 | ||
|
|
5052c7fa6f | ||
|
|
ab420e3d24 | ||
|
|
1616ba8ae4 | ||
|
|
da9a76715a | ||
|
|
3c68325132 | ||
|
|
5f9adcac37 | ||
|
|
d2dad75167 | ||
|
|
98c62fd6bd | ||
|
|
7fd6d78c83 | ||
|
|
c92959f3e8 | ||
|
|
c65e429072 | ||
|
|
c1ebce4ef5 | ||
|
|
c927e33c8c | ||
|
|
824aafbdea | ||
|
|
0c1586d7a4 | ||
|
|
b1ef52f62e | ||
|
|
05a913ccb2 | ||
|
|
f51dbcfb2c | ||
|
|
5f7578c5ea | ||
|
|
56eaca9081 | ||
|
|
51675f9d05 | ||
|
|
f5f87189df | ||
|
|
ef634075ab | ||
|
|
a07eea7815 | ||
|
|
5886b1ded8 | ||
|
|
299a80dd5a | ||
|
|
225e9e61ed | ||
|
|
fa4f2a938a | ||
|
|
ec2eefc9d2 | ||
|
|
58ee269855 | ||
|
|
ffc4f2c2d9 | ||
|
|
1b31c54917 | ||
|
|
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 | ||
|
|
fda30539b6 | ||
|
|
1ba68fcbfe | ||
|
|
f0e1c7e72c | ||
|
|
e90a3e2db6 | ||
|
|
663717d738 | ||
|
|
5329f212f7 | ||
|
|
d6e967a0d0 | ||
|
|
7ca2d20c17 | ||
|
|
9307ca5e16 | ||
|
|
60a42e3c34 | ||
|
|
5df95730d8 | ||
|
|
26a7aacfec | ||
|
|
67a9c454d0 | ||
|
|
c17493952b | ||
|
|
dd258bd46c | ||
|
|
8df9ea717c | ||
|
|
505c89066b | ||
|
|
31f2a47d26 | ||
|
|
e01ecfc387 | ||
|
|
69d9a0b11e | ||
|
|
33f4208f39 | ||
|
|
0eeda1d137 | ||
|
|
17d174bc5b | ||
|
|
9320f524a2 | ||
|
|
e31dc4e7f1 | ||
|
|
ab92e94bf8 | ||
|
|
da5708b5bc | ||
|
|
189a2a1871 | ||
|
|
ecf47da81b | ||
|
|
21c8b9a102 | ||
|
|
a07b418b8f | ||
|
|
4bf10e5612 | ||
|
|
e6fe6eb026 | ||
|
|
b4f80f39df | ||
|
|
4d32dd2cb5 | ||
|
|
de8fb60a30 | ||
|
|
b3b77f490d | ||
|
|
52abed83e6 | ||
|
|
80dc863455 | ||
|
|
1a3b55ce19 | ||
|
|
fa318a9f0e | ||
|
|
8dafad7ce3 | ||
|
|
78e35a5be8 | ||
|
|
35ed555857 | ||
|
|
954a5d77d3 | ||
|
|
f3130ff517 | ||
|
|
012c99be9e | ||
|
|
c8575c315b | ||
|
|
601d69faeb | ||
|
|
fdb7781a9b | ||
|
|
087578693e | ||
|
|
aceabb63f5 | ||
|
|
8587f72f81 | ||
|
|
1b5a71d478 | ||
|
|
83ad3b09d9 | ||
|
|
72811092b4 | ||
|
|
b67135e2c1 | ||
|
|
f5e16b0b70 | ||
|
|
f8535dd272 | ||
|
|
5cd8681b80 | ||
|
|
4b381c82b5 | ||
|
|
820b064e7f | ||
|
|
70cb6148c6 | ||
|
|
0cb9cb8bc9 | ||
|
|
c2c88d743b | ||
|
|
e8ef6b0b38 | ||
|
|
257459f96a | ||
|
|
027115ab87 | ||
|
|
96cb8134c4 | ||
|
|
b108cd1c90 | ||
|
|
d1ce9cefb8 | ||
|
|
f75e04f091 | ||
|
|
1fc182817e | ||
|
|
3c28b0adeb | ||
|
|
ec4b3d9018 | ||
|
|
8654485cfe | ||
|
|
9beb73ea40 | ||
|
|
3b19a33d4b | ||
|
|
13ba78103c | ||
|
|
538e4a1506 | ||
|
|
934581c796 | ||
|
|
1486b98d27 | ||
|
|
6cda430f03 | ||
|
|
f56c3d5f6e | ||
|
|
74c9143c95 | ||
|
|
0e4a833ffa | ||
|
|
37ad9885b7 | ||
|
|
5cef9a4032 | ||
|
|
f49767c38b | ||
|
|
7e8699ba02 | ||
|
|
5f0ce5ed7a | ||
|
|
49c7620bdd | ||
|
|
80fa7a1acd | ||
|
|
68770a42e2 | ||
|
|
06aebf716e | ||
|
|
f551b19f40 | ||
|
|
6674ad69e1 | ||
|
|
37d35684f1 | ||
|
|
71e5de0cdc | ||
|
|
d8656c6c9c | ||
|
|
443b487a02 | ||
|
|
bac57ebdf0 | ||
|
|
213a33e4f3 | ||
|
|
a00f87582d | ||
|
|
f129623000 | ||
|
|
8dbc97e466 | ||
|
|
4a0db185c0 | ||
|
|
5793f63ac8 | ||
|
|
8aabc67634 | ||
|
|
34c494ce51 | ||
|
|
178de02783 | ||
|
|
94e5b8d2c6 | ||
|
|
89e2247c05 | ||
|
|
b2ede61b79 | ||
|
|
db381ae9d1 | ||
|
|
f946cfd647 | ||
|
|
46c48c5ea8 | ||
|
|
e3bf160072 | ||
|
|
791425a5a8 | ||
|
|
d7acfd1af9 | ||
|
|
80fbfd6365 | ||
|
|
2ca27ebfb0 | ||
|
|
aa7651d95c | ||
|
|
88952e87c1 | ||
|
|
99f947e577 | ||
|
|
99c21f4fd4 | ||
|
|
aca1e712b8 | ||
|
|
ba58cd07c5 | ||
|
|
c981a65834 |
58
.github/ISSUE_TEMPLATE/01-bug_report.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: 问题反馈
|
||||
description: 软件问题反馈
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: searched
|
||||
attributes:
|
||||
label: 已经搜索过 Issues,未发现重复问题*
|
||||
options:
|
||||
- label: 我已经搜索过 Issues,没有发现重复问题
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: system
|
||||
attributes:
|
||||
label: 操作系统及版本
|
||||
placeholder: Windows 10 22H2 / macOS Mojave / Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 软件安装版本
|
||||
placeholder: v0.2.3
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题简述及复现流程
|
||||
description: 请详细描述你遇到的问题,并提供复现步骤
|
||||
placeholder: |
|
||||
1. 打开软件
|
||||
2. 点击 xxx
|
||||
3. 预期结果是 ...
|
||||
4. 实际结果是 ...
|
||||
5. 截图 ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 其他补充
|
||||
description: 如果你有额外信息,请在此填写
|
||||
placeholder: 可选
|
||||
|
||||
- type: checkboxes
|
||||
id: pr
|
||||
attributes:
|
||||
label: 是否愿意提交 PR 修复当前 Issue
|
||||
options:
|
||||
- label: 我愿意尝试提交 PR
|
||||
37
.github/ISSUE_TEMPLATE/02-feature_request.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: 功能建议
|
||||
description: 添加全新功能或改进现有功能
|
||||
title: "[Enhancement] "
|
||||
labels: ["enhancement"]
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: searched
|
||||
attributes:
|
||||
label: 已经搜索过 Issues,未发现重复问题*
|
||||
options:
|
||||
- label: 我已经搜索过 Issues,没有发现重复问题
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: feature
|
||||
attributes:
|
||||
label: 功能描述
|
||||
description: 请详细描述你希望添加或改进的功能
|
||||
placeholder: 请描述你想要的功能
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 其他补充
|
||||
description: 如果你有额外信息,请在此填写
|
||||
placeholder: 可选
|
||||
|
||||
- type: checkboxes
|
||||
id: pr
|
||||
attributes:
|
||||
label: 是否愿意提交 PR 实现当前 Issue
|
||||
options:
|
||||
- label: 我愿意尝试提交 PR
|
||||
30
.github/ISSUE_TEMPLATE/03-generic.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: 其他反馈
|
||||
description: 其他类型反馈、建议或讨论
|
||||
title: "[Question] "
|
||||
labels: ["question"]
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: searched
|
||||
attributes:
|
||||
label: 已经搜索过 Issues,未发现重复问题*
|
||||
options:
|
||||
- label: 我已经搜索过 Issues,没有发现重复问题
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: content
|
||||
attributes:
|
||||
label: 内容
|
||||
description: 请填写你的反馈、建议或讨论内容
|
||||
placeholder: 请描述你的问题或想法
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 其他补充
|
||||
description: 如果你有额外信息,请在此填写
|
||||
placeholder: 可选
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
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:
|
||||
- '*'
|
||||
1155
.github/workflows/dev-build.yml
vendored
Normal file
22
.github/workflows/release-winget.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
required: true
|
||||
description: 'Tag of release you want to publish'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-2025-vs2026
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@v2
|
||||
with:
|
||||
identifier: Syngnat.GoNavi
|
||||
installers-regex: 'GoNavi-windows-(amd64|arm64)\.exe$'
|
||||
release-tag: ${{ inputs.release_tag || github.ref_name }}
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
1084
.github/workflows/release.yml
vendored
20
.gitignore
vendored
@@ -1,12 +1,12 @@
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
.gitignore
|
||||
# build / release artifacts
|
||||
frontend/release/
|
||||
**/release/
|
||||
**/dist/
|
||||
**/build/
|
||||
build/bin/
|
||||
|
||||
# wails / node artifacts (按需)
|
||||
node_modules/
|
||||
@@ -15,4 +15,18 @@ dist/
|
||||
.DS_Store
|
||||
.gemini-clipboard
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
GoNavi-Wails.exe
|
||||
.ace-tool/
|
||||
.superpowers/
|
||||
.claude/
|
||||
.gemini/
|
||||
.playwright-mcp/
|
||||
**/tmpclaude-*
|
||||
docs/superpowers/
|
||||
docs/需求追踪/
|
||||
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
.worktrees
|
||||
docs
|
||||
.tmp_superpowers_edit
|
||||
|
||||
106
AI_EXTENSIONS_ROADMAP.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# AI 扩展能力路线
|
||||
|
||||
当前 GoNavi 的 AI 链路是:
|
||||
|
||||
1. 前端 `AIChatPanel` 组装 system messages。
|
||||
2. 前端声明本地固定工具 `LOCAL_TOOLS`。
|
||||
3. 后端 `aiservice.Service` 只负责 Provider 配置、安全级别与模型转发。
|
||||
|
||||
这套结构已经足够承接“用户级提示词”,但要继续承接 MCP 和 Skills,需要先把“提示词 / 工具 / 技能”三层职责拆开。
|
||||
|
||||
## 1. 用户级自定义提示词
|
||||
|
||||
已落地的方向:
|
||||
|
||||
- 配置存储在 `ai_config.json` 的 `userPromptSettings`。
|
||||
- 由 `AISettingsModal` 提供编辑入口。
|
||||
- 由 `AIChatPanel` 在运行时追加为 system message。
|
||||
|
||||
建议长期保持 4 个层级:
|
||||
|
||||
- `global`: 所有 AI 会话统一追加。
|
||||
- `database`: 数据库 / SQL 场景追加。
|
||||
- `jvm`: JVM 资源浏览与分析场景追加。
|
||||
- `jvmDiagnostic`: JVM 诊断命令规划场景追加。
|
||||
|
||||
这样既能满足“个人习惯”定制,也不会把所有场景揉成一条超长总提示词。
|
||||
|
||||
## 2. MCP 能力开放
|
||||
|
||||
目标不是把 MCP 做成新的聊天面板,而是把它变成“外部工具源”。
|
||||
|
||||
建议后续拆成三层:
|
||||
|
||||
1. `tool registry`
|
||||
- 统一收口内置工具、本地扩展工具、MCP 工具。
|
||||
- 对模型只暴露统一的 `tools[]`。
|
||||
2. `mcp server config`
|
||||
- 保存 server 名称、transport、启动命令或 URL、超时、启用状态。
|
||||
- 由后端维护生命周期与连通性。
|
||||
3. `mcp runtime bridge`
|
||||
- 负责 `list tools / call tool / errors / timeout / auth`。
|
||||
|
||||
### MCP 是否需要单独 GitHub 仓库
|
||||
|
||||
不需要把“GoNavi 对 MCP 的支持”单独拆仓库。
|
||||
|
||||
更合理的边界是:
|
||||
|
||||
- `GoNavi 主仓库`
|
||||
- 维护 MCP client、配置、UI、工具注册和运行时桥接。
|
||||
- `单独仓库(可选)`
|
||||
- 只在你要发布一个可复用的 MCP Server 时才有价值。
|
||||
- 例如 `gonavi-mcp-sql-tools`、`gonavi-mcp-jvm-agent` 这类独立 server。
|
||||
|
||||
结论:
|
||||
|
||||
- “客户端支持 MCP” 不需要新仓库。
|
||||
- “某个独立 MCP Server” 是否拆仓库,取决于它要不要单独发布、复用或部署。
|
||||
|
||||
## 3. Skills 设计
|
||||
|
||||
Skills 不建议直接等同于“另一种提示词”。
|
||||
|
||||
更合适的定义是:
|
||||
|
||||
- `skill manifest`
|
||||
- 名称、说明、适用场景、是否默认启用。
|
||||
- `skill prompt`
|
||||
- 该技能追加的 system prompt / few-shot / 输出约束。
|
||||
- `skill tool requirements`
|
||||
- 该技能依赖哪些内置工具或 MCP 工具。
|
||||
- `skill shortcuts`
|
||||
- 可选地给欢迎卡片、斜杠命令或快速动作提供入口。
|
||||
|
||||
一个 Skill 本质上应该是“提示词 + 工具依赖 + 使用入口”的组合,而不是单独一段文案。
|
||||
|
||||
### Skills 是否需要单独 GitHub 仓库
|
||||
|
||||
第一阶段不需要。
|
||||
|
||||
建议顺序:
|
||||
|
||||
1. 先在 GoNavi 主仓库内把 Skills manifest/runtime 跑通。
|
||||
2. 等格式稳定后,再考虑增加“本地目录导入”或“Git 仓库导入”。
|
||||
|
||||
只有当你明确要做下面两件事时,独立仓库才值得:
|
||||
|
||||
- 把 Skills 当作社区共享资产分发。
|
||||
- 让不同团队独立维护自己的 skill pack。
|
||||
|
||||
## 建议的下一步实现顺序
|
||||
|
||||
1. 抽出统一 `ToolRegistry`,让 `LOCAL_TOOLS` 不再硬编码在聊天面板内部。
|
||||
2. 在 AI 设置中新增 `MCP Servers` 配置页。
|
||||
3. 后端先支持最小 transport:
|
||||
- `stdio`
|
||||
- `http/sse`(如果后续确认需要)
|
||||
4. 在 AI 设置中新增 `Skills` 配置页。
|
||||
5. 让 Skill 以 manifest 形式声明:
|
||||
- `id`
|
||||
- `name`
|
||||
- `description`
|
||||
- `systemPrompt`
|
||||
- `requiredTools`
|
||||
- `scopes`
|
||||
6. 再决定是否增加“从 Git 仓库同步 MCP/Skills 包”的分发能力。
|
||||
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,先对齐方案再实施
|
||||
- 如提交内容与项目当前架构方向冲突,维护者可能要求收敛范围后再合并
|
||||
|
||||
感谢你的贡献。
|
||||
270
README.md
@@ -1,134 +1,254 @@
|
||||
# GoNavi - 现代化的轻量级数据库管理工具
|
||||
# GoNavi - A Modern Lightweight Database Client
|
||||
|
||||
[](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)
|
||||
|
||||
**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 |
|
||||
| Columnar Analytics | StarRocks | 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 |
|
||||
| Search | Elasticsearch | Optional driver agent | Index browsing, mapping inspection, JSON DSL / query_string search |
|
||||
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
|
||||
|
||||
<h2 align="center">📸 Screenshots</h2>
|
||||
|
||||
<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/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
|
||||
<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/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/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
|
||||
<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/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>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心特性
|
||||
## Key Features
|
||||
|
||||
### 🚀 极致性能
|
||||
- **零卡顿交互**:采用独创的 "幽灵拖拽" (Ghost Resizing) 技术,在包含数万行数据的表格中调整列宽,依然保持 60fps+ 的丝滑体验。
|
||||
- **虚拟滚动**:轻松处理海量数据展示,拒绝卡顿。
|
||||
### AI Assistant (New)
|
||||
- **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.
|
||||
|
||||
### 🔌 多数据库支持
|
||||
- **MySQL**:完整的支持,包括表结构设计、索引管理、外键管理等。
|
||||
- **PostgreSQL**:基础支持(持续完善中)。
|
||||
- **SQLite**:本地文件数据库支持。
|
||||
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
|
||||
### Performance
|
||||
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
|
||||
- **Virtualized rendering**: keeps large result sets responsive.
|
||||
|
||||
### 📊 强大的数据管理 (DataGrid)
|
||||
- **所见即所得编辑**:直接在表格中双击单元格修改数据。
|
||||
- **事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
|
||||
- **智能上下文**:自动识别单表查询,解锁编辑功能;复杂查询自动切换为只读模式。
|
||||
- **数据导出**:支持导出为 CSV, Excel (XLSX), JSON, Markdown 等格式。
|
||||
### Data Management (DataGrid)
|
||||
- In-place cell editing.
|
||||
- Batch insert/update/delete with transaction-oriented submit/rollback.
|
||||
- Large-field popup editor.
|
||||
- Context actions (set NULL, copy/export, etc.).
|
||||
- Smart read/write mode switching based on query context.
|
||||
- Export formats: CSV, Excel (XLSX), JSON, Markdown.
|
||||
|
||||
### 📝 智能 SQL 编辑器
|
||||
- **Monaco Editor 内核**:集成 VS Code 同款编辑器,体验极佳。
|
||||
- **智能补全**:自动感知当前连接上下文,提供数据库、表名、字段名的实时补全。
|
||||
- **多标签页**:支持多窗口并行操作,像浏览器一样管理你的查询会话。
|
||||
### SQL Editor
|
||||
- Monaco Editor core.
|
||||
- Context-aware completion for databases/tables/columns.
|
||||
- Multi-tab query workflow.
|
||||
|
||||
### 🎨 现代化 UI
|
||||
- **Ant Design 5**:企业级 UI 设计语言。
|
||||
- **暗黑模式**:内置深色/浅色主题切换,适应不同光照环境。
|
||||
- **响应式布局**:灵活的侧边栏与布局调整。
|
||||
### Batch Export / Backup
|
||||
- Database-level and table-level batch export/backup.
|
||||
- Scope-aware operation flow to reduce mistakes.
|
||||
|
||||
### Connectivity
|
||||
- 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 execution logs with timing information.
|
||||
- Startup/scheduled/manual update checks.
|
||||
|
||||
### UI/UX
|
||||
- Ant Design 5 based interface.
|
||||
- Light/Dark themes.
|
||||
- Flexible sidebar and layout behavior.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
## Tech Stack
|
||||
|
||||
* **后端 (Backend)**: Go 1.24 + Wails v2
|
||||
* **前端 (Frontend)**: React 18 + TypeScript + Vite
|
||||
* **UI 框架**: Ant Design 5
|
||||
* **状态管理**: Zustand
|
||||
* **编辑器**: Monaco Editor
|
||||
- **Backend**: Go 1.24 + Wails v2
|
||||
- **Frontend**: React 18 + TypeScript + Vite
|
||||
- **UI**: Ant Design 5
|
||||
- **State Management**: Zustand
|
||||
- **Editor**: Monaco Editor
|
||||
|
||||
---
|
||||
|
||||
## 📦 安装与运行
|
||||
## Installation and Run
|
||||
|
||||
### 前置要求
|
||||
* [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`
|
||||
### Prerequisites
|
||||
- [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@v2.11.0`
|
||||
|
||||
### 开发模式
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
```shell
|
||||
# Clone
|
||||
git clone https://github.com/Syngnat/GoNavi.git
|
||||
cd GoNavi
|
||||
|
||||
# 启动开发服务器 (支持热重载)
|
||||
# Start development with hot reload
|
||||
wails dev
|
||||
|
||||
# Faster local startup when exported Go method signatures are unchanged
|
||||
node tools/wails-fast-dev.mjs
|
||||
|
||||
# Refresh Wails JS bindings after changing exported Go method signatures
|
||||
node tools/wails-fast-dev.mjs --refresh-bindings
|
||||
|
||||
# Windows PowerShell low-memory visual mode: disables transparent WebView/Acrylic backdrop
|
||||
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
|
||||
```
|
||||
|
||||
### 编译构建
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# 构建当前平台的可执行文件
|
||||
# Build for current platform
|
||||
wails build
|
||||
|
||||
# 清理并构建 (推荐发布前使用)
|
||||
# Clean build (recommended before release)
|
||||
wails build -clean
|
||||
```
|
||||
|
||||
构建产物将位于 `build/bin` 目录下。
|
||||
Artifacts are generated in `build/bin`.
|
||||
|
||||
### 跨平台编译 (GitHub Actions)
|
||||
### Cross-Platform Release (GitHub Actions)
|
||||
|
||||
本项目内置了 GitHub Actions 流水线,Push `v*` 格式的 Tag 即可自动触发构建并发布 Release。
|
||||
支持构建:
|
||||
* macOS (AMD64 / ARM64)
|
||||
* Windows (AMD64)
|
||||
The repository includes a release workflow.
|
||||
Push a `v*` tag to trigger automated build and release.
|
||||
Release notes are generated automatically from merged pull requests and categorized by `.github/release.yaml`.
|
||||
|
||||
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` 拖入 **应用程序** 文件夹。
|
||||
2. 打开 **终端 (Terminal)**。
|
||||
3. 复制并执行以下命令(输入密码时不会显示):
|
||||
```bash
|
||||
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
||||
```
|
||||
4. 或者:在 Finder 中右键点击应用图标,按住 `Control` 键选择 **打开**,然后在弹出的窗口中再次点击 **打开**。
|
||||
1. Move `GoNavi.app` to **Applications**.
|
||||
2. Open **Terminal**.
|
||||
3. Run:
|
||||
|
||||
```bash
|
||||
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
||||
```
|
||||
|
||||
Or right-click the app in Finder and choose **Open** with Control key flow.
|
||||
|
||||
### Linux: missing `libwebkit2gtk` / `libjavascriptcoregtk`
|
||||
|
||||
GoNavi depends on WebKitGTK runtime libraries.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
If you use Linux artifacts with the `-WebKit41` suffix, prefer Debian 13 / Ubuntu 24.04+.
|
||||
|
||||
### Linux: Chinese text appears as square boxes
|
||||
|
||||
Minimal Ubuntu 24.04 LTS desktop/server environments may not include Chinese CJK fonts. Install Noto / WenQuanYi fonts and restart GoNavi:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei
|
||||
fc-cache -fv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
## Contributing
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
Issues and pull requests are welcome.
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交你的改动 (`git commit -m 'feat: Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启一个 Pull Request
|
||||
For the full workflow, branch model, and maintainer sync rules, see:
|
||||
|
||||
## 📄 开源协议
|
||||
- [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).
|
||||
|
||||
238
README.zh-CN.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# 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 执行 |
|
||||
| 列式分析 | StarRocks | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
|
||||
| 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 |
|
||||
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
|
||||
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
|
||||
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
|
||||
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | Vastbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
|
||||
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
|
||||
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
|
||||
| 搜索 | Elasticsearch | 可选驱动代理 | 索引浏览、Mapping 检查、JSON DSL / query_string 查询 |
|
||||
| 扩展接入 | 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@v2.11.0`
|
||||
|
||||
### 开发模式
|
||||
|
||||
```shell
|
||||
# 克隆项目
|
||||
git clone https://github.com/Syngnat/GoNavi.git
|
||||
cd GoNavi
|
||||
|
||||
# 启动开发(热重载)
|
||||
wails dev
|
||||
|
||||
# 本地快速启动:未修改 Go 导出方法签名时使用
|
||||
node tools/wails-fast-dev.mjs
|
||||
|
||||
# 修改 Go 导出方法签名后刷新 Wails JS 绑定
|
||||
node tools/wails-fast-dev.mjs --refresh-bindings
|
||||
|
||||
# Windows PowerShell 低内存视觉模式:关闭透明 WebView 和 Acrylic 背景
|
||||
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
|
||||
```
|
||||
|
||||
### 编译构建
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Linux 中文显示为方框
|
||||
|
||||
Ubuntu 24.04 LTS 的最小化桌面或服务器环境可能没有安装中文 CJK 字体,GoNavi 打开后中文会显示为方框。安装 Noto / 文泉驿字体后重启 GoNavi:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei
|
||||
fc-cache -fv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
|
||||
欢迎提交 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
|
||||
460
build-driver-agents.sh
Executable file
@@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine clickhouse elasticsearch)
|
||||
DEFAULT_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64)
|
||||
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
|
||||
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
|
||||
DUCKDB_WINDOWS_SUPPORT_DLL="duckdb.dll"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./build-driver-agents.sh [选项]
|
||||
|
||||
选项:
|
||||
--drivers <列表> 指定驱动列表(逗号分隔),例如:kingbase,mongodb
|
||||
--platform <目标> 目标平台:current、all、GOOS/GOARCH,或逗号分隔列表
|
||||
默认 current(当前 Go 环境)
|
||||
--out-dir <目录> 输出目录根路径,默认:dist/driver-agents
|
||||
--bundle-name <文件名> 驱动总包 zip 名称,默认:GoNavi-DriverAgents.zip
|
||||
--strict 任一驱动构建失败即中断(默认失败后继续,最后汇总)
|
||||
--upx 要求使用 UPX 压缩支持的平台产物(默认 auto:有 upx 则压缩)
|
||||
--no-upx 禁用 UPX 压缩
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
./build-driver-agents.sh
|
||||
./build-driver-agents.sh --drivers kingbase
|
||||
./build-driver-agents.sh --platform windows/amd64 --drivers kingbase,mongodb
|
||||
./build-driver-agents.sh --platform all
|
||||
./build-driver-agents.sh --platform darwin/arm64,windows/amd64,linux/amd64
|
||||
EOF
|
||||
}
|
||||
|
||||
normalize_driver() {
|
||||
local name
|
||||
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)"
|
||||
case "$name" in
|
||||
doris|diros) echo "doris" ;;
|
||||
open_gauss|open-gauss) echo "opengauss" ;;
|
||||
elasticsearch|elastic) echo "elasticsearch" ;;
|
||||
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|iris|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
|
||||
}
|
||||
|
||||
current_platform() {
|
||||
echo "$(go env GOOS)/$(go env GOARCH)"
|
||||
}
|
||||
|
||||
append_platform() {
|
||||
local candidate
|
||||
candidate="$1"
|
||||
if [[ "$platform_seen" == *"|$candidate|"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
platforms+=("$candidate")
|
||||
platform_seen="${platform_seen}${candidate}|"
|
||||
}
|
||||
|
||||
normalize_platform() {
|
||||
local value goos goarch platform_dir
|
||||
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
case "$value" in
|
||||
current|"")
|
||||
current_platform
|
||||
;;
|
||||
*/*)
|
||||
goos="${value%%/*}"
|
||||
goarch="${value##*/}"
|
||||
platform_dir="$(platform_dir_name "$goos")"
|
||||
if [[ -z "$goos" || -z "$goarch" || "$platform_dir" == "Unknown" ]]; then
|
||||
return 1
|
||||
fi
|
||||
echo "$goos/$goarch"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
zip_bundle() {
|
||||
local bundle_zip_path="$1"
|
||||
local bundle_stage_dir="$2"
|
||||
local -a bundle_dirs=()
|
||||
local dir
|
||||
|
||||
for dir in "$bundle_stage_dir"/*; do
|
||||
[[ -d "$dir" ]] || continue
|
||||
bundle_dirs+=("$(basename "$dir")")
|
||||
done
|
||||
|
||||
if [[ ${#bundle_dirs[@]} -eq 0 ]]; then
|
||||
echo "❌ 驱动总包 staging 目录为空。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$bundle_zip_path"
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$bundle_stage_dir"
|
||||
zip -qry "$bundle_zip_path" "${bundle_dirs[@]}"
|
||||
)
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
BUNDLE_STAGE_DIR="$bundle_stage_dir" BUNDLE_ZIP_PATH="$bundle_zip_path" python3 - <<'PY'
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
stage = Path(os.environ["BUNDLE_STAGE_DIR"])
|
||||
target = Path(os.environ["BUNDLE_ZIP_PATH"])
|
||||
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in stage.rglob("*"):
|
||||
if path.is_file():
|
||||
zf.write(path, path.relative_to(stage).as_posix())
|
||||
PY
|
||||
else
|
||||
echo "❌ 未找到 zip 或 python3,无法生成驱动总包 zip。"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
zip_duckdb_windows_package() {
|
||||
local bundle_stage_dir="$1"
|
||||
local zip_path="$2"
|
||||
|
||||
rm -f "$zip_path"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
BUNDLE_STAGE_DIR="$bundle_stage_dir" BUNDLE_ZIP_PATH="$zip_path" python3 - <<'PY'
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
stage_dir = os.environ["BUNDLE_STAGE_DIR"]
|
||||
zip_path = os.environ["BUNDLE_ZIP_PATH"]
|
||||
entries = [
|
||||
("Windows/duckdb-driver-agent-windows-amd64.exe", os.path.join(stage_dir, "Windows", "duckdb-driver-agent-windows-amd64.exe")),
|
||||
("Windows/duckdb.dll", os.path.join(stage_dir, "Windows", "duckdb.dll")),
|
||||
]
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for arcname, src in entries:
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError(src)
|
||||
zf.write(src, arcname)
|
||||
PY
|
||||
elif command -v zip >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$bundle_stage_dir"
|
||||
zip -qry "$zip_path" "Windows/duckdb-driver-agent-windows-amd64.exe" "Windows/duckdb.dll"
|
||||
)
|
||||
else
|
||||
echo "❌ 未找到 python3 或 zip,无法生成 DuckDB Windows 专属驱动包。"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_duckdb_windows_library() {
|
||||
local cache_root="$1"
|
||||
local lib_dir="$cache_root/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
|
||||
local zip_path="$cache_root/libduckdb-windows-amd64.zip"
|
||||
|
||||
if [[ -f "$lib_dir/duckdb.dll" && -f "$lib_dir/duckdb.lib" ]]; then
|
||||
printf '%s\n' "$lib_dir"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$lib_dir"
|
||||
echo "⬇️ 下载 DuckDB Windows 官方动态库:$DUCKDB_WINDOWS_LIBRARY_URL" >&2
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q "$DUCKDB_WINDOWS_LIBRARY_URL" -O "$zip_path"
|
||||
else
|
||||
echo "❌ 未找到 curl 或 wget,无法下载 DuckDB Windows 动态库。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -qo "$zip_path" -d "$lib_dir"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
DUCKDB_LIB_ZIP="$zip_path" DUCKDB_LIB_DIR="$lib_dir" python3 - <<'PY'
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
zip_path = os.environ["DUCKDB_LIB_ZIP"]
|
||||
target = os.environ["DUCKDB_LIB_DIR"]
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
zf.extractall(target)
|
||||
PY
|
||||
else
|
||||
echo "❌ 未找到 unzip 或 python3,无法解压 DuckDB Windows 动态库。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$lib_dir/duckdb.dll" || ! -f "$lib_dir/duckdb.lib" ]]; then
|
||||
echo "❌ DuckDB Windows 动态库包缺少 duckdb.dll 或 duckdb.lib。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a"
|
||||
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a"
|
||||
printf '%s\n' "$lib_dir"
|
||||
}
|
||||
|
||||
join_by_comma() {
|
||||
local IFS=,
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
driver_csv=""
|
||||
target_platform=""
|
||||
out_root="dist/driver-agents"
|
||||
bundle_name="GoNavi-DriverAgents.zip"
|
||||
duckdb_windows_zip_name="duckdb-driver.zip"
|
||||
strict_mode="false"
|
||||
upx_mode="${GONAVI_DRIVER_AGENT_UPX:-auto}"
|
||||
|
||||
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
|
||||
;;
|
||||
--upx)
|
||||
upx_mode="required"
|
||||
shift
|
||||
;;
|
||||
--no-upx)
|
||||
upx_mode="off"
|
||||
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
|
||||
|
||||
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
|
||||
revision_driver_csv="$(join_by_comma "${drivers[@]}")"
|
||||
|
||||
declare -a platforms=()
|
||||
platform_seen="|"
|
||||
if [[ -z "$target_platform" ]]; then
|
||||
target_platform="current"
|
||||
fi
|
||||
IFS=',' read -r -a raw_platforms <<<"$target_platform"
|
||||
for item in "${raw_platforms[@]}"; do
|
||||
normalized_platform="$(printf '%s' "$item" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
if [[ "$normalized_platform" == "all" ]]; then
|
||||
for default_platform in "${DEFAULT_PLATFORMS[@]}"; do
|
||||
append_platform "$default_platform"
|
||||
done
|
||||
continue
|
||||
fi
|
||||
normalized_platform="$(normalize_platform "$item")" || {
|
||||
echo "❌ --platform 参数格式错误,应为 current、all、GOOS/GOARCH 或逗号分隔列表,例如 darwin/arm64,windows/amd64"
|
||||
exit 1
|
||||
}
|
||||
append_platform "$normalized_platform"
|
||||
done
|
||||
|
||||
if [[ ${#platforms[@]} -eq 0 ]]; then
|
||||
echo "❌ 未指定有效目标平台。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$out_root"
|
||||
out_root_abs="$(cd "$out_root" && pwd)"
|
||||
bundle_stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-driver-bundle.XXXXXX")"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$bundle_stage_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ ${#platforms[@]} -eq 1 ]]; then
|
||||
single_platform="${platforms[0]}"
|
||||
single_platform_key="${single_platform/\//-}"
|
||||
single_output_dir="${out_root%/}/$single_platform_key"
|
||||
mkdir -p "$single_output_dir"
|
||||
bundle_zip_path="$(cd "$single_output_dir" && pwd)/$bundle_name"
|
||||
else
|
||||
bundle_zip_path="$out_root_abs/$bundle_name"
|
||||
fi
|
||||
|
||||
declare -a built_assets=()
|
||||
declare -a failed_drivers=()
|
||||
declare -a skipped_drivers=()
|
||||
|
||||
echo "🚀 开始构建 optional-driver-agent"
|
||||
echo " 平台:${platforms[*]}"
|
||||
echo " 输出根目录:$out_root_abs"
|
||||
echo " 驱动列表:${drivers[*]}"
|
||||
|
||||
for platform in "${platforms[@]}"; do
|
||||
goos="${platform%%/*}"
|
||||
goarch="${platform##*/}"
|
||||
platform_key="${goos}-${goarch}"
|
||||
platform_dir="$(platform_dir_name "$goos")"
|
||||
output_dir="${out_root%/}/${platform_key}"
|
||||
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
|
||||
|
||||
mkdir -p "$output_dir" "$bundle_platform_dir"
|
||||
output_dir_abs="$(cd "$output_dir" && pwd)"
|
||||
|
||||
echo ""
|
||||
echo "🧭 生成 driver-agent revision 指纹:$platform"
|
||||
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$platform" --drivers "$revision_driver_csv"
|
||||
|
||||
for driver in "${drivers[@]}"; do
|
||||
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
|
||||
echo "⚠️ 跳过 duckdb($platform 仅支持 windows/amd64)"
|
||||
skipped_drivers+=("duckdb($platform)")
|
||||
continue
|
||||
fi
|
||||
|
||||
build_driver="$(build_driver_name "$driver")"
|
||||
tag="gonavi_${build_driver}_driver"
|
||||
build_tags="$tag"
|
||||
asset_name="${driver}-driver-agent-${goos}-${goarch}"
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
asset_name="${asset_name}.exe"
|
||||
fi
|
||||
output_path="$output_dir_abs/$asset_name"
|
||||
|
||||
cgo_enabled=0
|
||||
if [[ "$driver" == "duckdb" ]]; then
|
||||
cgo_enabled=1
|
||||
fi
|
||||
duckdb_lib_dir=""
|
||||
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" == "amd64" ]]; then
|
||||
duckdb_lib_dir="$(prepare_duckdb_windows_library "$bundle_stage_dir")"
|
||||
build_tags="$build_tags duckdb_use_lib"
|
||||
fi
|
||||
|
||||
echo "🔧 构建 $driver -> $asset_name (platform=$platform, tags=$build_tags, CGO_ENABLED=$cgo_enabled)"
|
||||
set +e
|
||||
if [[ -n "$duckdb_lib_dir" ]]; then
|
||||
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
|
||||
CGO_LDFLAGS="-L${duckdb_lib_dir} -lduckdb" PATH="${duckdb_lib_dir}:$PATH" \
|
||||
go build -tags "$build_tags" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
|
||||
else
|
||||
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
|
||||
go build -tags "$build_tags" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
|
||||
fi
|
||||
build_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ $build_exit -ne 0 ]]; then
|
||||
echo "❌ 构建失败:$driver ($platform)"
|
||||
failed_drivers+=("$driver($platform)")
|
||||
if [[ "$strict_mode" == "true" ]]; then
|
||||
exit $build_exit
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
GONAVI_DRIVER_AGENT_UPX="$upx_mode" "$SCRIPT_DIR/tools/compress-driver-artifact.sh" "$output_path" "$platform" "$platform_dir/$asset_name"
|
||||
cp "$output_path" "$bundle_platform_dir/$asset_name"
|
||||
if [[ -n "$duckdb_lib_dir" ]]; then
|
||||
cp "$duckdb_lib_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" "$output_dir_abs/$DUCKDB_WINDOWS_SUPPORT_DLL"
|
||||
GONAVI_DRIVER_AGENT_UPX="$upx_mode" "$SCRIPT_DIR/tools/compress-driver-artifact.sh" "$output_dir_abs/$DUCKDB_WINDOWS_SUPPORT_DLL" "$platform" "$platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL"
|
||||
cp "$output_dir_abs/$DUCKDB_WINDOWS_SUPPORT_DLL" "$bundle_platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL"
|
||||
built_assets+=("$platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL")
|
||||
fi
|
||||
built_assets+=("$platform_dir/$asset_name")
|
||||
done
|
||||
done
|
||||
|
||||
if [[ ${#built_assets[@]} -eq 0 ]]; then
|
||||
echo "❌ 未成功构建任何驱动代理。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
zip_bundle "$bundle_zip_path" "$bundle_stage_dir"
|
||||
|
||||
duckdb_asset_path="$out_root_abs/windows-amd64/duckdb-driver-agent-windows-amd64.exe"
|
||||
duckdb_dll_path="$out_root_abs/windows-amd64/$DUCKDB_WINDOWS_SUPPORT_DLL"
|
||||
if [[ -f "$duckdb_asset_path" && -f "$duckdb_dll_path" ]]; then
|
||||
duckdb_zip_path="$out_root_abs/windows-amd64/$duckdb_windows_zip_name"
|
||||
zip_duckdb_windows_package "$bundle_stage_dir" "$duckdb_zip_path"
|
||||
built_assets+=("Windows/$duckdb_windows_zip_name")
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 构建完成"
|
||||
echo " 单文件输出根目录:$out_root_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
|
||||
472
build-release.sh
@@ -1,17 +1,44 @@
|
||||
#!/bin/bash
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 配置
|
||||
APP_NAME="GoNavi"
|
||||
DIST_DIR="dist"
|
||||
BUILD_BIN_DIR="build/bin"
|
||||
DEFAULT_BINARY_NAME="GoNavi" # 对应 wails.json 中的 outputfilename
|
||||
DEV_VERSION_FILE="version/dev-version.txt"
|
||||
DEFAULT_DEV_VERSION="0.0.1-test"
|
||||
|
||||
# 提取版本号
|
||||
VERSION=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]')
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="0.0.0"
|
||||
fi
|
||||
resolve_build_version() {
|
||||
if [ -n "${GONAVI_VERSION:-}" ]; then
|
||||
printf '%s\n' "${GONAVI_VERSION}"
|
||||
return
|
||||
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"
|
||||
LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION"
|
||||
|
||||
# 颜色配置
|
||||
GREEN='\033[0;32m'
|
||||
@@ -19,131 +46,346 @@ RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
BUILD_FAILURES=()
|
||||
|
||||
record_build_failure() {
|
||||
local target="$1"
|
||||
BUILD_FAILURES+=("$target")
|
||||
}
|
||||
|
||||
get_file_size_bytes() {
|
||||
local target="$1"
|
||||
if [ ! -f "$target" ]; then
|
||||
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}"
|
||||
generate_driver_agent_revisions "darwin/${platform}"
|
||||
wails build -platform "darwin/${platform}" -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ macOS ${platform} 构建失败。${NC}"
|
||||
record_build_failure "macOS ${platform}"
|
||||
return
|
||||
fi
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
generate_driver_agent_revisions() {
|
||||
local platform="$1"
|
||||
echo " 🧭 正在生成 driver-agent revision 指纹 (${platform})..."
|
||||
./tools/generate-driver-agent-revisions.sh --platform "$platform"
|
||||
}
|
||||
|
||||
echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}"
|
||||
|
||||
# 清理并创建输出目录
|
||||
rm -rf $DIST_DIR
|
||||
mkdir -p $DIST_DIR
|
||||
rm -rf "$DIST_DIR"
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
# --- macOS ARM64 构建 ---
|
||||
echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}"
|
||||
wails build -platform darwin/arm64 -clean
|
||||
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
|
||||
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
|
||||
package_macos_release "arm64" "mac-arm64"
|
||||
package_macos_release "amd64" "mac-amd64"
|
||||
|
||||
# --- Windows AMD64 构建 ---
|
||||
echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
|
||||
if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
||||
wails build -platform windows/amd64 -clean
|
||||
generate_driver_agent_revisions "windows/amd64"
|
||||
wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS"
|
||||
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"
|
||||
else
|
||||
echo -e "${RED} ❌ Windows 构建失败。${NC}"
|
||||
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
|
||||
record_build_failure "Windows amd64"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 MinGW 工具 (x86_64-w64-mingw32-gcc),跳过 Windows 构建。${NC}"
|
||||
echo -e "${YELLOW} ⚠️ 未找到 MinGW 工具 (x86_64-w64-mingw32-gcc),跳过 Windows amd64 构建。${NC}"
|
||||
fi
|
||||
|
||||
# --- Windows ARM64 构建 ---
|
||||
echo -e "${GREEN}🪟 正在构建 Windows (arm64)...${NC}"
|
||||
if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
|
||||
generate_driver_agent_revisions "windows/arm64"
|
||||
wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
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"
|
||||
else
|
||||
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
|
||||
record_build_failure "Windows arm64"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 MinGW ARM64 工具 (aarch64-w64-mingw32-gcc),跳过 Windows arm64 构建。${NC}"
|
||||
echo " 安装命令: brew install mingw-w64 (需要支持 ARM64 的版本)"
|
||||
fi
|
||||
|
||||
# --- Linux AMD64 构建 ---
|
||||
echo -e "${GREEN}🐧 正在构建 Linux (amd64)...${NC}"
|
||||
# 检测当前系统
|
||||
CURRENT_OS=$(uname -s)
|
||||
CURRENT_ARCH=$(uname -m)
|
||||
|
||||
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
|
||||
# 本机 Linux amd64,直接构建
|
||||
generate_driver_agent_revisions "linux/amd64"
|
||||
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_LINUX_BIN="$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
|
||||
cd "$DIST_DIR"
|
||||
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
|
||||
rm "${APP_NAME}-${VERSION}-linux-amd64"
|
||||
cd ..
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux amd64 构建失败。${NC}"
|
||||
record_build_failure "Linux amd64"
|
||||
fi
|
||||
elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
|
||||
# macOS 或其他系统,尝试交叉编译
|
||||
export CC=x86_64-linux-gnu-gcc
|
||||
export CXX=x86_64-linux-gnu-g++
|
||||
export CGO_ENABLED=1
|
||||
generate_driver_agent_revisions "linux/amd64"
|
||||
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_LINUX_BIN="$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"
|
||||
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
|
||||
rm "${APP_NAME}-${VERSION}-linux-amd64"
|
||||
cd ..
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux amd64 交叉编译失败。${NC}"
|
||||
record_build_failure "Linux amd64"
|
||||
fi
|
||||
unset CC CXX CGO_ENABLED
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 非 Linux 系统且未找到交叉编译工具,跳过 Linux amd64 构建。${NC}"
|
||||
echo " 在 Linux 上运行此脚本可直接构建,或安装交叉编译工具链。"
|
||||
fi
|
||||
|
||||
# --- Linux ARM64 构建 ---
|
||||
echo -e "${GREEN}🐧 正在构建 Linux (arm64)...${NC}"
|
||||
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
|
||||
# 本机 Linux arm64,直接构建
|
||||
generate_driver_agent_revisions "linux/arm64"
|
||||
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_LINUX_BIN="$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"
|
||||
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
|
||||
rm "${APP_NAME}-${VERSION}-linux-arm64"
|
||||
cd ..
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux arm64 构建失败。${NC}"
|
||||
record_build_failure "Linux arm64"
|
||||
fi
|
||||
elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
|
||||
# 交叉编译
|
||||
export CC=aarch64-linux-gnu-gcc
|
||||
export CXX=aarch64-linux-gnu-g++
|
||||
export CGO_ENABLED=1
|
||||
generate_driver_agent_revisions "linux/arm64"
|
||||
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_LINUX_BIN="$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"
|
||||
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
|
||||
rm "${APP_NAME}-${VERSION}-linux-arm64"
|
||||
cd ..
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux arm64 交叉编译失败。${NC}"
|
||||
record_build_failure "Linux arm64"
|
||||
fi
|
||||
unset CC CXX CGO_ENABLED
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 非 Linux ARM64 系统且未找到交叉编译工具,跳过 Linux arm64 构建。${NC}"
|
||||
echo " 安装命令 (Ubuntu): sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu"
|
||||
echo " 安装命令 (macOS): brew install aarch64-linux-gnu-gcc (需要第三方 tap)"
|
||||
fi
|
||||
|
||||
# 清理中间构建目录
|
||||
rm -rf "build/bin"
|
||||
|
||||
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
|
||||
ls -1 "$DIST_DIR"
|
||||
echo -e "${GREEN}🔐 生成 SHA256SUMS...${NC}"
|
||||
if command -v sha256sum &> /dev/null; then
|
||||
cd "$DIST_DIR"
|
||||
: > SHA256SUMS
|
||||
for f in *; do
|
||||
[ -f "$f" ] || continue
|
||||
sha256sum "$f" >> SHA256SUMS
|
||||
done
|
||||
cd ..
|
||||
elif command -v shasum &> /dev/null; then
|
||||
cd "$DIST_DIR"
|
||||
: > SHA256SUMS
|
||||
for f in *; do
|
||||
[ -f "$f" ] || continue
|
||||
shasum -a 256 "$f" >> SHA256SUMS
|
||||
done
|
||||
cd ..
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 sha256sum/shasum,跳过校验文件生成。${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
|
||||
echo -e "${RED}❌ 构建未完全成功,失败平台:${BUILD_FAILURES[*]}${NC}"
|
||||
echo -e "${YELLOW}📦 已成功生成的产物在 'dist/' 目录下:${NC}"
|
||||
else
|
||||
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
|
||||
fi
|
||||
ls -lh "$DIST_DIR"
|
||||
echo ""
|
||||
echo -e "${GREEN}📋 支持的平台:${NC}"
|
||||
echo " • macOS (Intel/Apple Silicon): .zip"
|
||||
echo " • Windows (x64/ARM64): .exe"
|
||||
echo " • Linux (x64/ARM64): .tar.gz"
|
||||
echo ""
|
||||
echo -e "${YELLOW}💡 提示:Linux AppImage 包请使用 GitHub Actions CI/CD 构建。${NC}"
|
||||
|
||||
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BIN
build/appicon.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
68
build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}.dev</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
63
build/darwin/Info.plist
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
build/darwin/icon.icns
Normal file
BIN
build/windows/icon.ico
Normal file
|
After Width: | Height: | Size: 32 KiB |
15
build/windows/info.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
build/windows/wails.exe.manifest
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
193
cmd/gonavi-mcp-server/README.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# GoNavi MCP Server
|
||||
|
||||
`gonavi-mcp-server` 会把 GoNavi 已保存连接背后的数据库能力通过 MCP 暴露给外部客户端。本机客户端默认使用 `stdio`;云端 Agent 可使用显式开启的 Streamable HTTP 模式。
|
||||
|
||||
## 当前提供的 tools
|
||||
|
||||
- `get_connections`
|
||||
- 返回 GoNavi 已保存连接的 `id/name/type/target/defaultDatabase` 等摘要信息
|
||||
- `get_databases`
|
||||
- 入参:`connectionId`
|
||||
- `get_tables`
|
||||
- 入参:`connectionId`、可选 `dbName`
|
||||
- `get_columns`
|
||||
- 入参:`connectionId`、可选 `dbName`、`tableName`
|
||||
- `get_table_ddl`
|
||||
- 入参:`connectionId`、可选 `dbName`、`tableName`
|
||||
- `execute_sql`
|
||||
- 入参:`connectionId`、可选 `dbName`、`sql`
|
||||
- 默认只允许只读 SQL
|
||||
- 如果 SQL 包含 DDL/DML,必须显式传 `allowMutating=true`
|
||||
- `maxRowsPerResult` 用来限制单个结果集返回的行数,默认 `200`
|
||||
|
||||
远程 Agent 只需要库表结构时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、库表、字段、索引、外键、触发器和 DDL 工具。
|
||||
|
||||
## 运行方式
|
||||
|
||||
开发态直接运行:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/gonavi-mcp-server
|
||||
```
|
||||
|
||||
显式运行本机 `stdio`:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/gonavi-mcp-server stdio
|
||||
```
|
||||
|
||||
也可以先编译:
|
||||
|
||||
```powershell
|
||||
go build -o .\bin\gonavi-mcp-server.exe .\cmd\gonavi-mcp-server
|
||||
```
|
||||
|
||||
远程 Agent 使用 Streamable HTTP 时必须设置 bearer token:
|
||||
|
||||
```powershell
|
||||
$env:GONAVI_MCP_HTTP_TOKEN = "<随机token>"
|
||||
go run ./cmd/gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --schema-only
|
||||
```
|
||||
|
||||
安装包主程序也支持同样模式:
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\GoNavi\GoNavi.exe" mcp-server http --addr 127.0.0.1:8765 --path /mcp --token "<随机token>" --schema-only
|
||||
```
|
||||
|
||||
默认建议只监听 `127.0.0.1`,再通过 SSH 隧道、反向代理或内网网关暴露给云端 Agent。不要在没有 TLS、防火墙和鉴权的情况下直接监听公网地址。
|
||||
|
||||
无图形界面或需要把配置交给云端 Agent 时,可直接生成 OpenClaw / Hermans 等远程 MCP 配置:
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\GoNavi\GoNavi.exe" mcp-server remote-config --client openclaw --url "https://<你的域名或隧道地址>/mcp" --token "<随机token>" --schema-only
|
||||
```
|
||||
|
||||
独立 server 开发态也支持同样能力:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/gonavi-mcp-server remote-config --client hermans --url "https://<你的域名或隧道地址>/mcp" --token "<随机token>" --schema-only
|
||||
```
|
||||
|
||||
## Claude Code / Codex / OpenClaw / Hermans
|
||||
|
||||
正式安装包场景,推荐直接在 GoNavi 里使用“AI 设置 -> MCP 服务 -> 安装到 Claude Code / 安装到 Codex”。
|
||||
|
||||
它会自动把当前安装的 `GoNavi.exe` 写入 Claude Code 的用户级 `~/.claude.json`,命令形态类似:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gonavi": {
|
||||
"type": "stdio",
|
||||
"command": "C:\\Program Files\\GoNavi\\GoNavi.exe",
|
||||
"args": ["mcp-server"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样用户不需要自己找本机 `gonavi-mcp-server.exe` 路径,安装包本体就能直接作为 MCP 入口。
|
||||
|
||||
Codex 当前使用 `~/.codex/config.toml`,GoNavi 会写入类似下面这段:
|
||||
|
||||
```toml
|
||||
[mcp_servers.gonavi]
|
||||
command = 'C:\Program Files\GoNavi\GoNavi.exe'
|
||||
args = ['mcp-server']
|
||||
startup_timeout_sec = 60
|
||||
```
|
||||
|
||||
仓库开发态如果要在本机 `Claude Code CLI` 里稳定使用这个 MCP,仍然推荐走仓库内包装脚本:
|
||||
|
||||
```powershell
|
||||
.\tools\claude-gonavi-mcp.ps1 -p "必须调用 gonavi MCP 的 get_connections 工具"
|
||||
```
|
||||
|
||||
或者:
|
||||
|
||||
```cmd
|
||||
tools\claude-gonavi-mcp.cmd -p "必须调用 gonavi MCP 的 get_connections 工具"
|
||||
```
|
||||
|
||||
这个脚本会先构建 `bin\gonavi-mcp-server.exe`,再通过 `--mcp-config` 和 `--strict-mcp-config` 把 GoNavi MCP 单独注入当前 Claude 会话,避免默认混合 MCP 加载时序导致的首轮工具未挂载问题。
|
||||
|
||||
OpenClaw、Hermans 这类部署在云端或远端 Linux 的 Agent,不能直接使用 Windows 本机的 `stdio` 命令。GoNavi 的连接信息和数据库密码仍应留在 Windows 本机,由 GoNavi MCP 读取保存连接和系统凭据;远端 Agent 只拿到 MCP tools 和 `connectionId`。
|
||||
|
||||
推荐接入形态:
|
||||
|
||||
1. Windows 本机运行 GoNavi,并保持能访问已保存的数据库连接。
|
||||
2. 在 Windows 本机启动 `GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only`。
|
||||
3. 通过 SSH 隧道、反向代理或内网网关把 `http://127.0.0.1:8765/mcp` 暴露为云端 Agent 可访问的 HTTPS 地址。
|
||||
4. 在 OpenClaw / Hermans 中添加远程 MCP Server,transport 选择 Streamable HTTP,URL 指向 `/mcp` 地址,并设置请求头 `Authorization: Bearer <随机token>`。
|
||||
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases`、`get_tables`、`get_columns`、`get_table_ddl` 等工具读取结构。
|
||||
|
||||
如果目标 Agent 支持 `mcpServers` JSON,可按下面的通用片段配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gonavi": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://<你的域名或隧道地址>/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <随机token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不要把数据库 `host/user/password` 写入云端 Agent 的配置文件。默认 `--schema-only` 不暴露 `execute_sql`;如果你明确需要远程执行 SQL,可以去掉该参数,此时 `execute_sql` 仍受 GoNavi AI 安全设置控制,写操作必须显式传 `allowMutating=true`。
|
||||
|
||||
## MCP 客户端配置示例
|
||||
|
||||
开发态:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gonavi": {
|
||||
"command": "go",
|
||||
"args": ["run", "./cmd/gonavi-mcp-server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Windows 独立 server 编译产物(开发态):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gonavi": {
|
||||
"command": "D:\\Work\\CodeRepos\\GoNavi\\bin\\gonavi-mcp-server.exe",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Windows 已安装 GoNavi(推荐给最终用户):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gonavi": {
|
||||
"type": "stdio",
|
||||
"command": "C:\\Program Files\\GoNavi\\GoNavi.exe",
|
||||
"args": ["mcp-server"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
- 先调用 `get_connections`,拿到 `connectionId`
|
||||
- 之后所有数据库工具都只传 `connectionId`,由 GoNavi 服务端内部解析保存连接和密钥
|
||||
- 如果 `dbName` 为空,会优先使用该保存连接里的默认数据库
|
||||
- Server 会读取 GoNavi 当前活动数据目录里的连接配置,并通过系统 keyring/凭据管理器解析密文
|
||||
- 如果本机凭据存储不可用,依赖密钥的连接会返回对应错误
|
||||
42
cmd/gonavi-mcp-server/main.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/mcpserver"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
err := run(ctx, os.Args[1:])
|
||||
if err != nil {
|
||||
log.Printf("GoNavi MCP Server 退出: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return mcpserver.RunAppStdioServer(ctx)
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(args[0]))
|
||||
switch mode {
|
||||
case "stdio", "--stdio":
|
||||
return mcpserver.RunAppStdioServer(ctx)
|
||||
case "http", "--http", "streamable-http", "--streamable-http":
|
||||
options, err := mcpserver.ParseHTTPServerOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("GoNavi MCP Streamable HTTP Server 启动:addr=%s path=%s schemaOnly=%v", options.Addr, options.Path, options.SchemaOnly)
|
||||
return mcpserver.RunAppStreamableHTTPServer(ctx, options)
|
||||
case "remote-config", "--remote-config":
|
||||
return mcpserver.WriteRemoteMCPClientConfig(os.Stdout, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("未知 MCP server 模式: %s(支持 stdio/http/remote-config)", args[0])
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
27
cmd/mingw-import-lib/main.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"GoNavi-Wails/internal/buildutil"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
dllPath string
|
||||
dlltoolPath string
|
||||
outputLib string
|
||||
)
|
||||
|
||||
flag.StringVar(&dllPath, "dll", "", "Path to the source DLL")
|
||||
flag.StringVar(&dlltoolPath, "dlltool", "", "Optional path to dlltool executable")
|
||||
flag.StringVar(&outputLib, "output-lib", "", "Output import library path")
|
||||
flag.Parse()
|
||||
|
||||
if err := buildutil.GenerateWindowsImportLibraryFromDLL(dllPath, dlltoolPath, outputLib); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "generate mingw import library failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
227
cmd/mysql-driver-agent/main.go
Normal file
@@ -0,0 +1,227 @@
|
||||
//go:build gonavi_mysql_driver
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
type mysqlAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName,omitempty"`
|
||||
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
type mysqlAgentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
RowsAffected int64 `json:"rowsAffected,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
mysqlAgentMethodConnect = "connect"
|
||||
mysqlAgentMethodClose = "close"
|
||||
mysqlAgentMethodPing = "ping"
|
||||
mysqlAgentMethodQuery = "query"
|
||||
mysqlAgentMethodExec = "exec"
|
||||
mysqlAgentMethodGetDatabases = "getDatabases"
|
||||
mysqlAgentMethodGetTables = "getTables"
|
||||
mysqlAgentMethodGetCreateStmt = "getCreateStatement"
|
||||
mysqlAgentMethodGetColumns = "getColumns"
|
||||
mysqlAgentMethodGetAllColumns = "getAllColumns"
|
||||
mysqlAgentMethodGetIndexes = "getIndexes"
|
||||
mysqlAgentMethodGetForeignKey = "getForeignKeys"
|
||||
mysqlAgentMethodGetTriggers = "getTriggers"
|
||||
mysqlAgentMethodApplyChanges = "applyChanges"
|
||||
)
|
||||
|
||||
func main() {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 16<<10), 8<<20)
|
||||
writer := bufio.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
var inst *db.MySQLDB
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var req mysqlAgentRequest
|
||||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||||
_ = writeResponse(writer, mysqlAgentResponse{
|
||||
ID: req.ID,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("解析请求失败:%v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
resp := handleRequest(&inst, req)
|
||||
if err := writeResponse(writer, resp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if inst != nil {
|
||||
_ = inst.Close()
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRequest(inst **db.MySQLDB, req mysqlAgentRequest) mysqlAgentResponse {
|
||||
resp := mysqlAgentResponse{
|
||||
ID: req.ID,
|
||||
Success: true,
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(req.Method) {
|
||||
case mysqlAgentMethodConnect:
|
||||
if req.Config == nil {
|
||||
return fail(resp, "连接配置为空")
|
||||
}
|
||||
if *inst != nil {
|
||||
_ = (*inst).Close()
|
||||
}
|
||||
next := &db.MySQLDB{}
|
||||
if err := next.Connect(*req.Config); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
*inst = next
|
||||
return resp
|
||||
case mysqlAgentMethodClose:
|
||||
if *inst != nil {
|
||||
if err := (*inst).Close(); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
*inst = nil
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
if *inst == nil {
|
||||
return fail(resp, "connection not open")
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(req.Method) {
|
||||
case mysqlAgentMethodPing:
|
||||
if err := (*inst).Ping(); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
case mysqlAgentMethodQuery:
|
||||
data, fields, err := (*inst).Query(req.Query)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Fields = fields
|
||||
case mysqlAgentMethodExec:
|
||||
affected, err := (*inst).Exec(req.Query)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.RowsAffected = affected
|
||||
case mysqlAgentMethodGetDatabases:
|
||||
data, err := (*inst).GetDatabases()
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetTables:
|
||||
data, err := (*inst).GetTables(req.DBName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetCreateStmt:
|
||||
data, err := (*inst).GetCreateStatement(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetColumns:
|
||||
data, err := (*inst).GetColumns(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetAllColumns:
|
||||
data, err := (*inst).GetAllColumns(req.DBName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetIndexes:
|
||||
data, err := (*inst).GetIndexes(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetForeignKey:
|
||||
data, err := (*inst).GetForeignKeys(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetTriggers:
|
||||
data, err := (*inst).GetTriggers(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodApplyChanges:
|
||||
if req.Changes == nil {
|
||||
return fail(resp, "变更集为空")
|
||||
}
|
||||
applier, ok := interface{}(*inst).(interface {
|
||||
ApplyChanges(tableName string, changes connection.ChangeSet) error
|
||||
})
|
||||
if !ok {
|
||||
return fail(resp, "当前驱动不支持 ApplyChanges")
|
||||
}
|
||||
if err := applier.ApplyChanges(req.TableName, *req.Changes); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
default:
|
||||
return fail(resp, "不支持的方法")
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func writeResponse(writer *bufio.Writer, resp mysqlAgentResponse) error {
|
||||
payload, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
func fail(resp mysqlAgentResponse, errText string) mysqlAgentResponse {
|
||||
resp.Success = false
|
||||
resp.Error = strings.TrimSpace(errText)
|
||||
return resp
|
||||
}
|
||||
338
cmd/optional-driver-agent/main.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
type agentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
TimeoutMs int64 `json:"timeoutMs,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName,omitempty"`
|
||||
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
type agentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
RowsAffected int64 `json:"rowsAffected,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
agentMethodConnect = "connect"
|
||||
agentMethodClose = "close"
|
||||
agentMethodMetadata = "metadata"
|
||||
agentMethodPing = "ping"
|
||||
agentMethodQuery = "query"
|
||||
agentMethodExec = "exec"
|
||||
agentMethodGetDatabases = "getDatabases"
|
||||
agentMethodGetTables = "getTables"
|
||||
agentMethodGetCreateStmt = "getCreateStatement"
|
||||
agentMethodGetColumns = "getColumns"
|
||||
agentMethodGetAllColumns = "getAllColumns"
|
||||
agentMethodGetIndexes = "getIndexes"
|
||||
agentMethodGetForeignKey = "getForeignKeys"
|
||||
agentMethodGetTriggers = "getTriggers"
|
||||
agentMethodApplyChanges = "applyChanges"
|
||||
)
|
||||
|
||||
const legacyClickHouseDefaultTimeout = 2 * time.Hour
|
||||
|
||||
var (
|
||||
agentDriverType string
|
||||
agentDatabaseFactory func() db.Database
|
||||
)
|
||||
|
||||
func main() {
|
||||
if agentDatabaseFactory == nil || strings.TrimSpace(agentDriverType) == "" {
|
||||
fmt.Fprintf(os.Stderr, "未配置驱动代理 provider,请使用 gonavi_<driver>_driver 标签构建\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 16<<10), 8<<20)
|
||||
writer := bufio.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
var inst db.Database
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var req agentRequest
|
||||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||||
_ = writeResponse(writer, agentResponse{
|
||||
ID: req.ID,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("解析请求失败:%v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
resp := handleRequest(&inst, req)
|
||||
if err := writeResponse(writer, resp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if inst != nil {
|
||||
_ = inst.Close()
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
resp := agentResponse{ID: req.ID, Success: true}
|
||||
method := strings.TrimSpace(req.Method)
|
||||
|
||||
switch method {
|
||||
case agentMethodConnect:
|
||||
if req.Config == nil {
|
||||
return fail(resp, "连接配置为空")
|
||||
}
|
||||
if *inst != nil {
|
||||
_ = (*inst).Close()
|
||||
}
|
||||
next := agentDatabaseFactory()
|
||||
if next == nil {
|
||||
return fail(resp, "驱动代理初始化失败")
|
||||
}
|
||||
if err := next.Connect(*req.Config); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
*inst = next
|
||||
return resp
|
||||
case agentMethodClose:
|
||||
if *inst != nil {
|
||||
if err := (*inst).Close(); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
*inst = nil
|
||||
}
|
||||
return resp
|
||||
case agentMethodMetadata:
|
||||
resp.Data = map[string]string{
|
||||
"driverType": strings.TrimSpace(agentDriverType),
|
||||
"agentRevision": db.OptionalDriverAgentRevision(agentDriverType),
|
||||
"protocolSchema": "json-lines-v1",
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
if *inst == nil {
|
||||
return fail(resp, "connection not open")
|
||||
}
|
||||
|
||||
switch method {
|
||||
case agentMethodPing:
|
||||
if err := (*inst).Ping(); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
case agentMethodQuery:
|
||||
data, fields, err := queryWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Fields = fields
|
||||
case agentMethodExec:
|
||||
affected, err := execWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.RowsAffected = affected
|
||||
case agentMethodGetDatabases:
|
||||
data, err := (*inst).GetDatabases()
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetTables:
|
||||
data, err := (*inst).GetTables(req.DBName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetCreateStmt:
|
||||
data, err := (*inst).GetCreateStatement(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetColumns:
|
||||
data, err := (*inst).GetColumns(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetAllColumns:
|
||||
data, err := (*inst).GetAllColumns(req.DBName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetIndexes:
|
||||
data, err := (*inst).GetIndexes(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetForeignKey:
|
||||
data, err := (*inst).GetForeignKeys(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetTriggers:
|
||||
data, err := (*inst).GetTriggers(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodApplyChanges:
|
||||
if req.Changes == nil {
|
||||
return fail(resp, "变更集为空")
|
||||
}
|
||||
applier, ok := (*inst).(interface {
|
||||
ApplyChanges(tableName string, changes connection.ChangeSet) error
|
||||
})
|
||||
if !ok {
|
||||
return fail(resp, "当前驱动不支持 ApplyChanges")
|
||||
}
|
||||
if err := applier.ApplyChanges(req.TableName, *req.Changes); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
default:
|
||||
return fail(resp, "不支持的方法")
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func writeResponse(writer *bufio.Writer, resp agentResponse) error {
|
||||
// 对响应数据做统一 JSON 安全归一化:
|
||||
// 将 map[any]any(如 duckdb.Map)递归转换为 map[string]any,避免序列化失败导致代理进程退出。
|
||||
safeResp := resp
|
||||
safeResp.Data = normalizeAgentResponseData(resp.Data)
|
||||
payload, err := json.Marshal(safeResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
func fail(resp agentResponse, errText string) agentResponse {
|
||||
resp.Success = false
|
||||
resp.Error = strings.TrimSpace(errText)
|
||||
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)
|
||||
}
|
||||
200
cmd/optional-driver-agent/main_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestMetadataReportsAgentRevision(t *testing.T) {
|
||||
previousDriverType := agentDriverType
|
||||
previousFactory := agentDatabaseFactory
|
||||
t.Cleanup(func() {
|
||||
agentDriverType = previousDriverType
|
||||
agentDatabaseFactory = previousFactory
|
||||
})
|
||||
agentDriverType = "clickhouse"
|
||||
agentDatabaseFactory = func() db.Database { return nil }
|
||||
|
||||
var inst db.Database
|
||||
resp := handleRequest(&inst, agentRequest{ID: 7, Method: agentMethodMetadata})
|
||||
if !resp.Success {
|
||||
t.Fatalf("metadata request failed: %s", resp.Error)
|
||||
}
|
||||
data, ok := resp.Data.(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("metadata response data type = %T", resp.Data)
|
||||
}
|
||||
if data["driverType"] != "clickhouse" {
|
||||
t.Fatalf("unexpected driver type: %q", data["driverType"])
|
||||
}
|
||||
if data["agentRevision"] != db.OptionalDriverAgentRevision("clickhouse") {
|
||||
t.Fatalf("unexpected agent revision: %q", data["agentRevision"])
|
||||
}
|
||||
}
|
||||
|
||||
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_dameng.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_dameng_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "dameng"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.DamengDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_diros.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_diros_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "diros"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.DirosDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_duckdb.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_duckdb_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "duckdb"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.DuckDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_elasticsearch.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_elasticsearch_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "elasticsearch"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.ElasticsearchDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_highgo.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_highgo_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "highgo"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.HighGoDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_iris.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_iris_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "iris"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.IrisDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_kingbase.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_kingbase_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "kingbase"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.KingbaseDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_mariadb.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_mariadb_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "mariadb"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.MariaDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_mongodb.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_mongodb_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "mongodb"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.MongoDB{}
|
||||
}
|
||||
}
|
||||
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{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_mysql.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_mysql_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "mysql"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.MySQLDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_oceanbase.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_oceanbase_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "oceanbase"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.OceanBaseDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_opengauss.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_opengauss_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "opengauss"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.OpenGaussDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_sphinx.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_sphinx_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "sphinx"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.SphinxDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_sqlite.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_sqlite_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "sqlite"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.SQLiteDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_sqlserver.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_sqlserver_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "sqlserver"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.SqlServerDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_starrocks.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_starrocks_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "starrocks"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.StarRocksDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_tdengine.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_tdengine_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "tdengine"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.TDengineDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_vastbase.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_vastbase_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "vastbase"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.VastbaseDB{}
|
||||
}
|
||||
}
|
||||
107
docs/driver-manifest.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"engine": "go",
|
||||
"drivers": {
|
||||
"mariadb": {
|
||||
"engine": "go",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mariadb"
|
||||
},
|
||||
"oceanbase": {
|
||||
"engine": "go",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/oceanbase"
|
||||
},
|
||||
"doris": {
|
||||
"engine": "go",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/doris"
|
||||
},
|
||||
"sphinx": {
|
||||
"engine": "go",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sphinx"
|
||||
},
|
||||
"sqlserver": {
|
||||
"engine": "go",
|
||||
"version": "1.9.6",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sqlserver"
|
||||
},
|
||||
"sqlite": {
|
||||
"engine": "go",
|
||||
"version": "1.44.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sqlite"
|
||||
},
|
||||
"duckdb": {
|
||||
"engine": "go",
|
||||
"version": "2.5.6",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/duckdb"
|
||||
},
|
||||
"dameng": {
|
||||
"engine": "go",
|
||||
"version": "1.8.22",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/dameng"
|
||||
},
|
||||
"kingbase": {
|
||||
"engine": "go",
|
||||
"version": "0.0.0-20201021123113-29bd62a876c3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/kingbase"
|
||||
},
|
||||
"highgo": {
|
||||
"engine": "go",
|
||||
"version": "0.0.0-local",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/highgo"
|
||||
},
|
||||
"vastbase": {
|
||||
"engine": "go",
|
||||
"version": "1.11.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/vastbase"
|
||||
},
|
||||
"opengauss": {
|
||||
"engine": "go",
|
||||
"version": "1.11.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/opengauss"
|
||||
},
|
||||
"mongodb": {
|
||||
"engine": "go",
|
||||
"version": "2.5.0",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mongodb"
|
||||
},
|
||||
"tdengine": {
|
||||
"engine": "go",
|
||||
"version": "3.7.8",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/tdengine"
|
||||
},
|
||||
"clickhouse": {
|
||||
"engine": "go",
|
||||
"version": "2.43.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/clickhouse"
|
||||
},
|
||||
"postgres": {
|
||||
"engine": "go",
|
||||
"version": "1.11.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/postgres"
|
||||
},
|
||||
"elasticsearch": {
|
||||
"engine": "go",
|
||||
"version": "8.19.0",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/elasticsearch"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.ace-tool/
|
||||
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>
|
||||
@@ -2,12 +2,29 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
3594
frontend/package-lock.json
generated
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "gonavi-client",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.6.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
@@ -15,11 +16,18 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"antd": "^5.12.0",
|
||||
"clsx": "^2.1.0",
|
||||
"fflate": "^0.8.3",
|
||||
"mermaid": "^11.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"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",
|
||||
"uuid": "^9.0.1",
|
||||
"zustand": "^4.4.7"
|
||||
@@ -28,9 +36,12 @@
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
c1af19c07654ec9f98628c358ae49b1a
|
||||
416aaa5c6e66a62430103d6905ad9465
|
||||
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 |
10
frontend/public/db-icons/elasticsearch.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-15 0 286 286" width="800px" height="800px" preserveAspectRatio="xMidYMid">
|
||||
<g>
|
||||
<path d="M14.3443,80.1733 L203.5503,80.1733 C224.4013,80.1733 243.0203,70.6123 255.5133,55.6863 C229.4533,21.8353 188.5523,0.0003 142.5303,0.0003 C86.1783,0.0003 37.4763,32.7113 14.3443,80.1733" fill="#F0BF1A"/>
|
||||
<path d="M187.5152,102.4438 L5.7552,102.4438 C2.0332,115.1648 0.0002,128.6068 0.0002,142.5298 C0.0002,156.4538 2.0332,169.8968 5.7552,182.6168 L187.5152,182.6168 C209.3402,182.6168 227.6022,164.8008 227.6022,142.5298 C227.6022,120.2598 209.7862,102.4438 187.5152,102.4438" fill="#07A5DE"/>
|
||||
<path d="M255.9996,228.7548 C243.5856,214.1638 225.1166,204.8868 204.4406,204.8868 L14.3446,204.8868 C37.4766,252.3498 86.1786,285.0598 142.5296,285.0598 C188.8356,285.0598 229.9656,262.9628 255.9996,228.7548" fill="#3EBEB0"/>
|
||||
<path d="M5.7555,102.4438 C2.0325,115.1648 0.0005,128.6068 0.0005,142.5298 C0.0005,156.4538 2.0325,169.8968 5.7555,182.6168 L124.7135,182.6168 C127.8315,170.5908 129.6125,157.2288 129.6125,142.5298 C129.6125,127.8318 127.8315,114.4698 124.7135,102.4438 L5.7555,102.4438 Z" fill="#231F20"/>
|
||||
<path d="M70.8199,19.1528 C46.7669,33.4058 26.7239,54.7848 14.2529,80.1738 L119.3689,80.1738 C108.6789,55.6758 91.7539,35.1878 70.8199,19.1528" fill="#D7A229"/>
|
||||
<path d="M75.274,268.1347 C95.762,251.6547 112.242,229.8297 122.487,204.8867 L14.253,204.8867 C27.615,231.6117 48.995,253.8817 75.274,268.1347" fill="#019B8F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
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 |
52
frontend/public/logo.svg
Normal file
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<!-- Background: Soft Light Grey -->
|
||||
<linearGradient id="bgSoft" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f5f7fa;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c3cfe2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Hexagon: Solid Tech Pink -->
|
||||
<linearGradient id="solidPink" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#FF5F6D;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#FFC371;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- N: Solid Tech Blue/Cyan -->
|
||||
<linearGradient id="solidCyan" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#00c6ff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0072ff;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="hardShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
|
||||
<feOffset dx="4" dy="4" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.2"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect x="32" y="32" width="448" height="448" rx="100" fill="url(#bgSoft)" />
|
||||
|
||||
<!-- Main Content Centered -->
|
||||
<g transform="translate(106, 106) scale(0.6)" filter="url(#hardShadow)">
|
||||
|
||||
<!-- Hex G -->
|
||||
<path d="M 250 0 L 466 125 L 466 375 L 250 500 L 34 375 L 34 125 Z"
|
||||
fill="none" stroke="url(#solidPink)" stroke-width="45" stroke-linejoin="round"/>
|
||||
|
||||
<!-- G Crossbar -->
|
||||
<path d="M 466 300 L 330 300" stroke="url(#solidPink)" stroke-width="45" stroke-linecap="round"/>
|
||||
|
||||
<!-- Inner N -->
|
||||
<path d="M 160 350 L 160 150 L 340 350 L 340 150"
|
||||
fill="none" stroke="url(#solidCyan)" stroke-width="50" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
125
frontend/scripts/wails-frontend-install.mjs
Normal file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const frontendDir = path.resolve(scriptDir, '..');
|
||||
const packageJsonPath = path.join(frontendDir, 'package.json');
|
||||
const packageLockPath = path.join(frontendDir, 'package-lock.json');
|
||||
const nodeModulesPath = path.join(frontendDir, 'node_modules');
|
||||
const npmHiddenLockPath = path.join(nodeModulesPath, '.package-lock.json');
|
||||
const installStatePath = path.join(nodeModulesPath, '.gonavi-install-state.json');
|
||||
const npmCommand = 'npm';
|
||||
const commonArgs = [
|
||||
'--prefer-offline',
|
||||
'--no-audit',
|
||||
'--fund=false',
|
||||
'--fetch-retries=5',
|
||||
'--fetch-retry-mintimeout=20000',
|
||||
'--fetch-retry-maxtimeout=120000',
|
||||
];
|
||||
const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
|
||||
|
||||
const fail = (message) => {
|
||||
console.error(`[gonavi-frontend-install] ${message}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
const exitWithStatus = (status) => {
|
||||
process.exit(typeof status === 'number' && status > 0 && status <= 255 ? status : 1);
|
||||
};
|
||||
|
||||
const hashFile = (filePath) => {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(readFileSync(filePath));
|
||||
return hash.digest('hex');
|
||||
};
|
||||
|
||||
const currentState = () => ({
|
||||
packageJson: hashFile(packageJsonPath),
|
||||
packageLock: existsSync(packageLockPath) ? hashFile(packageLockPath) : '',
|
||||
});
|
||||
|
||||
const readInstalledState = () => {
|
||||
if (!existsSync(installStatePath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(installStatePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeInstalledState = (state) => {
|
||||
writeFileSync(installStatePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
const packageInputsAreOlderThanNpmLock = () => {
|
||||
if (!existsSync(npmHiddenLockPath)) return false;
|
||||
const markerTime = statSync(npmHiddenLockPath).mtimeMs;
|
||||
return [packageJsonPath, packageLockPath]
|
||||
.filter(existsSync)
|
||||
.every((filePath) => statSync(filePath).mtimeMs <= markerTime);
|
||||
};
|
||||
|
||||
const runNpm = (subcommand) => {
|
||||
const args = [subcommand, ...commonArgs];
|
||||
if (isCI) {
|
||||
console.log(
|
||||
`[gonavi-frontend-install] cwd=${process.cwd()} frontend=${frontendDir} node=${process.version} platform=${process.platform}/${process.arch} command=${npmCommand} ${args.join(' ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = spawnSync(npmCommand, args, {
|
||||
cwd: frontendDir,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
if (result.error) {
|
||||
fail(`failed to start npm: ${result.error.message}`);
|
||||
}
|
||||
if (result.signal) {
|
||||
fail(`npm was terminated by signal ${result.signal}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
console.error(`[gonavi-frontend-install] npm exited with status ${result.status ?? 'unknown'}`);
|
||||
exitWithStatus(result.status);
|
||||
}
|
||||
};
|
||||
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
fail(`package.json not found at ${packageJsonPath}; cwd=${process.cwd()}`);
|
||||
}
|
||||
|
||||
const state = currentState();
|
||||
const installedState = readInstalledState();
|
||||
const forceInstall = process.env.GONAVI_FORCE_FRONTEND_INSTALL === '1';
|
||||
|
||||
if (!forceInstall && existsSync(nodeModulesPath)) {
|
||||
if (
|
||||
installedState?.packageJson === state.packageJson &&
|
||||
installedState?.packageLock === state.packageLock
|
||||
) {
|
||||
console.log('Frontend dependencies are up to date; skipping npm install.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!installedState && isCI && existsSync(npmHiddenLockPath)) {
|
||||
writeInstalledState(state);
|
||||
console.log('Frontend dependencies are up to date from CI cache; recorded install state.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!installedState && packageInputsAreOlderThanNpmLock()) {
|
||||
writeInstalledState(state);
|
||||
console.log('Frontend dependencies are up to date; recorded install state.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
runNpm(isCI ? 'ci' : 'install');
|
||||
writeInstalledState(state);
|
||||
27
frontend/src/App.ai-panel-error-boundary.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const aiPanelBoundarySource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./components/ai/AIPanelErrorBoundary.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('AI panel lazy-load guard', () => {
|
||||
it('keeps AI panel failures scoped to the panel area with retry support', () => {
|
||||
expect(appSource).toContain("import AIChatPanel from './components/AIChatPanel';");
|
||||
expect(appSource).toContain("import AIPanelErrorBoundary from './components/ai/AIPanelErrorBoundary';");
|
||||
expect(aiPanelBoundarySource).toContain('class AIPanelErrorBoundary extends React.Component');
|
||||
expect(appSource).toContain('<AIPanelErrorBoundary');
|
||||
expect(appSource).toContain('key={aiPanelRenderNonce}');
|
||||
expect(appSource).toContain('AI 面板加载失败');
|
||||
expect(appSource).toContain('重新加载');
|
||||
expect(appSource).toContain('setAiPanelRenderNonce((current) => current + 1)');
|
||||
expect(appSource).toContain('<AIChatPanel width={aiPanelRenderWidth}');
|
||||
expect(appSource).not.toContain('const loadAIChatPanelModule = async (retryNonce: number) => {');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,33 @@
|
||||
:root {
|
||||
--gn-font-sans: "Inter", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "Ubuntu", sans-serif;
|
||||
--gn-font-mono: "JetBrains Mono", "Noto Sans Mono CJK SC", "Noto Sans Mono", ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden; /* Disable global scrollbar */
|
||||
background-color: transparent !important; /* CRITICAL: Allow Wails window transparency */
|
||||
}
|
||||
|
||||
body, #root {
|
||||
border-radius: var(--gonavi-border-radius); /* Slightly rounded app window corners */
|
||||
}
|
||||
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: var(--gn-font-sans);
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--gn-font-mono);
|
||||
}
|
||||
|
||||
/* 侧边栏 Tree 样式优化 */
|
||||
@@ -30,4 +55,639 @@ html, body, #root {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
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;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-switcher {
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-iconEle {
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-title {
|
||||
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 */
|
||||
body[data-theme='dark'] ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
body[data-theme='dark'] ::-webkit-scrollbar-track {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
body[data-theme='dark'] ::-webkit-scrollbar-corner {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
body[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #1f1f1f;
|
||||
}
|
||||
body[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
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 */
|
||||
body {
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] {
|
||||
/* 移除全局 text-shadow:对每个文本元素增加 GPU compositing 成本,
|
||||
在透明窗口环境下会显著加剧 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 滚动条 */
|
||||
.connection-modal-wrap {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.connection-modal-wrap .ant-modal-content {
|
||||
max-height: calc(100vh - 72px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.connection-modal-wrap .ant-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.connection-modal-wrap .ant-modal-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Custom Title Bar Close Button Hover */
|
||||
.titlebar-close-btn:hover {
|
||||
background-color: #ff4d4f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.driver-manager-modal .ant-modal-body {
|
||||
background: var(--ant-color-bg-layout, #f5f5f5);
|
||||
}
|
||||
|
||||
.driver-manager-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.driver-manager-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.driver-manager-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-manager-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(64px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.driver-manager-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
min-height: 58px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(5, 5, 5, 0.02);
|
||||
}
|
||||
|
||||
.driver-manager-stat span:first-child {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.driver-manager-stat-warning span:first-child {
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.driver-manager-directory-panel {
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.driver-manager-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.driver-manager-search {
|
||||
min-width: 280px;
|
||||
flex: 1 1 360px;
|
||||
}
|
||||
|
||||
.driver-manager-toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.driver-manager-batch-progress-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.driver-manager-batch-progress-header,
|
||||
.driver-manager-batch-progress-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-manager-batch-progress-header span,
|
||||
.driver-manager-batch-progress-meta span {
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.driver-manager-list-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.driver-manager-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.driver-manager-card {
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.driver-manager-card-warning {
|
||||
border-color: rgba(250, 173, 20, 0.35);
|
||||
}
|
||||
|
||||
.driver-manager-card-ready {
|
||||
border-color: rgba(82, 196, 26, 0.22);
|
||||
}
|
||||
|
||||
.driver-manager-card-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 38%);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.driver-manager-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-manager-title-row,
|
||||
.driver-manager-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-manager-driver-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.driver-manager-meta-row {
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.driver-manager-update-note {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(250, 173, 20, 0.1);
|
||||
}
|
||||
|
||||
.driver-manager-note-text,
|
||||
.driver-manager-muted-message {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.driver-manager-muted-message {
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.driver-manager-card-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-manager-control-block {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.driver-manager-control-label,
|
||||
.driver-manager-small-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.driver-manager-version-control {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.driver-manager-version-lock {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.driver-manager-card-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.driver-manager-card-actions .ant-btn {
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.driver-manager-footer-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.driver-manager-header,
|
||||
.driver-manager-card-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.driver-manager-stats {
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.driver-manager-card-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.driver-manager-batch-progress-header,
|
||||
.driver-manager-batch-progress-meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
.gonavi-query-editor-link-hint {
|
||||
color: #1677ff !important;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: solid;
|
||||
text-decoration-color: currentColor;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.gonavi-query-editor-object-token {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.gonavi-query-editor-column-token {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.gonavi-query-editor-db-token {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-query-editor-object-token {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-query-editor-link-hint {
|
||||
color: #69b1ff !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-query-editor-column-token {
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-query-editor-db-token {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
/* Legacy sidebar resize bounds — mirror v2 .gn-v2-app-sider so Ant Design inline width locks do not collapse drag range. */
|
||||
body[data-ui-version="legacy"] .ant-layout-sider {
|
||||
min-width: 232px !important;
|
||||
max-width: 420px !important;
|
||||
}
|
||||
|
||||
286
frontend/src/App.tool-center.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const appCss = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.css', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const linuxCJKFontBannerSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./components/LinuxCJKFontBanner.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const getGlobalShortcutCaseBlock = (action: string) => {
|
||||
const caseToken = `case '${action}':`;
|
||||
const start = appSource.indexOf(caseToken);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
|
||||
const afterCase = appSource.slice(start + caseToken.length);
|
||||
const nextCaseIndex = afterCase.search(/\n\s+case '[^']+':/);
|
||||
const switchEndIndex = afterCase.indexOf("window.addEventListener('keydown', handleGlobalShortcut, true);");
|
||||
const endIndex = nextCaseIndex >= 0 ? nextCaseIndex : switchEndIndex;
|
||||
|
||||
expect(endIndex).toBeGreaterThan(-1);
|
||||
return afterCase.slice(0, endIndex);
|
||||
};
|
||||
|
||||
describe('tool center menu entries', () => {
|
||||
it('exposes snippet management next to shortcut management', () => {
|
||||
expect(appSource).toContain("key: 'snippet-settings'");
|
||||
expect(appSource).toContain("title: '代码片段管理'");
|
||||
expect(appSource).toContain('setIsSnippetModalOpen(true)');
|
||||
|
||||
const snippetIndex = appSource.indexOf("key: 'snippet-settings'");
|
||||
const shortcutIndex = appSource.indexOf("key: 'shortcut-settings'", snippetIndex);
|
||||
expect(snippetIndex).toBeGreaterThan(-1);
|
||||
expect(shortcutIndex).toBeGreaterThan(snippetIndex);
|
||||
});
|
||||
|
||||
it('keeps the v2 AI entry in the sidebar and the legacy AI entry on the content edge', () => {
|
||||
expect(appSource).toContain('onToggleAI={toggleAIPanel}');
|
||||
expect(appSource).toContain('renderLegacyAIEdgeHandle');
|
||||
expect(appSource).toContain('resolveLegacyAIEdgeHandleDockStyle');
|
||||
expect(appSource).toContain('data-gonavi-legacy-ai-edge-action="true"');
|
||||
expect(appSource).toContain('{!isV2Ui && !aiPanelVisible && (');
|
||||
expect(appSource).toContain('{!isV2Ui && (');
|
||||
expect(appSource).not.toContain('data-gonavi-ai-entry-action="true"');
|
||||
});
|
||||
|
||||
it('keeps sidebar utility handlers stable so v2 button clicks do not repaint the workspace', () => {
|
||||
expect(appSource).toContain('const handleOpenToolsModal = useCallback(');
|
||||
expect(appSource).toContain('const handleOpenSettingsModal = useCallback(');
|
||||
expect(appSource).toContain('const handleToggleLogPanel = useCallback(');
|
||||
expect(appSource).toContain('const handleFocusSidebarSearch = useCallback(');
|
||||
expect(appSource).toContain('const antdTheme = useMemo(() => ({');
|
||||
expect(appSource).toContain('theme={antdTheme}');
|
||||
expect(appSource).toContain('const sqlLogCount = useStore(state => state.sqlLogs.length);');
|
||||
expect(appSource).toContain('onOpenTools={handleOpenToolsModal}');
|
||||
expect(appSource).toContain('onOpenSettings={handleOpenSettingsModal}');
|
||||
expect(appSource).toContain('onToggleLogPanel={handleToggleLogPanel}');
|
||||
expect(appSource).toContain('onFocusCommandSearch={handleFocusSidebarSearch}');
|
||||
expect(appSource).toContain('sqlLogCount={sqlLogCount}');
|
||||
expect(appSource).not.toContain('onOpenTools={() => setIsToolsModalOpen(true)}');
|
||||
expect(appSource).not.toContain('onOpenSettings={() => setIsSettingsModalOpen(true)}');
|
||||
expect(appSource).not.toContain('onToggleLogPanel={() => setIsLogPanelOpen((prev) => !prev)}');
|
||||
expect(appSource).not.toContain('theme={{');
|
||||
expect(appSource).not.toContain('const sqlLogs = useStore(state => state.sqlLogs);');
|
||||
});
|
||||
|
||||
it('lets the v2 Sidebar own the entire left layout instead of stacking legacy controls above it', () => {
|
||||
const siderIndex = appSource.indexOf("className={isV2Ui ? 'gn-v2-app-sider' : undefined}");
|
||||
const legacyGuardIndex = appSource.indexOf('{!isV2Ui && (', siderIndex);
|
||||
const legacyCreateIndex = appSource.indexOf('新建连接', legacyGuardIndex);
|
||||
const sidebarIndex = appSource.indexOf('<Sidebar', legacyGuardIndex);
|
||||
const floatingLogIndex = appSource.indexOf('Floating SQL Log Toggle', sidebarIndex);
|
||||
const floatingLogGuardIndex = appSource.indexOf('{!isV2Ui && (', floatingLogIndex);
|
||||
|
||||
expect(siderIndex).toBeGreaterThan(-1);
|
||||
expect(legacyGuardIndex).toBeGreaterThan(siderIndex);
|
||||
expect(legacyCreateIndex).toBeGreaterThan(legacyGuardIndex);
|
||||
expect(legacyCreateIndex).toBeLessThan(sidebarIndex);
|
||||
expect(appSource).toContain('paddingBottom: isV2Ui ? 0 : 58');
|
||||
expect(floatingLogIndex).toBeGreaterThan(sidebarIndex);
|
||||
expect(floatingLogGuardIndex).toBeGreaterThan(floatingLogIndex);
|
||||
});
|
||||
|
||||
it('uses the v2 green accent for sidebar and log resize guide lines', () => {
|
||||
expect(appSource).toContain('const resizeGuideColor = isV2Ui');
|
||||
expect(appSource).toContain("'var(--gn-accent, #16a34a)'");
|
||||
expect(appSource).toContain("darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)'");
|
||||
});
|
||||
|
||||
it('does not start sidebar resize from right-clicking the resize handle', () => {
|
||||
expect(appSource).toContain('if (e.button !== 0)');
|
||||
expect(appSource).toContain('onContextMenu={(event) => {');
|
||||
expect(appSource).toContain('event.preventDefault();');
|
||||
expect(appSource).toContain('event.stopPropagation();');
|
||||
|
||||
const guardIndex = appSource.indexOf('if (e.button !== 0)');
|
||||
const ghostDisplayIndex = appSource.indexOf("ghostRef.current.style.display = 'block'", guardIndex);
|
||||
const dragStartIndex = appSource.indexOf('sidebarDragRef.current = {', guardIndex);
|
||||
|
||||
expect(guardIndex).toBeGreaterThan(-1);
|
||||
expect(ghostDisplayIndex).toBeGreaterThan(guardIndex);
|
||||
expect(dragStartIndex).toBeGreaterThan(guardIndex);
|
||||
});
|
||||
|
||||
it('positions sidebar resize guide from the rendered sider edge', () => {
|
||||
expect(appSource).toContain('const siderRef = React.useRef<HTMLDivElement | null>(null);');
|
||||
expect(appSource).toContain('ref={siderRef}');
|
||||
expect(appSource).toContain('const siderRect = siderRef.current?.getBoundingClientRect();');
|
||||
expect(appSource).toContain('const startGuideLeft = siderRect?.right ?? sidebarWidth;');
|
||||
expect(appSource).toContain('const startWidth = siderRect?.width ?? sidebarWidth;');
|
||||
expect(appSource).toContain('resolveSidebarResizeBounds(siderRef.current)');
|
||||
expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft}px`;');
|
||||
expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;');
|
||||
});
|
||||
|
||||
it('keeps legacy sidebar resize bounds aligned with the v2 sider CSS limits', () => {
|
||||
expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*420px\s*!important;/s);
|
||||
});
|
||||
|
||||
it('keeps connection modal warm-mounted while leaving the other heavyweight modals conditional', () => {
|
||||
expect(appSource).toContain('const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);');
|
||||
expect(appSource).toContain('{isConnectionModalMounted && (');
|
||||
expect(appSource).toContain('{isToolsModalOpen && (');
|
||||
expect(appSource).toContain('{isSettingsModalOpen && (');
|
||||
expect(appSource).toContain('{isThemeModalOpen && (');
|
||||
expect(appSource).toContain('{isShortcutModalOpen && (');
|
||||
expect(appSource).toContain('{isAISettingsOpen && (');
|
||||
expect(appSource).toContain('{isDriverModalOpen && (');
|
||||
expect(appSource).toContain('{isSyncModalOpen && (');
|
||||
});
|
||||
|
||||
it('loads editable connection details before opening the edit modal so stored secrets can be shown', () => {
|
||||
expect(appSource).toContain("typeof backendApp?.GetEditableSavedConnection === 'function'");
|
||||
expect(appSource).toContain('const editableConnection = await backendApp.GetEditableSavedConnection(conn.id);');
|
||||
expect(appSource).toContain('setEditingConnection(nextConnection);');
|
||||
expect(appSource).toContain('setIsModalOpen(true);');
|
||||
});
|
||||
|
||||
it('loads editable AI provider details before opening the edit modal so stored api keys can be shown', () => {
|
||||
expect(appSource).toContain('<AISettingsModal');
|
||||
const modalSource = readFileSync(new URL('./components/AISettingsModal.tsx', import.meta.url), 'utf8');
|
||||
expect(modalSource).toContain("typeof Service?.AIGetEditableProvider === 'function'");
|
||||
expect(modalSource).toContain('await Service.AIGetEditableProvider(p.id)');
|
||||
});
|
||||
|
||||
it('keeps edit-mode passwords masked by default instead of forcing the eye toggle open', () => {
|
||||
expect(appSource).not.toContain('setPrimaryPasswordVisible(String(config.password || "").trim() !== "")');
|
||||
});
|
||||
|
||||
it('keeps shortcut manager scrolling inside the modal body', () => {
|
||||
expect(appSource).toContain('centered');
|
||||
expect(appSource).toContain("height: 'min(760px, calc(100vh - 80px))'");
|
||||
expect(appSource).toContain("maxHeight: 'calc(100vh - 80px)'");
|
||||
expect(appSource).toContain("body: { paddingTop: 8, overflow: 'hidden', flex: 1, minHeight: 0 }");
|
||||
expect(appSource).toContain('data-gonavi-shortcut-modal-scroll="true"');
|
||||
expect(appSource).toContain("height: '100%'");
|
||||
expect(appSource).toContain("overflowY: 'auto'");
|
||||
});
|
||||
|
||||
it('renders recorded shortcuts with platform-specific display labels', () => {
|
||||
expect(appSource).toContain('getShortcutDisplayLabel');
|
||||
expect(appSource).toContain('getShortcutDisplayLabel(binding.combo, activeShortcutPlatform)');
|
||||
});
|
||||
|
||||
it('executes every global shortcut action exposed in the shortcut manager', () => {
|
||||
const expectedHandlers = new Map([
|
||||
['runQuery', 'gonavi:run-active-query'],
|
||||
['focusSidebarSearch', 'gonavi:focus-sidebar-search'],
|
||||
['newQueryTab', 'handleNewQuery();'],
|
||||
['switchToNextTab', 'switchActiveTabByOffset(1);'],
|
||||
['switchToPreviousTab', 'switchActiveTabByOffset(-1);'],
|
||||
['newConnection', 'handleCreateConnection();'],
|
||||
['toggleAIPanel', 'toggleAIPanel();'],
|
||||
['toggleLogPanel', 'handleToggleLogPanel();'],
|
||||
['toggleTheme', 'setTheme('],
|
||||
['openShortcutManager', 'setIsShortcutModalOpen(true);'],
|
||||
['toggleMacFullscreen', 'handleTitleBarWindowToggle({ allowMacNativeFullscreen: true });'],
|
||||
['resetWindowZoom', 'handleManualResetWindowZoom();'],
|
||||
]);
|
||||
|
||||
for (const [action, handler] of expectedHandlers) {
|
||||
expect(getGlobalShortcutCaseBlock(action)).toContain(handler);
|
||||
}
|
||||
expect(appSource).toContain('const switchActiveTabByOffset = useCallback((offset: 1 | -1) => {');
|
||||
expect(appSource).toContain('const nextIndex = (baseIndex + offset + tabs.length) % tabs.length;');
|
||||
expect(appSource).toContain('setActiveTab(tabs[nextIndex].id);');
|
||||
expect(appSource).toContain('handleCreateConnection, handleManualResetWindowZoom');
|
||||
expect(appSource).toContain('switchActiveTabByOffset, themeMode');
|
||||
});
|
||||
|
||||
it('automatically resets WebView2 zoom when a Windows taskbar restore returns focus', () => {
|
||||
expect(appSource).toContain('shouldResetWebViewZoomForScaleFix(reason, hasViewportScaleDrift)');
|
||||
expect(appSource).toContain('const shouldResetWebViewZoom = shouldResetWebViewZoomForScaleFix(reason, hasViewportScaleDrift);');
|
||||
expect(appSource).toContain('if (shouldResetWebViewZoom && !isMaximised)');
|
||||
expect(appSource).toContain('const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.();');
|
||||
expect(appSource).toContain('if (!shouldApplyWindowsScaleFix(reason, hasViewportScaleDrift))');
|
||||
expect(appSource).toContain('const nudgedWidth = getWindowsScaleFixNudgedWidth(width);');
|
||||
expect(appSource).toContain('WindowSetSize(nudgedWidth, height);');
|
||||
expect(appSource).toContain('该异常不一定表现为 viewport ratio drift');
|
||||
});
|
||||
|
||||
it('captures window state on startup and lifecycle events instead of waiting only for the polling interval', () => {
|
||||
expect(appSource).toContain('const scheduleWindowStateSave = (delayMs = 120) => {');
|
||||
expect(appSource).toContain('if (hydrated) {');
|
||||
expect(appSource).toContain('scheduleWindowStateSave(320);');
|
||||
expect(appSource).toContain('const unsubscribeHydration = useStore.persist.onFinishHydration(() => {');
|
||||
expect(appSource).toContain("window.addEventListener('resize', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('focus', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('pageshow', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });");
|
||||
expect(appSource).toContain("window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });");
|
||||
});
|
||||
|
||||
it('keeps titlebar double-click on maximise while shortcuts may enter macOS fullscreen', () => {
|
||||
expect(appSource).toContain('const handleTitleBarWindowToggle = async (options?: { allowMacNativeFullscreen?: boolean }) => {');
|
||||
expect(appSource).toContain('const allowMacNativeFullscreen = options?.allowMacNativeFullscreen === true;');
|
||||
expect(appSource).toContain('if (allowMacNativeFullscreen && useNativeMacWindowControls && isMacRuntime) {');
|
||||
expect(appSource).toContain('void handleTitleBarWindowToggle({ allowMacNativeFullscreen: false });');
|
||||
expect(getGlobalShortcutCaseBlock('toggleMacFullscreen')).toContain('handleTitleBarWindowToggle({ allowMacNativeFullscreen: true });');
|
||||
});
|
||||
|
||||
it('captures global shortcuts before Monaco/editor defaults consume them', () => {
|
||||
expect(appSource).toContain("window.addEventListener('keydown', handleGlobalShortcut, true);");
|
||||
expect(appSource).toContain("window.removeEventListener('keydown', handleGlobalShortcut, true);");
|
||||
});
|
||||
|
||||
it('skips the native mac titlebar bridge when the current runtime does not expose it', () => {
|
||||
expect(appSource).toContain("const backendApp = (window as any).go?.app?.App;");
|
||||
expect(appSource).toContain("if (typeof backendApp?.SetMacNativeWindowControls !== 'function') {");
|
||||
expect(appSource).toContain('void safeWindowRuntimeCall(() => SetMacNativeWindowControls(useNativeMacWindowControls), undefined);');
|
||||
});
|
||||
|
||||
it('listens for command search query-tab events and routes them through handleNewQuery', () => {
|
||||
expect(appSource).toContain("window.addEventListener('gonavi:create-query-tab', handleCreateQueryTabEvent as EventListener);");
|
||||
expect(appSource).toContain("window.removeEventListener('gonavi:create-query-tab', handleCreateQueryTabEvent as EventListener);");
|
||||
expect(appSource).toContain('const handleCreateQueryTabEvent = () => {');
|
||||
expect(appSource).toContain('handleNewQuery();');
|
||||
});
|
||||
});
|
||||
|
||||
describe('global appearance tokens', () => {
|
||||
it('publishes v2 font and scale variables for non-AntD chrome', () => {
|
||||
expect(appSource).toContain("setProperty('--gonavi-font-size'");
|
||||
expect(appSource).toContain("setProperty('--gn-ui-scale'");
|
||||
expect(appSource).toContain("setProperty('--gn-font-size'");
|
||||
expect(appSource).toContain("setProperty('--gn-font-size-sm'");
|
||||
expect(appSource).toContain("setProperty('--gn-font-size-xs'");
|
||||
expect(appSource).toContain("setProperty('--gn-font-size-mono'");
|
||||
expect(appSource).toContain("setProperty('--gn-data-table-font-size'");
|
||||
expect(appSource).toContain("setProperty('--gn-sidebar-tree-font-size'");
|
||||
expect(appSource).toContain("setProperty('--gn-control-height'");
|
||||
expect(appSource).toContain("setProperty('--gn-control-height-sm'");
|
||||
expect(appSource).toContain('fontFamily: resolvedUiFontFamily');
|
||||
expect(appSource).toContain('fontFamilyCode: resolvedMonoFontFamily');
|
||||
expect(appSource).toContain('数据表字体大小');
|
||||
expect(appSource).toContain('左侧库表字体大小');
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies)');
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies)');
|
||||
expect(appSource).toContain('ListInstalledFontFamilies()');
|
||||
expect(appSource).toContain('const [installedFontFamilies, setInstalledFontFamilies] = useState<InstalledFontFamily[]>(EMPTY_INSTALLED_FONT_FAMILIES);');
|
||||
expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';");
|
||||
expect(appSource).toContain('<LinuxCJKFontBanner');
|
||||
expect(linuxCJKFontBannerSource).toContain('data-gonavi-linux-cjk-font-banner="true"');
|
||||
expect(linuxCJKFontBannerSource).toContain('Linux CJK fonts missing / Ubuntu 中文字体缺失');
|
||||
expect(appSource).toContain('setIsLinuxCJKFontBannerDismissed(true)');
|
||||
expect(appSource).toContain('matchFontFamilyOption');
|
||||
expect(appSource).toContain('showSearch');
|
||||
expect(appSource).toContain('const dataTableFontSizeFollowsGlobal = appearance.dataTableFontSizeFollowGlobal !== false;');
|
||||
expect(appSource).toContain('const sidebarTreeFontSizeFollowsGlobal = appearance.sidebarTreeFontSizeFollowGlobal !== false;');
|
||||
expect(appSource).toContain('disabled={dataTableFontSizeFollowsGlobal}');
|
||||
expect(appSource).toContain('disabled={sidebarTreeFontSizeFollowsGlobal}');
|
||||
expect(appSource).toContain("type={dataTableFontSizeFollowsGlobal ? 'primary' : 'default'}");
|
||||
expect(appSource).toContain("type={sidebarTreeFontSizeFollowsGlobal ? 'primary' : 'default'}");
|
||||
expect(appSource).toContain('dataTableFontSizeFollowGlobal: !dataTableFontSizeFollowsGlobal');
|
||||
expect(appSource).toContain('sidebarTreeFontSizeFollowGlobal: !sidebarTreeFontSizeFollowsGlobal');
|
||||
expect(appSource).toContain('dataTableFontSize: dataTableFontSizeFollowsGlobal');
|
||||
expect(appSource).toContain('sidebarTreeFontSize: sidebarTreeFontSizeFollowsGlobal');
|
||||
});
|
||||
});
|
||||
5017
frontend/src/App.tsx
51
frontend/src/App.ui-version.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('UI version switch placement', () => {
|
||||
it('loads the v2 theme stylesheet with the app shell', () => {
|
||||
expect(appSource).toContain("import './App.css';");
|
||||
expect(appSource).toContain("import './v2-theme.css';");
|
||||
});
|
||||
|
||||
it('keeps the UI version switch in theme mode and outside macOS-only settings', () => {
|
||||
const themeBranchIndex = appSource.indexOf("{themeModalSection === 'theme' ? (");
|
||||
const uiVersionIndex = appSource.indexOf('界面版本', themeBranchIndex);
|
||||
const lightThemeIndex = appSource.indexOf('亮色主题', themeBranchIndex);
|
||||
const appearanceBranchIndex = appSource.indexOf(') : (', themeBranchIndex);
|
||||
const macWindowIndex = appSource.indexOf('macOS 窗口控制');
|
||||
|
||||
expect(themeBranchIndex).toBeGreaterThan(-1);
|
||||
expect(uiVersionIndex).toBeGreaterThan(themeBranchIndex);
|
||||
expect(uiVersionIndex).toBeLessThan(lightThemeIndex);
|
||||
expect(uiVersionIndex).toBeLessThan(appearanceBranchIndex);
|
||||
expect(macWindowIndex).toBeGreaterThan(uiVersionIndex);
|
||||
expect(appSource).toContain("badge: '默认'");
|
||||
expect(appSource).toContain("badge: 'Beta'");
|
||||
expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key as 'legacy' | 'v2' })}");
|
||||
expect(appSource).toContain('新版 UI 仍在 Beta');
|
||||
expect(appSource).toContain('Windows、macOS 与 Linux 均可切换');
|
||||
expect(appSource).toContain('新版左侧搜索模式');
|
||||
expect(appSource).toContain("value={appearance.v2SidebarSearchMode ?? 'command'}");
|
||||
expect(appSource).toContain("setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })");
|
||||
});
|
||||
|
||||
it('uses the card-style v2 switch from the redesign instead of the segmented pill', () => {
|
||||
const uiVersionIndex = appSource.indexOf('界面版本');
|
||||
const themeModeIndex = appSource.indexOf('主题模式', uiVersionIndex);
|
||||
const uiVersionBlock = appSource.slice(uiVersionIndex, themeModeIndex);
|
||||
|
||||
expect(uiVersionBlock).toContain('NEW');
|
||||
expect(uiVersionBlock).toContain("gridTemplateColumns: 'repeat(2, minmax(0, 1fr))'");
|
||||
expect(uiVersionBlock).toContain("label: '旧版 UI'");
|
||||
expect(uiVersionBlock).toContain("label: '新版 UI'");
|
||||
expect(uiVersionBlock).toContain('CheckOutlined');
|
||||
expect(uiVersionBlock).toContain('新版左侧搜索模式');
|
||||
expect(uiVersionBlock).toContain('<Segmented');
|
||||
});
|
||||
});
|
||||
547
frontend/src/components/AIChatPanel.css
Normal file
@@ -0,0 +1,547 @@
|
||||
.ai-chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid rgba(128, 128, 128, 0.12);
|
||||
position: relative;
|
||||
font-family: var(--gn-font-sans, "Inter", "PingFang SC", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", sans-serif);
|
||||
}
|
||||
|
||||
/* 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: var(--gn-font-mono, "JetBrains Mono", ui-monospace, "SF Mono", Menlo, 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: var(--gn-font-mono, "JetBrains Mono", ui-monospace, "SF Mono", Menlo, 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-color: rgba(128, 128, 128, 0.22) !important;
|
||||
padding: 6px 10px;
|
||||
transition: all 0.2s ease;
|
||||
background: rgba(128, 128, 128, 0.03) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper:focus-within {
|
||||
border-color: rgba(128, 128, 128, 0.28) !important;
|
||||
background: rgba(128, 128, 128, 0.035) !important;
|
||||
box-shadow: none !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;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-wrapper,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-wrapper:focus-within {
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-surface,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-surface:focus-within {
|
||||
border: 0.5px solid var(--gn-br-2) !important;
|
||||
border-radius: 10px !important;
|
||||
background: var(--gn-bg-input) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-box textarea,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-box textarea.ant-input:focus,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-box textarea.ant-input:focus-visible {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
outline: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.ai-chat-panel .ant-message {
|
||||
width: min(100%, 720px);
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.ai-settings-body .ant-message {
|
||||
width: fit-content;
|
||||
max-width: min(520px, calc(100% - 32px));
|
||||
}
|
||||
|
||||
.ai-settings-body .ant-message .ant-message-notice {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ai-settings-body .ant-message .ant-message-notice-content {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
112
frontend/src/components/AIChatPanel.message-boundary.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8');
|
||||
const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8');
|
||||
const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversationView.tsx', import.meta.url), 'utf8');
|
||||
const derivedStateSource = readFileSync(new URL('./ai/aiChatPanelDerivedState.ts', import.meta.url), 'utf8');
|
||||
const autoContextSource = readFileSync(new URL('./ai/useAIChatAutoContext.ts', import.meta.url), 'utf8');
|
||||
const payloadDispatchSource = readFileSync(new URL('./ai/aiChatPayloadDispatch.ts', import.meta.url), 'utf8');
|
||||
const planContextSource = readFileSync(new URL('./ai/useAIChatPlanContexts.ts', import.meta.url), 'utf8');
|
||||
const resizeSource = readFileSync(new URL('./ai/useAIChatPanelResize.ts', import.meta.url), 'utf8');
|
||||
const runtimeResourcesSource = readFileSync(new URL('./ai/useAIChatRuntimeResources.ts', import.meta.url), 'utf8');
|
||||
const sessionStateSource = readFileSync(new URL('./ai/useAIChatSessionState.ts', import.meta.url), 'utf8');
|
||||
const titleGeneratorSource = readFileSync(new URL('./ai/useAIChatSessionTitleGenerator.ts', import.meta.url), 'utf8');
|
||||
const localToolsSource = readFileSync(new URL('./ai/useAIChatLocalTools.ts', import.meta.url), 'utf8');
|
||||
const streamSubscriptionSource = readFileSync(new URL('./ai/useAIChatStreamSubscription.ts', import.meta.url), 'utf8');
|
||||
const inspectionGuidanceSource = readFileSync(new URL('./ai/aiSystemInspectionGuidance.ts', import.meta.url), 'utf8');
|
||||
const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8');
|
||||
const runtimeSource = readFileSync(new URL('../utils/aiChatRuntime.ts', import.meta.url), 'utf8');
|
||||
|
||||
describe('AIChatPanel message render isolation', () => {
|
||||
it('keeps per-message render failures scoped to the broken bubble', () => {
|
||||
expect(source).toContain("import AIChatPanelConversationView from './ai/AIChatPanelConversationView';");
|
||||
expect(boundarySource).toContain('class AIMessageRenderBoundary extends React.Component');
|
||||
expect(source).toContain('[AI Message Render Error]');
|
||||
expect(conversationViewSource).toContain("import AIMessageRenderBoundary from './AIMessageRenderBoundary';");
|
||||
expect(boundarySource).toContain('这条 AI 消息渲染失败,已自动隔离');
|
||||
expect(source).toContain('__gonaviLastAIMessageRenderError');
|
||||
expect(conversationViewSource).toContain('<AIMessageRenderBoundary');
|
||||
expect(conversationViewSource).toContain('onDeleteMessage={onDeleteMessage}');
|
||||
});
|
||||
|
||||
it('loads user prompt settings and appends them as system messages', () => {
|
||||
expect(source).toContain("import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources';");
|
||||
expect(source).toContain('useAIChatRuntimeResources({ onOpenSettings })');
|
||||
expect(runtimeResourcesSource).toContain('AIGetUserPromptSettings');
|
||||
expect(runtimeResourcesSource).toContain("window.addEventListener('gonavi:ai:config-changed'");
|
||||
expect(systemContextSource).toContain('以下是当前用户的自定义补充提示词');
|
||||
expect(systemContextSource).toContain("appendCustomPromptGroup(systemMessages, ['database']");
|
||||
});
|
||||
|
||||
it('loads MCP tools and skills into the runtime tool chain', () => {
|
||||
expect(runtimeResourcesSource).toContain('AIListMCPTools');
|
||||
expect(runtimeResourcesSource).toContain('AIGetSkills');
|
||||
expect(source).toContain("import { useAIChatLocalTools } from './ai/useAIChatLocalTools';");
|
||||
expect(localToolsSource).toContain('executeLocalAIToolCall');
|
||||
expect(systemContextSource).toContain('以下是当前启用的 Skill');
|
||||
expect(source).toContain('buildAvailableAIChatTools');
|
||||
});
|
||||
|
||||
it('teaches the runtime to use deeper schema tools when analyzing structure details', () => {
|
||||
expect(systemContextSource).toContain('get_indexes、get_foreign_keys、get_triggers、get_table_ddl');
|
||||
expect(systemContextSource).toContain('inspect_active_tab 读取当前活动页签上下文');
|
||||
expect(systemContextSource).toContain('inspect_workspace_tabs 盘点当前工作区');
|
||||
expect(inspectionGuidanceSource).toContain('inspect_current_connection');
|
||||
expect(inspectionGuidanceSource).toContain('inspect_external_sql_directories');
|
||||
expect(inspectionGuidanceSource).toContain('inspect_external_sql_file');
|
||||
expect(localToolsSource).toContain('tabs: currentState.tabs');
|
||||
expect(localToolsSource).toContain('activeTabId: currentState.activeTabId');
|
||||
expect(localToolsSource).toContain('externalSQLDirectories: currentState.externalSQLDirectories');
|
||||
expect(localToolsSource).toContain('toolContextMap: toolContextMapRef.current');
|
||||
expect(localToolsSource).toContain('buildToolResultMessage');
|
||||
});
|
||||
|
||||
it('extracts chat runtime helpers so context compression and error cleanup stay out of the panel file', () => {
|
||||
expect(source).toContain("import { dispatchAIChatPayload } from './ai/aiChatPayloadDispatch';");
|
||||
expect(source).toContain("import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';");
|
||||
expect(source).toContain('compressContextIfNeeded, getDynamicMaxContextChars');
|
||||
expect(source).toContain('useAIChatStreamSubscription({');
|
||||
expect(source).toContain('useAIChatLocalTools({');
|
||||
expect(runtimeSource).toContain('export const getDynamicMaxContextChars');
|
||||
expect(runtimeSource).toContain('export const compressContextIfNeeded');
|
||||
expect(runtimeSource).toContain('export const sanitizeErrorMsg');
|
||||
expect(payloadDispatchSource).toContain('export const dispatchAIChatPayload');
|
||||
expect(payloadDispatchSource).toContain('sanitizeErrorMsg');
|
||||
expect(localToolsSource).toContain('compressContextIfNeeded');
|
||||
expect(localToolsSource).toContain('dispatchAIChatPayload');
|
||||
expect(streamSubscriptionSource).toContain('EventsOn(eventName, handler);');
|
||||
expect(streamSubscriptionSource).toContain('请直接使用 function call 调用工具执行操作');
|
||||
expect(streamSubscriptionSource).toContain('executeLocalTools(existing.tool_calls!, doneAssistantId)');
|
||||
expect(runtimeSource).toContain('⚙️ 对话已超载,正在启动记忆压缩');
|
||||
});
|
||||
|
||||
it('keeps the v2 history mode sorted by the latest updated session first', () => {
|
||||
expect(source).toContain("import { useAIChatSessionState } from './ai/useAIChatSessionState';");
|
||||
expect(source).toContain('const panelHistorySessions = useMemo(');
|
||||
expect(sessionStateSource).toContain('right.updatedAt - left.updatedAt');
|
||||
expect(sessionStateSource).toContain("const sid = aiActiveSessionId || 'session-fallback';");
|
||||
expect(source).toContain('buildAIChatInlineHistorySessions(orderedAISessions)');
|
||||
expect(derivedStateSource).toContain('export const buildAIChatInlineHistorySessions');
|
||||
expect(derivedStateSource).toContain('sessions.slice(0, limit)');
|
||||
expect(source).toContain('sessions={panelHistorySessions}');
|
||||
});
|
||||
|
||||
it('extracts plan-context, auto-context, title, and resize hooks so the panel file stays focused on orchestration', () => {
|
||||
expect(source).toContain("import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts';");
|
||||
expect(source).toContain("import { useAIChatAutoContext } from './ai/useAIChatAutoContext';");
|
||||
expect(source).toContain("import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator';");
|
||||
expect(source).toContain("import { useAIChatPanelResize } from './ai/useAIChatPanelResize';");
|
||||
expect(source).toContain("import { useAIChatLocalTools } from './ai/useAIChatLocalTools';");
|
||||
expect(planContextSource).toContain('export const useAIChatPlanContexts');
|
||||
expect(planContextSource).toContain('pendingJVMPlanContextRef');
|
||||
expect(autoContextSource).toContain('export const useAIChatAutoContext');
|
||||
expect(autoContextSource).toContain('DBShowCreateTable');
|
||||
expect(titleGeneratorSource).toContain('export const useAIChatSessionTitleGenerator');
|
||||
expect(titleGeneratorSource).toContain('Failed to auto-generate title');
|
||||
expect(resizeSource).toContain('export const useAIChatPanelResize');
|
||||
expect(resizeSource).toContain('document.body.style.pointerEvents = \'none\'');
|
||||
expect(localToolsSource).toContain('export const useAIChatLocalTools');
|
||||
expect(localToolsSource).toContain('MAX_TOOL_CALL_ROUNDS');
|
||||
});
|
||||
});
|
||||
693
frontend/src/components/AIChatPanel.tsx
Normal file
@@ -0,0 +1,693 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useStore } from '../store';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import type {
|
||||
AIChatAttachment,
|
||||
AIChatMessage,
|
||||
JVMAIPlanContext,
|
||||
JVMDiagnosticPlanContext,
|
||||
} from '../types';
|
||||
import './AIChatPanel.css';
|
||||
|
||||
import { AIChatHeader } from './ai/AIChatHeader';
|
||||
import { AIChatInput } from './ai/AIChatInput';
|
||||
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
|
||||
import AIChatPanelConversationView from './ai/AIChatPanelConversationView';
|
||||
import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
buildIncompleteProviderNotice,
|
||||
buildMissingModelNotice,
|
||||
buildMissingProviderNotice,
|
||||
} from '../utils/aiComposerNotice';
|
||||
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
|
||||
import { toAIRequestMessage } from '../utils/aiMessagePayload';
|
||||
import { compressContextIfNeeded, getDynamicMaxContextChars } from '../utils/aiChatRuntime';
|
||||
import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts';
|
||||
import { isMacLikePlatform } from '../utils/appearance';
|
||||
import { buildAvailableAIChatTools } from '../utils/aiToolRegistry';
|
||||
import {
|
||||
buildAIChatInlineHistorySessions,
|
||||
buildAIChatInsights,
|
||||
calculateAIContextUsageChars,
|
||||
collectAIChatContextTableNames,
|
||||
inferAIChatConnectionContext,
|
||||
resolveAIChatPanelMode,
|
||||
} from './ai/aiChatPanelDerivedState';
|
||||
import { dispatchAIChatPayload } from './ai/aiChatPayloadDispatch';
|
||||
import { buildAIChatReadinessSnapshot } from './ai/aiChatReadiness';
|
||||
import { buildAISystemContextMessages } from './ai/aiSystemContextMessages';
|
||||
import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources';
|
||||
import { useAIChatAutoContext } from './ai/useAIChatAutoContext';
|
||||
import { useAIChatPanelResize } from './ai/useAIChatPanelResize';
|
||||
import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts';
|
||||
import { useAIChatSessionState } from './ai/useAIChatSessionState';
|
||||
import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator';
|
||||
import { useAIChatLocalTools } from './ai/useAIChatLocalTools';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
darkMode: boolean;
|
||||
bgColor?: string;
|
||||
onClose: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
onWidthChange?: (width: number) => void;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
}
|
||||
|
||||
const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme
|
||||
}) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [draftAttachments, setDraftAttachments] = useState<AIChatAttachment[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat');
|
||||
const {
|
||||
activeProvider,
|
||||
composerNotice,
|
||||
dynamicModels,
|
||||
fetchDynamicModels,
|
||||
handleComposerAction,
|
||||
handleModelChange,
|
||||
handleOpenSettingsFromPanel,
|
||||
loadingModels,
|
||||
mcpTools,
|
||||
setComposerNotice,
|
||||
skills,
|
||||
userPromptSettings,
|
||||
} = useAIChatRuntimeResources({ onOpenSettings });
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数
|
||||
const {
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
pendingJVMPlanContextRef,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
} = useAIChatPlanContexts();
|
||||
|
||||
const aiChatHistory = useStore(state => state.aiChatHistory);
|
||||
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const createNewAISession = useStore(state => state.createNewAISession);
|
||||
const addAIChatMessage = useStore(state => state.addAIChatMessage);
|
||||
const updateAIChatMessage = useStore(state => state.updateAIChatMessage);
|
||||
const deleteAIChatMessage = useStore(state => state.deleteAIChatMessage);
|
||||
const truncateAIChatMessages = useStore(state => state.truncateAIChatMessages);
|
||||
const updateAISessionTitle = useStore(state => state.updateAISessionTitle);
|
||||
|
||||
const activeContext = useStore(state => state.activeContext);
|
||||
const aiContexts = useStore(state => state.aiContexts);
|
||||
const connections = useStore(state => state.connections);
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const sqlLogs = useStore(state => state.sqlLogs);
|
||||
const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId);
|
||||
const aiPanelVisible = useStore(state => state.aiPanelVisible);
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
|
||||
const {
|
||||
ghostRef,
|
||||
handleResizeStart,
|
||||
isResizing,
|
||||
panelRect,
|
||||
panelRef,
|
||||
panelWidth,
|
||||
} = useAIChatPanelResize({
|
||||
width,
|
||||
isV2Ui,
|
||||
onWidthChange,
|
||||
});
|
||||
const availableTools = useMemo(
|
||||
() => buildAvailableAIChatTools(mcpTools),
|
||||
[mcpTools],
|
||||
);
|
||||
const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding(
|
||||
state.shortcutOptions,
|
||||
'sendAIChatMessage',
|
||||
activeShortcutPlatform,
|
||||
));
|
||||
const { sid, messages, orderedAISessions } = useAIChatSessionState({
|
||||
aiActiveSessionId,
|
||||
aiPanelVisible,
|
||||
createNewAISession,
|
||||
});
|
||||
|
||||
useAIChatAutoContext({
|
||||
aiPanelVisible,
|
||||
activeTabId,
|
||||
tabs,
|
||||
});
|
||||
|
||||
const getConnectionName = useCallback(() => {
|
||||
let connectionId = activeContext?.connectionId;
|
||||
if (!connectionId) {
|
||||
const activeTab = tabs.find(t => t.id === activeTabId);
|
||||
connectionId = activeTab?.connectionId;
|
||||
}
|
||||
if (!connectionId) return '';
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
return conn ? conn.name : '';
|
||||
}, [activeContext, activeTabId, connections, tabs]);
|
||||
|
||||
const activeConnName = getConnectionName();
|
||||
|
||||
const textColor = overlayTheme.titleText;
|
||||
const mutedColor = overlayTheme.mutedText;
|
||||
const borderColor = overlayTheme.divider;
|
||||
const assistantBubbleBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)';
|
||||
const quickActionBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)';
|
||||
const quickActionBorder = overlayTheme.sectionBorder;
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: sending ? 'auto' : 'smooth', block: 'end' });
|
||||
}, [messages.length, sending]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.prompt) {
|
||||
setInput(detail.prompt);
|
||||
setTimeout(() => {
|
||||
const el = textareaRef.current as any;
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
window.addEventListener('gonavi:ai:inject-prompt', handler);
|
||||
return () => window.removeEventListener('gonavi:ai:inject-prompt', handler);
|
||||
}, []);
|
||||
|
||||
const generateTitleForSession = useAIChatSessionTitleGenerator({ updateAISessionTitle });
|
||||
|
||||
const handleScrollMessages = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
|
||||
setShowScrollBottom(!isNearBottom);
|
||||
}, []);
|
||||
|
||||
const scrollToMessagesBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const handleEditMessage = useCallback((msg: AIChatMessage) => {
|
||||
truncateAIChatMessages(sid, msg.id);
|
||||
deleteAIChatMessage(sid, msg.id);
|
||||
setInput(msg.content);
|
||||
setDraftAttachments(msg.attachments || []);
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}, [sid, truncateAIChatMessages, deleteAIChatMessage]);
|
||||
|
||||
const buildSystemContextMessages = useCallback((
|
||||
overrideJVMPlanContext?: JVMAIPlanContext,
|
||||
overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext,
|
||||
) => {
|
||||
const { activeContext, aiContexts, connections, tabs, activeTabId } = useStore.getState();
|
||||
return buildAISystemContextMessages({
|
||||
activeContext,
|
||||
aiContexts,
|
||||
connections,
|
||||
tabs,
|
||||
activeTabId,
|
||||
availableToolNames: availableTools.map((tool) => tool.function.name),
|
||||
skills,
|
||||
userPromptSettings,
|
||||
overrideJVMPlanContext,
|
||||
overrideJVMDiagnosticPlanContext,
|
||||
});
|
||||
}, [availableTools, skills, userPromptSettings]);
|
||||
|
||||
const {
|
||||
executeLocalTools,
|
||||
resetToolCallState,
|
||||
toolContextMapRef,
|
||||
} = useAIChatLocalTools({
|
||||
sid,
|
||||
activeProviderModel: activeProvider?.model,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
dynamicModels,
|
||||
mcpTools,
|
||||
nextMessageId: genId,
|
||||
pendingJVMPlanContextRef,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
setSending,
|
||||
skills,
|
||||
updateAIChatMessage,
|
||||
userPromptSettings,
|
||||
});
|
||||
|
||||
const handleRetryMessage = useCallback(async (msg: AIChatMessage) => {
|
||||
const historyLocal = useStore.getState().aiChatHistory[sid] || [];
|
||||
const aiIndex = historyLocal.findIndex(m => m.id === msg.id);
|
||||
if (aiIndex <= 0) return;
|
||||
|
||||
let lastUserMsgIndex = -1;
|
||||
for (let i = aiIndex - 1; i >= 0; i--) {
|
||||
if (historyLocal[i].role === 'user') {
|
||||
lastUserMsgIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserMsgIndex >= 0) {
|
||||
const userMsg = historyLocal[lastUserMsgIndex];
|
||||
truncateAIChatMessages(sid, userMsg.id);
|
||||
|
||||
resetToolCallState();
|
||||
nudgeCountRef.current = 0;
|
||||
const retryJVMPlanContext = msg.jvmPlanContext || getCurrentJVMPlanContext();
|
||||
const retryJVMDiagnosticPlanContext =
|
||||
msg.jvmDiagnosticPlanContext || getCurrentJVMDiagnosticPlanContext();
|
||||
pendingJVMPlanContextRef.current = retryJVMPlanContext;
|
||||
pendingJVMDiagnosticPlanContextRef.current = retryJVMDiagnosticPlanContext;
|
||||
|
||||
setSending(true);
|
||||
|
||||
// 插入 connecting 过渡消息(波纹动画),与 handleSend 保持一致
|
||||
const connectingMsg: AIChatMessage = {
|
||||
id: genId(), role: 'assistant', phase: 'connecting', content: '',
|
||||
timestamp: Date.now(), loading: true,
|
||||
jvmPlanContext: retryJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
|
||||
};
|
||||
addAIChatMessage(sid, connectingMsg);
|
||||
|
||||
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
|
||||
const messagesPayload = truncatedHistory.map(toAIRequestMessage);
|
||||
|
||||
try {
|
||||
const sysMessages = await buildSystemContextMessages(
|
||||
retryJVMPlanContext,
|
||||
retryJVMDiagnosticPlanContext,
|
||||
);
|
||||
const allMessages = [...sysMessages, ...messagesPayload];
|
||||
await dispatchAIChatPayload({
|
||||
sid,
|
||||
messages: allMessages,
|
||||
tools: availableTools,
|
||||
addAIChatMessage,
|
||||
updateAIChatMessage,
|
||||
setSending,
|
||||
nextMessageId: genId,
|
||||
pendingAssistantMessageId: connectingMsg.id,
|
||||
jvmPlanContext: retryJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
|
||||
});
|
||||
} catch {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
sid,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
truncateAIChatMessages,
|
||||
addAIChatMessage,
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
resetToolCallState,
|
||||
updateAIChatMessage,
|
||||
]);
|
||||
|
||||
useAIChatStreamSubscription({
|
||||
sid,
|
||||
sending,
|
||||
setSending,
|
||||
availableTools,
|
||||
addAIChatMessage,
|
||||
updateAIChatMessage,
|
||||
buildSystemContextMessages,
|
||||
executeLocalTools,
|
||||
generateTitleForSession,
|
||||
nextMessageId: genId,
|
||||
nudgeCountRef,
|
||||
pendingJVMPlanContextRef,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
});
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if ((!text && draftAttachments.length === 0) || sending) return;
|
||||
|
||||
const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
|
||||
const readiness = buildAIChatReadinessSnapshot({
|
||||
activeProvider,
|
||||
dynamicModels,
|
||||
loadingModels,
|
||||
activeContext,
|
||||
activeContextItems: aiContexts[connectionKey] || [],
|
||||
});
|
||||
|
||||
// 前置校验:必须配置供应商、补全基础参数并选择模型后才能发送
|
||||
if (readiness.status === 'missing_provider') {
|
||||
setComposerNotice(buildMissingProviderNotice());
|
||||
return;
|
||||
}
|
||||
if (readiness.status === 'provider_incomplete') {
|
||||
setComposerNotice(buildIncompleteProviderNotice(readiness.issues));
|
||||
return;
|
||||
}
|
||||
if (readiness.status === 'missing_model' || readiness.status === 'loading_models') {
|
||||
setComposerNotice(buildMissingModelNotice());
|
||||
return;
|
||||
}
|
||||
setComposerNotice(null);
|
||||
|
||||
resetToolCallState();
|
||||
nudgeCountRef.current = 0; // 重置催促计数
|
||||
const currentJVMPlanContext = getCurrentJVMPlanContext();
|
||||
const currentJVMDiagnosticPlanContext = getCurrentJVMDiagnosticPlanContext();
|
||||
pendingJVMPlanContextRef.current = currentJVMPlanContext;
|
||||
pendingJVMDiagnosticPlanContextRef.current = currentJVMDiagnosticPlanContext;
|
||||
|
||||
const currentAttachments = [...draftAttachments];
|
||||
const currentImages = currentAttachments
|
||||
.filter((attachment) => attachment.kind === 'image' && attachment.dataUrl)
|
||||
.map((attachment) => attachment.dataUrl as string);
|
||||
const currentFileAttachments = currentAttachments.filter((attachment) => attachment.kind !== 'image');
|
||||
setInput('');
|
||||
setDraftAttachments([]);
|
||||
setSending(true);
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
|
||||
const userMsg: AIChatMessage = {
|
||||
id: genId(), role: 'user', content: text, timestamp: Date.now(),
|
||||
images: currentImages.length > 0 ? currentImages : undefined,
|
||||
attachments: currentFileAttachments.length > 0 ? currentFileAttachments : undefined,
|
||||
};
|
||||
addAIChatMessage(sid, userMsg);
|
||||
|
||||
const connectingMsg: AIChatMessage = {
|
||||
id: genId(), role: 'assistant', phase: 'connecting', content: '',
|
||||
timestamp: Date.now(), loading: true,
|
||||
jvmPlanContext: currentJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
|
||||
};
|
||||
addAIChatMessage(sid, connectingMsg);
|
||||
|
||||
const systemMessages = await buildSystemContextMessages(
|
||||
currentJVMPlanContext,
|
||||
currentJVMDiagnosticPlanContext,
|
||||
);
|
||||
|
||||
// 【过渡状态 2】上下文已组装完成,即将接入模型
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: '模型接入中' });
|
||||
|
||||
const chatMessages = [...messages, userMsg].map(toAIRequestMessage);
|
||||
|
||||
let finalMessagesPayload = chatMessages;
|
||||
const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model);
|
||||
const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit);
|
||||
if (summary) {
|
||||
// 清理原有历史,保留系统生成的总结记录和当前的 userMsg 以及 connectingMsg
|
||||
const compressedMsg: AIChatMessage = {
|
||||
id: genId(), role: 'assistant', content: `【自动记忆重塑】已将超长历史压缩为摘要:\n\n${summary}`, timestamp: Date.now() - 1000
|
||||
};
|
||||
useStore.getState().replaceAIChatHistory(sid, [compressedMsg, userMsg, connectingMsg]);
|
||||
finalMessagesPayload = [
|
||||
{ role: 'assistant', content: compressedMsg.content },
|
||||
toAIRequestMessage(userMsg),
|
||||
];
|
||||
}
|
||||
|
||||
const allMessages = [...systemMessages, ...finalMessagesPayload];
|
||||
|
||||
// 【过渡状态 3】大脑唤醒
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: '唤醒推理引擎中' });
|
||||
|
||||
// 【过渡状态 4】最后一步,等待第一字节返回
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: '等待模型响应' });
|
||||
|
||||
await dispatchAIChatPayload({
|
||||
sid,
|
||||
messages: allMessages,
|
||||
tools: availableTools,
|
||||
addAIChatMessage,
|
||||
updateAIChatMessage,
|
||||
setSending,
|
||||
nextMessageId: genId,
|
||||
pendingAssistantMessageId: connectingMsg.id,
|
||||
jvmPlanContext: currentJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
|
||||
unavailableContent: '❌ AI Service 未就绪',
|
||||
onNonStreamSuccess: messages.length === 0
|
||||
? () => generateTitleForSession(sid)
|
||||
: undefined,
|
||||
});
|
||||
}, [
|
||||
input,
|
||||
draftAttachments,
|
||||
sending,
|
||||
messages,
|
||||
addAIChatMessage,
|
||||
sid,
|
||||
activeContext,
|
||||
activeProvider,
|
||||
aiContexts,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
dynamicModels,
|
||||
generateTitleForSession,
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
loadingModels,
|
||||
updateAIChatMessage,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, e, handleSend);
|
||||
}, [aiChatSendShortcutBinding, handleSend]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatCancel) {
|
||||
await Service.AIChatCancel(sid);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to stop chat stream', e);
|
||||
}
|
||||
setSending(false);
|
||||
}, [sid]);
|
||||
|
||||
const { inferredConnectionId, inferredDbName } = useMemo(
|
||||
() => inferAIChatConnectionContext({
|
||||
activeConnectionId: activeContext?.connectionId,
|
||||
activeDbName: activeContext?.dbName,
|
||||
messages,
|
||||
toolContextEntries: toolContextMapRef.current.values(),
|
||||
}),
|
||||
[activeContext?.connectionId, activeContext?.dbName, messages],
|
||||
);
|
||||
|
||||
// useMemo 缓存:避免内联闭包击穿子组件 memo
|
||||
const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]);
|
||||
const handleMessageRenderError = useCallback((error: Error, errorInfo: React.ErrorInfo, msg: AIChatMessage) => {
|
||||
console.error('[AI Message Render Error]', msg.id, error, errorInfo);
|
||||
const renderErrorPayload = {
|
||||
messageId: msg.id,
|
||||
role: msg.role,
|
||||
contentPreview: String(msg.content || '').slice(0, 240),
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
recordedAt: Date.now(),
|
||||
};
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__gonaviLastAIMessageRenderError = renderErrorPayload;
|
||||
}
|
||||
(globalThis as any).__gonaviLastAIMessageRenderError = renderErrorPayload;
|
||||
}, []);
|
||||
const currentSessionTitle = useMemo(
|
||||
() => orderedAISessions.find((session) => session.id === sid)?.title || '新对话',
|
||||
[orderedAISessions, sid],
|
||||
);
|
||||
const activeConnectionConfig = useMemo(() => {
|
||||
if (!inferredConnectionId) return undefined;
|
||||
const connection = connections.find(c => c.id === inferredConnectionId);
|
||||
return connection ? buildRpcConnectionConfig(connection.config) : undefined;
|
||||
}, [inferredConnectionId, connections]);
|
||||
const contextUsageChars = useMemo(
|
||||
() => calculateAIContextUsageChars(messages),
|
||||
[messages],
|
||||
);
|
||||
const contextTableNames = useMemo(
|
||||
() => collectAIChatContextTableNames({
|
||||
aiContexts,
|
||||
activeConnectionId: activeContext?.connectionId,
|
||||
activeDbName: activeContext?.dbName,
|
||||
}),
|
||||
[activeContext?.connectionId, activeContext?.dbName, aiContexts],
|
||||
);
|
||||
const aiInsights = useMemo(
|
||||
() => buildAIChatInsights({
|
||||
contextTableNames,
|
||||
sqlLogs,
|
||||
}),
|
||||
[contextTableNames, sqlLogs],
|
||||
);
|
||||
const panelHistorySessions = useMemo(
|
||||
() => buildAIChatInlineHistorySessions(orderedAISessions),
|
||||
[orderedAISessions],
|
||||
);
|
||||
const effectivePanelMode = useMemo(
|
||||
() => resolveAIChatPanelMode(isV2Ui, activePanelMode),
|
||||
[activePanelMode, isV2Ui],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className={`ai-chat-panel${isV2Ui ? ' gn-v2-ai-panel' : ''}`} style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
|
||||
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
|
||||
|
||||
{isResizing && panelRect.current && createPortal(
|
||||
<div
|
||||
ref={ghostRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: panelRect.current.top,
|
||||
bottom: panelRect.current.bottom,
|
||||
left: panelRect.current.left,
|
||||
width: '2px',
|
||||
background: darkMode ? '#ffd666' : '#1677ff',
|
||||
zIndex: 99999,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<AIChatHeader
|
||||
darkMode={darkMode}
|
||||
mutedColor={mutedColor}
|
||||
textColor={textColor}
|
||||
overlayTheme={overlayTheme}
|
||||
isV2Ui={isV2Ui}
|
||||
onHistoryClick={() => {
|
||||
if (isV2Ui) {
|
||||
setActivePanelMode('history');
|
||||
} else {
|
||||
setHistoryOpen(true);
|
||||
}
|
||||
}}
|
||||
onClear={() => {
|
||||
createNewAISession();
|
||||
setActivePanelMode('chat');
|
||||
}}
|
||||
onSettingsClick={handleOpenSettingsFromPanel}
|
||||
onClose={onClose}
|
||||
sessionTitle={currentSessionTitle}
|
||||
activeMode={effectivePanelMode}
|
||||
onModeChange={(mode) => {
|
||||
if (!isV2Ui) return;
|
||||
setActivePanelMode(mode);
|
||||
if (mode === 'history') {
|
||||
setHistoryOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<AIChatPanelConversationView
|
||||
mode={effectivePanelMode}
|
||||
messages={messages}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
textColor={textColor}
|
||||
mutedColor={mutedColor}
|
||||
quickActionBg={quickActionBg}
|
||||
quickActionBorder={quickActionBorder}
|
||||
showScrollBottom={showScrollBottom}
|
||||
contextTableNames={contextTableNames}
|
||||
isV2Ui={isV2Ui}
|
||||
insights={aiInsights}
|
||||
sessions={panelHistorySessions}
|
||||
activeSessionId={sid}
|
||||
activeConnectionId={inferredConnectionId}
|
||||
activeConnectionConfig={activeConnectionConfig}
|
||||
activeDbName={inferredDbName}
|
||||
messagesEndRef={messagesEndRef}
|
||||
onScrollMessages={handleScrollMessages}
|
||||
onQuickAction={(prompt: string, autoSend?: boolean) => {
|
||||
setInput(prompt);
|
||||
if (autoSend) {
|
||||
window.setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
onSelectSession={(sessionId) => {
|
||||
setAIActiveSessionId(sessionId);
|
||||
setActivePanelMode('chat');
|
||||
}}
|
||||
onEditMessage={handleEditMessage}
|
||||
onRetryMessage={handleRetryMessage}
|
||||
onDeleteMessage={handleDeleteMessage}
|
||||
onMessageRenderError={handleMessageRenderError}
|
||||
onScrollBottom={scrollToMessagesBottom}
|
||||
/>
|
||||
|
||||
<AIChatInput
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
draftAttachments={draftAttachments}
|
||||
setDraftAttachments={setDraftAttachments}
|
||||
sending={sending}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
handleKeyDown={handleKeyDown}
|
||||
activeConnName={activeConnName}
|
||||
activeContext={activeContext}
|
||||
activeProvider={activeProvider}
|
||||
dynamicModels={dynamicModels}
|
||||
loadingModels={loadingModels}
|
||||
sendShortcutBinding={aiChatSendShortcutBinding}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
composerNotice={composerNotice}
|
||||
onComposerAction={handleComposerAction}
|
||||
onModelChange={handleModelChange}
|
||||
onFetchModels={fetchDynamicModels}
|
||||
textareaRef={textareaRef}
|
||||
darkMode={darkMode}
|
||||
textColor={textColor}
|
||||
mutedColor={mutedColor}
|
||||
overlayTheme={overlayTheme}
|
||||
contextUsageChars={contextUsageChars}
|
||||
maxContextChars={getDynamicMaxContextChars(activeProvider?.model)}
|
||||
isV2Ui={isV2Ui}
|
||||
/>
|
||||
|
||||
<AIHistoryDrawer
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
bgColor={bgColor}
|
||||
darkMode={darkMode}
|
||||
textColor={textColor}
|
||||
mutedColor={mutedColor}
|
||||
borderColor={borderColor}
|
||||
onCreateNew={createNewAISession}
|
||||
sessionId={sid}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIChatPanel;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const source = readFileSync(new URL('./AISettingsModal.tsx', import.meta.url), 'utf8');
|
||||
const aiChatPanelCss = readFileSync(new URL('./AIChatPanel.css', import.meta.url), 'utf8');
|
||||
const providersSectionSource = readFileSync(new URL('./ai/AISettingsProvidersSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('AISettingsModal edit password behavior', () => {
|
||||
it('loads editable provider details before opening the edit modal', () => {
|
||||
expect(source).toContain("typeof Service?.AIGetEditableProvider === 'function'");
|
||||
expect(source).toContain('await Service.AIGetEditableProvider(p.id)');
|
||||
});
|
||||
|
||||
it('loads and saves user-level custom prompts through the AI service', () => {
|
||||
expect(source).toContain("callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS)");
|
||||
expect(source).toContain('await Service?.AISaveUserPromptSettings?.(payload);');
|
||||
expect(source).toContain("window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'))");
|
||||
expect(source).toContain("import AISettingsPromptsSection from './ai/AISettingsPromptsSection';");
|
||||
expect(source).toContain('<AISettingsPromptsSection');
|
||||
});
|
||||
|
||||
it('loads MCP servers and skills through the AI service', () => {
|
||||
expect(source).toContain('Service.AIGetMCPClientInstallStatuses?.()');
|
||||
expect(source).toContain('Service.AIGetMCPServers?.()');
|
||||
expect(source).toContain('Service.AIListMCPTools?.()');
|
||||
expect(source).toContain('Service.AIGetSkills?.()');
|
||||
expect(source).toContain("import AISettingsSkillsSection from './ai/AISettingsSkillsSection';");
|
||||
expect(source).toContain('<AISettingsSkillsSection');
|
||||
});
|
||||
|
||||
it('delegates bulky MCP and built-in tool sections to dedicated ai components', () => {
|
||||
expect(source).toContain("import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';");
|
||||
expect(source).toContain("import AISettingsProvidersSection from './ai/AISettingsProvidersSection';");
|
||||
expect(source).toContain("import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar';");
|
||||
expect(source).toContain("import AISettingsSafetySection from './ai/AISettingsSafetySection';");
|
||||
expect(source).toContain("import AISettingsContextSection from './ai/AISettingsContextSection';");
|
||||
expect(source).toContain('<AISettingsProvidersSection');
|
||||
expect(source).toContain("import AISettingsMCPSection from './ai/AISettingsMCPSection';");
|
||||
expect(source).toContain('<AISettingsSidebar');
|
||||
expect(source).toContain('<AISettingsSafetySection');
|
||||
expect(source).toContain('<AISettingsContextSection');
|
||||
expect(source).toContain('<AISettingsMCPSection');
|
||||
expect(source).toContain('<AIBuiltinToolsCatalog');
|
||||
});
|
||||
|
||||
it('wires the external MCP client install panel actions back to the modal handlers', () => {
|
||||
expect(source).toContain('mcpClientStatuses={mcpClientStatuses}');
|
||||
expect(source).toContain('selectedMCPClient={selectedMCPClient}');
|
||||
expect(source).toContain("import { useAIMCPClientInstaller } from './ai/useAIMCPClientInstaller';");
|
||||
expect(source).toContain('} = useAIMCPClientInstaller({');
|
||||
expect(source).toContain('handleSelectMCPClient,');
|
||||
expect(source).toContain('loadMCPClientStatuses,');
|
||||
expect(source).toContain('selectedMCPClientStatus,');
|
||||
expect(source).toContain('onSelectClient={handleSelectMCPClient}');
|
||||
expect(source).toContain('onRefreshStatus={() => void loadMCPClientStatuses()}');
|
||||
expect(source).toContain('onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}');
|
||||
expect(source).toContain('onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}');
|
||||
expect(source).toContain('onInstallSelectedClient={handleInstallSelectedMCPClient}');
|
||||
});
|
||||
|
||||
it('waits briefly for the AI service bridge before warning and removes noisy provider debug logs', () => {
|
||||
expect(source).toContain('const resolveAIService = useCallback(async () => {');
|
||||
expect(source).toContain('const service = await waitForAIService();');
|
||||
expect(source).not.toContain("console.log('[AI] AIGetProviders result:'");
|
||||
expect(source).not.toContain("console.log('[AI] AIGetActiveProvider result:'");
|
||||
});
|
||||
|
||||
it('keeps the prefilled api key masked by default', () => {
|
||||
expect(source).toContain('const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);');
|
||||
expect(providersSectionSource).toContain('visible: primaryPasswordVisible,');
|
||||
});
|
||||
|
||||
it('does not render the clear helper block anymore', () => {
|
||||
expect(source).not.toContain('当前已保存 API Key。留空表示继续沿用,输入新值表示替换。');
|
||||
expect(source).not.toContain('清除已保存 API Key');
|
||||
expect(source).not.toContain('留空表示继续沿用已保存密钥');
|
||||
});
|
||||
|
||||
it('renders in-modal test errors through the local message host', () => {
|
||||
expect(source).toContain('antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body })');
|
||||
expect(source).toContain("void messageApi.error(`测试失败: ${res?.message || '未知错误'}`);");
|
||||
});
|
||||
|
||||
it('keeps long ai settings toast errors wrapped within the modal body', () => {
|
||||
expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message {');
|
||||
expect(aiChatPanelCss).toContain('width: fit-content;');
|
||||
expect(aiChatPanelCss).toContain('max-width: min(520px, calc(100% - 32px));');
|
||||
expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message .ant-message-notice-content {');
|
||||
expect(aiChatPanelCss).toContain('max-width: 100%;');
|
||||
expect(aiChatPanelCss).toContain('white-space: normal;');
|
||||
expect(aiChatPanelCss).toContain('overflow-wrap: anywhere;');
|
||||
});
|
||||
});
|
||||
841
frontend/src/components/AISettingsModal.tsx
Normal file
@@ -0,0 +1,841 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Modal, Form, message as antdMessage } from 'antd';
|
||||
import { RobotOutlined } from '@ant-design/icons';
|
||||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AISkillConfig } from '../types';
|
||||
import {
|
||||
resolvePresetBaseURL,
|
||||
resolvePresetModelSelection,
|
||||
resolvePresetTransport,
|
||||
} from '../utils/aiProviderPresets';
|
||||
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
|
||||
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
|
||||
import { EMPTY_MCP_CLIENT_STATUSES } from '../utils/mcpClientInstallStatus';
|
||||
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
|
||||
import AISettingsMCPSection from './ai/AISettingsMCPSection';
|
||||
import type { AIMCPHTTPServerDraft } from './ai/AIMCPHTTPServerPanel';
|
||||
import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar';
|
||||
import AISettingsSafetySection from './ai/AISettingsSafetySection';
|
||||
import AISettingsContextSection from './ai/AISettingsContextSection';
|
||||
import AISettingsProvidersSection from './ai/AISettingsProvidersSection';
|
||||
import AISettingsPromptsSection from './ai/AISettingsPromptsSection';
|
||||
import AISettingsSkillsSection from './ai/AISettingsSkillsSection';
|
||||
import { useAIMCPClientInstaller } from './ai/useAIMCPClientInstaller';
|
||||
import {
|
||||
EMPTY_AI_USER_PROMPT_SETTINGS,
|
||||
EMPTY_MCP_SERVER,
|
||||
EMPTY_SKILL,
|
||||
PROVIDER_PRESETS,
|
||||
findPreset,
|
||||
matchProviderPreset,
|
||||
type ProviderPreset,
|
||||
waitForAIService,
|
||||
} from './ai/aiSettingsModalConfig';
|
||||
interface AISettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
focusProviderId?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
|
||||
running: false,
|
||||
addr: '127.0.0.1:8765',
|
||||
path: '/mcp',
|
||||
url: 'http://127.0.0.1:8765/mcp',
|
||||
schemaOnly: true,
|
||||
message: 'GoNavi MCP HTTP 服务未启动',
|
||||
};
|
||||
|
||||
const DEFAULT_MCP_HTTP_SERVER_DRAFT: AIMCPHTTPServerDraft = {
|
||||
addr: DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
|
||||
path: DEFAULT_MCP_HTTP_SERVER_STATUS.path,
|
||||
authorizationHeader: '',
|
||||
};
|
||||
|
||||
const buildMCPHTTPServerDraftFromStatus = (
|
||||
status: AIMCPHTTPServerStatus,
|
||||
fallback: AIMCPHTTPServerDraft = DEFAULT_MCP_HTTP_SERVER_DRAFT,
|
||||
): AIMCPHTTPServerDraft => ({
|
||||
addr: String(status.addr || fallback.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr).trim(),
|
||||
path: String(status.path || fallback.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path).trim(),
|
||||
authorizationHeader: String(
|
||||
status.authorizationHeader ||
|
||||
(status.token ? `Bearer ${status.token}` : '') ||
|
||||
fallback.authorizationHeader ||
|
||||
'',
|
||||
).trim(),
|
||||
});
|
||||
|
||||
const normalizeMCPHTTPAuthorizationToken = (value: string): string => {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
const withoutHeaderName = trimmed.replace(/^Authorization\s*:\s*/i, '').trim();
|
||||
return withoutHeaderName.replace(/^Bearer\s+/i, '').trim();
|
||||
};
|
||||
|
||||
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 [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
|
||||
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
|
||||
const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState<AIMCPHTTPServerStatus>(DEFAULT_MCP_HTTP_SERVER_STATUS);
|
||||
const [mcpHTTPServerDraft, setMCPHTTPServerDraft] = useState<AIMCPHTTPServerDraft>(DEFAULT_MCP_HTTP_SERVER_DRAFT);
|
||||
const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false);
|
||||
const [skills, setSkills] = useState<AISkillConfig[]>([]);
|
||||
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 [userPromptSettings, setUserPromptSettings] = useState<AIUserPromptSettings>(EMPTY_AI_USER_PROMPT_SETTINGS);
|
||||
const [activeSection, setActiveSection] = useState<AISettingsSectionKey>('providers');
|
||||
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const modalBodyRef = useRef<HTMLDivElement>(null);
|
||||
const missingAIServiceWarnedRef = useRef(false);
|
||||
|
||||
// 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 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 skillRequiredToolOptions = useMemo(() => ([
|
||||
...BUILTIN_AI_TOOL_INFO.map((tool) => ({
|
||||
label: `${tool.name} · 内置工具`,
|
||||
value: tool.name,
|
||||
})),
|
||||
...mcpTools.map((tool) => ({
|
||||
label: `${tool.alias} · ${tool.serverName}`,
|
||||
value: tool.alias,
|
||||
})),
|
||||
]), [mcpTools]);
|
||||
|
||||
const resolveAIService = useCallback(async () => {
|
||||
const service = await waitForAIService();
|
||||
if (service) {
|
||||
missingAIServiceWarnedRef.current = false;
|
||||
return service;
|
||||
}
|
||||
if (!missingAIServiceWarnedRef.current) {
|
||||
console.warn('[AI] Service not found on window.go');
|
||||
missingAIServiceWarnedRef.current = true;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const copyTextToClipboard = useCallback(async (text: string, successMessage: string) => {
|
||||
if (typeof navigator?.clipboard?.writeText !== 'function') {
|
||||
throw new Error('当前环境不支持复制到剪贴板');
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
void messageApi.success(successMessage);
|
||||
}, [messageApi]);
|
||||
|
||||
const {
|
||||
handleCopySelectedMCPConfigPath,
|
||||
handleCopySelectedMCPLaunchCommand,
|
||||
handleInstallSelectedMCPClient,
|
||||
handleSelectMCPClient,
|
||||
loadMCPClientStatuses,
|
||||
mcpClientStatusLoading,
|
||||
mcpClientStatuses,
|
||||
resetMCPClientSelectionTouched,
|
||||
selectedMCPClient,
|
||||
selectedMCPClientCommandText,
|
||||
selectedMCPClientStatus,
|
||||
syncMCPClientStatuses,
|
||||
} = useAIMCPClientInstaller({
|
||||
resolveAIService,
|
||||
messageApi,
|
||||
copyTextToClipboard,
|
||||
onBeforeInstall: () => setLoading(true),
|
||||
onAfterInstall: () => setLoading(false),
|
||||
onConfigChanged: () => window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')),
|
||||
});
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const Service = await resolveAIService();
|
||||
if (!Service) {
|
||||
return;
|
||||
}
|
||||
const callOrFallback = async <T,>(loader: (() => Promise<T>) | undefined, fallback: T): Promise<T> => {
|
||||
if (typeof loader !== 'function') {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return await loader();
|
||||
} catch (error) {
|
||||
console.warn('[AI] settings load fallback', error);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
const [provRes, safeRes, ctxRes, promptsRes, userPromptsRes, mcpServersRes, mcpToolsRes, mcpHTTPServerStatusRes, skillsRes, mcpClientStatusesRes] = await Promise.all([
|
||||
callOrFallback(() => Service.AIGetProviders?.(), []),
|
||||
callOrFallback<AISafetyLevel>(() => Service.AIGetSafetyLevel?.(), 'readonly'),
|
||||
callOrFallback<AIContextLevel>(() => Service.AIGetContextLevel?.(), 'schema_only'),
|
||||
callOrFallback(() => Service.AIGetBuiltinPrompts?.(), {}),
|
||||
callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS),
|
||||
callOrFallback(() => Service.AIGetMCPServers?.(), []),
|
||||
callOrFallback(() => Service.AIListMCPTools?.(), []),
|
||||
callOrFallback<AIMCPHTTPServerStatus>(() => Service.AIGetMCPHTTPServerStatus?.(), DEFAULT_MCP_HTTP_SERVER_STATUS),
|
||||
callOrFallback(() => Service.AIGetSkills?.(), []),
|
||||
callOrFallback<AIMCPClientInstallStatus[]>(() => Service.AIGetMCPClientInstallStatuses?.(), EMPTY_MCP_CLIENT_STATUSES),
|
||||
]);
|
||||
if (Array.isArray(provRes)) {
|
||||
setProviders(provRes);
|
||||
const activeRes = await Service.AIGetActiveProvider?.();
|
||||
if (activeRes) setActiveProviderId(activeRes);
|
||||
}
|
||||
if (safeRes) setSafetyLevel(safeRes);
|
||||
if (ctxRes) setContextLevel(ctxRes);
|
||||
if (promptsRes) setBuiltinPrompts(promptsRes);
|
||||
if (userPromptsRes) {
|
||||
setUserPromptSettings({
|
||||
...EMPTY_AI_USER_PROMPT_SETTINGS,
|
||||
...userPromptsRes,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(mcpServersRes)) setMCPServers(mcpServersRes);
|
||||
if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes);
|
||||
if (mcpHTTPServerStatusRes) {
|
||||
const nextStatus = {
|
||||
...DEFAULT_MCP_HTTP_SERVER_STATUS,
|
||||
...mcpHTTPServerStatusRes,
|
||||
};
|
||||
setMCPHTTPServerStatus(nextStatus);
|
||||
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(nextStatus, prev));
|
||||
}
|
||||
if (Array.isArray(skillsRes)) setSkills(skillsRes);
|
||||
if (Array.isArray(mcpClientStatusesRes)) {
|
||||
syncMCPClientStatuses(mcpClientStatusesRes);
|
||||
}
|
||||
} catch (e) { console.warn('Failed to load AI config', e); }
|
||||
}, [resolveAIService, syncMCPClientStatuses]);
|
||||
|
||||
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetMCPClientSelectionTouched();
|
||||
}
|
||||
}, [open, resetMCPClientSelectionTouched]);
|
||||
|
||||
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);
|
||||
setPrimaryPasswordVisible(false);
|
||||
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 = async (p: AIProviderConfig) => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const editableProvider = typeof Service?.AIGetEditableProvider === 'function'
|
||||
? await Service.AIGetEditableProvider(p.id)
|
||||
: p;
|
||||
// 尝试根据 baseUrl 和 type 推断 preset
|
||||
const matchedPreset = matchProviderPreset(editableProvider);
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: matchedPreset.backendType,
|
||||
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||||
valuesApiFormat: editableProvider.apiFormat,
|
||||
});
|
||||
applyProviderEditorSession(buildEditProviderEditorSession({
|
||||
provider: { ...editableProvider, presetKey: matchedPreset.key } as any,
|
||||
formValues: {
|
||||
...editableProvider,
|
||||
type: resolvedTransport.type,
|
||||
models: editableProvider.models || [],
|
||||
presetKey: matchedPreset.key,
|
||||
apiFormat: resolvedTransport.apiFormat || editableProvider.apiFormat || 'openai',
|
||||
},
|
||||
}));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '读取供应商配置失败');
|
||||
}
|
||||
};
|
||||
|
||||
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({
|
||||
apiKeyInput: values.apiKey,
|
||||
});
|
||||
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 handleSaveUserPromptSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const payload = {
|
||||
global: String(userPromptSettings.global || ''),
|
||||
database: String(userPromptSettings.database || ''),
|
||||
jvm: String(userPromptSettings.jvm || ''),
|
||||
jvmDiagnostic: String(userPromptSettings.jvmDiagnostic || ''),
|
||||
};
|
||||
await Service?.AISaveUserPromptSettings?.(payload);
|
||||
setUserPromptSettings(payload);
|
||||
void messageApi.success('自定义提示词已保存');
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '保存自定义提示词失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMCPServerDraft = (id: string, patch: Partial<AIMCPServerConfig>) => {
|
||||
setMCPServers((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||||
};
|
||||
|
||||
const handleAddMCPServer = (seed?: Partial<AIMCPServerConfig>) => {
|
||||
setMCPServers((prev) => [...prev, EMPTY_MCP_SERVER(seed)]);
|
||||
};
|
||||
|
||||
const handleSaveMCPServer = async (server: AIMCPServerConfig) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
await Service?.AISaveMCPServer?.(server);
|
||||
await loadConfig();
|
||||
void messageApi.success('MCP 服务已保存');
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '保存 MCP 服务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMCPServer = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (typeof Service?.AIDeleteMCPServer === 'function' && !String(id).startsWith('mcp-draft-')) {
|
||||
await Service.AIDeleteMCPServer(id);
|
||||
await loadConfig();
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} else {
|
||||
setMCPServers((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
void messageApi.success('MCP 服务已删除');
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '删除 MCP 服务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestMCPServer = async (server: AIMCPServerConfig) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const res = await Service?.AITestMCPServer?.(server);
|
||||
if (res?.success) {
|
||||
void messageApi.success(res?.message || 'MCP 服务连接成功');
|
||||
if (typeof Service?.AIListMCPTools === 'function') {
|
||||
const nextTools = await Service.AIListMCPTools();
|
||||
if (Array.isArray(nextTools)) setMCPTools(nextTools);
|
||||
} else if (Array.isArray(res?.tools)) {
|
||||
setMCPTools(res.tools);
|
||||
}
|
||||
} else {
|
||||
void messageApi.error(res?.message || 'MCP 服务测试失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '测试 MCP 服务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMCPHTTPServer = async (checked: boolean) => {
|
||||
try {
|
||||
setMCPHTTPServerLoading(true);
|
||||
const Service = await resolveAIService();
|
||||
if (!Service) {
|
||||
throw new Error('当前运行时暂不支持 MCP HTTP 服务控制');
|
||||
}
|
||||
if (checked && typeof Service.AIStartMCPHTTPServer !== 'function') {
|
||||
throw new Error('当前版本暂不支持启动 MCP HTTP 服务');
|
||||
}
|
||||
if (!checked && typeof Service.AIStopMCPHTTPServer !== 'function') {
|
||||
throw new Error('当前版本暂不支持停止 MCP HTTP 服务');
|
||||
}
|
||||
const nextStatus = checked
|
||||
? await Service.AIStartMCPHTTPServer({
|
||||
addr: mcpHTTPServerDraft.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
|
||||
path: mcpHTTPServerDraft.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path,
|
||||
token: normalizeMCPHTTPAuthorizationToken(mcpHTTPServerDraft.authorizationHeader),
|
||||
schemaOnly: true,
|
||||
})
|
||||
: await Service.AIStopMCPHTTPServer();
|
||||
if (nextStatus) {
|
||||
const normalizedStatus = {
|
||||
...DEFAULT_MCP_HTTP_SERVER_STATUS,
|
||||
...nextStatus,
|
||||
};
|
||||
setMCPHTTPServerStatus(normalizedStatus);
|
||||
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(normalizedStatus, prev));
|
||||
}
|
||||
void messageApi.success(checked ? 'GoNavi MCP HTTP 服务已启动' : 'GoNavi MCP HTTP 服务已停止');
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '切换 GoNavi MCP HTTP 服务失败');
|
||||
} finally {
|
||||
setMCPHTTPServerLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMCPHTTPServerDraft = (patch: Partial<AIMCPHTTPServerDraft>) => {
|
||||
setMCPHTTPServerDraft((prev) => ({
|
||||
...prev,
|
||||
...patch,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCopyMCPHTTPServerURL = async () => {
|
||||
const url = String(mcpHTTPServerStatus.url || '').trim();
|
||||
if (!url) {
|
||||
void messageApi.error('当前没有可复制的 MCP HTTP URL');
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(url, 'MCP HTTP URL 已复制');
|
||||
};
|
||||
|
||||
const handleCopyMCPHTTPServerAuthorization = async () => {
|
||||
const authorizationHeader = String(mcpHTTPServerStatus.authorizationHeader || '').trim();
|
||||
if (!authorizationHeader) {
|
||||
void messageApi.error('请先启动 MCP HTTP 服务生成 Authorization Header');
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(`Authorization: ${authorizationHeader}`, 'Authorization Header 已复制');
|
||||
};
|
||||
|
||||
const updateSkillDraft = (id: string, patch: Partial<AISkillConfig>) => {
|
||||
setSkills((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||||
};
|
||||
|
||||
const handleAddSkill = () => {
|
||||
setSkills((prev) => [...prev, EMPTY_SKILL()]);
|
||||
};
|
||||
|
||||
const handleSaveSkill = async (skill: AISkillConfig) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
await Service?.AISaveSkill?.(skill);
|
||||
await loadConfig();
|
||||
void messageApi.success('Skill 已保存');
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '保存 Skill 失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSkill = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (typeof Service?.AIDeleteSkill === 'function' && !String(id).startsWith('skill-draft-')) {
|
||||
await Service.AIDeleteSkill(id);
|
||||
await loadConfig();
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} else {
|
||||
setSkills((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
void messageApi.success('Skill 已删除');
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '删除 Skill 失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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({
|
||||
apiKeyInput: values.apiKey,
|
||||
});
|
||||
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 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}
|
||||
<AISettingsSidebar
|
||||
activeSection={activeSection}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
onSelectSection={setActiveSection}
|
||||
/>
|
||||
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
|
||||
{activeSection === 'providers' && (
|
||||
<AISettingsProvidersSection
|
||||
providers={providers}
|
||||
activeProviderId={activeProviderId}
|
||||
editingProvider={editingProvider}
|
||||
isEditing={isEditing}
|
||||
form={form}
|
||||
providerPresets={PROVIDER_PRESETS}
|
||||
watchedPresetKey={watchedPresetKey}
|
||||
watchedApiFormat={watchedApiFormat}
|
||||
loading={loading}
|
||||
testStatus={testStatus}
|
||||
primaryPasswordVisible={primaryPasswordVisible}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
inputBg={inputBg}
|
||||
onPrimaryPasswordVisibleChange={setPrimaryPasswordVisible}
|
||||
resolveProviderPreset={matchProviderPreset}
|
||||
resolvePresetByKey={findPreset}
|
||||
onAddProvider={handleAddProvider}
|
||||
onEditProvider={handleEditProvider}
|
||||
onDeleteProvider={handleDeleteProvider}
|
||||
onSetActiveProvider={handleSetActive}
|
||||
onCancelEdit={resetProviderEditorSession}
|
||||
onPresetChange={handlePresetChange}
|
||||
onTestProvider={handleTestProvider}
|
||||
onSaveProvider={handleSaveProvider}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'safety' && (
|
||||
<AISettingsSafetySection
|
||||
safetyLevel={safetyLevel}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
onChange={handleSafetyChange}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'context' && (
|
||||
<AISettingsContextSection
|
||||
contextLevel={contextLevel}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
onChange={handleContextChange}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'mcp' && (
|
||||
<AISettingsMCPSection
|
||||
mcpClientStatuses={mcpClientStatuses}
|
||||
selectedMCPClient={selectedMCPClient}
|
||||
selectedMCPClientStatus={selectedMCPClientStatus}
|
||||
selectedMCPClientCommandText={selectedMCPClientCommandText}
|
||||
mcpHTTPServerStatus={mcpHTTPServerStatus}
|
||||
mcpHTTPServerDraft={mcpHTTPServerDraft}
|
||||
mcpServers={mcpServers}
|
||||
mcpTools={mcpTools}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
inputBg={inputBg}
|
||||
loading={loading}
|
||||
mcpClientStatusLoading={mcpClientStatusLoading}
|
||||
mcpHTTPServerLoading={mcpHTTPServerLoading}
|
||||
onUpdateHTTPServerDraft={handleUpdateMCPHTTPServerDraft}
|
||||
onToggleHTTPServer={handleToggleMCPHTTPServer}
|
||||
onCopyHTTPServerURL={() => void handleCopyMCPHTTPServerURL()}
|
||||
onCopyHTTPServerAuthorization={() => void handleCopyMCPHTTPServerAuthorization()}
|
||||
onSelectClient={handleSelectMCPClient}
|
||||
onRefreshStatus={() => void loadMCPClientStatuses()}
|
||||
onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}
|
||||
onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}
|
||||
onInstallSelectedClient={handleInstallSelectedMCPClient}
|
||||
onAddServer={handleAddMCPServer}
|
||||
onUpdateServerDraft={updateMCPServerDraft}
|
||||
onTestServer={handleTestMCPServer}
|
||||
onSaveServer={handleSaveMCPServer}
|
||||
onDeleteServer={handleDeleteMCPServer}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'skills' && (
|
||||
<AISettingsSkillsSection
|
||||
skills={skills}
|
||||
skillRequiredToolOptions={skillRequiredToolOptions}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
inputBg={inputBg}
|
||||
loading={loading}
|
||||
onAddSkill={handleAddSkill}
|
||||
onUpdateSkillDraft={updateSkillDraft}
|
||||
onSaveSkill={handleSaveSkill}
|
||||
onDeleteSkill={handleDeleteSkill}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'tools' && (
|
||||
<AIBuiltinToolsCatalog
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'prompts' && (
|
||||
<AISettingsPromptsSection
|
||||
builtinPrompts={builtinPrompts}
|
||||
userPromptSettings={userPromptSettings}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
inputBg={inputBg}
|
||||
darkMode={darkMode}
|
||||
loading={loading}
|
||||
onChangeUserPrompt={(key, value) => setUserPromptSettings((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))}
|
||||
onSave={handleSaveUserPromptSettings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AISettingsModal;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const connectionModalSource = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8');
|
||||
const redisSectionsSource = readFileSync(new URL('./ConnectionModalRedisSections.tsx', import.meta.url), 'utf8');
|
||||
const mongoSectionsSource = readFileSync(new URL('./ConnectionModalMongoSections.tsx', import.meta.url), 'utf8');
|
||||
const connectionTypeCatalogSource = readFileSync(new URL('../utils/connectionTypeCatalog.ts', import.meta.url), 'utf8');
|
||||
const connectionTypeCapabilitiesSource = readFileSync(new URL('../utils/connectionTypeCapabilities.ts', import.meta.url), 'utf8');
|
||||
const source = `${connectionModalSource}\n${redisSectionsSource}\n${mongoSectionsSource}\n${connectionTypeCatalogSource}\n${connectionTypeCapabilitiesSource}`;
|
||||
|
||||
describe('ConnectionModal edit password behavior', () => {
|
||||
it('keeps the prefilled primary password masked by default', () => {
|
||||
expect(source).toContain('const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);');
|
||||
expect(source).not.toContain('setPrimaryPasswordVisible(String(config.password || "").trim() !== "")');
|
||||
expect(source).toContain('visible: primaryPasswordVisible,');
|
||||
});
|
||||
|
||||
it('does not render the primary-password clear helper block anymore', () => {
|
||||
expect(source).not.toContain('description:\n "当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。"');
|
||||
expect(source).not.toContain('description:\n "当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。"');
|
||||
expect(source).toContain('String(config.password || "") === ""');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionModal data source registry', () => {
|
||||
it('exposes Elasticsearch in the create-connection picker with HTTP defaults', () => {
|
||||
expect(source).toContain("case 'elasticsearch':");
|
||||
expect(source).toContain('return 9200;');
|
||||
expect(source).toContain('elasticsearch: ["http", "https"]');
|
||||
expect(source).toContain("key: 'elasticsearch'");
|
||||
expect(source).toContain("name: 'Elasticsearch'");
|
||||
expect(source).toContain('icon: getDbIcon(item.key, undefined, 36)');
|
||||
expect(source).toContain('type === "elasticsearch"');
|
||||
expect(source).toContain("return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';");
|
||||
expect(source).toContain(
|
||||
'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch") ? "" : "root";',
|
||||
);
|
||||
expect(source).toContain(
|
||||
'placeholder={dbType === "elasticsearch" ? "未开启认证可留空" : undefined}',
|
||||
);
|
||||
expect(source).toContain('label="显示数据库 (留空显示全部)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionModal Redis Sentinel configuration', () => {
|
||||
it('exposes Sentinel topology fields and safe defaults', () => {
|
||||
expect(source).toContain('label: "哨兵模式"');
|
||||
expect(source).toContain('name="redisSentinelMaster"');
|
||||
expect(source).toContain('Sentinel master 名称');
|
||||
expect(source).toContain('name="redisSentinelPassword"');
|
||||
expect(source).toContain('hasRedisSentinelPassword');
|
||||
expect(source).toContain('clearKey: "redisSentinelPassword"');
|
||||
expect(source).toContain('form.setFieldValue("port", 26379)');
|
||||
expect(source).toContain('form.setFieldValue("port", 6379)');
|
||||
});
|
||||
|
||||
it('keeps the saved host as the primary Redis node when editing multi-node configs', () => {
|
||||
expect(source).toContain('const savedPrimaryAddress = isFileDbConfigType');
|
||||
expect(source).toContain('savedPrimaryAddress,');
|
||||
expect(source).toContain('...(Array.isArray(config.hosts) ? config.hosts : [])');
|
||||
expect(source).toContain('const redisHosts =');
|
||||
expect(source).toContain('configType === "redis" ? normalizedHosts.slice(1) : [];');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionModal MongoDB configuration', () => {
|
||||
it('keeps replica, SRV, and read preference fields in the split Mongo sections', () => {
|
||||
expect(source).toContain('ConnectionModalMongoSections');
|
||||
expect(source).toContain('name="mongoSrv"');
|
||||
expect(source).toContain('SRV 与 SSH 隧道同时启用');
|
||||
expect(source).toContain('name="mongoReplicaPassword"');
|
||||
expect(source).toContain('clearKey: "mongoReplicaPassword"');
|
||||
expect(source).toContain('自动发现成员');
|
||||
expect(source).toContain('fieldName: "mongoReadPreference"');
|
||||
});
|
||||
});
|
||||
364
frontend/src/components/ConnectionModalMongoSections.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
ApiOutlined,
|
||||
ClusterOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import type { MongoMemberInfo, SavedConnection } from "../types";
|
||||
import {
|
||||
getStoredSecretPlaceholder,
|
||||
type ConnectionConfigSectionKey,
|
||||
} from "../utils/connectionModalPresentation";
|
||||
import { noAutoCapInputProps } from "../utils/inputAutoCap";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ChoiceCardOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type RenderChoiceCards = (params: {
|
||||
fieldName: string;
|
||||
value: string;
|
||||
options: ChoiceCardOption[];
|
||||
minWidth?: number;
|
||||
onSelect?: (value: string) => void;
|
||||
}) => React.ReactNode;
|
||||
|
||||
type RenderConfigSectionCard = (params: {
|
||||
sectionKey: ConnectionConfigSectionKey;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
}) => React.ReactNode;
|
||||
|
||||
type RenderStoredSecretControls = (params: {
|
||||
fieldName: string;
|
||||
clearKey: "mongoReplicaPassword";
|
||||
hasStoredSecret?: boolean;
|
||||
clearLabel: string;
|
||||
description: string;
|
||||
}) => React.ReactNode;
|
||||
|
||||
interface ConnectionModalMongoSectionsProps {
|
||||
mongoTopology: string;
|
||||
mongoSrv: boolean;
|
||||
useSSH: boolean;
|
||||
darkMode: boolean;
|
||||
modalMutedTextStyle: React.CSSProperties;
|
||||
mongoReadPreference: string;
|
||||
mongoMembers: MongoMemberInfo[];
|
||||
discoveringMembers: boolean;
|
||||
initialValues?: SavedConnection | null;
|
||||
renderChoiceCards: RenderChoiceCards;
|
||||
renderConfigSectionCard: RenderConfigSectionCard;
|
||||
renderStoredSecretControls: RenderStoredSecretControls;
|
||||
setChoiceFieldValue: (fieldName: string, value: string | boolean) => void;
|
||||
handleDiscoverMongoMembers: () => void;
|
||||
}
|
||||
|
||||
const ConnectionModalMongoSections: React.FC<ConnectionModalMongoSectionsProps> = ({
|
||||
mongoTopology,
|
||||
mongoSrv,
|
||||
useSSH,
|
||||
darkMode,
|
||||
modalMutedTextStyle,
|
||||
mongoReadPreference,
|
||||
mongoMembers,
|
||||
discoveringMembers,
|
||||
initialValues,
|
||||
renderChoiceCards,
|
||||
renderConfigSectionCard,
|
||||
renderStoredSecretControls,
|
||||
setChoiceFieldValue,
|
||||
handleDiscoverMongoMembers,
|
||||
}) => (
|
||||
<>
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "connectionMode",
|
||||
icon: <ClusterOutlined />,
|
||||
children: renderChoiceCards({
|
||||
fieldName: "mongoTopology",
|
||||
value: String(mongoTopology),
|
||||
options: [
|
||||
{
|
||||
value: "single",
|
||||
label: "单机模式",
|
||||
description: "只连接一个 MongoDB 节点。",
|
||||
},
|
||||
{
|
||||
value: "replica",
|
||||
label: "副本集 / 多节点",
|
||||
description: "配置副本集名称和多个候选节点。",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "mongoDiscovery",
|
||||
icon: <ApiOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="mongoSrv" hidden valuePropName="checked">
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: false,
|
||||
label: "标准地址",
|
||||
description: "使用 host:port 直连或副本集节点列表。",
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
label: "SRV 地址",
|
||||
description: "使用 mongodb+srv,由 DNS 发现目标节点。",
|
||||
},
|
||||
].map((option) => {
|
||||
const active = mongoSrv === option.value;
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setChoiceFieldValue("mongoSrv", option.value)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 14,
|
||||
border: active
|
||||
? darkMode
|
||||
? "1px solid rgba(255,214,102,0.42)"
|
||||
: "1px solid rgba(22,119,255,0.36)"
|
||||
: darkMode
|
||||
? "1px solid rgba(255,255,255,0.08)"
|
||||
: "1px solid rgba(16,24,40,0.08)",
|
||||
background: active
|
||||
? darkMode
|
||||
? "rgba(255,214,102,0.10)"
|
||||
: "rgba(22,119,255,0.07)"
|
||||
: darkMode
|
||||
? "rgba(255,255,255,0.03)"
|
||||
: "rgba(16,24,40,0.03)",
|
||||
color: darkMode ? "#f5f7ff" : "#162033",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Space size={8} wrap>
|
||||
<Text strong>{option.label}</Text>
|
||||
{active ? <Tag color="blue">当前</Tag> : null}
|
||||
</Space>
|
||||
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{mongoSrv && useSSH && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="SRV 与 SSH 隧道同时启用时,可能依赖本地 DNS 解析能力"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{mongoTopology === "replica" &&
|
||||
renderConfigSectionCard({
|
||||
sectionKey: "replica",
|
||||
icon: <ClusterOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="mongoHosts"
|
||||
label={mongoSrv ? "附加 SRV 主机(可选)" : "附加节点地址"}
|
||||
help={
|
||||
mongoSrv
|
||||
? "可输入多个候选主机名,格式:host;若留空则仅使用上方主机。"
|
||||
: "可输入多个节点地址,格式:host:port(回车确认)"
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={
|
||||
mongoSrv
|
||||
? "例如:cluster-a.example.com、cluster-b.example.com"
|
||||
: "例如:10.10.0.12:27017、10.10.0.13:27017"
|
||||
}
|
||||
tokenSeparators={[",", ";", " "]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="mongoReplicaSet"
|
||||
label="副本集名称(可选)"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input {...noAutoCapInputProps} placeholder="例如:rs0" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="mongoReplicaUser"
|
||||
label="副本集用户名(可选)"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input {...noAutoCapInputProps} placeholder="留空沿用主用户名" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="mongoReplicaPassword"
|
||||
label="副本集密码(可选)"
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
emptyPlaceholder: "留空沿用主密码",
|
||||
retainedLabel: "已保存副本集密码",
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: "mongoReplicaPassword",
|
||||
clearKey: "mongoReplicaPassword",
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
clearLabel: "清除已保存副本集密码",
|
||||
description:
|
||||
"当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。",
|
||||
})}
|
||||
<Space size={8} style={{ marginTop: 12, marginBottom: 12 }}>
|
||||
<Button
|
||||
onClick={handleDiscoverMongoMembers}
|
||||
loading={discoveringMembers}
|
||||
>
|
||||
自动发现成员
|
||||
</Button>
|
||||
</Space>
|
||||
{mongoMembers.length > 0 && (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(record) => record.host}
|
||||
pagination={false}
|
||||
dataSource={mongoMembers}
|
||||
style={{ marginBottom: 12 }}
|
||||
columns={[
|
||||
{ title: "Host", dataIndex: "host", width: "48%" },
|
||||
{
|
||||
title: "角色",
|
||||
dataIndex: "role",
|
||||
width: "32%",
|
||||
render: (value: string, record: MongoMemberInfo) => (
|
||||
<Tag color={record.isSelf ? "blue" : "default"}>
|
||||
{value || "UNKNOWN"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "健康",
|
||||
dataIndex: "healthy",
|
||||
width: "20%",
|
||||
render: (value: boolean) => (
|
||||
<Tag color={value ? "success" : "error"}>
|
||||
{value ? "正常" : "异常"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "mongoPolicy",
|
||||
icon: <ThunderboltOutlined />,
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="mongoAuthSource"
|
||||
label="认证库 (authSource)"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input {...noAutoCapInputProps} placeholder="默认使用 database 或 admin" />
|
||||
</Form.Item>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<Text strong>读偏好 (readPreference)</Text>
|
||||
{renderChoiceCards({
|
||||
fieldName: "mongoReadPreference",
|
||||
value: String(mongoReadPreference),
|
||||
minWidth: 130,
|
||||
options: [
|
||||
{
|
||||
value: "primary",
|
||||
label: "primary",
|
||||
description: "只读主节点。",
|
||||
},
|
||||
{
|
||||
value: "primaryPreferred",
|
||||
label: "primaryPreferred",
|
||||
description: "主节点优先。",
|
||||
},
|
||||
{
|
||||
value: "secondary",
|
||||
label: "secondary",
|
||||
description: "只读从节点。",
|
||||
},
|
||||
{
|
||||
value: "secondaryPreferred",
|
||||
label: "secondaryPreferred",
|
||||
description: "从节点优先。",
|
||||
},
|
||||
{
|
||||
value: "nearest",
|
||||
label: "nearest",
|
||||
description: "选择最近节点。",
|
||||
},
|
||||
],
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
export default ConnectionModalMongoSections;
|
||||
242
frontend/src/components/ConnectionModalRedisSections.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React from "react";
|
||||
import { Form, Input, Select } from "antd";
|
||||
import {
|
||||
ClusterOutlined,
|
||||
DatabaseOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import type { SavedConnection } from "../types";
|
||||
import {
|
||||
getStoredSecretPlaceholder,
|
||||
type ConnectionConfigSectionKey,
|
||||
} from "../utils/connectionModalPresentation";
|
||||
import { noAutoCapInputProps } from "../utils/inputAutoCap";
|
||||
|
||||
type ChoiceCardOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type RenderChoiceCards = (params: {
|
||||
fieldName: string;
|
||||
value: string;
|
||||
options: ChoiceCardOption[];
|
||||
minWidth?: number;
|
||||
onSelect?: (value: string) => void;
|
||||
}) => React.ReactNode;
|
||||
|
||||
type RenderConfigSectionCard = (params: {
|
||||
sectionKey: ConnectionConfigSectionKey;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
}) => React.ReactNode;
|
||||
|
||||
type RenderStoredSecretControls = (params: {
|
||||
fieldName: string;
|
||||
clearKey: "redisSentinelPassword";
|
||||
hasStoredSecret?: boolean;
|
||||
clearLabel: string;
|
||||
description: string;
|
||||
}) => React.ReactNode;
|
||||
|
||||
interface ConnectionModalRedisSectionsProps {
|
||||
redisTopology: string;
|
||||
redisDbList: number[];
|
||||
initialValues?: SavedConnection | null;
|
||||
primaryPasswordVisible: boolean;
|
||||
setPrimaryPasswordVisible: (visible: boolean) => void;
|
||||
renderChoiceCards: RenderChoiceCards;
|
||||
renderConfigSectionCard: RenderConfigSectionCard;
|
||||
renderStoredSecretControls: RenderStoredSecretControls;
|
||||
createUriAwareRequiredRule: (
|
||||
messageText: string,
|
||||
validateValue?: (value: unknown) => boolean,
|
||||
) => any;
|
||||
}
|
||||
|
||||
const ConnectionModalRedisSections: React.FC<ConnectionModalRedisSectionsProps> = ({
|
||||
redisTopology,
|
||||
redisDbList,
|
||||
initialValues,
|
||||
primaryPasswordVisible,
|
||||
setPrimaryPasswordVisible,
|
||||
renderChoiceCards,
|
||||
renderConfigSectionCard,
|
||||
renderStoredSecretControls,
|
||||
createUriAwareRequiredRule,
|
||||
}) => (
|
||||
<>
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "connectionMode",
|
||||
icon: <ClusterOutlined />,
|
||||
children: (
|
||||
<>
|
||||
{renderChoiceCards({
|
||||
fieldName: "redisTopology",
|
||||
value: String(redisTopology),
|
||||
options: [
|
||||
{
|
||||
value: "single",
|
||||
label: "单机模式",
|
||||
description: "只连接一个 Redis 节点。",
|
||||
},
|
||||
{
|
||||
value: "cluster",
|
||||
label: "集群模式",
|
||||
description: "Redis Cluster,配置多个种子节点。",
|
||||
},
|
||||
{
|
||||
value: "sentinel",
|
||||
label: "哨兵模式",
|
||||
description: "通过 Sentinel 发现主节点,适合主从高可用。",
|
||||
},
|
||||
],
|
||||
})}
|
||||
{(redisTopology === "cluster" || redisTopology === "sentinel") && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="redisHosts"
|
||||
label={
|
||||
redisTopology === "sentinel"
|
||||
? "Sentinel 附加节点地址"
|
||||
: "集群附加节点地址"
|
||||
}
|
||||
help={
|
||||
redisTopology === "sentinel"
|
||||
? "上方主机地址作为第一个 Sentinel;这里填写其他 Sentinel 节点,格式:host:port"
|
||||
: "主节点使用上方主机地址;这里填写其他种子节点,格式:host:port"
|
||||
}
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={
|
||||
redisTopology === "sentinel"
|
||||
? "例如:10.10.0.12:26379、10.10.0.13:26379"
|
||||
: "例如:10.10.0.12:6379、10.10.0.13:6379"
|
||||
}
|
||||
tokenSeparators={[",", ";", " "]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{redisTopology === "sentinel" && (
|
||||
<Form.Item
|
||||
name="redisSentinelMaster"
|
||||
label="Sentinel master 名称"
|
||||
help="填写 Sentinel 配置中的 monitor 名称,例如 mymaster。"
|
||||
rules={[
|
||||
createUriAwareRequiredRule(
|
||||
"请输入 Sentinel master 名称",
|
||||
),
|
||||
]}
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="例如:mymaster"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "credentials",
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="password" label="密码 (可选)">
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
visibilityToggle={{
|
||||
visible: primaryPasswordVisible,
|
||||
onVisibleChange: setPrimaryPasswordVisible,
|
||||
}}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder: "Redis 密码(如果设置了 requirepass)",
|
||||
retainedLabel: "已保存 Redis 密码",
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{redisTopology === "sentinel" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="redisSentinelUser"
|
||||
label="Sentinel 用户名(可选)"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="留空表示 Sentinel 不使用 ACL 用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="redisSentinelPassword"
|
||||
label="Sentinel 密码(可选)"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
|
||||
emptyPlaceholder: "Sentinel 自身认证密码,留空则不发送",
|
||||
retainedLabel: "已保存 Sentinel 密码",
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: "redisSentinelPassword",
|
||||
clearKey: "redisSentinelPassword",
|
||||
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
|
||||
clearLabel: "清除已保存 Sentinel 密码",
|
||||
description:
|
||||
"当前已保存 Sentinel 密码。留空表示继续沿用,输入新值表示替换。",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "databaseScope",
|
||||
icon: <DatabaseOutlined />,
|
||||
children: (
|
||||
<Form.Item
|
||||
name="includeRedisDatabases"
|
||||
label="显示数据库 (留空显示全部)"
|
||||
help="连接测试成功后可选择"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择显示的数据库"
|
||||
allowClear
|
||||
>
|
||||
{redisDbList.map((db) => (
|
||||
<Select.Option key={db} value={db}>
|
||||
db{db}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
export default ConnectionModalRedisSections;
|
||||
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}
|
||||
destroyOnHidden={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>
|
||||
);
|
||||
}
|
||||
1524
frontend/src/components/DataGrid.ddl.test.tsx
Normal file
641
frontend/src/components/DataGrid.layout.test.tsx
Normal file
@@ -0,0 +1,641 @@
|
||||
import React from 'react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGrid, {
|
||||
buildGridFieldSelectOptions,
|
||||
formatCellDisplayText,
|
||||
resolveContextMenuFieldName,
|
||||
resolveDefaultGridFilterOperator,
|
||||
resolveNextGridFilterOperatorForColumnChange,
|
||||
} from './DataGrid';
|
||||
import DataGridPaginationBar from './DataGridPaginationBar';
|
||||
import { cloneShortcutOptions, DEFAULT_SHORTCUT_OPTIONS } from '../utils/shortcuts';
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
connections: [],
|
||||
addSqlLog: vi.fn(),
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableDensity: 'comfortable',
|
||||
uiVersion: 'v2',
|
||||
},
|
||||
queryOptions: {
|
||||
showColumnComment: false,
|
||||
showColumnType: false,
|
||||
},
|
||||
setQueryOptions: vi.fn(),
|
||||
dataEditTransactionOptions: {
|
||||
commitMode: 'manual',
|
||||
autoCommitDelayMs: 5000,
|
||||
},
|
||||
setDataEditTransactionOptions: vi.fn(),
|
||||
addTab: vi.fn(),
|
||||
setActiveContext: 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(),
|
||||
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
|
||||
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(),
|
||||
DBGetForeignKeys: vi.fn(),
|
||||
DBShowCreateTable: 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"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
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"');
|
||||
expect(markup).toContain('data-grid-column-display-action="true"');
|
||||
expect(markup).toContain('data-grid-column-quick-find-action="true"');
|
||||
expect(markup).toContain('字段显示');
|
||||
expect(markup).toContain('跳列');
|
||||
expect(markup).toContain('对象设计');
|
||||
expect(markup).toContain('data-grid-page-find="true"');
|
||||
expect(markup).toContain('data-grid-page-find-prev="true"');
|
||||
expect(markup).toContain('data-grid-page-find-next="true"');
|
||||
expect(markup).toContain('gn-v2-data-grid-status-main');
|
||||
expect(markup).toContain('gn-v2-data-grid-status-right');
|
||||
expect(markup).toContain('data-grid-v2-pagination="true"');
|
||||
expect(markup).toContain('data-grid-v2-page-chip="true"');
|
||||
expect(markup).toContain('data-grid-v2-pagination-prev="true"');
|
||||
expect(markup).toContain('data-grid-v2-pagination-next="true"');
|
||||
expect(markup).toContain('data-grid-pagination-jump="true"');
|
||||
expect(markup).toContain('跳页');
|
||||
expect(markup).toContain('跳转页码');
|
||||
expect(markup).not.toContain('class="ant-pagination');
|
||||
expect(markup).not.toContain('class="data-grid-pagination-kicker"');
|
||||
expect(markup).toContain('当前页查找...');
|
||||
});
|
||||
|
||||
it('keeps the v2 footer fields action labeled as field info for views', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="user_view"
|
||||
objectType="view"
|
||||
readOnly
|
||||
pagination={{
|
||||
current: 1,
|
||||
pageSize: 100,
|
||||
total: 1,
|
||||
}}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('字段信息');
|
||||
expect(markup).not.toContain('对象设计');
|
||||
});
|
||||
|
||||
it('hides current-page find in JSON and text record views', () => {
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain("const visiblePageFindContent = viewMode === 'table' ? pageFindContent : null;");
|
||||
expect(source).toContain('pageFindContent={visiblePageFindContent}');
|
||||
});
|
||||
|
||||
it('keeps legacy secondary actions aligned on a shared search-row baseline', () => {
|
||||
const source = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8');
|
||||
const columnQuickFindSource = readFileSync(new URL('./DataGridColumnQuickFind.tsx', import.meta.url), 'utf8');
|
||||
const pageFindSource = readFileSync(new URL('./DataGridPageFind.tsx', import.meta.url), 'utf8');
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
const paginationSource = readFileSync(new URL('./DataGridPaginationBar.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('data-grid-legacy-secondary-actions="true"');
|
||||
expect(source).toContain('data-grid-legacy-secondary-row="primary"');
|
||||
expect(source).toContain('data-grid-legacy-secondary-row="search"');
|
||||
expect(source).toContain('data-grid-legacy-result-view-switcher="true"');
|
||||
expect(source).toContain('data-grid-legacy-column-quick-find="true"');
|
||||
expect(source).toContain('data-grid-legacy-page-find="true"');
|
||||
expect(source).toContain('data-grid-legacy-pagination="true"');
|
||||
expect(source).toContain("justifyContent: 'flex-start'");
|
||||
expect(source).toContain('minHeight: 32');
|
||||
expect(source).toContain("style={{ display: 'flex', minWidth: 0, marginLeft: 'auto' }}");
|
||||
expect(source).toContain("flex: '0 1 240px'");
|
||||
expect(source).toContain("flex: '0 1 auto'");
|
||||
expect(columnQuickFindSource).not.toContain('定位字段列');
|
||||
expect(columnQuickFindSource).toContain("flexWrap: 'nowrap'");
|
||||
expect(columnQuickFindSource).toContain("width: 168");
|
||||
expect(columnQuickFindSource).toContain('height: 32');
|
||||
expect(columnQuickFindSource).toContain('const legacyDropdownOpen =');
|
||||
expect(columnQuickFindSource).toContain('open={isV2Ui ? undefined : legacyDropdownOpen}');
|
||||
expect(columnQuickFindSource).toContain('onSubmit: (value?: string) => void;');
|
||||
expect(columnQuickFindSource).toContain('onSubmit(nextValue);');
|
||||
expect(columnQuickFindSource).toContain('onPressEnter={() => onSubmit(value)}');
|
||||
expect(columnQuickFindSource).not.toContain('data-grid-column-quick-find-submit=');
|
||||
expect(columnQuickFindSource).not.toContain(" '跳转'");
|
||||
expect(pageFindSource).toContain("gap: 8");
|
||||
expect(pageFindSource).toContain("flexWrap: 'nowrap'");
|
||||
expect(pageFindSource).toContain('height: 32');
|
||||
expect(pageFindSource).not.toContain("flexDirection: 'column'");
|
||||
expect(pageFindSource).not.toContain(" '上一个'");
|
||||
expect(pageFindSource).not.toContain(" '下一个'");
|
||||
expect(pageFindSource).toContain("paddingInline: 8");
|
||||
expect(pageFindSource).toContain("whiteSpace: 'nowrap'");
|
||||
expect(pageFindSource).toContain("onCancel: () => void;");
|
||||
expect(pageFindSource).toContain("if (event.key === 'Escape')");
|
||||
expect(pageFindSource).toContain('onCancel();');
|
||||
expect(pageFindSource).toContain("textAlign: 'left'");
|
||||
expect(dataGridSource).toContain("const normalizedPageFindText = useMemo(() => normalizeDataGridFindQuery(pageFindText), [pageFindText]);");
|
||||
expect(dataGridSource).not.toContain("const normalizedPageFindText = useMemo(() => normalizeDataGridFindQuery(deferredPageFindText), [deferredPageFindText]);");
|
||||
expect(dataGridSource).toContain("if (event.key === 'Escape')");
|
||||
expect(dataGridSource).toContain('if (activeSelection.size === 0) {');
|
||||
expect(dataGridSource).toContain('closeCellEditMode();');
|
||||
expect(dataGridSource).toContain('resetCellSelection();');
|
||||
expect(dataGridSource).toContain("tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable");
|
||||
expect(paginationSource).toContain("padding: 0");
|
||||
expect(paginationSource).toContain("justifyContent: 'flex-start'");
|
||||
});
|
||||
|
||||
it('avoids duplicating legacy pagination page text beside the pager', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGridPaginationBar
|
||||
isV2Ui={false}
|
||||
pagination={{
|
||||
current: 1,
|
||||
pageSize: 100,
|
||||
total: 24,
|
||||
}}
|
||||
paginationV2SummaryText="24 行"
|
||||
paginationSummaryText="当前 24 条 / 共 24 条"
|
||||
paginationControlTotal={24}
|
||||
paginationTotalPages={1}
|
||||
paginationPageSizeOptions={['100', '200']}
|
||||
onPageChange={() => {}}
|
||||
onPageSizeChange={() => {}}
|
||||
onV2PageStep={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('class="ant-pagination');
|
||||
expect(markup).not.toContain('第 1 / 1 页');
|
||||
});
|
||||
|
||||
it('renders the v2 DataGrid toolbar using the redesigned topbar hooks', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
editLocator={{
|
||||
strategy: 'primary-key',
|
||||
columns: ['id'],
|
||||
valueColumns: ['id'],
|
||||
readOnly: false,
|
||||
}}
|
||||
onReload={() => {}}
|
||||
showFilter
|
||||
onToggleFilter={() => {}}
|
||||
pagination={{
|
||||
current: 1,
|
||||
pageSize: 100,
|
||||
total: 1,
|
||||
}}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('gn-v2-data-grid');
|
||||
expect(markup).toContain('gn-v2-data-grid-toolbar-frame');
|
||||
expect(markup).toContain('gn-v2-data-grid-toolbar-title');
|
||||
expect(markup).toContain('gn-v2-toolbar-divider');
|
||||
expect(markup).toContain('gn-v2-commit-button');
|
||||
expect(markup).toContain('gn-v2-ai-insight-button');
|
||||
expect(markup).toContain('gn-v2-smart-filter-panel');
|
||||
expect(markup).toContain('gn-v2-data-grid-table-shell');
|
||||
expect(markup).toContain('gn-v2-data-grid-table-wrap');
|
||||
expect(markup).toContain('· main');
|
||||
expect(markup).toContain('提交事务');
|
||||
expect(markup).toContain('手动提交');
|
||||
expect(markup).toContain('AI 洞察');
|
||||
});
|
||||
|
||||
it('preserves fractional seconds when rendering datetime values', () => {
|
||||
expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456');
|
||||
});
|
||||
|
||||
it('renders bit column hex values as decimal flags', () => {
|
||||
expect(formatCellDisplayText('0x00', 'bit(1)')).toBe('0');
|
||||
expect(formatCellDisplayText('0x01', 'bit(1)')).toBe('1');
|
||||
expect(formatCellDisplayText('0x02', 'bit varying(8)')).toBe('2');
|
||||
expect(formatCellDisplayText('0x01', 'bytea')).toBe('0x01');
|
||||
});
|
||||
|
||||
it('resolves the field name copied from the cell context menu', () => {
|
||||
expect(resolveContextMenuFieldName('created_at', '创建时间')).toBe('created_at');
|
||||
expect(resolveContextMenuFieldName('', 'fallback_name')).toBe('fallback_name');
|
||||
});
|
||||
|
||||
it('uses contains as the default filter operator for string-like columns', () => {
|
||||
expect(resolveDefaultGridFilterOperator('varchar(255)')).toBe('CONTAINS');
|
||||
expect(resolveDefaultGridFilterOperator('character varying(64)')).toBe('CONTAINS');
|
||||
expect(resolveDefaultGridFilterOperator('nvarchar(max)')).toBe('CONTAINS');
|
||||
expect(resolveDefaultGridFilterOperator('Nullable(LowCardinality(String))')).toBe('CONTAINS');
|
||||
expect(resolveDefaultGridFilterOperator('text')).toBe('CONTAINS');
|
||||
|
||||
expect(resolveDefaultGridFilterOperator('int')).toBe('=');
|
||||
expect(resolveDefaultGridFilterOperator('decimal(10,2)')).toBe('=');
|
||||
expect(resolveDefaultGridFilterOperator('datetime')).toBe('=');
|
||||
});
|
||||
|
||||
it('updates only untouched default filter operators when the column changes', () => {
|
||||
expect(resolveNextGridFilterOperatorForColumnChange({
|
||||
currentOperator: '=',
|
||||
previousColumnType: 'int',
|
||||
nextColumnType: 'varchar(64)',
|
||||
})).toBe('CONTAINS');
|
||||
|
||||
expect(resolveNextGridFilterOperatorForColumnChange({
|
||||
currentOperator: 'CONTAINS',
|
||||
previousColumnType: 'varchar(64)',
|
||||
nextColumnType: 'bigint',
|
||||
})).toBe('=');
|
||||
|
||||
expect(resolveNextGridFilterOperatorForColumnChange({
|
||||
currentOperator: 'STARTS_WITH',
|
||||
previousColumnType: 'varchar(64)',
|
||||
nextColumnType: 'bigint',
|
||||
})).toBe('STARTS_WITH');
|
||||
});
|
||||
|
||||
it('keeps full field names in filter field select options', () => {
|
||||
const [option] = buildGridFieldSelectOptions(['mes_manufacture_order_really_long_column_name']);
|
||||
|
||||
expect(option).toEqual({
|
||||
value: 'mes_manufacture_order_really_long_column_name',
|
||||
label: 'mes_manufacture_order_really_long_column_name',
|
||||
title: 'mes_manufacture_order_really_long_column_name',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a DDL action for table data pages only', () => {
|
||||
const tableMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
|
||||
expect(tableMarkup).toContain('查看 DDL');
|
||||
expect(tableMarkup).toContain('对象设计');
|
||||
expect(tableMarkup).not.toContain('data-grid-locate-sidebar-action="true"');
|
||||
|
||||
const schemaTableMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="public.users"
|
||||
dbName=""
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"');
|
||||
expect(schemaTableMarkup).toContain('查看 DDL');
|
||||
expect(schemaTableMarkup).toContain('对象设计');
|
||||
expect(schemaTableMarkup).toContain('data-grid-page-find="true"');
|
||||
|
||||
const queryMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
exportScope="queryResult"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(queryMarkup).not.toContain('data-grid-ddl-action="true"');
|
||||
expect(queryMarkup).toContain('字段信息');
|
||||
expect(queryMarkup).not.toContain('对象设计');
|
||||
});
|
||||
|
||||
it('keeps row copy and paste as context menu actions instead of toolbar buttons', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
pkColumns={['id']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).not.toContain('data-grid-copy-row-action="true"');
|
||||
expect(markup).not.toContain('data-grid-paste-row-action="true"');
|
||||
});
|
||||
|
||||
it('renders a clickable copy action for aggregate query results', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
'COUNT(*)': 12,
|
||||
},
|
||||
]}
|
||||
columnNames={['COUNT(*)']}
|
||||
loading={false}
|
||||
exportScope="queryResult"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-grid-query-copy-action="true"');
|
||||
expect(markup).not.toMatch(/data-grid-query-copy-action="true"[^>]*disabled/);
|
||||
expect(markup).toContain('复制');
|
||||
expect(markup.match(/data-grid-query-copy-action="true"/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps query-result export scopes explicit and repositions v2 context menus after measuring', () => {
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain("type QueryResultExportScope = 'selected' | 'page' | 'all';");
|
||||
expect(source).toContain("title: '导出查询结果'");
|
||||
expect(source).toContain('data-query-result-export-scope="true"');
|
||||
expect(source).toContain('选中导出');
|
||||
expect(source).toContain('当前页导出');
|
||||
expect(source).toContain('全部导出');
|
||||
expect(source).toContain('const queryResultCurrentPageRows = useMemo(() => {');
|
||||
expect(source).toContain('const resolveContextMenuPosition = useCallback((x: number, y: number, estimatedWidth: number, estimatedHeight: number) => {');
|
||||
expect(source).toContain('const rect = element.getBoundingClientRect();');
|
||||
expect(source).toContain('ref={cellContextMenuPortalRef}');
|
||||
});
|
||||
|
||||
it('keeps inline cell editors stretched to the full cell width', () => {
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: \'100%\', minWidth: 0 };');
|
||||
expect(source).toContain('className="data-grid-inline-editor-form-item"');
|
||||
expect(source).toContain('className="data-grid-inline-editor-input"');
|
||||
expect(source).toContain('style={{ width: \'100%\', ...inputCellPadding }}');
|
||||
expect(source).toContain('.${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input-content');
|
||||
expect(source).toContain('.${gridId} .data-grid-inline-editor-input');
|
||||
});
|
||||
|
||||
it('disables browser autocapitalization for inline cell editors', () => {
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
const editorInputCount = source.match(/\{\.\.\.noAutoCapInputProps\}[\s\S]{0,180}className="data-grid-inline-editor-input"/g)?.length || 0;
|
||||
|
||||
expect(source).toContain("import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap';");
|
||||
expect(editorInputCount).toBe(2);
|
||||
});
|
||||
|
||||
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('data-grid-quick-where-input="true"');
|
||||
expect(markup).toContain('WHERE');
|
||||
expect(markup).toContain('输入 WHERE 后面的条件');
|
||||
});
|
||||
|
||||
it('keeps quick WHERE input clipboard editing isolated from grid shortcuts', () => {
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
const toolbarSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8');
|
||||
const filterHookSource = readFileSync(new URL('./useDataGridFilters.tsx', import.meta.url), 'utf8');
|
||||
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
|
||||
|
||||
expect(filterHookSource).toContain('const handleQuickWherePaste = React.useCallback');
|
||||
expect(filterHookSource).toContain("event.clipboardData.getData('text/plain')");
|
||||
expect(filterHookSource).toContain('const currentValue = input.value ?? quickWhereDraft;');
|
||||
expect(filterHookSource).toContain('event.stopPropagation();');
|
||||
expect(toolbarSource).toContain('data-grid-quick-where-input="true"');
|
||||
expect(toolbarSource).toContain('{...noAutoCapInputProps}');
|
||||
expect(toolbarSource).toContain('onCopy={onQuickWhereCopy}');
|
||||
expect(toolbarSource).toContain('onCut={onQuickWhereCut}');
|
||||
expect(toolbarSource).toContain('onPaste={onQuickWherePaste}');
|
||||
expect(source).toContain("['c', 'v', 'x'].includes");
|
||||
expect(css).toContain('[data-grid-quick-where-input="true"]');
|
||||
expect(css).toContain('font-size: var(--gn-font-size, 14px) !important;');
|
||||
expect(css).toContain('user-select: text !important;');
|
||||
});
|
||||
|
||||
it('keeps DataGrid scroll synchronization throttled to animation frames', () => {
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
const secondaryActionsSource = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8');
|
||||
const columnTitleSource = readFileSync(new URL('./DataGridColumnTitle.tsx', import.meta.url), 'utf8');
|
||||
const columnQuickFindSource = readFileSync(new URL('./DataGridColumnQuickFind.tsx', import.meta.url), 'utf8');
|
||||
const paginationBarSource = readFileSync(new URL('./DataGridPaginationBar.tsx', import.meta.url), 'utf8');
|
||||
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('virtualHorizontalElementsRef');
|
||||
expect(source).toContain('const handleSubmitColumnQuickFind = useCallback((submittedValue?: string) => {');
|
||||
expect(source).toContain('const effectiveQuery = String(submittedValue ?? columnQuickFindText);');
|
||||
expect(source).toContain('resolveDataGridColumnQuickFindTarget(displayColumnNames, query)');
|
||||
expect(source).toContain("onCancel={() => setPageFindText('')}");
|
||||
expect(source).toContain('enumerable: true');
|
||||
expect(source).toContain('resolveDataGridColumnQuickFindScrollLeft({');
|
||||
expect(source).toContain('const applied = applyVirtualHorizontalOffset(tableContainer, nextScrollLeft);');
|
||||
expect(source).toContain('syncExternalScrollFromTargets();');
|
||||
expect(source).toContain("const columnQuickFindContent = isTableSurfaceActive ? (");
|
||||
expect(secondaryActionsSource).toContain('data-grid-column-quick-find-action="true"');
|
||||
expect(source).toContain('type VirtualTableScrollReference = TableReference & {');
|
||||
expect(source).toContain('const tableRef = useRef<VirtualTableScrollReference | null>(null);');
|
||||
expect(source).toContain('resolveDataGridHorizontalWheelDelta({');
|
||||
expect(source).toContain('const virtualHorizontalAlignmentRafRef = useRef<number | null>(null);');
|
||||
expect(source).toContain('const scheduleVirtualHorizontalWheel = useCallback');
|
||||
expect(source).toContain('pendingTableHorizontalDeltaRef.current += delta;');
|
||||
expect(source).toContain('tableHorizontalWheelRafRef.current = requestAnimationFrame');
|
||||
expect(source).toContain('const scheduleVirtualHorizontalAlignment = useCallback((preferredLeft?: number) => {');
|
||||
expect(source).toContain('virtualHorizontalElementsRef.current = { tableContainer: null, holderEl: null, innerEl: null, headerEl: null };');
|
||||
expect(source).toContain('applyVirtualHorizontalOffset(tableContainer, nextLeft, { forceInternalScroll: true });');
|
||||
expect(source).toContain('}, [horizontalScrollVisible, scheduleVirtualHorizontalAlignment, tableRenderData, tableScrollX, virtualEditingCell]);');
|
||||
expect(source).toContain('tableInstance.scrollTo({ left: clampedOffset, top: holderEl.scrollTop });');
|
||||
expect(source).toContain('applyVirtualHorizontalOffset(tableContainer, latestExternalScroll.scrollLeft, { forceInternalScroll: true });');
|
||||
expect(source).toContain('if (externalSyncRafRef.current !== null)');
|
||||
expect(source).toContain('externalSyncRafRef.current = requestAnimationFrame');
|
||||
expect(source).toContain('const scheduleSyncExternalScrollFromTargets = useCallback');
|
||||
expect(source).toContain('tableTargetSyncRafRef.current = requestAnimationFrame');
|
||||
expect(source).toContain("boundHorizontalTargets = externalScroll ? [] : pickHorizontalScrollTargets(tableContainer);");
|
||||
expect(source).toContain('const useInlineEditableBodyCell = enableInlineEditableCell && !enableVirtual;');
|
||||
expect(source).toContain('if (useInlineEditableBodyCell) {');
|
||||
expect(source).toContain('}, areEditableCellPropsEqual);');
|
||||
expect(source).toContain('const [virtualEditingCell, setVirtualEditingCell] = useState<VirtualEditingCellState | null>(null);');
|
||||
expect(source).toContain('const openVirtualInlineEditor = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => {');
|
||||
expect(source).toContain('if (isVirtualInlineEditingCell && virtualEditable) {');
|
||||
expect(source).toContain('const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol(\'DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION\');');
|
||||
expect(source).toContain('const attachDataGridVirtualEditRenderVersion = <T extends Item>(');
|
||||
expect(source).toContain('hasDataGridVirtualEditRenderVersionChanged(record, prevRecord)');
|
||||
expect(source).not.toContain('if (enableVirtual && enableInlineEditableCell) {\n return (\n <EditableCell');
|
||||
expect(source).toContain("content-visibility: ${useVirtualHolderPaintHints ? 'auto' : 'visible'};");
|
||||
expect(source).toContain("content-visibility: ${useVirtualEditableVisibilityHints ? 'auto' : 'visible'};");
|
||||
expect(source).toContain("contain-intrinsic-size: ${useVirtualEditableVisibilityHints ? '24px 160px' : 'auto'};");
|
||||
expect(source).toContain("const useVirtualHolderPaintHints = !isMacLike && !isV2Ui;");
|
||||
expect(source).toContain("const useVirtualCellContentContain = false;");
|
||||
expect(source).toContain("const useVirtualEditableVisibilityHints = !isMacLike && !isV2Ui;");
|
||||
expect(source).toContain("contain: ${useVirtualRowCellContain ? 'layout paint style' : 'none'};");
|
||||
expect(source).toContain('const handleSharedCellContextMenu = useCallback');
|
||||
expect(source).toContain('const shouldUsePlainVirtualContent = isV2Ui && !modifiedStyle;');
|
||||
expect(source).toContain('if (shouldUsePlainVirtualContent) {');
|
||||
expect(source).toContain('return originalRenderContent;');
|
||||
expect(source).toContain('if (scrollSnapshotRafRef.current !== null) return;');
|
||||
expect(source).toContain('scrollSnapshotRafRef.current = requestAnimationFrame');
|
||||
expect(source).toContain('didRestoreScrollRef.current = false;');
|
||||
expect(source).toContain('useEffect(() => {');
|
||||
expect(source).toContain('}, [connectionId, dbName, tableName, data]);');
|
||||
expect(source).toContain('const applied = applyVirtualHorizontalOffset(tableContainer, nextLeft);');
|
||||
expect(source).toContain('resolvedLeft = readVirtualHorizontalOffset(tableContainer);');
|
||||
expect(source).toContain('lastReportedScrollRef.current = { top: nextTop, left: resolvedLeft };');
|
||||
expect(source).toContain("const dataGridBackdropFilter = isV2Ui || isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');");
|
||||
expect(source).toContain('rowHoverable={!enableVirtual}');
|
||||
expect(columnTitleSource).toContain("data-grid-column-highlighted={highlighted ? 'true' : undefined}");
|
||||
expect(columnTitleSource).toContain('data-column-name={normalizedName}');
|
||||
expect(columnQuickFindSource).toContain('AutoComplete');
|
||||
expect(columnQuickFindSource).toContain('placeholder="跳到字段列..."');
|
||||
expect(secondaryActionsSource.indexOf('{pageFindContent}')).toBeLessThan(secondaryActionsSource.indexOf('gn-v2-data-grid-status-center'));
|
||||
expect(css).toContain('width: 66px !important;');
|
||||
expect(css).toContain('grid-template-columns: 160px 26px 26px !important;');
|
||||
expect(css).toContain('container-name: gn-v2-data-grid-statusbar;');
|
||||
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-data-grid-statusbar::-webkit-scrollbar');
|
||||
expect(css).toContain('scrollbar-width: thin;');
|
||||
expect(css).toContain('min-width: max-content;');
|
||||
expect(css).toContain('flex: 0 0 auto;');
|
||||
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-data-grid-status-center {');
|
||||
expect(css).not.toContain('.gn-v2-data-grid-status-center > span:last-child {\n display: none;');
|
||||
expect(css).not.toContain('.gn-v2-data-grid-status-center > span:nth-child(2) {\n display: none;');
|
||||
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap::-webkit-scrollbar');
|
||||
expect(css).toContain('@container gn-v2-data-grid-statusbar (max-width: 960px)');
|
||||
expect(css).toContain('@container gn-v2-data-grid-statusbar (max-width: 760px)');
|
||||
expect(css).toContain('.data-grid-pagination-size-select.ant-select-focused .ant-select-selector');
|
||||
expect(css).toContain('overflow-x: auto;');
|
||||
expect(paginationBarSource).toContain("label: `${value}/页`");
|
||||
expect(paginationBarSource).toContain('const maxJumpPage = Math.max(1, paginationTotalPages);');
|
||||
expect(paginationBarSource).toContain('Math.min(maxJumpPage, Math.max(1, Math.trunc(Number(jumpPage))))');
|
||||
expect(paginationBarSource).toContain('onPressEnter={submitJumpPage}');
|
||||
expect(paginationBarSource).toContain('data-grid-pagination-jump="true"');
|
||||
expect(css).toContain('.data-grid-pagination-jump-input.ant-input-number-focused');
|
||||
expect(css).toContain('background: transparent !important;');
|
||||
});
|
||||
|
||||
it('keeps the DataGrid performance harness aligned with legacy and v2 comparison controls', () => {
|
||||
const harnessSource = readFileSync(new URL('../dev/PerfDataGridHarness.tsx', import.meta.url), 'utf8');
|
||||
expect(harnessSource).toContain("options={[");
|
||||
expect(harnessSource).toContain("{ label: '旧版 UI', value: 'legacy' }");
|
||||
expect(harnessSource).toContain("{ label: '新版 UI', value: 'v2' }");
|
||||
expect(harnessSource).toContain("{ value: 'comfortable', label: '标准' }");
|
||||
expect(harnessSource).toContain("{ value: 'standard', label: '紧凑' }");
|
||||
expect(harnessSource).toContain("{ value: 'compact', label: '极紧凑' }");
|
||||
expect(harnessSource).toContain("document.body.setAttribute('data-ui-version', uiVersion);");
|
||||
expect(harnessSource).toContain("if (value === null || value === undefined || value === '') {");
|
||||
expect(harnessSource).toContain("const currentState = useStore.getState();");
|
||||
});
|
||||
});
|
||||
104
frontend/src/components/DataGridColumnInfoPopoverContent.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { Button, Checkbox, Input } from 'antd';
|
||||
|
||||
export interface DataGridColumnInfoPopoverContentProps {
|
||||
darkMode: boolean;
|
||||
showColumnComment: boolean;
|
||||
showColumnType: boolean;
|
||||
columnSearchText: string;
|
||||
allOrderedColumnNames: string[];
|
||||
localHiddenColumns: string[];
|
||||
enableColumnOrderMemory: boolean;
|
||||
enableHiddenColumnMemory: boolean;
|
||||
canResetOrder: boolean;
|
||||
canResetHidden: boolean;
|
||||
onShowColumnCommentChange: (checked: boolean) => void;
|
||||
onShowColumnTypeChange: (checked: boolean) => void;
|
||||
onToggleAllColumnsVisibility: (visible: boolean) => void;
|
||||
onColumnSearchTextChange: (value: string) => void;
|
||||
onToggleColumnVisibility: (columnName: string, visible: boolean) => void;
|
||||
onEnableColumnOrderMemoryChange: (checked: boolean) => void;
|
||||
onEnableHiddenColumnMemoryChange: (checked: boolean) => void;
|
||||
onResetOrder: () => void;
|
||||
onResetHidden: () => void;
|
||||
}
|
||||
|
||||
const DataGridColumnInfoPopoverContent: React.FC<DataGridColumnInfoPopoverContentProps> = ({
|
||||
darkMode,
|
||||
showColumnComment,
|
||||
showColumnType,
|
||||
columnSearchText,
|
||||
allOrderedColumnNames,
|
||||
localHiddenColumns,
|
||||
enableColumnOrderMemory,
|
||||
enableHiddenColumnMemory,
|
||||
canResetOrder,
|
||||
canResetHidden,
|
||||
onShowColumnCommentChange,
|
||||
onShowColumnTypeChange,
|
||||
onToggleAllColumnsVisibility,
|
||||
onColumnSearchTextChange,
|
||||
onToggleColumnVisibility,
|
||||
onEnableColumnOrderMemoryChange,
|
||||
onEnableHiddenColumnMemoryChange,
|
||||
onResetOrder,
|
||||
onResetHidden,
|
||||
}) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 200, maxWidth: 300 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666' }}>显示设置</div>
|
||||
<Checkbox checked={showColumnComment} onChange={(e) => onShowColumnCommentChange(e.target.checked)}>
|
||||
表头显示备注
|
||||
</Checkbox>
|
||||
<Checkbox checked={showColumnType} onChange={(e) => onShowColumnTypeChange(e.target.checked)}>
|
||||
表头显示类型
|
||||
</Checkbox>
|
||||
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>列可见性</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(true)}>全显</a>
|
||||
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(false)}>全隐</a>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="搜索列名..."
|
||||
size="small"
|
||||
value={columnSearchText}
|
||||
onChange={(e) => onColumnSearchTextChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<div className="custom-scrollbar" style={{ maxHeight: 240, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{allOrderedColumnNames
|
||||
.filter((col) => !columnSearchText || col.toLowerCase().includes(columnSearchText.toLowerCase()))
|
||||
.map((col) => (
|
||||
<Checkbox
|
||||
key={col}
|
||||
checked={!localHiddenColumns.includes(col)}
|
||||
onChange={(e) => onToggleColumnVisibility(col, e.target.checked)}
|
||||
style={{ marginLeft: 0 }}
|
||||
>
|
||||
{col}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
|
||||
<Checkbox checked={enableColumnOrderMemory} onChange={(e) => onEnableColumnOrderMemoryChange(e.target.checked)}>
|
||||
记忆自定义列序
|
||||
</Checkbox>
|
||||
<Checkbox checked={enableHiddenColumnMemory} onChange={(e) => onEnableHiddenColumnMemoryChange(e.target.checked)}>
|
||||
记忆隐藏列配置
|
||||
</Checkbox>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetOrder} onClick={onResetOrder}>
|
||||
重置排序
|
||||
</Button>
|
||||
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetHidden} onClick={onResetHidden}>
|
||||
重置隐藏
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DataGridColumnInfoPopoverContent;
|
||||
71
frontend/src/components/DataGridColumnQuickFind.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { AutoComplete, Input, Tooltip } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
export interface DataGridColumnQuickFindProps {
|
||||
isV2Ui: boolean;
|
||||
darkMode: boolean;
|
||||
inputProps?: Record<string, unknown>;
|
||||
value: string;
|
||||
options: Array<{ value: string; label?: React.ReactNode }>;
|
||||
hasTarget: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value?: string) => void;
|
||||
}
|
||||
|
||||
const DataGridColumnQuickFind: React.FC<DataGridColumnQuickFindProps> = ({
|
||||
isV2Ui,
|
||||
inputProps,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const legacyDropdownOpen = !isV2Ui && String(value || '').trim().length > 0 && options.length > 0;
|
||||
|
||||
return (
|
||||
<Tooltip title="输入字段名,回车或点定位按钮即可跳到对应列">
|
||||
<div
|
||||
data-grid-column-quick-find="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-column-quick-find' : undefined}
|
||||
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', minWidth: 0, width: '100%', height: 32 }}
|
||||
>
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-data-grid-column-quick-find-row' : undefined}
|
||||
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, width: '100%', flexWrap: 'nowrap', height: 32 }}
|
||||
>
|
||||
<div className={isV2Ui ? 'gn-v2-data-grid-column-quick-find-field' : undefined} style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', height: 32 }}>
|
||||
<AutoComplete
|
||||
className={isV2Ui ? 'gn-v2-data-grid-column-quick-find-autocomplete' : undefined}
|
||||
options={options}
|
||||
value={value}
|
||||
open={isV2Ui ? undefined : legacyDropdownOpen}
|
||||
onChange={onChange}
|
||||
onSelect={(nextValue) => {
|
||||
onChange(nextValue);
|
||||
onSubmit(nextValue);
|
||||
}}
|
||||
filterOption={false}
|
||||
popupMatchSelectWidth={280}
|
||||
>
|
||||
<Input
|
||||
{...inputProps}
|
||||
allowClear
|
||||
size="small"
|
||||
variant="borderless"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="跳到字段列..."
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onPressEnter={() => onSubmit(value)}
|
||||
style={isV2Ui ? undefined : { width: 168, height: 32 }}
|
||||
/>
|
||||
</AutoComplete>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGridColumnQuickFind;
|
||||
112
frontend/src/components/DataGridColumnTitle.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGridColumnTitle from './DataGridColumnTitle';
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Tooltip: ({ children, title, rootClassName }: { children: React.ReactNode; title?: React.ReactNode; rootClassName?: string }) => (
|
||||
<>
|
||||
<div data-tooltip-root-class={rootClassName}>{title}</div>
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('DataGridColumnTitle', () => {
|
||||
it('marks v2 table headers as single-line when column type and comment rows are hidden', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGridColumnTitle
|
||||
columnName="id"
|
||||
showColumnType={false}
|
||||
showColumnComment={false}
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#999"
|
||||
columnMetaTooltipColor="#fff"
|
||||
darkMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-grid-column-title-single-line="true"');
|
||||
expect(markup).not.toContain('gn-v2-column-title-type');
|
||||
expect(markup).not.toContain('gn-v2-column-title-comment');
|
||||
});
|
||||
|
||||
it('renders column type and comment rows when enabled', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGridColumnTitle
|
||||
columnName="id"
|
||||
columnMeta={{ type: 'bigint', comment: '主键 ID' }}
|
||||
showColumnType
|
||||
showColumnComment
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#999"
|
||||
columnMetaTooltipColor="#fff"
|
||||
darkMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('class="gn-v2-column-title"');
|
||||
expect(markup).toContain('class="gn-v2-column-title-type"');
|
||||
expect(markup).toContain('bigint');
|
||||
expect(markup).toContain('class="gn-v2-column-title-comment"');
|
||||
expect(markup).toContain('主键 ID');
|
||||
expect(markup).toContain('flex-direction:column');
|
||||
expect(markup).toContain('align-items:flex-start');
|
||||
});
|
||||
|
||||
it('keeps column metadata tooltip readable in light theme', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGridColumnTitle
|
||||
columnName="auth_type"
|
||||
columnMeta={{ type: 'tinyint(4)', comment: '认证类型:1企业,2个人' }}
|
||||
showColumnType
|
||||
showColumnComment
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#595959"
|
||||
columnMetaTooltipColor="#262626"
|
||||
darkMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-tooltip-root-class="gn-data-grid-column-meta-tooltip"');
|
||||
expect(markup).toContain('class="gn-data-grid-column-meta-tooltip-content"');
|
||||
expect(markup).toContain('color:var(--gn-fg-1, #fff)');
|
||||
expect(markup).not.toContain('color:#fff');
|
||||
});
|
||||
|
||||
it('keeps the configured warm metadata tooltip color in dark theme', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGridColumnTitle
|
||||
columnName="auth_type"
|
||||
columnMeta={{ type: 'tinyint(4)', comment: '认证类型:1企业,2个人' }}
|
||||
showColumnType
|
||||
showColumnComment
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="rgba(255, 236, 179, 0.98)"
|
||||
columnMetaTooltipColor="rgba(255, 236, 179, 0.98)"
|
||||
darkMode
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('color:rgba(255, 236, 179, 0.98)');
|
||||
});
|
||||
|
||||
it('renders foreign-key jump affordance when reference target exists', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGridColumnTitle
|
||||
columnName="customer_id"
|
||||
foreignKeyTarget={{ refTableName: 'customers', refColumnName: 'id' }}
|
||||
showColumnType={false}
|
||||
showColumnComment={false}
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#999"
|
||||
columnMetaTooltipColor="#fff"
|
||||
darkMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-grid-fk-jump="true"');
|
||||
expect(markup).toContain('data-ref-table-name="customers"');
|
||||
});
|
||||
});
|
||||
181
frontend/src/components/DataGridColumnTitle.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { LinkOutlined } from '@ant-design/icons';
|
||||
|
||||
export interface DataGridColumnTitleProps {
|
||||
columnName: string;
|
||||
columnMeta?: {
|
||||
type?: string;
|
||||
comment?: string;
|
||||
} | null;
|
||||
foreignKeyTarget?: {
|
||||
refTableName?: string;
|
||||
refColumnName?: string;
|
||||
} | null;
|
||||
showColumnType: boolean;
|
||||
showColumnComment: boolean;
|
||||
metaFontSize: number;
|
||||
columnMetaHintColor: string;
|
||||
columnMetaTooltipColor: string;
|
||||
darkMode: boolean;
|
||||
highlighted?: boolean;
|
||||
onOpenForeignKey?: () => void;
|
||||
}
|
||||
|
||||
const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
|
||||
columnName,
|
||||
columnMeta,
|
||||
foreignKeyTarget,
|
||||
showColumnType,
|
||||
showColumnComment,
|
||||
metaFontSize,
|
||||
columnMetaHintColor,
|
||||
columnMetaTooltipColor,
|
||||
darkMode,
|
||||
highlighted = false,
|
||||
onOpenForeignKey,
|
||||
}) => {
|
||||
const normalizedName = String(columnName || '');
|
||||
const columnType = String(columnMeta?.type || '').trim();
|
||||
const columnComment = String(columnMeta?.comment || '').trim();
|
||||
const refTableName = String(foreignKeyTarget?.refTableName || '').trim();
|
||||
const refColumnName = String(foreignKeyTarget?.refColumnName || '').trim();
|
||||
const shouldShowColumnType = showColumnType && columnType.length > 0;
|
||||
const shouldShowColumnComment = showColumnComment && columnComment.length > 0;
|
||||
const isSingleLineColumnTitle = !shouldShowColumnType && !shouldShowColumnComment;
|
||||
|
||||
const hoverLines: string[] = [];
|
||||
if (columnType) hoverLines.push(`类型:${columnType}`);
|
||||
if (columnComment) hoverLines.push(`备注:${columnComment}`);
|
||||
if (refTableName) {
|
||||
const refColumnText = refColumnName ? `.${refColumnName}` : '';
|
||||
hoverLines.push(`外键:${refTableName}${refColumnText}`);
|
||||
}
|
||||
|
||||
const fieldLabel = refTableName ? (
|
||||
<button
|
||||
type="button"
|
||||
data-grid-fk-jump="true"
|
||||
data-column-name={normalizedName}
|
||||
data-ref-table-name={refTableName}
|
||||
title={`跳转到外键表:${refTableName}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpenForeignKey?.();
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
padding: 0,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
{normalizedName}
|
||||
</span>
|
||||
<LinkOutlined style={{ fontSize: metaFontSize + 1, color: columnMetaHintColor, flex: 'none' }} />
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{normalizedName}</span>
|
||||
);
|
||||
|
||||
const titleNode = (
|
||||
<div
|
||||
className={isSingleLineColumnTitle ? 'gn-v2-column-title is-single-line' : 'gn-v2-column-title'}
|
||||
data-grid-column-highlighted={highlighted ? 'true' : undefined}
|
||||
data-column-name={normalizedName}
|
||||
data-grid-column-title-single-line={isSingleLineColumnTitle ? 'true' : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
lineHeight: 1.2,
|
||||
borderRadius: highlighted ? 8 : undefined,
|
||||
background: highlighted ? (darkMode ? 'rgba(250, 173, 20, 0.18)' : 'rgba(250, 173, 20, 0.16)') : undefined,
|
||||
boxShadow: highlighted ? `inset 0 0 0 1px ${darkMode ? 'rgba(250, 173, 20, 0.5)' : 'rgba(250, 173, 20, 0.55)'}` : undefined,
|
||||
padding: highlighted ? '4px 6px' : undefined,
|
||||
transition: 'background 160ms ease, box-shadow 160ms ease',
|
||||
}}
|
||||
>
|
||||
{fieldLabel}
|
||||
{shouldShowColumnType && (
|
||||
<span
|
||||
className="gn-v2-column-title-type"
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontSize: metaFontSize,
|
||||
color: columnMetaHintColor,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{columnType}
|
||||
</span>
|
||||
)}
|
||||
{shouldShowColumnComment && (
|
||||
<span
|
||||
className="gn-v2-column-title-comment"
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontSize: metaFontSize,
|
||||
color: columnMetaHintColor,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{columnComment}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hoverLines.length === 0) {
|
||||
return titleNode;
|
||||
}
|
||||
|
||||
const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={(
|
||||
<pre
|
||||
className="gn-data-grid-column-meta-tooltip-content"
|
||||
style={{
|
||||
maxHeight: 260,
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: tooltipTextColor,
|
||||
}}
|
||||
>
|
||||
{hoverLines.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
rootClassName="gn-data-grid-column-meta-tooltip"
|
||||
styles={{ root: { maxWidth: 640 } }}
|
||||
{...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGridColumnTitle;
|
||||
217
frontend/src/components/DataGridLegacyCellContextMenu.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { CopyOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
|
||||
|
||||
interface CellContextMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
record: Record<string, any> | null;
|
||||
dataIndex: string;
|
||||
}
|
||||
|
||||
interface DataGridLegacyCellContextMenuProps {
|
||||
visible: boolean;
|
||||
darkMode: boolean;
|
||||
bgContextMenu: string;
|
||||
cellContextMenu: CellContextMenuState;
|
||||
canModifyData: boolean;
|
||||
copiedRowsForPasteLength: number;
|
||||
selectedRowKeysLength: number;
|
||||
copiedCellPatchAvailable: boolean;
|
||||
supportsCopyInsert: boolean;
|
||||
onClose: () => void;
|
||||
onCopyFieldName: () => void;
|
||||
onCopyRowData: () => void;
|
||||
onCopyRowForPaste: () => void;
|
||||
onPasteCopiedRowsAsNew: () => void;
|
||||
onSetNull: () => void;
|
||||
onEditRow: () => void;
|
||||
onFillToSelected: () => void;
|
||||
onPasteCopiedColumns: () => void;
|
||||
onCopyInsert: () => void;
|
||||
onCopyUpdate: () => void;
|
||||
onCopyDelete: () => void;
|
||||
onCopyJson: () => void;
|
||||
onCopyCsv: () => void;
|
||||
onCopyMarkdown: () => void;
|
||||
onExportCsv: () => void;
|
||||
onExportXlsx: () => void;
|
||||
onExportJson: () => void;
|
||||
onExportHtml: () => void;
|
||||
}
|
||||
|
||||
const baseItemStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
};
|
||||
|
||||
const separatorStyle = (darkMode: boolean): React.CSSProperties => ({
|
||||
height: 1,
|
||||
background: darkMode ? '#303030' : '#f0f0f0',
|
||||
margin: '4px 0',
|
||||
});
|
||||
|
||||
const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps> = ({
|
||||
visible,
|
||||
darkMode,
|
||||
bgContextMenu,
|
||||
cellContextMenu,
|
||||
canModifyData,
|
||||
copiedRowsForPasteLength,
|
||||
selectedRowKeysLength,
|
||||
copiedCellPatchAvailable,
|
||||
supportsCopyInsert,
|
||||
onClose,
|
||||
onCopyFieldName,
|
||||
onCopyRowData,
|
||||
onCopyRowForPaste,
|
||||
onPasteCopiedRowsAsNew,
|
||||
onSetNull,
|
||||
onEditRow,
|
||||
onFillToSelected,
|
||||
onPasteCopiedColumns,
|
||||
onCopyInsert,
|
||||
onCopyUpdate,
|
||||
onCopyDelete,
|
||||
onCopyJson,
|
||||
onCopyCsv,
|
||||
onCopyMarkdown,
|
||||
onExportCsv,
|
||||
onExportXlsx,
|
||||
onExportJson,
|
||||
onExportHtml,
|
||||
}) => {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hoverBg = darkMode ? '#303030' : '#f5f5f5';
|
||||
const canFillRows = selectedRowKeysLength > 0;
|
||||
const canPasteRows = copiedRowsForPasteLength > 0;
|
||||
|
||||
const makeHoverHandlers = (enabled = true) => ({
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (enabled) e.currentTarget.style.background = hoverBg;
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
},
|
||||
});
|
||||
|
||||
const closeAfter = (callback: () => void) => () => {
|
||||
callback();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-grid-legacy-cell-context-menu="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: cellContextMenu.x,
|
||||
top: cellContextMenu.y,
|
||||
zIndex: 10000,
|
||||
background: bgContextMenu,
|
||||
border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
minWidth: 160,
|
||||
maxHeight: `calc(100vh - ${cellContextMenu.y}px - 8px)`,
|
||||
overflowY: 'auto',
|
||||
color: darkMode ? '#fff' : 'rgba(0, 0, 0, 0.88)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onCopyFieldName}>
|
||||
<CopyOutlined style={{ marginRight: 8 }} />
|
||||
复制字段名称
|
||||
</div>
|
||||
<div style={separatorStyle(darkMode)} />
|
||||
{canModifyData && (
|
||||
<>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onSetNull}>
|
||||
设置为 NULL
|
||||
</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onEditRow}>
|
||||
<EditOutlined style={{ marginRight: 8 }} />
|
||||
编辑本行
|
||||
</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyRowForPaste)}>
|
||||
<CopyOutlined style={{ marginRight: 8 }} />
|
||||
复制本行为新增行
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...baseItemStyle,
|
||||
cursor: canPasteRows ? 'pointer' : 'not-allowed',
|
||||
opacity: canPasteRows ? 1 : 0.5,
|
||||
}}
|
||||
{...makeHoverHandlers(canPasteRows)}
|
||||
onClick={() => {
|
||||
if (canPasteRows) {
|
||||
onPasteCopiedRowsAsNew();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
|
||||
{canPasteRows ? `粘贴为新增行 (${copiedRowsForPasteLength})` : '粘贴为新增行'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...baseItemStyle,
|
||||
cursor: canFillRows ? 'pointer' : 'not-allowed',
|
||||
opacity: canFillRows ? 1 : 0.5,
|
||||
}}
|
||||
{...makeHoverHandlers(canFillRows)}
|
||||
onClick={() => {
|
||||
if (canFillRows) onFillToSelected();
|
||||
}}
|
||||
>
|
||||
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
|
||||
填充到选中行 ({selectedRowKeysLength})
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...baseItemStyle,
|
||||
cursor: copiedCellPatchAvailable ? 'pointer' : 'not-allowed',
|
||||
opacity: copiedCellPatchAvailable ? 1 : 0.5,
|
||||
}}
|
||||
{...makeHoverHandlers(copiedCellPatchAvailable)}
|
||||
onClick={() => {
|
||||
if (copiedCellPatchAvailable) onPasteCopiedColumns();
|
||||
}}
|
||||
>
|
||||
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
|
||||
粘贴已复制列(同名列)
|
||||
</div>
|
||||
<div style={separatorStyle(darkMode)} />
|
||||
</>
|
||||
)}
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyRowData)}>
|
||||
<CopyOutlined style={{ marginRight: 8 }} />
|
||||
复制行数据
|
||||
</div>
|
||||
{supportsCopyInsert && (
|
||||
<>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyInsert)}>复制为 INSERT</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyUpdate)}>复制为 UPDATE</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyDelete)}>复制为 DELETE</div>
|
||||
</>
|
||||
)}
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyJson)}>复制为 JSON</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyCsv)}>复制为 CSV</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyMarkdown)}>复制为 Markdown</div>
|
||||
<div style={separatorStyle(darkMode)} />
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportCsv)}>导出为 CSV</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportXlsx)}>导出为 Excel</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportJson)}>导出为 JSON</div>
|
||||
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportHtml)}>导出为 HTML</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGridLegacyCellContextMenu;
|
||||
313
frontend/src/components/DataGridModals.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React from 'react';
|
||||
import { Button, Checkbox, DatePicker, Form, Input, Modal, TimePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import Editor from './MonacoEditor';
|
||||
import {
|
||||
TEMPORAL_FORMATS,
|
||||
getTemporalPickerType,
|
||||
type TemporalPickerType,
|
||||
} from './dataGridTemporal';
|
||||
|
||||
type ColumnMeta = {
|
||||
type: string;
|
||||
comment: string;
|
||||
};
|
||||
|
||||
export interface DataGridRowEditorField {
|
||||
columnName: string;
|
||||
sample: string;
|
||||
placeholder?: string;
|
||||
isJson: boolean;
|
||||
useTextArea: boolean;
|
||||
pickerType?: TemporalPickerType;
|
||||
isTemporalValue: boolean;
|
||||
isWritable: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridModalsProps {
|
||||
tableName?: string;
|
||||
darkMode: boolean;
|
||||
displayColumnNames: string[];
|
||||
rowEditorOpen: boolean;
|
||||
rowEditorRowKey: string;
|
||||
rowEditorForm: any;
|
||||
rowEditorFields: DataGridRowEditorField[];
|
||||
onCloseRowEditor: () => void;
|
||||
onApplyRowEditor: () => void;
|
||||
onOpenRowEditorFieldEditor: (columnName: string) => void;
|
||||
cellEditorOpen: boolean;
|
||||
cellEditorMeta: { record: Record<string, unknown>; dataIndex: string; title: string } | null;
|
||||
cellEditorIsJson: boolean;
|
||||
cellEditorValue: string;
|
||||
onCloseCellEditor: () => void;
|
||||
onFormatJsonInEditor: () => void;
|
||||
onSaveCellEditor: () => void;
|
||||
onCellEditorValueChange: (value: string) => void;
|
||||
batchEditModalOpen: boolean;
|
||||
selectedCellsSize: number;
|
||||
batchEditSetNull: boolean;
|
||||
batchEditValue: string;
|
||||
onCloseBatchEditModal: () => void;
|
||||
onApplyBatchFill: () => void;
|
||||
onBatchEditSetNullChange: (checked: boolean) => void;
|
||||
onBatchEditValueChange: (value: string) => void;
|
||||
jsonEditorOpen: boolean;
|
||||
jsonEditorValue: string;
|
||||
onCloseJsonEditor: () => void;
|
||||
onFormatJsonEditor: () => void;
|
||||
onApplyJsonEditor: () => void;
|
||||
onJsonEditorValueChange: (value: string) => void;
|
||||
ddlModalOpen: boolean;
|
||||
ddlLoading: boolean;
|
||||
ddlText: string;
|
||||
onCloseDdlModal: () => void;
|
||||
onCopyDdl: () => void;
|
||||
}
|
||||
|
||||
const DataGridModals: React.FC<DataGridModalsProps> = ({
|
||||
tableName,
|
||||
darkMode,
|
||||
rowEditorOpen,
|
||||
rowEditorRowKey,
|
||||
rowEditorForm,
|
||||
rowEditorFields,
|
||||
onCloseRowEditor,
|
||||
onApplyRowEditor,
|
||||
onOpenRowEditorFieldEditor,
|
||||
cellEditorOpen,
|
||||
cellEditorMeta,
|
||||
cellEditorIsJson,
|
||||
cellEditorValue,
|
||||
onCloseCellEditor,
|
||||
onFormatJsonInEditor,
|
||||
onSaveCellEditor,
|
||||
onCellEditorValueChange,
|
||||
batchEditModalOpen,
|
||||
selectedCellsSize,
|
||||
batchEditSetNull,
|
||||
batchEditValue,
|
||||
onCloseBatchEditModal,
|
||||
onApplyBatchFill,
|
||||
onBatchEditSetNullChange,
|
||||
onBatchEditValueChange,
|
||||
jsonEditorOpen,
|
||||
jsonEditorValue,
|
||||
onCloseJsonEditor,
|
||||
onFormatJsonEditor,
|
||||
onApplyJsonEditor,
|
||||
onJsonEditorValueChange,
|
||||
ddlModalOpen,
|
||||
ddlLoading,
|
||||
ddlText,
|
||||
onCloseDdlModal,
|
||||
onCopyDdl,
|
||||
}) => (
|
||||
<>
|
||||
<Modal
|
||||
title="编辑行"
|
||||
open={rowEditorOpen}
|
||||
onCancel={onCloseRowEditor}
|
||||
width={980}
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCloseRowEditor}>取消</Button>,
|
||||
<Button key="ok" type="primary" onClick={onApplyRowEditor}>应用</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 8, color: '#888', fontSize: 12, display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span>{tableName ? `${tableName}` : ''}</span>
|
||||
<span>{rowEditorRowKey ? `rowKey: ${rowEditorRowKey}` : ''}</span>
|
||||
</div>
|
||||
<Form form={rowEditorForm} layout="vertical">
|
||||
<div className="custom-scrollbar" style={{ maxHeight: '62vh', overflow: 'auto', paddingRight: 8 }}>
|
||||
{rowEditorFields.map((field) => (
|
||||
<Form.Item key={field.columnName} label={field.columnName} style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
||||
<Form.Item name={field.columnName} noStyle>
|
||||
{field.isTemporalValue && field.pickerType ? (
|
||||
field.pickerType === 'time' ? (
|
||||
<TimePicker
|
||||
style={{ flex: 1, width: '100%' }}
|
||||
format={TEMPORAL_FORMATS[field.pickerType]}
|
||||
placeholder={field.placeholder}
|
||||
needConfirm={false}
|
||||
disabled={!field.isWritable}
|
||||
/>
|
||||
) : field.pickerType === 'datetime' ? (
|
||||
<DatePicker
|
||||
style={{ flex: 1, width: '100%' }}
|
||||
showTime
|
||||
format={TEMPORAL_FORMATS[field.pickerType]}
|
||||
placeholder={field.placeholder}
|
||||
needConfirm
|
||||
disabled={!field.isWritable}
|
||||
/>
|
||||
) : (
|
||||
<DatePicker
|
||||
style={{ flex: 1, width: '100%' }}
|
||||
format={TEMPORAL_FORMATS[field.pickerType]}
|
||||
picker={field.pickerType as any}
|
||||
placeholder={field.placeholder}
|
||||
needConfirm={false}
|
||||
disabled={!field.isWritable}
|
||||
/>
|
||||
)
|
||||
) : field.useTextArea ? (
|
||||
<Input.TextArea
|
||||
style={{ flex: 1 }}
|
||||
autoSize={{ minRows: field.isJson ? 4 : 1, maxRows: 10 }}
|
||||
placeholder={field.placeholder}
|
||||
disabled={!field.isWritable}
|
||||
/>
|
||||
) : (
|
||||
<Input style={{ flex: 1 }} placeholder={field.placeholder} disabled={!field.isWritable} />
|
||||
)}
|
||||
</Form.Item>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => onOpenRowEditorFieldEditor(field.columnName)}
|
||||
title="弹窗编辑"
|
||||
disabled={!field.isWritable}
|
||||
>
|
||||
...
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={cellEditorMeta ? `编辑单元格:${cellEditorMeta.title}` : '编辑单元格'}
|
||||
open={cellEditorOpen}
|
||||
onCancel={onCloseCellEditor}
|
||||
destroyOnHidden
|
||||
width={960}
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="format" onClick={onFormatJsonInEditor} disabled={!cellEditorIsJson}>格式化 JSON</Button>,
|
||||
<Button key="cancel" onClick={onCloseCellEditor}>取消</Button>,
|
||||
<Button key="ok" type="primary" onClick={onSaveCellEditor}>保存</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
|
||||
{cellEditorMeta ? `${tableName || ''}${tableName ? '.' : ''}${cellEditorMeta.dataIndex}` : ''}
|
||||
</div>
|
||||
{cellEditorOpen && (
|
||||
<Editor
|
||||
height="56vh"
|
||||
language={cellEditorIsJson ? 'json' : 'plaintext'}
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={cellEditorValue}
|
||||
onChange={(value) => onCellEditorValueChange(value || '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
fontSize: 14,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`批量填充 (${selectedCellsSize} 个单元格)`}
|
||||
open={batchEditModalOpen}
|
||||
onCancel={onCloseBatchEditModal}
|
||||
onOk={onApplyBatchFill}
|
||||
width={500}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Checkbox checked={batchEditSetNull} onChange={(event) => onBatchEditSetNullChange(event.target.checked)}>
|
||||
设置为 NULL
|
||||
</Checkbox>
|
||||
</div>
|
||||
{!batchEditSetNull && (
|
||||
<Input.TextArea
|
||||
value={batchEditValue}
|
||||
onChange={(event) => onBatchEditValueChange(event.target.value)}
|
||||
placeholder="输入要填充的值"
|
||||
autoSize={{ minRows: 3, maxRows: 10 }}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑 JSON 结果集"
|
||||
open={jsonEditorOpen}
|
||||
onCancel={onCloseJsonEditor}
|
||||
destroyOnHidden
|
||||
width={980}
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="format" onClick={onFormatJsonEditor}>格式化 JSON</Button>,
|
||||
<Button key="cancel" onClick={onCloseJsonEditor}>取消</Button>,
|
||||
<Button key="ok" type="primary" onClick={onApplyJsonEditor}>应用修改</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
|
||||
说明:此处按当前结果集顺序编辑,不支持在 JSON 模式增删记录(可在表格模式操作)。
|
||||
</div>
|
||||
{jsonEditorOpen && (
|
||||
<Editor
|
||||
height="56vh"
|
||||
language="json"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={jsonEditorValue}
|
||||
onChange={(value) => onJsonEditorValueChange(value || '')}
|
||||
options={{
|
||||
readOnly: false,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'off',
|
||||
fontSize: 12,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={tableName ? `DDL - ${tableName}` : 'DDL'}
|
||||
open={ddlModalOpen}
|
||||
onCancel={onCloseDdlModal}
|
||||
destroyOnHidden
|
||||
width={960}
|
||||
footer={[
|
||||
<Button key="copy" icon={<CopyOutlined />} onClick={onCopyDdl} disabled={!ddlText.trim()}>
|
||||
复制 DDL
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={onCloseDdlModal}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{ddlModalOpen && (
|
||||
<Editor
|
||||
height="56vh"
|
||||
language="sql"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={ddlLoading ? '正在加载 DDL...' : ddlText}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'off',
|
||||
fontSize: 12,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
export default DataGridModals;
|
||||
100
frontend/src/components/DataGridPageFind.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { Button, Input, Tooltip } from 'antd';
|
||||
import { LeftOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
export interface DataGridPageFindProps {
|
||||
isV2Ui: boolean;
|
||||
darkMode: boolean;
|
||||
inputProps?: Record<string, unknown>;
|
||||
pageFindText: string;
|
||||
normalizedPageFindText: string;
|
||||
hasMatches: boolean;
|
||||
activePageFindPosition: number;
|
||||
matchCount: number;
|
||||
occurrenceCount: number;
|
||||
matchedCellCount: number;
|
||||
onPageFindTextChange: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
onNavigatePrevious: () => void;
|
||||
onNavigateNext: () => void;
|
||||
}
|
||||
|
||||
const DataGridPageFind: React.FC<DataGridPageFindProps> = ({
|
||||
isV2Ui,
|
||||
darkMode,
|
||||
inputProps,
|
||||
pageFindText,
|
||||
normalizedPageFindText,
|
||||
hasMatches,
|
||||
activePageFindPosition,
|
||||
matchCount,
|
||||
occurrenceCount,
|
||||
matchedCellCount,
|
||||
onPageFindTextChange,
|
||||
onCancel,
|
||||
onNavigatePrevious,
|
||||
onNavigateNext,
|
||||
}) => (
|
||||
<Tooltip title="仅查找当前页已加载数据,不改变 WHERE 条件">
|
||||
<div
|
||||
data-grid-page-find="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find' : undefined}
|
||||
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'nowrap', height: 32 }}
|
||||
>
|
||||
<Input
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find-input' : undefined}
|
||||
{...inputProps}
|
||||
allowClear
|
||||
size="small"
|
||||
variant="borderless"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="当前页查找..."
|
||||
value={pageFindText}
|
||||
onChange={(event) => onPageFindTextChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
style={isV2Ui ? undefined : { width: 168, height: 32 }}
|
||||
/>
|
||||
<Button
|
||||
data-grid-page-find-prev="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find-prev' : undefined}
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={!hasMatches}
|
||||
onClick={onNavigatePrevious}
|
||||
style={isV2Ui ? undefined : { height: 32, minWidth: 32, paddingInline: 8 }}
|
||||
/>
|
||||
<Button
|
||||
data-grid-page-find-next="true"
|
||||
className={isV2Ui ? 'gn-v2-data-grid-page-find-next' : undefined}
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
disabled={!hasMatches}
|
||||
onClick={onNavigateNext}
|
||||
style={isV2Ui ? undefined : { height: 32, minWidth: 32, paddingInline: 8 }}
|
||||
/>
|
||||
{normalizedPageFindText && (
|
||||
<span
|
||||
aria-live="polite"
|
||||
style={isV2Ui ? undefined : {
|
||||
fontSize: 12,
|
||||
color: darkMode ? '#999' : '#666',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'left',
|
||||
flex: '0 1 auto',
|
||||
}}
|
||||
>
|
||||
{hasMatches ? `${activePageFindPosition} / ${matchCount} · ` : ''}匹配 {occurrenceCount} 处 / {matchedCellCount} 个单元格
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
export default DataGridPageFind;
|
||||
168
frontend/src/components/DataGridPaginationBar.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React from 'react';
|
||||
import { Button, InputNumber, Pagination, Select } from 'antd';
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
|
||||
interface DataGridPaginationState {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalKnown?: boolean;
|
||||
totalApprox?: boolean;
|
||||
approximateTotal?: number;
|
||||
totalCountLoading?: boolean;
|
||||
totalCountCancelled?: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridPaginationBarProps {
|
||||
isV2Ui: boolean;
|
||||
pagination?: DataGridPaginationState;
|
||||
paginationV2SummaryText: string;
|
||||
paginationSummaryText: string;
|
||||
paginationControlTotal: number;
|
||||
paginationTotalPages: number;
|
||||
paginationPageSizeOptions: string[];
|
||||
onPageChange?: (page: number, size: number) => void;
|
||||
onPageSizeChange: (value: string) => void;
|
||||
onV2PageStep: (direction: 'previous' | 'next') => void;
|
||||
}
|
||||
|
||||
const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
||||
isV2Ui,
|
||||
pagination,
|
||||
paginationV2SummaryText,
|
||||
paginationSummaryText,
|
||||
paginationControlTotal,
|
||||
paginationTotalPages,
|
||||
paginationPageSizeOptions,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onV2PageStep,
|
||||
}) => {
|
||||
const [jumpPage, setJumpPage] = React.useState<number | null>(pagination?.current ?? null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setJumpPage(pagination?.current ?? null);
|
||||
}, [pagination?.current]);
|
||||
|
||||
if (!pagination) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxJumpPage = Math.max(1, paginationTotalPages);
|
||||
const normalizedJumpPage = Number.isFinite(Number(jumpPage)) && Number(jumpPage) > 0
|
||||
? Math.min(maxJumpPage, Math.max(1, Math.trunc(Number(jumpPage))))
|
||||
: null;
|
||||
const jumpDisabled = !onPageChange || normalizedJumpPage === null || normalizedJumpPage === pagination.current;
|
||||
const submitJumpPage = () => {
|
||||
if (!onPageChange || normalizedJumpPage === null) return;
|
||||
if (normalizedJumpPage === pagination.current) return;
|
||||
onPageChange(normalizedJumpPage, pagination.pageSize);
|
||||
};
|
||||
const jumpPageControl = (
|
||||
<div className="data-grid-pagination-jump" data-grid-pagination-jump="true">
|
||||
<span className="data-grid-pagination-jump-label">跳页</span>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={maxJumpPage}
|
||||
precision={0}
|
||||
controls={false}
|
||||
value={jumpPage}
|
||||
onChange={(value) => setJumpPage(typeof value === 'number' && Number.isFinite(value) ? value : null)}
|
||||
onPressEnter={submitJumpPage}
|
||||
className="data-grid-pagination-jump-input"
|
||||
aria-label="跳转页码"
|
||||
disabled={!onPageChange}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
className="data-grid-pagination-jump-button"
|
||||
disabled={jumpDisabled}
|
||||
onClick={submitJumpPage}
|
||||
>
|
||||
跳
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${isV2Ui ? 'gn-v2-data-grid-pagination-wrap ' : ''}data-grid-pagination-wrap`}
|
||||
style={isV2Ui ? undefined : { padding: 0, borderTop: 'none', display: 'flex', justifyContent: 'flex-start' }}
|
||||
>
|
||||
{isV2Ui ? (
|
||||
<div className="data-grid-pagination-shell" data-grid-v2-pagination="true">
|
||||
<div className="data-grid-pagination-summary" aria-live="polite">
|
||||
<span className="data-grid-pagination-summary-value">{paginationV2SummaryText}</span>
|
||||
</div>
|
||||
<Button
|
||||
data-grid-v2-pagination-prev="true"
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={!onPageChange || pagination.current <= 1}
|
||||
onClick={() => onV2PageStep('previous')}
|
||||
/>
|
||||
<div className="data-grid-pagination-page-chip" data-grid-v2-page-chip="true">
|
||||
<strong>{pagination.current}</strong>
|
||||
<span>/</span>
|
||||
<span>{paginationTotalPages}</span>
|
||||
</div>
|
||||
<Button
|
||||
data-grid-v2-pagination-next="true"
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
disabled={!onPageChange || pagination.current >= paginationTotalPages}
|
||||
onClick={() => onV2PageStep('next')}
|
||||
/>
|
||||
{jumpPageControl}
|
||||
<Select
|
||||
size="small"
|
||||
popupMatchSelectWidth={false}
|
||||
value={String(pagination.pageSize)}
|
||||
onChange={onPageSizeChange}
|
||||
options={paginationPageSizeOptions.map((value) => ({ value, label: `${value}/页` }))}
|
||||
className="data-grid-pagination-size-select"
|
||||
aria-label="每页条数"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="data-grid-pagination-shell">
|
||||
<div className="data-grid-pagination-summary" aria-live="polite">
|
||||
<span className="data-grid-pagination-kicker">结果集</span>
|
||||
<span className="data-grid-pagination-summary-value">{paginationSummaryText}</span>
|
||||
</div>
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={paginationControlTotal}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
showTitle={false}
|
||||
size="small"
|
||||
itemRender={(_page, type, originalElement) => {
|
||||
if (type === 'prev') {
|
||||
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><LeftOutlined /></span>;
|
||||
}
|
||||
if (type === 'next') {
|
||||
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><RightOutlined /></span>;
|
||||
}
|
||||
return originalElement;
|
||||
}}
|
||||
/>
|
||||
{jumpPageControl}
|
||||
<Select
|
||||
size="small"
|
||||
popupMatchSelectWidth={false}
|
||||
value={String(pagination.pageSize)}
|
||||
onChange={onPageSizeChange}
|
||||
options={paginationPageSizeOptions.map((value) => ({ value, label: `${value} 条 / 页` }))}
|
||||
className="data-grid-pagination-size-select"
|
||||
aria-label="每页条数"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGridPaginationBar;
|
||||
131
frontend/src/components/DataGridPreviewPanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import Editor from './MonacoEditor';
|
||||
|
||||
type ColumnMeta = {
|
||||
type?: string;
|
||||
};
|
||||
|
||||
interface DataGridPreviewPanelProps {
|
||||
visible: boolean;
|
||||
isTableSurfaceActive: boolean;
|
||||
darkMode: boolean;
|
||||
focusedCellInfo: { dataIndex: string } | null;
|
||||
dataPanelIsJson: boolean;
|
||||
focusedCellWritable: boolean;
|
||||
dataPanelValue: string;
|
||||
columnMetaMap: Record<string, ColumnMeta>;
|
||||
columnMetaMapByLowerName: Record<string, ColumnMeta>;
|
||||
onFormatJson: () => void;
|
||||
onSave: () => void;
|
||||
onValueChange: (value: string) => void;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
isDirtyComparedToOriginal: (value: string) => boolean;
|
||||
}
|
||||
|
||||
const DataGridPreviewPanel: React.FC<DataGridPreviewPanelProps> = ({
|
||||
visible,
|
||||
isTableSurfaceActive,
|
||||
darkMode,
|
||||
focusedCellInfo,
|
||||
dataPanelIsJson,
|
||||
focusedCellWritable,
|
||||
dataPanelValue,
|
||||
columnMetaMap,
|
||||
columnMetaMapByLowerName,
|
||||
onFormatJson,
|
||||
onSave,
|
||||
onValueChange,
|
||||
onDirtyChange,
|
||||
isDirtyComparedToOriginal,
|
||||
}) => {
|
||||
if (!visible || !isTableSurfaceActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta = focusedCellInfo
|
||||
? (columnMetaMap[focusedCellInfo.dataIndex] || columnMetaMapByLowerName[focusedCellInfo.dataIndex.toLowerCase()])
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-grid-preview-panel="true"
|
||||
style={{
|
||||
height: 200,
|
||||
borderTop: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.12)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(255,255,255,0.6)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: darkMode ? '#aaa' : '#666', fontWeight: 500 }}>
|
||||
{focusedCellInfo ? focusedCellInfo.dataIndex : '点击单元格查看数据'}
|
||||
</span>
|
||||
{meta?.type ? <span style={{ color: '#888', fontSize: 11 }}>({meta.type})</span> : null}
|
||||
<div style={{ flex: 1 }} />
|
||||
{dataPanelIsJson && (
|
||||
<Button size="small" onClick={onFormatJson}>格式化 JSON</Button>
|
||||
)}
|
||||
{focusedCellWritable && (
|
||||
<Button size="small" type="primary" onClick={onSave}>保存</Button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{focusedCellInfo ? (
|
||||
<Editor
|
||||
height="100%"
|
||||
gonaviTypography="data"
|
||||
language={dataPanelIsJson ? 'json' : 'plaintext'}
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={dataPanelValue}
|
||||
onChange={(val) => {
|
||||
const newVal = val || '';
|
||||
onValueChange(newVal);
|
||||
onDirtyChange(isDirtyComparedToOriginal(newVal));
|
||||
}}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
readOnly: !focusedCellWritable,
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 4,
|
||||
padding: { top: 6, bottom: 6 },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#999',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
点击表格中的单元格以预览完整数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGridPreviewPanel;
|
||||